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>
527 lines
18 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|