Policy header, all five child collections, and the insurance reference catalogs become create/edit/delete-able on the RBAC foundation. API: - Policy gains archivedAt (soft-delete); list/browser default to archivedAt=null with ?includeArchived opt-in. - PoliciesService: header create/update/archive/restore (customer FK validated for a clean 404); add/update/remove for installments, vehicles, drivers, beneficiaries, claims — each scoped to its policy so one policy's id can't touch another's rows; lookups CRUD for providers, policy types, adjusters. - PoliciesController write routes: header create/update need STAFF+ (policy:create/update), archive/restore need MANAGER+ (policy:delete), every child route needs policy:update. New LookupsController at /lookups (read open; mutate needs lookup:manage / MANAGER+). Mutations audited. - DTOs (policy header, children, lookups); dates coerced; shared coerce.ts. Web: - Generic ChildCollection editor (config-driven add/edit/remove table), reused by both the policy detail child editors and the catalogs screen. - PolicyForm (header) with type/provider selects and a debounced CustomerPicker; /polizas/nuevo (accepts ?customerId prefill) and /polizas/[id]/editar. Policy detail: gated action bar (Editar/Archivar) + "Administrar detalles" child editors for all five collections. - /catalogos admin screen (aseguradoras/tipos/ajustadores), nav-gated on lookup:manage. "Nueva póliza" buttons on the list and on the customer detail (prefilled). api.ts + types for all of the above. Verified against dev: policy create (dates coerced, archivedAt null), installment/vehicle add, VIEWER child-add 403, cross-policy child guard 404, lookups CRUD with VIEWER 403 / MANAGER 201, archive drops from the default list and includeArchived surfaces it. Both apps compile clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
import { AppShell } from "@/components/AppShell";
|
||
import { useCan } from "@/lib/abilities";
|
||
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 (A–Z)" },
|
||
{ 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 canCreate = useCan("policy:create");
|
||
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>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||
<h1 className="page-title" style={{ margin: 0 }}>Pólizas</h1>
|
||
<span style={{ flex: 1 }} />
|
||
{canCreate && (
|
||
<Link href="/polizas/nuevo" className="btn btn-primary">+ Nueva póliza</Link>
|
||
)}
|
||
</div>
|
||
<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>
|
||
);
|
||
}
|