import jsPDF from "jspdf"; import { drawReportCoverPage, type ReportCoverData } from "@/lib/reportCover"; export type RowKind = "section" | "group" | "sub" | "total" | "grand" | "spacer"; export type StructuredRow = { kind: RowKind; label: string; code?: string; amount?: number; compare?: number; /** GL account id for per-account rows — enables drill-down from on-screen reports. */ accountId?: string; }; export type StructuredReport = { title: string; rows: StructuredRow[]; balanced?: boolean; outOfBalanceAmount?: number; cashHighlight?: { label: string; amount: number }; }; export type RenderOpts = { companyName: string; appName: string; rangeLabel: string; currency: string; showCodes: boolean; showCompare: boolean; showZero: boolean; /** Optional metadata lines for the header block (bold label + value). */ meta?: { label: string; value: string }[]; /** Preloaded logo (dataURL + dimensions) for the branded header. */ logo?: { dataURL: string; w: number; h: number } | null; }; type RGB = [number, number, number]; const TEXT: RGB = [33, 37, 41]; const MUTED: RGB = [110, 116, 122]; const RULE: RGB = [20, 20, 20]; const BAND: RGB = [232, 235, 238]; // section header band const ZEBRA: RGB = [247, 248, 250]; // alternating line-item rows const HEADER_FILL: RGB = [237, 239, 242]; // column-header row const BORDER: RGB = [196, 200, 205]; const RED: RGB = [185, 28, 28]; /** Accounting number: thousands separators, 2 decimals, leading minus for negatives. */ export function fmtAmount(n: number | undefined | null): string { if (n === undefined || n === null) return ""; return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } /** Draw the report body onto `doc`, starting on its current page. */ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): jsPDF { const firstPage = doc.getNumberOfPages(); const W = doc.internal.pageSize.getWidth(); const H = doc.internal.pageSize.getHeight(); const ML = 40, MR = 40; const contentR = W - MR; const amtRight = contentR - 6; const COLW = 86; // width of each numeric column const UNDER = 78; // underline width under a numeric column // Numeric column right edges. With a comparison: Amount | Comparative | Change | Change %. const colPct = amtRight; const colChange = amtRight - COLW; const colCompare = amtRight - 2 * COLW; const colAmount = opts.showCompare ? amtRight - 3 * COLW : amtRight; const numericLeft = colAmount - UNDER; // left boundary of the numeric block (for divider) const pctStr = (a?: number, c?: number) => a === undefined || c === undefined || Math.abs(c) < 0.005 ? "—" : `${(((a - c) / Math.abs(c)) * 100).toFixed(1)}%`; const isBalanceSheet = /balance sheet/i.test(report.title); const rightHeader = isBalanceSheet ? "Balance" : "Amount"; let y = 0; const drawColHeader = () => { const h = 18; doc.setFillColor(...HEADER_FILL); doc.rect(ML, y, contentR - ML, h, "F"); doc.setDrawColor(...BORDER); doc.setLineWidth(0.5); doc.rect(ML, y, contentR - ML, h); // vertical divider before the numeric block doc.line(numericLeft - 6, y, numericLeft - 6, y + h); doc.setFont("helvetica", "bold"); doc.setFontSize(opts.showCompare ? 8 : 9); doc.setTextColor(...TEXT); doc.text("Account Name", ML + 6, y + 12); doc.text(rightHeader, colAmount, y + 12, { align: "right" }); if (opts.showCompare) { doc.text("Comparative", colCompare, y + 12, { align: "right" }); doc.text("Change", colChange, y + 12, { align: "right" }); doc.text("Change %", colPct, y + 12, { align: "right" }); } y += h; }; // Full header (page 1): title + metadata block + column header const drawFullHeader = () => { // Branded logo (top-left) + centered title — matches the main reports. if (opts.logo) { try { const lh = 40; const lw = Math.min(150, lh * (opts.logo.w / opts.logo.h)); doc.addImage(opts.logo.dataURL, "PNG", ML, 26, lw, lh, undefined, "FAST"); } catch { /* ignore */ } } doc.setFont("helvetica", "bold"); doc.setFontSize(16); doc.setTextColor(...TEXT); doc.text(report.title, W / 2, 48, { align: "center" }); y = 82; const meta = opts.meta ?? [ { label: "Properties:", value: opts.companyName || "—" }, { label: isBalanceSheet ? "As of:" : "Period:", value: opts.rangeLabel }, { label: "Accounting Basis:", value: "Accrual" }, ]; doc.setFontSize(9.5); for (const m of meta) { doc.setFont("helvetica", "bold"); doc.setTextColor(...TEXT); doc.text(m.label, ML, y); const lw = doc.getTextWidth(m.label); doc.setFont("helvetica", "normal"); doc.text(` ${m.value}`, ML + lw, y); y += 14; } y += 6; drawColHeader(); }; // Continuation header (page 2+): centered title + column header only const drawContHeader = () => { doc.setFont("helvetica", "bold"); doc.setFontSize(12); doc.setTextColor(...TEXT); doc.text(report.title, W / 2, 40, { align: "center" }); y = 56; drawColHeader(); }; const drawFooter = () => { const total = doc.getNumberOfPages(); const gen = new Date().toLocaleString("en-US"); const pages = total - firstPage + 1; // Footer only on content pages (skip any preceding cover page). for (let p = firstPage; p <= total; p++) { doc.setPage(p); doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...MUTED); doc.text(`Generated ${gen}`, ML, H - 28); doc.text(`Page ${p - firstPage + 1} of ${pages}`, contentR, H - 28, { align: "right" }); } }; const ensure = (need: number) => { if (y + need > H - 50) { doc.addPage(); drawContHeader(); } }; drawFullHeader(); let alt = false; for (const row of report.rows) { if (row.kind === "spacer") { y += 6; alt = false; continue; } if (row.kind === "sub" && !opts.showZero && (row.amount ?? 0) === 0) continue; if (row.kind === "section") { ensure(20); doc.setFillColor(...BAND); doc.rect(ML, y, contentR - ML, 18, "F"); doc.setFont("helvetica", "bold"); doc.setFontSize(9.5); doc.setTextColor(...TEXT); doc.text(row.label, ML + 6, y + 12); y += 18; alt = false; continue; } if (row.kind === "group") { ensure(16); doc.setFont("helvetica", "bold"); doc.setFontSize(9); doc.setTextColor(...TEXT); doc.text(row.label, ML + 12, y + 11); y += 15; alt = false; continue; } const h = 16; ensure(h + 2); const top = y; const bold = row.kind === "total" || row.kind === "grand"; // zebra striping on line items if (row.kind === "sub" && alt) { doc.setFillColor(...ZEBRA); doc.rect(ML, top, contentR - ML, h, "F"); } // rule above subtotals (thin) and grand totals (heavy), over the number columns if (row.kind === "total" || row.kind === "grand") { doc.setDrawColor(...RULE); doc.setLineWidth(row.kind === "grand" ? 1.4 : 0.7); const underCols = opts.showCompare ? [colAmount, colCompare, colChange] : [colAmount]; for (const x of underCols) doc.line(x - UNDER, top + 1, x, top + 1); } // label const xLabel = row.kind === "sub" ? ML + 18 : row.kind === "total" ? ML + 12 : ML + 6; doc.setFont("helvetica", bold ? "bold" : "normal"); doc.setFontSize(9); doc.setTextColor(...TEXT); if (row.kind === "sub" && opts.showCodes && row.code) { doc.setTextColor(...MUTED); doc.setFontSize(8.5); doc.text(row.code, xLabel, top + 11); const cw = doc.getTextWidth(row.code) + 6; doc.setTextColor(...TEXT); doc.setFontSize(9); doc.text(row.label, xLabel + cw, top + 11); } else { doc.text(row.label, xLabel, top + 11); } // amounts const drawNum = (val: number | undefined, x: number) => { if (val === undefined) return; doc.setFont("helvetica", bold ? "bold" : "normal"); doc.setFontSize(9); doc.setTextColor(...(val < 0 ? RED : TEXT)); doc.text(fmtAmount(val), x, top + 11, { align: "right" }); }; drawNum(row.amount, colAmount); if (opts.showCompare) { drawNum(row.compare, colCompare); if (row.amount !== undefined && row.compare !== undefined) drawNum(row.amount - row.compare, colChange); if (row.amount !== undefined) { doc.setFont("helvetica", bold ? "bold" : "normal"); doc.setFontSize(9); doc.setTextColor(...MUTED); doc.text(pctStr(row.amount, row.compare), colPct, top + 11, { align: "right" }); } } y += h; alt = row.kind === "sub" ? !alt : false; } // Balance-sheet balanced callout if (report.balanced !== undefined) { ensure(28); y += 6; const ok = report.balanced; const border: RGB = ok ? [22, 128, 61] : [185, 28, 28]; const fill: RGB = ok ? [240, 253, 244] : [254, 242, 242]; const txt: RGB = ok ? [22, 101, 52] : [153, 27, 27]; doc.setDrawColor(...border); doc.setLineWidth(0.8); doc.setFillColor(...fill); doc.rect(ML, y, contentR - ML, 22, "FD"); doc.setFont("helvetica", "bold"); doc.setFontSize(9.5); doc.setTextColor(...txt); const msg = ok ? "Balance Sheet is balanced" : `Balance Sheet is OUT OF BALANCE by ${fmtAmount(report.outOfBalanceAmount ?? 0)} (Assets - Liabilities - Equity)`; doc.text(msg, ML + 8, y + 14); y += 28; } // Cash-flow net-change highlight if (report.cashHighlight) { ensure(28); y += 6; doc.setFillColor(...HEADER_FILL); doc.rect(ML, y, contentR - ML, 22, "F"); doc.setFont("helvetica", "bold"); doc.setFontSize(10); doc.setTextColor(...TEXT); doc.text(report.cashHighlight.label, ML + 8, y + 14); const amt = report.cashHighlight.amount; const c: RGB = amt < 0 ? RED : TEXT; doc.setTextColor(...c); doc.text(fmtAmount(amt), colAmount, y + 14, { align: "right" }); y += 28; } drawFooter(); return doc; } /** Render a financial report PDF (no cover page). */ export function renderReportPdf(report: StructuredReport, opts: RenderOpts): jsPDF { // Landscape when a comparison period is shown — room for the extra columns. const doc = new jsPDF({ unit: "pt", format: "letter", orientation: opts.showCompare ? "landscape" : "portrait" }); return buildReport(doc, report, opts); } /** * Render a financial report PDF that opens with the shared branded cover page * (same look as the general Report Generator), followed by the report body. */ export async function renderReportPdfWithCover( report: StructuredReport, opts: RenderOpts, cover: ReportCoverData, ): Promise { const doc = new jsPDF({ unit: "pt", format: "letter", orientation: opts.showCompare ? "landscape" : "portrait" }); await drawReportCoverPage(doc, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), cover); doc.addPage(); return buildReport(doc, report, opts); }