Files
jorgecuadros-platform/apps/web/src/app/clientes/[id]/page.tsx
T
rmancinasandClaude Opus 4.8 e2aba8bd17 Insurance module: policy browser (list/search/detail) + renewals view
Plan step 4. Adds the policies API and the Spanish-first /polizas pages on
top of the customer records the customer module already exposes.

API (apps/api/src/policies):
- GET /policies — search over policy number, customer name, agent, vehicle
  license plate, insured-driver name and legacy id; filters for vigencia
  bucket, ramo, aseguradora and liquidation state; five sort orders.
- GET /policies/stats — bucket counts plus premium in force split by
  currency (MXN and USD can't be summed).
- GET /policies/facets — ramos/aseguradoras with counts for the dropdowns.
- GET /policies/:id — full policy plus the owning customer.

Vigencia is derived from policyTo as active/expiring/expired/undated.
"undated" is a real bucket rather than an error case: 528 of the 2378
migrated policies carry no end date at all.

Web:
- /polizas — renewals-first browser; the stat cells double as vigencia
  filters, with a secondary row for ramo, aseguradora and sort order.
- /polizas/[id] — vigencia hero, condiciones y primas, pagos, vehículos,
  asegurados/beneficiarios, siniestros, the verbatim legacy coverage
  columns, and documents.
- Nav gains Clientes | Pólizas with a real active state, and the two
  modules cross-link in both directions.

Also fixes a display bug on the customer detail page: it headlined
policies.total, which is dead data — only 2 of 2378 rows are non-zero
(1585 are literally 0, 791 null), and one of those two is lower than its
own net premium. That rendered "$0.00 Total" on 1585 policies. Premium
headlines and the premium sort now use netPremium (2377/2378 populated);
total is shown only where it is non-zero, as raw source data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:27:38 -07:00

776 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { getCustomer } from "@/lib/api";
import {
domainLabel,
formatDate,
formatMoney,
premiumHeadline,
serviceKindGlyph,
serviceKindLabel,
SIN_NOMBRE,
sourceSystemLabel,
} from "@/lib/labels";
import type {
CustomerDetail,
Installment,
Policy,
Property,
Transaction,
TransactionSummaryRow,
} from "@/lib/types";
export default function ClienteDetailPage({
params,
}: {
params: { id: string };
}) {
const { id } = params;
return (
<AppShell>
<Detail id={id} />
</AppShell>
);
}
function Detail({ id }: { id: string }) {
const [data, setData] = useState<CustomerDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
getCustomer(id)
.then((d) => {
if (alive) {
setData(d);
setLoading(false);
}
})
.catch((e) => {
if (alive) {
setError(
e?.status === 404
? "No encontramos este cliente."
: e?.message ?? "No se pudo cargar el cliente.",
);
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;
const hasUtilities = data.properties.length > 0;
const hasInsurance = data.policies.length > 0;
return (
<div className="rise">
<BackLink />
<Hero data={data} hasUtilities={hasUtilities} hasInsurance={hasInsurance} />
<DatosSection data={data} />
<PropiedadesSection properties={data.properties} />
<PolizasSection policies={data.policies} />
<EstadoCuentaSection
summary={data.transactionSummary}
transactions={data.transactions}
/>
<DocumentosSection data={data} />
</div>
);
}
function BackLink() {
return (
<Link href="/clientes" className="back-link">
Volver a Clientes
</Link>
);
}
/* ------------------------------------------------------------------ Hero */
function Hero({
data,
hasUtilities,
hasInsurance,
}: {
data: CustomerDetail;
hasUtilities: boolean;
hasInsurance: boolean;
}) {
const provenance = data.legacyRefs
.map((r) => `${sourceSystemLabel(r.sourceSystem)} #${r.legacyId}`)
.join(" · ");
const facts: { label: string; value: string }[] = [
{ label: "Cliente desde", value: formatDate(data.customerSince) },
{
label: "Cuota",
value:
data.feeAmount != null && data.feeAmount !== ""
? formatMoney(data.feeAmount, data.preferredCurrency)
: "—",
},
{ label: "Propiedades", value: String(data.properties.length) },
{ label: "Pólizas", value: String(data.policies.length) },
{ label: "Moneda", value: data.preferredCurrency ?? "—" },
];
return (
<div className="detail-hero">
<div className="hero-top">
<div>
<h1
className={`hero-name${
data.name === SIN_NOMBRE ? " hero-name-missing" : ""
}`}
>
{data.name}
</h1>
{data.nameSource && (
<div className="hero-provenance">
Nombre recuperado de {data.nameSource} el registro original no
tenía nombre.
</div>
)}
{provenance && (
<div className="hero-provenance">Origen: {provenance}</div>
)}
</div>
<div className="hero-badges">
{hasUtilities && (
<span className="badge badge-servicios">
<span className="dot" /> Servicios
</span>
)}
{hasInsurance && (
<span className="badge badge-seguros">
<span className="dot" /> Seguros
</span>
)}
<span
className={`badge ${
data.status ? "badge-on-dark" : "badge-negative"
}`}
>
{data.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>
);
}
/* ------------------------------------------------------- Datos del cliente */
function DatosSection({ data }: { data: CustomerDetail }) {
const mxAddress = [data.addressLine1, data.addressLine2]
.filter(Boolean)
.join(", ");
const cityLine = [
data.city?.replace(/,\s*$/, ""),
data.state,
data.zipCode,
data.country,
]
.filter(Boolean)
.join(", ");
const idLine =
data.identificationNumber || data.identificationType
? [
data.identificationType,
data.identificationNumber,
data.identificationExpiration
? `vence ${formatDate(data.identificationExpiration)}`
: null,
]
.filter(Boolean)
.join(" · ")
: null;
return (
<section className="section">
<SectionHead rule="datos" title="Datos del cliente" />
<div className="card">
<div className="kv-grid">
<KV label="Teléfono" value={data.phone} mono />
<KV label="Móvil" value={data.mobile} mono />
<KV label="Fax" value={data.fax} mono />
<KV label="Correo electrónico" value={data.email} />
<KV label="Documento de identidad" value={idLine} />
<KV
label="Estado"
value={data.status ? "Activo" : "Inactivo"}
/>
{(mxAddress || cityLine) && (
<div className="kv-block">
<div className="kv-label">Domicilio</div>
<div className="kv-value">
{mxAddress && <div>{mxAddress}</div>}
{cityLine && <div>{cityLine}</div>}
{!mxAddress && !cityLine && "—"}
</div>
</div>
)}
{data.notes && (
<div className="kv-block">
<div className="kv-label">Notas</div>
<div className="kv-value">{data.notes}</div>
</div>
)}
</div>
</div>
</section>
);
}
function KV({
label,
value,
mono,
}: {
label: string;
value: string | null | undefined;
mono?: boolean;
}) {
return (
<div>
<div className="kv-label">{label}</div>
<div className={`kv-value${mono && value ? " mono" : ""}`}>
{value || "—"}
</div>
</div>
);
}
/* ----------------------------------------------- Propiedades y servicios */
function PropiedadesSection({ properties }: { properties: Property[] }) {
return (
<section className="section">
<SectionHead
rule="servicios"
title="Propiedades y servicios"
count={properties.length}
/>
<div className="card">
{properties.length === 0 ? (
<div className="empty-inline">
Este cliente no tiene propiedades registradas.
</div>
) : (
properties.map((p) => <PropertyCard key={p.id} p={p} />)
)}
</div>
</section>
);
}
function PropertyCard({ p }: { p: Property }) {
const addr = [p.addressLine1, p.addressLine2].filter(Boolean).join(", ");
const phones = [p.phone1, p.phone2, p.phone3].filter(Boolean);
return (
<div className="prop-card">
<div className="prop-addr">{addr || "Propiedad"}</div>
<div className="prop-meta">
{p.zone && <span>Zona: {p.zone}</span>}
{phones.length > 0 && (
<span className="mono">{phones.join(" · ")}</span>
)}
</div>
{p.services.length > 0 && (
<div className="svc-grid">
{p.services.map((s) => (
<div
key={s.id}
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>{s.notes}</span>}
</div>
</div>
))}
</div>
)}
{p.trustAccount && (
<div className="trust-box">
<div className="trust-title">Fideicomiso</div>
<div className="trust-facts">
{p.trustAccount.bankName && (
<span>
<strong>Banco:</strong> {p.trustAccount.bankName}
</span>
)}
{p.trustAccount.trustNumber && (
<span>
<strong>No.:</strong>{" "}
<span className="mono">{p.trustAccount.trustNumber}</span>
</span>
)}
{p.trustAccount.bankFee && (
<span>
<strong>Comisión:</strong>{" "}
{formatMoney(p.trustAccount.bankFee, "MXN")}
</span>
)}
{p.trustAccount.dueDate1 && (
<span>
<strong>Vigencia:</strong>{" "}
{formatDate(p.trustAccount.dueDate1)}
{p.trustAccount.dueDate2
? ` ${formatDate(p.trustAccount.dueDate2)}`
: ""}
</span>
)}
</div>
</div>
)}
</div>
);
}
/* ------------------------------------------------------ Pólizas de seguro */
function PolizasSection({ policies }: { policies: Policy[] }) {
return (
<section className="section">
<SectionHead
rule="seguros"
title="Pólizas de seguro"
count={policies.length}
/>
<div className="card">
{policies.length === 0 ? (
<div className="empty-inline">
Este cliente no tiene pólizas registradas.
</div>
) : (
policies.map((p) => <PolicyCard key={p.id} p={p} />)
)}
</div>
</section>
);
}
function PolicyCard({ p }: { p: Policy }) {
const { value: headline, label: headlineLabel } = premiumHeadline(p);
return (
<div className="policy-card">
<div className="policy-head">
<div>
<Link href={`/polizas/${p.id}`} className="policy-num policy-num-link">
{p.policyNumber || "—"}
</Link>
<div className="policy-type-row">
{p.policyType?.name && (
<span className="badge badge-seguros">
<span className="dot" /> {p.policyType.name}
</span>
)}
{p.insuranceProvider?.name && (
<span>{p.insuranceProvider.name}</span>
)}
{p.agentName && (
<>
<span className="sep">·</span>
<span>Agente: {p.agentName}</span>
</>
)}
<span
className={`badge ${
p.liquidated ? "badge-positive" : "badge-neutral"
}`}
>
{p.liquidated ? "Liquidada" : "Pendiente"}
</span>
</div>
<div className="policy-type-row">
<span className="kv-label" style={{ margin: 0 }}>
Vigencia:
</span>
<span className="mono">
{formatDate(p.policyFrom)} {formatDate(p.policyTo)}
</span>
</div>
</div>
<div className="policy-figures">
<div className="policy-total">
{formatMoney(headline, p.currency)}
</div>
<div className="policy-total-label">{headlineLabel}</div>
</div>
</div>
<div className="policy-body">
{p.installments.length > 0 && (
<div className="subpanel">
<div className="subpanel-title">
<span>Pagos</span>
<span>{p.installments.length}</span>
</div>
{p.installments.map((inst) => (
<InstallmentRow key={inst.id} inst={inst} />
))}
</div>
)}
{p.vehicles.length > 0 && (
<div className="subpanel">
<div className="subpanel-title">
<span>Vehículos</span>
<span>{p.vehicles.length}</span>
</div>
<div className="mini-list">
{p.vehicles.map((v) => (
<div key={v.id}>
{[v.make, v.model, v.modelYear].filter(Boolean).join(" ") ||
"Vehículo"}
<div className="mini-sub">
{[
v.bodyType,
v.licensePlate ? `Placa ${v.licensePlate}` : null,
]
.filter(Boolean)
.join(" · ")}
</div>
</div>
))}
</div>
</div>
)}
{p.insuredDrivers.length > 0 && (
<div className="subpanel">
<div className="subpanel-title">
<span>Asegurados</span>
<span>{p.insuredDrivers.length}</span>
</div>
<div className="mini-list">
{p.insuredDrivers.map((d) => (
<div key={d.id}>
{d.fullName || "—"}
{d.licenseNumber && (
<div className="mini-sub mono">Lic. {d.licenseNumber}</div>
)}
</div>
))}
</div>
</div>
)}
{p.beneficiaries.length > 0 && (
<div className="subpanel">
<div className="subpanel-title">
<span>Beneficiarios</span>
<span>{p.beneficiaries.length}</span>
</div>
<div className="mini-list">
{p.beneficiaries.map((b) => (
<div key={b.id}>
{b.name || "—"}
{(b.phone || b.email) && (
<div className="mini-sub">
{[b.phone, b.email].filter(Boolean).join(" · ")}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
function InstallmentRow({ inst }: { inst: Installment }) {
const method = inst.isCash
? "Efectivo"
: inst.checkNumber
? `Ref. ${inst.checkNumber}`
: null;
return (
<div className="pay-row">
<span style={{ display: "flex", alignItems: "center", gap: 9 }}>
<span className="pay-seq">{inst.sequence}</span>
<span>
{inst.paidDate ? formatDate(inst.paidDate) : "Sin pagar"}
{method && (
<span className="muted" style={{ fontSize: 11 }}>
{" "}
· {method}
</span>
)}
</span>
</span>
<span className="mono" style={{ fontWeight: 600 }}>
{formatMoney(inst.amount, inst.currency)}
</span>
</div>
);
}
/* ------------------------------------------------------- Estado de cuenta */
function EstadoCuentaSection({
summary,
transactions,
}: {
summary: TransactionSummaryRow[];
transactions: Transaction[];
}) {
return (
<section className="section">
<SectionHead
rule="cuenta"
title="Estado de cuenta"
count={transactions.length}
countSuffix="movimientos"
/>
{summary.length > 0 && (
<div className="summary-grid">
{summary.map((row, i) => (
<div className={`summary-card ${row.domain}`} key={i}>
<div className="summary-domain">
<span className={`tx-dot ${row.domain}`} />
{domainLabel(row.domain)} · {row.currency}
</div>
<div className="summary-total">
{formatMoney(row.total, row.currency)}
</div>
<div className="summary-count">
{row.count}{" "}
{row.count === 1 ? "movimiento" : "movimientos"}
</div>
</div>
))}
</div>
)}
<div className="card">
{transactions.length === 0 ? (
<div className="empty-inline">Sin movimientos registrados.</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Fecha</th>
<th>Línea</th>
<th>Tipo</th>
<th>Referencia</th>
<th>Concepto</th>
<th className="num">Monto</th>
</tr>
</thead>
<tbody>
{transactions.map((t) => (
<TxRow key={t.id} t={t} />
))}
</tbody>
</table>
</div>
)}
{transactions.length >= 100 && (
<div className="section-note" style={{ padding: "0 16px 14px" }}>
Mostrando los 100 movimientos más recientes.
</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";
const tipo =
t.type?.nameEs || t.type?.nameEn || "—";
const concept = t.message || t.period || "—";
return (
<tr>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(t.transactionDate)}
</td>
<td className="tx-domain-cell">
<span className={`tx-dot ${t.domain}`} />
{domainLabel(t.domain)}
</td>
<td>{tipo}</td>
<td className="tx-ref">{t.reference || "—"}</td>
<td className="tx-concept">{concept}</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: CustomerDetail }) {
type Doc = { type: string; key: string | null; scope: string };
const docs: Doc[] = [];
data.properties.forEach((p) => {
const label = [p.addressLine1].filter(Boolean).join("") || "Propiedad";
p.documents.forEach((d) =>
docs.push({
type: d.documentType || "Documento",
key: d.storageKey,
scope: label,
}),
);
});
data.policies.forEach((p) => {
p.documents.forEach((d) =>
docs.push({
type: d.documentType || "Documento",
key: d.storageKey,
scope: `Póliza ${p.policyNumber ?? ""}`.trim(),
}),
);
});
return (
<section className="section">
<SectionHead rule="docs" title="Documentos" count={docs.length} />
<div className="card">
{docs.length === 0 ? (
<div className="empty-inline">
No hay documentos registrados para este cliente.
</div>
) : (
<>
<div className="doc-list">
{docs.map((d, i) => (
<div className="doc-item" key={i}>
<span className="doc-icon" aria-hidden>
</span>
<div style={{ minWidth: 0 }}>
<div className="doc-type">{d.type}</div>
<div className="doc-key">{d.scope}</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 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: 140, marginBottom: 18 }}
/>
<div className="skeleton" style={{ height: 180, borderRadius: 16 }} />
<div
className="skeleton"
style={{ height: 200, borderRadius: 16, marginTop: 34 }}
/>
<div
className="skeleton"
style={{ height: 260, borderRadius: 16, marginTop: 34 }}
/>
</div>
);
}