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