feat(billing): shared statements module across both business lines
Plan step 6 — the payoff of the unified customer record: a utility charge and an insurance payment finally sit on the same page, under the same person, with a running balance. API (apps/api/src/billing/): - GET /billing — cross-customer movement browser. Search over customer, referencia, cheque, concepto and periodo; filters for business line, currency, charge-vs-credit, concept, origin table and a from/to date range; 5 sorts. Returns totals for the whole filtered set, not just the page, so a filtered view can't be misread as the full ledger. - GET /billing/balances — per-customer receivables worklist with owing/credit/settled buckets and 4 sorts. Raw SQL (parameterized via Prisma.sql): needs conditional sums per currency and per direction in one pass plus ordering and pagination on a computed balance, none of which groupBy expresses. - GET /billing/stats, /billing/facets, /billing/customers/:id. Web: - /estado-cuenta — two views over the same ledger, because staff ask two different questions: "Saldos por cliente" (who owes what) and "Movimientos" (every charge and credit). - /estado-cuenta/[id] — the statement: balance per currency, the same balance split by business line, charges broken out by concept, and the full movement list with a running balance. - Cross-linked from the customer and property detail pages. Two data findings shape the whole module: 1. transactions.amount is a signed ledger. Every charge type is negative without exception (WATER 3115/3117, ELECTRIC 2191/2191, PROPERTY TAXES 926/926, TRUST FEE 188/188) and every deposit type positive (CHECK and CASH DEPOSIT, PAYPAL, all of EFECTIVO). So SUM(amount) is the balance and negative means the customer owes the office. 2. Currency is not summable. 912 of the 1269 customers with a ledger move in both MXN and USD, the charge side is MXN-only while receipts arrive in both, and no per-movement exchange rate was ever stored. A single "total balance" would be a figure that never existed in the books, so every total is reported per currency and the balance filter/sort takes a currency argument rather than collapsing. Also: type_transactions.nameEs is entirely null (the legacy TYPE OF TRX ESPAÑOL column is empty in all 79 rows), so Spanish concept names come from a label map in labels.ts; the entries that are payee names rather than categories fall through untranslated, which is correct. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,7 @@ function Detail({ id }: { id: string }) {
|
||||
<PropiedadesSection properties={data.properties} />
|
||||
<PolizasSection policies={data.policies} />
|
||||
<EstadoCuentaSection
|
||||
customerId={data.id}
|
||||
summary={data.transactionSummary}
|
||||
transactions={data.transactions}
|
||||
/>
|
||||
@@ -574,9 +575,11 @@ function InstallmentRow({ inst }: { inst: Installment }) {
|
||||
|
||||
/* ------------------------------------------------------- Estado de cuenta */
|
||||
function EstadoCuentaSection({
|
||||
customerId,
|
||||
summary,
|
||||
transactions,
|
||||
}: {
|
||||
customerId: string;
|
||||
summary: TransactionSummaryRow[];
|
||||
transactions: Transaction[];
|
||||
}) {
|
||||
@@ -639,6 +642,14 @@ function EstadoCuentaSection({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{transactions.length > 0 && (
|
||||
<p className="section-note">
|
||||
<Link href={`/estado-cuenta/${customerId}`} className="inline-link">
|
||||
Ver estado de cuenta completo →
|
||||
</Link>{" "}
|
||||
con saldo, saldo corrido y desglose por línea de negocio.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { getStatement } from "@/lib/api";
|
||||
import {
|
||||
balancePhrase,
|
||||
balanceTone,
|
||||
directionLabel,
|
||||
domainLabel,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatNumber,
|
||||
ledgerSourceLabel,
|
||||
SIN_NOMBRE,
|
||||
txTypeLabel,
|
||||
} from "@/lib/labels";
|
||||
import type {
|
||||
LedgerCurrency,
|
||||
Statement,
|
||||
StatementMovement,
|
||||
TransactionDomain,
|
||||
} from "@/lib/types";
|
||||
|
||||
/**
|
||||
* One customer's statement across both business lines — the payoff of plan
|
||||
* step 6 and, ultimately, of the whole unified-customer project: a utility
|
||||
* charge and an insurance payment finally sit on the same page, under the same
|
||||
* person, with a running balance.
|
||||
*
|
||||
* The running balance is per currency (the API accumulates it chronologically
|
||||
* before handing the list back newest-first), so the movement table is scoped
|
||||
* to one currency at a time — a column that alternated between pesos and
|
||||
* dollars would be a meaningless number.
|
||||
*/
|
||||
export default function EstadoCuentaDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const { id } = params;
|
||||
return (
|
||||
<AppShell>
|
||||
<StatementView id={id} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function StatementView({ id }: { id: string }) {
|
||||
const [data, setData] = useState<Statement | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
|
||||
const [domain, setDomain] = useState<TransactionDomain | "">("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getStatement(id)
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
setData(d);
|
||||
// Default to the currency the customer actually moves the most in.
|
||||
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
|
||||
setCurrency(busiest?.currency ?? "MXN");
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setError(
|
||||
e?.status === 404
|
||||
? "No encontramos este cliente."
|
||||
: e?.message ?? "No se pudo cargar el estado de cuenta.",
|
||||
);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const movements = useMemo(() => {
|
||||
if (!data || !currency) return [];
|
||||
return data.movements.filter(
|
||||
(m) => m.currency === currency && (!domain || m.domain === domain),
|
||||
);
|
||||
}, [data, currency, domain]);
|
||||
|
||||
if (loading) return <StatementSkeleton />;
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<>
|
||||
<BackLink />
|
||||
<div className="state-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!data || !currency) return null;
|
||||
|
||||
const active = data.summary.find((s) => s.currency === currency);
|
||||
|
||||
return (
|
||||
<div className="rise">
|
||||
<BackLink />
|
||||
<Hero data={data} />
|
||||
|
||||
<section className="section">
|
||||
<SectionHead rule="cuenta" title="Saldo por moneda" />
|
||||
{data.summary.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="empty-inline">
|
||||
Este cliente no tiene movimientos registrados.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="summary-grid">
|
||||
{data.summary.map((s) => {
|
||||
const tone = balanceTone(s.balance);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={s.currency}
|
||||
className={`summary-card bal-card ${tone}${
|
||||
currency === s.currency ? " selected" : ""
|
||||
}`}
|
||||
onClick={() => setCurrency(s.currency)}
|
||||
aria-pressed={currency === s.currency}
|
||||
>
|
||||
<div className="summary-domain">
|
||||
Saldo en {s.currency} · {balancePhrase(s.balance)}
|
||||
</div>
|
||||
<div className={`summary-total bal-amount ${tone}`}>
|
||||
{formatMoney(s.balance, s.currency)}
|
||||
</div>
|
||||
<div className="bal-breakdown">
|
||||
<span className="tx-amount neg">
|
||||
{formatMoney(s.charges, s.currency)}
|
||||
</span>
|
||||
<span className="bal-breakdown-label">
|
||||
{formatNumber(s.chargeCount)} cargos
|
||||
</span>
|
||||
<span className="tx-amount pos">
|
||||
{formatMoney(s.credits, s.currency)}
|
||||
</span>
|
||||
<span className="bal-breakdown-label">
|
||||
{formatNumber(s.creditCount)} abonos
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-count">
|
||||
{formatDate(s.firstMovement)} a {formatDate(s.lastMovement)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="section-note">
|
||||
Los saldos se muestran por separado en cada moneda. La contabilidad
|
||||
heredada registró los cargos únicamente en pesos y los recibos en
|
||||
ambas monedas, sin guardar el tipo de cambio aplicado a cada
|
||||
movimiento, por lo que sumarlas produciría una cifra que nunca existió
|
||||
en los libros.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<PorLineaSection data={data} currency={currency} />
|
||||
<ConceptosSection data={data} currency={currency} />
|
||||
|
||||
<section className="section">
|
||||
<SectionHead
|
||||
rule="cuenta"
|
||||
title="Movimientos"
|
||||
count={movements.length}
|
||||
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
|
||||
/>
|
||||
|
||||
<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)}
|
||||
>
|
||||
{data.summary.map((s) => (
|
||||
<option key={s.currency} value={s.currency}>
|
||||
{s.currency} ({formatNumber(s.count)})
|
||||
</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 | "")
|
||||
}
|
||||
>
|
||||
<option value="">Ambas líneas</option>
|
||||
<option value="UTILITY">Servicios</option>
|
||||
<option value="INSURANCE">Seguros</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{movements.length === 0 ? (
|
||||
<div className="empty-inline">
|
||||
Sin movimientos en {currency}
|
||||
{domain ? ` para ${domainLabel(domain)}` : ""}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Línea</th>
|
||||
<th>Concepto</th>
|
||||
<th>Referencia</th>
|
||||
<th className="num">Cargo / Abono</th>
|
||||
<th className="num">Saldo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements.map((m) => (
|
||||
<StatementRow key={m.id} m={m} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{domain && movements.length > 0 && (
|
||||
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
||||
La columna de saldo es el saldo acumulado del cliente en{" "}
|
||||
{currency} sobre <strong>todas</strong> sus líneas — filtrar por
|
||||
línea oculta filas, no las descuenta.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{active && (
|
||||
<p className="section-note">
|
||||
Saldo final en {currency}:{" "}
|
||||
<strong>{formatMoney(active.balance, currency)}</strong> (
|
||||
{balancePhrase(active.balance).toLowerCase()}).
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link href="/estado-cuenta" className="back-link">
|
||||
← Volver a Estado de cuenta
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Hero({ data }: { data: Statement }) {
|
||||
const c = data.customer;
|
||||
const location = [c.city?.replace(/,\s*$/, ""), c.state]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
const facts: { label: string; value: string }[] = [
|
||||
{ label: "Cliente desde", value: formatDate(c.customerSince) },
|
||||
{ label: "Propiedades", value: String(c.propertyCount) },
|
||||
{ label: "Pólizas", value: String(c.policyCount) },
|
||||
{ label: "Teléfono", value: c.phone || c.mobile || "—" },
|
||||
{ label: "Correo", value: c.email || "—" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="detail-hero">
|
||||
<div className="hero-top">
|
||||
<div>
|
||||
<h1
|
||||
className={`hero-name${
|
||||
c.name === SIN_NOMBRE ? " hero-name-missing" : ""
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</h1>
|
||||
{location && <div className="hero-provenance">{location}</div>}
|
||||
{c.nameSource && (
|
||||
<div className="hero-provenance">
|
||||
Nombre recuperado de {c.nameSource} — el registro original no
|
||||
tenía nombre.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="hero-badges">
|
||||
{c.propertyCount > 0 && (
|
||||
<span className="badge badge-servicios">
|
||||
<span className="dot" /> Servicios
|
||||
</span>
|
||||
)}
|
||||
{c.policyCount > 0 && (
|
||||
<span className="badge badge-seguros">
|
||||
<span className="dot" /> Seguros
|
||||
</span>
|
||||
)}
|
||||
<span className={`badge ${c.status ? "badge-on-dark" : "badge-negative"}`}>
|
||||
{c.status ? "Activo" : "Inactivo"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-facts">
|
||||
{facts.map((f) => (
|
||||
<div key={f.label}>
|
||||
<div className="hero-fact-label">{f.label}</div>
|
||||
<div className="hero-fact-value">{f.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hero-links">
|
||||
<Link href={`/clientes/${c.id}`} className="btn btn-outline">
|
||||
Ver ficha del cliente
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The cross-line split — the same balance, broken out by business line. */
|
||||
function PorLineaSection({
|
||||
data,
|
||||
currency,
|
||||
}: {
|
||||
data: Statement;
|
||||
currency: LedgerCurrency;
|
||||
}) {
|
||||
const rows = data.byDomain.filter((d) => d.currency === currency);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="cuenta" title={`Por línea de negocio · ${currency}`} />
|
||||
<div className="summary-grid">
|
||||
{rows.map((r) => (
|
||||
<div className={`summary-card ${r.domain}`} key={r.domain}>
|
||||
<div className="summary-domain">
|
||||
<span className={`tx-dot ${r.domain}`} />
|
||||
{domainLabel(r.domain)}
|
||||
</div>
|
||||
<div className={`summary-total bal-amount ${balanceTone(r.balance)}`}>
|
||||
{formatMoney(r.balance, currency)}
|
||||
</div>
|
||||
<div className="bal-breakdown">
|
||||
<span className="tx-amount neg">
|
||||
{formatMoney(r.charges, currency)}
|
||||
</span>
|
||||
<span className="bal-breakdown-label">en cargos</span>
|
||||
<span className="tx-amount pos">
|
||||
{formatMoney(r.credits, currency)}
|
||||
</span>
|
||||
<span className="bal-breakdown-label">en abonos</span>
|
||||
</div>
|
||||
<div className="summary-count">
|
||||
{formatNumber(r.count)}{" "}
|
||||
{r.count === 1 ? "movimiento" : "movimientos"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where the charges went — the question a customer asks about their balance. */
|
||||
function ConceptosSection({
|
||||
data,
|
||||
currency,
|
||||
}: {
|
||||
data: Statement;
|
||||
currency: LedgerCurrency;
|
||||
}) {
|
||||
const rows = data.byType.filter((t) => t.currency === currency).slice(0, 12);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const largest = Math.abs(Number(rows[0]?.total ?? 0)) || 1;
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="servicios" title={`Cargos por concepto · ${currency}`} />
|
||||
<div className="card">
|
||||
<div className="concept-list">
|
||||
{rows.map((t) => (
|
||||
<div className="concept-row" key={`${t.name}-${t.currency}`}>
|
||||
<div className="concept-name">
|
||||
{txTypeLabel({ nameEn: t.name })}
|
||||
<span className="concept-count">
|
||||
{formatNumber(t.count)}{" "}
|
||||
{t.count === 1 ? "cargo" : "cargos"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="concept-bar" aria-hidden>
|
||||
<span
|
||||
style={{
|
||||
width: `${Math.max(
|
||||
2,
|
||||
(Math.abs(Number(t.total)) / largest) * 100,
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="concept-total tx-amount neg">
|
||||
{formatMoney(t.total, currency)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatementRow({ m }: { m: StatementMovement }) {
|
||||
const concept = m.message || m.period || null;
|
||||
return (
|
||||
<tr>
|
||||
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDate(m.transactionDate)}
|
||||
</td>
|
||||
<td className="tx-domain-cell">
|
||||
<span className={`tx-dot ${m.domain}`} />
|
||||
{domainLabel(m.domain)}
|
||||
</td>
|
||||
<td>
|
||||
{txTypeLabel(m.type)}
|
||||
{concept && <div className="tx-concept">{concept}</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>
|
||||
<span className="tx-cur">{directionLabel(m.direction)}</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
<span className={`bal-running ${balanceTone(m.balanceAfter)}`}>
|
||||
{formatMoney(m.balanceAfter, m.currency)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({
|
||||
rule,
|
||||
title,
|
||||
count,
|
||||
countSuffix,
|
||||
}: {
|
||||
rule: string;
|
||||
title: string;
|
||||
count?: number;
|
||||
countSuffix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="section-head">
|
||||
<span className={`section-rule ${rule}`} aria-hidden />
|
||||
<h2 className="section-title">{title}</h2>
|
||||
{count != null && (
|
||||
<span className="section-count">
|
||||
{formatNumber(count)} {countSuffix ?? ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatementSkeleton() {
|
||||
return (
|
||||
<div aria-hidden>
|
||||
<div className="skeleton" style={{ height: 18, width: 180 }} />
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 150, marginTop: 16, borderRadius: 16 }}
|
||||
/>
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ height: 320, marginTop: 24, borderRadius: 16 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,845 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import {
|
||||
getBillingFacets,
|
||||
getBillingStats,
|
||||
listBalances,
|
||||
listMovements,
|
||||
} from "@/lib/api";
|
||||
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 [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 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 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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements?.items.map((m) => (
|
||||
<MovementRow key={m.id} m={m} />
|
||||
))}
|
||||
</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 }: { m: MovementListItem }) {
|
||||
return (
|
||||
<tr>
|
||||
<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>
|
||||
<span className="tx-cur">
|
||||
{m.currency} · {directionLabel(m.direction)}
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1820,3 +1820,228 @@ button {
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Estado de cuenta (billing / statements) — plan step 6
|
||||
================================================================ */
|
||||
|
||||
/* A balance is signed: negative means the customer owes the office, positive
|
||||
means they hold a credit. `flat` is a real state (settled to zero), not a
|
||||
fallback, so it gets its own muted treatment rather than inheriting either. */
|
||||
.bal-amount {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-feature-settings: "tnum" 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bal-amount.owing {
|
||||
color: var(--negative);
|
||||
}
|
||||
.bal-amount.credit {
|
||||
color: var(--positive);
|
||||
}
|
||||
.bal-amount.flat {
|
||||
color: var(--muted);
|
||||
}
|
||||
.bal-running {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
font-feature-settings: "tnum" 1;
|
||||
white-space: nowrap;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.bal-running.owing {
|
||||
color: var(--negative);
|
||||
}
|
||||
.bal-running.credit {
|
||||
color: var(--positive);
|
||||
}
|
||||
|
||||
.bal-row .bal-side {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: flex-end;
|
||||
min-width: 160px;
|
||||
}
|
||||
.bal-side .bal-amount {
|
||||
font-size: 16px;
|
||||
}
|
||||
.bal-phrase {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.bal-phrase.owing {
|
||||
color: var(--negative);
|
||||
}
|
||||
.bal-phrase.credit {
|
||||
color: var(--positive);
|
||||
}
|
||||
.bal-phrase.flat {
|
||||
color: var(--muted);
|
||||
}
|
||||
/* The customer's other-currency balance, shown so a peso figure is never
|
||||
mistaken for the whole picture. */
|
||||
.bal-other {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.bal-row .bal-side {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Currency cards on the statement double as the movement-table currency
|
||||
switch, so they are buttons, not divs. */
|
||||
.bal-card {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
border: 1px solid var(--line);
|
||||
transition: border-color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.bal-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.bal-card.selected {
|
||||
border-color: var(--brand-600);
|
||||
box-shadow: 0 0 0 1px var(--brand-600);
|
||||
}
|
||||
.bal-breakdown {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 1px 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.bal-breakdown-label {
|
||||
color: var(--muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* Ledger-wide charge/credit totals in the page header. */
|
||||
.ledger-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.ledger-chip-cur {
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ledger-chip-figs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.ledger-chip-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Totals for the current movement filter — deliberately above the table, so a
|
||||
filtered view can't be read as if it were the whole ledger. */
|
||||
.filtered-totals {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.filtered-total {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-2);
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.filtered-total-cur {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.filtered-total-net strong {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Charges broken out by concept, with a proportional bar. */
|
||||
.concept-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.concept-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 1.2fr) minmax(60px, 2fr) auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.concept-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.concept-name {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.concept-count {
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
.concept-bar {
|
||||
height: 7px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.concept-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--negative);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.concept-total {
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.concept-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
.concept-bar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cross-link out of a detail hero (statement -> customer file). */
|
||||
.hero-links {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -438,7 +438,10 @@ function MovimientosSection({ data }: { data: PropertyDetail }) {
|
||||
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
||||
Los movimientos pertenecen al cliente, no a esta propiedad: el
|
||||
sistema anterior nunca ligó un pago a una propiedad concreta. Ver el{" "}
|
||||
<Link href={`/clientes/${data.customerId}`} className="inline-link">
|
||||
<Link
|
||||
href={`/estado-cuenta/${data.customerId}`}
|
||||
className="inline-link"
|
||||
>
|
||||
estado de cuenta completo
|
||||
</Link>
|
||||
.
|
||||
|
||||
Reference in New Issue
Block a user