- 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.
434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
/**
|
|
* 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, """);
|
|
}
|