"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 ( ); } function StatementView({ id }: { id: string }) { const canCapture = useCan("ledger:create"); const canVoid = useCan("ledger:void"); const [data, setData] = useState(null); const [facets, setFacets] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [captureOpen, setCaptureOpen] = useState(false); const [currency, setCurrency] = useState(null); const [domain, setDomain] = useState(""); 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 ; if (error) return ( <>
{error}
); if (!data || !currency) return null; const active = data.summary.find((s) => s.currency === currency); return (
{data.summary.length === 0 ? (
Este cliente no tiene movimientos registrados.
) : (
{data.summary.map((s) => { const tone = balanceTone(s.balance); return ( ); })}
)}

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.

setCaptureOpen((v) => !v)} > {captureOpen ? "Cerrar captura" : "Capturar movimiento"} ) : undefined } /> {captureOpen && ( { setCaptureOpen(false); reload(); }} onCancel={() => setCaptureOpen(false)} /> )}
{movements.length === 0 ? (
Sin movimientos de {data.year} en {currency} {domain ? ` para ${domainLabel(domain)}` : ""}.
) : (
{canVoid && ( )} {/* 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 && ( )} {movements.map((m) => ( ))}
Fecha Línea Concepto Referencia Cargo / Abono Saldo Acciones
)} {domain && movements.length > 0 && (
La columna de saldo es el saldo acumulado del cliente en{" "} {currency} sobre todas sus líneas — filtrar por línea oculta filas, no las descuenta.
)}
{active && (

Saldo final en {currency}:{" "} {formatMoney(active.balance, currency)} ( {balancePhrase(active.balance).toLowerCase()}).

)}
); } function BackLink() { return ( ← Volver a Estado de cuenta ); } 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 (

{c.name}

{location &&
{location}
} {c.nameSource && (
Nombre recuperado de {c.nameSource} — el registro original no tenía nombre.
)}
{c.propertyCount > 0 && ( Servicios )} {c.policyCount > 0 && ( Seguros )} {c.status ? "Activo" : "Inactivo"}
{facts.map((f) => (
{f.label}
{f.value}
))}
Ver ficha del cliente
); } /** 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 (
{rows.map((r) => (
{domainLabel(r.domain)}
{formatMoney(r.balance, currency)}
{formatMoney(r.charges, currency)} en cargos {formatMoney(r.credits, currency)} en abonos
{formatNumber(r.count)}{" "} {r.count === 1 ? "movimiento" : "movimientos"}
))}
); } /** 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 (
{rows.map((t) => (
{txTypeLabel({ nameEn: t.name })} {formatNumber(t.count)}{" "} {t.count === 1 ? "cargo" : "cargos"}
{formatMoney(t.total, currency)}
))}
); } /** 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 ( {formatDate(`${year}-01-01T00:00:00.000Z`)} Ambas líneas Saldo anterior
Al cierre de {year - 1}
{formatMoney(opening, currency)} {formatMoney(opening, currency)} {canVoid && } ); } 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 ( {formatDate(m.transactionDate)} {domainLabel(m.domain)} {txTypeLabel(m.type)} {concept &&
{concept}
} {m.reference || m.checkNumber || "—"}
{ledgerSourceLabel(m.source)}
{formatMoney(m.amount, m.currency)}
{directionLabel(m.direction)}
{formatMoney(m.balanceAfter, m.currency)} {canVoid && ( {!m.voided && ( )} )} ); } function SectionHead({ rule, title, count, countSuffix, right, }: { rule: string; title: string; count?: number; countSuffix?: string; right?: React.ReactNode; }) { return (

{title}

{count != null && ( {formatNumber(count)} {countSuffix ?? ""} )} {right && (
{right}
)}
); } function StatementSkeleton() { return (
); }