"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 { DiscardBatchCard } from "@/components/DiscardBatchCard"; import { addStatementFiles, confirmStatementBatch, discardStatementBatch, getStatementBatch, listStatementDocuments, rejectStatementDocument, reviewStatementDocument, setStatementSplits, statementPageUrl, } from "@/lib/api"; import { useCan } from "@/lib/abilities"; import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels"; import type { ConfirmBatchInput, SplitPartInput, 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 canIngest = useCan("statement:ingest"); const [batch, setBatch] = useState(null); const [docs, setDocs] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [discarding, setDiscarding] = useState(false); 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 || d.splits.length > 0), ).length; async function discard() { setDiscarding(true); setError(null); try { await discardStatementBatch(id); await load(); } catch (e) { setError((e as Error)?.message ?? "No se pudo descartar el lote."); } finally { setDiscarding(false); } } if (loading) return
Cargando…
; if (!batch) return
{error ?? "No encontrado."}
; const postedCount = batch.byStatus.POSTED ?? 0; // Discarding is only offered while the batch can still be abandoned whole: // nothing posted to the ledger yet, and not already settled. const canDiscard = canReview && batch.status !== "DISCARDED" && batch.status !== "COMPLETED" && postedCount === 0; 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 && ( )} {canIngest && !processing && batch.status !== "DISCARDED" && ( )} {canReview && readyCount > 0 && ( )} {canDiscard && ( )}
{sorted.map((doc) => ( ))}
); } const STATUS_LABEL_BATCH: Record = { UPLOADED: "Recibido", PROCESSING: "Procesando", READY_FOR_REVIEW: "Listo para revisar", COMPLETED: "Registrado", FAILED: "Falló", DISCARDED: "Descartado", }; /** * 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, lastCheckNumber, readyCount, onDone, setError, }: { batchId: string; lastCheckNumber: string | null; readyCount: number; onDone: () => void; setError: (m: string | null) => void; }) { // A batch kept open for one check is posted in rounds against that check. const [checkNumber, setCheckNumber] = useState(lastCheckNumber ?? ""); 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}` + (r.outstanding ? `; ${r.outstanding} sin fondos.` : "."), ); 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. Para dejar pendiente un solo recibo, márcalo «Sin fondos» en el recibo.

); } /** More scans for this batch — it stays open while receipts keep arriving. */ function AddFilesCard({ batchId, onDone, setError, }: { batchId: string; onDone: () => void; setError: (m: string | null) => void; }) { const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false); async function upload() { setBusy(true); setError(null); try { await addStatementFiles(batchId, files); setFiles([]); onDone(); } catch (e) { setError((e as Error)?.message ?? "No se pudieron agregar los archivos."); } finally { setBusy(false); } } return (
); } 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 [outstanding, setOutstanding] = useState(doc.outstanding); 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, outstanding, 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.outstanding && Sin fondos}{" "} {doc.provider && {doc.provider}}
{doc.splits.length > 0 ? ( <> Dividido:{" "} {doc.splits.map((s, i) => ( {i > 0 && " + "} {s.customer.name}{" "} {formatMoney(s.amount, "MXN")} ))} ) : 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); }} />
)} {canReview && !settled && ( )} {err &&
{err}
}
)}
); } type Part = { customerId: string; customerName: string; amount: string }; /** Equal parts in cents; the odd cents go to the first parts. */ function equalParts(total: number, n: number): string[] { const cents = Math.round(total * 100); const base = Math.floor(cents / n); return Array.from({ length: n }, (_, i) => ((base + (i < cents - base * n ? 1 : 0)) / 100).toFixed(2), ); } /** * One receipt paid by several customers — a water bill two neighbours share. * Amounts are free (the office splits unevenly as often as not) but must add * up to the receipt; "partes iguales" fills them in. */ function SplitEditor({ doc, onChange }: { doc: StatementDocument; onChange: () => void }) { const total = Number(doc.extractedAmount ?? 0); const [parts, setParts] = useState(() => doc.splits.map((s) => ({ customerId: s.customer.id, customerName: s.customer.name, amount: Number(s.amount).toFixed(2), })), ); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const sum = parts.reduce((s, p) => s + Math.round(Number(p.amount || 0) * 100), 0) / 100; const balanced = Math.round(sum * 100) === Math.round(total * 100); const complete = parts.length >= 2 && parts.every((p) => p.customerId && Number(p.amount) > 0); function setPart(i: number, patch: Partial) { setParts((prev) => prev.map((p, j) => (j === i ? { ...p, ...patch } : p))); } function start() { const first = { customerId: doc.matchedCustomer?.id ?? "", customerName: doc.matchedCustomer?.name ?? "", }; const [a, b] = equalParts(total, 2); setParts([ { ...first, amount: a }, { customerId: "", customerName: "", amount: b }, ]); } function evenly() { const amounts = equalParts(total, parts.length); setParts((prev) => prev.map((p, i) => ({ ...p, amount: amounts[i] }))); } async function save(next: SplitPartInput[]) { setBusy(true); setErr(null); try { await setStatementSplits(doc.id, next); onChange(); } catch (e) { setErr((e as Error)?.message ?? "No se pudo guardar la división."); } finally { setBusy(false); } } if (!parts.length) { return (
); } return (
División del recibo ({formatMoney(total, "MXN")}) {parts.map((p, i) => (
setPart(i, { customerId: cid, customerName: name })} />
setPart(i, { amount: e.target.value })} /> {parts.length > 2 && ( )}
))}
Suma {formatMoney(sum, "MXN")} {!balanced && ` — diferencia ${formatMoney(total - sum, "MXN")}`}
{err &&
{err}
}
); }