"use client"; import Link from "next/link"; import { useCallback, useEffect, useRef, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { ContextReports } from "@/components/ContextReports"; import { createBankMovement, getBankFacets, getBankStats, getBankSummary, listBankAccounts, listBankMovements, voidBankMovement, } from "@/lib/api"; import { useCan } from "@/lib/abilities"; import { bankDirectionLabel, bankSourceLabel, bankTone, formatDate, formatMoney, formatNumber, monthName, } from "@/lib/labels"; import type { BankAccount, BankCleared, BankDirection, BankFacets, BankListItem, BankListResponse, BankSort, BankStats, BankSummary, BankTotals, CreateBankMovementInput, Currency, } from "@/lib/types"; /** * Bank register (chequera) browser — plan step 7, multi-account since the * step-11 multi-bank work. * * This is the office's OWN checking accounts, not customer money. It is a * separate page from /estado-cuenta on purpose: nothing here belongs in a * customer's statement and the two sets of figures are never combined. * * Two views, both scoped to the ONE account picked at the top: * - "Movimientos": the register itself — every deposit and payment, by date, * payee, cheque number or amount. * - "Resumen": ingresos vs egresos per year, and per month inside a year, * with the running net movement since the register opened. * * Every amount is read in the selected account's currency. There is no "all * accounts" option on purpose — Utilities banks in MXN and Seguros in USD, so * one combined figure would be a number that never existed, exactly what * /estado-cuenta's per-currency rule avoids. See the module header in * `bank.service.ts` for why there is no category/ramo filter. */ type View = "movimientos" | "resumen"; const DIRECTIONS: { key: BankDirection | ""; label: string }[] = [ { key: "", label: "Ingresos y egresos" }, { key: "income", label: "Sólo ingresos" }, { key: "expense", label: "Sólo egresos" }, { key: "void", label: "Sólo cancelados" }, ]; const CLEARED: { key: BankCleared | ""; label: string }[] = [ { key: "", label: "Operados y pendientes" }, { key: "cleared", label: "Sólo operados" }, { key: "pending", label: "Sólo pendientes" }, ]; const SORTS: { key: BankSort; label: string }[] = [ { key: "date_desc", label: "Fecha (más reciente)" }, { key: "date_asc", label: "Fecha (más antigua)" }, { key: "amount_desc", label: "Ingreso más grande" }, { key: "amount_asc", label: "Egreso más grande" }, { key: "reference", label: "Número de cheque" }, ]; export default function BancoPage() { return ( ); } /** Remembers the last chequera a person looked at, per browser. */ const ACCOUNT_KEY = "banco.bankAccountId"; function BankBrowser() { const canCapture = useCan("bank:create"); const canVoid = useCan("bank:void"); const canManageAccounts = useCan("bank:manage-accounts"); const [accounts, setAccounts] = useState(null); const [accountId, setAccountId] = useState(null); const [accountsError, setAccountsError] = useState(null); const [stats, setStats] = useState(null); const [facets, setFacets] = useState(null); const [view, setView] = useState("movimientos"); const [query, setQuery] = useState(""); const [direction, setDirection] = useState(""); const [cleared, setCleared] = useState(""); const [from, setFrom] = useState(""); const [to, setTo] = useState(""); const [sort, setSort] = useState("date_desc"); const [movements, setMovements] = useState(null); const [summary, setSummary] = useState(null); const [summaryYear, setSummaryYear] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [captureOpen, setCaptureOpen] = useState(false); const debounceRef = useRef>(); const account = accounts?.find((a) => a.id === accountId) ?? null; const currency = account?.currency ?? "MXN"; // Accounts load first: nothing else on this page can be requested until one // is selected, because every read is scoped to exactly one chequera. useEffect(() => { listBankAccounts() .then((rows) => { setAccounts(rows); const remembered = typeof window !== "undefined" ? window.localStorage.getItem(ACCOUNT_KEY) : null; const pick = rows.find((a) => a.id === remembered) ?? rows.find((a) => a.active) ?? rows[0]; setAccountId(pick?.id ?? null); if (rows.length === 0) setLoading(false); }) .catch((e) => { setAccountsError(e?.message ?? "No se pudieron cargar las cuentas."); setLoading(false); }); }, []); function pickAccount(id: string) { setAccountId(id); if (typeof window !== "undefined") window.localStorage.setItem(ACCOUNT_KEY, id); // The previous account's figures must not linger while the new ones load. setStats(null); setFacets(null); setMovements(null); setSummary(null); setSummaryYear(null); } const refreshStats = useCallback(() => { if (!accountId) return; getBankStats(accountId) .then(setStats) .catch(() => setStats(null)); }, [accountId]); useEffect(() => { if (!accountId) return; refreshStats(); getBankFacets(accountId) .then(setFacets) .catch(() => setFacets(null)); }, [accountId, refreshStats]); const runSearch = useCallback( (p: number) => { if (!accountId) return; setLoading(true); setError(null); listBankMovements({ bankAccountId: accountId, query: query || undefined, direction: direction || undefined, cleared: cleared || undefined, from: from || undefined, to: to || undefined, sort, page: p, pageSize: 25, }) .then((res) => { setMovements(res); setLoading(false); }) .catch((e) => { setError(e?.message ?? "No se pudieron cargar los movimientos."); setLoading(false); }); }, [accountId, query, direction, cleared, from, to, sort], ); useEffect(() => { if (view !== "movimientos" || !accountId) return; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => runSearch(1), 280); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [runSearch, view, accountId]); useEffect(() => { if (view !== "resumen" || !accountId) return; setLoading(true); setError(null); getBankSummary(accountId, summaryYear ?? undefined) .then((res) => { setSummary(res); setLoading(false); }) .catch((e) => { setError(e?.message ?? "No se pudo cargar el resumen."); setLoading(false); }); }, [view, summaryYear, accountId]); function goToPage(p: number) { runSearch(p); if (typeof window !== "undefined") window.scrollTo({ top: 0, behavior: "smooth" }); } /** Clicking a year in the resumen drills into its months. */ function pickYear(year: number) { setSummaryYear((prev) => (prev === year ? null : year)); } /** Jumping from a headline figure lands on the matching filtered register. */ function pickDirection(d: BankDirection) { setView("movimientos"); setDirection(d); setSort(d === "income" ? "amount_desc" : "amount_asc"); } const filtered = query !== "" || direction !== "" || cleared !== "" || from !== "" || to !== "" || sort !== "date_desc"; function clearFilters() { setQuery(""); setDirection(""); setCleared(""); setFrom(""); setTo(""); setSort("date_desc"); } if (accountsError) { return ( <>

Chequera

{accountsError}
); } // No chequera on file: the register has nothing it could be scoped to. if (accounts && accounts.length === 0) { return ( <>

