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