Files
jorgecuadros-platform/apps/web/src/app/polizas/[id]/page.tsx
T
rmancinas 8802f08d4f
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m35s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m11s
feat(reports): reports module + /inicio + edo-cuenta-datos prefill
- 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.
2026-07-23 23:20:41 -07:00

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>
);
}