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:
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user