- New reports backend (registry, service, controller, outputs, types) with catalog endpoint + slug/CSV/XLSX/PDF/print outputs. - /reportes catalog + /reportes/[slug] runner; ReportRunner + ContextReports components wire pre-filtered links from domain pages. - Fix: /reportes/[slug] now reads searchParams and forwards initialParams to ReportRunner so /reportes/edo-cuenta-datos?customerId=... auto-runs instead of dropping the id and forcing a manual customer search. - /inicio landing page; root + login redirect to /inicio. - Company header env vars + logo asset for PDF/print rendering. - exceljs + pdfkit deps.
851 lines
26 KiB
TypeScript
851 lines
26 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { AppShell } from "@/components/AppShell";
|
|
import { ContextReports } from "@/components/ContextReports";
|
|
import {
|
|
addPolicyChild,
|
|
archivePolicy,
|
|
getLookups,
|
|
getPolicy,
|
|
policyDocumentDownloadUrl,
|
|
removePolicyChild,
|
|
removePolicyDocument,
|
|
restorePolicy,
|
|
updatePolicyChild,
|
|
uploadPolicyDocument,
|
|
} from "@/lib/api";
|
|
import { useCan } from "@/lib/abilities";
|
|
import { ChildCollection, type ChildConfig } from "@/components/ChildCollection";
|
|
import {
|
|
expiryPhrase,
|
|
formatDate,
|
|
formatMoney,
|
|
policyStatusLabel,
|
|
premiumHeadline,
|
|
SIN_NOMBRE,
|
|
} from "@/lib/labels";
|
|
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types";
|
|
|
|
export default function PolizaDetailPage({
|
|
params,
|
|
}: {
|
|
params: { id: string };
|
|
}) {
|
|
// Next 14 passes `params` as a plain object here — no `use()` unwrapping.
|
|
const { id } = params;
|
|
return (
|
|
<AppShell>
|
|
<Detail id={id} />
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
function Detail({ id }: { id: string }) {
|
|
const [data, setData] = useState<PolicyDetail | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
setLoading(true);
|
|
setError(null);
|
|
getPolicy(id)
|
|
.then((d) => {
|
|
if (alive) {
|
|
setData(d);
|
|
setLoading(false);
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
if (alive) {
|
|
setError(
|
|
e?.status === 404
|
|
? "No encontramos esta póliza."
|
|
: e?.message ?? "No se pudo cargar la póliza.",
|
|
);
|
|
setLoading(false);
|
|
}
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [id]);
|
|
|
|
if (loading) return <DetailSkeleton />;
|
|
|
|
if (error)
|
|
return (
|
|
<>
|
|
<BackLink />
|
|
<div className="state-error" role="alert">
|
|
{error}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
if (!data) return null;
|
|
|
|
const reload = () => getPolicy(id).then(setData).catch(() => {});
|
|
|
|
return (
|
|
<div className="rise">
|
|
<div className="detail-actionbar">
|
|
<BackLink />
|
|
<ContextReports
|
|
entries={[
|
|
{
|
|
slug: "edo-cuenta-datos",
|
|
label: "Estado de cuenta del cliente",
|
|
params: { customerId: data.customer.id },
|
|
},
|
|
]}
|
|
/>
|
|
<PolicyActions data={data} onChange={reload} />
|
|
</div>
|
|
<Hero data={data} />
|
|
<ClienteSection data={data} />
|
|
<CondicionesSection data={data} />
|
|
{data.installments.length > 0 && <PagosSection data={data} />}
|
|
{data.vehicles.length > 0 && <VehiculosSection data={data} />}
|
|
{(data.insuredDrivers.length > 0 || data.beneficiaries.length > 0) && (
|
|
<PersonasSection data={data} />
|
|
)}
|
|
{data.claims.length > 0 && <SiniestrosSection data={data} />}
|
|
<CoberturasSection data={data} />
|
|
<DocumentosSection data={data} onChange={reload} />
|
|
<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">
|
|
← Volver a Pólizas
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ Hero */
|
|
function Hero({ data }: { data: PolicyDetail }) {
|
|
const premium = premiumHeadline(data);
|
|
const phrase = expiryPhrase(data.daysToExpiry);
|
|
const provenance = [data.legacySourceTable, data.legacyId]
|
|
.filter(Boolean)
|
|
.join(" #");
|
|
|
|
const facts: { label: string; value: string }[] = [
|
|
{ label: "Vigencia desde", value: formatDate(data.policyFrom) },
|
|
{ label: "Vigencia hasta", value: formatDate(data.policyTo) },
|
|
{ label: premium.label, value: formatMoney(premium.value, data.currency) },
|
|
{ label: "Moneda", value: data.currency ?? "—" },
|
|
{ label: "Agente", value: data.agentName || "—" },
|
|
];
|
|
|
|
return (
|
|
<div className="detail-hero">
|
|
<div className="hero-top">
|
|
<div>
|
|
<h1 className="hero-name mono">{data.policyNumber || "—"}</h1>
|
|
<div className="hero-provenance">
|
|
{[data.policyType?.name, data.insuranceProvider?.name]
|
|
.filter(Boolean)
|
|
.join(" · ") || "Sin ramo ni aseguradora registrados"}
|
|
</div>
|
|
{provenance && (
|
|
<div className="hero-provenance">Origen: {provenance}</div>
|
|
)}
|
|
</div>
|
|
<div className="hero-badges">
|
|
<span className={`badge status-${data.status}`}>
|
|
{policyStatusLabel(data.status)}
|
|
{phrase && data.status !== "expired" ? ` · ${phrase}` : ""}
|
|
</span>
|
|
<span
|
|
className={`badge ${
|
|
data.liquidated ? "badge-positive" : "badge-negative"
|
|
}`}
|
|
>
|
|
{data.liquidated ? "Liquidada" : "Sin liquidar"}
|
|
</span>
|
|
{data.endorsement && (
|
|
<span className="badge badge-on-dark">Endoso</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="hero-facts">
|
|
{facts.map((f) => (
|
|
<div key={f.label}>
|
|
<div className="hero-fact-label">{f.label}</div>
|
|
<div className="hero-fact-value">{f.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------- Cliente */
|
|
function ClienteSection({ data }: { data: PolicyDetail }) {
|
|
const c = data.customer;
|
|
const location = [c.city?.replace(/,\s*$/, ""), c.state]
|
|
.filter(Boolean)
|
|
.join(", ");
|
|
|
|
return (
|
|
<section className="section">
|
|
<SectionHead rule="datos" title="Cliente" />
|
|
<div className="card">
|
|
<Link href={`/clientes/${c.id}`} className="owner-link">
|
|
<div>
|
|
<div
|
|
className={`owner-name${
|
|
c.name === SIN_NOMBRE ? " cust-name-missing" : ""
|
|
}`}
|
|
>
|
|
{c.name}
|
|
</div>
|
|
<div className="cust-sub">
|
|
{location && <span>{location}</span>}
|
|
{location && (c.phone || c.email) && (
|
|
<span className="sep">·</span>
|
|
)}
|
|
{(c.phone || c.mobile) && <span>{c.phone || c.mobile}</span>}
|
|
{c.email && (
|
|
<>
|
|
<span className="sep">·</span>
|
|
<span>{c.email}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<span className="owner-cta">Ver expediente →</span>
|
|
</Link>
|
|
|
|
{data.properties.length > 0 && (
|
|
<div className="linked-props">
|
|
<div className="kv-label">Propiedades cubiertas</div>
|
|
{data.properties.map((p) => (
|
|
<Link
|
|
key={p.id}
|
|
href={`/servicios/${p.id}`}
|
|
className="linked-prop link"
|
|
>
|
|
{[p.addressLine1, p.addressLine2].filter(Boolean).join(", ") ||
|
|
"Propiedad"}
|
|
{p.zone && <span className="muted"> · Zona {p.zone}</span>}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------- Condiciones */
|
|
function CondicionesSection({ data }: { data: PolicyDetail }) {
|
|
const cur = data.currency;
|
|
return (
|
|
<section className="section">
|
|
<SectionHead rule="seguros" title="Condiciones y primas" />
|
|
<div className="card">
|
|
<div className="kv-grid">
|
|
<KV label="Fecha de emisión" value={formatDate(data.policyDate)} />
|
|
<KV
|
|
label="Periodo de cobertura"
|
|
value={
|
|
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
|
|
}
|
|
/>
|
|
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
|
|
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
|
|
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
|
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
|
{/* The legacy `total` is 0 or null on all but 2 of 2378 policies —
|
|
only show it when it actually carries a figure. */}
|
|
{data.total != null && Number(data.total) > 0 && (
|
|
<KV label="Total" value={formatMoney(data.total, cur)} />
|
|
)}
|
|
<KV
|
|
label="Liquidación"
|
|
value={
|
|
data.liquidated
|
|
? [
|
|
data.liquidationNumber
|
|
? `No. ${data.liquidationNumber}`
|
|
: null,
|
|
data.liquidationDate
|
|
? formatDate(data.liquidationDate)
|
|
: null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ") || "Liquidada"
|
|
: "Pendiente"
|
|
}
|
|
/>
|
|
{data.observations && (
|
|
<div className="kv-block">
|
|
<div className="kv-label">Observaciones</div>
|
|
<div className="kv-value">{data.observations}</div>
|
|
</div>
|
|
)}
|
|
{data.notes && (
|
|
<div className="kv-block">
|
|
<div className="kv-label">Notas</div>
|
|
<div className="kv-value">{data.notes}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/* --------------------------------------------------------------- Pagos */
|
|
function PagosSection({ data }: { data: PolicyDetail }) {
|
|
const paid = data.installments.filter((i) => i.paidDate).length;
|
|
return (
|
|
<section className="section">
|
|
<SectionHead
|
|
rule="cuenta"
|
|
title="Pagos"
|
|
count={data.installments.length}
|
|
countSuffix={`· ${paid} pagados`}
|
|
/>
|
|
<div className="card">
|
|
<div className="subpanel" style={{ margin: 16 }}>
|
|
{data.installments.map((inst) => (
|
|
<InstallmentRow key={inst.id} inst={inst} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function InstallmentRow({ inst }: { inst: Installment }) {
|
|
const method = inst.isCash
|
|
? "Efectivo"
|
|
: inst.checkNumber
|
|
? `Ref. ${inst.checkNumber}`
|
|
: null;
|
|
return (
|
|
<div className="pay-row">
|
|
<span style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
|
<span className="pay-seq">{inst.sequence}</span>
|
|
<span>
|
|
{inst.paidDate ? formatDate(inst.paidDate) : "Sin pagar"}
|
|
{inst.dueDate && !inst.paidDate && (
|
|
<span className="muted" style={{ fontSize: 11 }}>
|
|
{" "}
|
|
· vence {formatDate(inst.dueDate)}
|
|
</span>
|
|
)}
|
|
{method && (
|
|
<span className="muted" style={{ fontSize: 11 }}>
|
|
{" "}
|
|
· {method}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</span>
|
|
<span className="mono" style={{ fontWeight: 600 }}>
|
|
{formatMoney(inst.amount, inst.currency)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ----------------------------------------------------------- Vehículos */
|
|
function VehiculosSection({ data }: { data: PolicyDetail }) {
|
|
return (
|
|
<section className="section">
|
|
<SectionHead
|
|
rule="servicios"
|
|
title="Vehículos asegurados"
|
|
count={data.vehicles.length}
|
|
/>
|
|
<div className="card">
|
|
<div className="veh-grid">
|
|
{data.vehicles.map((v) => (
|
|
<div className="veh-card" key={v.id}>
|
|
<div className="veh-title">
|
|
{[v.make, v.model, v.modelYear].filter(Boolean).join(" ") ||
|
|
"Vehículo"}
|
|
</div>
|
|
<div className="veh-facts">
|
|
{v.bodyType && <span>{v.bodyType}</span>}
|
|
{v.licensePlate && (
|
|
<span>
|
|
Placa: <span className="mono">{v.licensePlate}</span>
|
|
</span>
|
|
)}
|
|
{v.vinNumber && (
|
|
<span>
|
|
Serie: <span className="mono">{v.vinNumber}</span>
|
|
</span>
|
|
)}
|
|
{v.engineNumber && (
|
|
<span>
|
|
Motor: <span className="mono">{v.engineNumber}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------- Asegurados y beneficiarios */
|
|
function PersonasSection({ data }: { data: PolicyDetail }) {
|
|
return (
|
|
<section className="section">
|
|
<SectionHead rule="datos" title="Asegurados y beneficiarios" />
|
|
<div className="card">
|
|
<div className="policy-body">
|
|
{data.insuredDrivers.length > 0 && (
|
|
<div className="subpanel">
|
|
<div className="subpanel-title">
|
|
<span>Asegurados</span>
|
|
<span>{data.insuredDrivers.length}</span>
|
|
</div>
|
|
<div className="mini-list">
|
|
{data.insuredDrivers.map((d) => (
|
|
<div key={d.id}>
|
|
{d.fullName || "—"}
|
|
{d.licenseNumber && (
|
|
<div className="mini-sub mono">Lic. {d.licenseNumber}</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{data.beneficiaries.length > 0 && (
|
|
<div className="subpanel">
|
|
<div className="subpanel-title">
|
|
<span>Beneficiarios</span>
|
|
<span>{data.beneficiaries.length}</span>
|
|
</div>
|
|
<div className="mini-list">
|
|
{data.beneficiaries.map((b) => (
|
|
<div key={b.id}>
|
|
{b.name || "—"}
|
|
{(b.phone || b.email) && (
|
|
<div className="mini-sub">
|
|
{[b.phone, b.email].filter(Boolean).join(" · ")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/* --------------------------------------------------------- Siniestros */
|
|
function SiniestrosSection({ data }: { data: PolicyDetail }) {
|
|
return (
|
|
<section className="section">
|
|
<SectionHead rule="cuenta" title="Siniestros" count={data.claims.length} />
|
|
<div className="card">
|
|
{data.claims.map((c) => (
|
|
<div className="prop-card" key={c.id}>
|
|
<div className="prop-addr">{c.claimType || "Siniestro"}</div>
|
|
<div className="prop-meta">
|
|
{c.incidentDate && (
|
|
<span>Ocurrido: {formatDate(c.incidentDate)}</span>
|
|
)}
|
|
{c.reportedDate && (
|
|
<span>Reportado: {formatDate(c.reportedDate)}</span>
|
|
)}
|
|
{c.adjuster?.name && <span>Ajustador: {c.adjuster.name}</span>}
|
|
</div>
|
|
<div className="kv-grid" style={{ marginTop: 12 }}>
|
|
<KV
|
|
label="Monto reclamado"
|
|
value={formatMoney(c.claimedAmount, data.currency)}
|
|
/>
|
|
<KV
|
|
label="Monto liquidado"
|
|
value={formatMoney(c.settledAmount, data.currency)}
|
|
/>
|
|
<KV label="Fecha de finiquito" value={formatDate(c.settlementDate)} />
|
|
{c.description && (
|
|
<div className="kv-block">
|
|
<div className="kv-label">Descripción</div>
|
|
<div className="kv-value">{c.description}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------- Coberturas */
|
|
/** The legacy tables carry per-line coverage columns the target schema does
|
|
* not model; the migration preserved them verbatim in `coveragesJson`. */
|
|
function CoberturasSection({ data }: { data: PolicyDetail }) {
|
|
const entries = Object.entries(data.coveragesJson ?? {}).filter(
|
|
([, v]) => v !== null && v !== "" && v !== 0,
|
|
);
|
|
if (entries.length === 0) return null;
|
|
|
|
return (
|
|
<section className="section">
|
|
<SectionHead rule="seguros" title="Coberturas" count={entries.length} />
|
|
<div className="card">
|
|
<div className="kv-grid">
|
|
{entries.map(([k, v]) => (
|
|
<div key={k}>
|
|
<div className="kv-label">{k}</div>
|
|
<div className="kv-value">{String(v)}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
|
Campos de cobertura conservados tal cual desde el sistema anterior.
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------- Documentos */
|
|
function DocumentosSection({
|
|
data,
|
|
onChange,
|
|
}: {
|
|
data: PolicyDetail;
|
|
onChange: () => void;
|
|
}) {
|
|
const canEdit = useCan("policy:update");
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [type, setType] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function upload() {
|
|
if (!file) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await uploadPolicyDocument(data.id, file, type.trim() || undefined);
|
|
setFile(null);
|
|
setType("");
|
|
onChange();
|
|
} catch (e) {
|
|
setError((e as Error)?.message ?? "No se pudo subir el archivo.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="section">
|
|
<SectionHead rule="docs" title="Documentos" count={data.documents.length} />
|
|
<div className="card">
|
|
{data.documents.length === 0 ? (
|
|
<div className="empty-inline">
|
|
No hay documentos registrados para esta póliza.
|
|
</div>
|
|
) : (
|
|
<div className="doc-list">
|
|
{data.documents.map((d, i) => (
|
|
<div className="doc-item" key={d.id ?? i}>
|
|
<span className="doc-icon" aria-hidden>
|
|
▤
|
|
</span>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<div className="doc-type">{d.documentType || "Documento"}</div>
|
|
<div className="doc-key">{d.storageKey || "—"}</div>
|
|
</div>
|
|
{d.id && (
|
|
<a
|
|
className="btn btn-ghost"
|
|
href={policyDocumentDownloadUrl(data.id, d.id)}
|
|
>
|
|
Descargar
|
|
</a>
|
|
)}
|
|
{canEdit && d.id && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
onClick={async () => {
|
|
if (!window.confirm("¿Eliminar este documento?")) return;
|
|
try {
|
|
await removePolicyDocument(data.id, d.id!);
|
|
onChange();
|
|
} catch (e) {
|
|
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
|
|
}
|
|
}}
|
|
>
|
|
Eliminar
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{canEdit && (
|
|
<div style={{ padding: "0 22px 18px" }}>
|
|
{error && (
|
|
<div className="state-box state-error" style={{ marginBottom: 12 }}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
<div className="inline-form">
|
|
<input
|
|
className="input"
|
|
placeholder="Tipo (ej. CARATULA)"
|
|
value={type}
|
|
onChange={(e) => setType(e.target.value)}
|
|
/>
|
|
<input
|
|
type="file"
|
|
className="input"
|
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={!file || busy}
|
|
onClick={upload}
|
|
>
|
|
{busy ? "Subiendo…" : "Subir"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------ helpers */
|
|
function KV({
|
|
label,
|
|
value,
|
|
}: {
|
|
label: string;
|
|
value: string | null | undefined;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<div className="kv-label">{label}</div>
|
|
<div className="kv-value">{value || "—"}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionHead({
|
|
rule,
|
|
title,
|
|
count,
|
|
countSuffix,
|
|
}: {
|
|
rule: string;
|
|
title: string;
|
|
count?: number;
|
|
countSuffix?: string;
|
|
}) {
|
|
return (
|
|
<div className="section-head">
|
|
<span className={`section-rule ${rule}`} aria-hidden />
|
|
<h2 className="section-title">{title}</h2>
|
|
{count != null && (
|
|
<span className="section-count">
|
|
{count} {countSuffix ?? ""}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DetailSkeleton() {
|
|
return (
|
|
<div>
|
|
<div
|
|
className="skeleton"
|
|
style={{ height: 16, width: 140, marginBottom: 18 }}
|
|
/>
|
|
<div className="skeleton" style={{ height: 180, borderRadius: 16 }} />
|
|
<div
|
|
className="skeleton"
|
|
style={{ height: 200, borderRadius: 16, marginTop: 34 }}
|
|
/>
|
|
<div
|
|
className="skeleton"
|
|
style={{ height: 260, borderRadius: 16, marginTop: 34 }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|