diff --git a/src/lib/invoice-pdf.ts b/src/lib/invoice-pdf.ts index 7c3a953..210be87 100644 --- a/src/lib/invoice-pdf.ts +++ b/src/lib/invoice-pdf.ts @@ -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