Added subsection grouping

X-Lovable-Edit-ID: edt-b9b59457-4d13-43f0-912a-0a05a9445421
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 01:15:35 +00:00
co-authored by renee-png
2 changed files with 201 additions and 118 deletions
+91 -63
View File
@@ -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 {
@@ -369,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
+110 -55
View File
@@ -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";
@@ -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<string, { caseRow: any; items: any[]; subtotal: number }>();
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]);
@@ -209,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+/)
@@ -225,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,
@@ -238,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),
@@ -345,41 +385,56 @@ function InvoiceDetail() {
</tr>
</thead>
<tbody>
{g.items.map((it) => (
<tr key={it.id} className="border-t">
<td className="px-3 py-2 text-muted-foreground text-xs">{it.work_date ? formatDate(it.work_date) : "—"}</td>
<td className="px-3 py-2">
{canEdit && isDraft ? (
<Input className="h-8" defaultValue={it.description}
onBlur={(e) => e.target.value !== it.description && updateItem(it.id, { description: e.target.value })} />
) : (
<div>
<div>{it.description}</div>
{it.user?.full_name && <div className="text-[11px] text-muted-foreground italic">{it.user.full_name}</div>}
</div>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{canEdit && isDraft ? (
<Input className="h-8 text-right tabular-nums" defaultValue={Number(it.quantity)}
onBlur={(e) => Number(e.target.value) !== Number(it.quantity) && updateItem(it.id, { quantity: Number(e.target.value) })} />
) : Number(it.quantity).toFixed(2)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{canEdit && isDraft ? (
<Input className="h-8 text-right tabular-nums" defaultValue={Number(it.rate)}
onBlur={(e) => Number(e.target.value) !== Number(it.rate) && updateItem(it.id, { rate: Number(e.target.value) })} />
) : formatCurrency(it.rate)}
</td>
<td className="px-3 py-2 text-right tabular-nums font-medium">{formatCurrency(it.amount)}</td>
{canEdit && isDraft && (
<td className="px-2 py-2 text-right">
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => removeItem(it.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
{g.subsections.map((sub) => (
<Fragment key={sub.key}>
<tr className="bg-muted/20 border-t">
<td colSpan={4} className="px-3 py-1.5 text-[11px] uppercase tracking-wider font-semibold text-muted-foreground">
{sub.label}
</td>
)}
</tr>
<td className="px-3 py-1.5 text-right tabular-nums text-xs font-semibold text-muted-foreground">
{sub.billable ? formatCurrency(sub.subtotal) : "—"}
</td>
{canEdit && isDraft && <td />}
</tr>
{sub.items.map((it) => (
<tr key={it.id} className={`border-t ${!sub.billable ? "text-muted-foreground" : ""}`}>
<td className="px-3 py-2 text-xs">{it.work_date ? formatDate(it.work_date) : "—"}</td>
<td className="px-3 py-2">
{canEdit && isDraft ? (
<Input className="h-8" defaultValue={it.description}
onBlur={(e) => e.target.value !== it.description && updateItem(it.id, { description: e.target.value })} />
) : (
<div>
<div className={!sub.billable ? "italic" : ""}>{it.description}</div>
{it.user?.full_name && <div className="text-[11px] text-muted-foreground italic">{it.user.full_name}</div>}
</div>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{canEdit && isDraft ? (
<Input className="h-8 text-right tabular-nums" defaultValue={Number(it.quantity)}
onBlur={(e) => Number(e.target.value) !== Number(it.quantity) && updateItem(it.id, { quantity: Number(e.target.value) })} />
) : Number(it.quantity).toFixed(2)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{canEdit && isDraft ? (
<Input className="h-8 text-right tabular-nums" defaultValue={Number(it.rate)}
onBlur={(e) => Number(e.target.value) !== Number(it.rate) && updateItem(it.id, { rate: Number(e.target.value) })} />
) : formatCurrency(it.rate)}
</td>
<td className="px-3 py-2 text-right tabular-nums font-medium">
{sub.billable ? formatCurrency(it.amount) : "—"}
</td>
{canEdit && isDraft && (
<td className="px-2 py-2 text-right">
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => removeItem(it.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
)}
</tr>
))}
</Fragment>
))}
</tbody>
</table>