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.
This commit is contained in:
@@ -14,12 +14,14 @@ import type { AuthUser, Ability } from "@/lib/types";
|
||||
* content. Provides the AuthContext so any page can read the user's
|
||||
* abilities. Used by every authenticated page.
|
||||
*/
|
||||
const NAV: { href: string; label: string; ability?: Ability }[] = [
|
||||
const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[] = [
|
||||
{ href: "/inicio", label: "Inicio", exact: true },
|
||||
{ href: "/clientes", label: "Clientes" },
|
||||
{ href: "/servicios", label: "Propiedades" },
|
||||
{ href: "/polizas", label: "Pólizas" },
|
||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||
{ href: "/banco", label: "Chequera" },
|
||||
{ href: "/reportes", label: "Reportes" },
|
||||
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
||||
@@ -78,7 +80,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<AuthContext.Provider value={user}>
|
||||
<header className="appbar">
|
||||
<div className="appbar-inner">
|
||||
<Link href="/clientes" className="brand">
|
||||
<Link href="/inicio" className="brand">
|
||||
<img
|
||||
src="/images/company_logo.png"
|
||||
alt=""
|
||||
@@ -92,7 +94,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<nav className="appbar-nav" aria-label="Principal">
|
||||
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
|
||||
(item) => {
|
||||
const active = pathname?.startsWith(item.href) ?? false;
|
||||
const active = item.exact
|
||||
? pathname === item.href
|
||||
: pathname?.startsWith(item.href) ?? false;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* The context-report shortcut. One row of pill buttons that link to
|
||||
* pre-filtered /reportes/[slug] pages. Used in the page header of
|
||||
* domain pages (clientes, polizas, servicios, estado-cuenta, banco).
|
||||
*
|
||||
* `entries` accepts both static links (slug + label) and pre-filtered
|
||||
* links (slug + params object). Pre-filtered ones build the query
|
||||
* string automatically; the runner pre-fills the form.
|
||||
*/
|
||||
export interface ReportLink {
|
||||
slug: string;
|
||||
label: string;
|
||||
/** Optional pre-fill for the report's filter form. */
|
||||
params?: Record<string, string>;
|
||||
/** When true, opens the report in a new tab (for "see the catalog"
|
||||
* style entries where the user is going to look around). */
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export function ContextReports({
|
||||
label = "Reportes",
|
||||
entries,
|
||||
}: {
|
||||
label?: string;
|
||||
entries: ReportLink[];
|
||||
}) {
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className="context-reports" aria-label={label}>
|
||||
<span className="context-reports-label">{label}</span>
|
||||
{entries.map((e) => {
|
||||
const qs = e.params
|
||||
? "?" +
|
||||
new URLSearchParams(
|
||||
Object.entries(e.params).filter(([, v]) => v != null && v !== ""),
|
||||
).toString()
|
||||
: "";
|
||||
const href = `/reportes/${e.slug}${qs}`;
|
||||
return (
|
||||
<Link
|
||||
key={`${e.slug}-${JSON.stringify(e.params ?? {})}`}
|
||||
href={href}
|
||||
className="context-report-link"
|
||||
target={e.external ? "_blank" : undefined}
|
||||
rel={e.external ? "noopener" : undefined}
|
||||
>
|
||||
{e.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
"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} />
|
||||
) : (
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
// Hint to the bundler that API_ORIGIN is part of the API surface used here.
|
||||
void API_ORIGIN;
|
||||
Reference in New Issue
Block a user