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>
639 lines
19 KiB
TypeScript
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;
|