From f25a77823057a2417b307d90713baeabafab42b0 Mon Sep 17 00:00:00 2001 From: renee-png Date: Tue, 2 Jun 2026 02:02:07 -0400 Subject: [PATCH] PDF financial reports: add Comparative / Change / Change % columns renderReportPdf now matches the on-screen comparison: when a comparison period is active it renders Amount | Comparative | Change | Change % columns (was Previous | Amount), switches to landscape for room, and underlines each numeric column on totals. Applies to P&L, Cash Flow, Movement of Equity, and Balance Sheet exports. Co-Authored-By: Claude Opus 4.8 --- src/pages/accounting/lib/reportPdf.ts | 63 +++++++++++++++++---------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/src/pages/accounting/lib/reportPdf.ts b/src/pages/accounting/lib/reportPdf.ts index de98e7f..6e38ad7 100644 --- a/src/pages/accounting/lib/reportPdf.ts +++ b/src/pages/accounting/lib/reportPdf.ts @@ -56,9 +56,16 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js const ML = 40, MR = 40; const contentR = W - MR; const amtRight = contentR - 6; - const cmpRight = opts.showCompare ? amtRight - 108 : 0; - const amtUnderL = amtRight - 92; - const cmpUnderL = opts.showCompare ? cmpRight - 92 : 0; + 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; @@ -69,13 +76,16 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js doc.rect(ML, y, contentR - ML, h, "F"); doc.setDrawColor(...BORDER); doc.setLineWidth(0.5); doc.rect(ML, y, contentR - ML, h); - // vertical dividers before numeric columns - if (opts.showCompare) doc.line(cmpRight - 96, y, cmpRight - 96, y + h); - doc.line(amtRight - 96, y, amtRight - 96, y + h); - doc.setFont("helvetica", "bold"); doc.setFontSize(9); doc.setTextColor(...TEXT); + // 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); - if (opts.showCompare) doc.text("Previous", cmpRight, y + 12, { align: "right" }); - doc.text(rightHeader, amtRight, y + 12, { align: "right" }); + 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; }; @@ -167,8 +177,8 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js if (row.kind === "total" || row.kind === "grand") { doc.setDrawColor(...RULE); doc.setLineWidth(row.kind === "grand" ? 1.4 : 0.7); - doc.line(amtUnderL, top + 1, amtRight, top + 1); - if (opts.showCompare) doc.line(cmpUnderL, top + 1, cmpRight, top + 1); + const underCols = opts.showCompare ? [colAmount, colCompare, colChange] : [colAmount]; + for (const x of underCols) doc.line(x - UNDER, top + 1, x, top + 1); } // label @@ -185,16 +195,20 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js } // amounts - doc.setFont("helvetica", bold ? "bold" : "normal"); doc.setFontSize(9); - if (row.amount !== undefined) { - const c: RGB = row.amount < 0 ? RED : TEXT; - doc.setTextColor(...c); - doc.text(fmtAmount(row.amount), amtRight, top + 11, { align: "right" }); - } - if (opts.showCompare && row.compare !== undefined) { - const c: RGB = row.compare < 0 ? RED : TEXT; - doc.setTextColor(...c); - doc.text(fmtAmount(row.compare), cmpRight, top + 11, { align: "right" }); + 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; @@ -229,7 +243,7 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js const amt = report.cashHighlight.amount; const c: RGB = amt < 0 ? RED : TEXT; doc.setTextColor(...c); - doc.text(fmtAmount(amt), amtRight, y + 14, { align: "right" }); + doc.text(fmtAmount(amt), colAmount, y + 14, { align: "right" }); y += 28; } @@ -239,7 +253,8 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js /** Render a financial report PDF (no cover page). */ export function renderReportPdf(report: StructuredReport, opts: RenderOpts): jsPDF { - const doc = new jsPDF({ unit: "pt", format: "letter" }); + // 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); } @@ -252,7 +267,7 @@ export async function renderReportPdfWithCover( opts: RenderOpts, cover: ReportCoverData, ): Promise { - const doc = new jsPDF({ unit: "pt", format: "letter" }); + 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);