Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 18:10:54 +00:00
co-authored by renee-png
parent ab4e75a8b7
commit 3e23a3bb94
+339 -192
View File
@@ -55,6 +55,8 @@ export interface InvoicePdfInput {
dueDate?: string | null;
status: string;
notes?: string | null;
matter?: string | null;
terms?: string | null;
};
groups: InvoiceCaseGroup[];
totals: {
@@ -63,18 +65,35 @@ export interface InvoicePdfInput {
total: number;
paid: number;
balance: number;
taxRatePct?: number | null;
feesAndExpenses?: number | null;
alternateFees?: number | null;
adjustments?: Array<{ description: string; amount: 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] }) {
const fmtDateShort = (s: string | null | undefined): string => {
if (!s) return "—";
const d = new Date(s);
if (isNaN(d.getTime())) return "—";
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const yy = String(d.getFullYear()).slice(-2);
return `${mm}/${dd}/${yy}`;
};
function setFont(
pdf: jsPDF,
opts: { bold?: boolean; italic?: boolean; size?: number; color?: [number, number, number]; family?: "helvetica" | "times" },
) {
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.setFont(opts.family ?? "helvetica", style);
pdf.setFontSize(opts.size ?? 10);
if (opts.color) pdf.setTextColor(...opts.color);
else pdf.setTextColor(20, 24, 32);
@@ -103,11 +122,67 @@ async function imageNaturalSize(dataUrl: string): Promise<{ w: number; h: number
});
}
// Palette
const TEXT: [number, number, number] = [30, 38, 56]; // dark navy text
const MUTED: [number, number, number] = [120, 128, 140];
const RULE: [number, number, number] = [228, 232, 238];
const HEADER_BG: [number, number, number] = [243, 245, 248];
const ACCENT: [number, number, number] = [245, 158, 11]; // warm orange (smile)
function drawSmileMark(pdf: jsPDF, cx: number, cy: number, size: number) {
// Two checkmark ticks + a curved smile underneath, in accent color.
pdf.setDrawColor(...ACCENT);
pdf.setLineCap(1); // round
pdf.setLineJoin(1);
// Two checkmarks
pdf.setLineWidth(size * 0.09);
// first check
pdf.lines(
[
[size * 0.22, size * 0.22],
[size * 0.42, -size * 0.5],
],
cx - size * 0.55,
cy - size * 0.05,
);
// second check
pdf.lines(
[
[size * 0.22, size * 0.22],
[size * 0.42, -size * 0.5],
],
cx - size * 0.2,
cy - size * 0.05,
);
// Smile curve (open arc) — approximated with bezier
pdf.setLineWidth(size * 0.1);
// Draw using lines() with bezier control points: [cp1x, cp1y, cp2x, cp2y, ex, ey]
pdf.lines(
[
[size * 0.2, size * 0.55, size * 0.7, size * 0.55, size * 0.95, 0],
],
cx - size * 0.5,
cy + size * 0.2,
[1, 1],
"S",
false,
);
// Arrow tip on the right end of smile
pdf.setLineWidth(size * 0.09);
pdf.lines(
[
[-size * 0.18, -size * 0.05],
[size * 0.05, size * 0.18],
],
cx + size * 0.45,
cy + size * 0.2,
);
}
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) => {
@@ -118,92 +193,77 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin
}
};
// ===== Header band =====
// Logo: preserve aspect ratio, fit within a max box, no stretching/cropping.
let logoBoxW = 0;
let logoBoxH = 0;
// ===== Top: "Invoice" title (left) + logo or smile mark (right) =====
setFont(pdf, { bold: true, size: 34, color: TEXT, family: "times" });
pdf.text("Invoice", MARGIN, y + 28);
// Right side: firm logo if available, else decorative smile mark
if (input.firm.logoDataUrl) {
const MAX_W = 180;
const MAX_H = 80;
const MAX_W = 110;
const MAX_H = 60;
const size = await imageNaturalSize(input.firm.logoDataUrl);
let lw = MAX_W;
let lh = MAX_H;
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;
lw = size.w * scale;
lh = size.h * scale;
}
try {
pdf.addImage(
input.firm.logoDataUrl,
detectImageFormat(input.firm.logoDataUrl),
MARGIN,
PAGE_W - MARGIN - lw,
y,
logoBoxW,
logoBoxH,
lw,
lh,
undefined,
"FAST",
);
} catch {
// ignore — fall through to text-only header
drawSmileMark(pdf, PAGE_W - MARGIN - 40, y + 18, 60);
}
} else {
drawSmileMark(pdf, PAGE_W - MARGIN - 40, y + 18, 60);
}
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 });
y += 70;
// ===== FROM / TO / Info panel =====
// Three columns: FROM, TO, and a key-value info card on the right
const colW = (CONTENT_W - 20) / 3;
const fromX = MARGIN;
const toX = MARGIN + colW + 10;
const infoX = MARGIN + colW * 2 + 20;
const infoW = CONTENT_W - (colW * 2 + 20);
// FROM
setFont(pdf, { bold: true, size: 8, color: MUTED });
pdf.text("FROM:", fromX, y);
setFont(pdf, { bold: true, size: 12, color: TEXT });
pdf.text(input.firm.name || "Firm", fromX, y + 14);
setFont(pdf, { size: 9, color: TEXT });
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.phone,
input.firm.email,
input.firm.website,
].filter((l): l is string => !!l && l.trim().length > 0);
for (const l of firmLines) {
pdf.text(l, headerLeftX, fy);
pdf.text(l, fromX, 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 });
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(headerBottom, 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;
// TO
setFont(pdf, { bold: true, size: 8, color: MUTED });
pdf.text("TO:", toX, y);
setFont(pdf, { bold: true, size: 12, color: TEXT });
pdf.text(input.client.name, toX, y + 14);
setFont(pdf, { size: 9, color: TEXT });
let ty = y + 28;
const clientLines = [
input.client.contact,
input.client.address1,
@@ -211,153 +271,241 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin
[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;
pdf.text(l, toX, ty);
ty += 11;
}
y = by + 16;
// ===== Column geometry =====
// DATE | STAFF | DESCRIPTION | BILL | HRS | RATE | AMOUNT
const colDateX = MARGIN;
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
// Info card (right) — key/value rows
const infoRows: Array<[string, string]> = [
["Inv. Number", input.invoice.number],
];
if (input.invoice.matter) infoRows.push(["Matter", input.invoice.matter]);
infoRows.push(["Date of Issue", formatDate(input.invoice.issueDate)]);
if (input.invoice.terms) infoRows.push(["Terms", input.invoice.terms]);
if (input.invoice.dueDate) infoRows.push(["Date Due", formatDate(input.invoice.dueDate)]);
const rowH = 18;
const infoTop = y - 4;
const infoH = rowH * infoRows.length;
// background card
pdf.setFillColor(...HEADER_BG);
pdf.rect(infoX, infoTop, infoW, infoH, "F");
let iy = infoTop + 12;
infoRows.forEach((row, idx) => {
setFont(pdf, { size: 9, color: MUTED });
pdf.text(row[0], infoX + 10, iy);
setFont(pdf, { size: 9.5, bold: true, color: TEXT });
const w = pdf.getTextWidth(row[1]);
pdf.text(row[1], infoX + infoW - 10 - w, iy);
if (idx < infoRows.length - 1) {
pdf.setDrawColor(...RULE);
pdf.setLineWidth(0.5);
pdf.line(infoX + 10, iy + 6, infoX + infoW - 10, iy + 6);
}
iy += rowH;
});
y = Math.max(fy, ty, infoTop + infoH) + 24;
// ===== FEES & EXPENSES section =====
setFont(pdf, { bold: true, size: 9, color: TEXT });
pdf.text("FEES & EXPENSES", MARGIN, y);
y += 10;
// Column geometry for fees table
const colDateX = MARGIN + 10;
const colKindX = MARGIN + 100;
const colDescX = MARGIN + 150;
const colRateX = MARGIN + 360;
const colAdjX = MARGIN + 425;
const colTaxX = MARGIN + 470;
const colAmtX = PAGE_W - MARGIN - 10;
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("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);
y += 16;
pdf.setFillColor(...HEADER_BG);
pdf.rect(MARGIN, y, CONTENT_W, 22, "F");
setFont(pdf, { bold: true, size: 8, color: TEXT });
pdf.text("DATE", colDateX, y + 14);
pdf.text("DESCRIPTION", colDescX, y + 14);
const rW = pdf.getTextWidth("RATE");
pdf.text("RATE", colRateX - rW + 30, y + 14);
const aW = pdf.getTextWidth("ADJMTS");
pdf.text("ADJMTS", colAdjX - aW + 40, y + 14);
const tW = pdf.getTextWidth("TAX");
pdf.text("TAX", colTaxX - tW + 32, y + 14);
const totW = pdf.getTextWidth("TOTAL");
pdf.text("TOTAL", colAmtX - totW, y + 14);
y += 22;
};
// ===== 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);
drawColHeaders();
// Flatten line items across groups (the reference doesn't separate groups)
const allItems: Array<InvoiceLineItem & { _caseNumber?: string }> = [];
for (const g of input.groups) {
for (const it of g.items) {
allItems.push({ ...it, _caseNumber: g.caseNumber });
}
}
const taxRate = (input.totals.taxRatePct ?? 0) / 100;
for (const item of allItems) {
const isNonBillable = item.billable === false;
const descMaxW = colRateX - colDescX - 10;
const descLines = wrap(pdf, item.description || (item.kind === "expense" ? "Expense" : ""), descMaxW);
const lineHeight = 12;
const blockH = Math.max(lineHeight * descLines.length + 14, 30);
ensure(blockH + 4);
// Date (date + staff name on second line)
setFont(pdf, { size: 9, color: TEXT });
pdf.text(fmtDateShort(item.work_date), colDateX, y + 10);
if (item.user_name) {
setFont(pdf, { size: 8, color: MUTED });
pdf.text(item.user_name, colDateX, y + 22);
}
// Kind label (Fee / Expense)
setFont(pdf, { size: 9, color: TEXT });
const kindLabel =
item.kind === "expense" ? "Expense" : item.kind === "time" ? "Fee" : item.kind === "manual" ? "Fee" : item.kind;
pdf.text(kindLabel, colKindX, y + 10);
// Description
setFont(pdf, { size: 9, color: isNonBillable ? MUTED : TEXT, italic: isNonBillable });
let dy = y + 10;
for (const dl of descLines) {
pdf.text(dl, colDescX, dy);
dy += lineHeight;
}
// Rate (with x quantity below)
setFont(pdf, { size: 9, color: TEXT });
const rateText = fmtCurrency(item.rate);
const rW = pdf.getTextWidth(rateText);
pdf.text(rateText, colRateX - rW + 30, y + 10);
if (item.quantity && item.quantity !== 1) {
setFont(pdf, { size: 8, color: MUTED });
const qText = `x ${Number(item.quantity).toFixed(2)}`;
const qW = pdf.getTextWidth(qText);
pdf.text(qText, colRateX - qW + 30, y + 22);
}
// Adjustments column — placeholder $0.00 unless future per-line adjustments
setFont(pdf, { size: 9, color: TEXT });
const adjText = fmtCurrency(0);
const adjW = pdf.getTextWidth(adjText);
pdf.text(adjText, colAdjX - adjW + 40, y + 10);
// Tax per line
const lineTax = isNonBillable ? 0 : item.amount * taxRate;
const taxText = fmtCurrency(lineTax);
const taxW = pdf.getTextWidth(taxText);
pdf.text(taxText, colTaxX - taxW + 32, y + 10);
// Total (amount + tax)
const lineTotal = isNonBillable ? 0 : item.amount + lineTax;
const amt = isNonBillable ? "—" : fmtCurrency(lineTotal);
setFont(pdf, { size: 9, bold: true, color: TEXT });
const aw = pdf.getTextWidth(amt);
pdf.text(amt, colAmtX - aw, y + 10);
y += blockH;
pdf.setDrawColor(...RULE);
pdf.setLineWidth(0.4);
pdf.line(MARGIN, y, PAGE_W - MARGIN, y);
}
y += 20;
// ===== ADJUSTMENTS section =====
const adjustments = input.totals.adjustments ?? [];
if (input.totals.paid > 0 || adjustments.length > 0) {
ensure(80);
setFont(pdf, { bold: true, size: 9, color: TEXT });
pdf.text("ADJUSTMENTS", MARGIN, y);
y += 10;
pdf.setFillColor(...HEADER_BG);
pdf.rect(MARGIN, y, CONTENT_W, 22, "F");
setFont(pdf, { bold: true, size: 8, color: TEXT });
pdf.text("DESCRIPTION", colDateX, y + 14);
const amtH = pdf.getTextWidth("AMOUNT");
pdf.text("AMOUNT", colAmtX - amtH, y + 14);
y += 22;
drawColHeaders();
setFont(pdf, { size: 9, color: [30, 34, 44] });
for (const item of group.items) {
const descMaxW = colBillX - colDescX - 8;
const descLines = wrap(pdf, item.description || (item.kind === "expense" ? "Expense" : ""), descMaxW);
const lineHeight = 12;
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: bodyColor, italic: isNonBillable });
let dy = y;
for (const dl of descLines) {
pdf.text(dl, colDescX, dy);
dy += lineHeight;
}
// 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 (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);
y += blockH + 4;
pdf.setDrawColor(...rule);
const drawAdj = (desc: string, amount: number) => {
ensure(20);
setFont(pdf, { size: 9, color: TEXT });
pdf.text(desc, colDateX, y + 12);
const txt = amount < 0 ? `−${fmtCurrency(Math.abs(amount))}` : fmtCurrency(amount);
const w = pdf.getTextWidth(txt);
pdf.text(txt, colAmtX - w, y + 12);
y += 20;
pdf.setDrawColor(...RULE);
pdf.setLineWidth(0.4);
pdf.line(MARGIN, y - 2, PAGE_W - MARGIN, y - 2);
}
pdf.line(MARGIN, y, PAGE_W - MARGIN, y);
};
// Case subtotal (billable only)
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;
for (const a of adjustments) drawAdj(a.description, a.amount);
if (input.totals.paid > 0) drawAdj("Client Funds Applied", -input.totals.paid);
}
// ===== Totals =====
ensure(110);
y += 8;
pdf.setDrawColor(...accent);
pdf.setLineWidth(1);
pdf.line(PAGE_W - MARGIN - 240, y, PAGE_W - MARGIN, y);
y += 14;
// ===== Totals (right-aligned card, no shading, just rules) =====
ensure(140);
y += 18;
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 totalsX = PAGE_W - MARGIN - 260;
const totalsW = 260;
const drawTotalRow = (
label: string,
value: string,
opts?: { bold?: boolean; size?: number; color?: [number, number, number]; emphasize?: boolean },
) => {
setFont(pdf, { size: opts?.size ?? 10, bold: opts?.bold, color: opts?.color ?? TEXT });
pdf.text(label, totalsX + 10, y + 12);
const w = pdf.getTextWidth(value);
pdf.text(value, PAGE_W - MARGIN - w, y);
y += 16;
pdf.text(value, totalsX + totalsW - 10 - w, y + 12);
y += 22;
pdf.setDrawColor(...RULE);
pdf.setLineWidth(0.4);
pdf.line(totalsX, y, totalsX + totalsW, y);
};
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 });
// Top rule
pdf.setDrawColor(...RULE);
pdf.setLineWidth(0.4);
pdf.line(totalsX, y, totalsX + totalsW, y);
drawTotalRow("Fees & Expenses", fmtCurrency(input.totals.feesAndExpenses ?? input.totals.subtotal));
if (input.totals.alternateFees && input.totals.alternateFees > 0) {
drawTotalRow("Alternate Fees", fmtCurrency(input.totals.alternateFees));
}
if (input.totals.tax > 0) {
const ratePart = input.totals.taxRatePct ? ` (${input.totals.taxRatePct.toFixed(2)}%)` : "";
drawTotalRow(`Tax: State${ratePart}`, fmtCurrency(input.totals.tax));
}
if (input.totals.paid > 0) {
drawTotalRow("Funds Applied", `−${fmtCurrency(input.totals.paid)}`);
}
drawTotalRow("Total", fmtCurrency(input.totals.balance > 0 ? input.totals.balance : input.totals.total), {
bold: true,
size: 12,
color: TEXT,
});
// ===== Notes =====
if (input.invoice.notes) {
ensure(40);
y += 10;
setFont(pdf, { bold: true, size: 9, color: accent });
y += 18;
setFont(pdf, { bold: true, size: 9, color: TEXT });
pdf.text("NOTES", MARGIN, y);
y += 12;
setFont(pdf, { size: 9, color: [40, 44, 54] });
setFont(pdf, { size: 9, color: TEXT });
const lines = wrap(pdf, input.invoice.notes, CONTENT_W);
for (const l of lines) {
ensure(12);
@@ -371,7 +519,7 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin
const total = pdf.getNumberOfPages();
for (let i = 1; i <= total; i++) {
pdf.setPage(i);
setFont(pdf, { size: 8, color: muted });
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);
@@ -381,13 +529,12 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin
}
function drawPageFooter(pdf: jsPDF, input: InvoicePdfInput) {
const muted: [number, number, number] = [110, 116, 128];
pdf.setDrawColor(220, 224, 232);
pdf.setDrawColor(...RULE);
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);
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);
}