From 5a37fbbd54d1bd736e13caf5e560c20d2b6183ec Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:13:09 +0000 Subject: [PATCH 1/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/invoices.$invoiceId.tsx | 48 +++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/src/routes/invoices.$invoiceId.tsx b/src/routes/invoices.$invoiceId.tsx index 83bea10..adee1dd 100644 --- a/src/routes/invoices.$invoiceId.tsx +++ b/src/routes/invoices.$invoiceId.tsx @@ -65,20 +65,52 @@ function InvoiceDetail() { useEffect(() => { load(); }, [invoiceId]); + // Subsection keys & labels — order matters for display. + const SUBSECTIONS: { key: string; label: string; isExpense: boolean; billable: boolean }[] = [ + { key: "bill_fee", label: "Billable Fees", isExpense: false, billable: true }, + { key: "bill_exp", label: "Billable Expenses", isExpense: true, billable: true }, + { key: "nb_fee", label: "Non-Billable Fees", isExpense: false, billable: false }, + { key: "nb_exp", label: "Non-Billable Expenses", isExpense: true, billable: false }, + ]; + + const classifyItem = (it: any): string => { + const isExpense = it.kind === "expense"; + const isNonBillable = Number(it.amount) === 0 && (it.time_entry_id || it.expense_id); + if (isExpense) return isNonBillable ? "nb_exp" : "bill_exp"; + return isNonBillable ? "nb_fee" : "bill_fee"; + }; + const groups = useMemo(() => { - const map = new Map(); + const map = new Map< + string, + { + caseRow: any; + items: any[]; + subtotal: number; + subsections: { key: string; label: string; billable: boolean; items: any[]; subtotal: number }[]; + } + >(); for (const it of items) { const key = it.case?.id ?? "_none"; - if (!map.has(key)) map.set(key, { caseRow: it.case, items: [], subtotal: 0 }); + if (!map.has(key)) { + map.set(key, { + caseRow: it.case, + items: [], + subtotal: 0, + subsections: SUBSECTIONS.map((s) => ({ key: s.key, label: s.label, billable: s.billable, items: [], subtotal: 0 })), + }); + } const g = map.get(key)!; g.items.push(it); - // Only billable rows contribute to the case subtotal. A line is - // non-billable when it references a source time/expense entry but its - // amount is zero (the generator stores it that way for transparency). - const isNonBillable = - Number(it.amount) === 0 && (it.time_entry_id || it.expense_id); - if (!isNonBillable) g.subtotal += Number(it.amount); + const subKey = classifyItem(it); + const sub = g.subsections.find((s) => s.key === subKey)!; + sub.items.push(it); + const amt = Number(it.amount); + sub.subtotal += amt; + if (sub.billable) g.subtotal += amt; } + // Drop empty subsections + for (const g of map.values()) g.subsections = g.subsections.filter((s) => s.items.length > 0); return Array.from(map.values()); }, [items]); From 77afa0f788255ca03563abd2f05c14e17f9d4ebd Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:13:45 +0000 Subject: [PATCH 2/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/invoices.$invoiceId.tsx | 83 ++++++++++++++++++------------ 1 file changed, 49 insertions(+), 34 deletions(-) diff --git a/src/routes/invoices.$invoiceId.tsx b/src/routes/invoices.$invoiceId.tsx index adee1dd..d3dbe86 100644 --- a/src/routes/invoices.$invoiceId.tsx +++ b/src/routes/invoices.$invoiceId.tsx @@ -377,41 +377,56 @@ function InvoiceDetail() { - {g.items.map((it) => ( - - {it.work_date ? formatDate(it.work_date) : "—"} - - {canEdit && isDraft ? ( - e.target.value !== it.description && updateItem(it.id, { description: e.target.value })} /> - ) : ( -
-
{it.description}
- {it.user?.full_name &&
{it.user.full_name}
} -
- )} - - - {canEdit && isDraft ? ( - Number(e.target.value) !== Number(it.quantity) && updateItem(it.id, { quantity: Number(e.target.value) })} /> - ) : Number(it.quantity).toFixed(2)} - - - {canEdit && isDraft ? ( - Number(e.target.value) !== Number(it.rate) && updateItem(it.id, { rate: Number(e.target.value) })} /> - ) : formatCurrency(it.rate)} - - {formatCurrency(it.amount)} - {canEdit && isDraft && ( - - + {g.subsections.map((sub) => ( + + + + {sub.label} - )} - + + {sub.billable ? formatCurrency(sub.subtotal) : "—"} + + {canEdit && isDraft && } + + {sub.items.map((it) => ( + + {it.work_date ? formatDate(it.work_date) : "—"} + + {canEdit && isDraft ? ( + e.target.value !== it.description && updateItem(it.id, { description: e.target.value })} /> + ) : ( +
+
{it.description}
+ {it.user?.full_name &&
{it.user.full_name}
} +
+ )} + + + {canEdit && isDraft ? ( + Number(e.target.value) !== Number(it.quantity) && updateItem(it.id, { quantity: Number(e.target.value) })} /> + ) : Number(it.quantity).toFixed(2)} + + + {canEdit && isDraft ? ( + Number(e.target.value) !== Number(it.rate) && updateItem(it.id, { rate: Number(e.target.value) })} /> + ) : formatCurrency(it.rate)} + + + {sub.billable ? formatCurrency(it.amount) : "—"} + + {canEdit && isDraft && ( + + + + )} + + ))} +
))} From e29caa4980878c2ce9d9f6252d7520d44864ed3b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:13:56 +0000 Subject: [PATCH 3/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/invoices.$invoiceId.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/invoices.$invoiceId.tsx b/src/routes/invoices.$invoiceId.tsx index d3dbe86..368717f 100644 --- a/src/routes/invoices.$invoiceId.tsx +++ b/src/routes/invoices.$invoiceId.tsx @@ -1,5 +1,5 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState, useMemo, Fragment } from "react"; import { ProtectedLayout } from "@/components/protected-layout"; import { PageContainer } from "@/components/app-shell"; import { Button } from "@/components/ui/button"; From bd2124174debc4e4f1a9e7354ff29a189f02b238 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:14:05 +0000 Subject: [PATCH 4/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/invoices.$invoiceId.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/invoices.$invoiceId.tsx b/src/routes/invoices.$invoiceId.tsx index 368717f..b9341fa 100644 --- a/src/routes/invoices.$invoiceId.tsx +++ b/src/routes/invoices.$invoiceId.tsx @@ -378,7 +378,7 @@ function InvoiceDetail() { {g.subsections.map((sub) => ( - + {sub.label} @@ -426,7 +426,7 @@ function InvoiceDetail() { )} ))} - + ))} From baadbbfa26fe7a0738fdbf78947fcabdfaf9c90a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:14:17 +0000 Subject: [PATCH 5/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/invoice-pdf.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/lib/invoice-pdf.ts b/src/lib/invoice-pdf.ts index b4bc7ad..7c3a953 100644 --- a/src/lib/invoice-pdf.ts +++ b/src/lib/invoice-pdf.ts @@ -18,12 +18,23 @@ export interface InvoiceLineItem { billable?: boolean; } +export interface InvoiceSubsection { + key: string; + label: string; + billable: boolean; + items: InvoiceLineItem[]; + subtotal: number; +} + export interface InvoiceCaseGroup { caseNumber: string; caseTitle: string; practiceArea?: string | null; items: InvoiceLineItem[]; subtotal: number; + /** Optional subsections (Billable Fees, Billable Expenses, Non-Billable Fees, Non-Billable Expenses). + * When provided, the renderer groups items by subsection with a header and subtotal. */ + subsections?: InvoiceSubsection[]; } export interface InvoicePdfInput { From 1393c7e50e3293a32a6b66fd55634e34f6355518 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:14:48 +0000 Subject: [PATCH 6/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/invoice-pdf.ts | 143 +++++++++++++++++++++++------------------ 1 file changed, 80 insertions(+), 63 deletions(-) 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 From d67de10317647540eca1235bda2c8aeab65fc120 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:15:02 +0000 Subject: [PATCH 7/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/invoices.$invoiceId.tsx | 32 +++++++++++++++++++----------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/routes/invoices.$invoiceId.tsx b/src/routes/invoices.$invoiceId.tsx index b9341fa..7802443 100644 --- a/src/routes/invoices.$invoiceId.tsx +++ b/src/routes/invoices.$invoiceId.tsx @@ -241,14 +241,9 @@ function InvoiceDetail() { matter: groups.length === 1 ? (groups[0].caseRow?.title ?? null) : null, caseNumber: groups.length === 1 ? (groups[0].caseRow?.case_number ?? null) : null, }, - groups: groups.map((g) => ({ - caseNumber: g.caseRow?.case_number ?? "—", - caseTitle: g.caseRow?.title ?? "(unassigned)", - practiceArea: g.caseRow?.practice_area, - subtotal: g.subtotal, - items: g.items.map((it) => { - const name: string = - it.user?.full_name || it.user?.email || ""; + groups: groups.map((g) => { + const mapItem = (it: any) => { + const name: string = it.user?.full_name || it.user?.email || ""; const initials = name .trim() .split(/\s+/) @@ -257,8 +252,7 @@ function InvoiceDetail() { .slice(0, 3) .join("") .toUpperCase(); - const isNonBillable = - Number(it.amount) === 0 && (it.time_entry_id || it.expense_id); + const isNonBillable = Number(it.amount) === 0 && (it.time_entry_id || it.expense_id); return { kind: it.kind, description: it.description, @@ -270,8 +264,22 @@ function InvoiceDetail() { user_initials: initials || null, billable: !isNonBillable, }; - }), - })), + }; + return { + caseNumber: g.caseRow?.case_number ?? "—", + caseTitle: g.caseRow?.title ?? "(unassigned)", + practiceArea: g.caseRow?.practice_area, + subtotal: g.subtotal, + items: g.items.map(mapItem), + subsections: g.subsections.map((s) => ({ + key: s.key, + label: s.label, + billable: s.billable, + subtotal: s.subtotal, + items: s.items.map(mapItem), + })), + }; + }), totals: { subtotal: Number(invoice.subtotal), tax: Number(invoice.tax),