/** * 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 { 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 { 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") => `${escapeHtml(label)}`; const cell = (v: unknown, c: ColumnDef) => { const text = c.type === "money" ? fmtMoney(v) : v == null ? "" : String(v); const align = c.align ?? "left"; return `${escapeHtml(text)}`; }; const rows = result.rows .map( (r) => `${columns .map((c) => cell(r[c.key], c)) .join("")}`, ) .join(""); const totals = result.totals ? `${Object.entries( result.totals, ) .map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`) .join("  ·  ")}` : ""; // 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 ` ${escapeHtml(title)} — ${escapeHtml(company.name)}
${logoDataUrl ? `` : ""}
${escapeHtml(company.name)}
Reporte
${locatorLines.map((l) => escapeHtml(l)).join("
")} ${company.website ? `
${escapeHtml(company.website)}` : ""}

${escapeHtml(title)}

${result.subtitle ? `

${escapeHtml(result.subtitle)}

` : ""}

Impreso: ${new Date().toLocaleString("es-MX")}

${columns.map((c) => head(c.label, c.align)).join("")}${rows}${totals}
`; } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); }