Files
jorgecuadros-platform/apps/web/src/app/servicios/[id]/page.tsx
T
rmancinasandClaude Opus 4.8 2c6a6bf60b 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>
2026-07-22 23:29:19 -07:00

570 lines
17 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { getProperty } from "@/lib/api";
import {
expiryPhrase,
formatDate,
formatMoney,
formatNumber,
serviceKindGlyph,
serviceKindLabel,
serviceNoteLabel,
SIN_NOMBRE,
trustStatusLabel,
} from "@/lib/labels";
import type { PropertyDetail, Service, Transaction } from "@/lib/types";
export default function PropiedadDetailPage({
params,
}: {
params: { id: string };
}) {
// Next 14 passes `params` as a plain object here — no `use()` unwrapping.
const { id } = params;
return (
<AppShell>
<Detail id={id} />
</AppShell>
);
}
function Detail({ id }: { id: string }) {
const [data, setData] = useState<PropertyDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
getProperty(id)
.then((d) => {
if (alive) {
setData(d);
setLoading(false);
}
})
.catch((e) => {
if (alive) {
setError(
e?.status === 404
? "No encontramos esta propiedad."
: e?.message ?? "No se pudo cargar la propiedad.",
);
setLoading(false);
}
});
return () => {
alive = false;
};
}, [id]);
if (loading) return <DetailSkeleton />;
if (error)
return (
<>
<BackLink />
<div className="state-error" role="alert">
{error}
</div>
</>
);
if (!data) return null;
return (
<div className="rise">
<BackLink />
<Hero data={data} />
<ClienteSection data={data} />
<ServiciosSection data={data} />
<FideicomisoSection data={data} />
{data.policy && <PolizaSection data={data} />}
<MovimientosSection data={data} />
<DocumentosSection data={data} />
</div>
);
}
function BackLink() {
return (
<Link href="/servicios" className="back-link">
Volver a Propiedades
</Link>
);
}
/* ------------------------------------------------------------------ Hero */
function Hero({ data }: { data: PropertyDetail }) {
const addr = [data.addressLine1, data.addressLine2].filter(Boolean).join(", ");
const phones = [data.phone1, data.phone2, data.phone3].filter(Boolean);
const provenance = [data.legacySourceTable, data.legacyId]
.filter(Boolean)
.join(" #");
const activos = data.services.filter((s) => s.active).length;
const phrase = expiryPhrase(data.daysToTrustDue);
const facts: { label: string; value: string }[] = [
{ label: "Cliente", value: data.customer.name },
{
label: "Servicios",
value:
data.services.length === 0
? "Ninguno"
: `${activos} activos de ${data.services.length}`,
},
{ label: "Municipio", value: data.municipality || "—" },
{
label: "Fideicomiso",
value: data.trustAccount
? formatDate(data.trustAccount.dueDate2)
: "Sin fideicomiso",
},
{ label: "Teléfonos", value: phones.join(" · ") || "—" },
];
return (
<div className="detail-hero">
<div className="hero-top">
<div>
<h1 className="hero-name">{addr || "Propiedad sin dirección"}</h1>
<div className="hero-provenance">
{data.zone ? `Zona ${data.zone} · ` : ""}
{data.customer.name}
</div>
{provenance && (
<div className="hero-provenance">Origen: {provenance}</div>
)}
</div>
<div className="hero-badges">
{data.municipality && (
<span className="badge badge-servicios">
<span className="dot" /> {data.municipality}
</span>
)}
{data.trustAccount ? (
<span className={`badge status-${data.trustStatus}`}>
Fideicomiso · {trustStatusLabel(data.trustStatus)}
{phrase && data.trustStatus !== "expired" ? ` · ${phrase}` : ""}
</span>
) : (
<span className="badge badge-on-dark">Sin fideicomiso</span>
)}
{data.services.length === 0 && (
<span className="badge badge-neutral">Sin servicios</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>
);
}
/* -------------------------------------------------------------- Cliente */
function ClienteSection({ data }: { data: PropertyDetail }) {
const c = data.customer;
const location = [c.city?.replace(/,\s*$/, ""), c.state]
.filter(Boolean)
.join(", ");
return (
<section className="section">
<SectionHead rule="datos" title="Cliente" />
<div className="card">
<Link href={`/clientes/${c.id}`} className="owner-link">
<div>
<div
className={`owner-name${
c.name === SIN_NOMBRE ? " cust-name-missing" : ""
}`}
>
{c.name}
</div>
<div className="cust-sub">
{location && <span>{location}</span>}
{location && (c.phone || c.mobile || c.email) && (
<span className="sep">·</span>
)}
{(c.phone || c.mobile) && <span>{c.phone || c.mobile}</span>}
{c.email && (
<>
<span className="sep">·</span>
<span>{c.email}</span>
</>
)}
{c._count.policies > 0 && (
<>
<span className="sep">·</span>
<span>
{c._count.policies}{" "}
{c._count.policies === 1 ? "póliza" : "pólizas"}
</span>
</>
)}
</div>
</div>
<span className="owner-cta">Ver expediente </span>
</Link>
{data.siblings.length > 0 && (
<div className="linked-props">
<div className="kv-label">
Otras propiedades de este cliente ({data.siblings.length})
</div>
{data.siblings.map((s) => (
<Link key={s.id} href={`/servicios/${s.id}`} className="linked-prop link">
{[s.addressLine1, s.addressLine2].filter(Boolean).join(", ") ||
"Propiedad"}
<span className="muted">
{s.zone ? ` · Zona ${s.zone}` : ""} · {s.serviceCount}{" "}
{s.serviceCount === 1 ? "servicio" : "servicios"}
</span>
</Link>
))}
</div>
)}
</div>
</section>
);
}
/* ------------------------------------------------------------- Servicios */
function ServiciosSection({ data }: { data: PropertyDetail }) {
return (
<section className="section">
<SectionHead
rule="servicios"
title="Servicios"
count={data.services.length}
/>
<div className="card">
{data.services.length === 0 ? (
<div className="empty-inline">
Esta propiedad no tiene servicios registrados.
</div>
) : (
<div className="svc-grid" style={{ padding: 16 }}>
{data.services.map((s) => (
<ServiceCard key={s.id} s={s} />
))}
</div>
)}
</div>
</section>
);
}
function ServiceCard({ s }: { s: Service }) {
const noteLabel = serviceNoteLabel(s.kind);
return (
<div className={`svc-item${s.active ? "" : " inactive"}`}>
<div className="svc-head">
<span className="svc-kind">
<span className="svc-glyph" aria-hidden>
{serviceKindGlyph(s.kind)}
</span>
{serviceKindLabel(s.kind)}
</span>
{!s.active && <span className="badge badge-neutral">Inactivo</span>}
</div>
<div className="svc-detail">
{s.accountNumber && (
<span>
Cuenta: <span className="mono">{s.accountNumber}</span>
</span>
)}
{s.meterNumber && (
<span>
Medidor: <span className="mono">{s.meterNumber}</span>
</span>
)}
{s.route && (
<span>
Ruta: <span className="mono">{s.route}</span>
</span>
)}
{s.dueDay && <span>Día de pago: {s.dueDay}</span>}
{s.notes && (
<span>
{noteLabel ? `${noteLabel}: ` : ""}
{s.notes}
</span>
)}
</div>
</div>
);
}
/* ---------------------------------------------------------- Fideicomiso */
function FideicomisoSection({ data }: { data: PropertyDetail }) {
const t = data.trustAccount;
const phrase = expiryPhrase(data.daysToTrustDue);
return (
<section className="section">
<SectionHead rule="cuenta" title="Fideicomiso" />
<div className="card">
{!t ? (
<div className="empty-inline">
Esta propiedad no tiene fideicomiso registrado.
</div>
) : (
<>
<div className="kv-grid">
<KV label="Banco" value={t.bankName} />
<KV label="Número de fideicomiso" value={t.trustNumber} />
<KV
label="Comisión anual"
value={formatMoney(t.bankFee, "MXN")}
/>
<KV label="Vigencia desde" value={formatDate(t.dueDate1)} />
<KV
label="Próximo vencimiento"
value={
t.dueDate2
? `${formatDate(t.dueDate2)}${phrase ? ` · ${phrase}` : ""}`
: null
}
/>
<KV
label="Estado"
value={trustStatusLabel(data.trustStatus)}
/>
</div>
<div className="section-note" style={{ padding: "0 22px 18px" }}>
La comisión bancaria se cobra cada año en el próximo
vencimiento; el sistema anterior guardaba el par de fechas
(vence1 / vence2) del periodo en curso y del siguiente.
</div>
</>
)}
</div>
</section>
);
}
/* --------------------------------------------------------------- Póliza */
function PolizaSection({ data }: { data: PropertyDetail }) {
const p = data.policy!;
return (
<section className="section">
<SectionHead rule="seguros" title="Póliza vinculada" />
<div className="card">
<Link href={`/polizas/${p.id}`} className="owner-link">
<div>
<div className="owner-name mono">{p.policyNumber || "—"}</div>
<div className="cust-sub">
{p.policyType?.name && <span>{p.policyType.name}</span>}
{p.policyTo && (
<>
<span className="sep">·</span>
<span>Vence {formatDate(p.policyTo)}</span>
</>
)}
</div>
</div>
<span className="owner-cta">Ver póliza </span>
</Link>
</div>
</section>
);
}
/* ---------------------------------------------------------- Movimientos */
function MovimientosSection({ data }: { data: PropertyDetail }) {
return (
<section className="section">
<SectionHead
rule="cuenta"
title="Movimientos de servicios"
count={data.customerTransactions.length}
countSuffix="recientes"
/>
{data.customerLedger.length > 0 && (
<div className="summary-grid">
{data.customerLedger.map((row) => (
<div className="summary-card UTILITY" key={row.currency}>
<div className="summary-domain">
<span className="tx-dot UTILITY" />
Servicios · {row.currency}
</div>
<div className="summary-total">
{formatMoney(row.total, row.currency)}
</div>
<div className="summary-count">
{formatNumber(row.count)}{" "}
{row.count === 1 ? "movimiento" : "movimientos"}
</div>
</div>
))}
</div>
)}
<div className="card">
{data.customerTransactions.length === 0 ? (
<div className="empty-inline">Sin movimientos de servicios.</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Fecha</th>
<th>Tipo</th>
<th>Periodo</th>
<th>Referencia</th>
<th className="num">Monto</th>
</tr>
</thead>
<tbody>
{data.customerTransactions.map((t) => (
<TxRow key={t.id} t={t} />
))}
</tbody>
</table>
</div>
)}
<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={`/estado-cuenta/${data.customerId}`}
className="inline-link"
>
estado de cuenta completo
</Link>
.
</div>
</div>
</section>
);
}
function TxRow({ t }: { t: Transaction }) {
const num = t.amount != null ? Number(t.amount) : NaN;
const sign = !Number.isNaN(num) && num < 0 ? "neg" : "pos";
return (
<tr>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(t.transactionDate)}
</td>
<td>{t.type?.nameEs || t.type?.nameEn || "—"}</td>
<td>{t.period || "—"}</td>
<td className="tx-ref">{t.reference || "—"}</td>
<td className="num">
<span className={`tx-amount ${sign}`}>
{formatMoney(t.amount, t.currency)}
</span>{" "}
<span className="tx-cur">{t.currency}</span>
</td>
</tr>
);
}
/* ----------------------------------------------------------- Documentos */
function DocumentosSection({ data }: { data: PropertyDetail }) {
return (
<section className="section">
<SectionHead rule="docs" title="Documentos" count={data.documents.length} />
<div className="card">
{data.documents.length === 0 ? (
<div className="empty-inline">
No hay documentos registrados para esta propiedad.
</div>
) : (
<>
<div className="doc-list">
{data.documents.map((d, i) => (
<div className="doc-item" key={d.id ?? i}>
<span className="doc-icon" aria-hidden>
</span>
<div style={{ minWidth: 0 }}>
<div className="doc-type">{d.documentType || "Documento"}</div>
<div className="doc-key">{d.storageKey || "—"}</div>
</div>
</div>
))}
</div>
<div className="section-note" style={{ padding: "0 22px 18px" }}>
Los archivos se almacenan en el object storage (storageKey); no se
descargan desde esta vista.
</div>
</>
)}
</div>
</section>
);
}
/* ------------------------------------------------------------ helpers */
function KV({
label,
value,
}: {
label: string;
value: string | null | undefined;
}) {
return (
<div>
<div className="kv-label">{label}</div>
<div className="kv-value">{value || "—"}</div>
</div>
);
}
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">
{count} {countSuffix ?? ""}
</span>
)}
</div>
);
}
function DetailSkeleton() {
return (
<div>
<div
className="skeleton"
style={{ height: 16, width: 160, marginBottom: 18 }}
/>
<div className="skeleton" style={{ height: 180, borderRadius: 16 }} />
<div
className="skeleton"
style={{ height: 160, borderRadius: 16, marginTop: 34 }}
/>
<div
className="skeleton"
style={{ height: 240, borderRadius: 16, marginTop: 34 }}
/>
</div>
);
}