From ae1d84162ebdac14479de00e1b3db30b02b2329d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 03:03:21 +0000 Subject: [PATCH 1/2] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/invoice-generation.ts | 34 +++++++---- src/lib/invoice-pdf.ts | 109 ++++++++++++++++++++++++++-------- 2 files changed, 108 insertions(+), 35 deletions(-) diff --git a/src/lib/invoice-generation.ts b/src/lib/invoice-generation.ts index 6e5d8fe..448efc6 100644 --- a/src/lib/invoice-generation.ts +++ b/src/lib/invoice-generation.ts @@ -15,25 +15,31 @@ export interface GenerateInvoiceResult { invoiceNumber: string; } +// Round hours to the nearest 0.6 (per firm policy: tenths of an hour rounded +// up to the nearest 6-minute increment, capped at a 0.6h granularity). +export function roundHoursToSixTenths(hours: number): number { + const n = Math.max(0, Number(hours) || 0); + return +(Math.round(n / 0.6) * 0.6).toFixed(2); +} + export async function generateInvoiceForClient(args: GenerateInvoiceArgs): Promise { const { clientId, caseIds, createdBy } = args; const taxRate = args.taxRate ?? 0; const dueDays = args.dueDays ?? 30; if (caseIds.length === 0) throw new Error("No cases selected"); - // Pull unbilled time + expenses for the selected cases + // Pull ALL unbilled time + expenses for the selected cases (billable AND non-billable). + // Non-billable rows are listed for transparency at $0 and do not affect totals. const [{ data: time, error: te }, { data: exp, error: ee }] = await Promise.all([ supabase .from("time_entries") .select("id, case_id, work_date, hours, hourly_rate, description, user_id, billable, invoice_id") .in("case_id", caseIds) - .eq("billable", true) .is("invoice_id", null), supabase .from("expenses") .select("id, case_id, expense_date, amount, description, user_id, billable, invoice_id") .in("case_id", caseIds) - .eq("billable", true) .is("invoice_id", null), ]); if (te) throw te; @@ -44,8 +50,10 @@ export async function generateInvoiceForClient(args: GenerateInvoiceArgs): Promi } const subtotal = - (time ?? []).reduce((s, t) => s + Number(t.hours) * Number(t.hourly_rate), 0) + - (exp ?? []).reduce((s, e) => s + Number(e.amount), 0); + (time ?? []) + .filter((t) => t.billable) + .reduce((s, t) => s + roundHoursToSixTenths(Number(t.hours)) * Number(t.hourly_rate), 0) + + (exp ?? []).filter((e) => e.billable).reduce((s, e) => s + Number(e.amount), 0); const tax = +(subtotal * taxRate).toFixed(2); const total = +(subtotal + tax).toFixed(2); @@ -74,7 +82,10 @@ export async function generateInvoiceForClient(args: GenerateInvoiceArgs): Promi .single(); if (ie || !inv) throw ie ?? new Error("Failed to create invoice"); - // Build line items grouped per case in case order + // Build line items grouped per case in case order. + // Time entries are stored at their rounded (0.6h) quantity; non-billable rows + // are saved with amount = 0 so they appear on the invoice for transparency + // without affecting the subtotal. const lineItems: any[] = []; let order = 0; for (const cid of caseIds) { @@ -85,14 +96,15 @@ export async function generateInvoiceForClient(args: GenerateInvoiceArgs): Promi .filter((e) => e.case_id === cid) .sort((a, b) => (a.expense_date < b.expense_date ? -1 : 1)); for (const t of ts) { - const amount = +(Number(t.hours) * Number(t.hourly_rate)).toFixed(2); + const qty = roundHoursToSixTenths(Number(t.hours)); + const amount = t.billable ? +(qty * Number(t.hourly_rate)).toFixed(2) : 0; lineItems.push({ invoice_id: inv.id, case_id: cid, kind: "time", description: t.description, work_date: t.work_date, - quantity: t.hours, + quantity: qty, rate: t.hourly_rate, amount, time_entry_id: t.id, @@ -101,6 +113,7 @@ export async function generateInvoiceForClient(args: GenerateInvoiceArgs): Promi }); } for (const e of es) { + const amount = e.billable ? Number(e.amount) : 0; lineItems.push({ invoice_id: inv.id, case_id: cid, @@ -109,7 +122,7 @@ export async function generateInvoiceForClient(args: GenerateInvoiceArgs): Promi work_date: e.expense_date, quantity: 1, rate: e.amount, - amount: e.amount, + amount, expense_id: e.id, user_id: e.user_id, sort_order: order++, @@ -121,7 +134,8 @@ export async function generateInvoiceForClient(args: GenerateInvoiceArgs): Promi if (lierr) throw lierr; } - // Mark source records as billed + // Mark ALL source records (billable + non-billable) as billed so they don't + // re-appear on the next invoice. const timeIds = (time ?? []).map((t) => t.id); const expIds = (exp ?? []).map((e) => e.id); if (timeIds.length) await supabase.from("time_entries").update({ invoice_id: inv.id }).in("id", timeIds); diff --git a/src/lib/invoice-pdf.ts b/src/lib/invoice-pdf.ts index 0e22872..a7b1368 100644 --- a/src/lib/invoice-pdf.ts +++ b/src/lib/invoice-pdf.ts @@ -14,6 +14,8 @@ export interface InvoiceLineItem { rate: number; amount: number; user_name?: string | null; + user_initials?: string | null; + billable?: boolean; } export interface InvoiceCaseGroup { @@ -83,6 +85,24 @@ function wrap(pdf: jsPDF, text: string, maxWidth: number): string[] { return pdf.splitTextToSize(text, maxWidth) as string[]; } +function detectImageFormat(dataUrl: string): "PNG" | "JPEG" | "WEBP" { + const m = /^data:image\/(png|jpe?g|webp)/i.exec(dataUrl); + if (!m) return "PNG"; + const t = m[1].toLowerCase(); + if (t === "jpg" || t === "jpeg") return "JPEG"; + if (t === "webp") return "WEBP"; + return "PNG"; +} + +async function imageNaturalSize(dataUrl: string): Promise<{ w: number; h: number } | null> { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve({ w: img.naturalWidth || img.width, h: img.naturalHeight || img.height }); + img.onerror = () => resolve(null); + img.src = dataUrl; + }); +} + 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 @@ -99,15 +119,37 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin }; // ===== Header band ===== - // Firm name + address (left) + // Logo: preserve aspect ratio, fit within a max box, no stretching/cropping. + let logoBoxW = 0; + let logoBoxH = 0; if (input.firm.logoDataUrl) { + const MAX_W = 180; + const MAX_H = 80; + const size = await imageNaturalSize(input.firm.logoDataUrl); + 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; + } try { - pdf.addImage(input.firm.logoDataUrl, "PNG", MARGIN, y, 110, 44, undefined, "FAST"); + pdf.addImage( + input.firm.logoDataUrl, + detectImageFormat(input.firm.logoDataUrl), + MARGIN, + y, + logoBoxW, + logoBoxH, + undefined, + "FAST", + ); } catch { - // ignore + // ignore — fall through to text-only header } } - const headerLeftX = input.firm.logoDataUrl ? MARGIN + 122 : MARGIN; + 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 }); @@ -123,6 +165,8 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin pdf.text(l, headerLeftX, 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 }); @@ -145,7 +189,7 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin if (input.invoice.dueDate) drawKV("Due date", formatDate(input.invoice.dueDate)); drawKV("Status", input.invoice.status.toUpperCase()); - y = Math.max(fy, iy) + 12; + y = Math.max(headerBottom, iy) + 12; // Accent rule pdf.setDrawColor(...accent); @@ -172,19 +216,24 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin } y = by + 16; - // ===== Column headers ===== + // ===== Column geometry ===== + // DATE | STAFF | DESCRIPTION | BILL | HRS | RATE | AMOUNT const colDateX = MARGIN; - const colDescX = MARGIN + 64; - const colQtyX = MARGIN + 358; - const colRateX = MARGIN + 418; + 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 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("QTY/HRS", colQtyX, 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); @@ -211,39 +260,49 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin setFont(pdf, { size: 9, color: [30, 34, 44] }); for (const item of group.items) { - const descMaxW = colQtyX - colDescX - 8; + const descMaxW = colBillX - colDescX - 8; const descLines = wrap(pdf, item.description || (item.kind === "expense" ? "Expense" : ""), descMaxW); - const subLine = item.kind === "time" && item.user_name ? item.user_name : item.kind === "expense" ? "Expense" : ""; const lineHeight = 12; - const blockH = Math.max(lineHeight * descLines.length + (subLine ? 10 : 0), 16); + 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: [30, 34, 44] }); + setFont(pdf, { size: 9, color: bodyColor, italic: isNonBillable }); let dy = y; for (const dl of descLines) { pdf.text(dl, colDescX, dy); dy += lineHeight; } - if (subLine) { - setFont(pdf, { size: 8, color: muted, italic: true }); - pdf.text(subLine, colDescX, dy); - } - // Qty - setFont(pdf, { size: 9, color: [30, 34, 44] }); - const qtyText = item.kind === "time" ? Number(item.quantity).toFixed(2) : "1"; - pdf.text(qtyText, colQtyX, y); + // 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 - const amt = fmtCurrency(item.amount); + // 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); @@ -253,7 +312,7 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin pdf.line(MARGIN, y - 2, PAGE_W - MARGIN, y - 2); } - // Case subtotal + // Case subtotal (billable only) ensure(20); setFont(pdf, { bold: true, size: 9.5, color: accent }); const labelTxt = `${group.caseNumber} subtotal`; From 167b38401b206e92aa5b5dc956cd83bc43826d2d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 03:03:58 +0000 Subject: [PATCH 2/2] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/invoices.$invoiceId.tsx | 41 ++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/routes/invoices.$invoiceId.tsx b/src/routes/invoices.$invoiceId.tsx index 258fc1a..72ead90 100644 --- a/src/routes/invoices.$invoiceId.tsx +++ b/src/routes/invoices.$invoiceId.tsx @@ -70,7 +70,12 @@ function InvoiceDetail() { if (!map.has(key)) map.set(key, { caseRow: it.case, items: [], subtotal: 0 }); const g = map.get(key)!; g.items.push(it); - g.subtotal += Number(it.amount); + // 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); } return Array.from(map.values()); }, [items]); @@ -205,15 +210,31 @@ function InvoiceDetail() { caseTitle: g.caseRow?.title ?? "(unassigned)", practiceArea: g.caseRow?.practice_area, subtotal: g.subtotal, - items: g.items.map((it) => ({ - kind: it.kind, - description: it.description, - work_date: it.work_date, - quantity: Number(it.quantity), - rate: Number(it.rate), - amount: Number(it.amount), - user_name: it.user?.full_name || it.user?.email, - })), + items: g.items.map((it) => { + const name: string = + it.user?.full_name || it.user?.email || ""; + const initials = name + .trim() + .split(/\s+/) + .map((p: string) => p[0]) + .filter(Boolean) + .slice(0, 3) + .join("") + .toUpperCase(); + const isNonBillable = + Number(it.amount) === 0 && (it.time_entry_id || it.expense_id); + return { + kind: it.kind, + description: it.description, + work_date: it.work_date, + quantity: Number(it.quantity), + rate: Number(it.rate), + amount: Number(it.amount), + user_name: name || null, + user_initials: initials || null, + billable: !isNonBillable, + }; + }), })), totals: { subtotal: Number(invoice.subtotal),