"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 = { 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 ( ); } function BatchReview({ id }: { id: string }) { const canReview = useCan("statement:review"); const [batch, setBatch] = useState(null); const [docs, setDocs] = useState([]); const [error, setError] = useState(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
Cargando…
; if (!batch) return
{error ?? "No encontrado."}
; return (

Recibos — {serviceKindLabel(batch.serviceKind)} {batch.label ? ` · ${batch.label}` : ""}

{formatDate(batch.createdAt)} · {docs.length} página(s) ·{" "} {STATUS_LABEL_BATCH[batch.status] ?? batch.status}

Volver a captura
{error &&
{error}
} {processing && ( )} {canReview && readyCount > 0 && ( )}
{sorted.map((doc) => ( ))}
); } const STATUS_LABEL_BATCH: Record = { 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 (
{pct === null ? ( Leyendo los recibos… ) : ( <> {pct}% {done} de {docsLength} página(s) leídas {pendingOcr > 0 && {pendingOcr} en cola} )} Esta pantalla se actualiza sola.
); } function SummaryCard({ batch, readyCount, }: { batch: StatementBatchDetail; readyCount: number; }) { const entries = Object.entries(batch.byStatus) as [StatementDocumentStatus, number][]; return (
{entries.map(([status, count]) => (
{STATUS_LABEL[status] ?? status}
{count}
))}
Importe pendiente
{formatMoney(batch.pendingTotal, "MXN")}
Listos para registrar
{readyCount}
); } /** * 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(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 (

Registrar {readyCount} recibo(s)

{result && (
{result}
)}

Se registran como cargos del cliente, por la misma vía que la captura manual. Un lote registrado dos veces no duplica cobros.

); } 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(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 (
Página {doc.pageNumber}{" "} {STATUS_LABEL[doc.status] ?? doc.status}{" "} {doc.provider && {doc.provider}}
{doc.matchedCustomer ? ( {doc.matchedCustomer.name} ) : ( "Sin cliente asignado" )} {doc.extractedAccountRef && ` · cuenta ${doc.extractedAccountRef}`} {doc.extractedCadastralKey && ` · clave ${doc.extractedCadastralKey}`}
{doc.matchNote && (
{doc.matchNote}
)}
{doc.extractedAmount ? formatMoney(doc.extractedAmount, "MXN") : "sin importe"}
{open && (
{/* The scan itself — the reviewer's source of truth, not the OCR. */} {`Recibo {canReview && !settled && (
Cliente { setCustomerId(cid); setCustomerName(name); }} />
)} {err &&
{err}
}
)}
); }