diff --git a/src/lib/invoice-generation.ts b/src/lib/invoice-generation.ts new file mode 100644 index 0000000..6e5d8fe --- /dev/null +++ b/src/lib/invoice-generation.ts @@ -0,0 +1,157 @@ +import { supabase } from "@/integrations/supabase/client"; + +export interface GenerateInvoiceArgs { + clientId: string; + caseIds: string[]; // restrict billing to these cases (must belong to client) + createdBy: string; + invoicePrefix?: string; + taxRate?: number; // e.g. 0.07 + notes?: string; + dueDays?: number; +} + +export interface GenerateInvoiceResult { + invoiceId: string; + invoiceNumber: string; +} + +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 + 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; + if (ee) throw ee; + + if ((time?.length ?? 0) === 0 && (exp?.length ?? 0) === 0) { + throw new Error("No unbilled time or expenses on the selected cases"); + } + + const subtotal = + (time ?? []).reduce((s, t) => s + Number(t.hours) * Number(t.hourly_rate), 0) + + (exp ?? []).reduce((s, e) => s + Number(e.amount), 0); + const tax = +(subtotal * taxRate).toFixed(2); + const total = +(subtotal + tax).toFixed(2); + + const yr = new Date().getFullYear(); + const prefix = args.invoicePrefix?.trim() || "INV"; + const num = `${prefix}-${yr}-${Math.floor(1000 + Math.random() * 9000)}`; + const due = new Date(); + due.setDate(due.getDate() + dueDays); + + const { data: inv, error: ie } = await supabase + .from("invoices") + .insert({ + invoice_number: num, + client_id: clientId, + case_id: caseIds.length === 1 ? caseIds[0] : null, + status: "draft", + issue_date: new Date().toISOString().slice(0, 10), + due_date: due.toISOString().slice(0, 10), + subtotal, + tax, + total, + notes: args.notes ?? null, + created_by: createdBy, + }) + .select("id, invoice_number") + .single(); + if (ie || !inv) throw ie ?? new Error("Failed to create invoice"); + + // Build line items grouped per case in case order + const lineItems: any[] = []; + let order = 0; + for (const cid of caseIds) { + const ts = (time ?? []) + .filter((t) => t.case_id === cid) + .sort((a, b) => (a.work_date < b.work_date ? -1 : 1)); + const es = (exp ?? []) + .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); + lineItems.push({ + invoice_id: inv.id, + case_id: cid, + kind: "time", + description: t.description, + work_date: t.work_date, + quantity: t.hours, + rate: t.hourly_rate, + amount, + time_entry_id: t.id, + user_id: t.user_id, + sort_order: order++, + }); + } + for (const e of es) { + lineItems.push({ + invoice_id: inv.id, + case_id: cid, + kind: "expense", + description: e.description, + work_date: e.expense_date, + quantity: 1, + rate: e.amount, + amount: e.amount, + expense_id: e.id, + user_id: e.user_id, + sort_order: order++, + }); + } + } + if (lineItems.length) { + const { error: lierr } = await supabase.from("invoice_line_items").insert(lineItems); + if (lierr) throw lierr; + } + + // Mark source records as billed + 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); + if (expIds.length) await supabase.from("expenses").update({ invoice_id: inv.id }).in("id", expIds); + + return { invoiceId: inv.id, invoiceNumber: inv.invoice_number }; +} + +export async function recalcInvoiceTotals(invoiceId: string, taxRate?: number) { + const { data: items } = await supabase + .from("invoice_line_items") + .select("amount") + .eq("invoice_id", invoiceId); + const subtotal = (items ?? []).reduce((s, l: any) => s + Number(l.amount), 0); + let tax = 0; + if (taxRate != null) { + tax = +(subtotal * taxRate).toFixed(2); + } else { + // Preserve existing tax ratio if present + const { data: inv } = await supabase + .from("invoices") + .select("subtotal, tax") + .eq("id", invoiceId) + .maybeSingle(); + if (inv && Number(inv.subtotal) > 0) { + const ratio = Number(inv.tax) / Number(inv.subtotal); + tax = +(subtotal * ratio).toFixed(2); + } + } + const total = +(subtotal + tax).toFixed(2); + await supabase.from("invoices").update({ subtotal, tax, total }).eq("id", invoiceId); + return { subtotal, tax, total }; +} diff --git a/src/lib/invoice-pdf.ts b/src/lib/invoice-pdf.ts new file mode 100644 index 0000000..0e22872 --- /dev/null +++ b/src/lib/invoice-pdf.ts @@ -0,0 +1,334 @@ +import jsPDF from "jspdf"; +import { formatDate } from "./format"; + +const PAGE_W = 612; +const PAGE_H = 792; +const MARGIN = 54; +const CONTENT_W = PAGE_W - MARGIN * 2; + +export interface InvoiceLineItem { + kind: string; + description: string; + work_date: string | null; + quantity: number; + rate: number; + amount: number; + user_name?: string | null; +} + +export interface InvoiceCaseGroup { + caseNumber: string; + caseTitle: string; + practiceArea?: string | null; + items: InvoiceLineItem[]; + subtotal: number; +} + +export interface InvoicePdfInput { + firm: { + name?: string | null; + address1?: string | null; + address2?: string | null; + city?: string | null; + state?: string | null; + postal?: string | null; + email?: string | null; + phone?: string | null; + website?: string | null; + footerNote?: string | null; + logoDataUrl?: string | null; + }; + client: { + name: string; + contact?: string | null; + address1?: string | null; + address2?: string | null; + city?: string | null; + state?: string | null; + postal?: string | null; + }; + invoice: { + number: string; + issueDate: string; + dueDate?: string | null; + status: string; + notes?: string | null; + }; + groups: InvoiceCaseGroup[]; + totals: { + subtotal: number; + tax: number; + total: number; + paid: number; + balance: number; + }; +} + +const fmtCurrency = (n: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n || 0); + +function setFont(pdf: jsPDF, opts: { bold?: boolean; italic?: boolean; size?: number; color?: [number, number, number] }) { + let style: "normal" | "bold" | "italic" | "bolditalic" = "normal"; + if (opts.bold && opts.italic) style = "bolditalic"; + else if (opts.bold) style = "bold"; + else if (opts.italic) style = "italic"; + pdf.setFont("helvetica", style); + pdf.setFontSize(opts.size ?? 10); + if (opts.color) pdf.setTextColor(...opts.color); + else pdf.setTextColor(20, 24, 32); +} + +function wrap(pdf: jsPDF, text: string, maxWidth: number): string[] { + if (!text) return [""]; + return pdf.splitTextToSize(text, maxWidth) as string[]; +} + +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 + const muted: [number, number, number] = [110, 116, 128]; + const rule: [number, number, number] = [220, 224, 232]; + + let y = MARGIN; + const ensure = (need: number) => { + if (y + need > PAGE_H - MARGIN - 40) { + drawPageFooter(pdf, input); + pdf.addPage(); + y = MARGIN; + } + }; + + // ===== Header band ===== + // Firm name + address (left) + if (input.firm.logoDataUrl) { + try { + pdf.addImage(input.firm.logoDataUrl, "PNG", MARGIN, y, 110, 44, undefined, "FAST"); + } catch { + // ignore + } + } + const headerLeftX = input.firm.logoDataUrl ? MARGIN + 122 : MARGIN; + setFont(pdf, { bold: true, size: 14, color: accent }); + pdf.text(input.firm.name || "Firm", headerLeftX, y + 14); + setFont(pdf, { size: 9, color: muted }); + let fy = y + 28; + const firmLines = [ + input.firm.address1, + input.firm.address2, + [input.firm.city, input.firm.state, input.firm.postal].filter(Boolean).join(", "), + [input.firm.phone, input.firm.email].filter(Boolean).join(" · "), + input.firm.website, + ].filter((l): l is string => !!l && l.trim().length > 0); + for (const l of firmLines) { + pdf.text(l, headerLeftX, fy); + fy += 11; + } + + // INVOICE block (right) + setFont(pdf, { bold: true, size: 22, color: accent }); + const titleW = pdf.getTextWidth("INVOICE"); + pdf.text("INVOICE", PAGE_W - MARGIN - titleW, y + 18); + setFont(pdf, { size: 9, color: muted }); + const labelX = PAGE_W - MARGIN - 150; + const valueX = PAGE_W - MARGIN; + let iy = y + 36; + const drawKV = (k: string, v: string) => { + setFont(pdf, { size: 9, color: muted }); + pdf.text(k, labelX, iy); + setFont(pdf, { size: 10, bold: true }); + const w = pdf.getTextWidth(v); + pdf.text(v, valueX - w, iy); + iy += 13; + }; + drawKV("Invoice #", input.invoice.number); + drawKV("Issue date", formatDate(input.invoice.issueDate)); + if (input.invoice.dueDate) drawKV("Due date", formatDate(input.invoice.dueDate)); + drawKV("Status", input.invoice.status.toUpperCase()); + + y = Math.max(fy, iy) + 12; + + // Accent rule + pdf.setDrawColor(...accent); + pdf.setLineWidth(1.2); + pdf.line(MARGIN, y, PAGE_W - MARGIN, y); + y += 18; + + // ===== Bill To ===== + setFont(pdf, { size: 8, color: muted }); + pdf.text("BILL TO", MARGIN, y); + setFont(pdf, { bold: true, size: 12, color: accent }); + pdf.text(input.client.name, MARGIN, y + 14); + setFont(pdf, { size: 9, color: muted }); + let by = y + 28; + const clientLines = [ + input.client.contact, + input.client.address1, + input.client.address2, + [input.client.city, input.client.state, input.client.postal].filter(Boolean).join(", "), + ].filter((l): l is string => !!l && l.trim().length > 0); + for (const l of clientLines) { + pdf.text(l, MARGIN, by); + by += 11; + } + y = by + 16; + + // ===== Column headers ===== + const colDateX = MARGIN; + const colDescX = MARGIN + 64; + const colQtyX = MARGIN + 358; + const colRateX = MARGIN + 418; + 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("DESCRIPTION", colDescX, y + 2); + pdf.text("QTY/HRS", colQtyX, y + 2); + pdf.text("RATE", colRateX, y + 2); + const w = pdf.getTextWidth("AMOUNT"); + pdf.text("AMOUNT", colAmtX - w, y + 2); + y += 16; + }; + + // ===== Per-case groups ===== + for (const group of input.groups) { + ensure(60); + // Case header bar + pdf.setFillColor(...accent); + pdf.rect(MARGIN, y - 10, CONTENT_W, 22, "F"); + setFont(pdf, { bold: true, size: 11, color: [255, 255, 255] }); + pdf.text(group.caseTitle, MARGIN + 8, y + 4); + const caseMeta = [group.caseNumber, group.practiceArea].filter(Boolean).join(" · "); + if (caseMeta) { + setFont(pdf, { size: 9, color: [220, 228, 240] }); + const w = pdf.getTextWidth(caseMeta); + pdf.text(caseMeta, PAGE_W - MARGIN - 8 - w, y + 4); + } + y += 22; + + drawColHeaders(); + + setFont(pdf, { size: 9, color: [30, 34, 44] }); + for (const item of group.items) { + const descMaxW = colQtyX - 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); + ensure(blockH + 4); + + // Date + setFont(pdf, { size: 9, color: muted }); + pdf.text(item.work_date ? formatDate(item.work_date) : "—", colDateX + 2, y); + + // Description + setFont(pdf, { size: 9, color: [30, 34, 44] }); + 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); + + // Rate + pdf.text(fmtCurrency(item.rate), colRateX, y); + + // Amount + const amt = fmtCurrency(item.amount); + const aw = pdf.getTextWidth(amt); + pdf.text(amt, colAmtX - aw, y); + + y += blockH + 4; + pdf.setDrawColor(...rule); + pdf.setLineWidth(0.4); + pdf.line(MARGIN, y - 2, PAGE_W - MARGIN, y - 2); + } + + // Case subtotal + ensure(20); + setFont(pdf, { bold: true, size: 9.5, color: accent }); + const labelTxt = `${group.caseNumber} subtotal`; + pdf.text(labelTxt, colRateX - 40, y + 6); + const subText = fmtCurrency(group.subtotal); + const sw = pdf.getTextWidth(subText); + pdf.text(subText, colAmtX - sw, y + 6); + y += 22; + } + + // ===== Totals ===== + ensure(110); + y += 8; + pdf.setDrawColor(...accent); + pdf.setLineWidth(1); + pdf.line(PAGE_W - MARGIN - 240, y, PAGE_W - MARGIN, y); + y += 14; + + const drawTotalRow = (label: string, value: string, opts?: { bold?: boolean; size?: number; color?: [number, number, number] }) => { + setFont(pdf, { size: opts?.size ?? 10, bold: opts?.bold, color: opts?.color ?? [30, 34, 44] }); + pdf.text(label, PAGE_W - MARGIN - 240, y); + const w = pdf.getTextWidth(value); + pdf.text(value, PAGE_W - MARGIN - w, y); + y += 16; + }; + + drawTotalRow("Subtotal", fmtCurrency(input.totals.subtotal)); + if (input.totals.tax > 0) drawTotalRow("Tax", fmtCurrency(input.totals.tax)); + drawTotalRow("Total", fmtCurrency(input.totals.total), { bold: true, size: 11, color: accent }); + if (input.totals.paid > 0) { + drawTotalRow("Amount paid", `- ${fmtCurrency(input.totals.paid)}`, { color: muted }); + pdf.setDrawColor(...accent); + pdf.setLineWidth(0.6); + pdf.line(PAGE_W - MARGIN - 240, y - 8, PAGE_W - MARGIN, y - 8); + drawTotalRow("Balance due", fmtCurrency(input.totals.balance), { bold: true, size: 12, color: accent }); + } + + // ===== Notes ===== + if (input.invoice.notes) { + ensure(40); + y += 10; + setFont(pdf, { bold: true, size: 9, color: accent }); + pdf.text("NOTES", MARGIN, y); + y += 12; + setFont(pdf, { size: 9, color: [40, 44, 54] }); + const lines = wrap(pdf, input.invoice.notes, CONTENT_W); + for (const l of lines) { + ensure(12); + pdf.text(l, MARGIN, y); + y += 12; + } + } + + // Footer on every page + drawPageFooter(pdf, input); + const total = pdf.getNumberOfPages(); + for (let i = 1; i <= total; i++) { + pdf.setPage(i); + setFont(pdf, { size: 8, color: muted }); + const pageTxt = `Page ${i} of ${total}`; + const w = pdf.getTextWidth(pageTxt); + pdf.text(pageTxt, PAGE_W - MARGIN - w, PAGE_H - 22); + } + + pdf.save(filename.endsWith(".pdf") ? filename : `${filename}.pdf`); +} + +function drawPageFooter(pdf: jsPDF, input: InvoicePdfInput) { + const muted: [number, number, number] = [110, 116, 128]; + pdf.setDrawColor(220, 224, 232); + pdf.setLineWidth(0.5); + pdf.line(MARGIN, PAGE_H - 36, PAGE_W - MARGIN, PAGE_H - 36); + pdf.setFont("helvetica", "italic"); + pdf.setFontSize(8); + pdf.setTextColor(...muted); + const note = input.firm.footerNote || `Thank you for your business. Please remit payment to ${input.firm.name || "us"}.`; + pdf.text(note, MARGIN, PAGE_H - 22); +}