From d6e49a4c67bedeeb84b30d5e11fe3812bc2a6a67 Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Tue, 14 Apr 2026 12:16:22 -0400 Subject: [PATCH] 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 --- src/App.jsx | 8 +- src/components/FileAttachment.jsx | 69 +- src/components/Layout.jsx | 21 +- src/components/ProtectedRoute.jsx | 8 +- src/components/StatusBadge.jsx | 2 + src/index.css | 10 +- src/lib/brandBookEditor.js | 807 ++++++++++++ src/lib/brandbook.js | 546 ++++++++ src/lib/deleteHelpers.js | 47 + src/lib/email.js | 12 +- src/lib/signsurvey.js | 441 +++++++ src/pages/client/ClientDashboard.jsx | 13 +- src/pages/client/MyInvoices.jsx | 2 +- src/pages/client/MyProjectDetail.jsx | 11 +- src/pages/client/MyProjects.jsx | 14 +- src/pages/client/MyRequests.jsx | 2 +- src/pages/client/RequestDetail.jsx | 139 ++- src/pages/team/BrandBook.jsx | 1108 +++++++++++++++++ src/pages/team/BrandIdentity.jsx | 505 ++++++++ src/pages/team/Companies.jsx | 119 +- src/pages/team/CompanyDetail.jsx | 339 ++++- src/pages/team/CreateInvoice.jsx | 260 +++- src/pages/team/Dashboard.jsx | 140 ++- src/pages/team/InvoiceDetail.jsx | 55 +- src/pages/team/Invoices.jsx | 10 +- src/pages/team/ProjectDetail.jsx | 293 ++++- src/pages/team/Requests.jsx | 55 +- src/pages/team/SignSurvey.jsx | 802 ++++++++++++ src/pages/team/TaskDetail.jsx | 424 ++++++- .../functions/backfill-stripe-fees/index.ts | 74 ++ supabase/functions/stripe-webhook/index.ts | 56 + ...60414120000_add_stripe_fee_to_invoices.sql | 2 + .../add_brand_book_cover_fields.sql | 13 + .../add_brand_book_template_fields.sql | 4 + .../migrations/add_company_brand_fields.sql | 29 + supabase/migrations/add_external_role.sql | 123 ++ supabase/migrations/add_price_type.sql | 17 + supabase/migrations/add_revision_billing.sql | 15 + supabase/schema.sql | 323 ++++- 39 files changed, 6618 insertions(+), 300 deletions(-) create mode 100644 src/lib/brandBookEditor.js create mode 100644 src/lib/brandbook.js create mode 100644 src/lib/deleteHelpers.js create mode 100644 src/lib/signsurvey.js create mode 100644 src/pages/team/BrandBook.jsx create mode 100644 src/pages/team/BrandIdentity.jsx create mode 100644 src/pages/team/SignSurvey.jsx create mode 100644 supabase/functions/backfill-stripe-fees/index.ts create mode 100644 supabase/functions/stripe-webhook/index.ts create mode 100644 supabase/migrations/20260414120000_add_stripe_fee_to_invoices.sql create mode 100644 supabase/migrations/add_brand_book_cover_fields.sql create mode 100644 supabase/migrations/add_brand_book_template_fields.sql create mode 100644 supabase/migrations/add_company_brand_fields.sql create mode 100644 supabase/migrations/add_external_role.sql create mode 100644 supabase/migrations/add_price_type.sql create mode 100644 supabase/migrations/add_revision_billing.sql diff --git a/src/App.jsx b/src/App.jsx index 470ec2e..0857c7a 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,6 +13,7 @@ import Requests from './pages/team/Requests'; import Invoices from './pages/team/Invoices'; import CreateInvoice from './pages/team/CreateInvoice'; import InvoiceDetail from './pages/team/InvoiceDetail'; +import BrandBook from './pages/team/BrandBook'; import Settings from './pages/Settings'; import ClientDashboard from './pages/client/ClientDashboard'; @@ -31,15 +32,16 @@ export default function App() { } /> - } /> + } /> + } /> + } /> } /> } /> - } /> - } /> } /> } /> } /> } /> + } /> } /> diff --git a/src/components/FileAttachment.jsx b/src/components/FileAttachment.jsx index d3f73cf..a166f92 100755 --- a/src/components/FileAttachment.jsx +++ b/src/components/FileAttachment.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; const MAX_FILES = 20; const MAX_SIZE_MB = 10; @@ -11,24 +11,48 @@ const formatSize = (bytes) => { export default function FileAttachment({ files, onChange }) { const [errors, setErrors] = useState([]); + const [dragging, setDragging] = useState(false); + const dragCounter = useRef(0); - const handleChange = (e) => { - const incoming = Array.from(e.target.files); + const processFiles = (incoming) => { const combined = [...files, ...incoming]; const errs = []; - - if (combined.length > MAX_FILES) { - errs.push(`Maximum ${MAX_FILES} files allowed.`); - } - incoming.filter(f => f.size > MAX_SIZE_BYTES) - .forEach(f => errs.push(`"${f.name}" exceeds ${MAX_SIZE_MB} MB limit.`)); - + if (combined.length > MAX_FILES) errs.push(`Maximum ${MAX_FILES} files allowed.`); + incoming.filter(f => f.size > MAX_SIZE_BYTES).forEach(f => errs.push(`"${f.name}" exceeds ${MAX_SIZE_MB} MB limit.`)); if (errs.length > 0) { setErrors(errs); return; } setErrors([]); onChange(combined); + }; + + const handleChange = (e) => { + processFiles(Array.from(e.target.files)); e.target.value = ''; }; + const handleDragEnter = (e) => { + e.preventDefault(); + dragCounter.current++; + setDragging(true); + }; + + const handleDragLeave = (e) => { + e.preventDefault(); + dragCounter.current--; + if (dragCounter.current === 0) setDragging(false); + }; + + const handleDragOver = (e) => { + e.preventDefault(); + }; + + const handleDrop = (e) => { + e.preventDefault(); + dragCounter.current = 0; + setDragging(false); + const dropped = Array.from(e.dataTransfer.files); + if (dropped.length > 0) processFiles(dropped); + }; + const remove = (index) => { setErrors([]); onChange(files.filter((_, i) => i !== index)); @@ -43,16 +67,27 @@ export default function FileAttachment({ files, onChange }) { -
0 ? 'var(--accent)' : 'var(--border)'}`, - borderRadius: 8, padding: '18px 16px', textAlign: 'center', - background: 'var(--bg)', transition: 'all 0.15s', - }}> +
0 ? 'var(--accent)' : 'var(--border)'}`, + borderRadius: 8, padding: '18px 16px', textAlign: 'center', + background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)', + transition: 'all 0.15s', + }} + > diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 270a347..517503f 100755 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -7,9 +7,10 @@ function TeamNav({ onNav }) {
{[ { to: '/dashboard', label: 'Dashboard' }, - { to: '/requests', label: 'Requests' }, + { to: '/requests', label: 'Requests Inbox' }, + { to: '/brand-book', label: 'Brand Book (beta)' }, { to: '/invoices', label: 'Invoices' }, - { to: '/companies', label: 'Companies' }, + { to: '/companies', label: 'Clients & Users' }, ].map(({ to, label }) => ( `sidebar-link${isActive ? ' active' : ''}`}> {label} @@ -36,6 +37,16 @@ function ClientNav({ onNav }) { ); } +function ExternalNav({ onNav }) { + return ( +
+ `sidebar-link${isActive ? ' active' : ''}`}> + Dashboard + +
+ ); +} + export default function Layout({ children }) { const { currentUser, logout } = useAuth(); const navigate = useNavigate(); @@ -69,14 +80,16 @@ export default function Layout({ children }) { {currentUser?.role === 'team' ? setMenuOpen(false)} /> - : setMenuOpen(false)} />} + : currentUser?.role === 'external' + ? setMenuOpen(false)} /> + : setMenuOpen(false)} />}
setMenuOpen(false)} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
{initials}
{currentUser?.name || 'Set your name'}
-
{currentUser?.role}
+
{currentUser?.role === 'external' ? 'Team' : currentUser?.role}
diff --git a/src/components/ProtectedRoute.jsx b/src/components/ProtectedRoute.jsx index 5e9f415..06a51f7 100755 --- a/src/components/ProtectedRoute.jsx +++ b/src/components/ProtectedRoute.jsx @@ -4,9 +4,11 @@ import { useAuth } from '../context/AuthContext'; export default function ProtectedRoute({ children, role }) { const { currentUser } = useAuth(); if (!currentUser) return ; - if (role && currentUser.role !== role) { - return ; + if (role) { + const allowed = Array.isArray(role) ? role : [role]; + if (!allowed.includes(currentUser.role)) { + return ; + } } return children; } - diff --git a/src/components/StatusBadge.jsx b/src/components/StatusBadge.jsx index c7a87ac..ec85e0f 100755 --- a/src/components/StatusBadge.jsx +++ b/src/components/StatusBadge.jsx @@ -1,4 +1,6 @@ const labels = { + client_revision: 'Client Revision', + fourge_error: 'Fourge Error', not_started: 'Not Started', in_progress: 'In Progress', on_hold: 'On Hold', diff --git a/src/index.css b/src/index.css index 610ff07..efa972f 100755 --- a/src/index.css +++ b/src/index.css @@ -69,6 +69,8 @@ [data-theme="light"] .badge-revision { background: #fff7ed; color: #c2410c; border-color: #fed7aa; } [data-theme="light"] .badge-team { background: #f5f3ff; color: #7c3aed; border-color: #ddd6fe; } [data-theme="light"] .badge-client { background: #fffbeb; color: #b45309; border-color: #fde68a; } +[data-theme="light"] .badge-client_revision { background: #fff7ed; color: #c2410c; border-color: #fed7aa; } +[data-theme="light"] .badge-fourge_error { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; } [data-theme="light"] .notification-success { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; } [data-theme="light"] .notification-info { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; } [data-theme="light"] .btn-outline { color: #1a1a1a; border-color: #d0d0d0; } @@ -173,10 +175,11 @@ body { .btn { display: inline-flex; align-items: center; gap: 6px; padding: 9px 18px; border-radius: 6px; font-size: 13px; - font-weight: 600; cursor: pointer; border: none; + font-weight: 600; cursor: pointer; border: 1px solid transparent; transition: all 0.15s; text-decoration: none; white-space: nowrap; - font-family: inherit; + font-family: inherit; line-height: 1; } +.btn-sm { padding: 5px 12px; font-size: 12px; line-height: 1; } .btn-primary { background: var(--accent); color: #111111; } .btn-primary:hover { background: var(--accent-hover); } .btn-outline { background: transparent; color: var(--text-primary); border: 1px solid var(--border); } @@ -187,7 +190,6 @@ body { .btn-warning:hover { background: var(--accent-hover); } .btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); } .btn-danger:hover { background: var(--danger); color: white; } -.btn-sm { padding: 5px 12px; font-size: 12px; } .btn-lg { padding: 13px 28px; font-size: 15px; } /* Badges */ @@ -206,6 +208,8 @@ body { .badge-revision { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); } .badge-team { background: rgba(124,58,237,0.15); color: #c4b5fd; border: 1px solid rgba(124,58,237,0.3); } .badge-client { background: rgba(245,165,35,0.15); color: var(--accent); border: 1px solid rgba(245,165,35,0.3); } +.badge-client_revision { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); } +.badge-fourge_error { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); } /* Table */ .table-wrapper { overflow-x: auto; border-radius: 8px; border: 1px solid var(--border); background: var(--card-bg); } diff --git a/src/lib/brandBookEditor.js b/src/lib/brandBookEditor.js new file mode 100644 index 0000000..328f818 --- /dev/null +++ b/src/lib/brandBookEditor.js @@ -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' }); + } +} diff --git a/src/lib/brandbook.js b/src/lib/brandbook.js new file mode 100644 index 0000000..54e4eb7 --- /dev/null +++ b/src/lib/brandbook.js @@ -0,0 +1,546 @@ +import jsPDF from 'jspdf'; + +function loadImage(src) { + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = src; + }); +} + +function hexToRgb(hex) { + const clean = hex.replace('#', ''); + const bigint = parseInt(clean, 16); + return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255]; +} + +function isLight(hex) { + const [r, g, b] = hexToRgb(hex); + return (r * 299 + g * 587 + b * 114) / 1000 > 160; +} + +function getImgFormat(dataUrl) { + if (!dataUrl) return 'PNG'; + if (/image\/jpe?g/i.test(dataUrl)) return 'JPEG'; + return 'PNG'; +} + +async function toDataUrl(url) { + const img = await loadImage(url); + if (!img) return null; + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + canvas.getContext('2d').drawImage(img, 0, 0); + return canvas.toDataURL('image/png'); +} + +function sectionHeader(doc, label, y, pageWidth) { + doc.setFillColor(245, 165, 35); + doc.rect(14, y, 3, 10, 'F'); + doc.setFontSize(10); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(30, 30, 30); + doc.text(label.toUpperCase(), 21, y + 7); + return y + 18; +} + +function addHeader(doc, pageWidth, logo, logoW, logoH, headerH) { + doc.setFillColor(20, 20, 20); + doc.rect(0, 0, pageWidth, headerH, 'F'); + if (logo) { + doc.addImage(logo, 'PNG', 14, 6, logoW, logoH); + } else { + doc.setFontSize(10); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(255, 255, 255); + doc.text('FOURGE BRANDING', 14, headerH / 2 + 3); + } + doc.setFontSize(7); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(130, 130, 130); + doc.text('hello@fourgebranding.com ยท www.fourgebranding.com', pageWidth - 14, headerH / 2 + 2, { align: 'right' }); +} + +function addPageNumber(doc, pageNum, total, pageHeight, pageWidth) { + doc.setFontSize(7); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(180, 180, 180); + doc.text(`${pageNum} / ${total}`, pageWidth - 14, pageHeight - 8, { align: 'right' }); + doc.setDrawColor(230, 230, 230); + doc.setLineWidth(0.3); + doc.line(14, pageHeight - 13, pageWidth - 14, pageHeight - 13); +} + +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' }); +} + +export async function generateBrandBookPDF(data) { + const doc = new jsPDF(); + const pageWidth = doc.internal.pageSize.width; + const pageHeight = doc.internal.pageSize.height; + + // Preload Fourge logo for inner pages + const fourgeLogoImg = await loadImage('/fourge-logo.png'); + const logoW = 36; + const logoH = fourgeLogoImg ? (logoW / (fourgeLogoImg.naturalWidth / fourgeLogoImg.naturalHeight)) : 8; + const headerH = logoH + 12; + + // Pre-convert client logo URL to data URL + let clientLogoDataUrl = null; + if (data.clientLogoUrl) { + clientLogoDataUrl = await toDataUrl(data.clientLogoUrl); + } + + const colors = (data.colors || []).filter(c => c.name || c.hex); + const hasFonts = data.primaryFont || data.secondaryFont || data.fontNotes; + const hasVoice = data.brandVoice || data.brandAdjectives; + const hasLogo = data.logoNotes; + const hasDoDont = data.dos || data.donts; + + let totalPages = 1; + if (data.brandStory || data.brandValues) totalPages++; + if (colors.length > 0) totalPages++; + if (hasFonts) totalPages++; + if (hasVoice) totalPages++; + if (hasLogo || hasDoDont) totalPages++; + + let currentPage = 0; + + // โ”€โ”€โ”€ PAGE 1: Cover โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + currentPage++; + + const M = 12.7; // 0.5" margin in mm + const logoBox = 127; // 5" ร— 5" in mm + const clientLogoW = 88.9; // 3.5" in mm + const clientLogoH = 38.1; // 1.5" in mm + + // White background + doc.setFillColor(255, 255, 255); + doc.rect(0, 0, pageWidth, pageHeight, 'F'); + + // โ”€โ”€ Project logo box (top left) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + doc.setDrawColor(210, 210, 210); + doc.setLineWidth(0.4); + doc.rect(M, M, logoBox, logoBox); + + if (data.projectLogoDataUrl) { + const pImg = await loadImage(data.projectLogoDataUrl); + if (pImg) { + const ratio = pImg.naturalWidth / pImg.naturalHeight; + let dW, dH; + if (ratio >= 1) { + // Landscape/square: fill width first + dW = logoBox; + dH = dW / ratio; + } else { + // Portrait: fill height first + dH = logoBox; + dW = dH * ratio; + } + doc.addImage(data.projectLogoDataUrl, getImgFormat(data.projectLogoDataUrl), M, M, dW, dH); + } + } else { + doc.setFontSize(8); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(210, 210, 210); + doc.text('PROJECT LOGO', M + logoBox / 2, M + logoBox / 2, { align: 'center' }); + } + + // โ”€โ”€ Dates (top right) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const dateColX = M + logoBox + 10; + let ty = M + 4; + + doc.setFontSize(7); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(160, 160, 160); + doc.setCharSpace(1.2); + doc.text('CREATION DATE', dateColX, ty); + doc.setCharSpace(0); + ty += 6; + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(30, 30, 30); + doc.text(formatDate(data.creationDate), dateColX, ty); + ty += 16; + + doc.setFontSize(7); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(160, 160, 160); + doc.setCharSpace(1.2); + doc.text('REVISION DATE', dateColX, ty); + doc.setCharSpace(0); + ty += 6; + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(30, 30, 30); + doc.text(formatDate(data.revisionDate), dateColX, ty); + + // โ”€โ”€ Separator line โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const sepY = M + logoBox + 7; + doc.setDrawColor(210, 210, 210); + doc.setLineWidth(0.3); + doc.line(M, sepY, pageWidth - M, sepY); + + const botY = sepY + 10; + const colW = (pageWidth - 2 * M - 10) / 2; + const rightColX = M + colW + 10; + + // โ”€โ”€ Bottom left: Customer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + let lY = botY; + doc.setFontSize(7); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(160, 160, 160); + doc.setCharSpace(1.2); + doc.text('CUSTOMER', M, lY); + doc.setCharSpace(0); + lY += 7; + doc.setFontSize(13); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(20, 20, 20); + doc.text(data.customerName || 'โ€”', M, lY); + lY += 8; + if (data.streetAddress) { + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(80, 80, 80); + const addrLines = doc.splitTextToSize(data.streetAddress, colW); + doc.text(addrLines, M, lY); + } + + // โ”€โ”€ Bottom right: Signature / Approval โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + let rY = botY; + + // Signature of approval + doc.setFontSize(7); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(160, 160, 160); + doc.setCharSpace(1.2); + doc.text('SIGNATURE OF APPROVAL', rightColX, rY); + doc.setCharSpace(0); + rY += 12; + doc.setDrawColor(180, 180, 180); + doc.setLineWidth(0.3); + doc.line(rightColX, rY, pageWidth - M, rY); + rY += 14; + + // Approved date + doc.setFontSize(7); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(160, 160, 160); + doc.setCharSpace(1.2); + doc.text('APPROVED DATE', rightColX, rY); + doc.setCharSpace(0); + rY += 6; + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(30, 30, 30); + doc.text(formatDate(data.approvedDate), rightColX, rY); + rY += 14; + + // Notes + doc.setFontSize(7); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(160, 160, 160); + doc.setCharSpace(1.2); + doc.text('NOTES', rightColX, rY); + doc.setCharSpace(0); + rY += 6; + if (data.approvalNotes) { + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(60, 60, 60); + const noteLines = doc.splitTextToSize(data.approvalNotes, colW); + doc.text(noteLines, rightColX, rY); + } + + // โ”€โ”€ Client logo + contact (right-bottom aligned) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Contact text (right-aligned), bottom of page + const contactLineH = 5.5; + const hasContact = data.clientContactName || data.clientContactEmail || data.clientContactPhone; + const contactLines = [data.clientContactName, data.clientContactEmail, data.clientContactPhone].filter(Boolean); + const contactBlockH = contactLines.length * contactLineH; + const contactStartY = pageHeight - M - contactBlockH; + + if (contactLines.length > 0) { + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(60, 60, 60); + let cy = contactStartY + 4; + if (data.clientContactName) { + doc.setFont('helvetica', 'bold'); + doc.text(data.clientContactName, pageWidth - M, cy, { align: 'right' }); + cy += contactLineH; + } + doc.setFont('helvetica', 'normal'); + if (data.clientContactEmail) { doc.text(data.clientContactEmail, pageWidth - M, cy, { align: 'right' }); cy += contactLineH; } + if (data.clientContactPhone) { doc.text(data.clientContactPhone, pageWidth - M, cy, { align: 'right' }); } + } + + // Client logo box: 3.5" ร— 1.5", right-bottom, above contact text + const logoBoxGap = hasContact ? contactBlockH + 5 : 4; + const clientLogoBoxBottom = pageHeight - M - (hasContact ? contactBlockH + 6 : 2); + const clientLogoBoxTop = clientLogoBoxBottom - clientLogoH; + const clientLogoBoxLeft = pageWidth - M - clientLogoW; + + doc.setDrawColor(210, 210, 210); + doc.setLineWidth(0.3); + doc.rect(clientLogoBoxLeft, clientLogoBoxTop, clientLogoW, clientLogoH); + + if (clientLogoDataUrl) { + const clImg = await loadImage(clientLogoDataUrl); + if (clImg) { + const ratio = clImg.naturalWidth / clImg.naturalHeight; + // Scale to contain within box + let dW = clientLogoW, dH = clientLogoH; + if (ratio > clientLogoW / clientLogoH) { + dW = clientLogoW; + dH = dW / ratio; + } else { + dH = clientLogoH; + dW = dH * ratio; + } + // Center in box + const ox = clientLogoBoxLeft + (clientLogoW - dW) / 2; + const oy = clientLogoBoxTop + (clientLogoH - dH) / 2; + doc.addImage(clientLogoDataUrl, 'PNG', ox, oy, dW, dH); + } + } else if (!hasContact) { + doc.setFontSize(8); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(210, 210, 210); + doc.text('CLIENT LOGO', clientLogoBoxLeft + clientLogoW / 2, clientLogoBoxTop + clientLogoH / 2, { align: 'center' }); + } + + // โ”€โ”€โ”€ PAGE 2: Brand Story + Values โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (data.brandStory || data.brandValues) { + currentPage++; + doc.addPage(); + addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH); + let y = headerH + 16; + + if (data.brandStory) { + y = sectionHeader(doc, 'Brand Story', y, pageWidth); + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(60, 60, 60); + const lines = doc.splitTextToSize(data.brandStory, pageWidth - 28); + doc.text(lines, 14, y); + y += lines.length * 6 + 16; + } + + if (data.brandValues) { + y = sectionHeader(doc, 'Brand Values', y, pageWidth); + const values = data.brandValues.split('\n').map(v => v.trim()).filter(Boolean); + values.forEach(val => { + doc.setFillColor(245, 165, 35); + doc.circle(17, y - 1, 1.2, 'F'); + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(50, 50, 50); + doc.text(val, 22, y); + y += 8; + }); + } + + addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth); + } + + // โ”€โ”€โ”€ PAGE 3: Color Palette โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (colors.length > 0) { + currentPage++; + doc.addPage(); + addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH); + let y = headerH + 16; + y = sectionHeader(doc, 'Color Palette', y, pageWidth); + + const swatchW = 52; + const swatchH = 40; + const cols = 3; + const gapX = (pageWidth - 28 - swatchW * cols) / (cols - 1); + + colors.forEach((color, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + const x = 14 + col * (swatchW + gapX); + const sy = y + row * (swatchH + 22); + + const rgb = hexToRgb(color.hex || '#cccccc'); + doc.setFillColor(...rgb); + doc.roundedRect(x, sy, swatchW, swatchH, 3, 3, 'F'); + + doc.setFontSize(7); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...(isLight(color.hex || '#cccccc') ? [60, 60, 60] : [220, 220, 220])); + doc.text((color.hex || '').toUpperCase(), x + swatchW / 2, sy + swatchH - 5, { align: 'center' }); + + doc.setFontSize(9); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(40, 40, 40); + doc.text(color.name || 'Unnamed', x + swatchW / 2, sy + swatchH + 8, { align: 'center' }); + }); + + addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth); + } + + // โ”€โ”€โ”€ PAGE 4: Typography โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (hasFonts) { + currentPage++; + doc.addPage(); + addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH); + let y = headerH + 16; + y = sectionHeader(doc, 'Typography', y, pageWidth); + + const fontItems = [ + data.primaryFont && ['Primary Font', data.primaryFont], + data.secondaryFont && ['Secondary Font', data.secondaryFont], + ].filter(Boolean); + + fontItems.forEach(([label, fontName]) => { + doc.setFillColor(248, 248, 248); + doc.setDrawColor(220, 220, 220); + doc.setLineWidth(0.3); + doc.roundedRect(14, y, pageWidth - 28, 28, 3, 3, 'FD'); + + doc.setFontSize(7); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(150, 150, 150); + doc.text(label.toUpperCase(), 20, y + 8); + + doc.setFontSize(16); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(30, 30, 30); + doc.text(fontName, 20, y + 21); + + y += 36; + }); + + if (data.fontNotes) { + y += 4; + y = sectionHeader(doc, 'Usage Notes', y, pageWidth); + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(60, 60, 60); + const lines = doc.splitTextToSize(data.fontNotes, pageWidth - 28); + doc.text(lines, 14, y); + } + + addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth); + } + + // โ”€โ”€โ”€ PAGE 5: Brand Voice โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (hasVoice) { + currentPage++; + doc.addPage(); + addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH); + let y = headerH + 16; + y = sectionHeader(doc, 'Brand Voice & Tone', y, pageWidth); + + if (data.brandVoice) { + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(60, 60, 60); + const lines = doc.splitTextToSize(data.brandVoice, pageWidth - 28); + doc.text(lines, 14, y); + y += lines.length * 6 + 16; + } + + if (data.brandAdjectives) { + y = sectionHeader(doc, 'Brand Personality', y, pageWidth); + const tags = data.brandAdjectives.split(',').map(t => t.trim()).filter(Boolean); + let tx = 14; + tags.forEach(tag => { + const tw = doc.getTextWidth(tag) + 10; + if (tx + tw > pageWidth - 14) { tx = 14; y += 14; } + doc.setFillColor(255, 243, 215); + doc.setDrawColor(245, 165, 35); + doc.setLineWidth(0.3); + doc.roundedRect(tx, y - 6, tw, 10, 2, 2, 'FD'); + doc.setFontSize(8); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(150, 100, 20); + doc.text(tag, tx + 5, y + 1); + tx += tw + 5; + }); + } + + addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth); + } + + // โ”€โ”€โ”€ PAGE 6: Logo + Do's & Don'ts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (hasLogo || hasDoDont) { + currentPage++; + doc.addPage(); + addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH); + let y = headerH + 16; + + if (hasLogo) { + y = sectionHeader(doc, 'Logo Usage Guidelines', y, pageWidth); + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(60, 60, 60); + const lines = doc.splitTextToSize(data.logoNotes, pageWidth - 28); + doc.text(lines, 14, y); + y += lines.length * 6 + 16; + } + + if (hasDoDont) { + const colW = (pageWidth - 28 - 8) / 2; + + if (data.dos) { + doc.setFillColor(22, 163, 74); + doc.rect(14, y, 3, 10, 'F'); + doc.setFontSize(10); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(22, 163, 74); + doc.text("DO'S", 21, y + 7); + + const dosLines = data.dos.split('\n').map(l => l.trim()).filter(Boolean); + let dy = y + 18; + dosLines.forEach(line => { + doc.setFillColor(22, 163, 74); + doc.circle(16, dy - 1, 1.2, 'F'); + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(50, 50, 50); + const wrapped = doc.splitTextToSize(line, colW - 10); + doc.text(wrapped, 21, dy); + dy += wrapped.length * 5.5 + 3; + }); + } + + if (data.donts) { + const startX = 14 + colW + 8; + doc.setFillColor(220, 38, 38); + doc.rect(startX, y, 3, 10, 'F'); + doc.setFontSize(10); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(220, 38, 38); + doc.text("DON'TS", startX + 7, y + 7); + + const dontsLines = data.donts.split('\n').map(l => l.trim()).filter(Boolean); + let dy = y + 18; + dontsLines.forEach(line => { + doc.setFillColor(220, 38, 38); + doc.circle(startX + 2, dy - 1, 1.2, 'F'); + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(50, 50, 50); + const wrapped = doc.splitTextToSize(line, colW - 10); + doc.text(wrapped, startX + 7, dy); + dy += wrapped.length * 5.5 + 3; + }); + } + } + + addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth); + } + + const safeName = (data.brandName || 'brand-book').toLowerCase().replace(/[^a-z0-9]+/g, '-'); + doc.save(`${safeName}-brand-book.pdf`); +} diff --git a/src/lib/deleteHelpers.js b/src/lib/deleteHelpers.js new file mode 100644 index 0000000..b3092d5 --- /dev/null +++ b/src/lib/deleteHelpers.js @@ -0,0 +1,47 @@ +import { supabase } from './supabase'; + +/** + * Deletes all storage files (submissions + deliveries buckets) for the given task IDs. + * Call this before deleting tasks/projects/companies from the DB. + * The DB cascade handles record cleanup โ€” this handles the storage files only. + */ +export async function cleanupTaskStorage(taskIds) { + if (!taskIds?.length) return; + + // Get all submissions for these tasks + const { data: subs } = await supabase + .from('submissions') + .select('id') + .in('task_id', taskIds); + + const subIds = (subs || []).map(s => s.id); + + if (subIds.length) { + // Delete submission files from storage + const { data: subFiles } = await supabase + .from('submission_files') + .select('storage_path') + .in('submission_id', subIds); + + const subPaths = (subFiles || []).map(f => f.storage_path).filter(Boolean); + if (subPaths.length) await supabase.storage.from('submissions').remove(subPaths); + + // Get deliveries (linked via submission_id, not task_id) + const { data: deliveries } = await supabase + .from('deliveries') + .select('id') + .in('submission_id', subIds); + + const delIds = (deliveries || []).map(d => d.id); + + if (delIds.length) { + const { data: delFiles } = await supabase + .from('delivery_files') + .select('storage_path') + .in('delivery_id', delIds); + + const delPaths = (delFiles || []).map(f => f.storage_path).filter(Boolean); + if (delPaths.length) await supabase.storage.from('deliveries').remove(delPaths); + } + } +} diff --git a/src/lib/email.js b/src/lib/email.js index 2bf9aa9..d77c86d 100755 --- a/src/lib/email.js +++ b/src/lib/email.js @@ -1,8 +1,16 @@ import { supabase } from './supabase'; export async function sendEmail(type, to, data) { - const { error } = await supabase.functions.invoke('send-email', { + const { data: result, error } = await supabase.functions.invoke('send-email', { body: { type, to, data }, }); - if (error) console.error('Email error:', error); + if (error) { + console.error('Email invoke error:', error); + throw new Error(`Email failed: ${error.message || JSON.stringify(error)}`); + } + if (result?.error) { + console.error('Email send error:', result.error); + throw new Error(`Email failed: ${JSON.stringify(result.error)}`); + } + return result; } diff --git a/src/lib/signsurvey.js b/src/lib/signsurvey.js new file mode 100644 index 0000000..76162e9 --- /dev/null +++ b/src/lib/signsurvey.js @@ -0,0 +1,441 @@ +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 = 32; + +// 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(...DARK); + doc.rect(0, 0, W, HEADER_H, 'F'); + doc.setFillColor(...ACCENT); + doc.rect(0, 0, 4, HEADER_H, 'F'); + + if (logo) { + doc.addImage(logo, 'PNG', MARGIN, (HEADER_H - logoH) / 2, logoW, logoH); + } else { + doc.setFontSize(9); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(255, 255, 255); + doc.text('FOURGE BRANDING', MARGIN, HEADER_H / 2 + 3); + } + + doc.setFontSize(9); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(200, 200, 200); + doc.text(title.toUpperCase(), W / 2, HEADER_H / 2 + 3, { align: 'center' }); + + doc.setFontSize(8); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(120, 120, 120); + doc.text(`${pageNum} / ${totalPages}`, W - MARGIN, HEADER_H / 2 + 3, { align: 'right' }); +} + +function addFooter(doc, clientName, date) { + doc.setDrawColor(60, 60, 60); + doc.setLineWidth(0.3); + doc.line(MARGIN, H - 22, W - MARGIN, H - 22); + doc.setFontSize(7); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(130, 130, 130); + 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' }); +} + +export async function generateBrandBookEditorPDF(data) { + const { clientName, projectName, siteAddress, bookDate, preparedBy, revision, + siteMapSource, signs, surveyPhotoSources } = 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 = await resolvePhoto(siteMapSource); + const signPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.photoSource))); + const surveyPhotoDataUrls = await Promise.all((surveyPhotoSources || []).map(s => resolvePhoto(s))); + + // Count pages + let totalPages = 1; // cover + totalPages++; // sign inventory + totalPages += signs.length; + if (surveyPhotoDataUrls.some(Boolean)) totalPages++; + + let pageNum = 0; + const displayDate = bookDate + ? new Date(bookDate + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + : ''; + + // โ”€โ”€โ”€ COVER PAGE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + pageNum++; + doc.setFillColor(...DARK); + doc.rect(0, 0, W, H, 'F'); + doc.setFillColor(...ACCENT); + doc.rect(0, 0, W, 4, 'F'); + doc.rect(0, H - 4, W, 4, 'F'); + + if (logo) { + doc.addImage(logo, 'PNG', MARGIN, 22, logoW, logoH); + } else { + doc.setFontSize(10); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(255, 255, 255); + doc.text('FOURGE BRANDING', MARGIN, 38); + } + + const cy = H / 2; + + doc.setFontSize(9); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...ACCENT); + doc.setCharSpace(3); + doc.text('BRAND BOOK', W / 2, cy - 54, { align: 'center' }); + doc.setCharSpace(0); + + doc.setDrawColor(...ACCENT); + doc.setLineWidth(0.5); + doc.line(W / 2 - 40, cy - 46, W / 2 + 40, cy - 46); + + doc.setFontSize(36); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(255, 255, 255); + doc.text(clientName || 'Client Name', W / 2, cy - 18, { align: 'center' }); + + if (projectName) { + doc.setFontSize(14); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(160, 160, 160); + doc.text(projectName, W / 2, cy + 12, { 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(9); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(100, 100, 100); + doc.text(metaParts.join(' ยท '), W / 2, cy + 36, { align: 'center' }); + } + + // Revision badge bottom right of cover + const rev = String(revision || '01').padStart(2, '0'); + doc.setFontSize(10); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(80, 80, 80); + doc.text(`R${rev}`, W - MARGIN, cy + 36, { align: 'right' }); + + // โ”€โ”€โ”€ PAGE 2: SIGN INVENTORY โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + pageNum++; + doc.addPage(); + addHeader(doc, logo, logoW, logoH, 'Sign Inventory', pageNum, totalPages); + addFooter(doc, clientName, displayDate); + + const contentTop = HEADER_H + 16; + const contentBottom = H - 30; + const contentH = contentBottom - contentTop; + + if (siteMapDataUrl) { + const mapW = (W - MARGIN * 2 - 16) * 0.55; + const mapH = contentH; + const mapX = MARGIN; + const mapY = contentTop; + + const siteImg = await loadImage(siteMapDataUrl); + if (siteImg) { + const imgAspect = siteImg.naturalWidth / siteImg.naturalHeight; + const boxAspect = mapW / mapH; + let drawW, drawH, drawX, drawY; + if (imgAspect > boxAspect) { + drawW = mapW; drawH = mapW / imgAspect; + drawX = mapX; drawY = mapY + (mapH - drawH) / 2; + } else { + drawH = mapH; drawW = mapH * imgAspect; + drawX = mapX + (mapW - drawW) / 2; drawY = mapY; + } + doc.setDrawColor(80, 80, 80); + doc.setLineWidth(0.5); + doc.rect(mapX, mapY, mapW, mapH); + doc.addImage(siteMapDataUrl, drawX, drawY, drawW, drawH); + } + + const tableX = MARGIN + mapW + 16; + const tableW = W - MARGIN - tableX; + drawInventoryTable(doc, signs, tableX, contentTop, tableW, contentH); + } else { + drawInventoryTable(doc, signs, MARGIN, contentTop, W - MARGIN * 2, contentH); + } + + // โ”€โ”€โ”€ 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'}`; + addHeader(doc, logo, logoW, logoH, signLabel, pageNum, totalPages); + addFooter(doc, clientName, displayDate); + + const top = HEADER_H + 16; + const bottom = 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(30, 30, 30); + doc.rect(MARGIN, top, photoW, availH, 'F'); + doc.addImage(photoDataUrl, dx, dy, dw, dh); + doc.setDrawColor(60, 60, 60); + doc.setLineWidth(0.5); + doc.rect(MARGIN, top, photoW, availH); + } + } else { + doc.setFillColor(30, 30, 30); + doc.rect(MARGIN, top, photoW, availH, 'F'); + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(80, 80, 80); + doc.text('No Photo', MARGIN + photoW / 2, top + availH / 2, { align: 'center' }); + doc.setDrawColor(60, 60, 60); + 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); + } + } + + // โ”€โ”€โ”€ SURVEY PHOTOS PAGE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const validSurveyPhotos = surveyPhotoDataUrls.filter(Boolean); + if (validSurveyPhotos.length > 0) { + pageNum++; + doc.addPage(); + addHeader(doc, logo, logoW, logoH, 'Site Photos', pageNum, totalPages); + addFooter(doc, clientName, displayDate); + + const top = HEADER_H + 14; + const bottom = H - 30; + const availW = W - MARGIN * 2; + const cols = 4; + const rows = 3; + const gapX = 10; + const gapY = 10; + const thumbW = (availW - gapX * (cols - 1)) / cols; + const thumbH = (bottom - top - gapY * (rows - 1)) / rows; + + for (let i = 0; i < Math.min(validSurveyPhotos.length, cols * rows); i++) { + const dataUrl = validSurveyPhotos[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(30, 30, 30); + 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(60, 60, 60); + doc.setLineWidth(0.3); + doc.rect(tx, ty, thumbW, thumbH); + } + + if (validSurveyPhotos.length > cols * rows) { + doc.setFontSize(8); + doc.setFont('helvetica', 'italic'); + doc.setTextColor(120, 120, 120); + doc.text(`+${validSurveyPhotos.length - cols * rows} more photos not shown`, W - MARGIN, bottom + 8, { align: 'right' }); + } + } + + 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) { + const colDefs = [ + { 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(30, 30, 30); + 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 = [ + 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' }); + } +} diff --git a/src/pages/client/ClientDashboard.jsx b/src/pages/client/ClientDashboard.jsx index 77972cb..7ea6f01 100644 --- a/src/pages/client/ClientDashboard.jsx +++ b/src/pages/client/ClientDashboard.jsx @@ -7,16 +7,21 @@ import { useAuth } from '../../context/AuthContext'; export default function ClientDashboard() { const { currentUser } = useAuth(); - const company = currentUser?.company; + const companyId = currentUser?.company_id || currentUser?.company?.id; + const [company, setCompany] = useState(currentUser?.company || null); const [projects, setProjects] = useState([]); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { - if (!company?.id) { setLoading(false); return; } + if (!companyId) { setLoading(false); return; } async function load() { + if (!company) { + const { data: co } = await supabase.from('companies').select('*').eq('id', companyId).single(); + if (co) setCompany(co); + } const { data: p } = await supabase - .from('projects').select('*').eq('company_id', company.id).order('created_at', { ascending: false }); + .from('projects').select('*').eq('company_id', companyId).order('created_at', { ascending: false }); const projectList = p || []; setProjects(projectList); @@ -29,7 +34,7 @@ export default function ClientDashboard() { setLoading(false); } load(); - }, [company?.id]); + }, [companyId]); if (loading) return

Loading...

; diff --git a/src/pages/client/MyInvoices.jsx b/src/pages/client/MyInvoices.jsx index a3a61bc..cf2b515 100644 --- a/src/pages/client/MyInvoices.jsx +++ b/src/pages/client/MyInvoices.jsx @@ -13,7 +13,7 @@ export default function MyInvoices() { async function load() { const { data } = await supabase .from('invoices') - .select('*, company:companies(name, email), items:invoice_items(*)') + .select('*, company:companies(name), items:invoice_items(*)') .order('created_at', { ascending: false }); setInvoices((data || []).filter(inv => inv.status !== 'draft')); setLoading(false); diff --git a/src/pages/client/MyProjectDetail.jsx b/src/pages/client/MyProjectDetail.jsx index 521d548..54452ae 100644 --- a/src/pages/client/MyProjectDetail.jsx +++ b/src/pages/client/MyProjectDetail.jsx @@ -5,7 +5,7 @@ import StatusBadge from '../../components/StatusBadge'; import { supabase } from '../../lib/supabase'; import { useAuth } from '../../context/AuthContext'; -const vLabel = (v) => 'v' + String(v).padStart(2, '0'); +const vLabel = (v) => 'v' + String(v || 0).padStart(2, '0'); export default function MyProjectDetail() { const { id } = useParams(); @@ -137,7 +137,7 @@ export default function MyProjectDetail() { const isMine = initialSub?.submitted_by === currentUser.id; return ( -
+
@@ -156,12 +156,9 @@ export default function MyProjectDetail() { {hasRevision && <> ยท Updated by {latestSub.submitted_by_name}}
-
- - Details -
+
-
+ ); })}
diff --git a/src/pages/client/MyProjects.jsx b/src/pages/client/MyProjects.jsx index 07aac2f..ae841cc 100755 --- a/src/pages/client/MyProjects.jsx +++ b/src/pages/client/MyProjects.jsx @@ -5,7 +5,7 @@ import StatusBadge from '../../components/StatusBadge'; import { supabase } from '../../lib/supabase'; import { useAuth } from '../../context/AuthContext'; -const vLabel = (v) => 'v' + String(v).padStart(2, '0'); +const vLabel = (v) => 'v' + String(v || 0).padStart(2, '0'); function ProjectGroup({ project, tasks, submissions, currentUserId, filter }) { const [open, setOpen] = useState(true); @@ -65,13 +65,14 @@ function ProjectGroup({ project, tasks, submissions, currentUserId, filter }) { const isMine = initialSub?.submitted_by === currentUserId; return ( -
@@ -93,11 +94,8 @@ function ProjectGroup({ project, tasks, submissions, currentUserId, filter }) { {hasRevision && <> ยท Updated by {latestSub.submitted_by_name}}
-
- - Details -
-
+ + ); }) )} diff --git a/src/pages/client/MyRequests.jsx b/src/pages/client/MyRequests.jsx index 90dae34..6e8abe3 100755 --- a/src/pages/client/MyRequests.jsx +++ b/src/pages/client/MyRequests.jsx @@ -97,7 +97,7 @@ export default function MyRequests() { {task.title}{' '} - {'v' + String(task.current_version).padStart(2, '0')} + {'v' + String(task.current_version || 0).padStart(2, '0')}
diff --git a/src/pages/client/RequestDetail.jsx b/src/pages/client/RequestDetail.jsx index 4962cd1..0359f65 100755 --- a/src/pages/client/RequestDetail.jsx +++ b/src/pages/client/RequestDetail.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; +import JSZip from 'jszip'; import Layout from '../../components/Layout'; import StatusBadge from '../../components/StatusBadge'; import FileAttachment from '../../components/FileAttachment'; @@ -8,7 +9,7 @@ import { sendEmail } from '../../lib/email'; import { useAuth } from '../../context/AuthContext'; import { serviceTypes } from '../../data/mockData'; -const vLabel = (v) => 'v' + String(v).padStart(2, '0'); +const vLabel = (v) => 'v' + String(v || 0).padStart(2, '0'); export default function RequestDetail() { const { id } = useParams(); @@ -20,8 +21,12 @@ export default function RequestDetail() { const [submissions, setSubmissions] = useState([]); const [loading, setLoading] = useState(true); + const [editingTitle, setEditingTitle] = useState(false); + const [titleVal, setTitleVal] = useState(''); + const [savingTitle, setSavingTitle] = useState(false); + const [action, setAction] = useState(null); - const [revisionForm, setRevisionForm] = useState({ serviceType: '', deadline: '', description: '' }); + const [revisionForm, setRevisionForm] = useState({ serviceType: '', deadline: '', description: '', revisionType: 'client_revision' }); const [revisionFiles, setRevisionFiles] = useState([]); const [submitted, setSubmitted] = useState(false); const [saving, setSaving] = useState(false); @@ -34,7 +39,7 @@ export default function RequestDetail() { const [{ data: p }, { data: subs }] = await Promise.all([ supabase.from('projects').select('*').eq('id', t.project_id).single(), - supabase.from('submissions').select('*, delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'), + supabase.from('submissions').select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'), ]); setProject(p); setSubmissions(subs || []); @@ -124,6 +129,7 @@ export default function RequestDetail() { task_id: id, version_number: newVersion + 1, type: 'revision', + revision_type: revisionForm.revisionType, service_type: revisionForm.serviceType, deadline: revisionForm.deadline || null, description: revisionForm.description, @@ -158,7 +164,7 @@ export default function RequestDetail() { const { data: refreshed } = await supabase .from('submissions') - .select('*, delivery:deliveries(*, files:delivery_files(*))') + .select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))') .eq('task_id', id) .order('version_number'); setSubmissions(refreshed || []); @@ -175,6 +181,40 @@ export default function RequestDetail() { if (data?.signedUrl) window.open(data.signedUrl, '_blank'); }; + const getSubmissionFileUrl = async (path) => { + const { data } = await supabase.storage.from('submissions').createSignedUrl(path, 3600); + if (data?.signedUrl) window.open(data.signedUrl, '_blank'); + }; + + const handleSaveTitle = async (e) => { + e.preventDefault(); + if (!titleVal.trim()) return; + setSavingTitle(true); + await supabase.from('tasks').update({ title: titleVal.trim() }).eq('id', id); + setTask(t => ({ ...t, title: titleVal.trim() })); + setEditingTitle(false); + setSavingTitle(false); + }; + + const downloadAllSubmissionFiles = async (files, versionLabel) => { + const zip = new JSZip(); + for (const file of files) { + const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600); + if (data?.signedUrl) { + const response = await fetch(data.signedUrl); + const blob = await response.blob(); + zip.file(file.name, blob); + } + } + const content = await zip.generateAsync({ type: 'blob' }); + const zipName = `${project?.name || 'files'} ${versionLabel}.zip`.replace(/[^a-z0-9 ._-]/gi, '_'); + const a = document.createElement('a'); + a.href = URL.createObjectURL(content); + a.download = zipName; + a.click(); + URL.revokeObjectURL(a.href); + }; + if (loading) return

Loading...

; if (!task) return

Job not found.

; @@ -199,7 +239,25 @@ export default function RequestDetail() {
-
{titleWithVersion}
+ {editingTitle ? ( +
+ setTitleVal(e.target.value)} + autoFocus + required + style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 280 }} + /> + + +
+ ) : ( +
+
{titleWithVersion}
+ +
+ )}
{project?.name}
@@ -290,6 +348,41 @@ export default function RequestDetail() {
+ {(action === 'revision' || action === 'reopen') && ( +
+ +
+ + +
+
+ )}