"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { AppShell } from "@/components/AppShell"; import { ContextReports } from "@/components/ContextReports"; import { MovementForm } from "@/components/MovementForm"; import { getBillingFacets, getBillingStats, listBalances, listMovements, 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 { BalanceFilter, BalanceListItem, BalanceListResponse, BalanceSort, BillingFacets, BillingStats, LedgerCurrency, LedgerDirection, MovementListItem, MovementListResponse, MovementSort, TransactionDomain, } from "@/lib/types"; /** * Shared billing / statements browser — plan step 6. * * Two views over the same ledger, because staff ask two different questions: * - "Saldos": who owes what, one row per customer. The receivables worklist. * - "Movimientos": every individual charge and credit, filterable — the * answer to "what did we bill for water in April". * * Both are cross-line: a customer's utility charges and insurance movements sit * in the same ledger, which is the point of the unified customer record. * * Balances are always shown *per currency* and never added together — see the * currency note in `billing.service.ts`. */ type View = "saldos" | "movimientos"; const BALANCE_FILTERS: { key: BalanceFilter; label: string }[] = [ { key: "owing", label: "Con adeudo" }, { key: "credit", label: "Con saldo a favor" }, { key: "settled", label: "En ceros" }, { key: "all", label: "Todos" }, ]; const BALANCE_SORTS: { key: BalanceSort; label: string }[] = [ { key: "owing_desc", label: "Mayor adeudo primero" }, { key: "credit_desc", label: "Mayor saldo a favor primero" }, { key: "recent", label: "Movimiento más reciente" }, { key: "customer", label: "Cliente (A–Z)" }, ]; const MOVEMENT_SORTS: { key: MovementSort; label: string }[] = [ { key: "date_desc", label: "Fecha (más reciente)" }, { key: "date_asc", label: "Fecha (más antigua)" }, { key: "amount_asc", label: "Cargo más grande" }, { key: "amount_desc", label: "Abono más grande" }, { key: "customer", label: "Cliente (A–Z)" }, ]; const DIRECTIONS: { key: LedgerDirection | ""; label: string }[] = [ { key: "", label: "Cargos y abonos" }, { key: "charge", label: "Sólo cargos" }, { key: "credit", label: "Sólo abonos" }, ]; const DOMAINS: { key: TransactionDomain | ""; label: string }[] = [ { key: "", label: "Ambas líneas" }, { key: "UTILITY", label: "Servicios" }, { key: "INSURANCE", label: "Seguros" }, ]; export default function EstadoCuentaPage() { return ( ); } function BillingBrowser() { const canCapture = useCan("ledger:create"); const canVoid = useCan("ledger:void"); const [stats, setStats] = useState(null); const [facets, setFacets] = useState(null); const [view, setView] = useState("saldos"); // The currency every balance figure is filtered and sorted on. MXN is the // default because the charge side of the ledger is MXN-only. const [currency, setCurrency] = useState("MXN"); const [query, setQuery] = useState(""); const [domain, setDomain] = useState(""); const [balanceFilter, setBalanceFilter] = useState("owing"); const [balanceSort, setBalanceSort] = useState("owing_desc"); const [direction, setDirection] = useState(""); const [typeId, setTypeId] = useState(""); const [source, setSource] = useState(""); const [from, setFrom] = useState(""); const [to, setTo] = useState(""); const [movementSort, setMovementSort] = useState("date_desc"); const [balances, setBalances] = useState(null); const [movements, setMovements] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [captureOpen, setCaptureOpen] = useState(false); const debounceRef = useRef>(); useEffect(() => { getBillingStats().then(setStats).catch(() => setStats(null)); getBillingFacets().then(setFacets).catch(() => setFacets(null)); }, []); const runSearch = useCallback( (p: number) => { setLoading(true); setError(null); const done = (fn: () => void) => { fn(); setLoading(false); }; if (view === "saldos") { listBalances({ query: query || undefined, currency, balance: balanceFilter, domain: domain || undefined, sort: balanceSort, page: p, pageSize: 25, }) .then((res) => done(() => setBalances(res))) .catch((e) => { setError(e?.message ?? "No se pudieron cargar los saldos."); setLoading(false); }); } else { listMovements({ query: query || undefined, currency, domain: domain || undefined, direction: direction || undefined, typeId: typeId || undefined, source: source || undefined, from: from || undefined, to: to || undefined, sort: movementSort, page: p, pageSize: 25, }) .then((res) => done(() => setMovements(res))) .catch((e) => { setError(e?.message ?? "No se pudieron cargar los movimientos."); setLoading(false); }); } }, [ view, query, currency, domain, balanceFilter, balanceSort, direction, typeId, source, from, to, movementSort, ], ); useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => runSearch(1), 280); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [runSearch]); function goToPage(p: number) { runSearch(p); if (typeof window !== "undefined") window.scrollTo({ top: 0, behavior: "smooth" }); } /** Jumping in from a headline count should land on the matching worklist. */ function pickBalance(f: BalanceFilter, cur?: LedgerCurrency) { setView("saldos"); setBalanceFilter(f); if (cur) setCurrency(cur); setBalanceSort(f === "credit" ? "credit_desc" : "owing_desc"); } const data = view === "saldos" ? balances : movements; const filtered = query !== "" || domain !== "" || (view === "saldos" ? balanceFilter !== "owing" || balanceSort !== "owing_desc" : direction !== "" || typeId !== "" || source !== "" || from !== "" || to !== "" || movementSort !== "date_desc"); function clearFilters() { setQuery(""); setDomain(""); setCurrency("MXN"); setBalanceFilter("owing"); setBalanceSort("owing_desc"); setDirection(""); setTypeId(""); setSource(""); setFrom(""); setTo(""); setMovementSort("date_desc"); } return ( <>

