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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 02:02:07 -04:00
parent 3d11980b8c
commit f25a778230
+36 -21
View File
@@ -56,9 +56,16 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js
const ML = 40, MR = 40; const ML = 40, MR = 40;
const contentR = W - MR; const contentR = W - MR;
const amtRight = contentR - 6; const amtRight = contentR - 6;
const cmpRight = opts.showCompare ? amtRight - 108 : 0; const COLW = 86; // width of each numeric column
const amtUnderL = amtRight - 92; const UNDER = 78; // underline width under a numeric column
const cmpUnderL = opts.showCompare ? cmpRight - 92 : 0; // 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 isBalanceSheet = /balance sheet/i.test(report.title);
const rightHeader = isBalanceSheet ? "Balance" : "Amount"; const rightHeader = isBalanceSheet ? "Balance" : "Amount";
let y = 0; 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.rect(ML, y, contentR - ML, h, "F");
doc.setDrawColor(...BORDER); doc.setLineWidth(0.5); doc.setDrawColor(...BORDER); doc.setLineWidth(0.5);
doc.rect(ML, y, contentR - ML, h); doc.rect(ML, y, contentR - ML, h);
// vertical dividers before numeric columns // vertical divider before the numeric block
if (opts.showCompare) doc.line(cmpRight - 96, y, cmpRight - 96, y + h); doc.line(numericLeft - 6, y, numericLeft - 6, y + h);
doc.line(amtRight - 96, y, amtRight - 96, y + h); doc.setFont("helvetica", "bold"); doc.setFontSize(opts.showCompare ? 8 : 9); doc.setTextColor(...TEXT);
doc.setFont("helvetica", "bold"); doc.setFontSize(9); doc.setTextColor(...TEXT);
doc.text("Account Name", ML + 6, y + 12); doc.text("Account Name", ML + 6, y + 12);
if (opts.showCompare) doc.text("Previous", cmpRight, y + 12, { align: "right" }); doc.text(rightHeader, colAmount, y + 12, { align: "right" });
doc.text(rightHeader, amtRight, 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; y += h;
}; };
@@ -167,8 +177,8 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js
if (row.kind === "total" || row.kind === "grand") { if (row.kind === "total" || row.kind === "grand") {
doc.setDrawColor(...RULE); doc.setDrawColor(...RULE);
doc.setLineWidth(row.kind === "grand" ? 1.4 : 0.7); doc.setLineWidth(row.kind === "grand" ? 1.4 : 0.7);
doc.line(amtUnderL, top + 1, amtRight, top + 1); const underCols = opts.showCompare ? [colAmount, colCompare, colChange] : [colAmount];
if (opts.showCompare) doc.line(cmpUnderL, top + 1, cmpRight, top + 1); for (const x of underCols) doc.line(x - UNDER, top + 1, x, top + 1);
} }
// label // label
@@ -185,16 +195,20 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js
} }
// amounts // amounts
const drawNum = (val: number | undefined, x: number) => {
if (val === undefined) return;
doc.setFont("helvetica", bold ? "bold" : "normal"); doc.setFontSize(9); 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) { if (row.amount !== undefined) {
const c: RGB = row.amount < 0 ? RED : TEXT; doc.setFont("helvetica", bold ? "bold" : "normal"); doc.setFontSize(9); doc.setTextColor(...MUTED);
doc.setTextColor(...c); doc.text(pctStr(row.amount, row.compare), colPct, top + 11, { align: "right" });
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" });
} }
y += h; y += h;
@@ -229,7 +243,7 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js
const amt = report.cashHighlight.amount; const amt = report.cashHighlight.amount;
const c: RGB = amt < 0 ? RED : TEXT; const c: RGB = amt < 0 ? RED : TEXT;
doc.setTextColor(...c); doc.setTextColor(...c);
doc.text(fmtAmount(amt), amtRight, y + 14, { align: "right" }); doc.text(fmtAmount(amt), colAmount, y + 14, { align: "right" });
y += 28; y += 28;
} }
@@ -239,7 +253,8 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js
/** Render a financial report PDF (no cover page). */ /** Render a financial report PDF (no cover page). */
export function renderReportPdf(report: StructuredReport, opts: RenderOpts): jsPDF { 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); return buildReport(doc, report, opts);
} }
@@ -252,7 +267,7 @@ export async function renderReportPdfWithCover(
opts: RenderOpts, opts: RenderOpts,
cover: ReportCoverData, cover: ReportCoverData,
): Promise<jsPDF> { ): Promise<jsPDF> {
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); await drawReportCoverPage(doc, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), cover);
doc.addPage(); doc.addPage();
return buildReport(doc, report, opts); return buildReport(doc, report, opts);