Files
jorgecuadros-platform/apps/web/src/app/estado-cuenta/[id]/page.tsx
T
rmancinasandClaude Opus 5 bc749055e7
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m11s
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s
feat(statements): scope the estado de cuenta to the current year, oldest-first
The office's EDO CUENTA sheet has always been a *year* statement: a balance
forward line dated January 1st, then that year's movements in the order they
happened. Both of ours read the other way — every year the customer ever had,
newest first — so staff comparing the screen against the printed sheet were
reading two different documents.

Movements are now bounded to the calendar year and returned ascending, on the
screen (/estado-cuenta/[id]) and in the printable `edo-cuenta-datos` report
alike.

Earlier rows are dropped from the *list*, not from the arithmetic. The balance
floor normally lands on January 1st already, so for most customers nothing
extra is dropped at all; when it doesn't — a customer the last legacy publish
skipped, or one that never had an opening balance — the earlier rows are
folded into a carried balance and shown as a single "saldo anterior" line.
Discarding them instead would restart every balance at zero on January 1st and
nothing would throw; the numbers would just be wrong, which is how the
double-counting bug survived for years. `opening` is exposed per currency and
per business line so the totals still reconcile against the last running
balance printed.

Two things the report was missing on its own are fixed while it is being
touched, since it must agree with the screen to the peso:

  - it never applied the balance floor, so every pre-cutover row was counted
    twice — once inside the opening balance and once as itself;
  - its source-table exclusion used a bare `notIn`, and `NULL NOT IN (...)` is
    NULL rather than true, so every app-captured row (which has no
    legacySourceTable) silently vanished from the printout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 08:45:15 -07:00

