mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
c3d1d86b07
- P&L and Balance Sheet account rows are now clickable and open the General Ledger filtered to that account (its transaction list for the COA). Adds StructuredRow.accountId, threaded from the report builders, with a clickable row in StructuredTable and an initialAccountId prop on GeneralLedgerReport. - Bids/Quotes: PDF upload on the New Bid/Quote dialog (bid-attachments bucket + document_url/document_name columns), shown as a link on the bid Details dialog. Migration applied: bids_quotes_pdf_attachments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
import jsPDF from "jspdf";
|
|
import { drawReportCoverPage, type ReportCoverData } from "@/lib/reportCover";
|
|
|
|
export type RowKind = "section" | "group" | "sub" | "total" | "grand" | "spacer";
|
|
|
|
export type StructuredRow = {
|
|
kind: RowKind;
|
|
label: string;
|
|
code?: string;
|
|
amount?: number;
|
|
compare?: number;
|
|
/** GL account id for per-account rows — enables drill-down from on-screen reports. */
|
|
accountId?: string;
|
|
};
|
|
|
|
export type StructuredReport = {
|
|
title: string;
|
|
rows: StructuredRow[];
|
|
balanced?: boolean;
|
|
outOfBalanceAmount?: number;
|
|
cashHighlight?: { label: string; amount: number };
|
|
};
|
|
|
|
export type RenderOpts = {
|
|
companyName: string;
|
|
appName: string;
|
|
rangeLabel: string;
|
|
currency: string;
|
|
showCodes: boolean;
|
|
showCompare: boolean;
|
|
showZero: boolean;
|
|
/** Optional metadata lines for the header block (bold label + value). */
|
|
meta?: { label: string; value: string }[];
|
|
/** Preloaded logo (dataURL + dimensions) for the branded header. */
|
|
logo?: { dataURL: string; w: number; h: number } | null;
|
|
};
|
|
|
|
type RGB = [number, number, number];
|
|
|
|
const TEXT: RGB = [33, 37, 41];
|
|
const MUTED: RGB = [110, 116, 122];
|
|
const RULE: RGB = [20, 20, 20];
|
|
const BAND: RGB = [232, 235, 238]; // section header band
|
|
const ZEBRA: RGB = [247, 248, 250]; // alternating line-item rows
|
|
const HEADER_FILL: RGB = [237, 239, 242]; // column-header row
|
|
const BORDER: RGB = [196, 200, 205];
|
|
const RED: RGB = [185, 28, 28];
|
|
|
|
/** Accounting number: thousands separators, 2 decimals, leading minus for negatives. */
|
|
export function fmtAmount(n: number | undefined | null): string {
|
|
if (n === undefined || n === null) return "";
|
|
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
|
|
/** 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;
|
|
const contentR = W - MR;
|
|
const amtRight = contentR - 6;
|
|
const COLW = 86; // width of each numeric column
|
|
const UNDER = 78; // underline width under a numeric column
|
|
// Numeric column right edges. With a comparison: Amount | Comparative | Change | Change %.
|
|
const colPct = amtRight;
|
|
const colChange = amtRight - COLW;
|
|
const colCompare = amtRight - 2 * COLW;
|
|
const colAmount = opts.showCompare ? amtRight - 3 * COLW : amtRight;
|
|
const numericLeft = colAmount - UNDER; // left boundary of the numeric block (for divider)
|
|
const pctStr = (a?: number, c?: number) =>
|
|
a === undefined || c === undefined || Math.abs(c) < 0.005 ? "—" : `${(((a - c) / Math.abs(c)) * 100).toFixed(1)}%`;
|
|
const isBalanceSheet = /balance sheet/i.test(report.title);
|
|
const rightHeader = isBalanceSheet ? "Balance" : "Amount";
|
|
let y = 0;
|
|
|
|
const drawColHeader = () => {
|
|
const h = 18;
|
|
doc.setFillColor(...HEADER_FILL);
|
|
doc.rect(ML, y, contentR - ML, h, "F");
|
|
doc.setDrawColor(...BORDER); doc.setLineWidth(0.5);
|
|
doc.rect(ML, y, contentR - ML, h);
|
|
// vertical divider before the numeric block
|
|
doc.line(numericLeft - 6, y, numericLeft - 6, y + h);
|
|
doc.setFont("helvetica", "bold"); doc.setFontSize(opts.showCompare ? 8 : 9); doc.setTextColor(...TEXT);
|
|
doc.text("Account Name", ML + 6, y + 12);
|
|
doc.text(rightHeader, colAmount, y + 12, { align: "right" });
|
|
if (opts.showCompare) {
|
|
doc.text("Comparative", colCompare, y + 12, { align: "right" });
|
|
doc.text("Change", colChange, y + 12, { align: "right" });
|
|
doc.text("Change %", colPct, y + 12, { align: "right" });
|
|
}
|
|
y += h;
|
|
};
|
|
|
|
// Full header (page 1): title + metadata block + column header
|
|
const drawFullHeader = () => {
|
|
// Branded logo (top-left) + centered title — matches the main reports.
|
|
if (opts.logo) {
|
|
try {
|
|
const lh = 40; const lw = Math.min(150, lh * (opts.logo.w / opts.logo.h));
|
|
doc.addImage(opts.logo.dataURL, "PNG", ML, 26, lw, lh, undefined, "FAST");
|
|
} catch { /* ignore */ }
|
|
}
|
|
doc.setFont("helvetica", "bold"); doc.setFontSize(16); doc.setTextColor(...TEXT);
|
|
doc.text(report.title, W / 2, 48, { align: "center" });
|
|
y = 82;
|
|
|
|
const meta = opts.meta ?? [
|
|
{ label: "Properties:", value: opts.companyName || "—" },
|
|
{ label: isBalanceSheet ? "As of:" : "Period:", value: opts.rangeLabel },
|
|
{ label: "Accounting Basis:", value: "Accrual" },
|
|
];
|
|
doc.setFontSize(9.5);
|
|
for (const m of meta) {
|
|
doc.setFont("helvetica", "bold"); doc.setTextColor(...TEXT);
|
|
doc.text(m.label, ML, y);
|
|
const lw = doc.getTextWidth(m.label);
|
|
doc.setFont("helvetica", "normal");
|
|
doc.text(` ${m.value}`, ML + lw, y);
|
|
y += 14;
|
|
}
|
|
y += 6;
|
|
drawColHeader();
|
|
};
|
|
|
|
// Continuation header (page 2+): centered title + column header only
|
|
const drawContHeader = () => {
|
|
doc.setFont("helvetica", "bold"); doc.setFontSize(12); doc.setTextColor(...TEXT);
|
|
doc.text(report.title, W / 2, 40, { align: "center" });
|
|
y = 56;
|
|
drawColHeader();
|
|
};
|
|
|
|
const drawFooter = () => {
|
|
const total = doc.getNumberOfPages();
|
|
const gen = new Date().toLocaleString("en-US");
|
|
const pages = total - firstPage + 1;
|
|
// 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(`Generated ${gen}`, ML, H - 28);
|
|
doc.text(`Page ${p - firstPage + 1} of ${pages}`, contentR, H - 28, { align: "right" });
|
|
}
|
|
};
|
|
|
|
const ensure = (need: number) => {
|
|
if (y + need > H - 50) { doc.addPage(); drawContHeader(); }
|
|
};
|
|
|
|
drawFullHeader();
|
|
|
|
let alt = false;
|
|
for (const row of report.rows) {
|
|
if (row.kind === "spacer") { y += 6; alt = false; continue; }
|
|
if (row.kind === "sub" && !opts.showZero && (row.amount ?? 0) === 0) continue;
|
|
|
|
if (row.kind === "section") {
|
|
ensure(20);
|
|
doc.setFillColor(...BAND);
|
|
doc.rect(ML, y, contentR - ML, 18, "F");
|
|
doc.setFont("helvetica", "bold"); doc.setFontSize(9.5); doc.setTextColor(...TEXT);
|
|
doc.text(row.label, ML + 6, y + 12);
|
|
y += 18; alt = false; continue;
|
|
}
|
|
|
|
if (row.kind === "group") {
|
|
ensure(16);
|
|
doc.setFont("helvetica", "bold"); doc.setFontSize(9); doc.setTextColor(...TEXT);
|
|
doc.text(row.label, ML + 12, y + 11);
|
|
y += 15; alt = false; continue;
|
|
}
|
|
|
|
const h = 16;
|
|
ensure(h + 2);
|
|
const top = y;
|
|
const bold = row.kind === "total" || row.kind === "grand";
|
|
|
|
// zebra striping on line items
|
|
if (row.kind === "sub" && alt) {
|
|
doc.setFillColor(...ZEBRA);
|
|
doc.rect(ML, top, contentR - ML, h, "F");
|
|
}
|
|
|
|
// rule above subtotals (thin) and grand totals (heavy), over the number columns
|
|
if (row.kind === "total" || row.kind === "grand") {
|
|
doc.setDrawColor(...RULE);
|
|
doc.setLineWidth(row.kind === "grand" ? 1.4 : 0.7);
|
|
const underCols = opts.showCompare ? [colAmount, colCompare, colChange] : [colAmount];
|
|
for (const x of underCols) doc.line(x - UNDER, top + 1, x, top + 1);
|
|
}
|
|
|
|
// label
|
|
const xLabel = row.kind === "sub" ? ML + 18 : row.kind === "total" ? ML + 12 : ML + 6;
|
|
doc.setFont("helvetica", bold ? "bold" : "normal"); doc.setFontSize(9); doc.setTextColor(...TEXT);
|
|
if (row.kind === "sub" && opts.showCodes && row.code) {
|
|
doc.setTextColor(...MUTED); doc.setFontSize(8.5);
|
|
doc.text(row.code, xLabel, top + 11);
|
|
const cw = doc.getTextWidth(row.code) + 6;
|
|
doc.setTextColor(...TEXT); doc.setFontSize(9);
|
|
doc.text(row.label, xLabel + cw, top + 11);
|
|
} else {
|
|
doc.text(row.label, xLabel, top + 11);
|
|
}
|
|
|
|
// amounts
|
|
const drawNum = (val: number | undefined, x: number) => {
|
|
if (val === undefined) return;
|
|
doc.setFont("helvetica", bold ? "bold" : "normal"); doc.setFontSize(9);
|
|
doc.setTextColor(...(val < 0 ? RED : TEXT));
|
|
doc.text(fmtAmount(val), x, top + 11, { align: "right" });
|
|
};
|
|
drawNum(row.amount, colAmount);
|
|
if (opts.showCompare) {
|
|
drawNum(row.compare, colCompare);
|
|
if (row.amount !== undefined && row.compare !== undefined) drawNum(row.amount - row.compare, colChange);
|
|
if (row.amount !== undefined) {
|
|
doc.setFont("helvetica", bold ? "bold" : "normal"); doc.setFontSize(9); doc.setTextColor(...MUTED);
|
|
doc.text(pctStr(row.amount, row.compare), colPct, top + 11, { align: "right" });
|
|
}
|
|
}
|
|
|
|
y += h;
|
|
alt = row.kind === "sub" ? !alt : false;
|
|
}
|
|
|
|
// Balance-sheet balanced callout
|
|
if (report.balanced !== undefined) {
|
|
ensure(28); y += 6;
|
|
const ok = report.balanced;
|
|
const border: RGB = ok ? [22, 128, 61] : [185, 28, 28];
|
|
const fill: RGB = ok ? [240, 253, 244] : [254, 242, 242];
|
|
const txt: RGB = ok ? [22, 101, 52] : [153, 27, 27];
|
|
doc.setDrawColor(...border); doc.setLineWidth(0.8);
|
|
doc.setFillColor(...fill);
|
|
doc.rect(ML, y, contentR - ML, 22, "FD");
|
|
doc.setFont("helvetica", "bold"); doc.setFontSize(9.5); doc.setTextColor(...txt);
|
|
const msg = ok
|
|
? "Balance Sheet is balanced"
|
|
: `Balance Sheet is OUT OF BALANCE by ${fmtAmount(report.outOfBalanceAmount ?? 0)} (Assets - Liabilities - Equity)`;
|
|
doc.text(msg, ML + 8, y + 14);
|
|
y += 28;
|
|
}
|
|
|
|
// Cash-flow net-change highlight
|
|
if (report.cashHighlight) {
|
|
ensure(28); y += 6;
|
|
doc.setFillColor(...HEADER_FILL);
|
|
doc.rect(ML, y, contentR - ML, 22, "F");
|
|
doc.setFont("helvetica", "bold"); doc.setFontSize(10); doc.setTextColor(...TEXT);
|
|
doc.text(report.cashHighlight.label, ML + 8, y + 14);
|
|
const amt = report.cashHighlight.amount;
|
|
const c: RGB = amt < 0 ? RED : TEXT;
|
|
doc.setTextColor(...c);
|
|
doc.text(fmtAmount(amt), colAmount, y + 14, { align: "right" });
|
|
y += 28;
|
|
}
|
|
|
|
drawFooter();
|
|
return doc;
|
|
}
|
|
|
|
/** Render a financial report PDF (no cover page). */
|
|
export function renderReportPdf(report: StructuredReport, opts: RenderOpts): jsPDF {
|
|
// Landscape when a comparison period is shown — room for the extra columns.
|
|
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: opts.showCompare ? "landscape" : "portrait" });
|
|
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", orientation: opts.showCompare ? "landscape" : "portrait" });
|
|
await drawReportCoverPage(doc, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), cover);
|
|
doc.addPage();
|
|
return buildReport(doc, report, opts);
|
|
}
|