fix: subcontractor PDF quality, footer on all pages, header gap

- Canvas 3x scale + JPEG 0.97 (matches main invoice quality)
- Footer drawn on every page canvas
- Continuation header gap increased (headerH+28 → +50 with label)
- Pagination pre-calculated before rendering (no mid-render addPage)
- 'PO Date' label → 'Invoice Date'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-08 16:17:20 -04:00
parent 48c8fbb7ff
commit 623dcb7983
2 changed files with 180 additions and 158 deletions
+2 -1
View File
@@ -19,7 +19,8 @@
"Bash(git commit -q -m 'style: increase card-bg alpha to match Safari rendering *)",
"Bash(git commit -q -m 'fix: subcontractor PDF title + multi-page support *)",
"Bash(git commit -q -m 'style: card-bg dark-based alpha to match Safari panel look *)",
"Bash(git commit -q -m 'style: white-based card-bg 0.07, blur 12px→20px for less transparency *)"
"Bash(git commit -q -m 'style: white-based card-bg 0.07, blur 12px→20px for less transparency *)",
"Bash(git commit -q -m 'fix: subcontractor PDF quality, footer on all pages, header gap *)"
]
}
}
+175 -154
View File
@@ -589,193 +589,214 @@ export async function generateReceiptPDF(invoice, company, items, options = {})
export async function generateSubcontractorPOPDF(po, options = {}) {
const logo = await loadImage('/fourge-logo.png');
const doc = new jsPDF({ unit: 'pt', format: 'letter', compress: true, putOnlyUsedFonts: true, precision: 2 });
const pageWidth = 612;
const pageHeight = 792;
const scale = 3;
const margin = 42;
const rightEdge = pageWidth - margin;
const headerH = 62;
const poNumber = po.po_number || 'PO';
const footerLineY = pageHeight - 44;
const footerTextY = pageHeight - 28;
const safeBottom = footerLineY - 16;
const poNumber = po.po_number || 'INV';
const statusLabel = String(po.status || 'draft').replace(/_/g, ' ').toUpperCase();
const projectName = po.project?.name || 'Subcontractor Work';
const companyName = po.project?.company?.name || 'Fourge Branding';
const subcontractorName = po.profile?.name || 'External Team Member';
const subcontractorEmail = po.profile?.email || '';
const tableW = pageWidth - margin * 2;
const rowH = 28;
doc.setFillColor(20, 20, 20);
doc.rect(0, 0, pageWidth, headerH, 'F');
if (logo) {
const logoW = 96;
const logoH = logoW / (logo.naturalWidth / logo.naturalHeight);
doc.addImage(logo, 'PNG', margin, 16, logoW, logoH);
} else {
doc.setFont('helvetica', 'bold');
doc.setFontSize(13);
doc.setTextColor(255, 255, 255);
doc.text('FOURGE BRANDING', margin, 36);
}
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(170, 170, 170);
doc.text('1855.368.7434 | hello@fourgebranding.com | www.fourgebranding.com', rightEdge, 35, { align: 'right' });
let y = 102;
doc.setTextColor(30, 30, 30);
doc.setFont('helvetica', 'bold');
doc.setFontSize(22);
doc.text('SUBCONTRACTOR INVOICE', margin, y);
doc.setFontSize(10);
doc.setTextColor(90, 90, 90);
doc.text(poNumber, rightEdge, y - 4, { align: 'right' });
y += 24;
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.setTextColor(120, 120, 120);
doc.text('SUBCONTRACTOR', margin, y);
doc.text('DETAILS', 330, y);
y += 16;
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(30, 30, 30);
doc.text(subcontractorName, margin, y);
doc.text(projectName, 330, y);
y += 14;
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(95, 95, 95);
if (subcontractorEmail) doc.text(subcontractorEmail, margin, y);
doc.text(companyName, 330, y);
y += 30;
const rows = [
['PO Date', po.date ? formatDate(po.date) : ''],
const metaRows = [
['Invoice Date', po.date ? formatDate(po.date) : ''],
['Due Date', po.due_date ? formatDate(po.due_date) : 'Not set'],
['Terms', po.terms || 'Net 15'],
['Status', statusLabel],
['Amount', formatCurrency(po.amount)],
];
const tableX = margin;
const tableW = pageWidth - margin * 2;
rows.forEach(([label, value], index) => {
const rowY = y + index * 28;
if (index % 2 === 0) {
doc.setFillColor(248, 248, 248);
doc.rect(tableX, rowY - 16, tableW, 28, 'F');
}
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(105, 105, 105);
doc.text(label, tableX + 12, rowY);
doc.setFont('helvetica', 'bold');
doc.setTextColor(35, 35, 35);
doc.text(String(value), tableX + tableW - 12, rowY, { align: 'right' });
});
y += rows.length * 28 + 22;
const pageHeight = 792;
const safeBottom = pageHeight - 80; // leave room for footer
function addContinuationHeader() {
doc.addPage();
doc.setFillColor(20, 20, 20);
doc.rect(0, 0, pageWidth, headerH, 'F');
if (logo) {
const logoW = 96;
const logoH = logoW / (logo.naturalWidth / logo.naturalHeight);
doc.addImage(logo, 'PNG', margin, 16, logoW, logoH);
}
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(170, 170, 170);
doc.text('1855.368.7434 | hello@fourgebranding.com | www.fourgebranding.com', rightEdge, 35, { align: 'right' });
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(120, 120, 120);
doc.text(`${poNumber} (continued)`, margin, headerH + 14);
return headerH + 28;
}
if (po.items?.length > 0) {
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(30, 30, 30);
doc.text('Line Items', margin, y);
y += 16;
const sortedItems = po.items
const sortedItems = (po.items || [])
.slice()
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
let rowIndex = 0;
for (const item of sortedItems) {
if (y + 28 > safeBottom) {
y = addContinuationHeader();
rowIndex = 0;
// ── Measure and paginate items ──────────────────────────────────────────────
const measureCanvas = document.createElement('canvas');
const mCtx = measureCanvas.getContext('2d');
if (!mCtx) throw new Error('Canvas unavailable');
// First page: header + title block + meta table → items start ~y=310
const firstPageItemsStart = headerH + 20 + 26 + 32 + metaRows.length * rowH + 38;
const contPageItemsStart = headerH + 50; // after continuation label
const firstPageItemSlots = Math.floor((safeBottom - firstPageItemsStart) / rowH);
const contPageItemSlots = Math.floor((safeBottom - contPageItemsStart) / rowH);
const pages = [];
let remaining = [...sortedItems];
pages.push(remaining.splice(0, Math.max(1, firstPageItemSlots)));
while (remaining.length > 0) {
pages.push(remaining.splice(0, Math.max(1, contPageItemSlots)));
}
if (rowIndex % 2 === 0) {
doc.setFillColor(248, 248, 248);
doc.rect(tableX, y - 14, tableW, 28, 'F');
if (pages.length === 0) pages.push([]);
// ── Per-page canvas renderer ────────────────────────────────────────────────
function renderPageCanvas(pageItems, pageIndex, pageCount) {
const canvas = document.createElement('canvas');
canvas.width = pageWidth * scale;
canvas.height = pageHeight * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
// White background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, pageWidth, pageHeight);
// ── Header bar ────────────────────────────────────────────────────────────
ctx.fillStyle = '#141414';
ctx.fillRect(0, 0, pageWidth, headerH);
if (logo) {
const logoW = 96;
const logoH = logoW / (logo.naturalWidth / logo.naturalHeight);
ctx.drawImage(logo, margin, (headerH - logoH) / 2, logoW, logoH);
} else {
ctx.font = '700 13px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#ffffff';
ctx.fillText('FOURGE BRANDING', margin, headerH / 2 + 4);
}
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(45, 45, 45);
doc.text(item.description || item.task?.title || 'Work Item', tableX + 12, y);
doc.setFont('helvetica', 'bold');
doc.text(formatCurrency(item.amount), tableX + tableW - 12, y, { align: 'right' });
ctx.font = '9px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#aaaaaa';
drawRightText(ctx, '1855.368.7434 | hello@fourgebranding.com | www.fourgebranding.com', rightEdge, headerH / 2 + 3);
// ── Footer (every page) ───────────────────────────────────────────────────
drawRule(ctx, margin, rightEdge, footerLineY);
ctx.font = '8px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#aaaaaa';
mCtx.font = '8px Helvetica, Arial, sans-serif';
const footerLines = wrapText(mCtx,
'This invoice authorizes payment for the subcontractor work described above. Payment is subject to Fourge Branding approval and completion of assigned work.',
rightEdge - margin);
footerLines.forEach((line, i) => ctx.fillText(line, margin, footerTextY + i * 10));
let y = headerH;
if (pageIndex === 0) {
// ── Title ───────────────────────────────────────────────────────────────
y += 26;
ctx.font = '700 22px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#1e1e1e';
ctx.fillText('SUBCONTRACTOR INVOICE', margin, y);
ctx.font = '10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#5a5a5a';
drawRightText(ctx, poNumber, rightEdge, y - 4);
y += 26;
// ── Two-column info ──────────────────────────────────────────────────────
ctx.font = '700 9px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#787878';
ctx.fillText('SUBCONTRACTOR', margin, y);
ctx.fillText('DETAILS', 330, y);
y += 16;
ctx.font = '700 12px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#1e1e1e';
ctx.fillText(subcontractorName, margin, y);
ctx.fillText(projectName, 330, y);
y += 14;
ctx.font = '10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#5f5f5f';
if (subcontractorEmail) ctx.fillText(subcontractorEmail, margin, y);
ctx.fillText(companyName, 330, y);
y += 30;
// ── Meta table ───────────────────────────────────────────────────────────
metaRows.forEach(([label, value], idx) => {
const rowY = y + idx * rowH;
if (idx % 2 === 0) {
ctx.fillStyle = '#f8f8f8';
ctx.fillRect(margin, rowY - 16, tableW, rowH);
}
ctx.font = '10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#696969';
ctx.fillText(label, margin + 12, rowY);
ctx.font = '700 10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#232323';
mCtx.font = '700 10px Helvetica, Arial, sans-serif';
drawRightText(ctx, String(value), margin + tableW - 12, rowY);
});
y += metaRows.length * rowH + 22;
} else {
// ── Continuation label ───────────────────────────────────────────────────
y += 28;
rowIndex++;
}
ctx.font = '9px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#888888';
ctx.fillText(`${poNumber} (continued)`, margin, y);
y += 22;
}
if (y + 40 > safeBottom) y = addContinuationHeader();
// ── Line items ─────────────────────────────────────────────────────────────
if (pageItems.length > 0 || pageIndex === 0) {
ctx.font = '700 11px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#1e1e1e';
ctx.fillText('Line Items', margin, y);
y += 16;
}
pageItems.forEach((item, idx) => {
if (idx % 2 === 0) {
ctx.fillStyle = '#f8f8f8';
ctx.fillRect(margin, y - 14, tableW, rowH);
}
ctx.font = '10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#2d2d2d';
mCtx.font = '10px Helvetica, Arial, sans-serif';
const descLines = wrapText(mCtx, item.description || item.task?.title || 'Work Item', tableW - 120);
ctx.fillText(descLines[0], margin + 12, y);
ctx.font = '700 10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#1e1e1e';
drawRightText(ctx, formatCurrency(item.amount), margin + tableW - 12, y);
y += rowH;
});
// ── Last page: summary + notes ─────────────────────────────────────────────
if (pageIndex === pageCount - 1) {
y += 10;
if (po.description) {
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(30, 30, 30);
doc.text(po.items?.length > 0 ? 'Summary' : 'Scope of Work', margin, y);
ctx.font = '700 11px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#1e1e1e';
ctx.fillText(po.items?.length > 0 ? 'Summary' : 'Scope of Work', margin, y);
y += 16;
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(70, 70, 70);
const scopeLines = doc.splitTextToSize(po.description, pageWidth - margin * 2);
doc.text(scopeLines, margin, y);
y += scopeLines.length * 13 + 18;
ctx.font = '10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#464646';
mCtx.font = '10px Helvetica, Arial, sans-serif';
const scopeLines = wrapText(mCtx, po.description, tableW);
scopeLines.forEach(line => { ctx.fillText(line, margin, y); y += 13; });
y += 10;
}
if (po.notes) {
if (y + 40 > safeBottom) y = addContinuationHeader();
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(30, 30, 30);
doc.text('Notes', margin, y);
ctx.font = '700 11px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#1e1e1e';
ctx.fillText('Notes', margin, y);
y += 16;
doc.setFont('helvetica', 'normal');
doc.setTextColor(70, 70, 70);
const noteLines = doc.splitTextToSize(po.notes, pageWidth - margin * 2);
doc.text(noteLines, margin, y);
ctx.font = '10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#464646';
mCtx.font = '10px Helvetica, Arial, sans-serif';
const noteLines = wrapText(mCtx, po.notes, tableW);
noteLines.forEach(line => { ctx.fillText(line, margin, y); y += 13; });
}
}
const footerLineY = Math.max(y + 20, pageHeight - 60);
doc.setDrawColor(220, 220, 220);
doc.line(margin, footerLineY, rightEdge, footerLineY);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(120, 120, 120);
doc.text(
'This invoice authorizes payment for the subcontractor work described above. Payment is subject to Fourge Branding approval and completion of assigned work.',
margin,
footerLineY + 16,
{ maxWidth: pageWidth - margin * 2 },
);
return canvas;
}
// ── Assemble PDF ────────────────────────────────────────────────────────────
const doc = new jsPDF({ unit: 'pt', format: 'letter', compress: true, putOnlyUsedFonts: true, precision: 2 });
for (let i = 0; i < pages.length; i++) {
if (i > 0) doc.addPage();
const canvas = renderPageCanvas(pages[i], i, pages.length);
const imgData = canvas.toDataURL('image/jpeg', 0.97);
doc.addImage(imgData, 'JPEG', 0, 0, pageWidth, pageHeight);
}
const filename = `${poNumber}.pdf`;
if (options.save === false || options.output === 'blob') return doc.output('blob');