Files
jorgecuadros-platform/apps/web/src/app/polizas/page.tsx
T
rmancinasandClaude Opus 4.8 7a46c30d9b feat(policies): full CRUD + child editors + insurance lookups (plan phase 3)
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>
2026-07-23 12:22:02 -07:00

487 lines
14 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 { 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 (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 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>
);
}