Files
jorgecuadros-platform/apps/web/src/app/estado-cuenta/page.tsx
T
rmancinas 8802f08d4f
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m35s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m11s
feat(reports): reports module + /inicio + edo-cuenta-datos prefill
- New reports backend (registry, service, controller, outputs, types)
  with catalog endpoint + slug/CSV/XLSX/PDF/print outputs.
- /reportes catalog + /reportes/[slug] runner; ReportRunner + ContextReports
  components wire pre-filtered links from domain pages.
- Fix: /reportes/[slug] now reads searchParams and forwards initialParams to
  ReportRunner so /reportes/edo-cuenta-datos?customerId=... auto-runs
  instead of dropping the id and forcing a manual customer search.
- /inicio landing page; root + login redirect to /inicio.
- Company header env vars + logo asset for PDF/print rendering.
- exceljs + pdfkit deps.
2026-07-23 23:20:41 -07:00

941 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 (AZ)" },
];
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 (AZ)" },
];
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 (
<AppShell>
<BillingBrowser />
</AppShell>
);
}
function BillingBrowser() {
const canCapture = useCan("ledger:create");
const canVoid = useCan("ledger:void");
const [stats, setStats] = useState<BillingStats | null>(null);
const [facets, setFacets] = useState<BillingFacets | null>(null);
const [view, setView] = useState<View>("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<LedgerCurrency>("MXN");
const [query, setQuery] = useState("");
const [domain, setDomain] = useState<TransactionDomain | "">("");
const [balanceFilter, setBalanceFilter] = useState<BalanceFilter>("owing");
const [balanceSort, setBalanceSort] = useState<BalanceSort>("owing_desc");
const [direction, setDirection] = useState<LedgerDirection | "">("");
const [typeId, setTypeId] = useState("");
const [source, setSource] = useState("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [movementSort, setMovementSort] = useState<MovementSort>("date_desc");
const [balances, setBalances] = useState<BalanceListResponse | null>(null);
const [movements, setMovements] = useState<MovementListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [captureOpen, setCaptureOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
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 (
<>
<div className="page-head rise">
<p className="eyebrow">Cobranza y facturación</p>
<h1 className="page-title">Estado de cuenta</h1>
<BillingStatStrip
stats={stats}
currency={currency}
balanceFilter={view === "saldos" ? balanceFilter : null}
onPickBalance={pickBalance}
/>
<LedgerTotalsStrip stats={stats} />
</div>
<div style={{ marginTop: 12 }}>
<ContextReports
entries={[
{ slug: "listado-en-rojo", label: "En rojo" },
{ slug: "reporte-de-efectivo", label: "Reporte de efectivo" },
]}
/>
</div>
<div className="toolbar">
<div className="search-box">
<span className="search-icon" aria-hidden>
</span>
<input
className="input search-input"
type="search"
value={query}
onChange={(e) => 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"
/>
</div>
<div className="seg" role="tablist" aria-label="Vista">
{(
[
{ key: "saldos" as View, label: "Saldos por cliente" },
{ key: "movimientos" as View, label: "Movimientos" },
]
).map((v) => (
<button
key={v.key}
type="button"
role="tab"
aria-selected={view === v.key}
className={`seg-btn ${view === v.key ? "active" : ""}`}
onClick={() => setView(v.key)}
>
{v.label}
</button>
))}
</div>
{view === "movimientos" && canCapture && (
<button
type="button"
className="btn btn-primary"
onClick={() => setCaptureOpen((v) => !v)}
>
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
</button>
)}
</div>
{view === "movimientos" && captureOpen && (
<section className="section">
<div className="section-head">
<span className="section-rule cuenta" aria-hidden />
<h2 className="section-title">Capturar movimiento</h2>
</div>
<MovementForm
concepts={facets?.types ?? []}
defaultCurrency={currency}
onSaved={() => {
setCaptureOpen(false);
runSearch(movements?.page ?? 1);
getBillingStats()
.then(setStats)
.catch(() => setStats(null));
}}
onCancel={() => setCaptureOpen(false)}
/>
</section>
)}
<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)}
>
<option value="MXN">Pesos (MXN)</option>
<option value="USD">Dólares (USD)</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 | "")}
>
{DOMAINS.map((d) => (
<option key={d.key} value={d.key}>
{d.label}
</option>
))}
</select>
</label>
{view === "saldos" ? (
<>
<label className="filter-field">
<span className="filter-label">Saldo</span>
<select
className="input select"
value={balanceFilter}
onChange={(e) =>
setBalanceFilter(e.target.value as BalanceFilter)
}
>
{BALANCE_FILTERS.map((b) => (
<option key={b.key} value={b.key}>
{b.label}
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Ordenar por</span>
<select
className="input select"
value={balanceSort}
onChange={(e) => setBalanceSort(e.target.value as BalanceSort)}
>
{BALANCE_SORTS.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</label>
</>
) : (
<>
<label className="filter-field">
<span className="filter-label">Movimiento</span>
<select
className="input select"
value={direction}
onChange={(e) =>
setDirection(e.target.value as LedgerDirection | "")
}
>
{DIRECTIONS.map((d) => (
<option key={d.key} value={d.key}>
{d.label}
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Concepto</span>
<select
className="input select"
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
>
<option value="">Todos los conceptos</option>
{facets?.types.map((t) => (
<option key={t.id} value={t.id}>
{txTypeLabel({ nameEn: t.name })} ({formatNumber(t.count)})
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Origen</span>
<select
className="input select"
value={source}
onChange={(e) => setSource(e.target.value)}
>
<option value="">Todos los orígenes</option>
{facets?.sources.map((s) => (
<option key={s.name} value={s.name}>
{ledgerSourceLabel(s.name)} ({formatNumber(s.count)})
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Desde</span>
<input
className="input"
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
/>
</label>
<label className="filter-field">
<span className="filter-label">Hasta</span>
<input
className="input"
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
/>
</label>
<label className="filter-field">
<span className="filter-label">Ordenar por</span>
<select
className="input select"
value={movementSort}
onChange={(e) =>
setMovementSort(e.target.value as MovementSort)
}
>
{MOVEMENT_SORTS.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</label>
</>
)}
{filtered && (
<button
type="button"
className="btn btn-ghost filter-clear"
onClick={clearFilters}
>
Limpiar filtros
</button>
)}
</div>
{data && !loading && !error && (
<div className="result-meta" aria-live="polite">
{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}”` : ""}
</div>
)}
{view === "movimientos" && movements && !loading && (
<FilteredTotals totals={movements.totals} />
)}
{error ? (
<div className="state-error" role="alert">
{error}
</div>
) : loading ? (
<ListSkeleton />
) : data && data.total === 0 ? (
<EmptyState query={query} view={view} />
) : view === "saldos" ? (
<>
<div className="cust-list">
{balances?.items.map((b) => (
<BalanceRow key={b.id} b={b} currency={currency} />
))}
</div>
{balances && balances.pageCount > 1 && (
<Pager
page={balances.page}
pageCount={balances.pageCount}
onChange={goToPage}
/>
)}
</>
) : (
<>
<div className="card">
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Fecha</th>
<th>Cliente</th>
<th>Línea</th>
<th>Concepto</th>
<th>Referencia</th>
<th className="num">Monto</th>
{canVoid && <th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>}
</tr>
</thead>
<tbody>
{movements?.items.map((m) => (
<MovementRow
key={m.id}
m={m}
canVoid={canVoid}
onVoided={() => {
runSearch(movements?.page ?? 1);
getBillingStats()
.then(setStats)
.catch(() => setStats(null));
}}
/>
))}
</tbody>
</table>
</div>
</div>
{movements && movements.pageCount > 1 && (
<Pager
page={movements.page}
pageCount={movements.pageCount}
onChange={goToPage}
/>
)}
</>
)}
</>
);
}
/** 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 (
<div className="stat-strip" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<div className="stat-cell" key={i}>
<div className="skeleton" style={{ height: 25, width: "60%" }} />
<div
className="skeleton"
style={{ height: 11, width: "80%", marginTop: 8 }}
/>
</div>
))}
</div>
);
}
const cur = stats.byCurrency.find((c) => c.currency === currency);
return (
<div className="stat-strip">
<button
type="button"
className={`stat-cell stat-cell-btn accent${
balanceFilter === "owing" ? " selected" : ""
}`}
onClick={() => onPickBalance("owing")}
aria-pressed={balanceFilter === "owing"}
>
<div className="stat-value">{formatNumber(cur?.owing ?? 0)}</div>
<div className="stat-label">Clientes con adeudo ({currency})</div>
</button>
<button
type="button"
className={`stat-cell stat-cell-btn${
balanceFilter === "credit" ? " selected" : ""
}`}
onClick={() => onPickBalance("credit")}
aria-pressed={balanceFilter === "credit"}
>
<div className="stat-value">{formatNumber(cur?.inCredit ?? 0)}</div>
<div className="stat-label">Con saldo a favor ({currency})</div>
</button>
<button
type="button"
className={`stat-cell stat-cell-btn${
balanceFilter === "all" ? " selected" : ""
}`}
onClick={() => onPickBalance("all")}
aria-pressed={balanceFilter === "all"}
>
<div className="stat-value">{formatNumber(stats.ledgerCustomers)}</div>
<div className="stat-label">Clientes con movimientos</div>
</button>
<div className="stat-cell">
<div className="stat-value">{formatNumber(stats.movements)}</div>
<div className="stat-label">
Movimientos · {formatDate(stats.firstMovement)} a{" "}
{formatDate(stats.lastMovement)}
</div>
</div>
<div className="stat-cell">
<div className="stat-value">
{formatNumber(stats.crossLineCustomers)}
</div>
<div className="stat-label">Con movimientos en ambas líneas</div>
</div>
</div>
);
}
/**
* 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 (
<div className="mix-strip">
<span className="premium-caption">Movimiento histórico</span>
{stats.byCurrency.map((c) => (
<div className="ledger-chip" key={c.currency}>
<span className="ledger-chip-cur">{c.currency}</span>
<span className="ledger-chip-figs">
<span className="tx-amount neg">
{formatMoney(c.charges, c.currency)}
</span>
<span className="ledger-chip-label">
en cargos · {formatNumber(c.chargeCount)}
</span>
</span>
<span className="ledger-chip-figs">
<span className="tx-amount pos">
{formatMoney(c.credits, c.currency)}
</span>
<span className="ledger-chip-label">
en abonos · {formatNumber(c.creditCount)}
</span>
</span>
</div>
))}
</div>
);
}
/** 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 (
<div className="filtered-totals">
{totals.map((t) => (
<div className="filtered-total" key={t.currency}>
<span className="filtered-total-cur">{t.currency}</span>
<span>
<strong className="tx-amount neg">
{formatMoney(t.charges, t.currency)}
</strong>{" "}
cargos
</span>
<span>
<strong className="tx-amount pos">
{formatMoney(t.credits, t.currency)}
</strong>{" "}
abonos
</span>
<span className="filtered-total-net">
Neto <strong>{formatMoney(t.net, t.currency)}</strong>
</span>
</div>
))}
</div>
);
}
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 (
<Link href={`/estado-cuenta/${b.id}`} className="cust-row bal-row">
<div className="cust-main">
<div className="cust-name">
<span className={b.name === SIN_NOMBRE ? "cust-name-missing" : undefined}>
{b.name}
</span>
{b.utilityMovements > 0 && (
<span className="badge badge-servicios">
<span className="dot" /> Servicios
</span>
)}
{b.insuranceMovements > 0 && (
<span className="badge badge-seguros">
<span className="dot" /> Seguros
</span>
)}
</div>
<div className="cust-sub">
{location && <span>{location}</span>}
{location && <span className="sep">·</span>}
<span>
{formatNumber(b.movements)}{" "}
{b.movements === 1 ? "movimiento" : "movimientos"}
</span>
<span className="sep">·</span>
<span>último {formatDate(b.lastMovement)}</span>
</div>
</div>
<div className="bal-side">
<div className={`bal-amount ${tone}`}>
{formatMoney(selected.balance, selected.currency)}
</div>
<div className={`bal-phrase ${tone}`}>
{balancePhrase(selected.balance)} · {selected.currency}
</div>
{other && Math.abs(Number(other.balance)) >= 0.005 && (
<div className="bal-other">
{formatMoney(other.balance, other.currency)} en {other.currency}
</div>
)}
</div>
</Link>
);
}
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 (
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(m.transactionDate)}
</td>
<td>
<Link href={`/estado-cuenta/${m.customerId}`} className="inline-link">
<span
className={
m.customerName === SIN_NOMBRE ? "cust-name-missing" : undefined
}
>
{m.customerName}
</span>
</Link>
</td>
<td className="tx-domain-cell">
<span className={`tx-dot ${m.domain}`} />
{domainLabel(m.domain)}
</td>
<td>
{txTypeLabel(m.type)}
{m.message && <div className="tx-concept">{m.message}</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">
{m.currency} · {directionLabel(m.direction)}
</div>
</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 Pager({
page,
pageCount,
onChange,
}: {
page: number;
pageCount: number;
onChange: (p: number) => void;
}) {
return (
<nav className="pager" aria-label="Paginación">
<button
type="button"
className="btn btn-outline"
onClick={() => onChange(page - 1)}
disabled={page <= 1}
>
Anterior
</button>
<span className="pager-info">
Página <strong>{page}</strong> de {pageCount}
</span>
<button
type="button"
className="btn btn-outline"
onClick={() => onChange(page + 1)}
disabled={page >= pageCount}
>
Siguiente
</button>
</nav>
);
}
function ListSkeleton() {
return (
<div className="cust-list" aria-hidden>
{Array.from({ length: 8 }).map((_, i) => (
<div className="skeleton skel-row" key={i} />
))}
</div>
);
}
function EmptyState({ query, view }: { query: string; view: View }) {
return (
<div className="state-box">
<div className="state-glyph" aria-hidden>
</div>
<h3>Sin resultados</h3>
<p>
{query
? `No encontramos ${
view === "saldos" ? "clientes" : "movimientos"
} para “${query}”.`
: `No hay ${
view === "saldos" ? "saldos" : "movimientos"
} que coincidan con los filtros.`}
</p>
</div>
);
}