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
+478
View File
@@ -0,0 +1,478 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import {
EXPIRY_WINDOW_DAYS,
getPolicyFacets,
getPolicyStats,
listPolicies,
} from "@/lib/api";
import {
expiryPhrase,
formatDate,
formatMoney,
formatNumber,
policyStatusLabel,
premiumHeadline,
SIN_NOMBRE,
} from "@/lib/labels";
import type {
PolicyFacets,
PolicyListItem,
PolicyListResponse,
PolicySort,
PolicyStats,
PolicyStatus,
} from "@/lib/types";
type StatusFilter = "all" | PolicyStatus;
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
{ key: "all", label: "Todas" },
{ key: "expiring", label: "Por vencer" },
{ key: "active", label: "Vigentes" },
{ key: "expired", label: "Vencidas" },
{ key: "undated", label: "Sin vigencia" },
];
const SORTS: { key: PolicySort; label: string }[] = [
{ key: "expiry_desc", label: "Vencimiento (más reciente)" },
{ key: "expiry_asc", label: "Vencimiento (más próximo)" },
{ key: "customer", label: "Cliente (AZ)" },
{ key: "number", label: "Número de póliza" },
{ key: "premium_desc", label: "Prima (mayor a menor)" },
];
export default function PolizasPage() {
return (
<AppShell>
<PolizasBrowser />
</AppShell>
);
}
function PolizasBrowser() {
const [stats, setStats] = useState<PolicyStats | null>(null);
const [facets, setFacets] = useState<PolicyFacets | null>(null);
const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
const [typeId, setTypeId] = useState("");
const [providerId, setProviderId] = useState("");
const [sort, setSort] = useState<PolicySort>("expiry_desc");
const [data, setData] = useState<PolicyListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
getPolicyStats().then(setStats).catch(() => setStats(null));
getPolicyFacets().then(setFacets).catch(() => setFacets(null));
}, []);
const runSearch = useCallback(
(p: number) => {
setLoading(true);
setError(null);
listPolicies({
query: query || undefined,
status: status === "all" ? undefined : status,
typeId: typeId || undefined,
providerId: providerId || undefined,
sort,
days: EXPIRY_WINDOW_DAYS,
page: p,
pageSize: 25,
})
.then((res) => {
setData(res);
setLoading(false);
})
.catch((e) => {
setError(e?.message ?? "No se pudieron cargar las pólizas.");
setLoading(false);
});
},
[query, status, typeId, providerId, sort],
);
// Debounced re-query whenever any filter changes; always back to page 1.
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" });
}
const filtered =
query !== "" || status !== "all" || typeId !== "" || providerId !== "";
return (
<>
<div className="page-head rise">
<p className="eyebrow">Cartera de seguros</p>
<h1 className="page-title">Pólizas</h1>
<StatStrip
stats={stats}
status={status}
onPickStatus={(s) => setStatus(s)}
/>
<PremiumStrip 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="Buscar por póliza, cliente, placa, agente…"
aria-label="Buscar pólizas"
/>
</div>
<div className="seg" role="tablist" aria-label="Filtrar por vigencia">
{STATUS_FILTERS.map((f) => (
<button
key={f.key}
type="button"
role="tab"
aria-selected={status === f.key}
className={`seg-btn ${status === f.key ? "active" : ""}`}
onClick={() => setStatus(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
<div className="filter-row">
<label className="filter-field">
<span className="filter-label">Ramo</span>
<select
className="input select"
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
>
<option value="">Todos los ramos</option>
{facets?.types
.filter((t) => t.count > 0)
.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({formatNumber(t.count)})
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Aseguradora</span>
<select
className="input select"
value={providerId}
onChange={(e) => setProviderId(e.target.value)}
>
<option value="">Todas las aseguradoras</option>
{facets?.providers
.filter((p) => p.count > 0)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({formatNumber(p.count)})
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Ordenar por</span>
<select
className="input select"
value={sort}
onChange={(e) => setSort(e.target.value as PolicySort)}
>
{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={() => {
setQuery("");
setStatus("all");
setTypeId("");
setProviderId("");
}}
>
Limpiar filtros
</button>
)}
</div>
{data && !loading && !error && (
<div className="result-meta" aria-live="polite">
{data.total === 0
? "Sin resultados"
: `${formatNumber(data.total)} ${
data.total === 1 ? "póliza" : "pólizas"
}`}
{query ? ` para “${query}` : ""}
</div>
)}
{error ? (
<div className="state-error" role="alert">
{error}
</div>
) : loading ? (
<ListSkeleton />
) : data && data.items.length === 0 ? (
<EmptyState query={query} />
) : (
<>
<div className="cust-list">
{data?.items.map((p) => (
<PolicyRow key={p.id} p={p} />
))}
</div>
{data && data.pageCount > 1 && (
<Pager
page={data.page}
pageCount={data.pageCount}
onChange={goToPage}
/>
)}
</>
)}
</>
);
}
/** Counts double as filter shortcuts — clicking a cell applies that bucket. */
function StatStrip({
stats,
status,
onPickStatus,
}: {
stats: PolicyStats | null;
status: StatusFilter;
onPickStatus: (s: StatusFilter) => void;
}) {
if (!stats) {
return (
<div className="stat-strip" aria-hidden>
{Array.from({ length: 6 }).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 cells: {
key: StatusFilter;
value: number;
label: string;
accent?: boolean;
}[] = [
{ key: "all", value: stats.total, label: "Pólizas", accent: true },
{
key: "expiring",
value: stats.expiring,
label: `Vencen en ${stats.days} días`,
accent: true,
},
{ key: "active", value: stats.active, label: "Vigentes" },
{ key: "expired", value: stats.expired, label: "Vencidas" },
{ key: "undated", value: stats.undated, label: "Sin vigencia" },
];
return (
<div className="stat-strip">
{cells.map((c) => (
<button
type="button"
key={c.label}
className={`stat-cell stat-cell-btn${c.accent ? " accent" : ""}${
status === c.key ? " selected" : ""
}`}
onClick={() => onPickStatus(c.key)}
aria-pressed={status === c.key}
>
<div className="stat-value">{formatNumber(c.value)}</div>
<div className="stat-label">{c.label}</div>
</button>
))}
<div className="stat-cell">
<div className="stat-value">{formatNumber(stats.pending)}</div>
<div className="stat-label">Sin liquidar</div>
</div>
</div>
);
}
/** Premium in force, split by currency — MXN and USD can't be summed. */
function PremiumStrip({ stats }: { stats: PolicyStats | null }) {
if (!stats || stats.premiumInForce.length === 0) return null;
return (
<div className="premium-strip">
<span className="premium-caption">Prima neta vigente</span>
{stats.premiumInForce.map((row) => (
<span className="premium-chip" key={row.currency}>
<strong>{formatMoney(row.netPremium, row.currency)}</strong>
<span className="premium-chip-sub">
{row.currency} · {formatNumber(row.count)}{" "}
{row.count === 1 ? "póliza" : "pólizas"}
</span>
</span>
))}
</div>
);
}
function PolicyRow({ p }: { p: PolicyListItem }) {
const premium = premiumHeadline(p);
const phrase = expiryPhrase(p.daysToExpiry);
const showPhrase = p.status === "expiring" || p.status === "active";
return (
<Link href={`/polizas/${p.id}`} className="cust-row pol-row">
<div className="cust-main">
<div className="cust-name">
<span className="mono pol-number">{p.policyNumber || "—"}</span>
{p.policyType?.name && (
<span className="badge badge-seguros">
<span className="dot" /> {p.policyType.name}
</span>
)}
<span className={`badge status-${p.status}`}>
{policyStatusLabel(p.status)}
</span>
{!p.liquidated && (
<span className="badge badge-neutral">Sin liquidar</span>
)}
</div>
<div className="cust-sub">
<span
className={
p.customerName === SIN_NOMBRE ? "cust-name-missing" : undefined
}
>
{p.customerName}
</span>
{p.insuranceProvider?.name && (
<>
<span className="sep">·</span>
<span>{p.insuranceProvider.name}</span>
</>
)}
{p.vehicleCount > 0 && (
<>
<span className="sep">·</span>
<span>
{p.vehicleCount}{" "}
{p.vehicleCount === 1 ? "vehículo" : "vehículos"}
</span>
</>
)}
</div>
</div>
<div className="pol-side">
<div className="pol-premium">
{formatMoney(premium.value, p.currency)}
</div>
<div className="pol-dates mono">
{formatDate(p.policyFrom)} {formatDate(p.policyTo)}
</div>
{showPhrase && phrase && (
<div className={`pol-phrase ${p.status}`}>{phrase}</div>
)}
</div>
</Link>
);
}
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 }: { query: string }) {
return (
<div className="state-box">
<div className="state-glyph" aria-hidden>
</div>
<h3>Sin resultados</h3>
<p>
{query
? `No encontramos pólizas para “${query}”.`
: "No hay pólizas que coincidan con los filtros."}
</p>
</div>
);
}