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:
+91
-63
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user