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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1344,6 +1344,11 @@ button {
|
||||
.tx-table tr:hover td {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
/* Drill-down rows (the chequera's yearly summary) read as pressable. */
|
||||
.tx-table tr.selected td {
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 2px 0 0 var(--brand-600);
|
||||
}
|
||||
.tx-domain-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const NAV = [
|
||||
{ href: "/servicios", label: "Propiedades" },
|
||||
{ href: "/polizas", label: "Pólizas" },
|
||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||
{ href: "/banco", label: "Chequera" },
|
||||
];
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -6,6 +6,13 @@ import type {
|
||||
BalanceFilter,
|
||||
BalanceListResponse,
|
||||
BalanceSort,
|
||||
BankCleared,
|
||||
BankDirection,
|
||||
BankFacets,
|
||||
BankListResponse,
|
||||
BankSort,
|
||||
BankStats,
|
||||
BankSummary,
|
||||
BillingFacets,
|
||||
BillingStats,
|
||||
BusinessLine,
|
||||
@@ -294,3 +301,43 @@ export function getBillingFacets(): Promise<BillingFacets> {
|
||||
export function getStatement(customerId: string): Promise<Statement> {
|
||||
return apiFetch<Statement>(`/billing/customers/${customerId}`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Bank register (chequera) */
|
||||
|
||||
export interface BankQuery {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
direction?: BankDirection;
|
||||
cleared?: BankCleared;
|
||||
/** `YYYY-MM-DD`, inclusive on both ends. */
|
||||
from?: string;
|
||||
to?: string;
|
||||
sort?: BankSort;
|
||||
}
|
||||
|
||||
export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (q.query) params.set("query", q.query);
|
||||
if (q.page) params.set("page", String(q.page));
|
||||
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
||||
if (q.direction) params.set("direction", q.direction);
|
||||
if (q.cleared) params.set("cleared", q.cleared);
|
||||
if (q.from) params.set("from", q.from);
|
||||
if (q.to) params.set("to", q.to);
|
||||
if (q.sort) params.set("sort", q.sort);
|
||||
const qs = params.toString();
|
||||
return apiFetch<BankListResponse>(`/bank${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export function getBankStats(): Promise<BankStats> {
|
||||
return apiFetch<BankStats>("/bank/stats");
|
||||
}
|
||||
|
||||
export function getBankFacets(): Promise<BankFacets> {
|
||||
return apiFetch<BankFacets>("/bank/facets");
|
||||
}
|
||||
|
||||
export function getBankSummary(year?: number): Promise<BankSummary> {
|
||||
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Spanish label maps + formatting helpers. Single source of truth for i18n.
|
||||
|
||||
import type {
|
||||
BankDirection,
|
||||
LedgerDirection,
|
||||
PolicyStatus,
|
||||
ServiceKind,
|
||||
@@ -219,6 +220,59 @@ export function balanceTone(balance: string | number): "owing" | "credit" | "fla
|
||||
return n < 0 ? "owing" : "credit";
|
||||
}
|
||||
|
||||
// ----- chequera / bank register -----
|
||||
|
||||
/**
|
||||
* The office's own account, so the words are the bank's, not the ledger's:
|
||||
* an ingreso is money arriving, an egreso money leaving, and a zero-amount row
|
||||
* is a cheque that was voided.
|
||||
*/
|
||||
export const BANK_DIRECTION_LABELS: Record<BankDirection, string> = {
|
||||
income: "Ingreso",
|
||||
expense: "Egreso",
|
||||
void: "Cancelado",
|
||||
};
|
||||
|
||||
export function bankDirectionLabel(d: BankDirection): string {
|
||||
return BANK_DIRECTION_LABELS[d] ?? d;
|
||||
}
|
||||
|
||||
/** CSS-class suffix for colouring a bank figure, matching `.tx-amount`. */
|
||||
export function bankTone(d: BankDirection): "pos" | "neg" | "" {
|
||||
if (d === "income") return "pos";
|
||||
return d === "expense" ? "neg" : "";
|
||||
}
|
||||
|
||||
/** Legacy SCOTHIA table a register row came from. */
|
||||
export const BANK_SOURCE_LABELS: Record<string, string> = {
|
||||
"DATOS I": "Ingresos",
|
||||
"DATOS E": "Egresos",
|
||||
};
|
||||
|
||||
export function bankSourceLabel(source: string | null | undefined): string {
|
||||
if (!source) return "—";
|
||||
return BANK_SOURCE_LABELS[source] ?? source;
|
||||
}
|
||||
|
||||
export const MONTH_NAMES = [
|
||||
"Enero",
|
||||
"Febrero",
|
||||
"Marzo",
|
||||
"Abril",
|
||||
"Mayo",
|
||||
"Junio",
|
||||
"Julio",
|
||||
"Agosto",
|
||||
"Septiembre",
|
||||
"Octubre",
|
||||
"Noviembre",
|
||||
"Diciembre",
|
||||
];
|
||||
|
||||
export function monthName(month: number): string {
|
||||
return MONTH_NAMES[month - 1] ?? String(month);
|
||||
}
|
||||
|
||||
// ----- formatting -----
|
||||
|
||||
export function formatMoney(
|
||||
|
||||
@@ -671,3 +671,91 @@ export interface CustomerDetail {
|
||||
transactions: Transaction[];
|
||||
transactionSummary: TransactionSummaryRow[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Bank register (chequera) */
|
||||
|
||||
/**
|
||||
* The office's own checking account. Single-currency (MXN) and with no customer
|
||||
* link — see `bank.service.ts`. Positive is a deposit, negative a payment, and
|
||||
* exactly zero a cancelled cheque.
|
||||
*/
|
||||
export type BankDirection = "income" | "expense" | "void";
|
||||
|
||||
export type BankCleared = "cleared" | "pending";
|
||||
|
||||
export type BankSort =
|
||||
| "date_desc"
|
||||
| "date_asc"
|
||||
| "amount_desc"
|
||||
| "amount_asc"
|
||||
| "reference";
|
||||
|
||||
export interface BankListItem {
|
||||
id: string;
|
||||
transactionDate: string;
|
||||
/** "INGRESO" / "EGRESO" as recorded in the source. */
|
||||
transactionType: string | null;
|
||||
/** Cheque number on egresos, deposit slip on ingresos. */
|
||||
reference: string | null;
|
||||
/** The payee or payer — a name, not a category. */
|
||||
concept: string | null;
|
||||
amount: string;
|
||||
direction: BankDirection;
|
||||
cleared: boolean;
|
||||
transferred: boolean;
|
||||
notes: string | null;
|
||||
/** "CIENTO CINCUENTA MIL PESOS 00/100" — egresos only. */
|
||||
amountInWords: string | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
export interface BankTotals {
|
||||
income: string;
|
||||
incomeCount: number;
|
||||
expense: string;
|
||||
expenseCount: number;
|
||||
net: string;
|
||||
voidCount: number;
|
||||
}
|
||||
|
||||
export interface BankListResponse {
|
||||
items: BankListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
/** Totals for the whole filtered set, not just the current page. */
|
||||
totals: BankTotals;
|
||||
}
|
||||
|
||||
export interface BankStats extends BankTotals {
|
||||
movements: number;
|
||||
firstMovement: string | null;
|
||||
lastMovement: string | null;
|
||||
/** `operado` is false — recorded but not yet cleared by the bank. */
|
||||
pending: number;
|
||||
transferred: number;
|
||||
}
|
||||
|
||||
export interface BankFacets {
|
||||
years: { year: number; count: number }[];
|
||||
}
|
||||
|
||||
export interface BankPeriodRow {
|
||||
/** Year, or month number 1–12 in the monthly table. */
|
||||
period: number;
|
||||
count: number;
|
||||
income: string;
|
||||
expense: string;
|
||||
net: string;
|
||||
/** Net movement since the register opened — not a bank balance. */
|
||||
cumulative: string;
|
||||
}
|
||||
|
||||
export interface BankSummary {
|
||||
year: number | null;
|
||||
years: (BankPeriodRow & { opening: string })[];
|
||||
months: BankPeriodRow[];
|
||||
/** Cumulative figure the selected year opened on. */
|
||||
opening: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user