mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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>
This commit is contained in:
@@ -258,7 +258,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
for (let from = 0; ; from += PAGE) {
|
for (let from = 0; ; from += PAGE) {
|
||||||
let q = supabase
|
let q = supabase
|
||||||
.from("bills")
|
.from("bills")
|
||||||
.select("*, associations(name), chart_of_accounts(account_name, account_number), invoices:source_invoice_id(id, vendor_name, invoice_number, issue_date, due_date, amount, description, raw_pdf_url)")
|
.select("*, associations(name), chart_of_accounts(account_name, account_number), invoices:source_invoice_id(id, vendor_name, invoice_number, issue_date, due_date, amount, description, raw_pdf_url, line_items)")
|
||||||
.order("created_at", { ascending: false });
|
.order("created_at", { ascending: false });
|
||||||
if (isBoardView) {
|
if (isBoardView) {
|
||||||
q = q.in("association_id", boardAssociationIds!).in("id", assignedBillIds!);
|
q = q.in("association_id", boardAssociationIds!).in("id", assignedBillIds!);
|
||||||
@@ -1229,6 +1229,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
description: b.invoices?.description ?? b.description,
|
description: b.invoices?.description ?? b.description,
|
||||||
status: displayStatus,
|
status: displayStatus,
|
||||||
createdAt: b.created_at,
|
createdAt: b.created_at,
|
||||||
|
lineItems: Array.isArray(b.invoices?.line_items) ? b.invoices.line_items : undefined,
|
||||||
approvals: (approvalsByBill[b.id] || []).map((a: any) => ({ name: a.approver_name, status: a.status })),
|
approvals: (approvalsByBill[b.id] || []).map((a: any) => ({ name: a.approver_name, status: a.status })),
|
||||||
})}>
|
})}>
|
||||||
<Download className="h-4 w-4 mr-2" /> Download PDF
|
<Download className="h-4 w-4 mr-2" /> Download PDF
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import jsPDF from "jspdf";
|
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.
|
// One-page "summarized bill" PDF for the Bill Approvals page.
|
||||||
export type BillSummaryData = {
|
export type BillSummaryData = {
|
||||||
association: string;
|
association: string;
|
||||||
@@ -12,6 +20,7 @@ export type BillSummaryData = {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
|
lineItems?: BillLineItem[];
|
||||||
approvals?: { name: string; status: string }[];
|
approvals?: { name: string; status: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +38,7 @@ const cap = (s?: string | null) => (s ? s.charAt(0).toUpperCase() + s.slice(1) :
|
|||||||
export function generateBillSummaryPDF(bill: BillSummaryData): void {
|
export function generateBillSummaryPDF(bill: BillSummaryData): void {
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||||
const W = doc.internal.pageSize.getWidth();
|
const W = doc.internal.pageSize.getWidth();
|
||||||
|
const H = doc.internal.pageSize.getHeight();
|
||||||
const M = 48;
|
const M = 48;
|
||||||
|
|
||||||
// Header bar
|
// Header bar
|
||||||
@@ -43,21 +53,19 @@ export function generateBillSummaryPDF(bill: BillSummaryData): void {
|
|||||||
doc.text(bill.association || "", W - M, 28, { align: "right" });
|
doc.text(bill.association || "", W - M, 28, { align: "right" });
|
||||||
doc.text("Generated: " + fmtDate(new Date().toISOString()), W - M, 44, { align: "right" });
|
doc.text("Generated: " + fmtDate(new Date().toISOString()), W - M, 44, { align: "right" });
|
||||||
|
|
||||||
// Vendor + amount
|
// Vendor
|
||||||
let y = 100;
|
let y = 98;
|
||||||
doc.setTextColor(44, 62, 80);
|
doc.setTextColor(44, 62, 80);
|
||||||
doc.setFont("helvetica", "bold");
|
doc.setFont("helvetica", "bold");
|
||||||
doc.setFontSize(15);
|
doc.setFontSize(15);
|
||||||
doc.text(bill.vendor || "Vendor", M, y);
|
doc.text(bill.vendor || "Vendor", M, y);
|
||||||
doc.setFontSize(20);
|
y += 10;
|
||||||
doc.text(money(bill.amount), W - M, y, { align: "right" });
|
|
||||||
y += 12;
|
|
||||||
doc.setDrawColor(236, 240, 241);
|
doc.setDrawColor(236, 240, 241);
|
||||||
doc.setLineWidth(1);
|
doc.setLineWidth(1);
|
||||||
doc.line(M, y, W - M, y);
|
doc.line(M, y, W - M, y);
|
||||||
y += 26;
|
y += 24;
|
||||||
|
|
||||||
// Two-column field grid
|
// Field grid
|
||||||
const col2 = M + (W - 2 * M) / 2;
|
const col2 = M + (W - 2 * M) / 2;
|
||||||
const field = (label: string, value: string, x: number, yy: number) => {
|
const field = (label: string, value: string, x: number, yy: number) => {
|
||||||
doc.setFont("helvetica", "bold");
|
doc.setFont("helvetica", "bold");
|
||||||
@@ -71,33 +79,91 @@ export function generateBillSummaryPDF(bill: BillSummaryData): void {
|
|||||||
};
|
};
|
||||||
field("Invoice #", bill.invoiceNumber || "—", M, y);
|
field("Invoice #", bill.invoiceNumber || "—", M, y);
|
||||||
field("Status", cap(bill.status), col2, y);
|
field("Status", cap(bill.status), col2, y);
|
||||||
y += 44;
|
y += 42;
|
||||||
field("Bill Date", fmtDate(bill.billDate), M, y);
|
field("Bill Date", fmtDate(bill.billDate), M, y);
|
||||||
field("Due Date", fmtDate(bill.dueDate), col2, y);
|
field("Due Date", fmtDate(bill.dueDate), col2, y);
|
||||||
y += 44;
|
y += 42;
|
||||||
field("Expense Account", bill.account || "—", M, y);
|
field("Expense Account", bill.account || "—", M, y);
|
||||||
field("Submitted", fmtDate(bill.createdAt), col2, y);
|
field("Submitted", fmtDate(bill.createdAt), col2, y);
|
||||||
y += 44;
|
y += 46;
|
||||||
|
|
||||||
// Description
|
// ── Line items table ───────────────────────────────────────────────
|
||||||
if (bill.description && bill.description.trim()) {
|
const rawItems = (bill.lineItems ?? []).filter(
|
||||||
doc.setFont("helvetica", "bold");
|
(li) => li && (li.amount != null || (li.description ?? li.name)),
|
||||||
doc.setFontSize(9);
|
);
|
||||||
doc.setTextColor(52, 73, 94);
|
const items: BillLineItem[] = rawItems.length
|
||||||
doc.text("Description / Notes", M, y);
|
? rawItems
|
||||||
y += 15;
|
: [{ description: bill.description || bill.account || "Bill amount", quantity: null, unit_price: null, amount: bill.amount }];
|
||||||
doc.setFont("helvetica", "normal");
|
|
||||||
doc.setFontSize(11);
|
// Columns (right edges)
|
||||||
doc.setTextColor(44, 62, 80);
|
const amtR = W - M;
|
||||||
const lines = doc.splitTextToSize(bill.description.trim(), W - 2 * M);
|
const unitR = amtR - 95;
|
||||||
doc.text(lines, M, y);
|
const qtyR = unitR - 70;
|
||||||
y += lines.length * 14 + 12;
|
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
|
// Approvals
|
||||||
|
if (y > H - 110) { doc.addPage(); y = M; }
|
||||||
doc.setDrawColor(236, 240, 241);
|
doc.setDrawColor(236, 240, 241);
|
||||||
|
doc.setLineWidth(1);
|
||||||
doc.line(M, y, W - M, y);
|
doc.line(M, y, W - M, y);
|
||||||
y += 24;
|
y += 22;
|
||||||
doc.setFont("helvetica", "bold");
|
doc.setFont("helvetica", "bold");
|
||||||
doc.setFontSize(11);
|
doc.setFontSize(11);
|
||||||
doc.setTextColor(44, 62, 80);
|
doc.setTextColor(44, 62, 80);
|
||||||
|
|||||||
Reference in New Issue
Block a user