Add Stripe fee tracking on paid invoices + backfill function
- Store stripe_fee on invoices when webhook receives checkout.session.completed - Display Stripe fee and net received in InvoiceDetail when paid via Stripe - Add backfill-stripe-fees edge function to populate fee on existing paid invoices - Migration: add stripe_fee column to invoices table - Includes all pending portal changes (brand book, sign survey, task/project/company updates, etc.) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,807 @@
|
||||
import jsPDF from 'jspdf';
|
||||
|
||||
// Letter landscape: 792 x 612 pt
|
||||
const W = 792;
|
||||
const H = 612;
|
||||
const MARGIN = 36;
|
||||
const ACCENT = [245, 165, 35];
|
||||
const DARK = [18, 18, 18];
|
||||
const HEADER_H = 64;
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
// Accepts File, data URL string, or https URL string — returns data URL
|
||||
async function resolvePhoto(source) {
|
||||
if (!source) return null;
|
||||
if (source instanceof File) {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target.result);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(source);
|
||||
});
|
||||
}
|
||||
if (typeof source === 'string') {
|
||||
if (source.startsWith('data:')) return source;
|
||||
try {
|
||||
const resp = await fetch(source);
|
||||
const blob = await resp.blob();
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target.result);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve) => {
|
||||
if (!src) return resolve(null);
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function addHeader(doc, logo, logoW, logoH, title, pageNum, totalPages) {
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(0, 0, W, HEADER_H, 'F');
|
||||
doc.setFillColor(...ACCENT);
|
||||
doc.rect(0, 0, 4, HEADER_H, 'F');
|
||||
doc.setDrawColor(220, 220, 220);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(0, HEADER_H, W, HEADER_H);
|
||||
|
||||
if (logo) {
|
||||
doc.addImage(logo, 'PNG', MARGIN, (HEADER_H - logoH) / 2, logoW, logoH);
|
||||
} else {
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...DARK);
|
||||
doc.text('FOURGE BRANDING', MARGIN, HEADER_H / 2 + 3);
|
||||
}
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(80, 80, 80);
|
||||
doc.text(title.toUpperCase(), W / 2, HEADER_H / 2 + 3, { align: 'center' });
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(`${pageNum} / ${totalPages}`, W - MARGIN, HEADER_H / 2 + 3, { align: 'right' });
|
||||
}
|
||||
|
||||
function addFooter(doc, clientName, date) {
|
||||
doc.setDrawColor(210, 210, 210);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(MARGIN, H - 22, W - MARGIN, H - 22);
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(clientName, MARGIN, H - 12);
|
||||
doc.text('hello@fourgebranding.com · www.fourgebranding.com', W / 2, H - 12, { align: 'center' });
|
||||
if (date) doc.text(date, W - MARGIN, H - 12, { align: 'right' });
|
||||
}
|
||||
|
||||
const BOLCHOZ_LOGO_H = 36; // 0.5" client logo height
|
||||
const BOLCHOZ_FOOTER_H = BOLCHOZ_LOGO_H + 8; // space reserved at bottom for footer
|
||||
|
||||
function addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg) {
|
||||
const logoY = H - MARGIN - BOLCHOZ_LOGO_H;
|
||||
const pgDivY = H - MARGIN - 3.5; // vertical center of 10pt text
|
||||
|
||||
let footerLogoW = 0;
|
||||
if (clientLogoDataUrl && clientLogoImg) {
|
||||
const aspect = clientLogoImg.naturalWidth / clientLogoImg.naturalHeight;
|
||||
footerLogoW = BOLCHOZ_LOGO_H * aspect;
|
||||
doc.addImage(clientLogoDataUrl, MARGIN, logoY, footerLogoW, BOLCHOZ_LOGO_H);
|
||||
}
|
||||
|
||||
const pgLabel = `Page ${String(pageNum).padStart(2, '0')} of ${String(totalPages).padStart(2, '0')}`;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(pgLabel, W - MARGIN, H - MARGIN, { align: 'right' });
|
||||
|
||||
const pgLabelW = doc.getTextWidth(pgLabel);
|
||||
const divStartX = MARGIN + (footerLogoW > 0 ? footerLogoW + 12 : 0);
|
||||
const divEndX = W - MARGIN - pgLabelW - 10;
|
||||
if (divEndX > divStartX) {
|
||||
doc.setDrawColor(210, 210, 210);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(divStartX, pgDivY, divEndX, pgDivY);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateBrandBookEditorPDF(data) {
|
||||
const { template = 'fourge', clientName, projectName, siteAddress, bookDate, preparedBy, revision,
|
||||
siteMapSource, inventoryMapSource, signs, sitePhotoSources,
|
||||
projectLogoSource, creationDate, revisionDate,
|
||||
customerName, customerAddress, clientLogoSource,
|
||||
clientContactName, clientContactEmail, clientContactPhone,
|
||||
approvedDate, approvalNotes } = data;
|
||||
|
||||
const doc = new jsPDF({ orientation: 'landscape', format: 'letter', unit: 'pt' });
|
||||
|
||||
// Load assets
|
||||
const logo = await loadImage('/fourge-logo.png');
|
||||
const logoW = 40;
|
||||
const logoH = logo ? (logoW / (logo.naturalWidth / logo.naturalHeight)) : 10;
|
||||
|
||||
// Resolve all photos to data URLs up front
|
||||
const [siteMapDataUrl, inventoryMapDataUrl, projectLogoDataUrl, clientLogoDataUrl] = await Promise.all([
|
||||
resolvePhoto(siteMapSource),
|
||||
resolvePhoto(inventoryMapSource),
|
||||
resolvePhoto(projectLogoSource),
|
||||
resolvePhoto(clientLogoSource),
|
||||
]);
|
||||
const clientLogoImg = clientLogoDataUrl ? await loadImage(clientLogoDataUrl) : null;
|
||||
const signPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.photoSource)));
|
||||
const sitePhotoDataUrls = await Promise.all((sitePhotoSources || []).map(s => resolvePhoto(s)));
|
||||
const validSitePhotos = sitePhotoDataUrls.filter(Boolean);
|
||||
const PHOTOS_PER_PAGE = 16;
|
||||
|
||||
// Count pages
|
||||
let totalPages = 1; // cover
|
||||
if (siteMapDataUrl) totalPages++; // site map page
|
||||
totalPages++; // sign inventory
|
||||
totalPages += signs.length; // sign pages
|
||||
totalPages += Math.ceil(validSitePhotos.length / PHOTOS_PER_PAGE); // site photo pages
|
||||
|
||||
let pageNum = 0;
|
||||
const rev = String(revision || '01').padStart(2, '0');
|
||||
const displayDate = bookDate
|
||||
? new Date(bookDate + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: '';
|
||||
|
||||
// ─── COVER PAGE ──────────────────────────────────────────────────────────────
|
||||
pageNum++;
|
||||
|
||||
if (template === 'bolchoz') {
|
||||
// ── Bolchoz Sign Solutions Cover ─────────────────────────────────────────────
|
||||
|
||||
// White background
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(0, 0, W, H, 'F');
|
||||
|
||||
// ── Project logo box (top left, 5"×5" = 360×360pt, no border) ──────────────
|
||||
const logoBoxSize = 288; // 4"
|
||||
const logoBoxX = MARGIN;
|
||||
const logoBoxY = MARGIN; // flush to top margin
|
||||
|
||||
if (projectLogoDataUrl) {
|
||||
const pImg = await loadImage(projectLogoDataUrl);
|
||||
if (pImg) {
|
||||
const ratio = pImg.naturalWidth / pImg.naturalHeight;
|
||||
let dW, dH;
|
||||
if (ratio >= 1) { dW = logoBoxSize; dH = dW / ratio; }
|
||||
else { dH = logoBoxSize; dW = dH * ratio; }
|
||||
doc.addImage(projectLogoDataUrl, logoBoxX, logoBoxY, dW, dH);
|
||||
}
|
||||
} else {
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(210, 210, 210);
|
||||
doc.text('PROJECT LOGO', logoBoxX + logoBoxSize / 2, logoBoxY + logoBoxSize / 2, { align: 'center' });
|
||||
}
|
||||
|
||||
// ── Fourge logo (left of right column) ───────────────────────────────────────
|
||||
const dateColX = logoBoxX + logoBoxSize + 20;
|
||||
if (logo) {
|
||||
doc.addImage(logo, 'PNG', dateColX, logoBoxY, logoW, logoH);
|
||||
} else {
|
||||
doc.setFontSize(8); doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...DARK);
|
||||
doc.text('FOURGE BRANDING', dateColX, logoBoxY + 8);
|
||||
}
|
||||
|
||||
// ── Dates (top right corner, right-aligned, starting at same Y as logo top) ──
|
||||
// Helper: right-align text at W-MARGIN, accounting for charSpace
|
||||
const rtx = (text, cs = 0) => {
|
||||
const w = doc.getTextWidth(text) + (text.length > 1 ? (text.length - 1) * cs : 0);
|
||||
return W - MARGIN - w;
|
||||
};
|
||||
|
||||
let ty = logoBoxY;
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('CREATION DATE', rtx('CREATION DATE', 1.5), ty);
|
||||
doc.setCharSpace(0);
|
||||
ty += 16;
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(30, 30, 30);
|
||||
if (creationDate) doc.text(formatDate(creationDate), rtx(formatDate(creationDate)), ty);
|
||||
ty += 28;
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('REVISION DATE', rtx('REVISION DATE', 1.5), ty);
|
||||
doc.setCharSpace(0);
|
||||
ty += 16;
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(30, 30, 30);
|
||||
if (revisionDate) doc.text(formatDate(revisionDate), rtx(formatDate(revisionDate)), ty);
|
||||
|
||||
const sepY = logoBoxY + logoBoxSize + 10;
|
||||
|
||||
const botY = sepY + 14;
|
||||
const halfW = (W - MARGIN * 2 - 20) / 2;
|
||||
const rightColX = MARGIN + halfW + 20;
|
||||
|
||||
// ── Bottom left: Customer (anchored to bottom-left corner) ──────────────────
|
||||
const addrText = customerAddress || siteAddress || '';
|
||||
const addrLineH = 11;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
const addrLines = addrText ? doc.splitTextToSize(addrText, halfW) : [];
|
||||
|
||||
// Work bottom-up to anchor to H - MARGIN
|
||||
const lY_addr = H - MARGIN;
|
||||
const lY_addrStart = addrLines.length > 0 ? lY_addr - (addrLines.length - 1) * addrLineH : lY_addr;
|
||||
const lY_addrLabel = (addrLines.length > 0 ? lY_addrStart : lY_addr) - 16;
|
||||
const lY_name = lY_addrLabel - 28;
|
||||
const lY_customerLabel = lY_name - 16;
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('CUSTOMER', MARGIN, lY_customerLabel);
|
||||
doc.setCharSpace(0);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(20, 20, 20);
|
||||
if (customerName || clientName) doc.text(customerName || clientName, MARGIN, lY_name);
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('ADDRESS', MARGIN, lY_addrLabel);
|
||||
doc.setCharSpace(0);
|
||||
if (addrLines.length > 0) {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(80, 80, 80);
|
||||
doc.text(addrLines, MARGIN, lY_addrStart);
|
||||
}
|
||||
|
||||
// ── Right column: Signature / Approval (under revision date, right-aligned) ──
|
||||
const sigLineH = 14; // ~1 line break
|
||||
let rY = ty + sigLineH * 8; // 8 line breaks below revision date
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('SIGNATURE OF APPROVAL', rtx('SIGNATURE OF APPROVAL', 1.5), rY);
|
||||
doc.setCharSpace(0);
|
||||
rY += 16 + 12 + 28; // same spacing as if value were present
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('APPROVED DATE', rtx('APPROVED DATE', 1.5), rY);
|
||||
doc.setCharSpace(0);
|
||||
rY += 16;
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(30, 30, 30);
|
||||
if (approvedDate) doc.text(formatDate(approvedDate), rtx(formatDate(approvedDate)), rY);
|
||||
rY += 28;
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('NOTES', rtx('NOTES', 1.5), rY);
|
||||
doc.setCharSpace(0);
|
||||
rY += 16;
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(60, 60, 60);
|
||||
if (approvalNotes) {
|
||||
const noteLines = doc.splitTextToSize(approvalNotes, halfW);
|
||||
doc.text(noteLines, W - MARGIN, rY, { align: 'right' });
|
||||
}
|
||||
|
||||
// ── Client logo + contact (right-bottom aligned) ──────────────────────────────
|
||||
const clientLogoW = 252; // 3.5"
|
||||
const clientLogoH = 108; // 1.5"
|
||||
const contactLineH = 14;
|
||||
const contactLines = [clientContactName, clientContactEmail, clientContactPhone].filter(Boolean);
|
||||
const contactBlockH = contactLines.length * contactLineH;
|
||||
|
||||
// Contact text right-aligned at bottom
|
||||
const contactStartY = H - MARGIN - contactBlockH + 10;
|
||||
if (contactLines.length > 0) {
|
||||
let cy2 = contactStartY;
|
||||
if (clientContactName) {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(40, 40, 40);
|
||||
doc.text(clientContactName, W - MARGIN, cy2, { align: 'right' });
|
||||
cy2 += contactLineH;
|
||||
}
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(80, 80, 80);
|
||||
if (clientContactEmail) { doc.text(clientContactEmail, W - MARGIN, cy2, { align: 'right' }); cy2 += contactLineH; }
|
||||
if (clientContactPhone) { doc.text(clientContactPhone, W - MARGIN, cy2, { align: 'right' }); }
|
||||
}
|
||||
|
||||
// Client logo box above contact text
|
||||
const clBoxBottom = H - MARGIN - (contactBlockH > 0 ? contactBlockH + 8 : 0);
|
||||
const clBoxTop = clBoxBottom - clientLogoH;
|
||||
const clBoxLeft = W - MARGIN - clientLogoW;
|
||||
|
||||
if (clientLogoDataUrl) {
|
||||
const clImg = await loadImage(clientLogoDataUrl);
|
||||
if (clImg) {
|
||||
const ratio = clImg.naturalWidth / clImg.naturalHeight;
|
||||
const boxRatio = clientLogoW / clientLogoH;
|
||||
let dW, dH, dx, dy;
|
||||
if (ratio > boxRatio) {
|
||||
dW = clientLogoW; dH = dW / ratio;
|
||||
dx = clBoxLeft; dy = clBoxTop + (clientLogoH - dH) / 2;
|
||||
} else {
|
||||
dH = clientLogoH; dW = dH * ratio;
|
||||
dx = clBoxLeft + (clientLogoW - dW) / 2; dy = clBoxTop;
|
||||
}
|
||||
doc.addImage(clientLogoDataUrl, dx, dy, dW, dH);
|
||||
}
|
||||
} else {
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(210, 210, 210);
|
||||
doc.text('CLIENT LOGO', clBoxLeft + clientLogoW / 2, clBoxTop + clientLogoH / 2, { align: 'center' });
|
||||
}
|
||||
|
||||
} else {
|
||||
// ── Fourge Branding Default Cover ────────────────────────────────────────────
|
||||
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(0, 0, W, H, 'F');
|
||||
doc.setFillColor(...ACCENT);
|
||||
doc.rect(0, 0, W, 5, 'F');
|
||||
doc.rect(0, H - 5, W, 5, 'F');
|
||||
|
||||
if (logo) {
|
||||
doc.addImage(logo, 'PNG', MARGIN, 18, logoW, logoH);
|
||||
} else {
|
||||
doc.setFontSize(10); doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...DARK); doc.text('FOURGE BRANDING', MARGIN, 30);
|
||||
}
|
||||
|
||||
const cY = H / 2;
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...ACCENT); doc.setCharSpace(3);
|
||||
doc.text('BRAND BOOK', W / 2, cY - 50, { align: 'center' });
|
||||
doc.setCharSpace(0);
|
||||
doc.setDrawColor(...ACCENT); doc.setLineWidth(0.5);
|
||||
doc.line(W / 2 - 40, cY - 42, W / 2 + 40, cY - 42);
|
||||
|
||||
doc.setFontSize(34); doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...DARK);
|
||||
doc.text(customerName || clientName || '-', W / 2, cY - 14, { align: 'center' });
|
||||
|
||||
if (projectName) {
|
||||
doc.setFontSize(14); doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(100, 100, 100);
|
||||
doc.text(projectName, W / 2, cY + 14, { align: 'center' });
|
||||
}
|
||||
|
||||
const metaParts = [];
|
||||
if (siteAddress) metaParts.push(siteAddress);
|
||||
if (displayDate) metaParts.push(displayDate);
|
||||
if (preparedBy) metaParts.push(`Prepared by ${preparedBy}`);
|
||||
if (metaParts.length > 0) {
|
||||
doc.setFontSize(8); doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(metaParts.join(' · '), W / 2, cY + 36, { align: 'center' });
|
||||
}
|
||||
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(180, 180, 180);
|
||||
doc.text(`R${rev}`, W - MARGIN, cY + 36, { align: 'right' });
|
||||
|
||||
} // end template branch
|
||||
|
||||
// ─── SITE MAP PAGE (optional) ─────────────────────────────────────────────────
|
||||
if (siteMapDataUrl) {
|
||||
pageNum++;
|
||||
doc.addPage();
|
||||
if (template === 'bolchoz') {
|
||||
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
|
||||
|
||||
// "Site Map" header top-left
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('SITE MAP', MARGIN, MARGIN);
|
||||
doc.setCharSpace(0);
|
||||
|
||||
const smLabelH = 22; // space below label before map
|
||||
const smTop = MARGIN + smLabelH;
|
||||
const smBottom = H - MARGIN - BOLCHOZ_FOOTER_H;
|
||||
const smW = W - MARGIN * 2;
|
||||
const smH = smBottom - smTop;
|
||||
|
||||
const smImg = await loadImage(siteMapDataUrl);
|
||||
if (smImg) {
|
||||
const aspect = smImg.naturalWidth / smImg.naturalHeight;
|
||||
const boxAspect = smW / smH;
|
||||
let dw, dh, dx, dy;
|
||||
if (aspect > boxAspect) {
|
||||
dw = smW; dh = dw / aspect; dx = MARGIN; dy = smTop + (smH - dh) / 2;
|
||||
} else {
|
||||
dh = smH; dw = dh * aspect; dx = MARGIN + (smW - dw) / 2; dy = smTop;
|
||||
}
|
||||
doc.addImage(siteMapDataUrl, dx, dy, dw, dh);
|
||||
}
|
||||
} else {
|
||||
addHeader(doc, logo, logoW, logoH, 'Site Map', pageNum, totalPages);
|
||||
addFooter(doc, clientName, displayDate);
|
||||
const smTop = HEADER_H + 16;
|
||||
const smBottom = H - 30;
|
||||
const smW = W - MARGIN * 2;
|
||||
const smH = smBottom - smTop;
|
||||
const smImg = await loadImage(siteMapDataUrl);
|
||||
if (smImg) {
|
||||
const aspect = smImg.naturalWidth / smImg.naturalHeight;
|
||||
const boxAspect = smW / smH;
|
||||
let dw, dh, dx, dy;
|
||||
if (aspect > boxAspect) {
|
||||
dw = smW; dh = dw / aspect; dx = MARGIN; dy = smTop + (smH - dh) / 2;
|
||||
} else {
|
||||
dh = smH; dw = dh * aspect; dx = MARGIN + (smW - dw) / 2; dy = smTop;
|
||||
}
|
||||
doc.setDrawColor(200, 200, 200); doc.setLineWidth(0.4);
|
||||
doc.rect(MARGIN, smTop, smW, smH);
|
||||
doc.addImage(siteMapDataUrl, dx, dy, dw, dh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SIGN INVENTORY PAGE ─────────────────────────────────────────────────────
|
||||
pageNum++;
|
||||
doc.addPage();
|
||||
|
||||
const invContentTop = template === 'bolchoz' ? MARGIN : HEADER_H + 16;
|
||||
const invContentBottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
|
||||
const invContentH = invContentBottom - invContentTop;
|
||||
|
||||
const tableW = (W - MARGIN * 2) / 3;
|
||||
const mapW = W - MARGIN * 2 - tableW - 16;
|
||||
const mapX = MARGIN;
|
||||
const mapY = invContentTop;
|
||||
const tableX = MARGIN + mapW + 16;
|
||||
|
||||
if (template === 'bolchoz') {
|
||||
// White background
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(0, 0, W, H, 'F');
|
||||
|
||||
// Map at "cover" fill
|
||||
if (inventoryMapDataUrl) {
|
||||
const invMapImg = await loadImage(inventoryMapDataUrl);
|
||||
if (invMapImg) {
|
||||
const imgAspect = invMapImg.naturalWidth / invMapImg.naturalHeight;
|
||||
const boxAspect = mapW / invContentH;
|
||||
let drawW, drawH, drawX, drawY;
|
||||
if (imgAspect > boxAspect) {
|
||||
drawH = invContentH; drawW = invContentH * imgAspect;
|
||||
drawX = mapX - (drawW - mapW) / 2; drawY = mapY;
|
||||
} else {
|
||||
drawW = mapW; drawH = mapW / imgAspect;
|
||||
drawX = mapX; drawY = mapY - (drawH - invContentH) / 2;
|
||||
}
|
||||
doc.addImage(inventoryMapDataUrl, drawX, drawY, drawW, drawH);
|
||||
}
|
||||
}
|
||||
|
||||
// White overlays to crop image to map box
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(0, 0, W, mapY, 'F');
|
||||
doc.rect(0, mapY + invContentH, W, H - mapY - invContentH, 'F');
|
||||
doc.rect(0, mapY, mapX, invContentH, 'F');
|
||||
doc.rect(mapX + mapW, mapY, W - mapX - mapW, invContentH, 'F');
|
||||
|
||||
if (!inventoryMapDataUrl) {
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(180, 180, 180);
|
||||
doc.text('No Map Available', mapX + mapW / 2, mapY + invContentH / 2, { align: 'center' });
|
||||
}
|
||||
|
||||
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
|
||||
} else {
|
||||
addHeader(doc, logo, logoW, logoH, 'Sign Inventory', pageNum, totalPages);
|
||||
addFooter(doc, clientName, displayDate);
|
||||
if (inventoryMapDataUrl) {
|
||||
const invMapImg = await loadImage(inventoryMapDataUrl);
|
||||
if (invMapImg) {
|
||||
const imgAspect = invMapImg.naturalWidth / invMapImg.naturalHeight;
|
||||
const boxAspect = mapW / invContentH;
|
||||
let drawW, drawH, drawX, drawY;
|
||||
if (imgAspect > boxAspect) {
|
||||
drawW = mapW; drawH = mapW / imgAspect;
|
||||
drawX = mapX; drawY = mapY + (invContentH - drawH) / 2;
|
||||
} else {
|
||||
drawH = invContentH; drawW = invContentH * imgAspect;
|
||||
drawX = mapX + (mapW - drawW) / 2; drawY = mapY;
|
||||
}
|
||||
doc.addImage(inventoryMapDataUrl, drawX, drawY, drawW, drawH);
|
||||
}
|
||||
} else {
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(180, 180, 180);
|
||||
doc.text('No Map Available', mapX + mapW / 2, mapY + invContentH / 2, { align: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
drawInventoryTable(doc, signs, tableX, invContentTop, tableW, invContentH, template);
|
||||
|
||||
// ─── SIGN PAGES ───────────────────────────────────────────────────────────────
|
||||
for (let i = 0; i < signs.length; i++) {
|
||||
const sign = signs[i];
|
||||
const photoDataUrl = signPhotoDataUrls[i];
|
||||
pageNum++;
|
||||
doc.addPage();
|
||||
const signLabel = `Sign ${sign.signNumber || (i + 1)} — ${sign.type || 'Sign'}`;
|
||||
if (template === 'bolchoz') {
|
||||
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
|
||||
} else {
|
||||
addHeader(doc, logo, logoW, logoH, signLabel, pageNum, totalPages);
|
||||
addFooter(doc, clientName, displayDate);
|
||||
}
|
||||
|
||||
const top = template === 'bolchoz' ? MARGIN : HEADER_H + 16;
|
||||
const bottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
|
||||
const availH = bottom - top;
|
||||
const photoW = (W - MARGIN * 2 - 20) * 0.45;
|
||||
const specsX = MARGIN + photoW + 20;
|
||||
const specsW = W - MARGIN - specsX;
|
||||
|
||||
if (photoDataUrl) {
|
||||
const photoImg = await loadImage(photoDataUrl);
|
||||
if (photoImg) {
|
||||
const imgAspect = photoImg.naturalWidth / photoImg.naturalHeight;
|
||||
const boxAspect = photoW / availH;
|
||||
let dw, dh, dx, dy;
|
||||
if (imgAspect > boxAspect) {
|
||||
dw = photoW; dh = photoW / imgAspect;
|
||||
dx = MARGIN; dy = top + (availH - dh) / 2;
|
||||
} else {
|
||||
dh = availH; dw = availH * imgAspect;
|
||||
dx = MARGIN + (photoW - dw) / 2; dy = top;
|
||||
}
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(MARGIN, top, photoW, availH, 'F');
|
||||
doc.addImage(photoDataUrl, dx, dy, dw, dh);
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(MARGIN, top, photoW, availH);
|
||||
}
|
||||
} else {
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(MARGIN, top, photoW, availH, 'F');
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(180, 180, 180);
|
||||
doc.text('No Photo', MARGIN + photoW / 2, top + availH / 2, { align: 'center' });
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(MARGIN, top, photoW, availH);
|
||||
}
|
||||
|
||||
let sy = top;
|
||||
|
||||
doc.setFillColor(...ACCENT);
|
||||
doc.roundedRect(specsX, sy, 44, 18, 3, 3, 'F');
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...DARK);
|
||||
doc.text(`#${sign.signNumber || (i + 1)}`, specsX + 22, sy + 12, { align: 'center' });
|
||||
sy += 26;
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(sign.type || 'Sign Type', specsX, sy);
|
||||
sy += 20;
|
||||
|
||||
doc.setDrawColor(...ACCENT);
|
||||
doc.setLineWidth(1);
|
||||
doc.line(specsX, sy, specsX + specsW, sy);
|
||||
sy += 12;
|
||||
|
||||
const specs = [
|
||||
['Location', sign.location || '-'],
|
||||
['Dimensions', sign.width && sign.height ? `${sign.width}" W × ${sign.height}" H` : '-'],
|
||||
['Material', sign.material || '— (placeholder)'],
|
||||
['Illumination', sign.illumination || '— (placeholder)'],
|
||||
['Condition', sign.condition || '— (placeholder)'],
|
||||
['Mount Type', sign.mountType || '— (placeholder)'],
|
||||
];
|
||||
|
||||
specs.forEach(([label, value]) => {
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(label.toUpperCase(), specsX, sy);
|
||||
sy += 9;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(40, 40, 40);
|
||||
const wrapped = doc.splitTextToSize(value, specsW);
|
||||
doc.text(wrapped, specsX, sy);
|
||||
sy += wrapped.length * 6 + 10;
|
||||
});
|
||||
|
||||
if (sign.notes) {
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text('NOTES', specsX, sy);
|
||||
sy += 9;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(80, 80, 80);
|
||||
const noteLines = doc.splitTextToSize(sign.notes, specsW);
|
||||
doc.text(noteLines, specsX, sy);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SITE PHOTOS PAGES (4×4 grid, 16 per page) ───────────────────────────────
|
||||
if (validSitePhotos.length > 0) {
|
||||
const cols = 4;
|
||||
const rows = 4;
|
||||
const gapX = 8;
|
||||
const gapY = 8;
|
||||
const photoPageCount = Math.ceil(validSitePhotos.length / PHOTOS_PER_PAGE);
|
||||
|
||||
for (let pg = 0; pg < photoPageCount; pg++) {
|
||||
pageNum++;
|
||||
doc.addPage();
|
||||
const pageLabel = photoPageCount > 1 ? `Site Photos (${pg + 1}/${photoPageCount})` : 'Site Photos';
|
||||
if (template === 'bolchoz') {
|
||||
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
|
||||
} else {
|
||||
addHeader(doc, logo, logoW, logoH, pageLabel, pageNum, totalPages);
|
||||
addFooter(doc, clientName, displayDate);
|
||||
}
|
||||
|
||||
const top = template === 'bolchoz' ? MARGIN : HEADER_H + 14;
|
||||
const bottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
|
||||
const availW = W - MARGIN * 2;
|
||||
const thumbW = (availW - gapX * (cols - 1)) / cols;
|
||||
const thumbH = (bottom - top - gapY * (rows - 1)) / rows;
|
||||
|
||||
const pagePhotos = validSitePhotos.slice(pg * PHOTOS_PER_PAGE, (pg + 1) * PHOTOS_PER_PAGE);
|
||||
for (let i = 0; i < pagePhotos.length; i++) {
|
||||
const dataUrl = pagePhotos[i];
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const tx = MARGIN + col * (thumbW + gapX);
|
||||
const ty = top + row * (thumbH + gapY);
|
||||
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(tx, ty, thumbW, thumbH, 'F');
|
||||
|
||||
const img = await loadImage(dataUrl);
|
||||
if (img) {
|
||||
const aspect = img.naturalWidth / img.naturalHeight;
|
||||
const boxAspect = thumbW / thumbH;
|
||||
let dw, dh, dx, dy;
|
||||
if (aspect > boxAspect) {
|
||||
dw = thumbW; dh = thumbW / aspect; dx = tx; dy = ty + (thumbH - dh) / 2;
|
||||
} else {
|
||||
dh = thumbH; dw = thumbH * aspect; dx = tx + (thumbW - dw) / 2; dy = ty;
|
||||
}
|
||||
doc.addImage(dataUrl, dx, dy, dw, dh);
|
||||
}
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.rect(tx, ty, thumbW, thumbH);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const safePart = (str) => (str || '').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, ' ');
|
||||
const filename = [safePart(projectName), safePart(siteAddress), `R${rev}`].filter(Boolean).join('.');
|
||||
doc.save(`${filename || 'BrandBook'}.pdf`);
|
||||
}
|
||||
|
||||
function drawInventoryTable(doc, signs, x, y, w, h, template) {
|
||||
const colDefs = template === 'bolchoz'
|
||||
? [
|
||||
{ label: '#', flex: 0.5 },
|
||||
{ label: 'Existing', flex: 1.5 },
|
||||
{ label: 'Recommendation', flex: 1.5 },
|
||||
]
|
||||
: [
|
||||
{ label: '#', flex: 0.5 },
|
||||
{ label: 'Type', flex: 1.5 },
|
||||
{ label: 'Location', flex: 2 },
|
||||
{ label: 'Dimensions', flex: 1.2 },
|
||||
{ label: 'Notes', flex: 2 },
|
||||
];
|
||||
const totalFlex = colDefs.reduce((s, c) => s + c.flex, 0);
|
||||
const cols = colDefs.map(c => ({ ...c, w: (c.flex / totalFlex) * w }));
|
||||
|
||||
const rowH = 18;
|
||||
doc.setFillColor(...DARK);
|
||||
doc.rect(x, y, w, rowH, 'F');
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...ACCENT);
|
||||
|
||||
let cx = x;
|
||||
cols.forEach(col => { doc.text(col.label, cx + 5, y + 12); cx += col.w; });
|
||||
|
||||
let ry = y + rowH;
|
||||
signs.forEach((sign, i) => {
|
||||
if (ry > y + h - rowH) return;
|
||||
const rowData = template === 'bolchoz'
|
||||
? [
|
||||
sign.signNumber || String(i + 1),
|
||||
sign.type || '',
|
||||
sign.recommendation || '',
|
||||
]
|
||||
: [
|
||||
sign.signNumber || String(i + 1),
|
||||
sign.type || '-',
|
||||
sign.location || '-',
|
||||
sign.width && sign.height ? `${sign.width}" × ${sign.height}"` : '-',
|
||||
sign.notes || '',
|
||||
];
|
||||
|
||||
doc.setFillColor(i % 2 === 0 ? 248 : 242, i % 2 === 0 ? 248 : 242, i % 2 === 0 ? 248 : 242);
|
||||
doc.rect(x, ry, w, rowH, 'F');
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(40, 40, 40);
|
||||
|
||||
cx = x;
|
||||
cols.forEach((col, ci) => {
|
||||
const truncated = doc.splitTextToSize(rowData[ci] || '', col.w - 8)[0] || '';
|
||||
doc.text(truncated, cx + 5, ry + 12);
|
||||
cx += col.w;
|
||||
});
|
||||
|
||||
doc.setDrawColor(220, 220, 220);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.line(x, ry + rowH, x + w, ry + rowH);
|
||||
ry += rowH;
|
||||
});
|
||||
|
||||
doc.setDrawColor(180, 180, 180);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(x, y, w, ry - y);
|
||||
|
||||
if (signs.length === 0) {
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'italic');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.text('No signs added yet', x + w / 2, y + rowH + 16, { align: 'center' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user