Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 01:14:48 +00:00
co-authored by renee-png
parent baadbbfa26
commit 1393c7e50e
+80 -63
View File
@@ -380,73 +380,90 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin
const taxRate = (input.totals.taxRatePct ?? 0) / 100;
const multipleCases = input.groups.length > 1;
const drawSubsectionHeader = (label: string, subtotal: number, billable: boolean) => {
ensure(22);
pdf.setFillColor(250, 250, 252);
pdf.rect(MARGIN, y, CONTENT_W, 16, "F");
setFont(pdf, { bold: true, size: 8, color: MUTED });
pdf.text(label.toUpperCase(), MARGIN + 8, y + 11);
if (billable) {
const sub = fmtCurrency(subtotal);
const sw = pdf.getTextWidth(sub);
setFont(pdf, { bold: true, size: 8, color: TEXT });
pdf.text(sub, colAmtX - sw, y + 11);
}
y += 16;
pdf.setDrawColor(...RULE);
pdf.setLineWidth(0.4);
pdf.line(MARGIN, y, PAGE_W - MARGIN, y);
};
const drawItem = (item: InvoiceLineItem) => {
const isNonBillable = item.billable === false;
const descMaxW = colQtyR - colDescX - 14;
const descLines = wrap(pdf, item.description || (item.kind === "expense" ? "Expense" : ""), descMaxW);
const lineHeight = 12;
const blockH = Math.max(lineHeight * descLines.length + 14, item.user_name ? 32 : 26);
ensure(blockH + 4);
setFont(pdf, { size: 9, color: TEXT });
pdf.text(fmtDateShort(item.work_date), colDateX, y + 12);
if (item.user_name) {
setFont(pdf, { size: 7.5, color: MUTED });
const nameLines = wrap(pdf, item.user_name, colKindX - colDateX - 4);
pdf.text(nameLines[0], colDateX, y + 23);
}
setFont(pdf, { size: 9, color: MUTED });
const kindLabel =
item.kind === "expense" ? "Expense" : item.kind === "time" ? "Fee" : item.kind === "manual" ? "Fee" : item.kind;
pdf.text(kindLabel, colKindX, y + 12);
setFont(pdf, { size: 9, color: isNonBillable ? MUTED : TEXT, italic: isNonBillable });
let dy = y + 12;
for (const dl of descLines) {
pdf.text(dl, colDescX, dy);
dy += lineHeight;
}
setFont(pdf, { size: 9, color: TEXT });
const qtyText = item.kind === "expense" ? "—" : Number(item.quantity).toFixed(2);
const qW = pdf.getTextWidth(qtyText);
pdf.text(qtyText, colQtyR - qW, y + 12);
const rateText = fmtCurrency(item.rate);
const rW = pdf.getTextWidth(rateText);
pdf.text(rateText, colRateR - rW, y + 12);
const lineTax = isNonBillable ? 0 : item.amount * taxRate;
const taxText = fmtCurrency(lineTax);
const taxW = pdf.getTextWidth(taxText);
pdf.text(taxText, colTaxR - taxW, y + 12);
const lineTotal = isNonBillable ? 0 : item.amount + lineTax;
const amt = isNonBillable ? "—" : fmtCurrency(lineTotal);
setFont(pdf, { size: 9, bold: true, color: TEXT });
const aw = pdf.getTextWidth(amt);
pdf.text(amt, colAmtX - aw, y + 12);
y += blockH;
pdf.setDrawColor(...RULE);
pdf.setLineWidth(0.4);
pdf.line(MARGIN, y, PAGE_W - MARGIN, y);
};
for (const g of input.groups) {
// Always show the case name above its charges (per-case subtotal only when >1 case).
drawCaseHeader(g);
for (const item of g.items) {
const isNonBillable = item.billable === false;
const descMaxW = colQtyR - colDescX - 14;
const descLines = wrap(pdf, item.description || (item.kind === "expense" ? "Expense" : ""), descMaxW);
const lineHeight = 12;
// Reserve enough vertical space: description lines + room for staff name under date.
const blockH = Math.max(lineHeight * descLines.length + 14, item.user_name ? 32 : 26);
ensure(blockH + 4);
// Build subsections: use provided list if any, else a single all-items section.
const subs: InvoiceSubsection[] =
g.subsections && g.subsections.length > 0
? g.subsections
: [{ key: "_all", label: "", billable: true, items: g.items, subtotal: g.subtotal }];
// Date + staff name
setFont(pdf, { size: 9, color: TEXT });
pdf.text(fmtDateShort(item.work_date), colDateX, y + 12);
if (item.user_name) {
setFont(pdf, { size: 7.5, color: MUTED });
const nameLines = wrap(pdf, item.user_name, colKindX - colDateX - 4);
pdf.text(nameLines[0], colDateX, y + 23);
}
// Type label (Fee / Expense)
setFont(pdf, { size: 9, color: MUTED });
const kindLabel =
item.kind === "expense" ? "Expense" : item.kind === "time" ? "Fee" : item.kind === "manual" ? "Fee" : item.kind;
pdf.text(kindLabel, colKindX, y + 12);
// Description
setFont(pdf, { size: 9, color: isNonBillable ? MUTED : TEXT, italic: isNonBillable });
let dy = y + 12;
for (const dl of descLines) {
pdf.text(dl, colDescX, dy);
dy += lineHeight;
}
// Qty (right-aligned). For expenses (qty=1, no hours concept) show "—".
setFont(pdf, { size: 9, color: TEXT });
const qtyText =
item.kind === "expense"
? "—"
: Number(item.quantity).toFixed(2);
const qW = pdf.getTextWidth(qtyText);
pdf.text(qtyText, colQtyR - qW, y + 12);
// Rate (right-aligned)
const rateText = fmtCurrency(item.rate);
const rW = pdf.getTextWidth(rateText);
pdf.text(rateText, colRateR - rW, y + 12);
// Tax per line
const lineTax = isNonBillable ? 0 : item.amount * taxRate;
const taxText = fmtCurrency(lineTax);
const taxW = pdf.getTextWidth(taxText);
pdf.text(taxText, colTaxR - taxW, y + 12);
// Total (amount + tax)
const lineTotal = isNonBillable ? 0 : item.amount + lineTax;
const amt = isNonBillable ? "—" : fmtCurrency(lineTotal);
setFont(pdf, { size: 9, bold: true, color: TEXT });
const aw = pdf.getTextWidth(amt);
pdf.text(amt, colAmtX - aw, y + 12);
y += blockH;
pdf.setDrawColor(...RULE);
pdf.setLineWidth(0.4);
pdf.line(MARGIN, y, PAGE_W - MARGIN, y);
for (const sub of subs) {
if (sub.label) drawSubsectionHeader(sub.label, sub.subtotal, sub.billable);
for (const item of sub.items) drawItem(item);
}
// Per-case subtotal when there are multiple cases