Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 02:34:53 +00:00
co-authored by renee-png
parent 3374695d1b
commit ef7bc8adc2
2 changed files with 491 additions and 0 deletions
+157
View File
@@ -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<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
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 };
}
+334
View File
@@ -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);
}