Cobranza y facturación

Estado de cuenta

setQuery(e.target.value)} placeholder={ view === "saldos" ? "Buscar por cliente o ciudad…" : "Buscar por cliente, referencia, cheque, concepto…" } aria-label="Buscar en el estado de cuenta" />
{( [ { key: "saldos" as View, label: "Saldos por cliente" }, { key: "movimientos" as View, label: "Movimientos" }, ] ).map((v) => ( ))}
{view === "movimientos" && canCapture && ( )}
{view === "movimientos" && captureOpen && (

Capturar movimiento

{ setCaptureOpen(false); runSearch(movements?.page ?? 1); getBillingStats() .then(setStats) .catch(() => setStats(null)); }} onCancel={() => setCaptureOpen(false)} />
)}
{view === "saldos" ? ( <> ) : ( <> )} {filtered && ( )}
{data && !loading && !error && (
{data.total === 0 ? "Sin resultados" : view === "saldos" ? `${formatNumber(data.total)} ${ data.total === 1 ? "cliente" : "clientes" } · saldo en ${currency}` : `${formatNumber(data.total)} ${ data.total === 1 ? "movimiento" : "movimientos" }`} {query ? ` para “${query}”` : ""}
)} {view === "movimientos" && movements && !loading && ( )} {error ? (
{error}
) : loading ? ( ) : data && data.total === 0 ? ( ) : view === "saldos" ? ( <>
{balances?.items.map((b) => ( ))}
{balances && balances.pageCount > 1 && ( )} ) : ( <>
{canVoid && } {movements?.items.map((m) => ( { runSearch(movements?.page ?? 1); getBillingStats() .then(setStats) .catch(() => setStats(null)); }} /> ))}
Fecha Cliente Línea Concepto Referencia MontoAcciones
{movements && movements.pageCount > 1 && ( )} )} ); } /** Headline counts; the owing/credit cells double as worklist shortcuts. */ function BillingStatStrip({ stats, currency, balanceFilter, onPickBalance, }: { stats: BillingStats | null; currency: LedgerCurrency; balanceFilter: BalanceFilter | null; onPickBalance: (f: BalanceFilter, cur?: LedgerCurrency) => void; }) { if (!stats) { return (
{Array.from({ length: 5 }).map((_, i) => (
))}
); } const cur = stats.byCurrency.find((c) => c.currency === currency); return (
{formatNumber(stats.movements)}
Movimientos · {formatDate(stats.firstMovement)} a{" "} {formatDate(stats.lastMovement)}
{formatNumber(stats.crossLineCustomers)}
Con movimientos en ambas líneas
); } /** * Charges vs credits for the whole ledger, per currency. Kept as two separate * chips rather than one figure: the two currencies are never added together. */ function LedgerTotalsStrip({ stats }: { stats: BillingStats | null }) { if (!stats || stats.byCurrency.length === 0) return null; return (
Movimiento histórico {stats.byCurrency.map((c) => (
{c.currency} {formatMoney(c.charges, c.currency)} en cargos · {formatNumber(c.chargeCount)} {formatMoney(c.credits, c.currency)} en abonos · {formatNumber(c.creditCount)}
))}
); } /** Totals for everything the current movement filter matched, not just the page. */ function FilteredTotals({ totals, }: { totals: MovementListResponse["totals"]; }) { if (totals.length === 0) return null; return (
{totals.map((t) => (
{t.currency} {formatMoney(t.charges, t.currency)} {" "} cargos {formatMoney(t.credits, t.currency)} {" "} abonos Neto {formatMoney(t.net, t.currency)}
))}
); } function BalanceRow({ b, currency, }: { b: BalanceListItem; currency: LedgerCurrency; }) { const selected = b.balances.find((x) => x.currency === currency) ?? b.balances[0]; const other = b.balances.find((x) => x.currency !== currency); const tone = balanceTone(selected.balance); const location = [b.city?.replace(/,\s*$/, ""), b.state] .filter(Boolean) .join(", "); return (
{b.name} {b.utilityMovements > 0 && ( Servicios )} {b.insuranceMovements > 0 && ( Seguros )}
{location && {location}} {location && ·} {formatNumber(b.movements)}{" "} {b.movements === 1 ? "movimiento" : "movimientos"} · último {formatDate(b.lastMovement)}
{formatMoney(selected.balance, selected.currency)}
{balancePhrase(selected.balance)} · {selected.currency}
{other && Math.abs(Number(other.balance)) >= 0.005 && (
{formatMoney(other.balance, other.currency)} en {other.currency}
)}
); } function MovementRow({ m, canVoid, onVoided, }: { m: MovementListItem; canVoid: boolean; onVoided: () => void; }) { 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)} {m.customerName} {domainLabel(m.domain)} {txTypeLabel(m.type)} {m.message &&
{m.message}
} {m.reference || m.checkNumber || "—"}
{ledgerSourceLabel(m.source)}
{formatMoney(m.amount, m.currency)}
{m.currency} · {directionLabel(m.direction)}
{canVoid && ( {!m.voided && ( )} )} ); } function Pager({ page, pageCount, onChange, }: { page: number; pageCount: number; onChange: (p: number) => void; }) { return ( ); } function ListSkeleton() { return (
{Array.from({ length: 8 }).map((_, i) => (
))}
); } function EmptyState({ query, view }: { query: string; view: View }) { return (

Sin resultados

{query ? `No encontramos ${ view === "saldos" ? "clientes" : "movimientos" } para “${query}”.` : `No hay ${ view === "saldos" ? "saldos" : "movimientos" } que coincidan con los filtros.`}

); }