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
+2 -118
View File
@@ -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();