Files
jorgecuadros-platform/apps/web/src/app/recibos/[id]/page.tsx
T
rmancinasandClaude Opus 5.5 fd5df240bc
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m57s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m22s
feat(recibos): split a receipt, sin fondos per receipt, keep a batch open
Three asks from the 2026-10-08 meeting, all on the OCR receipt queue.

- Split: one receipt paid by several customers (411 + 523 sharing water
  3014057) posts one charge per part. statement_document_splits holds free
  amounts that must sum to the receipt in cents, checked on save and again
  at confirm; "partes iguales" fills them in. Each part's row carries
  captureRef <docId>#<n>, so the double-post guard still holds.
- Sin fondos per receipt: StatementDocument.outstanding, toggled in review.
  At confirm a line is outstanding if its receipt or the whole check is.
- Open batch: POST /statements/batches/:id/files appends scans, numbered
  after the existing pages; a COMPLETED batch reopens. The confirm card
  prefills the check the batch was last posted against.

Verified on dev against a real batch: 1,892.00 split 946/946 across two
customers, a sibling receipt posted outstanding, second confirm refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:20:20 -07:00

803 lines
24 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 { 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<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 canIngest = useCan("statement:ingest");
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 [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 <div className="state-box">Cargando…</div>;
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
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 (
<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} />
{canIngest && !processing && batch.status !== "DISCARDED" && (
<AddFilesCard batchId={id} onDone={load} setError={setError} />
)}
{canReview && readyCount > 0 && (
<ConfirmCard
batchId={id}
lastCheckNumber={batch.lastCheckNumber}
readyCount={readyCount}
onDone={load}
setError={setError}
/>
)}
{canDiscard && (
<DiscardBatchCard
busy={discarding}
onDiscard={discard}
pageCount={docs.length}
what="recibo"
/>
)}
<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ó",
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 (
<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,
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<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}` +
(r.outstanding ? `; ${r.outstanding} sin fondos.` : "."),
);
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)}
/>{" "}
Todo el cheque sin fondos
</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. Para dejar
pendiente un solo recibo, márcalo «Sin fondos» en el recibo.
</p>
</section>
);
}
/** 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<File[]>([]);
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 (
<section className="card" style={{ padding: 16 }}>
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
<label>
<span className="page-sub">Agregar recibos a este lote</span>
<input
type="file"
accept="application/pdf"
multiple
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
/>
</label>
<button
type="button"
className="btn btn-outline"
disabled={!files.length || busy}
onClick={upload}
>
{busy ? "Subiendo…" : `Agregar ${files.length || ""} archivo(s)`}
</button>
</div>
</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 [outstanding, setOutstanding] = useState(doc.outstanding);
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,
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 (
<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.outstanding && <span className="tag">Sin fondos</span>}{" "}
{doc.provider && <span className="page-sub">{doc.provider}</span>}
<div className="page-sub" style={{ marginTop: 4 }}>
{doc.splits.length > 0 ? (
<>
Dividido:{" "}
{doc.splits.map((s, i) => (
<span key={s.id}>
{i > 0 && " + "}
<Link href={`/clientes/${s.customer.id}`}>{s.customer.name}</Link>{" "}
{formatMoney(s.amount, "MXN")}
</span>
))}
</>
) : 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>
<label className="check">
<input
type="checkbox"
checked={outstanding}
onChange={(e) => setOutstanding(e.target.checked)}
/>{" "}
Sin fondos
</label>
<button
type="button"
className="btn btn-primary"
disabled={busy || (!customerId && doc.splits.length === 0)}
onClick={() => save("MATCHED")}
>
Guardar
</button>
<button
type="button"
className="btn btn-ghost"
disabled={busy}
onClick={reject}
>
Descartar
</button>
</div>
)}
{canReview && !settled && (
<SplitEditor doc={doc} onChange={onChange} />
)}
{err && <div className="state-box state-error">{err}</div>}
</div>
)}
</div>
);
}
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<Part[]>(() =>
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<string | null>(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<Part>) {
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 (
<div>
<button
type="button"
className="btn btn-ghost"
disabled={!doc.extractedAmount}
title={doc.extractedAmount ? undefined : "Captura el importe primero"}
onClick={start}
>
Dividir entre varios clientes
</button>
</div>
);
}
return (
<div className="stack" style={{ gap: 8 }}>
<strong>División del recibo ({formatMoney(total, "MXN")})</strong>
{parts.map((p, i) => (
<div key={i} className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
<div style={{ minWidth: 260 }}>
<CustomerPicker
value={p.customerId}
valueName={p.customerName}
onPick={(cid, name) => setPart(i, { customerId: cid, customerName: name })}
/>
</div>
<input
className="input"
inputMode="decimal"
style={{ width: 120 }}
value={p.amount}
onChange={(e) => setPart(i, { amount: e.target.value })}
/>
{parts.length > 2 && (
<button
type="button"
className="btn btn-ghost"
onClick={() => setParts((prev) => prev.filter((_, j) => j !== i))}
>
Quitar
</button>
)}
</div>
))}
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
<button
type="button"
className="btn btn-ghost"
onClick={() => setParts((prev) => [...prev, { customerId: "", customerName: "", amount: "" }])}
>
Agregar cliente
</button>
<button type="button" className="btn btn-ghost" onClick={evenly}>
Partes iguales
</button>
<span className={balanced ? "page-sub" : "state-error"}>
Suma {formatMoney(sum, "MXN")}
{!balanced && ` — diferencia ${formatMoney(total - sum, "MXN")}`}
</span>
<button
type="button"
className="btn btn-primary"
disabled={busy || !balanced || !complete}
onClick={() =>
save(parts.map((p) => ({ customerId: p.customerId, amount: Number(p.amount) })))
}
>
Guardar división
</button>
<button
type="button"
className="btn btn-outline"
disabled={busy}
onClick={() => (doc.splits.length ? save([]) : setParts([]))}
>
Quitar división
</button>
</div>
{err && <div className="state-box state-error">{err}</div>}
</div>
);
}