From 623dcb7983cf0a3016becaa510962e45ffbf40c4 Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Mon, 8 Jun 2026 16:17:20 -0400 Subject: [PATCH] fix: subcontractor PDF quality, footer on all pages, header gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/settings.local.json | 3 +- src/lib/invoice.js | 335 +++++++++++++++++++----------------- 2 files changed, 180 insertions(+), 158 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d0a4b61..d27e52e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 *)" ] } } diff --git a/src/lib/invoice.js b/src/lib/invoice.js index 1e5f80b..a4d2501 100644 --- a/src/lib/invoice.js +++ b/src/lib/invoice.js @@ -589,194 +589,215 @@ 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 sortedItems = (po.items || []) + .slice() + .sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0)); - const pageHeight = 792; - const safeBottom = pageHeight - 80; // leave room for footer + // ── Measure and paginate items ────────────────────────────────────────────── + const measureCanvas = document.createElement('canvas'); + const mCtx = measureCanvas.getContext('2d'); + if (!mCtx) throw new Error('Canvas unavailable'); - function addContinuationHeader() { - doc.addPage(); - doc.setFillColor(20, 20, 20); - doc.rect(0, 0, pageWidth, headerH, 'F'); + // 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 (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); - doc.addImage(logo, 'PNG', margin, 16, logoW, logoH); + 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(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; - } + 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); - 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; + // ── 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)); - const sortedItems = po.items - .slice() - .sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0)); + let y = headerH; - let rowIndex = 0; - for (const item of sortedItems) { - if (y + 28 > safeBottom) { - y = addContinuationHeader(); - rowIndex = 0; - } - if (rowIndex % 2 === 0) { - doc.setFillColor(248, 248, 248); - doc.rect(tableX, y - 14, tableW, 28, 'F'); - } - 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' }); + 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; } - y += 22; + + // ── 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) { + 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; + 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) { + ctx.font = '700 11px Helvetica, Arial, sans-serif'; + ctx.fillStyle = '#1e1e1e'; + ctx.fillText('Notes', margin, y); + y += 16; + 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; }); + } + } + + return canvas; } - if (y + 40 > safeBottom) y = addContinuationHeader(); - - 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); - 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; + // ── 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); } - 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); - 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); - } - - 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 }, - ); - const filename = `${poNumber}.pdf`; if (options.save === false || options.output === 'blob') return doc.output('blob'); doc.save(filename);