Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 03:03:21 +00:00
co-authored by renee-png
parent 28724b6de0
commit ae1d84162e
2 changed files with 108 additions and 35 deletions
+84 -25
View File
@@ -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`;