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:
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Output renderers for the reports module.
|
||||
*
|
||||
* Every report's `run` returns `{ columns, rows, totals?, subtitle? }`.
|
||||
* CSV/XLSX/PDF all derive from the same shape so adding a report = one
|
||||
* registry entry, no per-format template.
|
||||
*
|
||||
* PDF uses pdfkit. The statement format (edo-cuenta-datos) uses a
|
||||
* different layout than the tabular one — handled inline.
|
||||
*/
|
||||
|
||||
// pdfkit exports its constructor via `module.exports = PDFDocument`, so a
|
||||
// namespace import gets the type, and `import = require()` gets the value.
|
||||
import PDFDocument = require("pdfkit");
|
||||
import * as ExcelJS from "exceljs";
|
||||
import type { ColumnDef, ReportResult } from "./reports.types";
|
||||
import { getCompanyInfo } from "./company";
|
||||
|
||||
type Doc = PDFKit.PDFDocument;
|
||||
|
||||
/* ----------------------------------------------------------------- CSV */
|
||||
|
||||
function csvCell(v: unknown): string {
|
||||
if (v === null || v === undefined) return "";
|
||||
const s = String(v);
|
||||
if (s.includes(",") || s.includes('"') || s.includes("\n") || s.includes("\r")) {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function renderCsv(columns: ColumnDef[], result: ReportResult): string {
|
||||
const headers = columns.map((c) => csvCell(c.label)).join(",");
|
||||
const lines = result.rows.map((r) =>
|
||||
columns
|
||||
.map((c) => {
|
||||
const v = r[c.key];
|
||||
if (typeof v === "number") return v;
|
||||
return csvCell(v);
|
||||
})
|
||||
.join(","),
|
||||
);
|
||||
const totals: string[] = [];
|
||||
if (result.totals) {
|
||||
for (const [k, v] of Object.entries(result.totals)) {
|
||||
totals.push(csvCell(k), csvCell(v));
|
||||
}
|
||||
}
|
||||
return [headers, ...lines, ...(totals.length ? [totals.join(",")] : [])].join(
|
||||
"\n",
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- XLSX */
|
||||
|
||||
export async function renderXlsx(
|
||||
columns: ColumnDef[],
|
||||
result: ReportResult,
|
||||
): Promise<Buffer> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = "Jorge Cuadros & Asociados";
|
||||
const ws = wb.addWorksheet("Reporte", {
|
||||
views: [{ state: "frozen", ySplit: 1 }],
|
||||
});
|
||||
ws.columns = columns.map((c) => ({
|
||||
header: c.label,
|
||||
key: c.key,
|
||||
width: Math.max(10, Math.min(40, (c.label.length + 2) * 1.2)),
|
||||
}));
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = {
|
||||
type: "pattern",
|
||||
pattern: "solid",
|
||||
fgColor: { argb: "FFE2EDE9" }, // brand-tint
|
||||
};
|
||||
for (const row of result.rows) {
|
||||
ws.addRow(row);
|
||||
}
|
||||
// Number formatting for money columns.
|
||||
for (const col of columns) {
|
||||
if (col.type === "money" || col.type === "number") {
|
||||
ws.getColumn(col.key).numFmt =
|
||||
col.type === "money" ? "#,##0.00" : "#,##0";
|
||||
ws.getColumn(col.key).alignment = { horizontal: "right" };
|
||||
}
|
||||
}
|
||||
if (result.totals) {
|
||||
const last = ws.addRow({});
|
||||
let i = 1;
|
||||
for (const [k, v] of Object.entries(result.totals)) {
|
||||
const cell = ws.getCell(last.number, i);
|
||||
cell.value = `${k}: ${v}`;
|
||||
cell.font = { bold: true };
|
||||
i++;
|
||||
}
|
||||
}
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(buf);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- PDF */
|
||||
|
||||
const BRAND = "#0c322d";
|
||||
const ACCENT = "#bf5a34";
|
||||
const MUTED = "#756c5c";
|
||||
const LINE = "#e4dccb";
|
||||
|
||||
function fmtMoney(v: unknown): string {
|
||||
if (v === null || v === undefined || v === "") return "";
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return String(v);
|
||||
return n.toLocaleString("es-MX", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function pdfRow(
|
||||
doc: Doc,
|
||||
y: number,
|
||||
cols: Array<{ label: string; width: number; align?: "left" | "right" }>,
|
||||
values: Array<{ text: string; align?: "left" | "right" }>,
|
||||
x: number,
|
||||
): number {
|
||||
let cx = x;
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
const c = cols[i];
|
||||
const v = values[i] ?? { text: "" };
|
||||
const align = v.align ?? c.align ?? "left";
|
||||
const w = c.width;
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(9)
|
||||
.fillColor("#211d17")
|
||||
.text(v.text, cx, y, {
|
||||
width: w - 4,
|
||||
align,
|
||||
ellipsis: true,
|
||||
lineBreak: false,
|
||||
height: 16,
|
||||
});
|
||||
cx += w;
|
||||
}
|
||||
return y + 18;
|
||||
}
|
||||
|
||||
export function renderPdf(
|
||||
columns: ColumnDef[],
|
||||
result: ReportResult,
|
||||
title: string,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({
|
||||
size: "LETTER",
|
||||
layout: "landscape",
|
||||
margins: { top: 96, bottom: 56, left: 48, right: 48 },
|
||||
bufferPages: true,
|
||||
info: {
|
||||
Title: title,
|
||||
Author: "Jorge Cuadros & Asociados",
|
||||
Subject: "Reporte",
|
||||
Creator: "Jorge Cuadros Platform — Reports module",
|
||||
},
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on("data", (c: Buffer) => chunks.push(c));
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
doc.on("error", reject);
|
||||
|
||||
const company = getCompanyInfo();
|
||||
const pageW = doc.page.width - 96;
|
||||
|
||||
/** The header is repeated on every page (via addPage + manual draw). */
|
||||
const drawHeader = () => {
|
||||
// Background bar (brand pine) for the masthead.
|
||||
doc.rect(0, 0, doc.page.width, 60).fill(BRAND);
|
||||
|
||||
// Logo, fitted to a 40px box, with 8px padding.
|
||||
let textX = 48;
|
||||
if (company.logo) {
|
||||
const targetH = 40;
|
||||
const scale = targetH / company.logo.height;
|
||||
const w = company.logo.width * scale;
|
||||
doc.image(company.logo.buffer, 48, 10, { height: targetH });
|
||||
textX = 48 + w + 14;
|
||||
}
|
||||
|
||||
// Company name (large) + "Reporte" tag below it.
|
||||
doc
|
||||
.fillColor("#f5f1e8")
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(15)
|
||||
.text(company.name, textX, 14, { width: pageW - (textX - 48), lineBreak: false });
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(8)
|
||||
.fillColor("#cde0db")
|
||||
.text("Reporte", textX, 36, { lineBreak: false });
|
||||
|
||||
// Right-aligned company locator (address + phone + email).
|
||||
const rightLines = [
|
||||
company.addressLine1,
|
||||
company.addressLine2,
|
||||
[company.cityState].filter(Boolean).join(" · "),
|
||||
[company.phone, company.email].filter(Boolean).join(" · "),
|
||||
company.taxId ? `RFC: ${company.taxId}` : "",
|
||||
].filter(Boolean);
|
||||
doc.font("Helvetica").fontSize(8).fillColor("#cde0db");
|
||||
let ry = 12;
|
||||
for (const line of rightLines) {
|
||||
doc.text(line, 48, ry, {
|
||||
width: pageW,
|
||||
align: "right",
|
||||
lineBreak: false,
|
||||
ellipsis: true,
|
||||
});
|
||||
ry += 10;
|
||||
}
|
||||
|
||||
// Thin accent line under the masthead.
|
||||
doc.rect(0, 60, doc.page.width, 2).fill(ACCENT);
|
||||
|
||||
// Title + subtitle + printed-at.
|
||||
doc
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(15)
|
||||
.fillColor(BRAND)
|
||||
.text(title, 48, 72, { lineBreak: false });
|
||||
let metaY = 92;
|
||||
if (result.subtitle) {
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(9)
|
||||
.fillColor(MUTED)
|
||||
.text(result.subtitle, 48, metaY, { lineBreak: false });
|
||||
metaY += 12;
|
||||
}
|
||||
const printedAt = new Date().toLocaleString("es-MX");
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(8)
|
||||
.fillColor(MUTED)
|
||||
.text(`Impreso: ${printedAt}`, 48, metaY, { lineBreak: false });
|
||||
};
|
||||
|
||||
drawHeader();
|
||||
|
||||
// Column widths: distribute page width minus margins, weighted.
|
||||
const totalW = columns.reduce((s, c) => s + (c.width ?? 12), 0);
|
||||
const cols = columns.map((c) => ({
|
||||
label: c.label,
|
||||
width: ((c.width ?? 12) / totalW) * pageW,
|
||||
align: c.align,
|
||||
}));
|
||||
|
||||
let y = 130;
|
||||
const drawTableHeader = () => {
|
||||
doc.rect(48, y, pageW, 18).fill("#faf6ee");
|
||||
y = pdfRow(
|
||||
doc,
|
||||
y + 4,
|
||||
cols,
|
||||
cols.map((c) => ({ text: c.label, align: c.align })),
|
||||
48,
|
||||
);
|
||||
doc
|
||||
.moveTo(48, y)
|
||||
.lineTo(48 + pageW, y)
|
||||
.strokeColor(LINE)
|
||||
.lineWidth(0.5)
|
||||
.stroke();
|
||||
};
|
||||
drawTableHeader();
|
||||
|
||||
// Body rows.
|
||||
for (const r of result.rows) {
|
||||
if (y > doc.page.height - 64) {
|
||||
doc.addPage({ layout: "landscape", margins: { top: 96, bottom: 56, left: 48, right: 48 } });
|
||||
drawHeader();
|
||||
y = 130;
|
||||
drawTableHeader();
|
||||
}
|
||||
const vals = columns.map((c) => {
|
||||
const v = r[c.key];
|
||||
const text = c.type === "money" ? fmtMoney(v) : v == null ? "" : String(v);
|
||||
return { text, align: c.align };
|
||||
});
|
||||
y = pdfRow(doc, y + 4, cols, vals, 48);
|
||||
doc
|
||||
.moveTo(48, y)
|
||||
.lineTo(48 + pageW, y)
|
||||
.strokeColor("#e4dccb")
|
||||
.lineWidth(0.4)
|
||||
.stroke();
|
||||
}
|
||||
|
||||
// Totals.
|
||||
if (result.totals) {
|
||||
y += 6;
|
||||
doc.rect(48, y, pageW, 18).fill(ACCENT);
|
||||
doc
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(9)
|
||||
.fillColor("#f5f1e8")
|
||||
.text(
|
||||
Object.entries(result.totals)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(" · "),
|
||||
52,
|
||||
y + 5,
|
||||
{ width: pageW - 8, align: "left" },
|
||||
);
|
||||
}
|
||||
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- print (HTML) */
|
||||
|
||||
/**
|
||||
* Print-stylesheet-friendly HTML. The web app's print stylesheet hides
|
||||
* nav, but otherwise this is a plain table the browser paginates itself.
|
||||
*
|
||||
* The header carries the company info (logo + name + locator) so printed
|
||||
* pages stand alone — staff can hand one to a customer and the office
|
||||
* identification is on every sheet, not buried in the cover page.
|
||||
*/
|
||||
export function renderPrintHtml(
|
||||
columns: ColumnDef[],
|
||||
result: ReportResult,
|
||||
title: string,
|
||||
): string {
|
||||
const company = getCompanyInfo();
|
||||
const head = (label: string, align?: "left" | "right") =>
|
||||
`<th style="text-align:${align ?? "left"};padding:6px 8px;border-bottom:2px solid #0c322d;background:#faf6ee;font-size:11px">${escapeHtml(label)}</th>`;
|
||||
const cell = (v: unknown, c: ColumnDef) => {
|
||||
const text = c.type === "money" ? fmtMoney(v) : v == null ? "" : String(v);
|
||||
const align = c.align ?? "left";
|
||||
return `<td style="text-align:${align};padding:4px 8px;border-bottom:1px solid #e4dccb;font-size:11px;${c.type === "money" ? "font-variant-numeric:tabular-nums" : ""}">${escapeHtml(text)}</td>`;
|
||||
};
|
||||
const rows = result.rows
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr>${columns
|
||||
.map((c) => cell(r[c.key], c))
|
||||
.join("")}</tr>`,
|
||||
)
|
||||
.join("");
|
||||
const totals = result.totals
|
||||
? `<tr><td colspan="${columns.length}" style="padding:8px;background:#bf5a34;color:#f5f1e8;font-weight:600;font-size:11px">${Object.entries(
|
||||
result.totals,
|
||||
)
|
||||
.map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`)
|
||||
.join(" · ")}</td></tr>`
|
||||
: "";
|
||||
|
||||
// Logo embedded as base64 data URL — the print page is opened as a
|
||||
// new tab and printed standalone, so a relative path to the web app
|
||||
// wouldn't resolve when launched outside the web's origin.
|
||||
const logoDataUrl = company.logo
|
||||
? `data:image/png;base64,${company.logo.buffer.toString("base64")}`
|
||||
: null;
|
||||
|
||||
const locatorLines = [
|
||||
company.addressLine1,
|
||||
company.addressLine2,
|
||||
company.cityState,
|
||||
[company.phone, company.email].filter(Boolean).join(" · "),
|
||||
company.taxId ? `RFC: ${company.taxId}` : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="es"><head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapeHtml(title)} — ${escapeHtml(company.name)}</title>
|
||||
<style>
|
||||
@page { size: letter landscape; margin: 0.5in; }
|
||||
body { font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif; color: #211d17; margin: 0; }
|
||||
.masthead { display: flex; align-items: flex-start; gap: 16px; padding: 12px 16px; background: #0c322d; color: #f5f1e8; border-radius: 6px 6px 0 0; }
|
||||
.masthead-logo { flex: 0 0 auto; }
|
||||
.masthead-logo img { display: block; height: 56px; width: auto; }
|
||||
.masthead-text { flex: 1; min-width: 0; }
|
||||
.masthead-name { font-family: Georgia, "Times New Roman", serif; font-size: 20px; font-weight: 600; line-height: 1.1; }
|
||||
.masthead-tag { font-size: 11px; color: #cde0db; margin-top: 2px; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.masthead-locator { font-size: 10px; color: #cde0db; text-align: right; line-height: 1.35; white-space: nowrap; }
|
||||
.accent { height: 3px; background: #bf5a34; }
|
||||
.head { padding: 12px 4px 8px; }
|
||||
.head h1 { font-family: Georgia, "Times New Roman", serif; font-size: 18px; margin: 0; color: #0c322d; }
|
||||
.head p { font-size: 11px; color: #756c5c; margin: 2px 0 0; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
@media print {
|
||||
.noprint { display: none; }
|
||||
.masthead { border-radius: 0; }
|
||||
}
|
||||
.noprint { padding: 8px 0; }
|
||||
.noprint button { padding: 6px 12px; background: #0c322d; color: #f5f1e8; border: 0; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||
.footer { margin-top: 16px; font-size: 9px; color: #756c5c; border-top: 1px solid #e4dccb; padding-top: 6px; display: flex; justify-content: space-between; }
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="noprint"><button onclick="window.print()">Imprimir / Guardar PDF</button></div>
|
||||
<div class="masthead">
|
||||
${logoDataUrl ? `<div class="masthead-logo"><img src="${logoDataUrl}" alt="" /></div>` : ""}
|
||||
<div class="masthead-text">
|
||||
<div class="masthead-name">${escapeHtml(company.name)}</div>
|
||||
<div class="masthead-tag">Reporte</div>
|
||||
</div>
|
||||
<div class="masthead-locator">
|
||||
${locatorLines.map((l) => escapeHtml(l)).join("<br/>")}
|
||||
${company.website ? `<br/>${escapeHtml(company.website)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="accent"></div>
|
||||
<div class="head">
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
${result.subtitle ? `<p>${escapeHtml(result.subtitle)}</p>` : ""}
|
||||
<p>Impreso: ${new Date().toLocaleString("es-MX")}</p>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>${columns.map((c) => head(c.label, c.align)).join("")}</tr></thead>
|
||||
<tbody>${rows}${totals}</tbody>
|
||||
</table>
|
||||
<div class="footer">
|
||||
<span>${escapeHtml(company.name)} · ${escapeHtml(company.phone)} · ${escapeHtml(company.email)}</span>
|
||||
<span>${escapeHtml(title)}</span>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
Reference in New Issue
Block a user