Files
acmcc/src/pages/accounting/lib/reportPdf.ts
T
admin c3a0682e57 Accounting: A/P-clearing payments, check return address + MICR gaps, dashboard fixes
Bill/vendor payments:
- Bill "Pay" and bank-register vendor debits now clear Accounts Payable
  (Dr A/P / Cr Bank) instead of re-debiting the expense, which had
  double-counted expenses in the P&L and never cleared A/P. The expense
  account is kept as a display-only category on the payment.

Checks:
- Bill-payment checks now print the return address (payer name/address)
  from the company check layout (was hardcoded blank).
- Per-segment MICR gap control (check# / routing / account) in both check
  generators, wired to Check Setup (bill payments) and the Check Layout
  editor (Print Checks). New columns: check_settings.micr_gap_1/2 and
  check_layouts/company_check_layouts.micr_gap_1/2.

Accounting Dashboard / Financial Overview:
- Date-range selector (presets + custom) drives the charts, top expenses,
  and recent transactions.
- PDF title renamed to "Financial Overview" and shows the period.
- Fixed amounts rendering as "$ 5 0 0. 0 0": the U+2212 minus sign forced
  jsPDF into a UTF-16 fallback; replaced with an ASCII hyphen (also in the
  report PDF out-of-balance line).

DB migrations applied to the project: repost_gl_on_line_item_change,
budget_actuals_accrual_owner_income, add_micr_gap_controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 00:35:30 -04:00

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