Files
jorgecuadros-platform/apps/web/src/components/ManualCheckCapture.tsx
T
rmancinasandClaude Opus 5 b59abda895
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m46s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s
feat(captura): fold recibo OCR into Captura as an automatic mode
Scanning a stack of bills and keying them in are the same daily job, ending
in the same ledger path, so OCR intake becomes a mode of the capture screen
instead of a second menu entry:

- components/Captura.tsx holds the mode switch; the manual check form moves
  verbatim to components/ManualCheckCapture.tsx and the OCR intake to
  components/StatementIntake.tsx.
- /estado-cuenta/lote opens on manual, /recibos on automatic — both render
  Captura, so batch-review links and old bookmarks still land right.
- Nav drops "Recibos (OCR)"; "Captura" covers both, with a NavLink.aliases
  field so /recibos still highlights it.

Also fixes the "El almacenamiento de documentos no está configurado" failure
staff hit on upload. Uploading with no object storage configured used to
succeed, then die on the first put minutes later, leaving a FAILED batch
whose only explanation was that string. createBatch now refuses up front,
GET /statements/status reports storageAvailable alongside ocrAvailable, and
the intake tab explains the situation instead of offering an upload that
cannot work. S3_* documented in .env.example (deploy stacks already set it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 01:04:09 -07:00

527 lines
18 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { CustomerPicker } from "@/components/CustomerPicker";
import { createMovementBatch, getBillingFacets, getByCheck } from "@/lib/api";
import { formatMoney, formatNumber, txTypeLabel } from "@/lib/labels";
import type {
BatchCreateInput,
BillingFacets,
ByCheckResponse,
Currency,
LedgerCurrency,
TransactionDomain,
} from "@/lib/types";
/**
* Batch capture by check — the "Editor" screen from the legacy system
* (docs/RECEIPT_CAPTURE_SPEC.md §1.2), and the manual half of the Captura
* screen (see `Captura`).
*
* Staff key many customers' receipts against ONE physical check before cutting
* it, then check that the captured total matches the check's amount. That
* reconciliation is the whole point, so the running total is the most prominent
* thing on the page and an optional "importe del cheque" field turns it into a
* live difference.
*
* No batch entity is persisted: `checkNumber` is a plain column, and grouping
* by it answers every by-check question (see the "Reporte por cheque" report).
*/
const DOMAINS: { key: TransactionDomain; label: string }[] = [
{ key: "UTILITY", label: "Servicios" },
{ key: "INSURANCE", label: "Seguros" },
{ key: "TRUST", label: "Fideicomiso" },
];
interface Line {
/** Local row key — lines have no server identity until the batch posts. */
key: number;
customerId: string;
customerName: string;
amount: string;
reference: string;
period: string;
outstanding: boolean;
}
function blankLine(key: number): Line {
return {
key,
customerId: "",
customerName: "",
amount: "",
reference: "",
period: "",
outstanding: false,
};
}
export function ManualCheckCapture() {
const [facets, setFacets] = useState<BillingFacets | null>(null);
// Check-level fields — shared by every line.
const [domain, setDomain] = useState<TransactionDomain>("UTILITY");
const [currency, setCurrency] = useState<LedgerCurrency>("MXN");
const [typeId, setTypeId] = useState("");
const [checkNumber, setCheckNumber] = useState("");
const [transactionDate, setTransactionDate] = useState(
new Date().toISOString().slice(0, 10),
);
/** The physical check's amount, for reconciliation only — never submitted. */
const [checkAmount, setCheckAmount] = useState("");
const [lines, setLines] = useState<Line[]>([blankLine(1), blankLine(2), blankLine(3)]);
const [nextKey, setNextKey] = useState(4);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [posted, setPosted] = useState<ByCheckResponse | null>(null);
useEffect(() => {
getBillingFacets().then(setFacets).catch(() => setFacets(null));
}, []);
const filled = lines.filter(
(l) => l.customerId && l.amount.trim() !== "" && Number.isFinite(Number(l.amount)),
);
// Charges are captured as positive numbers and signed on submit, matching
// MovementForm — staff type what's on the bill, not a negative.
const total = useMemo(
() =>
filled
.filter((l) => !l.outstanding)
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
[filled],
);
const outstandingTotal = useMemo(
() =>
filled
.filter((l) => l.outstanding)
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
[filled],
);
const checkAmt = Number(checkAmount);
const hasCheckAmt = checkAmount.trim() !== "" && Number.isFinite(checkAmt);
const diff = hasCheckAmt ? checkAmt - total : 0;
const reconciled = hasCheckAmt && Math.abs(diff) < 0.005;
function update(key: number, patch: Partial<Line>) {
setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l)));
}
function addLine() {
setLines((ls) => [...ls, blankLine(nextKey)]);
setNextKey((k) => k + 1);
}
function removeLine(key: number) {
setLines((ls) => (ls.length === 1 ? ls : ls.filter((l) => l.key !== key)));
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!checkNumber.trim()) {
setError("Indica el número de cheque.");
return;
}
if (filled.length === 0) {
setError("Captura al menos una línea con cliente y monto.");
return;
}
const dupes = filled
.map((l) => l.customerId)
.filter((id, i, arr) => arr.indexOf(id) !== i);
if (dupes.length) {
const names = filled
.filter((l) => dupes.includes(l.customerId))
.map((l) => l.customerName);
if (
!window.confirm(
`Hay más de una línea para el mismo cliente (${[...new Set(names)].join(
", ",
)}). ¿Continuar?`,
)
)
return;
}
const payload: BatchCreateInput = {
domain,
transactionDate,
checkNumber: checkNumber.trim(),
currency: currency as Currency,
typeId: typeId || undefined,
lines: filled.map((l) => ({
customerId: l.customerId,
// Every line of a check batch is a charge the office paid out.
amount: -Math.abs(Number(l.amount)),
reference: l.reference.trim() || undefined,
period: l.period.trim() || undefined,
outstanding: l.outstanding || undefined,
})),
};
setSaving(true);
setError(null);
try {
await createMovementBatch(payload);
// Re-read through the by-check view so the confirmation shows what's
// actually stored (including anything captured against this check
// earlier), not just what this request sent.
setPosted(await getByCheck(payload.checkNumber));
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo guardar el lote.");
} finally {
setSaving(false);
}
}
function reset() {
setPosted(null);
setLines([blankLine(nextKey), blankLine(nextKey + 1), blankLine(nextKey + 2)]);
setNextKey((k) => k + 3);
setCheckNumber("");
setCheckAmount("");
}
if (posted) {
return (
<>
<div className="page-head">
<div>
<h2 className="page-title">Lote capturado</h2>
<p className="eyebrow">
Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "}
{posted.count === 1 ? "movimiento" : "movimientos"}
</p>
</div>
<div style={{ display: "flex", gap: 10 }}>
<button type="button" className="btn btn-primary" onClick={reset}>
Capturar otro cheque
</button>
<Link href="/estado-cuenta" className="btn btn-outline">
Volver a estado de cuenta
</Link>
</div>
</div>
<div className="filtered-totals" style={{ marginBottom: 16 }}>
{posted.totals.map((t) => (
<div className="filtered-total" key={t.currency}>
<span className="filtered-total-cur">{t.currency}</span>
<span className="filtered-total-net">
Total del cheque <strong>{formatMoney(t.total, t.currency)}</strong>
</span>
<span>{formatNumber(t.count)} movimientos</span>
</div>
))}
{posted.outstandingCount > 0 && (
<div className="filtered-total">
<span>
{formatNumber(posted.outstandingCount)} sin fondos (no suman al
total)
</span>
</div>
)}
</div>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Cliente</th>
<th>Referencia</th>
<th>Periodo</th>
<th>Estado</th>
<th className="num">Monto</th>
</tr>
</thead>
<tbody>
{posted.items.map((i) => (
<tr key={i.id}>
<td>
<Link
href={`/estado-cuenta/${i.customerId}`}
className="inline-link"
>
{i.customerName}
</Link>
</td>
<td>{i.reference || "—"}</td>
<td>{i.period || "—"}</td>
<td>{i.outstanding ? "Sin fondos" : "Pagado"}</td>
<td className="num">
<span className="tx-amount neg">
{formatMoney(i.amount, i.currency)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="muted" style={{ marginTop: 14 }}>
Para imprimir la conciliación, usa el reporte{" "}
<Link
href={`/reportes/cheque-count?checkNumber=${encodeURIComponent(
posted.checkNumber,
)}`}
className="inline-link"
>
Reporte por cheque
</Link>
.
</p>
</>
);
}
return (
<>
{error && <div className="state-box state-error">{error}</div>}
<form onSubmit={submit}>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>
Datos del cheque
</h2>
<div className="form-grid">
<label className="field">
<span className="field-label">Número de cheque *</span>
<input
className="input"
value={checkNumber}
onChange={(e) => setCheckNumber(e.target.value)}
required
/>
</label>
<label className="field">
<span className="field-label">Fecha *</span>
<input
className="input"
type="date"
required
value={transactionDate}
onChange={(e) => setTransactionDate(e.target.value)}
/>
</label>
<label className="field">
<span className="field-label">Línea de negocio *</span>
<select
className="select"
value={domain}
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
>
{DOMAINS.map((d) => (
<option key={d.key} value={d.key}>
{d.label}
</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">Moneda *</span>
<select
className="select"
value={currency}
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
>
<option value="MXN">Pesos (MXN)</option>
<option value="USD">Dólares (USD)</option>
</select>
</label>
<label className="field">
<span className="field-label">Concepto</span>
<select
className="select"
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
>
<option value="">(sin concepto)</option>
{facets?.types.map((t) => (
<option key={t.id} value={t.id}>
{txTypeLabel({ nameEn: t.name })}
</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">Importe del cheque</span>
<input
className="input"
type="number"
step="0.01"
min="0"
value={checkAmount}
onChange={(e) => setCheckAmount(e.target.value)}
placeholder="Para conciliar"
/>
</label>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 14,
}}
>
<h2 className="section-title" style={{ margin: 0 }}>
Recibos ({formatNumber(filled.length)})
</h2>
<button type="button" className="btn btn-outline" onClick={addLine}>
Agregar línea
</button>
</div>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th style={{ minWidth: 240 }}>Cliente *</th>
<th style={{ minWidth: 120 }}>Referencia</th>
<th style={{ minWidth: 100 }}>Periodo</th>
<th style={{ minWidth: 110 }} className="num">
Monto *
</th>
<th style={{ whiteSpace: "nowrap" }}>Sin fondos</th>
<th style={{ width: 1 }} />
</tr>
</thead>
<tbody>
{lines.map((l) => (
<tr key={l.key}>
<td>
<CustomerPicker
value={l.customerId}
valueName={l.customerId ? l.customerName : undefined}
onPick={(id, name) =>
update(l.key, { customerId: id, customerName: name })
}
/>
</td>
<td>
<input
className="input"
value={l.reference}
onChange={(e) =>
update(l.key, { reference: e.target.value })
}
/>
</td>
<td>
<input
className="input"
value={l.period}
onChange={(e) => update(l.key, { period: e.target.value })}
placeholder="2026-07"
/>
</td>
<td>
<input
className="input num"
type="number"
step="0.01"
min="0"
value={l.amount}
onChange={(e) => update(l.key, { amount: e.target.value })}
placeholder="0.00"
/>
</td>
<td style={{ textAlign: "center" }}>
<input
type="checkbox"
checked={l.outstanding}
onChange={(e) =>
update(l.key, { outstanding: e.target.checked })
}
aria-label="Sin fondos"
/>
</td>
<td>
<button
type="button"
className="btn btn-ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
onClick={() => removeLine(l.key)}
disabled={lines.length === 1}
>
Quitar
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>
Conciliación
</h2>
<div className="filtered-totals">
<div className="filtered-total">
<span className="filtered-total-cur">{currency}</span>
<span className="filtered-total-net">
Capturado <strong>{formatMoney(String(-total), currency)}</strong>
</span>
<span>{formatNumber(filled.filter((l) => !l.outstanding).length)} recibos</span>
</div>
{outstandingTotal > 0 && (
<div className="filtered-total">
<span>
Sin fondos{" "}
<strong>{formatMoney(String(-outstandingTotal), currency)}</strong>{" "}
(no suma al cheque)
</span>
</div>
)}
{hasCheckAmt && (
<div className="filtered-total">
<span className="filtered-total-net">
{reconciled ? (
<strong className="tx-amount pos">Cuadra con el cheque</strong>
) : (
<>
Diferencia{" "}
<strong className="tx-amount neg">
{formatMoney(String(diff), currency)}
</strong>
</>
)}
</span>
</div>
)}
</div>
</div>
<div className="form-actions">
<button
type="submit"
className="btn btn-primary"
disabled={saving || filled.length === 0}
>
{saving
? "Guardando…"
: `Capturar ${formatNumber(filled.length)} ${
filled.length === 1 ? "recibo" : "recibos"
}`}
</button>
<Link href="/estado-cuenta" className="btn btn-outline">
Cancelar
</Link>
</div>
</form>
</>
);
}