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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user