mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -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" });
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user