The office keeps more than one operating account (Utilities banks in MXN, Seguros in USD), but bank_transactions was a single implicit MXN register by design. Adds Bank/BankAccount and makes every read and write in the module scoped to exactly one account. Schema: - Bank / BankAccount. Currency is fixed per account and BankTransaction has no currency column of its own — a movement inherits its account's, the way a real bank account doesn't mix currencies. - BankTransaction.bankAccountId, required. A movement with no known account isn't reconcilable against a statement. - @@index([bankAccountId, transactionDate]): every read now filters by account and orders/groups by date. Migration: - backfill_bank_accounts.py seeds Scotiabank + "Utilities — Scotiabank (MXN)" and backfills all 22,669 existing rows onto it, then promotes the column to NOT NULL and attaches the FK. Standalone because prisma db push cannot add a required column to a populated table. Idempotent; re-running once a second account exists does not re-point rows. - run_all.py runs it (both modes) before transform_bank.py, which now resolves the account by label and fails fast if it is missing. API: - ?bankAccountId= required on list/stats/facets/summary — not optional with an "all accounts" default, since summing an MXN and a USD register repeats the currency-collapsing mistake the billing module exists to prevent. Missing is 400, unknown is 404. - facets() had no account clause at all and summary() has two raw-SQL rollups; all three are now parameterised. Scoping only one of summary's queries would leave the year list and its drill-down describing different books. - New bank/accounts + bank/banks sub-resource under a MANAGER bank:manage-accounts ability. currency is absent from the update DTO: booked movements are denominated in it, so editing would re-denominate history. Capture into a closed account is rejected. Web: - /banco gains an account picker (remembered per browser) and reads every figure in the selected account's currency; the "single currency (MXN)" doc-comment and the hardcoded MXN formatting are gone. - New /banco/cuentas for banks and accounts. Accounts are closed, never deleted — the FK is required, so deleting one would destroy its register. - /inicio's chequera card names the account it is reading instead of implying a single register. Verified against dev + browser: a second USD account showed full read/write isolation from the MXN register, whose totals were unchanged (22,669 movements, net 1,014,266.97). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1229 lines
37 KiB
TypeScript
1229 lines
37 KiB
TypeScript
"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 (
|
|
<AppShell>
|
|
<BankBrowser />
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
/** 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<BankAccount[] | null>(null);
|
|
const [accountId, setAccountId] = useState<string | null>(null);
|
|
const [accountsError, setAccountsError] = useState<string | null>(null);
|
|
|
|
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 [captureOpen, setCaptureOpen] = useState(false);
|
|
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
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 (
|
|
<>
|
|
<div className="page-head">
|
|
<h1 className="page-title">Chequera</h1>
|
|
</div>
|
|
<div className="state-error" role="alert">
|
|
{accountsError}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// No chequera on file: the register has nothing it could be scoped to.
|
|
if (accounts && accounts.length === 0) {
|
|
return (
|
|
<>
|
|
<div className="page-head">
|
|
<p className="eyebrow">Cuentas propias de la oficina</p>
|
|
<h1 className="page-title">Chequera</h1>
|
|
</div>
|
|
<div className="state-box">
|
|
<div className="state-glyph" aria-hidden>
|
|
⌗
|
|
</div>
|
|
<h3>Sin cuentas registradas</h3>
|
|
<p>
|
|
{canManageAccounts ? (
|
|
<>
|
|
Registra una cuenta bancaria en{" "}
|
|
<Link href="/banco/cuentas">Cuentas de chequera</Link> para
|
|
empezar a capturar movimientos.
|
|
</>
|
|
) : (
|
|
"Pide a un administrador que registre una cuenta bancaria."
|
|
)}
|
|
</p>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="page-head rise">
|
|
<p className="eyebrow">Cuenta propia de la oficina</p>
|
|
<h1 className="page-title">Chequera</h1>
|
|
<AccountPicker
|
|
accounts={accounts}
|
|
accountId={accountId}
|
|
onPick={pickAccount}
|
|
canManageAccounts={canManageAccounts}
|
|
/>
|
|
<BankStatStrip
|
|
stats={stats}
|
|
currency={currency}
|
|
direction={view === "movimientos" ? direction : ""}
|
|
onPickDirection={pickDirection}
|
|
/>
|
|
<p className="section-note">
|
|
Movimientos de{" "}
|
|
<strong>{account ? account.label : "la cuenta seleccionada"}</strong>,
|
|
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.
|
|
</p>
|
|
<div style={{ marginTop: 8 }}>
|
|
<ContextReports
|
|
entries={[
|
|
{ slug: "reporte-de-efectivo", label: "Reporte de efectivo" },
|
|
]}
|
|
/>
|
|
</div>
|
|
</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>
|
|
{view === "movimientos" && canCapture && account?.active && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={() => setCaptureOpen((v) => !v)}
|
|
>
|
|
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{account && !account.active && (
|
|
<div className="section-note">
|
|
Esta cuenta está cerrada: su historial se consulta, pero no admite
|
|
movimientos nuevos.
|
|
</div>
|
|
)}
|
|
|
|
{view === "movimientos" && captureOpen && account && (
|
|
<BankCaptureForm
|
|
account={account}
|
|
onSaved={() => {
|
|
setCaptureOpen(false);
|
|
runSearch(movements?.page ?? 1);
|
|
refreshStats();
|
|
}}
|
|
onCancel={() => setCaptureOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{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} currency={currency} />
|
|
)}
|
|
|
|
{error ? (
|
|
<div className="state-error" role="alert">
|
|
{error}
|
|
</div>
|
|
) : loading ? (
|
|
<ListSkeleton />
|
|
) : view === "resumen" ? (
|
|
<SummaryView
|
|
summary={summary}
|
|
year={summaryYear}
|
|
currency={currency}
|
|
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>
|
|
{canVoid && (
|
|
<th style={{ width: 1, whiteSpace: "nowrap" }}>
|
|
Acciones
|
|
</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{movements?.items.map((m) => (
|
|
<BankRow
|
|
key={m.id}
|
|
m={m}
|
|
currency={currency}
|
|
canVoid={canVoid}
|
|
onVoided={() => {
|
|
runSearch(movements?.page ?? 1);
|
|
refreshStats();
|
|
}}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{movements && movements.pageCount > 1 && (
|
|
<Pager
|
|
page={movements.page}
|
|
pageCount={movements.pageCount}
|
|
onChange={goToPage}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div className="skeleton" style={{ height: 34, width: 260, marginTop: 12 }} />
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="filter-row"
|
|
style={{ marginTop: 12, alignItems: "flex-end" }}
|
|
>
|
|
<label className="filter-field">
|
|
<span className="filter-label">Cuenta</span>
|
|
<select
|
|
className="input select"
|
|
value={accountId ?? ""}
|
|
onChange={(e) => onPick(e.target.value)}
|
|
aria-label="Cuenta de chequera"
|
|
>
|
|
{accounts.map((a) => (
|
|
<option key={a.id} value={a.id}>
|
|
{a.label} · {a.currency}
|
|
{a.active ? "" : " (cerrada)"}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{canManageAccounts && (
|
|
<Link href="/banco/cuentas" className="btn btn-ghost">
|
|
Administrar cuentas
|
|
</Link>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<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, currency)}
|
|
</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, currency)}
|
|
</div>
|
|
<div className="stat-label">
|
|
En egresos · {formatNumber(stats.expenseCount)} movimientos
|
|
</div>
|
|
</button>
|
|
<div className="stat-cell">
|
|
<div className="stat-value">{formatMoney(stats.net, currency)}</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,
|
|
currency,
|
|
}: {
|
|
totals: BankTotals;
|
|
currency: Currency;
|
|
}) {
|
|
if (totals.incomeCount + totals.expenseCount + totals.voidCount === 0)
|
|
return null;
|
|
return (
|
|
<div className="filtered-totals">
|
|
<div className="filtered-total">
|
|
<span className="filtered-total-cur">{currency}</span>
|
|
<span>
|
|
<strong className="tx-amount pos">
|
|
{formatMoney(totals.income, currency)}
|
|
</strong>{" "}
|
|
en ingresos · {formatNumber(totals.incomeCount)}
|
|
</span>
|
|
<span>
|
|
<strong className="tx-amount neg">
|
|
{formatMoney(totals.expense, currency)}
|
|
</strong>{" "}
|
|
en egresos · {formatNumber(totals.expenseCount)}
|
|
</span>
|
|
<span className="filtered-total-net">
|
|
Neto <strong>{formatMoney(totals.net, currency)}</strong>
|
|
</span>
|
|
{totals.voidCount > 0 && (
|
|
<span>{formatNumber(totals.voidCount)} cancelados</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
|
|
<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, currency)}
|
|
</span>
|
|
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
|
|
</td>
|
|
{canVoid && (
|
|
<td style={{ whiteSpace: "nowrap" }}>
|
|
{!m.voided && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
style={{ padding: "4px 10px", fontSize: 12 }}
|
|
onClick={doVoid}
|
|
disabled={busy}
|
|
>
|
|
{busy ? "Anulando…" : "Anular"}
|
|
</button>
|
|
)}
|
|
</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,
|
|
currency,
|
|
onPickYear,
|
|
}: {
|
|
summary: BankSummary | null;
|
|
year: number | null;
|
|
currency: Currency;
|
|
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, currency)}
|
|
</span>
|
|
</td>
|
|
<td className="num">
|
|
<span className="tx-amount neg">
|
|
{formatMoney(r.expense, currency)}
|
|
</span>
|
|
</td>
|
|
<td className="num">
|
|
<span
|
|
className={`tx-amount ${
|
|
Number(r.net) < 0 ? "neg" : "pos"
|
|
}`}
|
|
>
|
|
{formatMoney(r.net, currency)}
|
|
</span>
|
|
</td>
|
|
<td className="num mono">{formatMoney(r.cumulative, currency)}</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, currency)}
|
|
</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, currency)}
|
|
</span>
|
|
</td>
|
|
<td className="num">
|
|
<span className="tx-amount neg">
|
|
{formatMoney(r.expense, currency)}
|
|
</span>
|
|
</td>
|
|
<td className="num">
|
|
<span
|
|
className={`tx-amount ${
|
|
Number(r.net) < 0 ? "neg" : "pos"
|
|
}`}
|
|
>
|
|
{formatMoney(r.net, currency)}
|
|
</span>
|
|
</td>
|
|
<td className="num mono">
|
|
{formatMoney(r.cumulative, currency)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 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<BankDirection>("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<string | null>(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 (
|
|
<form onSubmit={submit}>
|
|
{error && <div className="state-box state-error">{error}</div>}
|
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
|
<h2 className="section-title" style={{ marginBottom: 4 }}>
|
|
Capturar movimiento de chequera
|
|
</h2>
|
|
<p className="section-note" style={{ marginBottom: 14 }}>
|
|
Se registra en <strong>{account.label}</strong>, en {account.currency}.
|
|
</p>
|
|
<div className="form-grid">
|
|
<label className="field">
|
|
<span className="field-label">
|
|
Tipo <span aria-hidden>*</span>
|
|
</span>
|
|
<select
|
|
className="select"
|
|
value={direction}
|
|
onChange={(e) => setDirection(e.target.value as BankDirection)}
|
|
>
|
|
<option value="income">Ingreso</option>
|
|
<option value="expense">Egreso</option>
|
|
</select>
|
|
</label>
|
|
<label className="field">
|
|
<span className="field-label">
|
|
Fecha <span aria-hidden>*</span>
|
|
</span>
|
|
<input
|
|
className="input"
|
|
type="date"
|
|
required
|
|
value={transactionDate}
|
|
onChange={(e) => setTransactionDate(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span className="field-label">
|
|
Monto ({account.currency}) <span aria-hidden>*</span>
|
|
</span>
|
|
<input
|
|
className="input"
|
|
type="number"
|
|
step="0.01"
|
|
required
|
|
min="0"
|
|
value={amount}
|
|
onChange={(e) => setAmount(e.target.value)}
|
|
placeholder="0.00"
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span className="field-label">Concepto</span>
|
|
<input
|
|
className="input"
|
|
value={concept}
|
|
onChange={(e) => setConcept(e.target.value)}
|
|
placeholder="Beneficiario o motivo"
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span className="field-label">Referencia / cheque</span>
|
|
<input
|
|
className="input"
|
|
value={reference}
|
|
onChange={(e) => setReference(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span className="field-label">Tipo en origen</span>
|
|
<input
|
|
className="input"
|
|
value={transactionType}
|
|
onChange={(e) => setTransactionType(e.target.value)}
|
|
placeholder="INGRESO / EGRESO"
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span className="field-label">Monto en letras</span>
|
|
<input
|
|
className="input"
|
|
value={amountInWords}
|
|
onChange={(e) => setAmountInWords(e.target.value)}
|
|
placeholder="Ej. CIENTO CINCUENTA MIL PESOS 00/100"
|
|
/>
|
|
</label>
|
|
<label
|
|
className="field"
|
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={cleared}
|
|
onChange={(e) => setCleared(e.target.checked)}
|
|
/>
|
|
<span className="field-label" style={{ margin: 0 }}>
|
|
Operado por el banco
|
|
</span>
|
|
</label>
|
|
<label
|
|
className="field"
|
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={transferred}
|
|
onChange={(e) => setTransferred(e.target.checked)}
|
|
/>
|
|
<span className="field-label" style={{ margin: 0 }}>
|
|
Transferencia
|
|
</span>
|
|
</label>
|
|
</div>
|
|
<label className="field" style={{ marginTop: 16 }}>
|
|
<span className="field-label">Notas</span>
|
|
<textarea
|
|
className="input"
|
|
rows={2}
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="form-actions">
|
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
|
{saving ? "Guardando…" : "Capturar movimiento"}
|
|
</button>
|
|
<button type="button" className="btn btn-outline" onClick={onCancel}>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|