Files
jorgecuadros-platform/apps/web/src/components/ReportRunner.tsx
T
rmancinasandClaude Opus 5.5 b016e53893
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m42s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s
feat(customers): a shelf folder number the office can hand back out
The office files every customer in a numbered paper folder and reuses the
number once an account closes. That number was the legacy NUMid, which is
also an import key and the portal login, so reusing it meant re-pointing an
identity. It is now customers.folderNumber: a plain label nothing joins on.

- Unique only among non-archived customers, checked in CustomersService
  (create, update, restore). Archived customers keep theirs as history.
- GET /customers/folder/next suggests the lowest free number; the new
  customer form pre-fills it.
- Backfilled from the utilities NUMid (dev: 1,171 folders, 0 shared); the
  importer copies it on load. Insurance-only customers start without one.
- Reports, the printed statement and the customer file show "Carpeta"
  instead of NUMid. The list searches by it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:04:01 -07:00

639 lines
19 KiB
TypeScript

"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<string, string>;
}) {
// The form state, keyed by param.key. Initialised from defaults +
// initialParams (initialParams wins for explicitly-set keys).
const [params, setParams] = useState<Record<string, string>>(() => {
const seed: Record<string, string> = {};
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<ReportRunResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = useCallback(
(p: Record<string, string>) => {
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 (
<div className="report-runner">
<form className="report-filters" onSubmit={applyFilters}>
{def.params.map((p) => (
<ParamField
key={p.key}
param={p}
value={params[p.key] ?? ""}
onChange={(v) => updateParam(p.key, v)}
/>
))}
<div className="report-filters-actions">
<button
type="submit"
className="btn btn-primary"
disabled={loading}
>
{loading ? "Corriendo…" : "Correr reporte"}
</button>
</div>
</form>
{error && <div className="report-error">{error}</div>}
{result && (
<ResultBlock def={def} result={result} params={params} loading={loading} />
)}
</div>
);
}
/* ---------------------------------------------------------- one param field */
function ParamField({
param,
value,
onChange,
}: {
param: ReportParam;
value: string;
onChange: (v: string) => void;
}) {
const label = (
<span className="filter-label">
{param.label}
{param.kind === "customer-picker" && !value && (
<span className="muted" style={{ marginLeft: 6, fontWeight: 400 }}>
(requerido)
</span>
)}
</span>
);
if (param.kind === "select") {
return (
<label className="filter-field">
{label}
<select
className="input select"
value={value}
onChange={(e) => onChange(e.target.value)}
>
{!param.defaultValue && <option value="">—</option>}
{param.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
);
}
if (param.kind === "date") {
return (
<label className="filter-field">
{label}
<input
type="date"
className="input"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
if (param.kind === "number") {
return (
<label className="filter-field">
{label}
<input
type="number"
className="input"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={param.defaultValue}
/>
</label>
);
}
if (param.kind === "customer-picker") {
return (
<div className="filter-field">
{label}
<CustomerPicker
value={value}
onPick={(id) => onChange(id)}
/>
</div>
);
}
return (
<label className="filter-field">
{label}
<input
type="text"
className="input"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={param.placeholder ?? ""}
/>
</label>
);
}
/* ---------------------------------------------------------- results block */
function ResultBlock({
def,
result,
params,
loading,
}: {
def: ReportDef;
result: ReportRunResult;
params: Record<string, string>;
loading: boolean;
}) {
return (
<div className="report-result">
<div className="report-result-head">
<div className="report-result-meta">
{result.subtitle && <p className="muted">{result.subtitle}</p>}
<p className="muted small">
{formatNumber(result.rows.length)} fila
{result.rows.length === 1 ? "" : "s"}
{result.totals && (
<>
{" "}·{" "}
{Object.entries(result.totals)
.map(([k, v]) => `${k}: ${v}`)
.join(" · ")}
</>
)}
</p>
</div>
<div className="report-output-buttons">
<a
className="btn btn-outline btn-sm"
href={reportDownloadUrl(def.slug, "print", params)}
target="_blank"
rel="noopener"
>
Imprimir
</a>
<a
className="btn btn-outline btn-sm"
href={reportDownloadUrl(def.slug, "csv", params)}
download
>
CSV
</a>
<a
className="btn btn-outline btn-sm"
href={reportDownloadUrl(def.slug, "xlsx", params)}
download
>
Excel
</a>
<a
className="btn btn-outline btn-sm"
href={reportDownloadUrl(def.slug, "pdf", params)}
download
>
PDF
</a>
</div>
</div>
{loading && <div className="report-loading">Actualizando…</div>}
{def.format === "statement" ? (
<StatementLayout result={result} />
) : def.format === "letter" ? (
<LetterLayout result={result} />
) : (
<TabularLayout result={result} />
)}
</div>
);
}
/* ---------------------------------------------------------- tabular layout */
function TabularLayout({ result }: { result: ReportRunResult }) {
if (result.rows.length === 0) {
return (
<div className="empty-inline">
No se encontraron filas con los filtros actuales.
</div>
);
}
return (
<div className="report-table-wrap">
<table className="report-table">
<thead>
<tr>
{result.columns.map((c) => (
<th
key={c.key}
style={{
textAlign: c.align ?? "left",
width: c.width ? `${c.width * 6}px` : undefined,
}}
>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((r, i) => (
<tr key={i}>
{result.columns.map((c) => {
const v = r[c.key];
return (
<td
key={c.key}
style={{
textAlign: c.align ?? "left",
fontVariantNumeric:
c.type === "money" || c.type === "number"
? "tabular-nums"
: undefined,
}}
>
{formatCell(v, c.type)}
</td>
);
})}
</tr>
))}
</tbody>
{result.totals && (
<tfoot>
<tr>
<td
colSpan={result.columns.length}
className="report-totals"
>
{Object.entries(result.totals)
.map(([k, v]) => `${k}: ${v}`)
.join(" · ")}
</td>
</tr>
</tfoot>
)}
</table>
</div>
);
}
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<string, unknown>
| 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 (
<div className="empty-inline">
Selecciona un cliente y corre el reporte para ver el estado de cuenta.
</div>
);
}
return (
<div className="statement">
<header className="statement-head">
<h2 className="statement-name">{String(header.name ?? "—")}</h2>
{header.folder && header.folder !== "—" ? (
<p className="muted">Carpeta {String(header.folder)}</p>
) : null}
{Boolean(header.address) && (
<p className="muted">{String(header.address)}</p>
)}
{Boolean(header.city) && <p className="muted">{String(header.city)}</p>}
{Boolean(header.phone || header.email) && (
<p className="muted small">
{String(header.phone ?? "")}
{header.phone && header.email ? " · " : ""}
{String(header.email ?? "")}
</p>
)}
</header>
{summaries.length > 0 && (
<section className="statement-summary">
<h3 className="statement-section-title">Resumen por moneda</h3>
<table className="report-table">
<thead>
<tr>
<th>Moneda</th>
<th style={{ textAlign: "right" }}>Cargos</th>
<th style={{ textAlign: "right" }}>Abonos</th>
<th style={{ textAlign: "right" }}>Saldo</th>
<th style={{ textAlign: "right" }}>Movs.</th>
</tr>
</thead>
<tbody>
{summaries.map((s, i) => (
<tr key={i}>
<td>{String(s.currency)}</td>
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
{formatCell(s.charges, "money")}
</td>
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
{formatCell(s.credits, "money")}
</td>
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
{formatCell(s.balance, "money")}
</td>
<td style={{ textAlign: "right" }}>{formatCell(s.count, "number")}</td>
</tr>
))}
</tbody>
</table>
</section>
)}
{movements.length > 0 && (
<section className="statement-movements">
<h3 className="statement-section-title">Movimientos</h3>
<div className="report-table-wrap">
<table className="report-table">
<thead>
<tr>
<th>Fecha</th>
<th>Concepto</th>
<th>Referencia</th>
<th style={{ textAlign: "right" }}>Cargo / Abono</th>
<th style={{ textAlign: "right" }}>Saldo</th>
</tr>
</thead>
<tbody>
{movements.map((m, i) => (
<tr key={i}>
<td>{String(m.date ?? "")}</td>
<td>{String(m.concept ?? "")}</td>
<td>{String(m.reference ?? "")}</td>
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
{formatCell(m.amount, "money")}
</td>
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
{formatCell(m.balanceAfter, "money")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
);
}
/* ----------------------------------------------------------- letter layout */
const GENERATION_LABEL: Record<string, string> = {
"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 (
<div className="empty-inline">
No hay pólizas por vencer con los filtros actuales.
</div>
);
}
return (
<div className="renewal-letters">
{letters.map((r, i) => {
const vehicle = r.vehicle as LetterVehicle | null;
const generation = String(r.generation ?? "1");
return (
<article className="renewal-letter" key={String(r.policyId ?? i)}>
<header className="renewal-letter-head">
<div>
<h2 className="renewal-letter-title">Aviso de renovación</h2>
<p className="muted small">
{GENERATION_LABEL[generation] ?? `Aviso ${generation}`}
</p>
</div>
<div className="renewal-letter-status">
{r.sentAt ? (
<span className="badge badge-positive">
<span className="dot" />
Enviado {String(r.sentAt)}
</span>
) : (
<span className="badge badge-neutral">
<span className="dot" />
Pendiente
</span>
)}
</div>
</header>
<div className="renewal-letter-grid">
<div>
<p className="muted small">Cliente</p>
<p className="renewal-letter-value">
{String(r.customerName ?? "—")}
</p>
</div>
<div>
<p className="muted small">Póliza</p>
<p className="renewal-letter-value">
{String(r.policyNumber ?? "—")}
</p>
</div>
<div>
<p className="muted small">Aseguradora</p>
<p className="renewal-letter-value">
{String(r.provider ?? "—")}
</p>
</div>
<div>
<p className="muted small">Vence</p>
<p className="renewal-letter-value">
{String(r.policyTo ?? "—")}
</p>
</div>
</div>
{vehicle && (
<div className="renewal-letter-vehicle">
<p className="muted small">Vehículo asegurado</p>
<p className="renewal-letter-value">
{[vehicle.modelYear, vehicle.make, vehicle.model]
.filter(Boolean)
.join(" ")}
{vehicle.bodyType ? ` · ${vehicle.bodyType}` : ""}
</p>
<p className="muted small">
{[
vehicle.engineNumber && `Motor: ${vehicle.engineNumber}`,
vehicle.licensePlate && `Placa: ${vehicle.licensePlate}`,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
)}
<div className="renewal-letter-coverage">
<CoverageItem label="Cobertura (días)" value={r.coverageDays} />
<CoverageItem label="CSL" value={r.cslLimit} money />
<CoverageItem label="Gastos médicos" value={r.medicalCoverage} money />
<CoverageItem label="Daños a propiedad" value={r.propertyDamage} money />
<CoverageItem label="Responsabilidad por persona" value={r.perPersonLiability} money />
</div>
<div className="renewal-letter-premium">
{Boolean(r.netPremium) && (
<span>Prima neta: {formatCell(r.netPremium, "money")}</span>
)}
{Boolean(r.total) && (
<span className="renewal-letter-total">
Total: {formatCell(r.total, "money")} {String(r.currency ?? "")}
</span>
)}
</div>
</article>
);
})}
</div>
);
}
function CoverageItem({
label,
value,
money,
}: {
label: string;
value: unknown;
money?: boolean;
}) {
if (value === null || value === undefined || value === "") return null;
return (
<div className="renewal-letter-coverage-item">
<p className="muted small">{label}</p>
<p className="renewal-letter-value">
{money ? formatCell(value, "money") : String(value)}
</p>
</div>
);
}
// Hint to the bundler that API_ORIGIN is part of the API surface used here.
void API_ORIGIN;