Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
3374695d1b
commit
ef7bc8adc2
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user