Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
7f3210452d
commit
9c11ad8140
+118
-85
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user