feat(bank): chequera register module (plan step 7)

Adds the office's own bank-register browser over the migrated SCOTHIA
data (22,354 bank_transactions), the last self-contained feature module.

API (apps/api/src/bank):
- GET /bank        register browser: search over concepto/reference/notes/
                   amountInWords; direction (income|expense|void), cleared and
                   date-range filters; 5 sorts; income/expense/net totals for
                   the whole filtered set, not just the page
- GET /bank/stats  headline income/expense/net + counts, date span, pending
- GET /bank/facets year list for the period filter
- GET /bank/summary  year and month rollups with a running net-movement figure

Web (/banco): "Movimientos" register + "Resumen por periodo" with year->month
drill-down; added to the AppShell nav as "Chequera".

Deliberately kept OUT of /estado-cuenta: this is the office's own money, not
customer balances, and the two are never summed or shown together.

No category/ramo dimension, and the deferred concept->ramo classifier is
dropped as won't-build: concepto is a payee name (0 of 22,354 match a
category) and TABLA RAMODOS is an expense chart of accounts + owner names,
not the insurance/servicios/fideicomiso split it was assumed to be, so a
classifier would invent data. Single currency (MXN); the "acumulado" is net
movement since the register opened (no opening balance in the source), not a
bank balance. Verified end-to-end in the browser; totals reconcile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 11:32:05 -07:00
co-authored by Claude Opus 4.8
parent 12a1523073
commit db862df8fe
11 changed files with 1434 additions and 14 deletions
+748
View File
@@ -0,0 +1,748 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import {
getBankFacets,
getBankStats,
getBankSummary,
listBankMovements,
} from "@/lib/api";
import {
bankDirectionLabel,
bankSourceLabel,
bankTone,
formatDate,
formatMoney,
formatNumber,
monthName,
} from "@/lib/labels";
import type {
BankCleared,
BankDirection,
BankFacets,
BankListItem,
BankListResponse,
BankSort,
BankStats,
BankSummary,
BankTotals,
} from "@/lib/types";
/**
* Bank register (chequera) browser — plan step 7.
*
* This is the office's OWN checking account, 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:
* - "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 in 2013.
*
* Single currency (MXN) — the source has no currency column. 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 (
<AppShell>
<BankBrowser />
</AppShell>
);
}
function BankBrowser() {
const [stats, setStats] = useState<BankStats | null>(null);
const [facets, setFacets] = useState<BankFacets | null>(null);
const [view, setView] = useState<View>("movimientos");
const [query, setQuery] = useState("");
const [direction, setDirection] = useState<BankDirection | "">("");
const [cleared, setCleared] = useState<BankCleared | "">("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [sort, setSort] = useState<BankSort>("date_desc");
const [movements, setMovements] = useState<BankListResponse | null>(null);
const [summary, setSummary] = useState<BankSummary | null>(null);
const [summaryYear, setSummaryYear] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
getBankStats().then(setStats).catch(() => setStats(null));
getBankFacets().then(setFacets).catch(() => setFacets(null));
}, []);
const runSearch = useCallback(
(p: number) => {
setLoading(true);
setError(null);
listBankMovements({
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);
});
},
[query, direction, cleared, from, to, sort],
);
useEffect(() => {
if (view !== "movimientos") return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => runSearch(1), 280);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [runSearch, view]);
useEffect(() => {
if (view !== "resumen") return;
setLoading(true);
setError(null);
getBankSummary(summaryYear ?? undefined)
.then((res) => {
setSummary(res);
setLoading(false);
})
.catch((e) => {
setError(e?.message ?? "No se pudo cargar el resumen.");
setLoading(false);
});
}, [view, summaryYear]);
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");
}
return (
<>
<div className="page-head rise">
<p className="eyebrow">Cuenta propia de la oficina</p>
<h1 className="page-title">Chequera</h1>
<BankStatStrip
stats={stats}
direction={view === "movimientos" ? direction : ""}
onPickDirection={pickDirection}
/>
<p className="section-note">
Movimientos de la cuenta bancaria de la oficina, en pesos. No forma
parte del estado de cuenta de los clientes y sus cifras no se suman
con las de ellos.
</p>
</div>
<div className="toolbar">
<div className="search-box">
<span className="search-icon" aria-hidden>
</span>
<input
className="input search-input"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar por beneficiario, cheque, nota…"
aria-label="Buscar en la chequera"
disabled={view === "resumen"}
/>
</div>
<div className="seg" role="tablist" aria-label="Vista">
{(
[
{ key: "movimientos" as View, label: "Movimientos" },
{ key: "resumen" as View, label: "Resumen por periodo" },
]
).map((v) => (
<button
key={v.key}
type="button"
role="tab"
aria-selected={view === v.key}
className={`seg-btn ${view === v.key ? "active" : ""}`}
onClick={() => setView(v.key)}
>
{v.label}
</button>
))}
</div>
</div>
{view === "movimientos" && (
<div className="filter-row">
<label className="filter-field">
<span className="filter-label">Movimiento</span>
<select
className="input select"
value={direction}
onChange={(e) =>
setDirection(e.target.value as BankDirection | "")
}
>
{DIRECTIONS.map((d) => (
<option key={d.key} value={d.key}>
{d.label}
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Estatus</span>
<select
className="input select"
value={cleared}
onChange={(e) => setCleared(e.target.value as BankCleared | "")}
>
{CLEARED.map((c) => (
<option key={c.key} value={c.key}>
{c.label}
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Desde</span>
<input
className="input"
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
/>
</label>
<label className="filter-field">
<span className="filter-label">Hasta</span>
<input
className="input"
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
/>
</label>
<label className="filter-field">
<span className="filter-label">Ordenar por</span>
<select
className="input select"
value={sort}
onChange={(e) => setSort(e.target.value as BankSort)}
>
{SORTS.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</label>
{filtered && (
<button
type="button"
className="btn btn-ghost filter-clear"
onClick={clearFilters}
>
Limpiar filtros
</button>
)}
</div>
)}
{view === "resumen" && facets && facets.years.length > 0 && (
<div className="filter-row">
<label className="filter-field">
<span className="filter-label">Año</span>
<select
className="input select"
value={summaryYear ?? ""}
onChange={(e) =>
setSummaryYear(e.target.value ? Number(e.target.value) : null)
}
>
<option value="">Todos los años</option>
{facets.years.map((y) => (
<option key={y.year} value={y.year}>
{y.year} ({formatNumber(y.count)})
</option>
))}
</select>
</label>
</div>
)}
{view === "movimientos" && movements && !loading && !error && (
<div className="result-meta" aria-live="polite">
{movements.total === 0
? "Sin resultados"
: `${formatNumber(movements.total)} ${
movements.total === 1 ? "movimiento" : "movimientos"
}`}
{query ? ` para “${query}` : ""}
</div>
)}
{view === "movimientos" && movements && !loading && (
<FilteredTotals totals={movements.totals} />
)}
{error ? (
<div className="state-error" role="alert">
{error}
</div>
) : loading ? (
<ListSkeleton />
) : view === "resumen" ? (
<SummaryView
summary={summary}
year={summaryYear}
onPickYear={pickYear}
/>
) : movements && movements.total === 0 ? (
<EmptyState query={query} />
) : (
<>
<div className="card">
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Fecha</th>
<th>Cheque / ref.</th>
<th>Beneficiario / concepto</th>
<th>Origen</th>
<th className="num">Monto</th>
</tr>
</thead>
<tbody>
{movements?.items.map((m) => (
<BankRow key={m.id} m={m} />
))}
</tbody>
</table>
</div>
</div>
{movements && movements.pageCount > 1 && (
<Pager
page={movements.page}
pageCount={movements.pageCount}
onChange={goToPage}
/>
)}
</>
)}
</>
);
}
/** Headline figures; the ingreso/egreso cells double as register shortcuts. */
function BankStatStrip({
stats,
direction,
onPickDirection,
}: {
stats: BankStats | null;
direction: BankDirection | "";
onPickDirection: (d: BankDirection) => void;
}) {
if (!stats) {
return (
<div className="stat-strip" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<div className="stat-cell" key={i}>
<div className="skeleton" style={{ height: 25, width: "60%" }} />
<div
className="skeleton"
style={{ height: 11, width: "80%", marginTop: 8 }}
/>
</div>
))}
</div>
);
}
return (
<div className="stat-strip">
<button
type="button"
className={`stat-cell stat-cell-btn accent${
direction === "income" ? " selected" : ""
}`}
onClick={() => onPickDirection("income")}
aria-pressed={direction === "income"}
>
<div className="stat-value tx-amount pos">
{formatMoney(stats.income, "MXN")}
</div>
<div className="stat-label">
En ingresos · {formatNumber(stats.incomeCount)} movimientos
</div>
</button>
<button
type="button"
className={`stat-cell stat-cell-btn${
direction === "expense" ? " selected" : ""
}`}
onClick={() => onPickDirection("expense")}
aria-pressed={direction === "expense"}
>
<div className="stat-value tx-amount neg">
{formatMoney(stats.expense, "MXN")}
</div>
<div className="stat-label">
En egresos · {formatNumber(stats.expenseCount)} movimientos
</div>
</button>
<div className="stat-cell">
<div className="stat-value">{formatMoney(stats.net, "MXN")}</div>
{/* Not the bank balance: the register carries no opening balance. */}
<div className="stat-label">Movimiento neto acumulado</div>
</div>
<div className="stat-cell">
<div className="stat-value">{formatNumber(stats.movements)}</div>
<div className="stat-label">
Movimientos · {formatDate(stats.firstMovement)} a{" "}
{formatDate(stats.lastMovement)}
</div>
</div>
<button
type="button"
className={`stat-cell stat-cell-btn${
direction === "void" ? " selected" : ""
}`}
onClick={() => onPickDirection("void")}
aria-pressed={direction === "void"}
>
<div className="stat-value">{formatNumber(stats.voidCount)}</div>
<div className="stat-label">
Cheques cancelados · {formatNumber(stats.pending)} sin operar
</div>
</button>
</div>
);
}
/** Totals for everything the current filter matched, not just the page. */
function FilteredTotals({ totals }: { totals: BankTotals }) {
if (totals.incomeCount + totals.expenseCount + totals.voidCount === 0)
return null;
return (
<div className="filtered-totals">
<div className="filtered-total">
<span className="filtered-total-cur">MXN</span>
<span>
<strong className="tx-amount pos">
{formatMoney(totals.income, "MXN")}
</strong>{" "}
en ingresos · {formatNumber(totals.incomeCount)}
</span>
<span>
<strong className="tx-amount neg">
{formatMoney(totals.expense, "MXN")}
</strong>{" "}
en egresos · {formatNumber(totals.expenseCount)}
</span>
<span className="filtered-total-net">
Neto <strong>{formatMoney(totals.net, "MXN")}</strong>
</span>
{totals.voidCount > 0 && (
<span>{formatNumber(totals.voidCount)} cancelados</span>
)}
</div>
</div>
);
}
function BankRow({ m }: { m: BankListItem }) {
return (
<tr>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(m.transactionDate)}
</td>
<td className="tx-ref">
{m.reference || "—"}
{!m.cleared && (
<div className="tx-concept">Sin operar</div>
)}
</td>
<td>
{m.concept || <span className="muted">Sin concepto</span>}
{m.notes && <div className="tx-concept">Nota: {m.notes}</div>}
</td>
<td>{bankSourceLabel(m.source)}</td>
<td className="num">
<span className={`tx-amount ${bankTone(m.direction)}`}>
{m.direction === "void" ? "—" : formatMoney(m.amount, "MXN")}
</span>
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
</td>
</tr>
);
}
/**
* 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,
onPickYear,
}: {
summary: BankSummary | null;
year: number | null;
onPickYear: (y: number) => void;
}) {
if (!summary) return null;
return (
<>
<div className="card">
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Año</th>
<th className="num">Movimientos</th>
<th className="num">Ingresos</th>
<th className="num">Egresos</th>
<th className="num">Neto</th>
<th className="num">Acumulado</th>
</tr>
</thead>
<tbody>
{summary.years.map((r) => (
<tr
key={r.period}
onClick={() => onPickYear(r.period)}
style={{ cursor: "pointer" }}
className={year === r.period ? "selected" : undefined}
>
<td className="mono">
<strong>{r.period}</strong>
</td>
<td className="num">{formatNumber(r.count)}</td>
<td className="num">
<span className="tx-amount pos">
{formatMoney(r.income, "MXN")}
</span>
</td>
<td className="num">
<span className="tx-amount neg">
{formatMoney(r.expense, "MXN")}
</span>
</td>
<td className="num">
<span
className={`tx-amount ${
Number(r.net) < 0 ? "neg" : "pos"
}`}
>
{formatMoney(r.net, "MXN")}
</span>
</td>
<td className="num mono">{formatMoney(r.cumulative, "MXN")}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<p className="section-note">
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.
</p>
{year && summary.months.length > 0 && (
<div className="section">
<div className="section-head">
<span className="section-rule cuenta" />
<h2 className="section-title">Meses de {year}</h2>
<span className="section-count">
abre en {formatMoney(summary.opening, "MXN")}
</span>
</div>
<div className="card">
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Mes</th>
<th className="num">Movimientos</th>
<th className="num">Ingresos</th>
<th className="num">Egresos</th>
<th className="num">Neto</th>
<th className="num">Acumulado</th>
</tr>
</thead>
<tbody>
{summary.months.map((r) => (
<tr key={r.period}>
<td>{monthName(r.period)}</td>
<td className="num">{formatNumber(r.count)}</td>
<td className="num">
<span className="tx-amount pos">
{formatMoney(r.income, "MXN")}
</span>
</td>
<td className="num">
<span className="tx-amount neg">
{formatMoney(r.expense, "MXN")}
</span>
</td>
<td className="num">
<span
className={`tx-amount ${
Number(r.net) < 0 ? "neg" : "pos"
}`}
>
{formatMoney(r.net, "MXN")}
</span>
</td>
<td className="num mono">
{formatMoney(r.cumulative, "MXN")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</>
);
}
function Pager({
page,
pageCount,
onChange,
}: {
page: number;
pageCount: number;
onChange: (p: number) => void;
}) {
return (
<nav className="pager" aria-label="Paginación">
<button
type="button"
className="btn btn-outline"
onClick={() => onChange(page - 1)}
disabled={page <= 1}
>
Anterior
</button>
<span className="pager-info">
Página <strong>{page}</strong> de {pageCount}
</span>
<button
type="button"
className="btn btn-outline"
onClick={() => onChange(page + 1)}
disabled={page >= pageCount}
>
Siguiente
</button>
</nav>
);
}
function ListSkeleton() {
return (
<div className="cust-list" aria-hidden>
{Array.from({ length: 8 }).map((_, i) => (
<div className="skeleton skel-row" key={i} />
))}
</div>
);
}
function EmptyState({ query }: { query: string }) {
return (
<div className="state-box">
<div className="state-glyph" aria-hidden>
</div>
<h3>Sin resultados</h3>
<p>
{query
? `No encontramos movimientos para “${query}”.`
: "No hay movimientos que coincidan con los filtros."}
</p>
</div>
);
}