From 3e23a3bb94584a3cb7828e65e1f5956274fcc97d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:10:54 +0000 Subject: [PATCH 1/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/invoice-pdf.ts | 531 ++++++++++++++++++++++++++--------------- 1 file changed, 339 insertions(+), 192 deletions(-) diff --git a/src/lib/invoice-pdf.ts b/src/lib/invoice-pdf.ts index a7b1368..13007ea 100644 --- a/src/lib/invoice-pdf.ts +++ b/src/lib/invoice-pdf.ts @@ -55,6 +55,8 @@ export interface InvoicePdfInput { dueDate?: string | null; status: string; notes?: string | null; + matter?: string | null; + terms?: string | null; }; groups: InvoiceCaseGroup[]; totals: { @@ -63,18 +65,35 @@ export interface InvoicePdfInput { total: number; paid: number; balance: number; + taxRatePct?: number | null; + feesAndExpenses?: number | null; + alternateFees?: number | null; + adjustments?: Array<{ description: string; amount: number }>; }; } const fmtCurrency = (n: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n || 0); -function setFont(pdf: jsPDF, opts: { bold?: boolean; italic?: boolean; size?: number; color?: [number, number, number] }) { +const fmtDateShort = (s: string | null | undefined): string => { + if (!s) return "—"; + const d = new Date(s); + if (isNaN(d.getTime())) return "—"; + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + const yy = String(d.getFullYear()).slice(-2); + return `${mm}/${dd}/${yy}`; +}; + +function setFont( + pdf: jsPDF, + opts: { bold?: boolean; italic?: boolean; size?: number; color?: [number, number, number]; family?: "helvetica" | "times" }, +) { let style: "normal" | "bold" | "italic" | "bolditalic" = "normal"; if (opts.bold && opts.italic) style = "bolditalic"; else if (opts.bold) style = "bold"; else if (opts.italic) style = "italic"; - pdf.setFont("helvetica", style); + pdf.setFont(opts.family ?? "helvetica", style); pdf.setFontSize(opts.size ?? 10); if (opts.color) pdf.setTextColor(...opts.color); else pdf.setTextColor(20, 24, 32); @@ -103,11 +122,67 @@ async function imageNaturalSize(dataUrl: string): Promise<{ w: number; h: number }); } +// Palette +const TEXT: [number, number, number] = [30, 38, 56]; // dark navy text +const MUTED: [number, number, number] = [120, 128, 140]; +const RULE: [number, number, number] = [228, 232, 238]; +const HEADER_BG: [number, number, number] = [243, 245, 248]; +const ACCENT: [number, number, number] = [245, 158, 11]; // warm orange (smile) + +function drawSmileMark(pdf: jsPDF, cx: number, cy: number, size: number) { + // Two checkmark ticks + a curved smile underneath, in accent color. + pdf.setDrawColor(...ACCENT); + pdf.setLineCap(1); // round + pdf.setLineJoin(1); + + // Two checkmarks + pdf.setLineWidth(size * 0.09); + // first check + pdf.lines( + [ + [size * 0.22, size * 0.22], + [size * 0.42, -size * 0.5], + ], + cx - size * 0.55, + cy - size * 0.05, + ); + // second check + pdf.lines( + [ + [size * 0.22, size * 0.22], + [size * 0.42, -size * 0.5], + ], + cx - size * 0.2, + cy - size * 0.05, + ); + + // Smile curve (open arc) — approximated with bezier + pdf.setLineWidth(size * 0.1); + // Draw using lines() with bezier control points: [cp1x, cp1y, cp2x, cp2y, ex, ey] + pdf.lines( + [ + [size * 0.2, size * 0.55, size * 0.7, size * 0.55, size * 0.95, 0], + ], + cx - size * 0.5, + cy + size * 0.2, + [1, 1], + "S", + false, + ); + // Arrow tip on the right end of smile + pdf.setLineWidth(size * 0.09); + pdf.lines( + [ + [-size * 0.18, -size * 0.05], + [size * 0.05, size * 0.18], + ], + cx + size * 0.45, + cy + size * 0.2, + ); +} + export async function downloadInvoicePdf(input: InvoicePdfInput, filename: string) { const pdf = new jsPDF({ unit: "pt", format: "letter" }); - const accent: [number, number, number] = [30, 58, 95]; // deep navy - const muted: [number, number, number] = [110, 116, 128]; - const rule: [number, number, number] = [220, 224, 232]; let y = MARGIN; const ensure = (need: number) => { @@ -118,92 +193,77 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin } }; - // ===== Header band ===== - // Logo: preserve aspect ratio, fit within a max box, no stretching/cropping. - let logoBoxW = 0; - let logoBoxH = 0; + // ===== Top: "Invoice" title (left) + logo or smile mark (right) ===== + setFont(pdf, { bold: true, size: 34, color: TEXT, family: "times" }); + pdf.text("Invoice", MARGIN, y + 28); + + // Right side: firm logo if available, else decorative smile mark if (input.firm.logoDataUrl) { - const MAX_W = 180; - const MAX_H = 80; + const MAX_W = 110; + const MAX_H = 60; const size = await imageNaturalSize(input.firm.logoDataUrl); + let lw = MAX_W; + let lh = MAX_H; if (size && size.w > 0 && size.h > 0) { const scale = Math.min(MAX_W / size.w, MAX_H / size.h, 1); - logoBoxW = size.w * scale; - logoBoxH = size.h * scale; - } else { - logoBoxW = MAX_W; - logoBoxH = MAX_H; + lw = size.w * scale; + lh = size.h * scale; } try { pdf.addImage( input.firm.logoDataUrl, detectImageFormat(input.firm.logoDataUrl), - MARGIN, + PAGE_W - MARGIN - lw, y, - logoBoxW, - logoBoxH, + lw, + lh, undefined, "FAST", ); } catch { - // ignore — fall through to text-only header + drawSmileMark(pdf, PAGE_W - MARGIN - 40, y + 18, 60); } + } else { + drawSmileMark(pdf, PAGE_W - MARGIN - 40, y + 18, 60); } - const headerLeftX = logoBoxW > 0 ? MARGIN + logoBoxW + 14 : MARGIN; - setFont(pdf, { bold: true, size: 14, color: accent }); - pdf.text(input.firm.name || "Firm", headerLeftX, y + 14); - setFont(pdf, { size: 9, color: muted }); + + y += 70; + + // ===== FROM / TO / Info panel ===== + // Three columns: FROM, TO, and a key-value info card on the right + const colW = (CONTENT_W - 20) / 3; + const fromX = MARGIN; + const toX = MARGIN + colW + 10; + const infoX = MARGIN + colW * 2 + 20; + const infoW = CONTENT_W - (colW * 2 + 20); + + // FROM + setFont(pdf, { bold: true, size: 8, color: MUTED }); + pdf.text("FROM:", fromX, y); + setFont(pdf, { bold: true, size: 12, color: TEXT }); + pdf.text(input.firm.name || "Firm", fromX, y + 14); + setFont(pdf, { size: 9, color: TEXT }); let fy = y + 28; const firmLines = [ input.firm.address1, input.firm.address2, [input.firm.city, input.firm.state, input.firm.postal].filter(Boolean).join(", "), - [input.firm.phone, input.firm.email].filter(Boolean).join(" · "), + input.firm.phone, + input.firm.email, input.firm.website, ].filter((l): l is string => !!l && l.trim().length > 0); for (const l of firmLines) { - pdf.text(l, headerLeftX, fy); + pdf.text(l, fromX, fy); fy += 11; } - // Make sure the header band is at least as tall as the logo - const headerBottom = Math.max(fy, y + logoBoxH + 4); - // INVOICE block (right) - setFont(pdf, { bold: true, size: 22, color: accent }); - const titleW = pdf.getTextWidth("INVOICE"); - pdf.text("INVOICE", PAGE_W - MARGIN - titleW, y + 18); - setFont(pdf, { size: 9, color: muted }); - const labelX = PAGE_W - MARGIN - 150; - const valueX = PAGE_W - MARGIN; - let iy = y + 36; - const drawKV = (k: string, v: string) => { - setFont(pdf, { size: 9, color: muted }); - pdf.text(k, labelX, iy); - setFont(pdf, { size: 10, bold: true }); - const w = pdf.getTextWidth(v); - pdf.text(v, valueX - w, iy); - iy += 13; - }; - drawKV("Invoice #", input.invoice.number); - drawKV("Issue date", formatDate(input.invoice.issueDate)); - if (input.invoice.dueDate) drawKV("Due date", formatDate(input.invoice.dueDate)); - drawKV("Status", input.invoice.status.toUpperCase()); - - y = Math.max(headerBottom, iy) + 12; - - // Accent rule - pdf.setDrawColor(...accent); - pdf.setLineWidth(1.2); - pdf.line(MARGIN, y, PAGE_W - MARGIN, y); - y += 18; - - // ===== Bill To ===== - setFont(pdf, { size: 8, color: muted }); - pdf.text("BILL TO", MARGIN, y); - setFont(pdf, { bold: true, size: 12, color: accent }); - pdf.text(input.client.name, MARGIN, y + 14); - setFont(pdf, { size: 9, color: muted }); - let by = y + 28; + // TO + setFont(pdf, { bold: true, size: 8, color: MUTED }); + pdf.text("TO:", toX, y); + setFont(pdf, { bold: true, size: 12, color: TEXT }); + pdf.text(input.client.name, toX, y + 14); + setFont(pdf, { size: 9, color: TEXT }); + let ty = y + 28; const clientLines = [ input.client.contact, input.client.address1, @@ -211,153 +271,241 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin [input.client.city, input.client.state, input.client.postal].filter(Boolean).join(", "), ].filter((l): l is string => !!l && l.trim().length > 0); for (const l of clientLines) { - pdf.text(l, MARGIN, by); - by += 11; + pdf.text(l, toX, ty); + ty += 11; } - y = by + 16; - // ===== Column geometry ===== - // DATE | STAFF | DESCRIPTION | BILL | HRS | RATE | AMOUNT - const colDateX = MARGIN; - const colStaffX = MARGIN + 58; - const colDescX = MARGIN + 92; - const colBillX = MARGIN + 332; - const colHrsX = MARGIN + 372; - const colRateX = MARGIN + 422; - const colAmtX = PAGE_W - MARGIN; // right aligned + // Info card (right) — key/value rows + const infoRows: Array<[string, string]> = [ + ["Inv. Number", input.invoice.number], + ]; + if (input.invoice.matter) infoRows.push(["Matter", input.invoice.matter]); + infoRows.push(["Date of Issue", formatDate(input.invoice.issueDate)]); + if (input.invoice.terms) infoRows.push(["Terms", input.invoice.terms]); + if (input.invoice.dueDate) infoRows.push(["Date Due", formatDate(input.invoice.dueDate)]); + + const rowH = 18; + const infoTop = y - 4; + const infoH = rowH * infoRows.length; + + // background card + pdf.setFillColor(...HEADER_BG); + pdf.rect(infoX, infoTop, infoW, infoH, "F"); + + let iy = infoTop + 12; + infoRows.forEach((row, idx) => { + setFont(pdf, { size: 9, color: MUTED }); + pdf.text(row[0], infoX + 10, iy); + setFont(pdf, { size: 9.5, bold: true, color: TEXT }); + const w = pdf.getTextWidth(row[1]); + pdf.text(row[1], infoX + infoW - 10 - w, iy); + if (idx < infoRows.length - 1) { + pdf.setDrawColor(...RULE); + pdf.setLineWidth(0.5); + pdf.line(infoX + 10, iy + 6, infoX + infoW - 10, iy + 6); + } + iy += rowH; + }); + + y = Math.max(fy, ty, infoTop + infoH) + 24; + + // ===== FEES & EXPENSES section ===== + setFont(pdf, { bold: true, size: 9, color: TEXT }); + 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; + const drawColHeaders = () => { - pdf.setFillColor(245, 247, 250); - pdf.rect(MARGIN, y - 10, CONTENT_W, 18, "F"); - setFont(pdf, { bold: true, size: 8, color: accent }); - pdf.text("DATE", colDateX + 2, y + 2); - pdf.text("STAFF", colStaffX, y + 2); - pdf.text("DESCRIPTION", colDescX, y + 2); - pdf.text("BILL", colBillX, y + 2); - pdf.text("HRS/QTY", colHrsX, y + 2); - pdf.text("RATE", colRateX, y + 2); - const w = pdf.getTextWidth("AMOUNT"); - pdf.text("AMOUNT", colAmtX - w, y + 2); - y += 16; + 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("DESCRIPTION", colDescX, 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); + const tW = pdf.getTextWidth("TAX"); + pdf.text("TAX", colTaxX - tW + 32, y + 14); + const totW = pdf.getTextWidth("TOTAL"); + pdf.text("TOTAL", colAmtX - totW, y + 14); + y += 22; }; - // ===== Per-case groups ===== - for (const group of input.groups) { - ensure(60); - // Case header bar - pdf.setFillColor(...accent); - pdf.rect(MARGIN, y - 10, CONTENT_W, 22, "F"); - setFont(pdf, { bold: true, size: 11, color: [255, 255, 255] }); - pdf.text(group.caseTitle, MARGIN + 8, y + 4); - const caseMeta = [group.caseNumber, group.practiceArea].filter(Boolean).join(" · "); - if (caseMeta) { - setFont(pdf, { size: 9, color: [220, 228, 240] }); - const w = pdf.getTextWidth(caseMeta); - pdf.text(caseMeta, PAGE_W - MARGIN - 8 - w, y + 4); + 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 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; + pdf.setDrawColor(...RULE); + pdf.setLineWidth(0.4); + pdf.line(MARGIN, y, PAGE_W - MARGIN, y); + } + + y += 20; + + // ===== ADJUSTMENTS section ===== + const adjustments = input.totals.adjustments ?? []; + if (input.totals.paid > 0 || adjustments.length > 0) { + ensure(80); + setFont(pdf, { bold: true, size: 9, color: TEXT }); + pdf.text("ADJUSTMENTS", MARGIN, y); + y += 10; + + pdf.setFillColor(...HEADER_BG); + pdf.rect(MARGIN, y, CONTENT_W, 22, "F"); + setFont(pdf, { bold: true, size: 8, color: TEXT }); + pdf.text("DESCRIPTION", colDateX, y + 14); + const amtH = pdf.getTextWidth("AMOUNT"); + pdf.text("AMOUNT", colAmtX - amtH, y + 14); y += 22; - drawColHeaders(); - - setFont(pdf, { size: 9, color: [30, 34, 44] }); - for (const item of group.items) { - const descMaxW = colBillX - colDescX - 8; - const descLines = wrap(pdf, item.description || (item.kind === "expense" ? "Expense" : ""), descMaxW); - const lineHeight = 12; - const blockH = Math.max(lineHeight * descLines.length, 16); - ensure(blockH + 4); - - const isNonBillable = item.billable === false; - const bodyColor: [number, number, number] = isNonBillable ? muted : [30, 34, 44]; - - // Date - setFont(pdf, { size: 9, color: muted }); - pdf.text(item.work_date ? formatDate(item.work_date) : "—", colDateX + 2, y); - - // Staff initials - setFont(pdf, { size: 9, bold: true, color: accent }); - pdf.text(item.user_initials || "—", colStaffX, y); - - // Description - setFont(pdf, { size: 9, color: bodyColor, italic: isNonBillable }); - let dy = y; - for (const dl of descLines) { - pdf.text(dl, colDescX, dy); - dy += lineHeight; - } - - // Billable indicator - setFont(pdf, { size: 9, color: isNonBillable ? muted : [22, 122, 80], bold: !isNonBillable }); - pdf.text(isNonBillable ? "N/B" : "✓", colBillX, y); - - // Hrs / Qty - setFont(pdf, { size: 9, color: bodyColor }); - const qtyText = - item.kind === "time" || item.kind === "manual" - ? Number(item.quantity).toFixed(2) - : "1"; - pdf.text(qtyText, colHrsX, y); - - // Rate - pdf.text(fmtCurrency(item.rate), colRateX, y); - - // Amount (zero for non-billable) - const shownAmount = isNonBillable ? 0 : item.amount; - const amt = isNonBillable ? "—" : fmtCurrency(shownAmount); - const aw = pdf.getTextWidth(amt); - pdf.text(amt, colAmtX - aw, y); - - y += blockH + 4; - pdf.setDrawColor(...rule); + const drawAdj = (desc: string, amount: number) => { + ensure(20); + setFont(pdf, { size: 9, color: TEXT }); + pdf.text(desc, colDateX, y + 12); + const txt = amount < 0 ? `−${fmtCurrency(Math.abs(amount))}` : fmtCurrency(amount); + const w = pdf.getTextWidth(txt); + pdf.text(txt, colAmtX - w, y + 12); + y += 20; + pdf.setDrawColor(...RULE); pdf.setLineWidth(0.4); - pdf.line(MARGIN, y - 2, PAGE_W - MARGIN, y - 2); - } + pdf.line(MARGIN, y, PAGE_W - MARGIN, y); + }; - // Case subtotal (billable only) - ensure(20); - setFont(pdf, { bold: true, size: 9.5, color: accent }); - const labelTxt = `${group.caseNumber} subtotal`; - pdf.text(labelTxt, colRateX - 40, y + 6); - const subText = fmtCurrency(group.subtotal); - const sw = pdf.getTextWidth(subText); - pdf.text(subText, colAmtX - sw, y + 6); - y += 22; + for (const a of adjustments) drawAdj(a.description, a.amount); + if (input.totals.paid > 0) drawAdj("Client Funds Applied", -input.totals.paid); } - // ===== Totals ===== - ensure(110); - y += 8; - pdf.setDrawColor(...accent); - pdf.setLineWidth(1); - pdf.line(PAGE_W - MARGIN - 240, y, PAGE_W - MARGIN, y); - y += 14; + // ===== Totals (right-aligned card, no shading, just rules) ===== + ensure(140); + y += 18; - const drawTotalRow = (label: string, value: string, opts?: { bold?: boolean; size?: number; color?: [number, number, number] }) => { - setFont(pdf, { size: opts?.size ?? 10, bold: opts?.bold, color: opts?.color ?? [30, 34, 44] }); - pdf.text(label, PAGE_W - MARGIN - 240, y); + const totalsX = PAGE_W - MARGIN - 260; + const totalsW = 260; + + const drawTotalRow = ( + label: string, + value: string, + opts?: { bold?: boolean; size?: number; color?: [number, number, number]; emphasize?: boolean }, + ) => { + setFont(pdf, { size: opts?.size ?? 10, bold: opts?.bold, color: opts?.color ?? TEXT }); + pdf.text(label, totalsX + 10, y + 12); const w = pdf.getTextWidth(value); - pdf.text(value, PAGE_W - MARGIN - w, y); - y += 16; + pdf.text(value, totalsX + totalsW - 10 - w, y + 12); + y += 22; + pdf.setDrawColor(...RULE); + pdf.setLineWidth(0.4); + pdf.line(totalsX, y, totalsX + totalsW, y); }; - drawTotalRow("Subtotal", fmtCurrency(input.totals.subtotal)); - if (input.totals.tax > 0) drawTotalRow("Tax", fmtCurrency(input.totals.tax)); - drawTotalRow("Total", fmtCurrency(input.totals.total), { bold: true, size: 11, color: accent }); - if (input.totals.paid > 0) { - drawTotalRow("Amount paid", `- ${fmtCurrency(input.totals.paid)}`, { color: muted }); - pdf.setDrawColor(...accent); - pdf.setLineWidth(0.6); - pdf.line(PAGE_W - MARGIN - 240, y - 8, PAGE_W - MARGIN, y - 8); - drawTotalRow("Balance due", fmtCurrency(input.totals.balance), { bold: true, size: 12, color: accent }); + // Top rule + pdf.setDrawColor(...RULE); + pdf.setLineWidth(0.4); + pdf.line(totalsX, y, totalsX + totalsW, y); + + drawTotalRow("Fees & Expenses", fmtCurrency(input.totals.feesAndExpenses ?? input.totals.subtotal)); + if (input.totals.alternateFees && input.totals.alternateFees > 0) { + drawTotalRow("Alternate Fees", fmtCurrency(input.totals.alternateFees)); } + if (input.totals.tax > 0) { + const ratePart = input.totals.taxRatePct ? ` (${input.totals.taxRatePct.toFixed(2)}%)` : ""; + drawTotalRow(`Tax: State${ratePart}`, fmtCurrency(input.totals.tax)); + } + if (input.totals.paid > 0) { + drawTotalRow("Funds Applied", `−${fmtCurrency(input.totals.paid)}`); + } + drawTotalRow("Total", fmtCurrency(input.totals.balance > 0 ? input.totals.balance : input.totals.total), { + bold: true, + size: 12, + color: TEXT, + }); // ===== Notes ===== if (input.invoice.notes) { ensure(40); - y += 10; - setFont(pdf, { bold: true, size: 9, color: accent }); + y += 18; + setFont(pdf, { bold: true, size: 9, color: TEXT }); pdf.text("NOTES", MARGIN, y); y += 12; - setFont(pdf, { size: 9, color: [40, 44, 54] }); + setFont(pdf, { size: 9, color: TEXT }); const lines = wrap(pdf, input.invoice.notes, CONTENT_W); for (const l of lines) { ensure(12); @@ -371,7 +519,7 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin const total = pdf.getNumberOfPages(); for (let i = 1; i <= total; i++) { pdf.setPage(i); - setFont(pdf, { size: 8, color: muted }); + setFont(pdf, { size: 8, color: MUTED }); const pageTxt = `Page ${i} of ${total}`; const w = pdf.getTextWidth(pageTxt); pdf.text(pageTxt, PAGE_W - MARGIN - w, PAGE_H - 22); @@ -381,13 +529,12 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin } function drawPageFooter(pdf: jsPDF, input: InvoicePdfInput) { - const muted: [number, number, number] = [110, 116, 128]; - pdf.setDrawColor(220, 224, 232); + pdf.setDrawColor(...RULE); pdf.setLineWidth(0.5); pdf.line(MARGIN, PAGE_H - 36, PAGE_W - MARGIN, PAGE_H - 36); pdf.setFont("helvetica", "italic"); pdf.setFontSize(8); - pdf.setTextColor(...muted); + pdf.setTextColor(...MUTED); const note = input.firm.footerNote || `Thank you for your business. Please remit payment to ${input.firm.name || "us"}.`; pdf.text(note, MARGIN, PAGE_H - 22); } From 856cabaf34c22ee9edf255ec9fb7abab26277088 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:11:12 +0000 Subject: [PATCH 2/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/invoices.$invoiceId.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/invoices.$invoiceId.tsx b/src/routes/invoices.$invoiceId.tsx index 1471ea7..9e4c6ef 100644 --- a/src/routes/invoices.$invoiceId.tsx +++ b/src/routes/invoices.$invoiceId.tsx @@ -206,6 +206,7 @@ function InvoiceDetail() { dueDate: invoice.due_date, status: invoice.status, notes: invoice.notes, + matter: groups.length === 1 ? (groups[0].caseRow?.title ?? null) : null, }, groups: groups.map((g) => ({ caseNumber: g.caseRow?.case_number ?? "—", From 05af1a3aff71e93c0d8f331fd0aba59ccb70f1f9 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:11:21 +0000 Subject: [PATCH 3/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/invoices.$invoiceId.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/routes/invoices.$invoiceId.tsx b/src/routes/invoices.$invoiceId.tsx index 9e4c6ef..d12784c 100644 --- a/src/routes/invoices.$invoiceId.tsx +++ b/src/routes/invoices.$invoiceId.tsx @@ -245,6 +245,11 @@ function InvoiceDetail() { total: Number(invoice.total), paid: Number(invoice.amount_paid), balance, + taxRatePct: + Number(invoice.subtotal) > 0 + ? Math.round((Number(invoice.tax) / Number(invoice.subtotal)) * 10000) / 100 + : 0, + feesAndExpenses: Number(invoice.subtotal), }, }; await downloadInvoicePdf(input, `${invoice.invoice_number}.pdf`);