Files
acmcc/src/pages/accounting/lib/billSummaryPdf.ts
T
admin 386ee26a6a Bill summary PDF: itemized line items + total
Render an itemized table (Description / Qty / Unit Price / Amount) from the
linked invoice's line_items, with a Total row (the authoritative bill amount)
and a subtle note if the line items don't sum to it. Falls back to a single
row when no line items exist. Fetch invoices.line_items in the bills query and
pass it through to the generator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:13:50 -04:00

192 lines
6.4 KiB
TypeScript

import jsPDF from "jspdf";
export type BillLineItem = {
description?: string | null;
name?: string | null;
quantity?: number | null;
unit_price?: number | null;
amount?: number | null;
};
// One-page "summarized bill" PDF for the Bill Approvals page.
export type BillSummaryData = {
association: string;
vendor: string;
invoiceNumber?: string | null;
billDate?: string | null;
dueDate?: string | null;
amount: number;
account?: string | null;
description?: string | null;
status?: string | null;
createdAt?: string | null;
lineItems?: BillLineItem[];
approvals?: { name: string; status: string }[];
};
const money = (n: number) =>
"$" + Number(n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const fmtDate = (d?: string | null) => {
if (!d) return "—";
const x = new Date(d.length <= 10 ? d + "T12:00:00" : d);
return isNaN(+x) ? "—" : x.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
};
const cap = (s?: string | null) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : "—");
export function generateBillSummaryPDF(bill: BillSummaryData): void {
const doc = new jsPDF({ unit: "pt", format: "letter" });
const W = doc.internal.pageSize.getWidth();
const H = doc.internal.pageSize.getHeight();
const M = 48;
// Header bar
doc.setFillColor(44, 62, 80);
doc.rect(0, 0, W, 64, "F");
doc.setTextColor(255, 255, 255);
doc.setFont("helvetica", "bold");
doc.setFontSize(18);
doc.text("Bill Summary", M, 38);
doc.setFont("helvetica", "normal");
doc.setFontSize(9);
doc.text(bill.association || "", W - M, 28, { align: "right" });
doc.text("Generated: " + fmtDate(new Date().toISOString()), W - M, 44, { align: "right" });
// Vendor
let y = 98;
doc.setTextColor(44, 62, 80);
doc.setFont("helvetica", "bold");
doc.setFontSize(15);
doc.text(bill.vendor || "Vendor", M, y);
y += 10;
doc.setDrawColor(236, 240, 241);
doc.setLineWidth(1);
doc.line(M, y, W - M, y);
y += 24;
// Field grid
const col2 = M + (W - 2 * M) / 2;
const field = (label: string, value: string, x: number, yy: number) => {
doc.setFont("helvetica", "bold");
doc.setFontSize(9);
doc.setTextColor(52, 73, 94);
doc.text(label, x, yy);
doc.setFont("helvetica", "normal");
doc.setFontSize(11);
doc.setTextColor(44, 62, 80);
doc.text(value || "—", x, yy + 15);
};
field("Invoice #", bill.invoiceNumber || "—", M, y);
field("Status", cap(bill.status), col2, y);
y += 42;
field("Bill Date", fmtDate(bill.billDate), M, y);
field("Due Date", fmtDate(bill.dueDate), col2, y);
y += 42;
field("Expense Account", bill.account || "—", M, y);
field("Submitted", fmtDate(bill.createdAt), col2, y);
y += 46;
// ── Line items table ───────────────────────────────────────────────
const rawItems = (bill.lineItems ?? []).filter(
(li) => li && (li.amount != null || (li.description ?? li.name)),
);
const items: BillLineItem[] = rawItems.length
? rawItems
: [{ description: bill.description || bill.account || "Bill amount", quantity: null, unit_price: null, amount: bill.amount }];
// Columns (right edges)
const amtR = W - M;
const unitR = amtR - 95;
const qtyR = unitR - 70;
const descX = M;
const descW = qtyR - 70 - descX;
doc.setFont("helvetica", "bold");
doc.setFontSize(11);
doc.setTextColor(44, 62, 80);
doc.text("Line Items", M, y);
y += 14;
// table header
doc.setFillColor(44, 62, 80);
doc.rect(M, y, W - 2 * M, 20, "F");
doc.setTextColor(255, 255, 255);
doc.setFont("helvetica", "bold");
doc.setFontSize(9);
doc.text("Description", descX + 6, y + 14);
doc.text("Qty", qtyR, y + 14, { align: "right" });
doc.text("Unit Price", unitR, y + 14, { align: "right" });
doc.text("Amount", amtR - 4, y + 14, { align: "right" });
y += 20;
doc.setFont("helvetica", "normal");
doc.setTextColor(44, 62, 80);
let itemsTotal = 0;
for (const li of items) {
const desc = (li.description ?? li.name ?? "—").toString();
const lines = doc.splitTextToSize(desc, descW);
const rowH = Math.max(18, lines.length * 12 + 6);
if (y + rowH > H - 90) { doc.addPage(); y = M; }
doc.setFontSize(10);
doc.text(lines, descX + 6, y + 13);
if (li.quantity != null) doc.text(String(li.quantity), qtyR, y + 13, { align: "right" });
if (li.unit_price != null) doc.text(money(Number(li.unit_price)), unitR, y + 13, { align: "right" });
const amt = Number(li.amount ?? 0);
itemsTotal += amt;
doc.text(money(amt), amtR - 4, y + 13, { align: "right" });
doc.setDrawColor(236, 240, 241);
doc.setLineWidth(0.5);
doc.line(M, y + rowH, W - M, y + rowH);
y += rowH;
}
// Total row (authoritative bill amount)
y += 6;
doc.setFont("helvetica", "bold");
doc.setFontSize(12);
doc.text("Total", unitR, y + 6, { align: "right" });
doc.text(money(bill.amount), amtR - 4, y + 6, { align: "right" });
y += 14;
// Note if itemized lines don't sum to the bill total
if (Math.abs(itemsTotal - Number(bill.amount)) > 0.01 && rawItems.length) {
doc.setFont("helvetica", "italic");
doc.setFontSize(8);
doc.setTextColor(120, 120, 120);
doc.text(`(line items subtotal ${money(itemsTotal)})`, amtR - 4, y + 8, { align: "right" });
y += 12;
}
y += 14;
// Approvals
if (y > H - 110) { doc.addPage(); y = M; }
doc.setDrawColor(236, 240, 241);
doc.setLineWidth(1);
doc.line(M, y, W - M, y);
y += 22;
doc.setFont("helvetica", "bold");
doc.setFontSize(11);
doc.setTextColor(44, 62, 80);
doc.text("Board Approvals", M, y);
y += 18;
doc.setFontSize(10);
const approvals = bill.approvals ?? [];
if (approvals.length) {
for (const a of approvals) {
const mark = a.status === "approved" ? "[Approved]"
: a.status === "denied" || a.status === "rejected" ? "[Denied]" : "[Pending]";
doc.setFont("helvetica", "normal");
doc.setTextColor(44, 62, 80);
doc.text(`${mark} ${a.name}`, M, y);
y += 16;
}
} else {
doc.setFont("helvetica", "italic");
doc.setTextColor(120, 120, 120);
doc.text("No approvers assigned.", M, y);
}
const slug = (bill.vendor || "bill").replace(/[^a-z0-9]+/gi, "-").toLowerCase().replace(/^-|-$/g, "");
doc.save(`bill-summary-${slug}-${(bill.billDate || "").slice(0, 10) || "draft"}.pdf`);
}