- 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.
941 lines
28 KiB
TypeScript
941 lines
28 KiB
TypeScript
"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 (
|
||
<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>
|
||
);
|
||
}
|