"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { API_ORIGIN, reportDownloadUrl, runReport, } from "@/lib/api"; import { CustomerPicker } from "@/components/CustomerPicker"; import { formatMoney, formatNumber } from "@/lib/labels"; import type { ReportDef, ReportParam, ReportRunResult, } from "@/lib/types"; /** * The shared runner. Renders the filter form, fetches the result, and * shows the table + output buttons. One component, every report — the * per-report shape comes entirely from the def the API returns. */ export function ReportRunner({ def, initialParams, }: { def: ReportDef; /** Pre-filled param values (e.g. when launched with a customerId from * a context button on a customer detail page). */ initialParams?: Record; }) { // The form state, keyed by param.key. Initialised from defaults + // initialParams (initialParams wins for explicitly-set keys). const [params, setParams] = useState>(() => { const seed: Record = {}; for (const p of def.params) { if (p.kind === "select" || p.kind === "text" || p.kind === "number" || p.kind === "date") { if (p.defaultValue != null) seed[p.key] = p.defaultValue; } } if (initialParams) Object.assign(seed, initialParams); return seed; }); const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const run = useCallback( (p: Record) => { setLoading(true); setError(null); runReport(def.slug, p) .then(setResult) .catch((e) => { setError(e?.message ?? "No se pudo correr el reporte."); setResult(null); }) .finally(() => setLoading(false)); }, [def.slug], ); // Auto-run on mount so the runner opens with results, not blank. useEffect(() => { run(params); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function updateParam(key: string, value: string) { setParams((prev) => ({ ...prev, [key]: value })); } function applyFilters(e?: React.FormEvent) { e?.preventDefault(); run(params); } return (
{def.params.map((p) => ( updateParam(p.key, v)} /> ))}
{error &&
{error}
} {result && ( )}
); } /* ---------------------------------------------------------- one param field */ function ParamField({ param, value, onChange, }: { param: ReportParam; value: string; onChange: (v: string) => void; }) { const label = ( {param.label} {param.kind === "customer-picker" && !value && ( (requerido) )} ); if (param.kind === "select") { return ( ); } if (param.kind === "date") { return ( ); } if (param.kind === "number") { return ( ); } if (param.kind === "customer-picker") { return (
{label} onChange(id)} />
); } return ( ); } /* ---------------------------------------------------------- results block */ function ResultBlock({ def, result, params, loading, }: { def: ReportDef; result: ReportRunResult; params: Record; loading: boolean; }) { return (
{result.subtitle &&

{result.subtitle}

}

{formatNumber(result.rows.length)} fila {result.rows.length === 1 ? "" : "s"} {result.totals && ( <> {" "}·{" "} {Object.entries(result.totals) .map(([k, v]) => `${k}: ${v}`) .join(" · ")} )}

{loading &&
Actualizando…
} {def.format === "statement" ? ( ) : def.format === "letter" ? ( ) : ( )}
); } /* ---------------------------------------------------------- tabular layout */ function TabularLayout({ result }: { result: ReportRunResult }) { if (result.rows.length === 0) { return (
No se encontraron filas con los filtros actuales.
); } return (
{result.columns.map((c) => ( ))} {result.rows.map((r, i) => ( {result.columns.map((c) => { const v = r[c.key]; return ( ); })} ))} {result.totals && ( )}
{c.label}
{formatCell(v, c.type)}
{Object.entries(result.totals) .map(([k, v]) => `${k}: ${v}`) .join(" · ")}
); } function formatCell(v: unknown, type: string): string { if (v === null || v === undefined || v === "") return "—"; if (type === "money") { const n = Number(v); return Number.isFinite(n) ? new Intl.NumberFormat("es-MX", { minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(n) : String(v); } if (type === "number") { const n = Number(v); return Number.isFinite(n) ? formatNumber(n) : String(v); } return String(v); } /* --------------------------------------------------------- statement layout */ function StatementLayout({ result }: { result: ReportRunResult }) { // The edo-cuenta-datos report synthesises a list with __kind // discriminators (header, summary, movements-header, plain movement). // Group by kind and render each block inline. const header = result.rows.find((r) => r.__kind === "header") as | Record | undefined; const summaries = result.rows.filter((r) => r.__kind === "summary"); const movements = result.rows.filter( (r) => r.__kind !== "header" && r.__kind !== "summary" && r.__kind !== "movements-header", ); if (!header) { return (
Selecciona un cliente y corre el reporte para ver el estado de cuenta.
); } return (

{String(header.name ?? "—")}

{header.numid && header.numid !== "—" ? (

NUMid {String(header.numid)}

) : null} {Boolean(header.address) && (

{String(header.address)}

)} {Boolean(header.city) &&

{String(header.city)}

} {Boolean(header.phone || header.email) && (

{String(header.phone ?? "")} {header.phone && header.email ? " · " : ""} {String(header.email ?? "")}

)}
{summaries.length > 0 && (

Resumen por moneda

{summaries.map((s, i) => ( ))}
Moneda Cargos Abonos Saldo Movs.
{String(s.currency)} {formatCell(s.charges, "money")} {formatCell(s.credits, "money")} {formatCell(s.balance, "money")} {formatCell(s.count, "number")}
)} {movements.length > 0 && (

Movimientos

{movements.map((m, i) => ( ))}
Fecha Concepto Referencia Cargo / Abono Saldo
{String(m.date ?? "")} {String(m.concept ?? "")} {String(m.reference ?? "")} {formatCell(m.amount, "money")} {formatCell(m.balanceAfter, "money")}
)}
); } /* ----------------------------------------------------------- letter layout */ const GENERATION_LABEL: Record = { "1": "1er aviso", "2": "2o aviso", "3": "3er aviso", }; interface LetterVehicle { make?: string | null; model?: string | null; modelYear?: string | number | null; bodyType?: string | null; engineNumber?: string | null; licensePlate?: string | null; } /** * One card per policy due for renewal — the parameterized replacement for * the ~40 cloned "AVISO DE RENOVACION" Access reports (see * docs/RENEWAL_NOTICES.md). Coverage figures (CSL limit, medical coverage, * etc.) come from data (`aviso-renovacion`'s ReportDef reads * `Policy.coveragesJson`) instead of the legacy's hand-typed label text, so * one layout renders every carrier/coverage combination. */ function LetterLayout({ result }: { result: ReportRunResult }) { const letters = result.rows.filter((r) => r.__kind === "letter"); if (letters.length === 0) { return (
No hay pólizas por vencer con los filtros actuales.
); } return (
{letters.map((r, i) => { const vehicle = r.vehicle as LetterVehicle | null; const generation = String(r.generation ?? "1"); return (

Aviso de renovación

{GENERATION_LABEL[generation] ?? `Aviso ${generation}`}

{r.sentAt ? ( Enviado {String(r.sentAt)} ) : ( Pendiente )}

Cliente

{String(r.customerName ?? "—")}

Póliza

{String(r.policyNumber ?? "—")}

Aseguradora

{String(r.provider ?? "—")}

Vence

{String(r.policyTo ?? "—")}

{vehicle && (

Vehículo asegurado

{[vehicle.modelYear, vehicle.make, vehicle.model] .filter(Boolean) .join(" ")} {vehicle.bodyType ? ` · ${vehicle.bodyType}` : ""}

{[ vehicle.engineNumber && `Motor: ${vehicle.engineNumber}`, vehicle.licensePlate && `Placa: ${vehicle.licensePlate}`, ] .filter(Boolean) .join(" · ")}

)}
{Boolean(r.netPremium) && ( Prima neta: {formatCell(r.netPremium, "money")} )} {Boolean(r.total) && ( Total: {formatCell(r.total, "money")} {String(r.currency ?? "")} )}
); })}
); } function CoverageItem({ label, value, money, }: { label: string; value: unknown; money?: boolean; }) { if (value === null || value === undefined || value === "") return null; return (

{label}

{money ? formatCell(value, "money") : String(value)}

); } // Hint to the bundler that API_ORIGIN is part of the API surface used here. void API_ORIGIN;