"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(null); // Check-level fields — shared by every line. const [domain, setDomain] = useState("UTILITY"); const [currency, setCurrency] = useState("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([blankLine(1), blankLine(2), blankLine(3)]); const [nextKey, setNextKey] = useState(4); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [posted, setPosted] = useState(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) { 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 ( <>

Lote capturado

Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "} {posted.count === 1 ? "movimiento" : "movimientos"}

Volver a estado de cuenta
{posted.totals.map((t) => (
{t.currency} Total del cheque {formatMoney(t.total, t.currency)} {formatNumber(t.count)} movimientos
))} {posted.outstandingCount > 0 && (
{formatNumber(posted.outstandingCount)} sin fondos (no suman al total)
)}
{posted.items.map((i) => ( ))}
Cliente Referencia Periodo Estado Monto
{i.customerName} {i.reference || "—"} {i.period || "—"} {i.outstanding ? "Sin fondos" : "Pagado"} {formatMoney(i.amount, i.currency)}

Para imprimir la conciliación, usa el reporte{" "} Reporte por cheque .

); } return ( <> {error &&
{error}
}

Datos del cheque

Recibos ({formatNumber(filled.length)})

{lines.map((l) => ( ))}
Cliente * Referencia Periodo Monto * Sin fondos
update(l.key, { customerId: id, customerName: name }) } /> update(l.key, { reference: e.target.value }) } /> update(l.key, { period: e.target.value })} placeholder="2026-07" /> update(l.key, { amount: e.target.value })} placeholder="0.00" /> update(l.key, { outstanding: e.target.checked }) } aria-label="Sin fondos" />

Conciliación

{currency} Capturado {formatMoney(String(-total), currency)} {formatNumber(filled.filter((l) => !l.outstanding).length)} recibos
{outstandingTotal > 0 && (
Sin fondos{" "} {formatMoney(String(-outstandingTotal), currency)}{" "} (no suma al cheque)
)} {hasCheckAmt && (
{reconciled ? ( Cuadra con el cheque ) : ( <> Diferencia{" "} {formatMoney(String(diff), currency)} )}
)}
Cancelar
); }