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