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] 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]);