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>
502 lines
15 KiB
TypeScript
502 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { AppShell } from "@/components/AppShell";
|
|
import {
|
|
EXPIRY_WINDOW_DAYS,
|
|
getBankStats,
|
|
getBillingStats,
|
|
getPolicyStats,
|
|
getPropertyStats,
|
|
getStats,
|
|
listBankAccounts,
|
|
} from "@/lib/api";
|
|
import { useAuth } from "@/lib/abilities";
|
|
import {
|
|
balancePhrase,
|
|
formatDate,
|
|
formatMoney,
|
|
formatNumber,
|
|
policyStatusLabel,
|
|
trustStatusLabel,
|
|
} from "@/lib/labels";
|
|
import type {
|
|
BankAccount,
|
|
BankStats,
|
|
BillingStats,
|
|
CustomerStats,
|
|
PolicyStats,
|
|
PropertyStats,
|
|
} from "@/lib/types";
|
|
|
|
export default function InicioPage() {
|
|
return (
|
|
<AppShell>
|
|
<HomeDashboard />
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
interface DashboardData {
|
|
customers: CustomerStats | null;
|
|
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() {
|
|
const user = useAuth();
|
|
const [data, setData] = useState<DashboardData>({
|
|
customers: null,
|
|
policies: null,
|
|
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(),
|
|
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: bankResult?.stats ?? null,
|
|
bankAccount: bankResult?.account ?? null,
|
|
bankAccountCount: bankResult?.count ?? 0,
|
|
});
|
|
setLoading(false);
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, []);
|
|
|
|
const greeting = greetingFor(user?.name);
|
|
const lastBillingMovement = data.billing?.lastMovement ?? null;
|
|
const lastBankMovement = data.bank?.lastMovement ?? null;
|
|
|
|
return (
|
|
<div className="home rise">
|
|
<div className="page-head">
|
|
<p className="eyebrow">Resumen general</p>
|
|
<h1 className="page-title">{greeting}</h1>
|
|
<p className="muted" style={{ marginTop: 6, maxWidth: 640 }}>
|
|
Vista rápida del estado de la cartera de clientes, las pólizas
|
|
activas, los fideicomisos y los movimientos recientes.
|
|
</p>
|
|
<LastSeenLine
|
|
billing={lastBillingMovement}
|
|
bank={lastBankMovement}
|
|
loading={loading}
|
|
/>
|
|
</div>
|
|
|
|
<section aria-label="Indicadores principales" className="home-section">
|
|
<KpiCard
|
|
href="/clientes"
|
|
label="Clientes"
|
|
loading={loading}
|
|
primary={data.customers ? formatNumber(data.customers.customers) : "—"}
|
|
sub={
|
|
data.customers
|
|
? [
|
|
`${formatNumber(data.customers.withUtilities)} con servicios`,
|
|
`${formatNumber(data.customers.withInsurance)} con seguros`,
|
|
`${formatNumber(data.customers.bothLines)} en ambos ramos`,
|
|
]
|
|
: []
|
|
}
|
|
/>
|
|
<KpiCard
|
|
href="/servicios"
|
|
label="Propiedades"
|
|
loading={loading}
|
|
primary={data.properties ? formatNumber(data.properties.properties) : "—"}
|
|
sub={
|
|
data.properties
|
|
? [
|
|
`${formatNumber(data.properties.services)} servicios`,
|
|
`${formatNumber(data.properties.trusts)} fideicomisos`,
|
|
`${formatNumber(data.properties.trustExpiring)} por vencer`,
|
|
]
|
|
: []
|
|
}
|
|
/>
|
|
<KpiCard
|
|
href="/polizas"
|
|
label="Pólizas"
|
|
loading={loading}
|
|
primary={data.policies ? formatNumber(data.policies.total) : "—"}
|
|
sub={
|
|
data.policies
|
|
? [
|
|
`${formatNumber(data.policies.active)} vigentes`,
|
|
`${formatNumber(data.policies.expiring)} por vencer (${EXPIRY_WINDOW_DAYS} d)`,
|
|
`${formatNumber(data.policies.expired + data.policies.undated)} vencidas o sin fecha`,
|
|
]
|
|
: []
|
|
}
|
|
/>
|
|
<KpiCard
|
|
href="/estado-cuenta"
|
|
label="Movimientos"
|
|
loading={loading}
|
|
primary={data.billing ? formatNumber(data.billing.movements) : "—"}
|
|
sub={
|
|
data.billing
|
|
? [
|
|
`${formatNumber(data.billing.ledgerCustomers)} clientes con cargo`,
|
|
`${formatNumber(data.billing.crossLineCustomers)} con ambos ramos`,
|
|
lastBillingMovement
|
|
? `Último: ${formatDate(lastBillingMovement)}`
|
|
: "Sin movimientos",
|
|
]
|
|
: []
|
|
}
|
|
/>
|
|
</section>
|
|
|
|
<section aria-label="Atención" className="home-section">
|
|
<div className="section-head">
|
|
<h2 className="section-title">Atención</h2>
|
|
<span className="section-sub">
|
|
Lo que conviene revisar antes de cerrar el día
|
|
</span>
|
|
</div>
|
|
|
|
<div className="attention-grid">
|
|
<AttentionCard
|
|
href="/polizas?status=expiring"
|
|
tone="warn"
|
|
loading={loading}
|
|
title="Pólizas por vencer"
|
|
primary={
|
|
data.policies ? formatNumber(data.policies.expiring) : "—"
|
|
}
|
|
sub={
|
|
data.policies
|
|
? `En los próximos ${EXPIRY_WINDOW_DAYS} días — ventana: ${policyStatusLabel("expiring")}`
|
|
: undefined
|
|
}
|
|
meta={
|
|
data.policies
|
|
? `${formatNumber(data.policies.active)} vigentes · ${formatNumber(data.policies.expired)} vencidas`
|
|
: undefined
|
|
}
|
|
/>
|
|
<AttentionCard
|
|
href="/servicios?trust=expiring"
|
|
tone="warn"
|
|
loading={loading}
|
|
title="Fideicomisos por vencer"
|
|
primary={
|
|
data.properties ? formatNumber(data.properties.trustExpiring) : "—"
|
|
}
|
|
sub={
|
|
data.properties
|
|
? `Renueva en los próximos ${EXPIRY_WINDOW_DAYS} días (${trustStatusLabel("expiring")})`
|
|
: undefined
|
|
}
|
|
meta={
|
|
data.properties
|
|
? `${formatNumber(data.properties.trusts)} fideicomisos en cartera`
|
|
: undefined
|
|
}
|
|
/>
|
|
<AttentionCard
|
|
href="/polizas?status=undated"
|
|
tone="muted"
|
|
loading={loading}
|
|
title="Pólizas sin vigencia"
|
|
primary={data.policies ? formatNumber(data.policies.undated) : "—"}
|
|
sub={
|
|
data.policies
|
|
? "Sin fecha de fin registrada — revisar y completar"
|
|
: undefined
|
|
}
|
|
meta={
|
|
data.policies
|
|
? `${formatNumber(data.policies.liquidated)} liquidadas`
|
|
: undefined
|
|
}
|
|
/>
|
|
<AttentionCard
|
|
href="/estado-cuenta"
|
|
tone="info"
|
|
loading={loading}
|
|
title="Clientes con adeudo"
|
|
primary={
|
|
data.billing ? (
|
|
<CurrencyTotals
|
|
totals={data.billing.byCurrency}
|
|
field="owing"
|
|
/>
|
|
) : (
|
|
"—"
|
|
)
|
|
}
|
|
sub={
|
|
data.billing
|
|
? `En ${formatNumber(data.billing.ledgerCustomers)} expedientes con cargo`
|
|
: undefined
|
|
}
|
|
meta={
|
|
data.billing
|
|
? `${formatNumber(data.billing.crossLineCustomers)} clientes con cargo en ambos ramos`
|
|
: undefined
|
|
}
|
|
/>
|
|
<AttentionCard
|
|
href="/banco"
|
|
tone="info"
|
|
loading={loading}
|
|
title="Chequera del despacho"
|
|
primary={
|
|
data.bank && data.bankAccount ? (
|
|
<span className={data.bank.net.startsWith("-") ? "money-neg" : "money-pos"}>
|
|
{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 && data.bankAccount
|
|
? `${data.bankAccount.label} · ${balancePhrase(data.bank.net)}`
|
|
: undefined
|
|
}
|
|
meta={
|
|
data.bank
|
|
? `${formatNumber(data.bank.movements)} movimientos · ${formatNumber(data.bank.pending)} pendientes` +
|
|
(data.bankAccountCount > 1
|
|
? ` · ${formatNumber(data.bankAccountCount)} cuentas en total`
|
|
: "")
|
|
: undefined
|
|
}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section aria-label="Accesos rápidos" className="home-section">
|
|
<div className="section-head">
|
|
<h2 className="section-title">Accesos rápidos</h2>
|
|
<span className="section-sub">
|
|
Ir directo a cada módulo
|
|
</span>
|
|
</div>
|
|
<div className="quick-grid">
|
|
<QuickLink href="/clientes" label="Clientes" sub="Directorio unificado" />
|
|
<QuickLink href="/servicios" label="Propiedades" sub="Servicios y fideicomisos" />
|
|
<QuickLink href="/polizas" label="Pólizas" sub="Vigencias y liquidaciones" />
|
|
<QuickLink href="/estado-cuenta" label="Estado de cuenta" sub="Cargos y abonos" />
|
|
<QuickLink href="/banco" label="Chequera" sub="Ingresos y egresos" />
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function greetingFor(name?: string): string {
|
|
const hour = new Date().getHours();
|
|
const partOfDay =
|
|
hour < 12 ? "Buenos días" : hour < 19 ? "Buenas tardes" : "Buenas noches";
|
|
const display = (name ?? "").trim().split(/\s+/)[0];
|
|
return display ? `${partOfDay}, ${display}` : partOfDay;
|
|
}
|
|
|
|
function LastSeenLine({
|
|
billing,
|
|
bank,
|
|
loading,
|
|
}: {
|
|
billing: string | null;
|
|
bank: string | null;
|
|
loading: boolean;
|
|
}) {
|
|
if (loading) {
|
|
return (
|
|
<p className="muted home-last-seen" aria-hidden="true">
|
|
<span className="skeleton" style={{ display: "inline-block", width: 220, height: 12 }} />
|
|
</p>
|
|
);
|
|
}
|
|
if (!billing && !bank) return null;
|
|
return (
|
|
<p className="muted home-last-seen">
|
|
{billing && <>Último movimiento de cartera: <strong>{formatDate(billing)}</strong></>}
|
|
{billing && bank && <span aria-hidden="true"> · </span>}
|
|
{bank && <>Último movimiento de chequera: <strong>{formatDate(bank)}</strong></>}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
function KpiCard({
|
|
href,
|
|
label,
|
|
primary,
|
|
sub,
|
|
loading,
|
|
}: {
|
|
href: string;
|
|
label: string;
|
|
primary: React.ReactNode;
|
|
sub: string[];
|
|
loading: boolean;
|
|
}) {
|
|
return (
|
|
<Link href={href} className="kpi-card card">
|
|
<div className="kpi-label">{label}</div>
|
|
<div className="kpi-primary">
|
|
{loading ? (
|
|
<span
|
|
className="skeleton"
|
|
style={{ display: "inline-block", width: 90, height: 30 }}
|
|
/>
|
|
) : (
|
|
primary
|
|
)}
|
|
</div>
|
|
<ul className="kpi-sub">
|
|
{loading
|
|
? Array.from({ length: 3 }).map((_, i) => (
|
|
<li key={i} className="skeleton" style={{ height: 10 }} />
|
|
))
|
|
: sub.map((line) => <li key={line}>{line}</li>)}
|
|
</ul>
|
|
<span className="kpi-cta" aria-hidden="true">
|
|
Ver módulo →
|
|
</span>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function AttentionCard({
|
|
href,
|
|
tone,
|
|
title,
|
|
primary,
|
|
sub,
|
|
meta,
|
|
loading,
|
|
}: {
|
|
href: string;
|
|
tone: "warn" | "info" | "muted";
|
|
title: string;
|
|
primary: React.ReactNode;
|
|
sub?: string;
|
|
meta?: string;
|
|
loading: boolean;
|
|
}) {
|
|
return (
|
|
<Link href={href} className={`attention-card tone-${tone} card`}>
|
|
<div className="attention-head">
|
|
<span className="attention-title">{title}</span>
|
|
<span className="attention-arrow" aria-hidden="true">→</span>
|
|
</div>
|
|
<div className="attention-primary">
|
|
{loading ? (
|
|
<span
|
|
className="skeleton"
|
|
style={{ display: "inline-block", width: 70, height: 28 }}
|
|
/>
|
|
) : (
|
|
primary
|
|
)}
|
|
</div>
|
|
{sub && !loading && <div className="attention-sub">{sub}</div>}
|
|
{meta && !loading && <div className="attention-meta">{meta}</div>}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function QuickLink({
|
|
href,
|
|
label,
|
|
sub,
|
|
}: {
|
|
href: string;
|
|
label: string;
|
|
sub: string;
|
|
}) {
|
|
return (
|
|
<Link href={href} className="quick-link card">
|
|
<span className="quick-label">{label}</span>
|
|
<span className="quick-sub">{sub}</span>
|
|
<span className="quick-arrow" aria-hidden="true">→</span>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function CurrencyTotals({
|
|
totals,
|
|
field,
|
|
}: {
|
|
totals: BillingStats["byCurrency"];
|
|
field: "owing" | "inCredit";
|
|
}) {
|
|
if (!totals || totals.length === 0) return <>—</>;
|
|
return (
|
|
<span className="home-currency-totals">
|
|
{totals.map((c) => (
|
|
<span key={c.currency} className="home-currency-totals-row">
|
|
<span className="badge badge-count">{c.currency}</span>
|
|
<span className="home-currency-totals-num">
|
|
{formatNumber(c[field])}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</span>
|
|
);
|
|
}
|