Files
acmcc/src/pages/accounting/lib/reportPdf.ts
T
admin e302fb91f0 Accounting platform: remove Zoho, unify reports, board access, vendor sharing
- Remove the Zoho Books integration (edge functions, sync libs, settings,
  reports/overview, banking links, fees tab, import dialog); preserve fee
  rules as a standalone FeesTab and the COA accounting_system classification.
- Financial Overview/Reports (staff + board) render the Accounting dashboard
  and reports; board reports mirror the rich Accounting Reports.
- New Reserve Fund Schedule report + an is_reserve flag on accounts.
- Unify all report exports to a branded format (logo + centered header +
  footer): shared ReportSheet (on-screen) and reportHeader (PDF). Budget vs
  Actuals and Bank Reconciliation PDFs now match the reference layout.
- Render financial reports inline (no preview pop-up).
- Budget Management mirrors Accounting Budgeting (staff-accessible) with SPA
  navigation; editable bills in the Accounting Bills page.
- Negative opening balances flow through to the GL and reports (allow negative
  input; keep non-zero on save; signed CSV import).
- Upload a per-account trial balance via CSV on Opening Balances.
- Board members: read-only RLS access to their association's accounting ledger;
  editable board-members panel on the association page; share vendor contacts
  with the board (toggle + directory section).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:29:31 -04:00

283 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
};
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);
}