Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
28724b6de0
commit
ae1d84162e
+84
-25
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user