feat(reports): reports module + /inicio + edo-cuenta-datos prefill
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m35s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m11s

- 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:
2026-07-23 23:20:41 -07:00
parent 921a47cbaa
commit 8802f08d4f
30 changed files with 4424 additions and 6 deletions
+7 -3
View File
@@ -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>
);
}
+476
View File
@@ -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;