Cuentas propias de la oficina

Chequera

Sin cuentas registradas

{canManageAccounts ? ( <> Registra una cuenta bancaria en{" "} Cuentas de chequera para empezar a capturar movimientos. ) : ( "Pide a un administrador que registre una cuenta bancaria." )}

); } return ( <>

Cuenta propia de la oficina

Chequera

Movimientos de{" "} {account ? account.label : "la cuenta seleccionada"}, en {currency}. Cada cuenta se lee por separado: las cifras de dos chequeras nunca se suman, igual que los saldos por moneda del estado de cuenta. Tampoco forman parte del estado de cuenta de los clientes.

setQuery(e.target.value)} placeholder="Buscar por beneficiario, cheque, nota…" aria-label="Buscar en la chequera" disabled={view === "resumen"} />
{( [ { key: "movimientos" as View, label: "Movimientos" }, { key: "resumen" as View, label: "Resumen por periodo" }, ] ).map((v) => ( ))}
{view === "movimientos" && canCapture && account?.active && ( )}
{account && !account.active && (
Esta cuenta está cerrada: su historial se consulta, pero no admite movimientos nuevos.
)} {view === "movimientos" && captureOpen && account && ( { setCaptureOpen(false); runSearch(movements?.page ?? 1); refreshStats(); }} onCancel={() => setCaptureOpen(false)} /> )} {view === "movimientos" && (
{filtered && ( )}
)} {view === "resumen" && facets && facets.years.length > 0 && (
)} {view === "movimientos" && movements && !loading && !error && (
{movements.total === 0 ? "Sin resultados" : `${formatNumber(movements.total)} ${ movements.total === 1 ? "movimiento" : "movimientos" }`} {query ? ` para “${query}”` : ""}
)} {view === "movimientos" && movements && !loading && ( )} {error ? (
{error}
) : loading ? ( ) : view === "resumen" ? ( ) : movements && movements.total === 0 ? ( ) : ( <>
{canVoid && ( )} {movements?.items.map((m) => ( { runSearch(movements?.page ?? 1); refreshStats(); }} /> ))}
Fecha Cheque / ref. Beneficiario / concepto Origen Monto Acciones
{movements && movements.pageCount > 1 && ( )} )} ); } /** * Which chequera the whole page is reading. There is no "todas las cuentas" * option and there must not be one — see the file header. */ function AccountPicker({ accounts, accountId, onPick, canManageAccounts, }: { accounts: BankAccount[] | null; accountId: string | null; onPick: (id: string) => void; canManageAccounts: boolean; }) { if (!accounts) { return (
); } return (
{canManageAccounts && ( Administrar cuentas )}
); } /** Headline figures; the ingreso/egreso cells double as register shortcuts. */ function BankStatStrip({ stats, currency, direction, onPickDirection, }: { stats: BankStats | null; currency: Currency; direction: BankDirection | ""; onPickDirection: (d: BankDirection) => void; }) { if (!stats) { return (
{Array.from({ length: 5 }).map((_, i) => (
))}
); } return (
{formatMoney(stats.net, currency)}
{/* Not the bank balance: the register carries no opening balance. */}
Movimiento neto acumulado
{formatNumber(stats.movements)}
Movimientos · {formatDate(stats.firstMovement)} a{" "} {formatDate(stats.lastMovement)}
); } /** Totals for everything the current filter matched, not just the page. */ function FilteredTotals({ totals, currency, }: { totals: BankTotals; currency: Currency; }) { if (totals.incomeCount + totals.expenseCount + totals.voidCount === 0) return null; return (
{currency} {formatMoney(totals.income, currency)} {" "} en ingresos · {formatNumber(totals.incomeCount)} {formatMoney(totals.expense, currency)} {" "} en egresos · {formatNumber(totals.expenseCount)} Neto {formatMoney(totals.net, currency)} {totals.voidCount > 0 && ( {formatNumber(totals.voidCount)} cancelados )}
); } function BankRow({ m, currency, canVoid, onVoided, }: { m: BankListItem; currency: Currency; canVoid: boolean; onVoided: () => void; }) { const [busy, setBusy] = useState(false); async function doVoid() { if ( !window.confirm( "¿Anular este movimiento? Quedará tachado y no contará en los totales.", ) ) return; setBusy(true); try { await voidBankMovement(m.id); onVoided(); } catch (e) { window.alert( (e as Error)?.message ?? "No se pudo anular el movimiento.", ); setBusy(false); } } return ( {formatDate(m.transactionDate)} {m.reference || "—"} {!m.cleared &&
Sin operar
} {m.concept || Sin concepto} {m.notes &&
Nota: {m.notes}
} {bankSourceLabel(m.source)} {m.direction === "void" ? "—" : formatMoney(m.amount, currency)}
{bankDirectionLabel(m.direction)}
{canVoid && ( {!m.voided && ( )} )} ); } /** * Ingresos vs egresos per period. `cumulative` is the net movement since the * register opened in 2013, not a bank balance — SCOTHIA has no opening figure. */ function SummaryView({ summary, year, currency, onPickYear, }: { summary: BankSummary | null; year: number | null; currency: Currency; onPickYear: (y: number) => void; }) { if (!summary) return null; return ( <>
{summary.years.map((r) => ( onPickYear(r.period)} style={{ cursor: "pointer" }} className={year === r.period ? "selected" : undefined} > ))}
Año Movimientos Ingresos Egresos Neto Acumulado
{r.period} {formatNumber(r.count)} {formatMoney(r.income, currency)} {formatMoney(r.expense, currency)} {formatMoney(r.net, currency)} {formatMoney(r.cumulative, currency)}

El acumulado es el movimiento neto desde que inicia el registro; la chequera heredada no trae saldo inicial, así que no equivale al saldo del banco. Selecciona un año para ver sus meses.

{year && summary.months.length > 0 && (

Meses de {year}

abre en {formatMoney(summary.opening, currency)}
{summary.months.map((r) => ( ))}
Mes Movimientos Ingresos Egresos Neto Acumulado
{monthName(r.period)} {formatNumber(r.count)} {formatMoney(r.income, currency)} {formatMoney(r.expense, currency)} {formatMoney(r.net, currency)} {formatMoney(r.cumulative, currency)}
)} ); } /** Inline capture form for a single chequera movement. The amount is in the * selected account's currency; sign convention: positive = ingreso, negative * = egreso. Booked rows are never edited — fix mistakes with voidBankMovement * + a fresh capture. */ function BankCaptureForm({ account, onSaved, onCancel, }: { account: BankAccount; onSaved: () => void; onCancel: () => void; }) { const [direction, setDirection] = useState("expense"); const [amount, setAmount] = useState(""); const [transactionDate, setTransactionDate] = useState( new Date().toISOString().slice(0, 10), ); const [concept, setConcept] = useState(""); const [reference, setReference] = useState(""); const [transactionType, setTransactionType] = useState(""); const [cleared, setCleared] = useState(true); const [transferred, setTransferred] = useState(false); const [notes, setNotes] = useState(""); const [amountInWords, setAmountInWords] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); function s(v: string): string | undefined { const t = v.trim(); return t === "" ? undefined : t; } async function submit(e: React.FormEvent) { e.preventDefault(); const abs = Number(amount); if (!Number.isFinite(abs) || abs <= 0) { setError("El monto debe ser un número mayor a cero."); return; } const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs); const payload: CreateBankMovementInput = { bankAccountId: account.id, amount: signed, transactionDate, concept: s(concept), reference: s(reference), transactionType: s(transactionType), cleared, transferred, notes: s(notes), amountInWords: s(amountInWords), }; setSaving(true); setError(null); try { await createBankMovement(payload); onSaved(); } catch (e2) { setError((e2 as Error)?.message ?? "No se pudo guardar el movimiento."); setSaving(false); } } return (
{error &&
{error}
}

Capturar movimiento de chequera

Se registra en {account.label}, en {account.currency}.