Files
acmcc/src/pages/accounting/lib/reportPdf.ts
T
admin c3d1d86b07 Reports: account drill-down to GL; bids/quotes PDF attachments
- 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>
2026-06-03 00:47:11 -04:00

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);
}