"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) => (
))}
{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 (
{/* 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 (
);
}
/**
* 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 (
<>
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)}
Mes
Movimientos
Ingresos
Egresos
Neto
Acumulado
{summary.months.map((r) => (
{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 (
);
}
function Pager({
page,
pageCount,
onChange,
}: {
page: number;
pageCount: number;
onChange: (p: number) => void;
}) {
return (
);
}
function ListSkeleton() {
return (