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 '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 '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: 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 = {}) {
|
export async function generateSubcontractorPOPDF(po, options = {}) {
|
||||||
const logo = await loadImage('/fourge-logo.png');
|
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 pageWidth = 612;
|
||||||
|
const pageHeight = 792;
|
||||||
|
const scale = 3;
|
||||||
const margin = 42;
|
const margin = 42;
|
||||||
const rightEdge = pageWidth - margin;
|
const rightEdge = pageWidth - margin;
|
||||||
const headerH = 62;
|
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 statusLabel = String(po.status || 'draft').replace(/_/g, ' ').toUpperCase();
|
||||||
const projectName = po.project?.name || 'Subcontractor Work';
|
const projectName = po.project?.name || 'Subcontractor Work';
|
||||||
const companyName = po.project?.company?.name || 'Fourge Branding';
|
const companyName = po.project?.company?.name || 'Fourge Branding';
|
||||||
const subcontractorName = po.profile?.name || 'External Team Member';
|
const subcontractorName = po.profile?.name || 'External Team Member';
|
||||||
const subcontractorEmail = po.profile?.email || '';
|
const subcontractorEmail = po.profile?.email || '';
|
||||||
|
const tableW = pageWidth - margin * 2;
|
||||||
|
const rowH = 28;
|
||||||
|
|
||||||
doc.setFillColor(20, 20, 20);
|
const metaRows = [
|
||||||
doc.rect(0, 0, pageWidth, headerH, 'F');
|
['Invoice Date', po.date ? formatDate(po.date) : '—'],
|
||||||
|
|
||||||
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) : ''],
|
|
||||||
['Due Date', po.due_date ? formatDate(po.due_date) : 'Not set'],
|
['Due Date', po.due_date ? formatDate(po.due_date) : 'Not set'],
|
||||||
['Terms', po.terms || 'Net 15'],
|
['Terms', po.terms || 'Net 15'],
|
||||||
['Status', statusLabel],
|
['Status', statusLabel],
|
||||||
['Amount', formatCurrency(po.amount)],
|
['Amount', formatCurrency(po.amount)],
|
||||||
];
|
];
|
||||||
|
|
||||||
const tableX = margin;
|
const sortedItems = (po.items || [])
|
||||||
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
|
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
|
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
|
||||||
|
|
||||||
let rowIndex = 0;
|
// ── Measure and paginate items ──────────────────────────────────────────────
|
||||||
for (const item of sortedItems) {
|
const measureCanvas = document.createElement('canvas');
|
||||||
if (y + 28 > safeBottom) {
|
const mCtx = measureCanvas.getContext('2d');
|
||||||
y = addContinuationHeader();
|
if (!mCtx) throw new Error('Canvas unavailable');
|
||||||
rowIndex = 0;
|
|
||||||
|
// 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) {
|
if (pages.length === 0) pages.push([]);
|
||||||
doc.setFillColor(248, 248, 248);
|
|
||||||
doc.rect(tableX, y - 14, tableW, 28, 'F');
|
// ── 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');
|
ctx.font = '9px Helvetica, Arial, sans-serif';
|
||||||
doc.setFontSize(10);
|
ctx.fillStyle = '#aaaaaa';
|
||||||
doc.setTextColor(45, 45, 45);
|
drawRightText(ctx, '1855.368.7434 | hello@fourgebranding.com | www.fourgebranding.com', rightEdge, headerH / 2 + 3);
|
||||||
doc.text(item.description || item.task?.title || 'Work Item', tableX + 12, y);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
// ── Footer (every page) ───────────────────────────────────────────────────
|
||||||
doc.text(formatCurrency(item.amount), tableX + tableW - 12, y, { align: 'right' });
|
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;
|
y += 28;
|
||||||
rowIndex++;
|
ctx.font = '9px Helvetica, Arial, sans-serif';
|
||||||
}
|
ctx.fillStyle = '#888888';
|
||||||
|
ctx.fillText(`${poNumber} (continued)`, margin, y);
|
||||||
y += 22;
|
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) {
|
if (po.description) {
|
||||||
doc.setFont('helvetica', 'bold');
|
ctx.font = '700 11px Helvetica, Arial, sans-serif';
|
||||||
doc.setFontSize(11);
|
ctx.fillStyle = '#1e1e1e';
|
||||||
doc.setTextColor(30, 30, 30);
|
ctx.fillText(po.items?.length > 0 ? 'Summary' : 'Scope of Work', margin, y);
|
||||||
doc.text(po.items?.length > 0 ? 'Summary' : 'Scope of Work', margin, y);
|
|
||||||
y += 16;
|
y += 16;
|
||||||
|
ctx.font = '10px Helvetica, Arial, sans-serif';
|
||||||
doc.setFont('helvetica', 'normal');
|
ctx.fillStyle = '#464646';
|
||||||
doc.setFontSize(10);
|
mCtx.font = '10px Helvetica, Arial, sans-serif';
|
||||||
doc.setTextColor(70, 70, 70);
|
const scopeLines = wrapText(mCtx, po.description, tableW);
|
||||||
const scopeLines = doc.splitTextToSize(po.description, pageWidth - margin * 2);
|
scopeLines.forEach(line => { ctx.fillText(line, margin, y); y += 13; });
|
||||||
doc.text(scopeLines, margin, y);
|
y += 10;
|
||||||
y += scopeLines.length * 13 + 18;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (po.notes) {
|
if (po.notes) {
|
||||||
if (y + 40 > safeBottom) y = addContinuationHeader();
|
ctx.font = '700 11px Helvetica, Arial, sans-serif';
|
||||||
doc.setFont('helvetica', 'bold');
|
ctx.fillStyle = '#1e1e1e';
|
||||||
doc.setFontSize(11);
|
ctx.fillText('Notes', margin, y);
|
||||||
doc.setTextColor(30, 30, 30);
|
|
||||||
doc.text('Notes', margin, y);
|
|
||||||
y += 16;
|
y += 16;
|
||||||
doc.setFont('helvetica', 'normal');
|
ctx.font = '10px Helvetica, Arial, sans-serif';
|
||||||
doc.setTextColor(70, 70, 70);
|
ctx.fillStyle = '#464646';
|
||||||
const noteLines = doc.splitTextToSize(po.notes, pageWidth - margin * 2);
|
mCtx.font = '10px Helvetica, Arial, sans-serif';
|
||||||
doc.text(noteLines, margin, y);
|
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);
|
return canvas;
|
||||||
doc.setDrawColor(220, 220, 220);
|
}
|
||||||
doc.line(margin, footerLineY, rightEdge, footerLineY);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
// ── Assemble PDF ────────────────────────────────────────────────────────────
|
||||||
doc.setFontSize(9);
|
const doc = new jsPDF({ unit: 'pt', format: 'letter', compress: true, putOnlyUsedFonts: true, precision: 2 });
|
||||||
doc.setTextColor(120, 120, 120);
|
for (let i = 0; i < pages.length; i++) {
|
||||||
doc.text(
|
if (i > 0) doc.addPage();
|
||||||
'This invoice authorizes payment for the subcontractor work described above. Payment is subject to Fourge Branding approval and completion of assigned work.',
|
const canvas = renderPageCanvas(pages[i], i, pages.length);
|
||||||
margin,
|
const imgData = canvas.toDataURL('image/jpeg', 0.97);
|
||||||
footerLineY + 16,
|
doc.addImage(imgData, 'JPEG', 0, 0, pageWidth, pageHeight);
|
||||||
{ maxWidth: pageWidth - margin * 2 },
|
}
|
||||||
);
|
|
||||||
|
|
||||||
const filename = `${poNumber}.pdf`;
|
const filename = `${poNumber}.pdf`;
|
||||||
if (options.save === false || options.output === 'blob') return doc.output('blob');
|
if (options.save === false || options.output === 'blob') return doc.output('blob');
|
||||||
|
|||||||
Reference in New Issue
Block a user