Unify financial report styling with branded cover page

Extract the general Report Generator's branded cover (cover image/band,
logo, title, prepared-for/by) into shared src/lib/reportCover.ts. Financial
reports now open with the same cover: platform AccountingReportsPage via new
renderReportPdfWithCover(), and the Zoho/Board financial reports
(zohoFinancialReportPdf generators). ReportGeneratorPage refactored to use
the shared module (removes duplicated cover code).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 21:49:34 -04:00
parent 3c32f8ac47
commit 8360363a15
5 changed files with 196 additions and 125 deletions
+133
View File
@@ -0,0 +1,133 @@
import type { jsPDF } from "jspdf";
/** Data for the shared, branded report cover page. */
export type ReportCoverData = {
title: string;
date: string;
companyName: string;
preparedBy: string;
logoUrl?: string;
bgUrl?: string;
};
/** Load an image URL as a data URL for jsPDF embedding (browser only). */
export function loadImageAsDataURL(
url: string,
format: "jpeg" | "png" = "jpeg",
): Promise<{ dataURL: string; w: number; h: number } | null> {
return new Promise((resolve) => {
if (!url) return resolve(null);
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (format === "jpeg") {
ctx!.fillStyle = "#ffffff";
ctx!.fillRect(0, 0, canvas.width, canvas.height);
}
ctx?.drawImage(img, 0, 0);
const mime = format === "png" ? "image/png" : "image/jpeg";
resolve({ dataURL: canvas.toDataURL(mime, 0.85), w: img.width, h: img.height });
};
img.onerror = () => resolve(null);
img.src = url;
});
}
/**
* Draw the shared branded cover page on the current page of `doc`.
* Used by the general Report Generator and all financial reports so every
* exported report opens with the same look.
*/
export async function drawReportCoverPage(
doc: jsPDF,
W: number,
H: number,
cover: ReportCoverData,
): Promise<void> {
const cx = W / 2;
const headerH = H * 0.42;
const pdfFont = doc.getFontList()["Open Sans"] ? "Open Sans" : "helvetica";
const bgImg = cover.bgUrl ? await loadImageAsDataURL(cover.bgUrl, "jpeg") : null;
if (bgImg) {
const imgAspect = bgImg.w / bgImg.h;
const headerAspect = W / headerH;
let drawW = W, drawH = headerH, drawX = 0, drawY = 0;
if (imgAspect > headerAspect) {
drawH = headerH;
drawW = headerH * imgAspect;
drawX = -(drawW - W) / 2;
} else {
drawW = W;
drawH = W / imgAspect;
drawY = -(drawH - headerH) / 2;
}
doc.addImage(bgImg.dataURL, "JPEG", drawX, drawY, drawW, drawH);
} else {
doc.setFillColor(30, 41, 59);
doc.rect(0, 0, W, headerH, "F");
}
doc.setFillColor(37, 99, 235);
doc.rect(0, headerH, W, 4, "F");
let y = headerH + 14;
const logoImg = cover.logoUrl ? await loadImageAsDataURL(cover.logoUrl, "png") : null;
if (logoImg) {
const logoH = 50;
const logoW = logoH * (logoImg.w / logoImg.h);
doc.addImage(logoImg.dataURL, "PNG", cx - logoW / 2, y, logoW, logoH);
y += logoH + 24;
}
doc.setFont(pdfFont, "bold");
doc.setFontSize(28);
doc.setTextColor(15, 23, 42);
const titleLines = doc.splitTextToSize(cover.title.replace(/\n/g, " "), W - 100);
doc.text(titleLines, cx, y, { align: "center", lineHeightFactor: 1.3 });
y += titleLines.length * 32 + 8;
doc.setFillColor(37, 99, 235);
doc.rect(cx - 30, y, 60, 3, "F");
y += 24;
doc.setFont(pdfFont, "bold");
doc.setFontSize(9);
doc.setTextColor(100, 116, 139);
doc.text("PREPARED FOR", cx, y, { align: "center" });
y += 22;
doc.setFont(pdfFont, "bold");
doc.setFontSize(22);
doc.setTextColor(30, 41, 59);
const compLines = doc.splitTextToSize(cover.companyName || "Association Name", W - 120);
doc.text(compLines, cx, y, { align: "center" });
y += compLines.length * 26 + 20;
doc.setFillColor(248, 250, 252);
doc.setDrawColor(226, 232, 240);
const dateW = 200;
doc.roundedRect(cx - dateW / 2, y, dateW, 32, 3, 3, "FD");
doc.setFont(pdfFont, "bold");
doc.setFontSize(11);
doc.setTextColor(15, 23, 42);
doc.text(cover.date, cx, y + 21, { align: "center" });
const footY = H - 60;
doc.setDrawColor(226, 232, 240);
doc.setLineWidth(0.5);
doc.line(60, footY, W - 60, footY);
doc.setFont(pdfFont, "bold");
doc.setFontSize(8);
doc.setTextColor(100, 116, 139);
doc.text("PREPARED BY", cx, footY + 16, { align: "center" });
doc.setFont(pdfFont, "bold");
doc.setFontSize(12);
doc.setTextColor(30, 41, 59);
doc.text(cover.preparedBy || "", cx, footY + 32, { align: "center" });
}
+16
View File
@@ -1,8 +1,21 @@
import jsPDF from "jspdf"; import jsPDF from "jspdf";
import autoTable, { RowInput } from "jspdf-autotable"; import autoTable, { RowInput } from "jspdf-autotable";
import { drawReportCoverPage } from "@/lib/reportCover";
type Association = { id?: string; name: string; logo_url?: string | null }; type Association = { id?: string; name: string; logo_url?: string | null };
/** Prepend the shared branded cover page, then move to a fresh page for content. */
async function coverPage(doc: jsPDF, association: Association | null, title: string, subTitle: string) {
await drawReportCoverPage(doc, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), {
title,
date: subTitle,
companyName: association?.name || "All Associations",
preparedBy: "Avria Community Management, LLC",
logoUrl: association?.logo_url || undefined,
});
doc.addPage();
}
const fmt = (n: number | string | undefined | null) => { const fmt = (n: number | string | undefined | null) => {
const v = typeof n === "string" ? parseFloat(n) : n; const v = typeof n === "string" ? parseFloat(n) : n;
if (v == null || isNaN(v as number)) return "$0.00"; if (v == null || isNaN(v as number)) return "$0.00";
@@ -113,6 +126,7 @@ async function generateSectionedReportPdf(opts: {
}) { }) {
const { association, reportLabel, subTitle, rows } = opts; const { association, reportLabel, subTitle, rows } = opts;
const doc = new jsPDF({ unit: "pt", format: "letter" }); const doc = new jsPDF({ unit: "pt", format: "letter" });
await coverPage(doc, association, reportLabel, subTitle);
await drawHeader(doc, association, reportLabel, subTitle); await drawHeader(doc, association, reportLabel, subTitle);
const body: RowInput[] = rows.map((r) => { const body: RowInput[] = rows.map((r) => {
@@ -251,6 +265,7 @@ export async function generateARAgingPdf(opts: {
} }
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" }); const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
await coverPage(doc, association, "Accounts Receivable Aging", `As of ${asOfDate}`);
await drawHeader(doc, association, "Accounts Receivable Aging", `As of ${asOfDate}`); await drawHeader(doc, association, "Accounts Receivable Aging", `As of ${asOfDate}`);
const totals = { current: 0, d1_30: 0, d31_60: 0, d61_90: 0, over_90: 0, total: 0 }; const totals = { current: 0, d1_30: 0, d31_60: 0, d61_90: 0, over_90: 0, total: 0 };
@@ -377,6 +392,7 @@ export async function generateBudgetVsActualPdf(opts: {
: `${fromDate} to ${toDate}`; : `${fromDate} to ${toDate}`;
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" }); const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
await coverPage(doc, association, "Budget vs Actual", subTitle);
await drawHeader(doc, association, "Budget vs Actual", subTitle); await drawHeader(doc, association, "Budget vs Actual", subTitle);
if (budgets.length === 0) { if (budgets.length === 0) {
+2 -118
View File
@@ -15,6 +15,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { jsPDF } from "jspdf"; import { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable"; import autoTable from "jspdf-autotable";
import { drawReportCoverPage } from "@/lib/reportCover";
import { format, subMonths } from "date-fns"; import { format, subMonths } from "date-fns";
import type { Tables } from "@/integrations/supabase/types"; import type { Tables } from "@/integrations/supabase/types";
import { ensureFontsLoaded } from "@/lib/googleFontsManager"; import { ensureFontsLoaded } from "@/lib/googleFontsManager";
@@ -353,123 +354,6 @@ export default function ReportGeneratorPage() {
return cleanText(String(val)); return cleanText(String(val));
}; };
/** Load an image URL as a data URL for jsPDF embedding */
const loadImageAsDataURL = (url: string, format: "jpeg" | "png" = "jpeg"): Promise<{ dataURL: string; w: number; h: number } | null> => {
return new Promise((resolve) => {
if (!url) return resolve(null);
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (format === "jpeg") {
// Fill white background for JPEG (no transparency)
ctx!.fillStyle = "#ffffff";
ctx!.fillRect(0, 0, canvas.width, canvas.height);
}
ctx?.drawImage(img, 0, 0);
const mime = format === "png" ? "image/png" : "image/jpeg";
resolve({ dataURL: canvas.toDataURL(mime, 0.85), w: img.width, h: img.height });
};
img.onerror = () => resolve(null);
img.src = url;
});
};
/** Draw a professional cover page directly with jsPDF vectors + embedded images */
const drawCoverPage = async (doc: jsPDF, W: number, H: number) => {
const cx = W / 2;
const headerH = H * 0.42;
const pdfFont = doc.getFontList()["Open Sans"] ? "Open Sans" : "helvetica";
// Background image or solid dark band (no title here — title goes below)
const bgImg = coverData.bgUrl ? await loadImageAsDataURL(coverData.bgUrl, "jpeg") : null;
if (bgImg) {
const imgAspect = bgImg.w / bgImg.h;
const headerAspect = W / headerH;
let drawW = W, drawH = headerH, drawX = 0, drawY = 0;
if (imgAspect > headerAspect) {
drawH = headerH;
drawW = headerH * imgAspect;
drawX = -(drawW - W) / 2;
} else {
drawW = W;
drawH = W / imgAspect;
drawY = -(drawH - headerH) / 2;
}
doc.addImage(bgImg.dataURL, "JPEG", drawX, drawY, drawW, drawH);
} else {
doc.setFillColor(30, 41, 59);
doc.rect(0, 0, W, headerH, "F");
}
// Blue accent line flush at bottom of cover image
doc.setFillColor(37, 99, 235);
doc.rect(0, headerH, W, 4, "F");
let y = headerH + 14;
const logoImg = coverData.logoUrl ? await loadImageAsDataURL(coverData.logoUrl, "png") : null;
if (logoImg) {
const maxLogoH = 50;
const logoAspect = logoImg.w / logoImg.h;
const logoH = maxLogoH;
const logoW = logoH * logoAspect;
doc.addImage(logoImg.dataURL, "PNG", cx - logoW / 2, y, logoW, logoH);
y += logoH + 24;
}
doc.setFont(pdfFont, "bold");
doc.setFontSize(28);
doc.setTextColor(15, 23, 42);
const titleLines = doc.splitTextToSize(coverData.title.replace(/\n/g, " "), W - 100);
doc.text(titleLines, cx, y, { align: "center", lineHeightFactor: 1.3 });
y += titleLines.length * 32 + 8;
doc.setFillColor(37, 99, 235);
doc.rect(cx - 30, y, 60, 3, "F");
y += 24;
doc.setFont(pdfFont, "bold");
doc.setFontSize(9);
doc.setTextColor(100, 116, 139);
doc.text("PREPARED FOR", cx, y, { align: "center" });
y += 22;
doc.setFont(pdfFont, "bold");
doc.setFontSize(22);
doc.setTextColor(30, 41, 59);
const compLines = doc.splitTextToSize(coverData.companyName || "Association Name", W - 120);
doc.text(compLines, cx, y, { align: "center" });
y += compLines.length * 26 + 20;
doc.setFillColor(248, 250, 252);
doc.setDrawColor(226, 232, 240);
const dateText = coverData.date;
const dateW = 200;
doc.roundedRect(cx - dateW / 2, y, dateW, 32, 3, 3, "FD");
doc.setFont(pdfFont, "bold");
doc.setFontSize(11);
doc.setTextColor(15, 23, 42);
doc.text(dateText, cx, y + 21, { align: "center" });
const footY = H - 60;
doc.setDrawColor(226, 232, 240);
doc.setLineWidth(0.5);
doc.line(60, footY, W - 60, footY);
doc.setFont(pdfFont, "bold");
doc.setFontSize(8);
doc.setTextColor(100, 116, 139);
doc.text("PREPARED BY", cx, footY + 16, { align: "center" });
doc.setFont(pdfFont, "bold");
doc.setFontSize(12);
doc.setTextColor(30, 41, 59);
doc.text(coverData.preparedBy || "", cx, footY + 32, { align: "center" });
};
/** Only enabled entries that are modules with data, in order */ /** Only enabled entries that are modules with data, in order */
const getOrderedActiveModules = () => { const getOrderedActiveModules = () => {
return reportEntries return reportEntries
@@ -517,7 +401,7 @@ export default function ReportGeneratorPage() {
}; };
// ═══ COVER PAGE ═══ // ═══ COVER PAGE ═══
await drawCoverPage(doc, W, H); await drawReportCoverPage(doc, W, H, coverData);
const orderedEntries = getOrderedEnabledEntries(); const orderedEntries = getOrderedEnabledEntries();
+17 -3
View File
@@ -18,9 +18,10 @@ import { money, fmtDate } from "./lib/format";
import jsPDF from "jspdf"; import jsPDF from "jspdf";
import autoTable from "jspdf-autotable"; import autoTable from "jspdf-autotable";
import { import {
renderReportPdf, fmtAmount, renderReportPdfWithCover, fmtAmount,
type StructuredReport, type StructuredRow, type StructuredReport, type StructuredRow,
} from "./lib/reportPdf"; } from "./lib/reportPdf";
import { drawReportCoverPage, type ReportCoverData } from "@/lib/reportCover";
import { calcNetIncome, isRetainedEarnings, isCurrentYearEarnings, isSystemEquityAccount } from "./lib/earnings"; import { calcNetIncome, isRetainedEarnings, isCurrentYearEarnings, isSystemEquityAccount } from "./lib/earnings";
import { import {
computePnL, computeMargins, toMinor, fromMinor, PnlValidationError, computePnL, computeMargins, toMinor, fromMinor, PnlValidationError,
@@ -334,14 +335,27 @@ export default function AccountingReportsPage() {
const hasOwnExport = active === "trial-balance" || active === "general-ledger"; const hasOwnExport = active === "trial-balance" || active === "general-ledger";
const anyExportable = !!(structured || flat || exportFlat); const anyExportable = !!(structured || flat || exportFlat);
const doExportPDF = () => { const doExportPDF = async () => {
const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`; const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`;
const src = flat ?? exportFlat; const src = flat ?? exportFlat;
const cover: ReportCoverData = {
title: activeMeta.name,
date: rangeLabel,
companyName: associationName ?? "Company",
preparedBy: "Avria Community Management, LLC",
};
if (structured) { if (structured) {
const doc = renderReportPdf(structured, { companyName: associationName ?? "Company", appName: APP_NAME, rangeLabel, currency: cur, showCodes, showCompare, showZero }); const doc = await renderReportPdfWithCover(
structured,
{ companyName: associationName ?? "Company", appName: APP_NAME, rangeLabel, currency: cur, showCodes, showCompare, showZero },
cover,
);
doc.save(`${fileBase}.pdf`); doc.save(`${fileBase}.pdf`);
} else if (src) { } else if (src) {
const doc = new jsPDF({ orientation: src.columns.length > 6 ? "landscape" : "portrait" }); const doc = new jsPDF({ orientation: src.columns.length > 6 ? "landscape" : "portrait" });
// Shared branded cover page, then the tabular report on the next page.
await drawReportCoverPage(doc, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), cover);
doc.addPage();
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41); doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41);
doc.text(src.title, 14, 16); doc.text(src.title, 14, 16);
doc.setFont("helvetica", "bold"); doc.setFontSize(9); doc.setFont("helvetica", "bold"); doc.setFontSize(9);
+28 -4
View File
@@ -1,4 +1,5 @@
import jsPDF from "jspdf"; import jsPDF from "jspdf";
import { drawReportCoverPage, type ReportCoverData } from "@/lib/reportCover";
export type RowKind = "section" | "group" | "sub" | "total" | "grand" | "spacer"; export type RowKind = "section" | "group" | "sub" | "total" | "grand" | "spacer";
@@ -47,8 +48,9 @@ export function fmtAmount(n: number | undefined | null): string {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
export function renderReportPdf(report: StructuredReport, opts: RenderOpts): jsPDF { /** Draw the report body onto `doc`, starting on its current page. */
const doc = new jsPDF({ unit: "pt", format: "letter" }); function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): jsPDF {
const firstPage = doc.getNumberOfPages();
const W = doc.internal.pageSize.getWidth(); const W = doc.internal.pageSize.getWidth();
const H = doc.internal.pageSize.getHeight(); const H = doc.internal.pageSize.getHeight();
const ML = 40, MR = 40; const ML = 40, MR = 40;
@@ -114,11 +116,12 @@ export function renderReportPdf(report: StructuredReport, opts: RenderOpts): jsP
const drawFooter = () => { const drawFooter = () => {
const total = doc.getNumberOfPages(); const total = doc.getNumberOfPages();
const created = new Date().toLocaleDateString("en-US"); const created = new Date().toLocaleDateString("en-US");
for (let p = 1; p <= total; p++) { // Footer only on content pages (skip any preceding cover page).
for (let p = firstPage; p <= total; p++) {
doc.setPage(p); doc.setPage(p);
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...MUTED); doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...MUTED);
doc.text(`Created on ${created}`, ML, H - 28); doc.text(`Created on ${created}`, ML, H - 28);
doc.text(`Page ${p}`, contentR, H - 28, { align: "right" }); doc.text(`Page ${p - firstPage + 1}`, contentR, H - 28, { align: "right" });
} }
}; };
@@ -233,3 +236,24 @@ export function renderReportPdf(report: StructuredReport, opts: RenderOpts): jsP
drawFooter(); drawFooter();
return doc; return doc;
} }
/** Render a financial report PDF (no cover page). */
export function renderReportPdf(report: StructuredReport, opts: RenderOpts): jsPDF {
const doc = new jsPDF({ unit: "pt", format: "letter" });
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<jsPDF> {
const doc = new jsPDF({ unit: "pt", format: "letter" });
await drawReportCoverPage(doc, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), cover);
doc.addPage();
return buildReport(doc, report, opts);
}