mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import jsPDF from "jspdf";
|
||||
|
||||
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 }[];
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
export function renderReportPdf(report: StructuredReport, opts: RenderOpts): jsPDF {
|
||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||
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 cmpRight = opts.showCompare ? amtRight - 108 : 0;
|
||||
const amtUnderL = amtRight - 92;
|
||||
const cmpUnderL = opts.showCompare ? cmpRight - 92 : 0;
|
||||
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 dividers before numeric columns
|
||||
if (opts.showCompare) doc.line(cmpRight - 96, y, cmpRight - 96, y + h);
|
||||
doc.line(amtRight - 96, y, amtRight - 96, y + h);
|
||||
doc.setFont("helvetica", "bold"); doc.setFontSize(9); doc.setTextColor(...TEXT);
|
||||
doc.text("Account Name", ML + 6, y + 12);
|
||||
if (opts.showCompare) doc.text("Previous", cmpRight, y + 12, { align: "right" });
|
||||
doc.text(rightHeader, amtRight, y + 12, { align: "right" });
|
||||
y += h;
|
||||
};
|
||||
|
||||
// Full header (page 1): title + metadata block + column header
|
||||
const drawFullHeader = () => {
|
||||
y = 50;
|
||||
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(...TEXT);
|
||||
doc.text(report.title, ML, y);
|
||||
y += 20;
|
||||
|
||||
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+): title + column header only
|
||||
const drawContHeader = () => {
|
||||
y = 50;
|
||||
doc.setFont("helvetica", "bold"); doc.setFontSize(12); doc.setTextColor(...TEXT);
|
||||
doc.text(report.title, ML, y);
|
||||
y += 16;
|
||||
drawColHeader();
|
||||
};
|
||||
|
||||
const drawFooter = () => {
|
||||
const total = doc.getNumberOfPages();
|
||||
const created = new Date().toLocaleDateString("en-US");
|
||||
for (let p = 1; p <= total; p++) {
|
||||
doc.setPage(p);
|
||||
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...MUTED);
|
||||
doc.text(`Created on ${created}`, ML, H - 28);
|
||||
doc.text(`Page ${p}`, 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);
|
||||
doc.line(amtUnderL, top + 1, amtRight, top + 1);
|
||||
if (opts.showCompare) doc.line(cmpUnderL, top + 1, cmpRight, 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
|
||||
doc.setFont("helvetica", bold ? "bold" : "normal"); doc.setFontSize(9);
|
||||
if (row.amount !== undefined) {
|
||||
const c: RGB = row.amount < 0 ? RED : TEXT;
|
||||
doc.setTextColor(...c);
|
||||
doc.text(fmtAmount(row.amount), amtRight, top + 11, { align: "right" });
|
||||
}
|
||||
if (opts.showCompare && row.compare !== undefined) {
|
||||
const c: RGB = row.compare < 0 ? RED : TEXT;
|
||||
doc.setTextColor(...c);
|
||||
doc.text(fmtAmount(row.compare), cmpRight, 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), amtRight, y + 14, { align: "right" });
|
||||
y += 28;
|
||||
}
|
||||
|
||||
drawFooter();
|
||||
return doc;
|
||||
}
|
||||
Reference in New Issue
Block a user