diff --git a/src/lib/invoice-pdf.ts b/src/lib/invoice-pdf.ts index 13007ea..bef4564 100644 --- a/src/lib/invoice-pdf.ts +++ b/src/lib/invoice-pdf.ts @@ -314,109 +314,142 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin pdf.text("FEES & EXPENSES", MARGIN, y); y += 10; - // Column geometry for fees table - const colDateX = MARGIN + 10; - const colKindX = MARGIN + 100; - const colDescX = MARGIN + 150; - const colRateX = MARGIN + 360; - const colAdjX = MARGIN + 425; - const colTaxX = MARGIN + 470; - const colAmtX = PAGE_W - MARGIN - 10; + // Column geometry for fees table. + // Layout (left → right): + // DATE | TYPE | DESCRIPTION | QTY | RATE | TAX | TOTAL + const colDateX = MARGIN + 8; // date left edge + const colKindX = MARGIN + 70; // type label left edge + const colDescX = MARGIN + 120; // description left edge + const colQtyR = MARGIN + 360; // qty right edge + const colRateR = MARGIN + 415; // rate right edge + const colTaxR = MARGIN + 460; // tax right edge + const colAmtX = PAGE_W - MARGIN - 8; // total right edge const drawColHeaders = () => { pdf.setFillColor(...HEADER_BG); pdf.rect(MARGIN, y, CONTENT_W, 22, "F"); setFont(pdf, { bold: true, size: 8, color: TEXT }); pdf.text("DATE", colDateX, y + 14); + pdf.text("TYPE", colKindX, y + 14); pdf.text("DESCRIPTION", colDescX, y + 14); + const qW = pdf.getTextWidth("QTY"); + pdf.text("QTY", colQtyR - qW, y + 14); const rW = pdf.getTextWidth("RATE"); - pdf.text("RATE", colRateX - rW + 30, y + 14); - const aW = pdf.getTextWidth("ADJMTS"); - pdf.text("ADJMTS", colAdjX - aW + 40, y + 14); + pdf.text("RATE", colRateR - rW, y + 14); const tW = pdf.getTextWidth("TAX"); - pdf.text("TAX", colTaxX - tW + 32, y + 14); + pdf.text("TAX", colTaxR - tW, y + 14); const totW = pdf.getTextWidth("TOTAL"); pdf.text("TOTAL", colAmtX - totW, y + 14); y += 22; }; - drawColHeaders(); - - // Flatten line items across groups (the reference doesn't separate groups) - const allItems: Array = []; - for (const g of input.groups) { - for (const it of g.items) { - allItems.push({ ...it, _caseNumber: g.caseNumber }); + const drawCaseHeader = (g: InvoiceCaseGroup) => { + ensure(28); + pdf.setFillColor(248, 250, 253); + pdf.rect(MARGIN, y, CONTENT_W, 20, "F"); + setFont(pdf, { bold: true, size: 9.5, color: TEXT }); + const titleText = `${g.caseTitle}`; + pdf.text(titleText, MARGIN + 8, y + 13); + setFont(pdf, { size: 8, color: MUTED }); + const meta = [g.caseNumber, g.practiceArea].filter(Boolean).join(" · "); + if (meta) { + const metaW = pdf.getTextWidth(meta); + pdf.text(meta, PAGE_W - MARGIN - 8 - metaW, y + 13); } - } - - const taxRate = (input.totals.taxRatePct ?? 0) / 100; - - for (const item of allItems) { - const isNonBillable = item.billable === false; - const descMaxW = colRateX - colDescX - 10; - const descLines = wrap(pdf, item.description || (item.kind === "expense" ? "Expense" : ""), descMaxW); - const lineHeight = 12; - const blockH = Math.max(lineHeight * descLines.length + 14, 30); - ensure(blockH + 4); - - // Date (date + staff name on second line) - setFont(pdf, { size: 9, color: TEXT }); - pdf.text(fmtDateShort(item.work_date), colDateX, y + 10); - if (item.user_name) { - setFont(pdf, { size: 8, color: MUTED }); - pdf.text(item.user_name, colDateX, y + 22); - } - - // Kind label (Fee / Expense) - setFont(pdf, { size: 9, color: TEXT }); - const kindLabel = - item.kind === "expense" ? "Expense" : item.kind === "time" ? "Fee" : item.kind === "manual" ? "Fee" : item.kind; - pdf.text(kindLabel, colKindX, y + 10); - - // Description - setFont(pdf, { size: 9, color: isNonBillable ? MUTED : TEXT, italic: isNonBillable }); - let dy = y + 10; - for (const dl of descLines) { - pdf.text(dl, colDescX, dy); - dy += lineHeight; - } - - // Rate (with x quantity below) - setFont(pdf, { size: 9, color: TEXT }); - const rateText = fmtCurrency(item.rate); - const rW = pdf.getTextWidth(rateText); - pdf.text(rateText, colRateX - rW + 30, y + 10); - if (item.quantity && item.quantity !== 1) { - setFont(pdf, { size: 8, color: MUTED }); - const qText = `x ${Number(item.quantity).toFixed(2)}`; - const qW = pdf.getTextWidth(qText); - pdf.text(qText, colRateX - qW + 30, y + 22); - } - - // Adjustments column — placeholder $0.00 unless future per-line adjustments - setFont(pdf, { size: 9, color: TEXT }); - const adjText = fmtCurrency(0); - const adjW = pdf.getTextWidth(adjText); - pdf.text(adjText, colAdjX - adjW + 40, y + 10); - - // Tax per line - const lineTax = isNonBillable ? 0 : item.amount * taxRate; - const taxText = fmtCurrency(lineTax); - const taxW = pdf.getTextWidth(taxText); - pdf.text(taxText, colTaxX - taxW + 32, y + 10); - - // 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 + 10); - - y += blockH; + y += 20; pdf.setDrawColor(...RULE); pdf.setLineWidth(0.4); pdf.line(MARGIN, y, PAGE_W - MARGIN, y); + }; + + drawColHeaders(); + + const taxRate = (input.totals.taxRatePct ?? 0) / 100; + const multipleCases = input.groups.length > 1; + + for (const g of input.groups) { + if (multipleCases) 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); + + // 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); + } + + // Per-case subtotal when there are multiple cases + if (multipleCases) { + ensure(22); + setFont(pdf, { bold: true, size: 9, color: TEXT }); + const label = `Subtotal — ${g.caseNumber}`; + pdf.text(label, colDescX, y + 14); + const subText = fmtCurrency(g.subtotal); + const sw = pdf.getTextWidth(subText); + pdf.text(subText, colAmtX - sw, y + 14); + y += 20; + pdf.setDrawColor(...RULE); + pdf.setLineWidth(0.6); + pdf.line(MARGIN, y, PAGE_W - MARGIN, y); + y += 6; + } } y += 20;