Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 03:03:21 +00:00
co-authored by renee-png
parent 28724b6de0
commit ae1d84162e
2 changed files with 108 additions and 35 deletions
+24 -10
View File
@@ -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<GenerateInvoiceResult> {
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);
+84 -25
View File
@@ -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`;