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
+1
View File
@@ -20,6 +20,7 @@ const NAV: { href: string; label: string; ability?: Ability }[] = [
{ href: "/polizas", label: "Pólizas" },
{ href: "/estado-cuenta", label: "Estado de cuenta" },
{ href: "/banco", label: "Chequera" },
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
];
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
/** A single editable field in a child row. */
export type FieldDef = {
key: string;
label: string;
type?: "text" | "number" | "date" | "checkbox" | "select";
options?: { value: string; label: string }[];
width?: number;
};
export type ChildConfig = {
/** URL segment: installments | vehicles | drivers | beneficiaries | claims */
apiKind: string;
title: string;
fields: FieldDef[];
};
type RowValues = Record<string, string | boolean>;
function toDateInput(v: unknown): string {
if (!v || typeof v !== "string") return "";
const d = new Date(v);
return isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
}
/** Build editable values for a field from an existing row (or blank). */
function rowToValues(fields: FieldDef[], row?: Record<string, unknown>): RowValues {
const v: RowValues = {};
for (const f of fields) {
const raw = row?.[f.key];
if (f.type === "checkbox") v[f.key] = !!raw;
else if (f.type === "date") v[f.key] = toDateInput(raw);
else v[f.key] = raw == null ? "" : String(raw);
}
return v;
}
/** Coerce editable values into an API payload (numbers/blanks handled). */
function valuesToPayload(fields: FieldDef[], v: RowValues): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const f of fields) {
const val = v[f.key];
if (f.type === "checkbox") out[f.key] = !!val;
else if (f.type === "number") {
const s = String(val).trim();
out[f.key] = s === "" ? undefined : Number(s);
} else {
const s = String(val).trim();
out[f.key] = s === "" ? undefined : s;
}
}
return out;
}
/**
* Generic add/edit/remove editor for a policy's child collection. The parent
* owns the API calls (so it can reload the policy afterward); this component is
* pure UI over `rows` plus add/save/remove callbacks.
*/
export function ChildCollection({
config,
rows,
canEdit,
onAdd,
onSave,
onRemove,
}: {
config: ChildConfig;
rows: Record<string, unknown>[];
canEdit: boolean;
onAdd: (payload: Record<string, unknown>) => Promise<void>;
onSave: (id: string, payload: Record<string, unknown>) => Promise<void>;
onRemove: (id: string) => Promise<void>;
}) {
const [editingId, setEditingId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [values, setValues] = useState<RowValues>({});
const [busy, setBusy] = useState(false);
function startAdd() {
setEditingId(null);
setAdding(true);
setValues(rowToValues(config.fields));
}
function startEdit(row: Record<string, unknown>) {
setAdding(false);
setEditingId(String(row.id));
setValues(rowToValues(config.fields, row));
}
function cancel() {
setAdding(false);
setEditingId(null);
}
async function submit() {
setBusy(true);
try {
const payload = valuesToPayload(config.fields, values);
if (editingId) await onSave(editingId, payload);
else await onAdd(payload);
cancel();
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo guardar.");
} finally {
setBusy(false);
}
}
async function remove(id: string) {
if (!window.confirm("¿Eliminar este registro?")) return;
try {
await onRemove(id);
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
}
}
function editor() {
return (
<div className="child-editor">
<div className="form-grid">
{config.fields.map((f) => (
<label className="field" key={f.key}>
<span className="field-label">{f.label}</span>
{f.type === "checkbox" ? (
<input
type="checkbox"
checked={!!values[f.key]}
onChange={(e) => setValues({ ...values, [f.key]: e.target.checked })}
/>
) : f.type === "select" ? (
<select
className="select"
value={String(values[f.key] ?? "")}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
>
<option value=""></option>
{f.options?.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : (
<input
className="input"
type={f.type === "number" ? "number" : f.type === "date" ? "date" : "text"}
step={f.type === "number" ? "0.01" : undefined}
value={String(values[f.key] ?? "")}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
/>
)}
</label>
))}
</div>
<div className="form-actions">
<button type="button" className="btn btn-primary" onClick={submit} disabled={busy}>
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
</button>
<button type="button" className="btn btn-ghost" onClick={cancel}>
Cancelar
</button>
</div>
</div>
);
}
return (
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
<div className="child-head">
<h3 className="section-title" style={{ margin: 0 }}>
{config.title}
<span className="section-count"> {rows.length}</span>
</h3>
{canEdit && !adding && editingId === null && (
<button type="button" className="btn btn-outline" onClick={startAdd}>
+ Agregar
</button>
)}
</div>
{rows.length === 0 && !adding ? (
<div className="empty-inline">Sin registros.</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
{config.fields.map((f) => (
<th key={f.key}>{f.label}</th>
))}
{canEdit && <th className="num">Acciones</th>}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={String(row.id)}>
{config.fields.map((f) => (
<td key={f.key}>{cellText(f, row[f.key])}</td>
))}
{canEdit && (
<td>
<div className="row-actions">
<button
type="button"
className="btn btn-ghost"
onClick={() => startEdit(row)}
>
Editar
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => remove(String(row.id))}
>
Eliminar
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{(adding || editingId !== null) && editor()}
</div>
);
}
function cellText(f: FieldDef, raw: unknown): string {
if (f.type === "checkbox") return raw ? "Sí" : "No";
if (f.type === "date") return toDateInput(raw) || "—";
if (f.type === "select") {
const opt = f.options?.find((o) => o.value === String(raw));
return opt ? opt.label : "—";
}
return raw == null || raw === "" ? "—" : String(raw);
}
@@ -0,0 +1,90 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { listCustomers } from "@/lib/api";
import type { CustomerListItem } from "@/lib/types";
/**
* Debounced customer search + select. Reports the chosen customer's id and name
* upward. Used when creating a policy that isn't started from a customer page.
*/
export function CustomerPicker({
value,
valueName,
onPick,
}: {
value: string;
valueName?: string;
onPick: (id: string, name: string) => void;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<CustomerListItem[]>([]);
const [open, setOpen] = useState(false);
const debounce = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (debounce.current) clearTimeout(debounce.current);
if (query.trim().length < 2) {
setResults([]);
return;
}
debounce.current = setTimeout(() => {
listCustomers({ query, pageSize: 8 })
.then((r) => {
setResults(r.items);
setOpen(true);
})
.catch(() => setResults([]));
}, 260);
return () => {
if (debounce.current) clearTimeout(debounce.current);
};
}, [query]);
return (
<div className="picker">
{value ? (
<div className="picker-selected">
<span>{valueName ?? "Cliente seleccionado"}</span>
<button
type="button"
className="btn btn-ghost"
onClick={() => onPick("", "")}
>
Cambiar
</button>
</div>
) : (
<>
<input
className="input"
placeholder="Buscar cliente por nombre…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => results.length && setOpen(true)}
/>
{open && results.length > 0 && (
<ul className="picker-list">
{results.map((c) => (
<li key={c.id}>
<button
type="button"
className="picker-item"
onClick={() => {
onPick(c.id, c.name);
setOpen(false);
setQuery("");
}}
>
{c.name}
{c.city && <span className="muted"> · {c.city}</span>}
</button>
</li>
))}
</ul>
)}
</>
)}
</div>
);
}
+292
View File
@@ -0,0 +1,292 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { CustomerPicker } from "@/components/CustomerPicker";
import { createPolicy, getLookups, updatePolicy } from "@/lib/api";
import type {
Currency,
LookupsResponse,
PolicyDetail,
PolicyInput,
} from "@/lib/types";
function toDateInput(v: string | null | undefined): string {
if (!v) return "";
const d = new Date(v);
return isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
}
function numOrUndef(v: string): number | undefined {
const t = v.trim();
if (t === "") return undefined;
const n = Number(t);
return isNaN(n) ? undefined : n;
}
function s(v: string): string | undefined {
const t = v.trim();
return t === "" ? undefined : t;
}
type V = {
policyNumber: string;
policyTypeId: string;
insuranceProviderId: string;
agentName: string;
policyDate: string;
policyFrom: string;
policyTo: string;
netPremium: string;
policyFee: string;
brokerFee: string;
commission: string;
currency: Currency;
liquidated: boolean;
liquidationNumber: string;
liquidationDate: string;
endorsement: boolean;
observations: string;
notes: string;
};
function initial(p?: PolicyDetail): V {
return {
policyNumber: p?.policyNumber ?? "",
policyTypeId: p?.policyType?.id ?? "",
insuranceProviderId: p?.insuranceProvider?.id ?? "",
agentName: p?.agentName ?? "",
policyDate: toDateInput(p?.policyDate),
policyFrom: toDateInput(p?.policyFrom),
policyTo: toDateInput(p?.policyTo),
netPremium: p?.netPremium != null ? String(p.netPremium) : "",
policyFee: p?.policyFee != null ? String(p.policyFee) : "",
brokerFee: p?.brokerFee != null ? String(p.brokerFee) : "",
commission: p?.commission != null ? String(p.commission) : "",
currency: (p?.currency as Currency) ?? "MXN",
liquidated: p?.liquidated ?? false,
liquidationNumber: p?.liquidationNumber ?? "",
liquidationDate: toDateInput(p?.liquidationDate),
endorsement: p?.endorsement ?? false,
observations: p?.observations ?? "",
notes: p?.notes ?? "",
};
}
export function PolicyForm({
policy,
fixedCustomerId,
fixedCustomerName,
}: {
policy?: PolicyDetail;
fixedCustomerId?: string;
fixedCustomerName?: string;
}) {
const router = useRouter();
const editing = !!policy;
const [v, setV] = useState<V>(() => initial(policy));
const [lookups, setLookups] = useState<LookupsResponse | null>(null);
const [customerId, setCustomerId] = useState(
policy?.customer.id ?? fixedCustomerId ?? "",
);
const [customerName, setCustomerName] = useState(
policy?.customer.name ?? fixedCustomerName ?? "",
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getLookups().then(setLookups).catch(() => setLookups(null));
}, []);
function set<K extends keyof V>(k: K, val: V[K]) {
setV((p) => ({ ...p, [k]: val }));
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!customerId) {
setError("Seleccione un cliente.");
return;
}
setSaving(true);
setError(null);
const base = {
policyNumber: v.policyNumber.trim(),
policyTypeId: s(v.policyTypeId),
insuranceProviderId: s(v.insuranceProviderId),
agentName: s(v.agentName),
policyDate: s(v.policyDate),
policyFrom: s(v.policyFrom),
policyTo: s(v.policyTo),
netPremium: numOrUndef(v.netPremium),
policyFee: numOrUndef(v.policyFee),
brokerFee: numOrUndef(v.brokerFee),
commission: numOrUndef(v.commission),
currency: v.currency,
liquidated: v.liquidated,
liquidationNumber: s(v.liquidationNumber),
liquidationDate: s(v.liquidationDate),
endorsement: v.endorsement,
observations: s(v.observations),
notes: s(v.notes),
};
try {
if (editing) {
const saved = await updatePolicy(policy!.id, base);
router.push(`/polizas/${saved.id}`);
} else {
const payload: PolicyInput = { ...base, customerId };
const saved = await createPolicy(payload);
router.push(`/polizas/${saved.id}`);
}
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo guardar la póliza.");
setSaving(false);
}
}
return (
<form onSubmit={submit}>
{error && <div className="state-box state-error">{error}</div>}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>Datos de la póliza</h2>
<div className="form-grid">
<label className="field">
<span className="field-label">Cliente *</span>
{editing ? (
<input className="input" value={customerName} disabled />
) : (
<CustomerPicker
value={customerId}
valueName={customerName}
onPick={(id, name) => {
setCustomerId(id);
setCustomerName(name);
}}
/>
)}
</label>
<label className="field">
<span className="field-label">Número de póliza *</span>
<input className="input" required value={v.policyNumber}
onChange={(e) => set("policyNumber", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Tipo</span>
<select className="select" value={v.policyTypeId}
onChange={(e) => set("policyTypeId", e.target.value)}>
<option value=""></option>
{lookups?.types.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">Aseguradora</span>
<select className="select" value={v.insuranceProviderId}
onChange={(e) => set("insuranceProviderId", e.target.value)}>
<option value=""></option>
{lookups?.providers.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">Agente</span>
<input className="input" value={v.agentName}
onChange={(e) => set("agentName", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Moneda</span>
<select className="select" value={v.currency}
onChange={(e) => set("currency", e.target.value as Currency)}>
<option value="MXN">MXN</option>
<option value="USD">USD</option>
</select>
</label>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia y prima</h2>
<div className="form-grid">
<label className="field">
<span className="field-label">Emisión</span>
<input className="input" type="date" value={v.policyDate}
onChange={(e) => set("policyDate", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Desde</span>
<input className="input" type="date" value={v.policyFrom}
onChange={(e) => set("policyFrom", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Hasta</span>
<input className="input" type="date" value={v.policyTo}
onChange={(e) => set("policyTo", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Prima neta</span>
<input className="input" type="number" step="0.01" value={v.netPremium}
onChange={(e) => set("netPremium", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Derecho de póliza</span>
<input className="input" type="number" step="0.01" value={v.policyFee}
onChange={(e) => set("policyFee", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Comisión</span>
<input className="input" type="number" step="0.01" value={v.commission}
onChange={(e) => set("commission", e.target.value)} />
</label>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>Liquidación</h2>
<div className="form-grid">
<label className="field">
<span className="field-label">Liquidada</span>
<input type="checkbox" checked={v.liquidated}
onChange={(e) => set("liquidated", e.target.checked)} />
</label>
<label className="field">
<span className="field-label">Endoso</span>
<input type="checkbox" checked={v.endorsement}
onChange={(e) => set("endorsement", e.target.checked)} />
</label>
<label className="field">
<span className="field-label">No. liquidación</span>
<input className="input" value={v.liquidationNumber}
onChange={(e) => set("liquidationNumber", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Fecha liquidación</span>
<input className="input" type="date" value={v.liquidationDate}
onChange={(e) => set("liquidationDate", e.target.value)} />
</label>
</div>
<label className="field" style={{ marginTop: 14 }}>
<span className="field-label">Observaciones</span>
<textarea className="input" rows={2} value={v.observations}
onChange={(e) => set("observations", e.target.value)} />
</label>
<label className="field" style={{ marginTop: 12 }}>
<span className="field-label">Notas</span>
<textarea className="input" rows={2} value={v.notes}
onChange={(e) => set("notes", e.target.value)} />
</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? "Guardando…" : editing ? "Guardar cambios" : "Crear póliza"}
</button>
<button type="button" className="btn btn-outline" onClick={() => router.back()}>
Cancelar
</button>
</div>
</form>
);
}