mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
e302fb91f0
- 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>
283 lines
11 KiB
TypeScript
283 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;
|
||
};
|
||
|
||
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);
|
||
}
|