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>
This commit is contained in:
2026-07-22 21:27:38 -07:00
co-authored by Claude Opus 4.8
parent fa9b696752
commit e2aba8bd17
13 changed files with 1957 additions and 16 deletions
+588
View File
@@ -0,0 +1,588 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { getPolicy } from "@/lib/api";
import {
expiryPhrase,
formatDate,
formatMoney,
policyStatusLabel,
premiumHeadline,
SIN_NOMBRE,
} from "@/lib/labels";
import type { Installment, PolicyDetail } from "@/lib/types";
export default function PolizaDetailPage({
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<PolicyDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
getPolicy(id)
.then((d) => {
if (alive) {
setData(d);
setLoading(false);
}
})
.catch((e) => {
if (alive) {
setError(
e?.status === 404
? "No encontramos esta póliza."
: e?.message ?? "No se pudo cargar la póliza.",
);
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} />
<CondicionesSection data={data} />
{data.installments.length > 0 && <PagosSection data={data} />}
{data.vehicles.length > 0 && <VehiculosSection data={data} />}
{(data.insuredDrivers.length > 0 || data.beneficiaries.length > 0) && (
<PersonasSection data={data} />
)}
{data.claims.length > 0 && <SiniestrosSection data={data} />}
<CoberturasSection data={data} />
<DocumentosSection data={data} />
</div>
);
}
function BackLink() {
return (
<Link href="/polizas" className="back-link">
Volver a Pólizas
</Link>
);
}
/* ------------------------------------------------------------------ Hero */
function Hero({ data }: { data: PolicyDetail }) {
const premium = premiumHeadline(data);
const phrase = expiryPhrase(data.daysToExpiry);
const provenance = [data.legacySourceTable, data.legacyId]
.filter(Boolean)
.join(" #");
const facts: { label: string; value: string }[] = [
{ label: "Vigencia desde", value: formatDate(data.policyFrom) },
{ label: "Vigencia hasta", value: formatDate(data.policyTo) },
{ label: premium.label, value: formatMoney(premium.value, data.currency) },
{ label: "Moneda", value: data.currency ?? "—" },
{ label: "Agente", value: data.agentName || "—" },
];
return (
<div className="detail-hero">
<div className="hero-top">
<div>
<h1 className="hero-name mono">{data.policyNumber || "—"}</h1>
<div className="hero-provenance">
{[data.policyType?.name, data.insuranceProvider?.name]
.filter(Boolean)
.join(" · ") || "Sin ramo ni aseguradora registrados"}
</div>
{provenance && (
<div className="hero-provenance">Origen: {provenance}</div>
)}
</div>
<div className="hero-badges">
<span className={`badge status-${data.status}`}>
{policyStatusLabel(data.status)}
{phrase && data.status !== "expired" ? ` · ${phrase}` : ""}
</span>
<span
className={`badge ${
data.liquidated ? "badge-positive" : "badge-negative"
}`}
>
{data.liquidated ? "Liquidada" : "Sin liquidar"}
</span>
{data.endorsement && (
<span className="badge badge-on-dark">Endoso</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: PolicyDetail }) {
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.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>
</>
)}
</div>
</div>
<span className="owner-cta">Ver expediente </span>
</Link>
{data.properties.length > 0 && (
<div className="linked-props">
<div className="kv-label">Propiedades cubiertas</div>
{data.properties.map((p) => (
<div key={p.id} className="linked-prop">
{[p.addressLine1, p.addressLine2].filter(Boolean).join(", ") ||
"Propiedad"}
{p.zone && <span className="muted"> · Zona {p.zone}</span>}
</div>
))}
</div>
)}
</div>
</section>
);
}
/* ---------------------------------------------------------- Condiciones */
function CondicionesSection({ data }: { data: PolicyDetail }) {
const cur = data.currency;
return (
<section className="section">
<SectionHead rule="seguros" title="Condiciones y primas" />
<div className="card">
<div className="kv-grid">
<KV label="Fecha de emisión" value={formatDate(data.policyDate)} />
<KV
label="Periodo de cobertura"
value={
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
}
/>
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
{/* The legacy `total` is 0 or null on all but 2 of 2378 policies —
only show it when it actually carries a figure. */}
{data.total != null && Number(data.total) > 0 && (
<KV label="Total" value={formatMoney(data.total, cur)} />
)}
<KV
label="Liquidación"
value={
data.liquidated
? [
data.liquidationNumber
? `No. ${data.liquidationNumber}`
: null,
data.liquidationDate
? formatDate(data.liquidationDate)
: null,
]
.filter(Boolean)
.join(" · ") || "Liquidada"
: "Pendiente"
}
/>
{data.observations && (
<div className="kv-block">
<div className="kv-label">Observaciones</div>
<div className="kv-value">{data.observations}</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>
);
}
/* --------------------------------------------------------------- Pagos */
function PagosSection({ data }: { data: PolicyDetail }) {
const paid = data.installments.filter((i) => i.paidDate).length;
return (
<section className="section">
<SectionHead
rule="cuenta"
title="Pagos"
count={data.installments.length}
countSuffix={`· ${paid} pagados`}
/>
<div className="card">
<div className="subpanel" style={{ margin: 16 }}>
{data.installments.map((inst) => (
<InstallmentRow key={inst.id} inst={inst} />
))}
</div>
</div>
</section>
);
}
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"}
{inst.dueDate && !inst.paidDate && (
<span className="muted" style={{ fontSize: 11 }}>
{" "}
· vence {formatDate(inst.dueDate)}
</span>
)}
{method && (
<span className="muted" style={{ fontSize: 11 }}>
{" "}
· {method}
</span>
)}
</span>
</span>
<span className="mono" style={{ fontWeight: 600 }}>
{formatMoney(inst.amount, inst.currency)}
</span>
</div>
);
}
/* ----------------------------------------------------------- Vehículos */
function VehiculosSection({ data }: { data: PolicyDetail }) {
return (
<section className="section">
<SectionHead
rule="servicios"
title="Vehículos asegurados"
count={data.vehicles.length}
/>
<div className="card">
<div className="veh-grid">
{data.vehicles.map((v) => (
<div className="veh-card" key={v.id}>
<div className="veh-title">
{[v.make, v.model, v.modelYear].filter(Boolean).join(" ") ||
"Vehículo"}
</div>
<div className="veh-facts">
{v.bodyType && <span>{v.bodyType}</span>}
{v.licensePlate && (
<span>
Placa: <span className="mono">{v.licensePlate}</span>
</span>
)}
{v.vinNumber && (
<span>
Serie: <span className="mono">{v.vinNumber}</span>
</span>
)}
{v.engineNumber && (
<span>
Motor: <span className="mono">{v.engineNumber}</span>
</span>
)}
</div>
</div>
))}
</div>
</div>
</section>
);
}
/* ------------------------------------------- Asegurados y beneficiarios */
function PersonasSection({ data }: { data: PolicyDetail }) {
return (
<section className="section">
<SectionHead rule="datos" title="Asegurados y beneficiarios" />
<div className="card">
<div className="policy-body">
{data.insuredDrivers.length > 0 && (
<div className="subpanel">
<div className="subpanel-title">
<span>Asegurados</span>
<span>{data.insuredDrivers.length}</span>
</div>
<div className="mini-list">
{data.insuredDrivers.map((d) => (
<div key={d.id}>
{d.fullName || "—"}
{d.licenseNumber && (
<div className="mini-sub mono">Lic. {d.licenseNumber}</div>
)}
</div>
))}
</div>
</div>
)}
{data.beneficiaries.length > 0 && (
<div className="subpanel">
<div className="subpanel-title">
<span>Beneficiarios</span>
<span>{data.beneficiaries.length}</span>
</div>
<div className="mini-list">
{data.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>
</section>
);
}
/* --------------------------------------------------------- Siniestros */
function SiniestrosSection({ data }: { data: PolicyDetail }) {
return (
<section className="section">
<SectionHead rule="cuenta" title="Siniestros" count={data.claims.length} />
<div className="card">
{data.claims.map((c) => (
<div className="prop-card" key={c.id}>
<div className="prop-addr">{c.claimType || "Siniestro"}</div>
<div className="prop-meta">
{c.incidentDate && (
<span>Ocurrido: {formatDate(c.incidentDate)}</span>
)}
{c.reportedDate && (
<span>Reportado: {formatDate(c.reportedDate)}</span>
)}
{c.adjuster?.name && <span>Ajustador: {c.adjuster.name}</span>}
</div>
<div className="kv-grid" style={{ marginTop: 12 }}>
<KV
label="Monto reclamado"
value={formatMoney(c.claimedAmount, data.currency)}
/>
<KV
label="Monto liquidado"
value={formatMoney(c.settledAmount, data.currency)}
/>
<KV label="Fecha de finiquito" value={formatDate(c.settlementDate)} />
{c.description && (
<div className="kv-block">
<div className="kv-label">Descripción</div>
<div className="kv-value">{c.description}</div>
</div>
)}
</div>
</div>
))}
</div>
</section>
);
}
/* -------------------------------------------------------- Coberturas */
/** The legacy tables carry per-line coverage columns the target schema does
* not model; the migration preserved them verbatim in `coveragesJson`. */
function CoberturasSection({ data }: { data: PolicyDetail }) {
const entries = Object.entries(data.coveragesJson ?? {}).filter(
([, v]) => v !== null && v !== "" && v !== 0,
);
if (entries.length === 0) return null;
return (
<section className="section">
<SectionHead rule="seguros" title="Coberturas" count={entries.length} />
<div className="card">
<div className="kv-grid">
{entries.map(([k, v]) => (
<div key={k}>
<div className="kv-label">{k}</div>
<div className="kv-value">{String(v)}</div>
</div>
))}
</div>
<div className="section-note" style={{ padding: "0 22px 18px" }}>
Campos de cobertura conservados tal cual desde el sistema anterior.
</div>
</div>
</section>
);
}
/* -------------------------------------------------------- Documentos */
function DocumentosSection({ data }: { data: PolicyDetail }) {
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 póliza.
</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: 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>
);
}