673 lines
20 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { MovementForm } from "@/components/MovementForm";
import {
getBillingFacets,
getStatement,
voidMovement,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import {
balancePhrase,
balanceTone,
directionLabel,
domainLabel,
formatDate,
formatMoney,
formatNumber,
ledgerSourceLabel,
SIN_NOMBRE,
txTypeLabel,
} from "@/lib/labels";
import type {
BillingFacets,
LedgerCurrency,
Statement,
StatementMovement,
TransactionDomain,
} from "@/lib/types";
/**
* One customer's statement across both business lines — the payoff of plan
* step 6 and, ultimately, of the whole unified-customer project: a utility
* charge and an insurance payment finally sit on the same page, under the same
* person, with a running balance.
*
* The running balance is per currency, so the movement table is scoped to one
* currency at a time — a column that alternated between pesos and dollars would
* be a meaningless number.
*
* Like the legacy EDO CUENTA report, the table covers the current year only and
* runs oldest-first, opening on the balance carried in from before it.
*/
export default function EstadoCuentaDetailPage({
params,
}: {
params: { id: string };
}) {
const { id } = params;
return (
<AppShell>
<StatementView id={id} />
</AppShell>
);
}
function StatementView({ id }: { id: string }) {
const canCapture = useCan("ledger:create");
const canVoid = useCan("ledger:void");
const [data, setData] = useState<Statement | null>(null);
const [facets, setFacets] = useState<BillingFacets | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [captureOpen, setCaptureOpen] = useState(false);
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
const [domain, setDomain] = useState<TransactionDomain | "">("");
function reload() {
let alive = true;
setLoading(true);
setError(null);
getStatement(id)
.then((d) => {
if (!alive) return;
setData(d);
// Default to the currency the customer actually moves the most in;
// preserve a previously-chosen currency across reloads.
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
setCurrency((prev) => prev ?? busiest?.currency ?? "MXN");
setLoading(false);
})
.catch((e) => {
if (!alive) return;
setError(
e?.status === 404
? "No encontramos este cliente."
: e?.message ?? "No se pudo cargar el estado de cuenta.",
);
setLoading(false);
});
return () => {
alive = false;
};
}
useEffect(() => {
const cleanup = reload();
getBillingFacets().then(setFacets).catch(() => setFacets(null));
return cleanup;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const movements = useMemo(() => {
if (!data || !currency) return [];
return data.movements.filter(
(m) => m.currency === currency && (!domain || m.domain === domain),
);
}, [data, currency, domain]);
if (loading) return <StatementSkeleton />;
if (error)
return (
<>
<BackLink />
<div className="state-error" role="alert">
{error}
</div>
</>
);
if (!data || !currency) return null;
const active = data.summary.find((s) => s.currency === currency);
return (
<div className="rise">
<BackLink />
<Hero data={data} />
<section className="section">
<SectionHead rule="cuenta" title="Saldo por moneda" />
{data.summary.length === 0 ? (
<div className="card">
<div className="empty-inline">
Este cliente no tiene movimientos registrados.
</div>
</div>
) : (
<div className="summary-grid">
{data.summary.map((s) => {
const tone = balanceTone(s.balance);
return (
<button
type="button"
key={s.currency}
className={`summary-card bal-card ${tone}${
currency === s.currency ? " selected" : ""
}`}
onClick={() => setCurrency(s.currency)}
aria-pressed={currency === s.currency}
>
<div className="summary-domain">
Saldo en {s.currency} · {balancePhrase(s.balance)}
</div>
<div className={`summary-total bal-amount ${tone}`}>
{formatMoney(s.balance, s.currency)}
</div>
<div className="bal-breakdown">
<span className="tx-amount neg">
{formatMoney(s.charges, s.currency)}
</span>
<span className="bal-breakdown-label">
{formatNumber(s.chargeCount)} cargos
</span>
<span className="tx-amount pos">
{formatMoney(s.credits, s.currency)}
</span>
<span className="bal-breakdown-label">
{formatNumber(s.creditCount)} abonos
</span>
</div>
<div className="summary-count">
{formatDate(s.firstMovement)} a {formatDate(s.lastMovement)}
</div>
</button>
);
})}
</div>
)}
<p className="section-note">
Los saldos se muestran por separado en cada moneda. La contabilidad
heredada registró los cargos únicamente en pesos y los recibos en
ambas monedas, sin guardar el tipo de cambio aplicado a cada
movimiento, por lo que sumarlas produciría una cifra que nunca existió
en los libros.
</p>
</section>
<PorLineaSection data={data} currency={currency} />
<ConceptosSection data={data} currency={currency} />
<section className="section">
<SectionHead
rule="cuenta"
title={`Movimientos ${data.year}`}
count={movements.length}
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
right={
canCapture ? (
<button
type="button"
className="btn btn-primary"
onClick={() => setCaptureOpen((v) => !v)}
>
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
</button>
) : undefined
}
/>
{captureOpen && (
<MovementForm
concepts={facets?.types ?? []}
defaultCurrency={currency ?? "MXN"}
defaultCustomer={{
id: data.customer.id,
name: data.customer.name,
}}
onSaved={() => {
setCaptureOpen(false);
reload();
}}
onCancel={() => setCaptureOpen(false)}
/>
)}
<div className="filter-row">
<label className="filter-field">
<span className="filter-label">Moneda</span>
<select
className="input select"
value={currency}
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
>
{data.summary.map((s) => (
<option key={s.currency} value={s.currency}>
{s.currency} ({formatNumber(s.count)})
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Línea de negocio</span>
<select
className="input select"
value={domain}
onChange={(e) =>
setDomain(e.target.value as TransactionDomain | "")
}
>
<option value="">Ambas líneas</option>
<option value="UTILITY">Servicios</option>
<option value="INSURANCE">Seguros</option>
</select>
</label>
</div>
<div className="card">
{movements.length === 0 ? (
<div className="empty-inline">
Sin movimientos de {data.year} en {currency}
{domain ? ` para ${domainLabel(domain)}` : ""}.
</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Fecha</th>
<th>Línea</th>
<th>Concepto</th>
<th>Referencia</th>
<th className="num">Cargo / Abono</th>
<th className="num">Saldo</th>
{canVoid && (
<th style={{ width: 1, whiteSpace: "nowrap" }}>
Acciones
</th>
)}
</tr>
</thead>
<tbody>
{/*
The carried balance, shown the way the legacy report shows
it: a BALANCE FORWARD line above the year's movements. It
only appears when there is something to carry — when the
customer's opening-balance row is itself dated inside this
year (the usual case) it is listed as an ordinary movement
and this row is zero, so it is left out.
Suppressed under a business-line filter: the carried balance
is the customer's, across both lines, and printing it above
one line's rows would read as that line's opening balance.
*/}
{!domain && Number(active?.opening ?? 0) !== 0 && (
<OpeningRow
opening={active!.opening}
currency={currency}
year={data.year}
canVoid={canVoid}
/>
)}
{movements.map((m) => (
<StatementRow
key={m.id}
m={m}
canVoid={canVoid}
onVoided={reload}
/>
))}
</tbody>
</table>
</div>
)}
{domain && movements.length > 0 && (
<div className="section-note" style={{ padding: "0 16px 14px" }}>
La columna de saldo es el saldo acumulado del cliente en{" "}
{currency} sobre <strong>todas</strong> sus líneas filtrar por
línea oculta filas, no las descuenta.
</div>
)}
</div>
{active && (
<p className="section-note">
Saldo final en {currency}:{" "}
<strong>{formatMoney(active.balance, currency)}</strong> (
{balancePhrase(active.balance).toLowerCase()}).
</p>
)}
</section>
</div>
);
}
function BackLink() {
return (
<Link href="/estado-cuenta" className="back-link">
Volver a Estado de cuenta
</Link>
);
}
function Hero({ data }: { data: Statement }) {
const c = data.customer;
const location = [c.city?.replace(/,\s*$/, ""), c.state]
.filter(Boolean)
.join(", ");
const facts: { label: string; value: string }[] = [
{ label: "Cliente desde", value: formatDate(c.customerSince) },
{ label: "Propiedades", value: String(c.propertyCount) },
{ label: "Pólizas", value: String(c.policyCount) },
{ label: "Teléfono", value: c.phone || c.mobile || "—" },
{ label: "Correo", value: c.email || "—" },
];
return (
<div className="detail-hero">
<div className="hero-top">
<div>
<h1
className={`hero-name${
c.name === SIN_NOMBRE ? " hero-name-missing" : ""
}`}
>
{c.name}
</h1>
{location && <div className="hero-provenance">{location}</div>}
{c.nameSource && (
<div className="hero-provenance">
Nombre recuperado de {c.nameSource} el registro original no
tenía nombre.
</div>
)}
</div>
<div className="hero-badges">
{c.propertyCount > 0 && (
<span className="badge badge-servicios">
<span className="dot" /> Servicios
</span>
)}
{c.policyCount > 0 && (
<span className="badge badge-seguros">
<span className="dot" /> Seguros
</span>
)}
<span className={`badge ${c.status ? "badge-on-dark" : "badge-negative"}`}>
{c.status ? "Activo" : "Inactivo"}
</span>
</div>
</div>
<div className="hero-facts">
{facts.map((f) => (
<div key={f.label}>
<div className="hero-fact-label">{f.label}</div>
<div className="hero-fact-value">{f.value}</div>
</div>
))}
</div>
<div className="hero-links">
<Link href={`/clientes/${c.id}`} className="btn btn-outline">
Ver ficha del cliente
</Link>
</div>
</div>
);
}
/** The cross-line split — the same balance, broken out by business line. */
function PorLineaSection({
data,
currency,
}: {
data: Statement;
currency: LedgerCurrency;
}) {
const rows = data.byDomain.filter((d) => d.currency === currency);
if (rows.length === 0) return null;
return (
<section className="section">
<SectionHead rule="cuenta" title={`Por línea de negocio · ${currency}`} />
<div className="summary-grid">
{rows.map((r) => (
<div className={`summary-card ${r.domain}`} key={r.domain}>
<div className="summary-domain">
<span className={`tx-dot ${r.domain}`} />
{domainLabel(r.domain)}
</div>
<div className={`summary-total bal-amount ${balanceTone(r.balance)}`}>
{formatMoney(r.balance, currency)}
</div>
<div className="bal-breakdown">
<span className="tx-amount neg">
{formatMoney(r.charges, currency)}
</span>
<span className="bal-breakdown-label">en cargos</span>
<span className="tx-amount pos">
{formatMoney(r.credits, currency)}
</span>
<span className="bal-breakdown-label">en abonos</span>
</div>
<div className="summary-count">
{formatNumber(r.count)}{" "}
{r.count === 1 ? "movimiento" : "movimientos"}
</div>
</div>
))}
</div>
</section>
);
}
/** Where the charges went — the question a customer asks about their balance. */
function ConceptosSection({
data,
currency,
}: {
data: Statement;
currency: LedgerCurrency;
}) {
const rows = data.byType.filter((t) => t.currency === currency).slice(0, 12);
if (rows.length === 0) return null;
const largest = Math.abs(Number(rows[0]?.total ?? 0)) || 1;
return (
<section className="section">
<SectionHead rule="servicios" title={`Cargos por concepto · ${currency}`} />
<div className="card">
<div className="concept-list">
{rows.map((t) => (
<div className="concept-row" key={`${t.name}-${t.currency}`}>
<div className="concept-name">
{txTypeLabel({ nameEn: t.name })}
<span className="concept-count">
{formatNumber(t.count)}{" "}
{t.count === 1 ? "cargo" : "cargos"}
</span>
</div>
<div className="concept-bar" aria-hidden>
<span
style={{
width: `${Math.max(
2,
(Math.abs(Number(t.total)) / largest) * 100,
)}%`,
}}
/>
</div>
<div className="concept-total tx-amount neg">
{formatMoney(t.total, currency)}
</div>
</div>
))}
</div>
</div>
</section>
);
}
/** The balance carried into the statement year — legacy's BALANCE FORWARD. */
function OpeningRow({
opening,
currency,
year,
canVoid,
}: {
opening: string;
currency: LedgerCurrency;
year: number;
canVoid: boolean;
}) {
return (
<tr>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(`${year}-01-01T00:00:00.000Z`)}
</td>
<td className="tx-domain-cell">Ambas líneas</td>
<td>
Saldo anterior
<div className="tx-concept">Al cierre de {year - 1}</div>
</td>
<td className="tx-ref"></td>
<td className="num">
<span className={`tx-amount ${Number(opening) < 0 ? "neg" : "pos"}`}>
{formatMoney(opening, currency)}
</span>
</td>
<td className="num">
<span className={`bal-running ${balanceTone(opening)}`}>
{formatMoney(opening, currency)}
</span>
</td>
{canVoid && <td />}
</tr>
);
}
function StatementRow({
m,
canVoid,
onVoided,
}: {
m: StatementMovement;
canVoid: boolean;
onVoided: () => void;
}) {
const concept = m.message || m.period || null;
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 voidMovement(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-domain-cell">
<span className={`tx-dot ${m.domain}`} />
{domainLabel(m.domain)}
</td>
<td>
{txTypeLabel(m.type)}
{concept && <div className="tx-concept">{concept}</div>}
</td>
<td className="tx-ref">
{m.reference || m.checkNumber || "—"}
<div className="tx-concept">{ledgerSourceLabel(m.source)}</div>
</td>
<td className="num">
<span className={`tx-amount ${m.direction === "charge" ? "neg" : "pos"}`}>
{formatMoney(m.amount, m.currency)}
</span>
<div className="tx-cur">{directionLabel(m.direction)}</div>
</td>
<td className="num">
<span className={`bal-running ${balanceTone(m.balanceAfter)}`}>
{formatMoney(m.balanceAfter, m.currency)}
</span>
</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>
);
}
function SectionHead({
rule,
title,
count,
countSuffix,
right,
}: {
rule: string;
title: string;
count?: number;
countSuffix?: string;
right?: React.ReactNode;
}) {
return (
<div
className="section-head"
style={
right
? { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }
: undefined
}
>
<span className={`section-rule ${rule}`} aria-hidden />
<h2 className="section-title">{title}</h2>
{count != null && (
<span className="section-count">
{formatNumber(count)} {countSuffix ?? ""}
</span>
)}
{right && (
<div style={{ marginLeft: "auto" }}>{right}</div>
)}
</div>
);
}
function StatementSkeleton() {
return (
<div aria-hidden>
<div className="skeleton" style={{ height: 18, width: 180 }} />
<div
className="skeleton"
style={{ height: 150, marginTop: 16, borderRadius: 16 }}
/>
<div
className="skeleton"
style={{ height: 320, marginTop: 24, borderRadius: 16 }}
/>
</div>
);
}