Files
jorgecuadros-platform/apps/web/src/app/estado-cuenta/[id]/page.tsx
T
rmancinasandClaude Opus 4.8 2c6a6bf60b feat(billing): shared statements module across both business lines
Plan step 6 — the payoff of the unified customer record: a utility charge
and an insurance payment finally sit on the same page, under the same
person, with a running balance.

API (apps/api/src/billing/):
- GET /billing — cross-customer movement browser. Search over customer,
  referencia, cheque, concepto and periodo; filters for business line,
  currency, charge-vs-credit, concept, origin table and a from/to date
  range; 5 sorts. Returns totals for the whole filtered set, not just the
  page, so a filtered view can't be misread as the full ledger.
- GET /billing/balances — per-customer receivables worklist with
  owing/credit/settled buckets and 4 sorts. Raw SQL (parameterized via
  Prisma.sql): needs conditional sums per currency and per direction in
  one pass plus ordering and pagination on a computed balance, none of
  which groupBy expresses.
- GET /billing/stats, /billing/facets, /billing/customers/:id.

Web:
- /estado-cuenta — two views over the same ledger, because staff ask two
  different questions: "Saldos por cliente" (who owes what) and
  "Movimientos" (every charge and credit).
- /estado-cuenta/[id] — the statement: balance per currency, the same
  balance split by business line, charges broken out by concept, and the
  full movement list with a running balance.
- Cross-linked from the customer and property detail pages.

Two data findings shape the whole module:

1. transactions.amount is a signed ledger. Every charge type is negative
   without exception (WATER 3115/3117, ELECTRIC 2191/2191, PROPERTY TAXES
   926/926, TRUST FEE 188/188) and every deposit type positive (CHECK and
   CASH DEPOSIT, PAYPAL, all of EFECTIVO). So SUM(amount) is the balance
   and negative means the customer owes the office.

2. Currency is not summable. 912 of the 1269 customers with a ledger move
   in both MXN and USD, the charge side is MXN-only while receipts arrive
   in both, and no per-movement exchange rate was ever stored. A single
   "total balance" would be a figure that never existed in the books, so
   every total is reported per currency and the balance filter/sort takes
   a currency argument rather than collapsing.

Also: type_transactions.nameEs is entirely null (the legacy TYPE OF TRX
ESPAÑOL column is empty in all 79 rows), so Spanish concept names come
from a label map in labels.ts; the entries that are payee names rather
than categories fall through untranslated, which is correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:29:19 -07:00

501 lines
15 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { getStatement } from "@/lib/api";
import {
balancePhrase,
balanceTone,
directionLabel,
domainLabel,
formatDate,
formatMoney,
formatNumber,
ledgerSourceLabel,
SIN_NOMBRE,
txTypeLabel,
} from "@/lib/labels";
import type {
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 (the API accumulates it chronologically
* before handing the list back newest-first), 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.
*/
export default function EstadoCuentaDetailPage({
params,
}: {
params: { id: string };
}) {
const { id } = params;
return (
<AppShell>
<StatementView id={id} />
</AppShell>
);
}
function StatementView({ id }: { id: string }) {
const [data, setData] = useState<Statement | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
const [domain, setDomain] = useState<TransactionDomain | "">("");
useEffect(() => {
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.
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
setCurrency(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;
};
}, [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"
count={movements.length}
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
/>
<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 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>
</tr>
</thead>
<tbody>
{movements.map((m) => (
<StatementRow key={m.id} m={m} />
))}
</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>
);
}
function StatementRow({ m }: { m: StatementMovement }) {
const concept = m.message || m.period || null;
return (
<tr>
<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>
<span className="tx-cur">{directionLabel(m.direction)}</span>
</td>
<td className="num">
<span className={`bal-running ${balanceTone(m.balanceAfter)}`}>
{formatMoney(m.balanceAfter, m.currency)}
</span>
</td>
</tr>
);
}
function SectionHead({
rule,
title,
count,
countSuffix,
}: {
rule: string;
title: string;
count?: number;
countSuffix?: string;
}) {
return (
<div className="section-head">
<span className={`section-rule ${rule}`} aria-hidden />
<h2 className="section-title">{title}</h2>
{count != null && (
<span className="section-count">
{formatNumber(count)} {countSuffix ?? ""}
</span>
)}
</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>
);
}