Fixed PDF invoice layout

X-Lovable-Edit-ID: edt-8a57fac1-90b9-446f-b54c-479eedbc0b24
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 19:21:52 +00:00
co-authored by renee-png
+118 -85
View File
@@ -314,109 +314,142 @@ export async function downloadInvoicePdf(input: InvoicePdfInput, filename: strin
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;
// Column geometry for fees table.
// Layout (left → right):
// DATE | TYPE | DESCRIPTION | QTY | RATE | TAX | TOTAL
const colDateX = MARGIN + 8; // date left edge
const colKindX = MARGIN + 70; // type label left edge
const colDescX = MARGIN + 120; // description left edge
const colQtyR = MARGIN + 360; // qty right edge
const colRateR = MARGIN + 415; // rate right edge
const colTaxR = MARGIN + 460; // tax right edge
const colAmtX = PAGE_W - MARGIN - 8; // total right edge
const drawColHeaders = () => {
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("TYPE", colKindX, y + 14);
pdf.text("DESCRIPTION", colDescX, y + 14);
const qW = pdf.getTextWidth("QTY");
pdf.text("QTY", colQtyR - qW, 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);
pdf.text("RATE", colRateR - rW, y + 14);
const tW = pdf.getTextWidth("TAX");
pdf.text("TAX", colTaxX - tW + 32, y + 14);
pdf.text("TAX", colTaxR - tW, y + 14);
const totW = pdf.getTextWidth("TOTAL");
pdf.text("TOTAL", colAmtX - totW, y + 14);
y += 22;
};
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 drawCaseHeader = (g: InvoiceCaseGroup) => {
ensure(28);
pdf.setFillColor(248, 250, 253);
pdf.rect(MARGIN, y, CONTENT_W, 20, "F");
setFont(pdf, { bold: true, size: 9.5, color: TEXT });
const titleText = `${g.caseTitle}`;
pdf.text(titleText, MARGIN + 8, y + 13);
setFont(pdf, { size: 8, color: MUTED });
const meta = [g.caseNumber, g.practiceArea].filter(Boolean).join(" · ");
if (meta) {
const metaW = pdf.getTextWidth(meta);
pdf.text(meta, PAGE_W - MARGIN - 8 - metaW, y + 13);
}
}
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;
y += 20;
pdf.setDrawColor(...RULE);
pdf.setLineWidth(0.4);
pdf.line(MARGIN, y, PAGE_W - MARGIN, y);
};
drawColHeaders();
const taxRate = (input.totals.taxRatePct ?? 0) / 100;
const multipleCases = input.groups.length > 1;
for (const g of input.groups) {
if (multipleCases) drawCaseHeader(g);
for (const item of g.items) {
const isNonBillable = item.billable === false;
const descMaxW = colQtyR - colDescX - 14;
const descLines = wrap(pdf, item.description || (item.kind === "expense" ? "Expense" : ""), descMaxW);
const lineHeight = 12;
// Reserve enough vertical space: description lines + room for staff name under date.
const blockH = Math.max(lineHeight * descLines.length + 14, item.user_name ? 32 : 26);
ensure(blockH + 4);
// Date + staff name
setFont(pdf, { size: 9, color: TEXT });
pdf.text(fmtDateShort(item.work_date), colDateX, y + 12);
if (item.user_name) {
setFont(pdf, { size: 7.5, color: MUTED });
const nameLines = wrap(pdf, item.user_name, colKindX - colDateX - 4);
pdf.text(nameLines[0], colDateX, y + 23);
}
// Type label (Fee / Expense)
setFont(pdf, { size: 9, color: MUTED });
const kindLabel =
item.kind === "expense" ? "Expense" : item.kind === "time" ? "Fee" : item.kind === "manual" ? "Fee" : item.kind;
pdf.text(kindLabel, colKindX, y + 12);
// Description
setFont(pdf, { size: 9, color: isNonBillable ? MUTED : TEXT, italic: isNonBillable });
let dy = y + 12;
for (const dl of descLines) {
pdf.text(dl, colDescX, dy);
dy += lineHeight;
}
// Qty (right-aligned). For expenses (qty=1, no hours concept) show "—".
setFont(pdf, { size: 9, color: TEXT });
const qtyText =
item.kind === "expense"
? "—"
: Number(item.quantity).toFixed(2);
const qW = pdf.getTextWidth(qtyText);
pdf.text(qtyText, colQtyR - qW, y + 12);
// Rate (right-aligned)
const rateText = fmtCurrency(item.rate);
const rW = pdf.getTextWidth(rateText);
pdf.text(rateText, colRateR - rW, y + 12);
// Tax per line
const lineTax = isNonBillable ? 0 : item.amount * taxRate;
const taxText = fmtCurrency(lineTax);
const taxW = pdf.getTextWidth(taxText);
pdf.text(taxText, colTaxR - taxW, y + 12);
// 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 + 12);
y += blockH;
pdf.setDrawColor(...RULE);
pdf.setLineWidth(0.4);
pdf.line(MARGIN, y, PAGE_W - MARGIN, y);
}
// Per-case subtotal when there are multiple cases
if (multipleCases) {
ensure(22);
setFont(pdf, { bold: true, size: 9, color: TEXT });
const label = `Subtotal — ${g.caseNumber}`;
pdf.text(label, colDescX, y + 14);
const subText = fmtCurrency(g.subtotal);
const sw = pdf.getTextWidth(subText);
pdf.text(subText, colAmtX - sw, y + 14);
y += 20;
pdf.setDrawColor(...RULE);
pdf.setLineWidth(0.6);
pdf.line(MARGIN, y, PAGE_W - MARGIN, y);
y += 6;
}
}
y += 20;