feat(bank): multi-bank chequera — required bankAccountId, per-account scoping
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>
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import {
|
||||
createBankAccount,
|
||||
createBankInstitution,
|
||||
listBankAccounts,
|
||||
listBankInstitutions,
|
||||
updateBankAccount,
|
||||
updateBankInstitution,
|
||||
} from "@/lib/api";
|
||||
import { domainLabel } from "@/lib/labels";
|
||||
import type {
|
||||
BankAccount,
|
||||
BankInstitution,
|
||||
Currency,
|
||||
TransactionDomain,
|
||||
} from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Chequera accounts admin — docs/RECEIPT_CAPTURE_SPEC.md §3.
|
||||
*
|
||||
* Two levels: the bank (institution) and the accounts held at it. Opening an
|
||||
* account is rare and consequential — its currency is what every movement
|
||||
* booked into it is denominated in, and it can't be changed afterwards without
|
||||
* silently re-denominating history, so the edit form deliberately has no
|
||||
* currency field.
|
||||
*
|
||||
* Accounts are never deleted: `bank_transactions.bankAccountId` is a required
|
||||
* FK, so a used account can't be removed without destroying its register.
|
||||
* Closing one (`active: false`) hides it from new captures while leaving the
|
||||
* history readable, matching this app's never-hard-delete convention.
|
||||
*/
|
||||
const CURRENCIES: Currency[] = ["MXN", "USD"];
|
||||
const BUSINESS_LINES: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
||||
|
||||
export default function CuentasChequeraPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Cuentas />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Cuentas() {
|
||||
const canEdit = useCan("bank:manage-accounts");
|
||||
const [banks, setBanks] = useState<BankInstitution[] | null>(null);
|
||||
const [accounts, setAccounts] = useState<BankAccount[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function reload() {
|
||||
Promise.all([listBankInstitutions(), listBankAccounts()])
|
||||
.then(([b, a]) => {
|
||||
setBanks(b);
|
||||
setAccounts(a);
|
||||
})
|
||||
.catch((e) => setError(e?.message ?? "No se pudieron cargar las cuentas."));
|
||||
}
|
||||
useEffect(reload, []);
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<h1 className="page-title">Cuentas de chequera</h1>
|
||||
</div>
|
||||
<div className="state-box state-error">
|
||||
No tiene permisos para administrar cuentas bancarias.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<p className="eyebrow">
|
||||
<Link href="/banco">Chequera</Link>
|
||||
</p>
|
||||
<h1 className="page-title">Cuentas de chequera</h1>
|
||||
<p className="section-note">
|
||||
Cada cuenta es una chequera física y se lleva por separado. La moneda
|
||||
se fija al darla de alta porque todos sus movimientos quedan
|
||||
registrados en ella; para cambiarla hay que abrir otra cuenta. Las
|
||||
cuentas no se eliminan: se cierran, y su historial sigue consultable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
{!banks || !accounts ? (
|
||||
<div className="empty-inline">
|
||||
<span className="spinner" aria-label="Cargando" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<BanksSection banks={banks} onChanged={reload} />
|
||||
<AccountsSection
|
||||
banks={banks}
|
||||
accounts={accounts}
|
||||
onChanged={reload}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ banks */
|
||||
|
||||
function BanksSection({
|
||||
banks,
|
||||
onChanged,
|
||||
}: {
|
||||
banks: BankInstitution[];
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [country, setCountry] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function startAdd() {
|
||||
setEditingId(null);
|
||||
setAdding(true);
|
||||
setName("");
|
||||
setCountry("");
|
||||
}
|
||||
function startEdit(b: BankInstitution) {
|
||||
setAdding(false);
|
||||
setEditingId(b.id);
|
||||
setName(b.name);
|
||||
setCountry(b.country ?? "");
|
||||
}
|
||||
function cancel() {
|
||||
setAdding(false);
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) {
|
||||
window.alert("El nombre del banco es obligatorio.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = { name: name.trim(), country: country.trim() };
|
||||
if (editingId) await updateBankInstitution(editingId, payload);
|
||||
else await createBankInstitution(payload);
|
||||
cancel();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
window.alert((e as Error)?.message ?? "No se pudo guardar el banco.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const editor = (
|
||||
<div className="child-editor">
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
Banco <span aria-hidden>*</span>
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Ej. Scotiabank"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">País</span>
|
||||
<input
|
||||
className="input"
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
placeholder="MX / US"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={cancel}>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||
<div className="child-head">
|
||||
<h3 className="section-title" style={{ margin: 0 }}>
|
||||
Bancos
|
||||
<span className="section-count"> {banks.length}</span>
|
||||
</h3>
|
||||
{!adding && editingId === null && (
|
||||
<button type="button" className="btn btn-outline" onClick={startAdd}>
|
||||
+ Agregar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{banks.length === 0 && !adding ? (
|
||||
<div className="empty-inline">Sin bancos registrados.</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Banco</th>
|
||||
<th>País</th>
|
||||
<th className="num">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adding && (
|
||||
<tr>
|
||||
<td colSpan={3}>{editor}</td>
|
||||
</tr>
|
||||
)}
|
||||
{banks.map((b) =>
|
||||
editingId === b.id ? (
|
||||
<tr key={b.id}>
|
||||
<td colSpan={3}>{editor}</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={b.id}>
|
||||
<td>{b.name}</td>
|
||||
<td>{b.country || "—"}</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => startEdit(b)}
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- accounts */
|
||||
|
||||
function AccountsSection({
|
||||
banks,
|
||||
accounts,
|
||||
onChanged,
|
||||
}: {
|
||||
banks: BankInstitution[];
|
||||
accounts: BankAccount[];
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [bankId, setBankId] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [currency, setCurrency] = useState<Currency>("MXN");
|
||||
const [businessLine, setBusinessLine] = useState<string>("");
|
||||
const [active, setActive] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function startAdd() {
|
||||
setEditingId(null);
|
||||
setAdding(true);
|
||||
setBankId(banks[0]?.id ?? "");
|
||||
setLabel("");
|
||||
setCurrency("MXN");
|
||||
setBusinessLine("");
|
||||
setActive(true);
|
||||
}
|
||||
function startEdit(a: BankAccount) {
|
||||
setAdding(false);
|
||||
setEditingId(a.id);
|
||||
setBankId(a.bankId);
|
||||
setLabel(a.label);
|
||||
setCurrency(a.currency);
|
||||
setBusinessLine(a.businessLine ?? "");
|
||||
setActive(a.active);
|
||||
}
|
||||
function cancel() {
|
||||
setAdding(false);
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!bankId) {
|
||||
window.alert("Selecciona el banco de la cuenta.");
|
||||
return;
|
||||
}
|
||||
if (!label.trim()) {
|
||||
window.alert("El nombre de la cuenta es obligatorio.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const line = businessLine
|
||||
? (businessLine as TransactionDomain)
|
||||
: undefined;
|
||||
if (editingId) {
|
||||
// No `currency`: see the file header.
|
||||
await updateBankAccount(editingId, {
|
||||
bankId,
|
||||
label: label.trim(),
|
||||
businessLine: line,
|
||||
active,
|
||||
});
|
||||
} else {
|
||||
await createBankAccount({
|
||||
bankId,
|
||||
label: label.trim(),
|
||||
currency,
|
||||
businessLine: line,
|
||||
active,
|
||||
});
|
||||
}
|
||||
cancel();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
window.alert((e as Error)?.message ?? "No se pudo guardar la cuenta.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const editor = (
|
||||
<div className="child-editor">
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
Banco <span aria-hidden>*</span>
|
||||
</span>
|
||||
<select
|
||||
className="select"
|
||||
value={bankId}
|
||||
onChange={(e) => setBankId(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{banks.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
Nombre de la cuenta <span aria-hidden>*</span>
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Ej. Seguros — Bank of America (USD)"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
Moneda <span aria-hidden>*</span>
|
||||
</span>
|
||||
<select
|
||||
className="select"
|
||||
value={currency}
|
||||
disabled={editingId !== null}
|
||||
onChange={(e) => setCurrency(e.target.value as Currency)}
|
||||
>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{editingId !== null && (
|
||||
<span className="section-note">
|
||||
No se puede cambiar: los movimientos ya registrados están en esta
|
||||
moneda.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Línea de negocio</span>
|
||||
<select
|
||||
className="select"
|
||||
value={businessLine}
|
||||
onChange={(e) => setBusinessLine(e.target.value)}
|
||||
>
|
||||
<option value="">Sin asignar</option>
|
||||
{BUSINESS_LINES.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{domainLabel(d)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="section-note">
|
||||
Referencia nada más: una chequera puede pagar de varias líneas.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="field"
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={(e) => setActive(e.target.checked)}
|
||||
/>
|
||||
<span className="field-label" style={{ margin: 0 }}>
|
||||
Cuenta abierta
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={cancel}>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||
<div className="child-head">
|
||||
<h3 className="section-title" style={{ margin: 0 }}>
|
||||
Cuentas
|
||||
<span className="section-count"> {accounts.length}</span>
|
||||
</h3>
|
||||
{!adding && editingId === null && banks.length > 0 && (
|
||||
<button type="button" className="btn btn-outline" onClick={startAdd}>
|
||||
+ Agregar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{banks.length === 0 ? (
|
||||
<div className="empty-inline">
|
||||
Registra primero el banco donde está la cuenta.
|
||||
</div>
|
||||
) : accounts.length === 0 && !adding ? (
|
||||
<div className="empty-inline">Sin cuentas registradas.</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cuenta</th>
|
||||
<th>Banco</th>
|
||||
<th>Moneda</th>
|
||||
<th>Línea</th>
|
||||
<th>Estatus</th>
|
||||
<th className="num">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adding && (
|
||||
<tr>
|
||||
<td colSpan={6}>{editor}</td>
|
||||
</tr>
|
||||
)}
|
||||
{accounts.map((a) =>
|
||||
editingId === a.id ? (
|
||||
<tr key={a.id}>
|
||||
<td colSpan={6}>{editor}</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={a.id}>
|
||||
<td>{a.label}</td>
|
||||
<td>{a.bankName}</td>
|
||||
<td className="mono">{a.currency}</td>
|
||||
<td>{a.businessLine ? domainLabel(a.businessLine) : "—"}</td>
|
||||
<td>{a.active ? "Abierta" : "Cerrada"}</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => startEdit(a)}
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
<Link href="/banco" className="btn btn-ghost">
|
||||
Ver movimientos
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+243
-47
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ContextReports } from "@/components/ContextReports";
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
getBankFacets,
|
||||
getBankStats,
|
||||
getBankSummary,
|
||||
listBankAccounts,
|
||||
listBankMovements,
|
||||
voidBankMovement,
|
||||
} from "@/lib/api";
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
monthName,
|
||||
} from "@/lib/labels";
|
||||
import type {
|
||||
BankAccount,
|
||||
BankCleared,
|
||||
BankDirection,
|
||||
BankFacets,
|
||||
@@ -32,23 +35,28 @@ import type {
|
||||
BankSummary,
|
||||
BankTotals,
|
||||
CreateBankMovementInput,
|
||||
Currency,
|
||||
} from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Bank register (chequera) browser — plan step 7.
|
||||
* Bank register (chequera) browser — plan step 7, multi-account since the
|
||||
* step-11 multi-bank work.
|
||||
*
|
||||
* This is the office's OWN checking account, not customer money. It is a
|
||||
* 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:
|
||||
* 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 in 2013.
|
||||
* with the running net movement since the register opened.
|
||||
*
|
||||
* 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.
|
||||
* 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";
|
||||
|
||||
@@ -81,9 +89,18 @@ export default function BancoPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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");
|
||||
@@ -104,16 +121,66 @@ function BankBrowser() {
|
||||
|
||||
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(() => {
|
||||
getBankStats().then(setStats).catch(() => setStats(null));
|
||||
getBankFacets().then(setFacets).catch(() => setFacets(null));
|
||||
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,
|
||||
@@ -132,23 +199,23 @@ function BankBrowser() {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[query, direction, cleared, from, to, sort],
|
||||
[accountId, query, direction, cleared, from, to, sort],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "movimientos") return;
|
||||
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]);
|
||||
}, [runSearch, view, accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "resumen") return;
|
||||
if (view !== "resumen" || !accountId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getBankSummary(summaryYear ?? undefined)
|
||||
getBankSummary(accountId, summaryYear ?? undefined)
|
||||
.then((res) => {
|
||||
setSummary(res);
|
||||
setLoading(false);
|
||||
@@ -157,7 +224,7 @@ function BankBrowser() {
|
||||
setError(e?.message ?? "No se pudo cargar el resumen.");
|
||||
setLoading(false);
|
||||
});
|
||||
}, [view, summaryYear]);
|
||||
}, [view, summaryYear, accountId]);
|
||||
|
||||
function goToPage(p: number) {
|
||||
runSearch(p);
|
||||
@@ -194,20 +261,71 @@ function BankBrowser() {
|
||||
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 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.
|
||||
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
|
||||
@@ -252,7 +370,7 @@ function BankBrowser() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{view === "movimientos" && canCapture && (
|
||||
{view === "movimientos" && canCapture && account?.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
@@ -263,12 +381,20 @@ function BankBrowser() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === "movimientos" && captureOpen && (
|
||||
{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);
|
||||
getBankStats().then(setStats).catch(() => setStats(null));
|
||||
refreshStats();
|
||||
}}
|
||||
onCancel={() => setCaptureOpen(false)}
|
||||
/>
|
||||
@@ -389,7 +515,7 @@ function BankBrowser() {
|
||||
)}
|
||||
|
||||
{view === "movimientos" && movements && !loading && (
|
||||
<FilteredTotals totals={movements.totals} />
|
||||
<FilteredTotals totals={movements.totals} currency={currency} />
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
@@ -402,6 +528,7 @@ function BankBrowser() {
|
||||
<SummaryView
|
||||
summary={summary}
|
||||
year={summaryYear}
|
||||
currency={currency}
|
||||
onPickYear={pickYear}
|
||||
/>
|
||||
) : movements && movements.total === 0 ? (
|
||||
@@ -430,12 +557,11 @@ function BankBrowser() {
|
||||
<BankRow
|
||||
key={m.id}
|
||||
m={m}
|
||||
currency={currency}
|
||||
canVoid={canVoid}
|
||||
onVoided={() => {
|
||||
runSearch(movements?.page ?? 1);
|
||||
getBankStats()
|
||||
.then(setStats)
|
||||
.catch(() => setStats(null));
|
||||
refreshStats();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -456,13 +582,66 @@ function BankBrowser() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}) {
|
||||
@@ -493,7 +672,7 @@ function BankStatStrip({
|
||||
aria-pressed={direction === "income"}
|
||||
>
|
||||
<div className="stat-value tx-amount pos">
|
||||
{formatMoney(stats.income, "MXN")}
|
||||
{formatMoney(stats.income, currency)}
|
||||
</div>
|
||||
<div className="stat-label">
|
||||
En ingresos · {formatNumber(stats.incomeCount)} movimientos
|
||||
@@ -508,14 +687,14 @@ function BankStatStrip({
|
||||
aria-pressed={direction === "expense"}
|
||||
>
|
||||
<div className="stat-value tx-amount neg">
|
||||
{formatMoney(stats.expense, "MXN")}
|
||||
{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, "MXN")}</div>
|
||||
<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>
|
||||
@@ -544,27 +723,33 @@ function BankStatStrip({
|
||||
}
|
||||
|
||||
/** Totals for everything the current filter matched, not just the page. */
|
||||
function FilteredTotals({ totals }: { totals: BankTotals }) {
|
||||
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">MXN</span>
|
||||
<span className="filtered-total-cur">{currency}</span>
|
||||
<span>
|
||||
<strong className="tx-amount pos">
|
||||
{formatMoney(totals.income, "MXN")}
|
||||
{formatMoney(totals.income, currency)}
|
||||
</strong>{" "}
|
||||
en ingresos · {formatNumber(totals.incomeCount)}
|
||||
</span>
|
||||
<span>
|
||||
<strong className="tx-amount neg">
|
||||
{formatMoney(totals.expense, "MXN")}
|
||||
{formatMoney(totals.expense, currency)}
|
||||
</strong>{" "}
|
||||
en egresos · {formatNumber(totals.expenseCount)}
|
||||
</span>
|
||||
<span className="filtered-total-net">
|
||||
Neto <strong>{formatMoney(totals.net, "MXN")}</strong>
|
||||
Neto <strong>{formatMoney(totals.net, currency)}</strong>
|
||||
</span>
|
||||
{totals.voidCount > 0 && (
|
||||
<span>{formatNumber(totals.voidCount)} cancelados</span>
|
||||
@@ -576,10 +761,12 @@ function FilteredTotals({ totals }: { totals: BankTotals }) {
|
||||
|
||||
function BankRow({
|
||||
m,
|
||||
currency,
|
||||
canVoid,
|
||||
onVoided,
|
||||
}: {
|
||||
m: BankListItem;
|
||||
currency: Currency;
|
||||
canVoid: boolean;
|
||||
onVoided: () => void;
|
||||
}) {
|
||||
@@ -620,7 +807,7 @@ function BankRow({
|
||||
<td>{bankSourceLabel(m.source)}</td>
|
||||
<td className="num">
|
||||
<span className={`tx-amount ${bankTone(m.direction)}`}>
|
||||
{m.direction === "void" ? "—" : formatMoney(m.amount, "MXN")}
|
||||
{m.direction === "void" ? "—" : formatMoney(m.amount, currency)}
|
||||
</span>
|
||||
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
|
||||
</td>
|
||||
@@ -650,10 +837,12 @@ function BankRow({
|
||||
function SummaryView({
|
||||
summary,
|
||||
year,
|
||||
currency,
|
||||
onPickYear,
|
||||
}: {
|
||||
summary: BankSummary | null;
|
||||
year: number | null;
|
||||
currency: Currency;
|
||||
onPickYear: (y: number) => void;
|
||||
}) {
|
||||
if (!summary) return null;
|
||||
@@ -687,12 +876,12 @@ function SummaryView({
|
||||
<td className="num">{formatNumber(r.count)}</td>
|
||||
<td className="num">
|
||||
<span className="tx-amount pos">
|
||||
{formatMoney(r.income, "MXN")}
|
||||
{formatMoney(r.income, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
<span className="tx-amount neg">
|
||||
{formatMoney(r.expense, "MXN")}
|
||||
{formatMoney(r.expense, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
@@ -701,10 +890,10 @@ function SummaryView({
|
||||
Number(r.net) < 0 ? "neg" : "pos"
|
||||
}`}
|
||||
>
|
||||
{formatMoney(r.net, "MXN")}
|
||||
{formatMoney(r.net, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num mono">{formatMoney(r.cumulative, "MXN")}</td>
|
||||
<td className="num mono">{formatMoney(r.cumulative, currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -724,7 +913,7 @@ function SummaryView({
|
||||
<span className="section-rule cuenta" />
|
||||
<h2 className="section-title">Meses de {year}</h2>
|
||||
<span className="section-count">
|
||||
abre en {formatMoney(summary.opening, "MXN")}
|
||||
abre en {formatMoney(summary.opening, currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="card">
|
||||
@@ -747,12 +936,12 @@ function SummaryView({
|
||||
<td className="num">{formatNumber(r.count)}</td>
|
||||
<td className="num">
|
||||
<span className="tx-amount pos">
|
||||
{formatMoney(r.income, "MXN")}
|
||||
{formatMoney(r.income, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
<span className="tx-amount neg">
|
||||
{formatMoney(r.expense, "MXN")}
|
||||
{formatMoney(r.expense, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
@@ -761,11 +950,11 @@ function SummaryView({
|
||||
Number(r.net) < 0 ? "neg" : "pos"
|
||||
}`}
|
||||
>
|
||||
{formatMoney(r.net, "MXN")}
|
||||
{formatMoney(r.net, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num mono">
|
||||
{formatMoney(r.cumulative, "MXN")}
|
||||
{formatMoney(r.cumulative, currency)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -779,13 +968,16 @@ function SummaryView({
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline capture form for a single chequera movement. Single currency (MXN);
|
||||
* sign convention: positive = ingreso, negative = egreso. Booked rows are
|
||||
* never edited — fix mistakes with voidBankMovement + a fresh capture. */
|
||||
/** 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;
|
||||
}) {
|
||||
@@ -818,6 +1010,7 @@ function BankCaptureForm({
|
||||
}
|
||||
const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs);
|
||||
const payload: CreateBankMovementInput = {
|
||||
bankAccountId: account.id,
|
||||
amount: signed,
|
||||
transactionDate,
|
||||
concept: s(concept),
|
||||
@@ -843,9 +1036,12 @@ function BankCaptureForm({
|
||||
<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: 14 }}>
|
||||
<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">
|
||||
@@ -874,7 +1070,7 @@ function BankCaptureForm({
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
Monto (MXN) <span aria-hidden>*</span>
|
||||
Monto ({account.currency}) <span aria-hidden>*</span>
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getPolicyStats,
|
||||
getPropertyStats,
|
||||
getStats,
|
||||
listBankAccounts,
|
||||
} from "@/lib/api";
|
||||
import { useAuth } from "@/lib/abilities";
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
trustStatusLabel,
|
||||
} from "@/lib/labels";
|
||||
import type {
|
||||
BankAccount,
|
||||
BankStats,
|
||||
BillingStats,
|
||||
CustomerStats,
|
||||
@@ -41,7 +43,29 @@ interface DashboardData {
|
||||
policies: PolicyStats | null;
|
||||
properties: PropertyStats | null;
|
||||
billing: BillingStats | null;
|
||||
/** Figures for ONE chequera — see `bankAccount` for which. */
|
||||
bank: BankStats | null;
|
||||
/**
|
||||
* The chequera the card above is reading. The office keeps more than one, in
|
||||
* different currencies, so this card shows the default account rather than a
|
||||
* cross-account total, which would be a figure that never existed.
|
||||
*/
|
||||
bankAccount: BankAccount | null;
|
||||
bankAccountCount: number;
|
||||
}
|
||||
|
||||
/** Same default as /banco, so the two screens agree on which chequera opens. */
|
||||
function defaultAccount(accounts: BankAccount[]): BankAccount | null {
|
||||
const remembered =
|
||||
typeof window !== "undefined"
|
||||
? window.localStorage.getItem("banco.bankAccountId")
|
||||
: null;
|
||||
return (
|
||||
accounts.find((a) => a.id === remembered) ??
|
||||
accounts.find((a) => a.active) ??
|
||||
accounts[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function HomeDashboard() {
|
||||
@@ -52,25 +76,42 @@ function HomeDashboard() {
|
||||
properties: null,
|
||||
billing: null,
|
||||
bank: null,
|
||||
bankAccount: null,
|
||||
bankAccountCount: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
// The chequera figures need an account id, so that read is a two-step:
|
||||
// list the accounts, then ask the default one for its stats.
|
||||
const bank = listBankAccounts().then(async (accounts) => {
|
||||
const account = defaultAccount(accounts);
|
||||
if (!account) return { account: null, stats: null, count: 0 };
|
||||
return {
|
||||
account,
|
||||
stats: await getBankStats(account.id),
|
||||
count: accounts.length,
|
||||
};
|
||||
});
|
||||
|
||||
Promise.allSettled([
|
||||
getStats(),
|
||||
getPolicyStats(),
|
||||
getPropertyStats(),
|
||||
getBillingStats(),
|
||||
getBankStats(),
|
||||
bank,
|
||||
]).then((results) => {
|
||||
if (!alive) return;
|
||||
const bankResult = results[4].status === "fulfilled" ? results[4].value : null;
|
||||
setData({
|
||||
customers: results[0].status === "fulfilled" ? results[0].value : null,
|
||||
policies: results[1].status === "fulfilled" ? results[1].value : null,
|
||||
properties: results[2].status === "fulfilled" ? results[2].value : null,
|
||||
billing: results[3].status === "fulfilled" ? results[3].value : null,
|
||||
bank: results[4].status === "fulfilled" ? results[4].value : null,
|
||||
bank: bankResult?.stats ?? null,
|
||||
bankAccount: bankResult?.account ?? null,
|
||||
bankAccountCount: bankResult?.count ?? 0,
|
||||
});
|
||||
setLoading(false);
|
||||
});
|
||||
@@ -260,22 +301,27 @@ function HomeDashboard() {
|
||||
loading={loading}
|
||||
title="Chequera del despacho"
|
||||
primary={
|
||||
data.bank ? (
|
||||
data.bank && data.bankAccount ? (
|
||||
<span className={data.bank.net.startsWith("-") ? "money-neg" : "money-pos"}>
|
||||
{formatMoney(data.bank.net, "MXN")}
|
||||
{formatMoney(data.bank.net, data.bankAccount.currency)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
}
|
||||
// Names the account, because this is one chequera's figure and the
|
||||
// office has more than one — they are never added together.
|
||||
sub={
|
||||
data.bank
|
||||
? balancePhrase(data.bank.net)
|
||||
data.bank && data.bankAccount
|
||||
? `${data.bankAccount.label} · ${balancePhrase(data.bank.net)}`
|
||||
: undefined
|
||||
}
|
||||
meta={
|
||||
data.bank
|
||||
? `${formatNumber(data.bank.movements)} movimientos · ${formatNumber(data.bank.pending)} pendientes`
|
||||
? `${formatNumber(data.bank.movements)} movimientos · ${formatNumber(data.bank.pending)} pendientes` +
|
||||
(data.bankAccountCount > 1
|
||||
? ` · ${formatNumber(data.bankAccountCount)} cuentas en total`
|
||||
: "")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user