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>
This commit is contained in:
2026-07-23 12:22:02 -07:00
co-authored by Claude Opus 4.8
parent 12692a0af8
commit 7a46c30d9b
22 changed files with 1973 additions and 19 deletions
+171 -3
View File
@@ -3,7 +3,17 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { getPolicy } from "@/lib/api";
import {
addPolicyChild,
archivePolicy,
getLookups,
getPolicy,
removePolicyChild,
restorePolicy,
updatePolicyChild,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { ChildCollection, type ChildConfig } from "@/components/ChildCollection";
import {
expiryPhrase,
formatDate,
@@ -12,7 +22,7 @@ import {
premiumHeadline,
SIN_NOMBRE,
} from "@/lib/labels";
import type { Installment, PolicyDetail } from "@/lib/types";
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types";
export default function PolizaDetailPage({
params,
@@ -73,9 +83,14 @@ function Detail({ id }: { id: string }) {
if (!data) return null;
const reload = () => getPolicy(id).then(setData).catch(() => {});
return (
<div className="rise">
<BackLink />
<div className="detail-actionbar">
<BackLink />
<PolicyActions data={data} onChange={reload} />
</div>
<Hero data={data} />
<ClienteSection data={data} />
<CondicionesSection data={data} />
@@ -87,10 +102,163 @@ function Detail({ id }: { id: string }) {
{data.claims.length > 0 && <SiniestrosSection data={data} />}
<CoberturasSection data={data} />
<DocumentosSection data={data} />
<ChildrenEditor data={data} onChange={reload} />
</div>
);
}
/** Edit / archive controls for the policy header. */
function PolicyActions({
data,
onChange,
}: {
data: PolicyDetail;
onChange: () => void;
}) {
const canEdit = useCan("policy:update");
const canDelete = useCan("policy:delete");
const [busy, setBusy] = useState(false);
const archived = data.archivedAt != null;
async function toggle() {
const verb = archived ? "restaurar" : "archivar";
if (!window.confirm(`¿Seguro que desea ${verb} esta póliza?`)) return;
setBusy(true);
try {
if (archived) await restorePolicy(data.id);
else await archivePolicy(data.id);
onChange();
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo completar la acción.");
} finally {
setBusy(false);
}
}
if (!canEdit && !canDelete) return null;
return (
<div className="row-actions">
{archived && <span className="badge badge-negative">Archivada</span>}
{canEdit && (
<Link href={`/polizas/${data.id}/editar`} className="btn btn-outline">
Editar
</Link>
)}
{canDelete && (
<button type="button" className="btn btn-ghost" onClick={toggle} disabled={busy}>
{archived ? "Restaurar" : "Archivar"}
</button>
)}
</div>
);
}
/** Editable child collections — only shown to users who can edit the policy. */
function ChildrenEditor({
data,
onChange,
}: {
data: PolicyDetail;
onChange: () => void;
}) {
const canEdit = useCan("policy:update");
const [adjusters, setAdjusters] = useState<AdjusterRow[]>([]);
useEffect(() => {
if (canEdit) getLookups().then((l) => setAdjusters(l.adjusters)).catch(() => {});
}, [canEdit]);
if (!canEdit) return null;
const INSTALLMENTS: ChildConfig = {
apiKind: "installments",
title: "Pagos",
fields: [
{ key: "sequence", label: "Sec.", type: "number" },
{ key: "amount", label: "Monto", type: "number" },
{ key: "currency", label: "Moneda", type: "select",
options: [{ value: "MXN", label: "MXN" }, { value: "USD", label: "USD" }] },
{ key: "dueDate", label: "Vence", type: "date" },
{ key: "paidDate", label: "Pagado", type: "date" },
{ key: "checkNumber", label: "Cheque" },
{ key: "isCash", label: "Efectivo", type: "checkbox" },
],
};
const VEHICLES: ChildConfig = {
apiKind: "vehicles",
title: "Vehículos",
fields: [
{ key: "make", label: "Marca" },
{ key: "model", label: "Modelo" },
{ key: "modelYear", label: "Año" },
{ key: "licensePlate", label: "Placa" },
{ key: "vinNumber", label: "VIN" },
{ key: "stateCode", label: "Estado" },
],
};
const DRIVERS: ChildConfig = {
apiKind: "drivers",
title: "Conductores",
fields: [
{ key: "fullName", label: "Nombre" },
{ key: "birthDate", label: "Nacimiento", type: "date" },
{ key: "sex", label: "Sexo" },
{ key: "occupation", label: "Ocupación" },
{ key: "licenseNumber", label: "Licencia" },
{ key: "licenseState", label: "Estado" },
],
};
const BENEFICIARIES: ChildConfig = {
apiKind: "beneficiaries",
title: "Beneficiarios",
fields: [
{ key: "name", label: "Nombre" },
{ key: "phone", label: "Teléfono" },
{ key: "email", label: "Correo" },
{ key: "address", label: "Dirección" },
],
};
const CLAIMS: ChildConfig = {
apiKind: "claims",
title: "Siniestros",
fields: [
{ key: "claimType", label: "Tipo" },
{ key: "incidentDate", label: "Fecha", type: "date" },
{ key: "description", label: "Descripción" },
{ key: "adjusterId", label: "Ajustador", type: "select",
options: adjusters.map((a) => ({ value: a.id, label: a.name ?? a.company ?? a.id })) },
{ key: "claimedAmount", label: "Reclamado", type: "number" },
{ key: "settledAmount", label: "Pagado", type: "number" },
{ key: "resolved", label: "Resuelto", type: "checkbox" },
],
};
const bind = (cfg: ChildConfig, rows: Record<string, unknown>[]) => (
<ChildCollection
config={cfg}
rows={rows}
canEdit={canEdit}
onAdd={async (p) => { await addPolicyChild(data.id, cfg.apiKind, p); onChange(); }}
onSave={async (cid, p) => { await updatePolicyChild(data.id, cfg.apiKind, cid, p); onChange(); }}
onRemove={async (cid) => { await removePolicyChild(data.id, cfg.apiKind, cid); onChange(); }}
/>
);
return (
<section className="section">
<div className="section-head">
<span className="section-rule cuenta" aria-hidden />
<h2 className="section-title">Administrar detalles</h2>
</div>
{bind(INSTALLMENTS, data.installments as unknown as Record<string, unknown>[])}
{bind(VEHICLES, data.vehicles as unknown as Record<string, unknown>[])}
{bind(DRIVERS, data.insuredDrivers as unknown as Record<string, unknown>[])}
{bind(BENEFICIARIES, data.beneficiaries as unknown as Record<string, unknown>[])}
{bind(CLAIMS, data.claims as unknown as Record<string, unknown>[])}
</section>
);
}
function BackLink() {
return (
<Link href="/polizas" className="back-link">