Implements docs/RECEIPT_CAPTURE_SPEC.md §1, the legacy "Editor" replacement, on top of the single-movement capture from plan step 6. No new abilities: batching and resolving are both capturing. - outstanding (legacy NOPAGO): capture flag, ?outstanding= filter, and POST /billing/:id/resolve-outstanding (gated ledger:create, not ledger:void — resolving completes a capture rather than reversing one). Outstanding rows are excluded from every balance aggregate, matching the legacy SALDOS ULTIMO 0 query's HAVING NOPAGO = 0, but still count in the movement browser's filtered totals. - POST /billing/batch: many customers' receipts against one check, in one $transaction. Deliberately not a persisted batch entity — checkNumber is already a column and grouping by it answers every legacy by-check query. - GET /billing/by-check + a cheque-count report, replacing REPORTE CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE ALF|COUNT|NUM. Print, PDF, CSV and XLSX come free from the existing /reportes/:slug machinery. - Web: /estado-cuenta/lote (the Editor screen, with live reconciliation against the physical check amount), an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog, plus a top-level "Captura" nav entry. Integration seam for the OCR auto-capture module (spec §2), which is required to post through createBatch rather than writing Transaction rows itself: items[i] maps to lines[i] so postedTransactionId can be zipped back on; opts.refs[i] stamps captureRef with a duplicate-post guard that a voided row deliberately does not block; opts.source is service-level only, so an HTTP client cannot label hand-keyed rows as machine-captured. captureSource/captureRef are nullable so the 40,136 migrated rows stay NULL rather than being mislabelled. Fixes two pre-existing bugs found while building this: - statement() filtered legacySourceTable with `notIn`, which compiles to SQL NOT IN — and `NULL NOT IN (...)` is NULL, so every app-captured movement was invisible on the customer statement (438 rows in the movement browser vs 392 on the statement) while showing everywhere else. This would have made the whole capture feature look broken. - The balances count query omitted the void filter its own page query applied, so the total disagreed with the rows. Nav highlighting now resolves by longest match; the previous first-startsWith logic lit up both the parent and any nested entry. Verified end-to-end against the dev DB, API and browser; all test rows removed afterwards. Also corrects RESUME.md, which documented the dev ports as :3001/:3000 — they are :4501/:4500, from the env files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1098 lines
33 KiB
TypeScript
1098 lines
33 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,
|
||
resolveOutstanding,
|
||
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("");
|
||
// "" = no filter, "true" = only NOPAGO rows, "false" = only settled ones.
|
||
const [outstanding, setOutstanding] = useState<"" | "true" | "false">("");
|
||
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 [resolving, setResolving] = useState<MovementListItem | null>(null);
|
||
|
||
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,
|
||
outstanding: outstanding === "" ? undefined : outstanding === "true",
|
||
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,
|
||
outstanding,
|
||
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 && (
|
||
<div style={{ display: "flex", gap: 10 }}>
|
||
<Link href="/estado-cuenta/lote" className="btn btn-outline">
|
||
Captura por cheque
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
onClick={() => setCaptureOpen((v) => !v)}
|
||
>
|
||
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{view === "movimientos" && resolving && (
|
||
<ResolveDialog
|
||
movement={resolving}
|
||
onCancel={() => setResolving(null)}
|
||
onDone={() => {
|
||
setResolving(null);
|
||
runSearch(movements?.page ?? 1);
|
||
getBillingStats()
|
||
.then(setStats)
|
||
.catch(() => setStats(null));
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{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">Estado de pago</span>
|
||
<select
|
||
className="input select"
|
||
value={outstanding}
|
||
onChange={(e) =>
|
||
setOutstanding(e.target.value as "" | "true" | "false")
|
||
}
|
||
>
|
||
<option value="">Todos</option>
|
||
<option value="true">Sin fondos (pendientes)</option>
|
||
<option value="false">Pagados</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 || canCapture) && (
|
||
<th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{movements?.items.map((m) => (
|
||
<MovementRow
|
||
key={m.id}
|
||
m={m}
|
||
canVoid={canVoid}
|
||
canCapture={canCapture}
|
||
onResolve={setResolving}
|
||
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,
|
||
canCapture,
|
||
onVoided,
|
||
onResolve,
|
||
}: {
|
||
m: MovementListItem;
|
||
canVoid: boolean;
|
||
canCapture: boolean;
|
||
onVoided: () => void;
|
||
onResolve: (m: MovementListItem) => 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)}
|
||
{m.outstanding && !m.voided && (
|
||
<>
|
||
{" · "}
|
||
<span className="tx-outstanding">sin fondos</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</td>
|
||
{(canVoid || canCapture) && (
|
||
<td style={{ whiteSpace: "nowrap" }}>
|
||
{/* Resolver only makes sense on a live outstanding row, and it's a
|
||
capture action (completing one), not a void. */}
|
||
{!m.voided && m.outstanding && canCapture && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost"
|
||
style={{ padding: "4px 10px", fontSize: 12 }}
|
||
onClick={() => onResolve(m)}
|
||
>
|
||
Resolver
|
||
</button>
|
||
)}
|
||
{!m.voided && canVoid && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost"
|
||
style={{ padding: "4px 10px", fontSize: 12 }}
|
||
onClick={doVoid}
|
||
disabled={busy}
|
||
>
|
||
{busy ? "Anulando…" : "Anular"}
|
||
</button>
|
||
)}
|
||
</td>
|
||
)}
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Resolve an outstanding row: the check finally got cut. Takes the check number
|
||
* and the date it was paid, which also becomes the movement's date — the legacy
|
||
* behavior, since the ledger date is when money actually moved.
|
||
*/
|
||
function ResolveDialog({
|
||
movement,
|
||
onDone,
|
||
onCancel,
|
||
}: {
|
||
movement: MovementListItem;
|
||
onDone: () => void;
|
||
onCancel: () => void;
|
||
}) {
|
||
const [checkNumber, setCheckNumber] = useState("");
|
||
const [resolvedDate, setResolvedDate] = useState(
|
||
new Date().toISOString().slice(0, 10),
|
||
);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!checkNumber.trim()) {
|
||
setError("Indica el número de cheque.");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
await resolveOutstanding(movement.id, {
|
||
checkNumber: checkNumber.trim(),
|
||
resolvedDate,
|
||
});
|
||
onDone();
|
||
} catch (e2) {
|
||
setError((e2 as Error)?.message ?? "No se pudo resolver el movimiento.");
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||
<h2 className="section-title" style={{ marginBottom: 6 }}>
|
||
Resolver movimiento sin fondos
|
||
</h2>
|
||
<p className="muted" style={{ marginBottom: 14 }}>
|
||
{movement.customerName} · {formatMoney(movement.amount, movement.currency)}{" "}
|
||
{movement.currency}
|
||
{movement.reference ? ` · ${movement.reference}` : ""}
|
||
</p>
|
||
{error && <div className="state-box state-error">{error}</div>}
|
||
<form onSubmit={submit}>
|
||
<div className="form-grid">
|
||
<label className="field">
|
||
<span className="field-label">Número de cheque *</span>
|
||
<input
|
||
className="input"
|
||
value={checkNumber}
|
||
onChange={(e) => setCheckNumber(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
</label>
|
||
<label className="field">
|
||
<span className="field-label">Fecha de pago *</span>
|
||
<input
|
||
className="input"
|
||
type="date"
|
||
required
|
||
value={resolvedDate}
|
||
onChange={(e) => setResolvedDate(e.target.value)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<p className="muted" style={{ fontSize: 13, marginTop: 10 }}>
|
||
El movimiento tomará esta fecha y empezará a contar en el saldo del
|
||
cliente.
|
||
</p>
|
||
<div className="form-actions">
|
||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||
{busy ? "Resolviendo…" : "Resolver"}
|
||
</button>
|
||
<button type="button" className="btn btn-outline" onClick={onCancel}>
|
||
Cancelar
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|