Backend already returns per-status counts via byStatus; render a real progress bar (X% / N de M / en cola) while PENDING_OCR pages remain, using the existing progress-track CSS. Falls back to indeterminate when no docs have been reported yet.
526 lines
16 KiB
TypeScript
526 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import { AppShell } from "@/components/AppShell";
|
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
|
import {
|
|
confirmStatementBatch,
|
|
getStatementBatch,
|
|
listStatementDocuments,
|
|
rejectStatementDocument,
|
|
reviewStatementDocument,
|
|
statementPageUrl,
|
|
} from "@/lib/api";
|
|
import { useCan } from "@/lib/abilities";
|
|
import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels";
|
|
import type {
|
|
ConfirmBatchInput,
|
|
StatementBatchDetail,
|
|
StatementDocument,
|
|
StatementDocumentStatus,
|
|
} from "@/lib/types";
|
|
|
|
/**
|
|
* Review queue for one batch of scanned bills.
|
|
*
|
|
* The reviewer's job is to answer one question per page — "is this the right
|
|
* customer for this amount?" — so the page image sits next to the extracted
|
|
* fields and every row can be corrected in place. Rows the matcher is sure
|
|
* about are pre-approved and can be posted in bulk; everything else is listed
|
|
* first, because that is the work.
|
|
*/
|
|
|
|
const STATUS_LABEL: Record<StatementDocumentStatus, string> = {
|
|
PENDING_OCR: "En proceso",
|
|
OCR_FAILED: "No se pudo leer",
|
|
NEEDS_REVIEW: "Requiere revisión",
|
|
MATCHED: "Identificado",
|
|
CONFIRMED: "Confirmado",
|
|
POSTED: "Registrado",
|
|
REJECTED: "Descartado",
|
|
};
|
|
|
|
/** Rows still needing a decision, listed before the settled ones. */
|
|
const OPEN_FIRST: StatementDocumentStatus[] = [
|
|
"NEEDS_REVIEW",
|
|
"OCR_FAILED",
|
|
"MATCHED",
|
|
"CONFIRMED",
|
|
"POSTED",
|
|
"REJECTED",
|
|
"PENDING_OCR",
|
|
];
|
|
|
|
export default function RecibosBatchPage({ params }: { params: { id: string } }) {
|
|
return (
|
|
<AppShell>
|
|
<BatchReview id={params.id} />
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
function BatchReview({ id }: { id: string }) {
|
|
const canReview = useCan("statement:review");
|
|
const [batch, setBatch] = useState<StatementBatchDetail | null>(null);
|
|
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const [b, d] = await Promise.all([
|
|
getStatementBatch(id),
|
|
listStatementDocuments(id),
|
|
]);
|
|
setBatch(b);
|
|
setDocs(d);
|
|
setError(null);
|
|
} catch (e) {
|
|
setError((e as Error)?.message ?? "No se pudo cargar el lote.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const processing = batch?.status === "PROCESSING" || batch?.status === "UPLOADED";
|
|
useEffect(() => {
|
|
if (!processing) return;
|
|
const t = setInterval(() => void load(), 4000);
|
|
return () => clearInterval(t);
|
|
}, [processing, load]);
|
|
|
|
const sorted = useMemo(
|
|
() =>
|
|
[...docs].sort(
|
|
(a, b) =>
|
|
OPEN_FIRST.indexOf(a.status) - OPEN_FIRST.indexOf(b.status) ||
|
|
a.pageNumber - b.pageNumber,
|
|
),
|
|
[docs],
|
|
);
|
|
|
|
const readyCount = docs.filter(
|
|
(d) => d.status === "MATCHED" && d.matchedCustomer,
|
|
).length;
|
|
|
|
if (loading) return <div className="state-box">Cargando…</div>;
|
|
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
|
|
|
|
return (
|
|
<div className="stack">
|
|
<header className="page-head">
|
|
<div>
|
|
<h1 className="page-title">
|
|
Recibos — {serviceKindLabel(batch.serviceKind)}
|
|
{batch.label ? ` · ${batch.label}` : ""}
|
|
</h1>
|
|
<p className="page-sub">
|
|
{formatDate(batch.createdAt)} · {docs.length} página(s) ·{" "}
|
|
{STATUS_LABEL_BATCH[batch.status] ?? batch.status}
|
|
</p>
|
|
</div>
|
|
<Link className="btn btn-ghost" href="/recibos">
|
|
Volver a captura
|
|
</Link>
|
|
</header>
|
|
|
|
{error && <div className="state-box state-error">{error}</div>}
|
|
|
|
{processing && (
|
|
<ProcessingBanner docsLength={docs.length} pendingOcr={batch.byStatus.PENDING_OCR ?? 0} />
|
|
)}
|
|
|
|
<SummaryCard batch={batch} readyCount={readyCount} />
|
|
|
|
{canReview && readyCount > 0 && (
|
|
<ConfirmCard
|
|
batchId={id}
|
|
readyCount={readyCount}
|
|
onDone={load}
|
|
setError={setError}
|
|
/>
|
|
)}
|
|
|
|
<section className="stack">
|
|
{sorted.map((doc) => (
|
|
<DocumentRow
|
|
key={doc.id}
|
|
doc={doc}
|
|
canReview={canReview}
|
|
onChange={load}
|
|
/>
|
|
))}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const STATUS_LABEL_BATCH: Record<string, string> = {
|
|
UPLOADED: "Recibido",
|
|
PROCESSING: "Procesando",
|
|
READY_FOR_REVIEW: "Listo para revisar",
|
|
COMPLETED: "Registrado",
|
|
FAILED: "Falló",
|
|
};
|
|
|
|
/**
|
|
* Live readout while OCR is running. The backend tells us how many pages are
|
|
* still PENDING_OCR, so we can show real progress instead of "loading…". When
|
|
* the docs list hasn't caught up to the upload yet (total === 0) we fall back
|
|
* to the indeterminate bar.
|
|
*/
|
|
function ProcessingBanner({
|
|
docsLength,
|
|
pendingOcr,
|
|
}: {
|
|
docsLength: number;
|
|
pendingOcr: number;
|
|
}) {
|
|
const done = Math.max(docsLength - pendingOcr, 0);
|
|
const pct =
|
|
docsLength > 0 ? Math.min(100, Math.round((done / docsLength) * 100)) : null;
|
|
return (
|
|
<div className="card" style={{ padding: 16 }}>
|
|
<div className="upload-progress" style={{ padding: 0 }}>
|
|
<div
|
|
className={`progress-track${pct === null ? " progress-indeterminate" : ""}`}
|
|
role="progressbar"
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={pct ?? undefined}
|
|
>
|
|
<div className="progress-fill" style={{ width: `${pct ?? 100}%` }} />
|
|
</div>
|
|
<div className="upload-progress-stats">
|
|
{pct === null ? (
|
|
<span>Leyendo los recibos…</span>
|
|
) : (
|
|
<>
|
|
<strong>{pct}%</strong>
|
|
<span>
|
|
{done} de {docsLength} página(s) leídas
|
|
</span>
|
|
{pendingOcr > 0 && <span>{pendingOcr} en cola</span>}
|
|
</>
|
|
)}
|
|
<span style={{ marginLeft: "auto" }}>
|
|
Esta pantalla se actualiza sola.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SummaryCard({
|
|
batch,
|
|
readyCount,
|
|
}: {
|
|
batch: StatementBatchDetail;
|
|
readyCount: number;
|
|
}) {
|
|
const entries = Object.entries(batch.byStatus) as [StatementDocumentStatus, number][];
|
|
return (
|
|
<section className="card" style={{ padding: 16 }}>
|
|
<div className="inline-form" style={{ flexWrap: "wrap", gap: 20 }}>
|
|
{entries.map(([status, count]) => (
|
|
<div key={status}>
|
|
<div className="page-sub">{STATUS_LABEL[status] ?? status}</div>
|
|
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{count}</div>
|
|
</div>
|
|
))}
|
|
<div>
|
|
<div className="page-sub">Importe pendiente</div>
|
|
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>
|
|
{formatMoney(batch.pendingTotal, "MXN")}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="page-sub">Listos para registrar</div>
|
|
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{readyCount}</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Posting is by check, exactly as on the manual capture screen — an OCR batch
|
|
* is still "these bills, paid with this check", so the same fields are asked
|
|
* for and the same ledger path is used.
|
|
*/
|
|
function ConfirmCard({
|
|
batchId,
|
|
readyCount,
|
|
onDone,
|
|
setError,
|
|
}: {
|
|
batchId: string;
|
|
readyCount: number;
|
|
onDone: () => void;
|
|
setError: (m: string | null) => void;
|
|
}) {
|
|
const [checkNumber, setCheckNumber] = useState("");
|
|
const [transactionDate, setTransactionDate] = useState(
|
|
new Date().toISOString().slice(0, 10),
|
|
);
|
|
const [outstanding, setOutstanding] = useState(false);
|
|
const [includeReviewed, setIncludeReviewed] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [result, setResult] = useState<string | null>(null);
|
|
|
|
async function submit() {
|
|
if (!checkNumber.trim()) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const input: ConfirmBatchInput = {
|
|
checkNumber: checkNumber.trim(),
|
|
transactionDate,
|
|
outstanding,
|
|
includeReviewed,
|
|
};
|
|
const r = await confirmStatementBatch(batchId, input);
|
|
setResult(
|
|
`Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}.`,
|
|
);
|
|
setCheckNumber("");
|
|
onDone();
|
|
} catch (e) {
|
|
setError((e as Error)?.message ?? "No se pudo registrar el lote.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="card" style={{ padding: 16 }}>
|
|
<h2 className="section-title" style={{ marginTop: 0 }}>
|
|
Registrar {readyCount} recibo(s)
|
|
</h2>
|
|
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
|
<label>
|
|
<span className="page-sub">Cheque</span>
|
|
<input
|
|
className="input"
|
|
value={checkNumber}
|
|
onChange={(e) => setCheckNumber(e.target.value)}
|
|
placeholder="Número de cheque"
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span className="page-sub">Fecha</span>
|
|
<input
|
|
type="date"
|
|
className="input"
|
|
value={transactionDate}
|
|
onChange={(e) => setTransactionDate(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="check">
|
|
<input
|
|
type="checkbox"
|
|
checked={outstanding}
|
|
onChange={(e) => setOutstanding(e.target.checked)}
|
|
/>{" "}
|
|
Sin fondos (queda pendiente)
|
|
</label>
|
|
<label className="check">
|
|
<input
|
|
type="checkbox"
|
|
checked={includeReviewed}
|
|
onChange={(e) => setIncludeReviewed(e.target.checked)}
|
|
/>{" "}
|
|
Incluir los confirmados a mano
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={!checkNumber.trim() || busy}
|
|
onClick={submit}
|
|
>
|
|
{busy ? "Registrando…" : "Registrar"}
|
|
</button>
|
|
</div>
|
|
{result && (
|
|
<div className="state-box" style={{ marginTop: 12 }}>
|
|
{result}
|
|
</div>
|
|
)}
|
|
<p className="page-sub" style={{ marginTop: 12 }}>
|
|
Se registran como cargos del cliente, por la misma vía que la captura
|
|
manual. Un lote registrado dos veces no duplica cobros.
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function DocumentRow({
|
|
doc,
|
|
canReview,
|
|
onChange,
|
|
}: {
|
|
doc: StatementDocument;
|
|
canReview: boolean;
|
|
onChange: () => void;
|
|
}) {
|
|
const [open, setOpen] = useState(
|
|
doc.status === "NEEDS_REVIEW" || doc.status === "OCR_FAILED",
|
|
);
|
|
const [amount, setAmount] = useState(doc.extractedAmount ?? "");
|
|
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
|
|
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
|
|
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
|
|
const [busy, setBusy] = useState(false);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
const settled = doc.status === "POSTED" || doc.status === "REJECTED";
|
|
|
|
async function save(status: "MATCHED" | "CONFIRMED") {
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
await reviewStatementDocument(doc.id, {
|
|
accountRef: accountRef.trim() || undefined,
|
|
amount: amount ? Number(amount) : undefined,
|
|
matchedCustomerId: customerId || undefined,
|
|
status,
|
|
});
|
|
onChange();
|
|
} catch (e) {
|
|
setErr((e as Error)?.message ?? "No se pudo guardar.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function reject() {
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
await rejectStatementDocument(doc.id);
|
|
onChange();
|
|
} catch (e) {
|
|
setErr((e as Error)?.message ?? "No se pudo descartar.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="card" style={{ padding: 16 }}>
|
|
<div
|
|
className="inline-form"
|
|
style={{ justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}
|
|
>
|
|
<div>
|
|
<strong>Página {doc.pageNumber}</strong>{" "}
|
|
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>{" "}
|
|
{doc.provider && <span className="page-sub">{doc.provider}</span>}
|
|
<div className="page-sub" style={{ marginTop: 4 }}>
|
|
{doc.matchedCustomer ? (
|
|
<Link href={`/clientes/${doc.matchedCustomer.id}`}>
|
|
{doc.matchedCustomer.name}
|
|
</Link>
|
|
) : (
|
|
"Sin cliente asignado"
|
|
)}
|
|
{doc.extractedAccountRef && ` · cuenta ${doc.extractedAccountRef}`}
|
|
{doc.extractedCadastralKey && ` · clave ${doc.extractedCadastralKey}`}
|
|
</div>
|
|
{doc.matchNote && (
|
|
<div className="page-sub" style={{ marginTop: 4 }}>
|
|
{doc.matchNote}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="inline-form" style={{ gap: 8 }}>
|
|
<strong>
|
|
{doc.extractedAmount
|
|
? formatMoney(doc.extractedAmount, "MXN")
|
|
: "sin importe"}
|
|
</strong>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
onClick={() => setOpen((v) => !v)}
|
|
>
|
|
{open ? "Ocultar" : "Ver recibo"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{open && (
|
|
<div style={{ marginTop: 12, display: "grid", gap: 16 }}>
|
|
{/* The scan itself — the reviewer's source of truth, not the OCR. */}
|
|
<img
|
|
src={statementPageUrl(doc.id)}
|
|
alt={`Recibo página ${doc.pageNumber}`}
|
|
style={{
|
|
maxWidth: "100%",
|
|
border: "1px solid var(--border, #ddd)",
|
|
borderRadius: 6,
|
|
}}
|
|
/>
|
|
|
|
{canReview && !settled && (
|
|
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
|
<label>
|
|
<span className="page-sub">Cuenta</span>
|
|
<input
|
|
className="input"
|
|
value={accountRef}
|
|
onChange={(e) => setAccountRef(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span className="page-sub">Importe</span>
|
|
<input
|
|
className="input"
|
|
inputMode="decimal"
|
|
value={amount}
|
|
onChange={(e) => setAmount(e.target.value)}
|
|
/>
|
|
</label>
|
|
<div style={{ minWidth: 260 }}>
|
|
<span className="page-sub">Cliente</span>
|
|
<CustomerPicker
|
|
value={customerId}
|
|
valueName={customerName}
|
|
onPick={(cid, name) => {
|
|
setCustomerId(cid);
|
|
setCustomerName(name);
|
|
}}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={busy || !customerId}
|
|
onClick={() => save("MATCHED")}
|
|
>
|
|
Guardar
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
disabled={busy}
|
|
onClick={reject}
|
|
>
|
|
Descartar
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{err && <div className="state-box state-error">{err}</div>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|