"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 ( ); } function StatementView({ id }: { id: string }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [currency, setCurrency] = useState(null); const [domain, setDomain] = useState(""); 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 ; 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.

{movements.length === 0 ? (
Sin movimientos en {currency} {domain ? ` para ${domainLabel(domain)}` : ""}.
) : (
{movements.map((m) => ( ))}
Fecha Línea Concepto Referencia Cargo / Abono Saldo
)} {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)}
))}
); } function StatementRow({ m }: { m: StatementMovement }) { const concept = m.message || m.period || null; 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)} ); } function SectionHead({ rule, title, count, countSuffix, }: { rule: string; title: string; count?: number; countSuffix?: string; }) { return (

{title}

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