From 8360363a1529bf1dc933f1a37b9aa5b8b371e41b Mon Sep 17 00:00:00 2001 From: renee-png Date: Mon, 1 Jun 2026 21:49:34 -0400 Subject: [PATCH] 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 --- src/lib/reportCover.ts | 133 ++++++++++++++++++ src/lib/zohoFinancialReportPdf.ts | 16 +++ src/pages/ReportGeneratorPage.tsx | 120 +--------------- .../accounting/AccountingReportsPage.tsx | 20 ++- src/pages/accounting/lib/reportPdf.ts | 32 ++++- 5 files changed, 196 insertions(+), 125 deletions(-) create mode 100644 src/lib/reportCover.ts diff --git a/src/lib/reportCover.ts b/src/lib/reportCover.ts new file mode 100644 index 0000000..8c14601 --- /dev/null +++ b/src/lib/reportCover.ts @@ -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 { + 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" }); +} diff --git a/src/lib/zohoFinancialReportPdf.ts b/src/lib/zohoFinancialReportPdf.ts index cafc2ab..55c8da2 100644 --- a/src/lib/zohoFinancialReportPdf.ts +++ b/src/lib/zohoFinancialReportPdf.ts @@ -1,8 +1,21 @@ import jsPDF from "jspdf"; import autoTable, { RowInput } from "jspdf-autotable"; +import { drawReportCoverPage } from "@/lib/reportCover"; 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 v = typeof n === "string" ? parseFloat(n) : n; 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 doc = new jsPDF({ unit: "pt", format: "letter" }); + await coverPage(doc, association, reportLabel, subTitle); await drawHeader(doc, association, reportLabel, subTitle); 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" }); + await coverPage(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 }; @@ -377,6 +392,7 @@ export async function generateBudgetVsActualPdf(opts: { : `${fromDate} to ${toDate}`; 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); if (budgets.length === 0) { diff --git a/src/pages/ReportGeneratorPage.tsx b/src/pages/ReportGeneratorPage.tsx index 71bfc0e..ed0e8d5 100644 --- a/src/pages/ReportGeneratorPage.tsx +++ b/src/pages/ReportGeneratorPage.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { jsPDF } from "jspdf"; import autoTable from "jspdf-autotable"; +import { drawReportCoverPage } from "@/lib/reportCover"; import { format, subMonths } from "date-fns"; import type { Tables } from "@/integrations/supabase/types"; import { ensureFontsLoaded } from "@/lib/googleFontsManager"; @@ -353,123 +354,6 @@ export default function ReportGeneratorPage() { 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 */ const getOrderedActiveModules = () => { return reportEntries @@ -517,7 +401,7 @@ export default function ReportGeneratorPage() { }; // ═══ COVER PAGE ═══ - await drawCoverPage(doc, W, H); + await drawReportCoverPage(doc, W, H, coverData); const orderedEntries = getOrderedEnabledEntries(); diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index 5c7ae22..3895e26 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -18,9 +18,10 @@ import { money, fmtDate } from "./lib/format"; import jsPDF from "jspdf"; import autoTable from "jspdf-autotable"; import { - renderReportPdf, fmtAmount, + renderReportPdfWithCover, fmtAmount, type StructuredReport, type StructuredRow, } from "./lib/reportPdf"; +import { drawReportCoverPage, type ReportCoverData } from "@/lib/reportCover"; import { calcNetIncome, isRetainedEarnings, isCurrentYearEarnings, isSystemEquityAccount } from "./lib/earnings"; import { computePnL, computeMargins, toMinor, fromMinor, PnlValidationError, @@ -334,14 +335,27 @@ export default function AccountingReportsPage() { const hasOwnExport = active === "trial-balance" || active === "general-ledger"; const anyExportable = !!(structured || flat || exportFlat); - const doExportPDF = () => { + const doExportPDF = async () => { const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`; const src = flat ?? exportFlat; + const cover: ReportCoverData = { + title: activeMeta.name, + date: rangeLabel, + companyName: associationName ?? "Company", + preparedBy: "Avria Community Management, LLC", + }; 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`); } else if (src) { 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.text(src.title, 14, 16); doc.setFont("helvetica", "bold"); doc.setFontSize(9); diff --git a/src/pages/accounting/lib/reportPdf.ts b/src/pages/accounting/lib/reportPdf.ts index 13a2701..de98e7f 100644 --- a/src/pages/accounting/lib/reportPdf.ts +++ b/src/pages/accounting/lib/reportPdf.ts @@ -1,4 +1,5 @@ import jsPDF from "jspdf"; +import { drawReportCoverPage, type ReportCoverData } from "@/lib/reportCover"; 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 }); } -export function renderReportPdf(report: StructuredReport, opts: RenderOpts): jsPDF { - const doc = new jsPDF({ unit: "pt", format: "letter" }); +/** 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; @@ -114,11 +116,12 @@ export function renderReportPdf(report: StructuredReport, opts: RenderOpts): jsP const drawFooter = () => { const total = doc.getNumberOfPages(); 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.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...MUTED); 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(); 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 { + 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); +}