Fixed logo sizing and PDF lines
X-Lovable-Edit-ID: edt-5bbdd0e5-c94a-49c7-86aa-1928a1bd78bb Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -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
@@ -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`;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user