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:
Krao Hasanee
2026-04-14 12:16:22 -04:00
parent 906a0041a4
commit d6e49a4c67
39 changed files with 6618 additions and 300 deletions
+5 -3
View File
@@ -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() {
<Routes>
<Route path="/" element={<Login />} />
<Route path="/dashboard" element={<ProtectedRoute role="team"><Dashboard /></ProtectedRoute>} />
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external']}><Dashboard /></ProtectedRoute>} />
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external']}><ProjectDetail /></ProtectedRoute>} />
<Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external']}><TaskDetail /></ProtectedRoute>} />
<Route path="/companies" element={<ProtectedRoute role="team"><Companies /></ProtectedRoute>} />
<Route path="/companies/:id" element={<ProtectedRoute role="team"><CompanyDetail /></ProtectedRoute>} />
<Route path="/projects/:id" element={<ProtectedRoute role="team"><ProjectDetail /></ProtectedRoute>} />
<Route path="/tasks/:id" element={<ProtectedRoute role="team"><TaskDetail /></ProtectedRoute>} />
<Route path="/requests" element={<ProtectedRoute role="team"><Requests /></ProtectedRoute>} />
<Route path="/invoices" element={<ProtectedRoute role="team"><Invoices /></ProtectedRoute>} />
<Route path="/invoices/new" element={<ProtectedRoute role="team"><CreateInvoice /></ProtectedRoute>} />
<Route path="/invoices/:id" element={<ProtectedRoute role="team"><InvoiceDetail /></ProtectedRoute>} />
<Route path="/brand-book" element={<ProtectedRoute role="team"><BrandBook /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
+51 -16
View File
@@ -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 }) {
</span>
</label>
<div style={{
border: `2px dashed ${files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8, padding: '18px 16px', textAlign: 'center',
background: 'var(--bg)', transition: 'all 0.15s',
}}>
background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)',
transition: 'all 0.15s',
}}
>
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id="req-file-upload" />
<label htmlFor="req-file-upload" style={{ cursor: 'pointer' }}>
<div style={{ fontSize: 22, marginBottom: 4 }}>📎</div>
<div style={{ fontSize: 22, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div>
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
{files.length > 0 ? `${files.length} file${files.length !== 1 ? 's' : ''} attached — click to add more` : 'Click to attach files'}
{dragging
? 'Drop files here'
: files.length > 0
? `${files.length} file${files.length !== 1 ? 's' : ''} attached — click or drag to add more`
: 'Click or drag files here'}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
</label>
+16 -3
View File
@@ -7,9 +7,10 @@ function TeamNav({ onNav }) {
<div className="sidebar-section">
{[
{ 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 }) => (
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
{label}
@@ -36,6 +37,16 @@ function ClientNav({ onNav }) {
);
}
function ExternalNav({ onNav }) {
return (
<div className="sidebar-section">
<NavLink to="/dashboard" onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
Dashboard
</NavLink>
</div>
);
}
export default function Layout({ children }) {
const { currentUser, logout } = useAuth();
const navigate = useNavigate();
@@ -69,6 +80,8 @@ export default function Layout({ children }) {
{currentUser?.role === 'team'
? <TeamNav onNav={() => setMenuOpen(false)} />
: currentUser?.role === 'external'
? <ExternalNav onNav={() => setMenuOpen(false)} />
: <ClientNav onNav={() => setMenuOpen(false)} />}
<div className="sidebar-bottom">
@@ -76,7 +89,7 @@ export default function Layout({ children }) {
<div className="sidebar-avatar" style={{ width: 28, height: 28, fontSize: 11, flexShrink: 0 }}>{initials}</div>
<div className="sidebar-user-info">
<div className="sidebar-user-name">{currentUser?.name || 'Set your name'}</div>
<div className="sidebar-user-role">{currentUser?.role}</div>
<div className="sidebar-user-role">{currentUser?.role === 'external' ? 'Team' : currentUser?.role}</div>
</div>
</NavLink>
<div style={{ display: 'flex', alignItems: 'center', padding: '0 12px', gap: 8 }}>
+5 -3
View File
@@ -4,9 +4,11 @@ import { useAuth } from '../context/AuthContext';
export default function ProtectedRoute({ children, role }) {
const { currentUser } = useAuth();
if (!currentUser) return <Navigate to="/" replace />;
if (role && currentUser.role !== role) {
return <Navigate to={currentUser.role === 'team' ? '/dashboard' : '/my-dashboard'} replace />;
if (role) {
const allowed = Array.isArray(role) ? role : [role];
if (!allowed.includes(currentUser.role)) {
return <Navigate to={currentUser.role === 'client' ? '/my-dashboard' : '/dashboard'} replace />;
}
}
return children;
}
+2
View File
@@ -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',
+7 -3
View File
@@ -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); }
+807
View File
@@ -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' });
}
}
+546
View File
@@ -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`);
}
+47
View File
@@ -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);
}
}
}
+10 -2
View File
@@ -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;
}
+441
View File
@@ -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' });
}
}
+9 -4
View File
@@ -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 <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
+1 -1
View File
@@ -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);
+3 -6
View File
@@ -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 (
<div key={task.id} className="request-card">
<Link key={task.id} to={`/my-requests/${task.id}`} className="request-card" style={{ textDecoration: 'none', cursor: 'pointer', display: 'block' }}>
<div className="request-card-header">
<div>
<div className="request-card-title">
@@ -156,12 +156,9 @@ export default function MyProjectDetail() {
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StatusBadge status={task.status} />
<Link to={`/my-requests/${task.id}`} className="btn btn-outline btn-sm">Details</Link>
</div>
</div>
</div>
</Link>
);
})}
</div>
+5 -7
View File
@@ -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 (
<div
<Link
key={task.id}
to={`/my-requests/${task.id}`}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: i < filteredTasks.length - 1 ? '1px solid var(--border)' : 'none',
gap: 8,
gap: 8, textDecoration: 'none', cursor: 'pointer',
}}
>
<div style={{ minWidth: 0 }}>
@@ -93,11 +94,8 @@ function ProjectGroup({ project, tasks, submissions, currentUserId, filter }) {
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<StatusBadge status={task.status} />
<Link to={`/my-requests/${task.id}`} className="btn btn-outline btn-sm">Details</Link>
</div>
</div>
</Link>
);
})
)}
+1 -1
View File
@@ -97,7 +97,7 @@ export default function MyRequests() {
<span style={{ fontWeight: 600, fontSize: 13 }}>
{task.title}{' '}
<span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>
{'v' + String(task.current_version).padStart(2, '0')}
{'v' + String(task.current_version || 0).padStart(2, '0')}
</span>
</span>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
+133 -4
View File
@@ -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 <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!task) return <Layout><p>Job not found.</p></Layout>;
@@ -199,7 +239,25 @@ export default function RequestDetail() {
<div className="page-header">
<div>
{editingTitle ? (
<form onSubmit={handleSaveTitle} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input
type="text"
value={titleVal}
onChange={e => setTitleVal(e.target.value)}
autoFocus
required
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 280 }}
/>
<button type="submit" className="btn btn-primary btn-sm" disabled={savingTitle}>{savingTitle ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingTitle(false)}>Cancel</button>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="page-title">{titleWithVersion}</div>
<button className="btn btn-outline btn-sm" onClick={() => { setTitleVal(task.title); setEditingTitle(true); }}>Edit</button>
</div>
)}
<div className="page-subtitle">{project?.name}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
@@ -290,6 +348,41 @@ export default function RequestDetail() {
<input type="date" value={revisionForm.deadline} onChange={set('deadline')} />
</div>
</div>
{(action === 'revision' || action === 'reopen') && (
<div className="form-group">
<label>Revision Type *</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 4 }}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontWeight: 400 }}>
<input
type="radio"
name="revisionType"
value="client_revision"
checked={revisionForm.revisionType === 'client_revision'}
onChange={set('revisionType')}
style={{ marginTop: 2, flexShrink: 0 }}
/>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>Client Revision</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>I want changes made to the current work</div>
</div>
</label>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontWeight: 400 }}>
<input
type="radio"
name="revisionType"
value="fourge_error"
checked={revisionForm.revisionType === 'fourge_error'}
onChange={set('revisionType')}
style={{ marginTop: 2, flexShrink: 0 }}
/>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>Fourge Error</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Something was incorrect or not delivered as agreed</div>
</div>
</label>
</div>
</div>
)}
<div className="form-group">
<label>{action === 'edit' ? 'What would you like to change? *' : 'What needs to be changed? *'}</label>
<textarea placeholder={formPlaceholder} value={revisionForm.description} onChange={set('description')} required />
@@ -322,6 +415,7 @@ export default function RequestDetail() {
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="version-number">{vLabel(primary.version_number - 1)}</div>
<StatusBadge status={primary.type} />
{primary.revision_type && <StatusBadge status={primary.revision_type} />}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{primary.submitted_by_name && <span>{primary.submitted_by_name} · </span>}
@@ -337,6 +431,28 @@ export default function RequestDetail() {
<p style={{ marginTop: 4, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{primary.description}</p>
</div>
{primary.files?.length > 0 && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<label style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</label>
{primary.files.length > 1 && (
<button className="btn btn-outline btn-sm" onClick={() => downloadAllSubmissionFiles(primary.files, vLabel(primary.version_number - 1))}>⬇ Download All</button>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{primary.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📎</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
</div>
<button className="btn btn-outline btn-sm" onClick={() => getSubmissionFileUrl(file.storage_path)}>📥 View</button>
</div>
))}
</div>
</div>
)}
{amendments.map(amendment => (
<div key={amendment.id} style={{ marginTop: 12, padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 8, letterSpacing: 0.5 }}>
@@ -346,6 +462,19 @@ export default function RequestDetail() {
{amendment.submitted_by_name} · {new Date(amendment.submitted_at).toLocaleDateString()}
</div>
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
{amendment.files?.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{amendment.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📎</span>
<span style={{ fontSize: 12, fontWeight: 500 }}>{file.name}</span>
</div>
<button className="btn btn-outline btn-sm" onClick={() => getSubmissionFileUrl(file.storage_path)}>📥 View</button>
</div>
))}
</div>
)}
</div>
))}
File diff suppressed because it is too large Load Diff
+505
View File
@@ -0,0 +1,505 @@
import { useState, useEffect, useRef } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { generateBrandBookPDF } from '../../lib/brandbook';
const DEFAULT_COLORS = [
{ name: 'Primary', hex: '#1a1a1a' },
{ name: 'Accent', hex: '#F5A523' },
{ name: 'White', hex: '#ffffff' },
];
const EMPTY_FORM = {
// Cover page
selectedCompanyId: '',
projectLogoDataUrl: null,
creationDate: new Date().toISOString().slice(0, 10),
revisionDate: '',
customerName: '',
streetAddress: '',
clientLogoUrl: '',
clientContactName: '',
clientContactEmail: '',
clientContactPhone: '',
approvedDate: '',
approvalNotes: '',
// Brand identity
companyName: '',
brandName: '',
tagline: '',
brandStory: '',
brandValues: '',
colors: DEFAULT_COLORS,
primaryFont: '',
secondaryFont: '',
fontNotes: '',
brandVoice: '',
brandAdjectives: '',
logoNotes: '',
dos: '',
donts: '',
};
export default function BrandBook() {
const [companies, setCompanies] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
const [generating, setGenerating] = useState(false);
const [notification, setNotification] = useState(null);
const [uploadingClientLogo, setUploadingClientLogo] = useState(false);
const [savingClientInfo, setSavingClientInfo] = useState(false);
const [clientInfoSaved, setClientInfoSaved] = useState(false);
const projectLogoRef = useRef();
const clientLogoRef = useRef();
useEffect(() => {
supabase.from('companies').select('id, name').order('name').then(({ data }) => setCompanies(data || []));
}, []);
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
const handleCompanyChange = async (e) => {
const companyId = e.target.value;
if (!companyId) {
setForm(f => ({ ...f, selectedCompanyId: '', companyName: '', customerName: '', streetAddress: '', clientLogoUrl: '', clientContactName: '', clientContactEmail: '', clientContactPhone: '' }));
return;
}
const { data: co } = await supabase.from('companies').select('*').eq('id', companyId).single();
if (co) {
setForm(f => ({
...f,
selectedCompanyId: companyId,
companyName: co.name,
customerName: f.customerName || co.name,
streetAddress: co.address || f.streetAddress,
clientLogoUrl: co.client_logo_url || '',
clientContactName: co.contact_name || '',
clientContactEmail: co.contact_email || '',
clientContactPhone: co.contact_phone || '',
}));
}
};
const handleProjectLogoUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setForm(f => ({ ...f, projectLogoDataUrl: ev.target.result }));
reader.readAsDataURL(file);
};
const handleClientLogoUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!form.selectedCompanyId) {
setNotification({ type: 'error', msg: 'Select a company before uploading their logo.' });
return;
}
setUploadingClientLogo(true);
const ext = file.name.split('.').pop().toLowerCase();
const path = `${form.selectedCompanyId}/logo.${ext}`;
await supabase.storage.from('company-logos').remove([path]);
const { error } = await supabase.storage.from('company-logos').upload(path, file, { upsert: true });
if (!error) {
const { data: { publicUrl } } = supabase.storage.from('company-logos').getPublicUrl(path);
await supabase.from('companies').update({ client_logo_url: publicUrl }).eq('id', form.selectedCompanyId);
setForm(f => ({ ...f, clientLogoUrl: publicUrl }));
} else {
setNotification({ type: 'error', msg: 'Failed to upload client logo.' });
}
setUploadingClientLogo(false);
};
const handleSaveClientInfo = async () => {
if (!form.selectedCompanyId) return;
setSavingClientInfo(true);
await supabase.from('companies').update({
address: form.streetAddress || null,
contact_name: form.clientContactName || null,
contact_email: form.clientContactEmail || null,
contact_phone: form.clientContactPhone || null,
}).eq('id', form.selectedCompanyId);
setSavingClientInfo(false);
setClientInfoSaved(true);
setTimeout(() => setClientInfoSaved(false), 2500);
};
const setColor = (i, field, value) => {
setForm(f => ({
...f,
colors: f.colors.map((c, idx) => idx === i ? { ...c, [field]: value } : c),
}));
};
const addColor = () => setForm(f => ({ ...f, colors: [...f.colors, { name: '', hex: '#cccccc' }] }));
const removeColor = (i) => setForm(f => ({ ...f, colors: f.colors.filter((_, idx) => idx !== i) }));
const handleGenerate = async () => {
if (!form.brandName.trim()) {
setNotification({ type: 'error', msg: 'Brand name is required.' });
return;
}
setGenerating(true);
setNotification(null);
try {
await generateBrandBookPDF(form);
setNotification({ type: 'success', msg: '✓ Brand book PDF downloaded!' });
} catch (err) {
setNotification({ type: 'error', msg: `Failed to generate PDF: ${err.message}` });
}
setGenerating(false);
};
const handleReset = () => {
if (window.confirm('Clear all fields and start over?')) {
setForm(EMPTY_FORM);
setNotification(null);
}
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Brand Book <span style={{ fontSize: 13, fontWeight: 500, color: 'var(--accent)', marginLeft: 8, padding: '2px 8px', border: '1px solid var(--accent)', borderRadius: 4 }}>beta</span></div>
<div className="page-subtitle">Fill in the brand details below and generate a PDF brand book.</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-outline btn-sm" onClick={handleReset}>Reset</button>
<button className="btn btn-primary btn-sm" onClick={handleGenerate} disabled={generating}>
{generating ? 'Generating...' : '⬇ Generate PDF'}
</button>
</div>
</div>
{notification && (
<div className={`notification ${notification.type === 'error' ? 'notification-error' : 'notification-success'}`} style={{ marginBottom: 24 }}>
{notification.msg}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* ── COVER PAGE ─────────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Cover Page</div>
{/* Company + Brand Name */}
<div className="grid-2">
<div className="form-group">
<label>Client Company</label>
<select value={form.selectedCompanyId} onChange={handleCompanyChange}>
<option value=""> Select company </option>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Brand Name *</label>
<input type="text" placeholder="e.g. Acme Corp" value={form.brandName} onChange={set('brandName')} />
</div>
</div>
{/* Project Logo */}
<div className="form-group">
<label>Project Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(top left, 5"×5" area)</span></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{form.projectLogoDataUrl && (
<img
src={form.projectLogoDataUrl}
alt="Project logo preview"
style={{ maxHeight: 60, maxWidth: 120, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }}
/>
)}
<div>
<button className="btn btn-outline btn-sm" onClick={() => projectLogoRef.current?.click()}>
{form.projectLogoDataUrl ? 'Replace Logo' : 'Upload Logo'}
</button>
{form.projectLogoDataUrl && (
<button className="btn btn-outline btn-sm" style={{ marginLeft: 8, color: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={() => setForm(f => ({ ...f, projectLogoDataUrl: null }))}>
Remove
</button>
)}
<input ref={projectLogoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleProjectLogoUpload} />
</div>
</div>
</div>
{/* Dates */}
<div className="grid-2">
<div className="form-group">
<label>Creation Date</label>
<input type="date" value={form.creationDate} onChange={set('creationDate')} />
</div>
<div className="form-group">
<label>Revision Date <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={form.revisionDate} onChange={set('revisionDate')} />
</div>
</div>
{/* Customer info */}
<div className="grid-2">
<div className="form-group">
<label>Customer Name</label>
<input type="text" placeholder="e.g. Acme Corp" value={form.customerName} onChange={set('customerName')} />
</div>
<div className="form-group">
<label>Street Address <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="text" placeholder="e.g. 123 Main St, City, State 00000" value={form.streetAddress} onChange={set('streetAddress')} />
</div>
</div>
{/* Divider */}
<div style={{ borderTop: '1px solid var(--border)', margin: '8px 0 20px' }} />
{/* Client info (saved per company) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>Client Info</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Saved to the selected company reused across all brand books.</div>
</div>
{form.selectedCompanyId && (
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo}>
{savingClientInfo ? 'Saving...' : clientInfoSaved ? '✓ Saved' : 'Save to Company'}
</button>
)}
</div>
{/* Client logo */}
<div className="form-group">
<label>Client Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{form.clientLogoUrl && (
<img
src={form.clientLogoUrl}
alt="Client logo preview"
style={{ maxHeight: 50, maxWidth: 140, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }}
/>
)}
<div>
<button
className="btn btn-outline btn-sm"
onClick={() => clientLogoRef.current?.click()}
disabled={uploadingClientLogo}
>
{uploadingClientLogo ? 'Uploading...' : form.clientLogoUrl ? 'Replace Logo' : 'Upload Logo'}
</button>
{!form.selectedCompanyId && (
<span style={{ marginLeft: 10, fontSize: 12, color: 'var(--text-muted)' }}>Select a company first</span>
)}
<input ref={clientLogoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleClientLogoUpload} />
</div>
</div>
</div>
<div className="grid-2" style={{ marginTop: 4 }}>
<div className="form-group">
<label>Contact Name</label>
<input type="text" placeholder="e.g. Jane Smith" value={form.clientContactName} onChange={set('clientContactName')} />
</div>
<div className="form-group">
<label>Email</label>
<input type="email" placeholder="e.g. jane@client.com" value={form.clientContactEmail} onChange={set('clientContactEmail')} />
</div>
</div>
<div className="form-group" style={{ maxWidth: 320 }}>
<label>Phone</label>
<input type="text" placeholder="e.g. (555) 000-0000" value={form.clientContactPhone} onChange={set('clientContactPhone')} />
</div>
{/* Divider */}
<div style={{ borderTop: '1px solid var(--border)', margin: '8px 0 20px' }} />
{/* Approval */}
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 14 }}>Approval</div>
<div className="grid-2">
<div className="form-group">
<label>Approved Date <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={form.approvedDate} onChange={set('approvedDate')} />
</div>
</div>
<div className="form-group">
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<textarea
placeholder="Any approval notes or conditions..."
value={form.approvalNotes}
onChange={set('approvalNotes')}
style={{ minHeight: 70 }}
/>
</div>
</div>
{/* ── BRAND IDENTITY ─────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Brand Identity</div>
<div className="form-group">
<label>Tagline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="text" placeholder="e.g. Built for the bold." value={form.tagline} onChange={set('tagline')} />
</div>
</div>
{/* ── BRAND STORY ────────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Brand Story & Values</div>
<div className="form-group">
<label>Brand Story</label>
<textarea
placeholder="Describe the brand — its origin, mission, and what makes it unique..."
value={form.brandStory}
onChange={set('brandStory')}
style={{ minHeight: 120 }}
/>
</div>
<div className="form-group">
<label>
Brand Values
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>one per line</span>
</label>
<textarea
placeholder={"Innovation\nAuthenticity\nCommunity"}
value={form.brandValues}
onChange={set('brandValues')}
style={{ minHeight: 80 }}
/>
</div>
</div>
{/* ── COLOR PALETTE ──────────────────────────────────────────────────────── */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div className="card-title" style={{ margin: 0 }}>Color Palette</div>
<button className="btn btn-outline btn-sm" onClick={addColor}>+ Add Color</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{form.colors.map((color, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
type="color"
value={color.hex}
onChange={e => setColor(i, 'hex', e.target.value)}
style={{ width: 44, height: 38, padding: 2, borderRadius: 6, border: '1px solid var(--border)', cursor: 'pointer', background: 'none' }}
/>
<input
type="text"
value={color.hex}
onChange={e => setColor(i, 'hex', e.target.value)}
placeholder="#000000"
style={{ width: 100, margin: 0, fontFamily: 'monospace', fontSize: 13 }}
/>
<input
type="text"
value={color.name}
onChange={e => setColor(i, 'name', e.target.value)}
placeholder="Color name"
style={{ flex: 1, margin: 0 }}
/>
<div style={{ width: 38, height: 38, borderRadius: 6, background: color.hex, border: '1px solid var(--border)', flexShrink: 0 }} />
<button
type="button"
onClick={() => removeColor(i)}
style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px', flexShrink: 0 }}
></button>
</div>
))}
</div>
</div>
{/* ── TYPOGRAPHY ─────────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Typography</div>
<div className="grid-2">
<div className="form-group">
<label>Primary Font</label>
<input type="text" placeholder="e.g. Neue Haas Grotesk" value={form.primaryFont} onChange={set('primaryFont')} />
</div>
<div className="form-group">
<label>Secondary Font</label>
<input type="text" placeholder="e.g. Freight Text Pro" value={form.secondaryFont} onChange={set('secondaryFont')} />
</div>
</div>
<div className="form-group">
<label>Usage Notes</label>
<textarea
placeholder="e.g. Primary font used for headings and UI. Secondary font for body copy and long-form text..."
value={form.fontNotes}
onChange={set('fontNotes')}
style={{ minHeight: 80 }}
/>
</div>
</div>
{/* ── BRAND VOICE ────────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Brand Voice & Tone</div>
<div className="form-group">
<label>Voice Description</label>
<textarea
placeholder="Describe how the brand communicates — its personality, tone, and style of writing..."
value={form.brandVoice}
onChange={set('brandVoice')}
style={{ minHeight: 100 }}
/>
</div>
<div className="form-group">
<label>
Brand Adjectives
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>comma separated</span>
</label>
<input
type="text"
placeholder="e.g. Bold, Approachable, Modern, Trustworthy"
value={form.brandAdjectives}
onChange={set('brandAdjectives')}
/>
</div>
</div>
{/* ── LOGO GUIDELINES ────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Logo Usage Guidelines</div>
<div className="form-group">
<label>Guidelines</label>
<textarea
placeholder="e.g. Always use the full-color logo on light backgrounds. Use the white version on dark backgrounds. Minimum size 40px. Never stretch, rotate, or recolor the logo..."
value={form.logoNotes}
onChange={set('logoNotes')}
style={{ minHeight: 100 }}
/>
</div>
</div>
{/* ── DO'S & DON'TS ──────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Do's & Don'ts</div>
<div className="grid-2">
<div className="form-group">
<label style={{ color: '#16a34a' }}> Do's <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>one per line</span></label>
<textarea
placeholder={"Use brand colors consistently\nMaintain clear space around the logo\nUse approved fonts only"}
value={form.dos}
onChange={set('dos')}
style={{ minHeight: 120 }}
/>
</div>
<div className="form-group">
<label style={{ color: '#dc2626' }}>✕ Don'ts <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>one per line</span></label>
<textarea
placeholder={"Don't alter the logo colors\nDon't use unapproved fonts\nDon't place logo on busy backgrounds"}
value={form.donts}
onChange={set('donts')}
style={{ minHeight: 120 }}
/>
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 24, paddingBottom: 40 }}>
<button className="btn btn-outline" onClick={handleReset}>Reset</button>
<button className="btn btn-primary btn-lg" onClick={handleGenerate} disabled={generating}>
{generating ? 'Generating...' : '⬇ Generate Brand Book PDF'}
</button>
</div>
</Layout>
);
}
+93 -14
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
export default function Companies() {
const navigate = useNavigate();
@@ -13,9 +14,12 @@ export default function Companies() {
const [showNew, setShowNew] = useState(false);
const [newForm, setNewForm] = useState({ name: '', phone: '', address: '' });
const [showNewUser, setShowNewUser] = useState(false);
const [userForm, setUserForm] = useState({ name: '', email: '', password: '', company_id: '' });
const [userForm, setUserForm] = useState({ name: '', email: '', password: '', company_id: '', role: 'client' });
const [saving, setSaving] = useState(false);
const [userError, setUserError] = useState('');
const [editingUserId, setEditingUserId] = useState(null);
const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null);
useEffect(() => {
load();
@@ -52,6 +56,37 @@ export default function Companies() {
}
};
const handleDeleteCompany = async (company) => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return;
const companyProjects = projects.filter(p => p.company_id === company.id);
const projectIds = companyProjects.map(p => p.id);
if (projectIds.length) {
const companyTaskIds = tasks.filter(t => projectIds.includes(t.project_id)).map(t => t.id);
await cleanupTaskStorage(companyTaskIds);
}
await supabase.from('companies').delete().eq('id', company.id);
setCompanies(prev => prev.filter(c => c.id !== company.id));
};
const handleEditUserSave = async (userId) => {
if (!editUserVal.trim()) return;
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
setEditingUserId(null);
};
const handleDeleteUser = async (user) => {
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account. This cannot be undone.`)) return;
setDeletingUserId(user.id);
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
const errMsg = data?.error || error?.message;
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
setProfiles(prev => prev.filter(u => u.id !== user.id));
setDeletingUserId(null);
};
const handleCreateUser = async (e) => {
e.preventDefault();
setUserError('');
@@ -61,7 +96,8 @@ export default function Companies() {
name: userForm.name.trim(),
email: userForm.email.trim(),
password: userForm.password,
company_id: userForm.company_id || null,
role: userForm.role,
company_id: userForm.role === 'client' ? (userForm.company_id || null) : null,
},
});
setSaving(false);
@@ -71,7 +107,7 @@ export default function Companies() {
return;
}
setShowNewUser(false);
setUserForm({ name: '', email: '', password: '', company_id: '' });
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
load();
};
@@ -83,7 +119,7 @@ export default function Companies() {
<Layout>
<div className="page-header">
<div>
<div className="page-title">Companies</div>
<div className="page-title">Clients & Users</div>
<div className="page-subtitle">
{companies.length} {companies.length !== 1 ? 'companies' : 'company'}
{unassigned.length > 0 && (
@@ -94,10 +130,10 @@ export default function Companies() {
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-outline" onClick={() => { setShowNewUser(s => !s); setShowNew(false); setUserError(''); }}>
<button className="btn btn-outline btn-sm" onClick={() => { setShowNewUser(s => !s); setShowNew(false); setUserError(''); }}>
{showNewUser ? 'Cancel' : '+ New User'}
</button>
<button className="btn btn-primary" onClick={() => { setShowNew(s => !s); setShowNewUser(false); }}>
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(s => !s); setShowNewUser(false); }}>
{showNew ? 'Cancel' : '+ New Company'}
</button>
</div>
@@ -125,6 +161,16 @@ export default function Companies() {
<input type="password" placeholder="Temporary password" value={userForm.password}
onChange={e => setUserForm(f => ({ ...f, password: e.target.value }))} required minLength={6} />
</div>
<div className="form-group">
<label>Role *</label>
<select value={userForm.role} onChange={e => setUserForm(f => ({ ...f, role: e.target.value, company_id: '' }))}>
<option value="client">Client</option>
<option value="team">Team</option>
<option value="external">External</option>
</select>
</div>
</div>
{userForm.role === 'client' && (
<div className="form-group">
<label>Assign to Company</label>
<select value={userForm.company_id} onChange={e => setUserForm(f => ({ ...f, company_id: e.target.value }))}>
@@ -132,7 +178,7 @@ export default function Companies() {
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
)}
{userError && <p style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{userError}</p>}
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={saving}>
@@ -198,17 +244,42 @@ export default function Companies() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{unassigned.map(user => (
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div>
<div style={{ flex: 1 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="text"
value={editUserVal}
onChange={e => setEditUserVal(e.target.value)}
autoFocus
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }}
/>
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
</div>
) : (
<>
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
</>
)}
</div>
<span style={{ fontSize: 12, color: 'var(--danger)', fontWeight: 500 }}>No company</span>
{editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ fontSize: 12, color: 'var(--danger)', fontWeight: 500, marginRight: 4 }}>No company</span>
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>Edit</button>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDeleteUser(user)}
disabled={deletingUserId === user.id}
>{deletingUserId === user.id ? '...' : 'Delete'}</button>
</div>
)}
</div>
))}
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 10 }}>
Open a company and assign them from the Users tab.
</p>
</div>
)}
@@ -228,7 +299,7 @@ export default function Companies() {
return (
<div key={company.id} style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', background: 'var(--card-bg-2)', borderBottom: '1px solid var(--border)' }}>
<Link to={`/companies/${company.id}`} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', background: 'var(--card-bg-2)', borderBottom: '1px solid var(--border)', textDecoration: 'none', cursor: 'pointer' }}>
<div>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)' }}>{company.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
@@ -239,8 +310,16 @@ export default function Companies() {
{company.phone && <> · {company.phone}</>}
</div>
</div>
<Link to={`/companies/${company.id}`} className="btn btn-outline btn-sm">View</Link>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
<button
type="button"
onClick={e => { e.preventDefault(); e.stopPropagation(); handleDeleteCompany(company); }}
style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px', lineHeight: 1 }}
title="Delete company"
>✕</button>
</div>
</Link>
{companyProfiles.length > 0 && (
<div>
+261 -34
View File
@@ -4,6 +4,7 @@ import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { serviceTypes } from '../../data/mockData';
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
export default function CompanyDetail() {
const { id } = useParams();
@@ -19,6 +20,15 @@ export default function CompanyDetail() {
const [tab, setTab] = useState('users');
const [savingPrice, setSavingPrice] = useState(null);
const [assigning, setAssigning] = useState(false);
const [showNewProject, setShowNewProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [savingProject, setSavingProject] = useState(false);
const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState('');
const [savingName, setSavingName] = useState(false);
const [editingUserId, setEditingUserId] = useState(null);
const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null);
useEffect(() => {
load();
@@ -42,6 +52,33 @@ export default function CompanyDetail() {
setLoading(false);
}
const handleCompanyNameSave = async (e) => {
e.preventDefault();
if (!nameVal.trim()) return;
setSavingName(true);
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
setCompany(c => ({ ...c, name: nameVal.trim() }));
setEditingName(false);
setSavingName(false);
};
const handleEditUserSave = async (userId) => {
if (!editUserVal.trim()) return;
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
setUsers(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
setEditingUserId(null);
};
const handleDeleteUser = async (user) => {
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account and all access. This cannot be undone.`)) return;
setDeletingUserId(user.id);
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
const errMsg = data?.error || error?.message;
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
setUsers(prev => prev.filter(u => u.id !== user.id));
setDeletingUserId(null);
};
const handleAssignUser = async (userId) => {
setAssigning(true);
await supabase.from('profiles').update({ company_id: id }).eq('id', userId);
@@ -64,29 +101,65 @@ export default function CompanyDetail() {
}
};
const getPrice = (serviceType) => prices.find(p => p.service_type === serviceType)?.price ?? '';
const handleDeleteCompany = async () => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data. This cannot be undone.`)) return;
await cleanupTaskStorage(tasks.map(t => t.id));
await supabase.from('companies').delete().eq('id', id);
navigate('/companies');
};
const handlePriceChange = (serviceType, value) => {
const handleDeleteProject = async (project) => {
if (!window.confirm(`Delete project "${project.name}"? All jobs and files in this project will be permanently deleted.`)) return;
const projectTaskIds = tasks.filter(t => t.project_id === project.id).map(t => t.id);
await cleanupTaskStorage(projectTaskIds);
await supabase.from('projects').delete().eq('id', project.id);
setProjects(prev => prev.filter(p => p.id !== project.id));
setTasks(prev => prev.filter(t => t.project_id !== project.id));
};
const handleCreateProject = async (e) => {
e.preventDefault();
if (!newProjectName.trim()) return;
setSavingProject(true);
const { data } = await supabase.from('projects').insert({
company_id: id,
name: newProjectName.trim(),
status: 'active',
}).select().single();
if (data) {
setProjects(prev => [data, ...prev]);
setNewProjectName('');
setShowNewProject(false);
}
setSavingProject(false);
};
const getPrice = (serviceType, priceType) =>
prices.find(p => p.service_type === serviceType && p.price_type === priceType)?.price ?? '';
const handlePriceChange = (serviceType, priceType, value) => {
setPrices(prev => {
const existing = prev.find(p => p.service_type === serviceType);
if (existing) return prev.map(p => p.service_type === serviceType ? { ...p, price: value } : p);
return [...prev, { service_type: serviceType, price: value, company_id: id }];
const existing = prev.find(p => p.service_type === serviceType && p.price_type === priceType);
if (existing) return prev.map(p => p.service_type === serviceType && p.price_type === priceType ? { ...p, price: value } : p);
return [...prev, { service_type: serviceType, price_type: priceType, price: value, company_id: id }];
});
};
const handlePriceSave = async (serviceType) => {
setSavingPrice(serviceType);
const priceVal = getPrice(serviceType);
const existing = prices.find(p => p.service_type === serviceType && p.id);
for (const priceType of ['new', 'revision']) {
const priceVal = getPrice(serviceType, priceType);
const existing = prices.find(p => p.service_type === serviceType && p.price_type === priceType && p.id);
if (existing) {
const { error: updateError } = await supabase.from('company_prices').update({ price: Number(priceVal) }).eq('id', existing.id);
if (updateError) { setSavingPrice(null); alert('Failed to save price. Please try again.'); return; }
} else {
const { data, error: insertError } = await supabase.from('company_prices').insert({
company_id: id, service_type: serviceType, price: Number(priceVal),
const { error } = await supabase.from('company_prices').update({ price: Number(priceVal) }).eq('id', existing.id);
if (error) { setSavingPrice(null); alert('Failed to save price. Please try again.'); return; }
} else if (priceVal !== '') {
const { data, error } = await supabase.from('company_prices').insert({
company_id: id, service_type: serviceType, price_type: priceType, price: Number(priceVal),
}).select().single();
if (insertError) { setSavingPrice(null); alert('Failed to save price. Please try again.'); return; }
if (data) setPrices(prev => prev.map(p => p.service_type === serviceType ? data : p));
if (error) { setSavingPrice(null); alert('Failed to save price. Please try again.'); return; }
if (data) setPrices(prev => [...prev.filter(p => !(p.service_type === serviceType && p.price_type === priceType && !p.id)), data]);
}
}
setSavingPrice(null);
};
@@ -103,7 +176,25 @@ export default function CompanyDetail() {
<div className="page-header">
<div>
{editingName ? (
<form onSubmit={handleCompanyNameSave} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input
type="text"
value={nameVal}
onChange={e => setNameVal(e.target.value)}
autoFocus
required
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 260 }}
/>
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="page-title">{company.name}</div>
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(company.name); setEditingName(true); }}>Edit</button>
</div>
)}
<div className="page-subtitle">
{company.phone && <>{company.phone}</>}
{company.phone && company.address && ' · '}
@@ -111,7 +202,11 @@ export default function CompanyDetail() {
{!company.phone && !company.address && 'No contact info'}
</div>
</div>
<span className="badge badge-client" style={{ fontSize: 13, padding: '6px 14px' }}>Company</span>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }}
onClick={handleDeleteCompany}
>Delete Company</button>
</div>
<div className="stats-grid" style={{ marginBottom: 28 }}>
@@ -139,7 +234,7 @@ export default function CompanyDetail() {
{/* Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 24, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
{['users', 'pricing'].map(t => (
{['users', 'projects', 'pricing'].map(t => (
<button
key={t}
onClick={() => setTab(t)}
@@ -176,7 +271,7 @@ export default function CompanyDetail() {
padding: '12px 0',
borderBottom: i < users.length - 1 ? '1px solid var(--border)' : 'none',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1 }}>
<div style={{
width: 32, height: 32, borderRadius: 4, background: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -184,19 +279,49 @@ export default function CompanyDetail() {
}}>
{user.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div>
<div style={{ flex: 1 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="text"
value={editUserVal}
onChange={e => setEditUserVal(e.target.value)}
autoFocus
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }}
/>
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
</div>
) : (
<>
<div style={{ fontWeight: 600, fontSize: 14 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email || '—'}</div>
</>
)}
</div>
</div>
{editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button
className="btn btn-outline btn-sm"
onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}
>Edit</button>
<button
className="btn btn-outline btn-sm"
style={{ fontSize: 11, color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleRemoveUser(user.id)}
>Unassign</button>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDeleteUser(user)}
disabled={deletingUserId === user.id}
>
Remove
{deletingUserId === user.id ? '...' : 'Delete'}
</button>
</div>
)}
</div>
))}
</div>
)}
@@ -211,17 +336,41 @@ export default function CompanyDetail() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{unassigned.map(user => (
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div>
<div style={{ flex: 1 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="text"
value={editUserVal}
onChange={e => setEditUserVal(e.target.value)}
autoFocus
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }}
/>
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
</div>
) : (
<>
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
</>
)}
</div>
<button
className="btn btn-primary btn-sm"
onClick={() => handleAssignUser(user.id)}
disabled={assigning}
>
{editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button className="btn btn-primary btn-sm" onClick={() => handleAssignUser(user.id)} disabled={assigning}>
Assign to {company.name}
</button>
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>Edit</button>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDeleteUser(user)}
disabled={deletingUserId === user.id}
>{deletingUserId === user.id ? '...' : 'Delete'}</button>
</div>
)}
</div>
))}
</div>
@@ -230,6 +379,76 @@ export default function CompanyDetail() {
</div>
)}
{/* Projects Tab */}
{tab === 'projects' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-primary btn-sm" onClick={() => setShowNewProject(s => !s)}>
{showNewProject ? 'Cancel' : '+ New Project'}
</button>
</div>
{showNewProject && (
<div className="card">
<div className="card-title">New Project</div>
<form onSubmit={handleCreateProject} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<div className="form-group" style={{ flex: 1, marginBottom: 0 }}>
<label>Project Name *</label>
<input
type="text"
placeholder="e.g. Brand Identity 2026"
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
required
autoFocus
/>
</div>
<button type="submit" className="btn btn-primary" disabled={savingProject || !newProjectName.trim()}>
{savingProject ? 'Creating...' : 'Create'}
</button>
</form>
</div>
)}
{projects.length === 0 ? (
<div className="empty-state">
<h3>No projects yet</h3>
<p>Create a project to start adding jobs.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{projects.map(project => {
const projectTasks = tasks.filter(t => t.project_id === project.id);
const active = projectTasks.filter(t => t.status !== 'client_approved').length;
const done = projectTasks.filter(t => t.status === 'client_approved').length;
return (
<div key={project.id} style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<Link to={`/projects/${project.id}`} style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
<div>
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
{projectTasks.length} job{projectTasks.length !== 1 ? 's' : ''}
{active > 0 && <> · <span style={{ color: 'var(--accent)' }}>{active} active</span></>}
{done > 0 && <> · {done} done</>}
{' · '}Started {new Date(project.created_at).toLocaleDateString()}
</div>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
</Link>
<button
type="button"
onClick={() => handleDeleteProject(project)}
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
title="Delete project"
>✕</button>
</div>
);
})}
</div>
)}
</div>
)}
{/* Pricing Tab */}
{tab === 'pricing' && (
<div className="card">
@@ -237,27 +456,35 @@ export default function CompanyDetail() {
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 }}>
Set prices per service type for this company. These auto-fill when creating an invoice.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<div />
{['New', 'Revision'].map(label => (
<div key={label} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
))}
<div />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{serviceTypes.map(serviceType => (
<div key={serviceType} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 14, fontWeight: 500, flex: 1 }}>{serviceType}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<div key={serviceType} style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, alignItems: 'center' }}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{serviceType}</div>
{['new', 'revision'].map(priceType => (
<div key={priceType} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>$</span>
<input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={getPrice(serviceType)}
onChange={e => handlePriceChange(serviceType, e.target.value)}
style={{ margin: 0, width: 90 }}
value={getPrice(serviceType, priceType)}
onChange={e => handlePriceChange(serviceType, priceType, e.target.value)}
style={{ margin: 0, width: '100%', textAlign: 'right' }}
/>
</div>
))}
<button
className="btn btn-outline btn-sm"
onClick={() => handlePriceSave(serviceType)}
disabled={savingPrice === serviceType}
style={{ flexShrink: 0 }}
>
{savingPrice === serviceType ? '...' : 'Save'}
</button>
+186 -30
View File
@@ -1,11 +1,11 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
function newItem(description = '', unit_price = '', quantity = 1, task_id = null) {
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id };
function newItem(description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) {
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id };
}
export default function CreateInvoice() {
@@ -15,11 +15,14 @@ export default function CreateInvoice() {
const [companies, setCompanies] = useState([]);
const [selectedCompanyId, setSelectedCompanyId] = useState('');
const [uninvoicedTasks, setUninvoicedTasks] = useState([]);
const [uninvoicedRevisions, setUninvoicedRevisions] = useState([]);
const [priceList, setPriceList] = useState([]);
const [items, setItems] = useState([newItem()]);
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
const [loadingTasks, setLoadingTasks] = useState(false);
const dragItem = useRef(null);
const dragOver = useRef(null);
const today = new Date().toISOString().split('T')[0];
const net30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
@@ -29,7 +32,7 @@ export default function CreateInvoice() {
}, []);
useEffect(() => {
if (!selectedCompanyId) { setUninvoicedTasks([]); setPriceList([]); setItems([newItem()]); return; }
if (!selectedCompanyId) { setUninvoicedTasks([]); setUninvoicedRevisions([]); setPriceList([]); setItems([newItem()]); return; }
setLoadingTasks(true);
Promise.all([
supabase.from('projects').select('id').eq('company_id', selectedCompanyId),
@@ -37,26 +40,68 @@ export default function CreateInvoice() {
]).then(async ([{ data: projects }, { data: prices }]) => {
setPriceList(prices || []);
if (projects && projects.length > 0) {
const { data: tasks } = await supabase
const projectIds = projects.map(p => p.id);
const [{ data: tasks }, { data: revisions }] = await Promise.all([
supabase
.from('tasks')
.select('*, project:projects(name)')
.in('project_id', projects.map(p => p.id))
.eq('invoiced', false);
setUninvoicedTasks(tasks || []);
.select('*, project:projects(name), submissions(service_type, type, version_number)')
.in('project_id', projectIds)
.eq('invoiced', false),
supabase
.from('submissions')
.select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))')
.eq('type', 'revision')
.or('revision_type.eq.client_revision,revision_type.is.null')
.eq('invoiced', false)
.in('task_id',
(await supabase.from('tasks').select('id').in('project_id', projectIds)).data?.map(t => t.id) || []
),
]);
const tasksWithService = (tasks || []).map(t => {
const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0];
return { ...t, service_type: initial?.service_type || t.title };
});
setUninvoicedTasks(tasksWithService);
setUninvoicedRevisions(revisions || []);
} else {
setUninvoicedTasks([]);
setUninvoicedRevisions([]);
}
setLoadingTasks(false);
});
}, [selectedCompanyId]);
const addTaskAsItem = (task) => {
const price = priceList.find(p => p.service_type === task.title);
const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
const description = task.service_type && task.service_type !== task.title
? `${task.service_type}${task.title}`
: task.title;
setItems(prev => {
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
return [newItem(task.title, price?.price || '', 1, task.id)];
return [newItem(description, price?.price || '', 1, task.id)];
}
return [...prev, newItem(task.title, price?.price || '', 1, task.id)];
return [...prev, newItem(description, price?.price || '', 1, task.id)];
});
};
const getRevisionServiceType = (revision) => {
const initial = (revision.task?.submissions || []).find(s => s.type === 'initial') || (revision.task?.submissions || [])[0];
return initial?.service_type || revision.service_type || revision.task?.title || 'Revision';
};
const addRevisionAsItem = (revision) => {
const versionLabel = 'v' + String((revision.version_number || 1) - 1).padStart(2, '0');
const serviceLabel = getRevisionServiceType(revision);
const taskTitle = revision.task?.title;
const description = serviceLabel && taskTitle && serviceLabel !== taskTitle
? `${serviceLabel}${taskTitle} — Revision ${versionLabel}`
: `${serviceLabel || taskTitle || 'Revision'} — Revision ${versionLabel}`;
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
setItems(prev => {
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
return [newItem(description, price?.price || '', 1, revision.task_id, revision.id)];
}
return [...prev, newItem(description, price?.price || '', 1, revision.task_id, revision.id)];
});
};
@@ -66,6 +111,27 @@ export default function CreateInvoice() {
const removeItem = (id) => setItems(prev => prev.filter(item => item.id !== id));
const handleDrop = (targetIndex) => {
if (dragItem.current === null || dragItem.current === targetIndex) { dragItem.current = null; return; }
setItems(prev => {
const next = [...prev];
const [moved] = next.splice(dragItem.current, 1);
next.splice(targetIndex, 0, moved);
return next;
});
dragItem.current = null;
};
const sortItems = (mode) => {
setItems(prev => {
const next = [...prev];
if (mode === 'new-first') return next.sort((a, b) => (!!a.submission_id) - (!!b.submission_id));
if (mode === 'revision-first') return next.sort((a, b) => (!!b.submission_id) - (!!a.submission_id));
if (mode === 'az') return next.sort((a, b) => a.description.localeCompare(b.description));
return next;
});
};
const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
const handleSave = async (status) => {
@@ -96,6 +162,7 @@ export default function CreateInvoice() {
validItems.map(item => ({
invoice_id: invoice.id,
task_id: item.task_id || null,
submission_id: item.submission_id || null,
description: item.description,
quantity: Number(item.quantity) || 1,
unit_price: Number(item.unit_price) || 0,
@@ -103,11 +170,18 @@ export default function CreateInvoice() {
);
}
const taskIds = validItems.filter(i => i.task_id).map(i => i.task_id);
// Mark tasks as invoiced (only items without a submission_id are base task items)
const taskIds = validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
if (taskIds.length > 0) {
await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
}
// Mark client revisions as invoiced
const submissionIds = validItems.filter(i => i.submission_id).map(i => i.submission_id);
if (submissionIds.length > 0) {
await supabase.from('submissions').update({ invoiced: true }).in('id', submissionIds);
}
navigate(`/invoices/${invoice.id}`, { state: { invoice } });
};
@@ -123,15 +197,14 @@ export default function CreateInvoice() {
<div className="page-subtitle">Invoice date: {new Date(today).toLocaleDateString()} · Due: {new Date(net30).toLocaleDateString()} (Net 30)</div>
</div>
<div className="action-buttons">
<button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
<button className="btn btn-primary" onClick={() => handleSave('sent')} disabled={saving}>
<button className="btn btn-outline btn-sm" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
<button className="btn btn-primary btn-sm" onClick={() => handleSave('sent')} disabled={saving}>
{saving ? 'Saving...' : 'Finalise & Send'}
</button>
</div>
</div>
<div className="grid-2" style={{ gap: 24, marginBottom: 24 }}>
<div className="card">
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Company</div>
<div className="form-group">
<label>Select Company *</label>
@@ -149,23 +222,33 @@ export default function CreateInvoice() {
)}
</div>
<div className="card">
<div className="card-title">Uninvoiced Requests</div>
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Unbilled New Requests</div>
{uninvoicedTasks.length > 0 && !loadingTasks && (
<button
className="btn btn-outline btn-sm"
onClick={() => uninvoicedTasks.forEach(task => { if (!items.some(i => i.task_id === task.id && !i.submission_id)) addTaskAsItem(task); })}
>
+ Add All
</button>
)}
</div>
{!selectedCompanyId ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Select a company to see their uninvoiced requests.</p>
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Select a company to see their unbilled requests.</p>
) : loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : uninvoicedTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No uninvoiced requests found.</p>
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No unbilled new requests.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{uninvoicedTasks.map(task => {
const price = priceList.find(p => p.service_type === task.title);
const alreadyAdded = items.some(i => i.task_id === task.id);
const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
const alreadyAdded = items.some(i => i.task_id === task.id && !i.submission_id);
return (
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{task.title}</div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{task.service_type && task.service_type !== task.title ? `${task.service_type}${task.title}` : task.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{task.project?.name} · {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</div>
</div>
<button
@@ -181,20 +264,93 @@ export default function CreateInvoice() {
</div>
)}
</div>
{selectedCompanyId && (uninvoicedRevisions.length > 0 || loadingTasks) && (
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Unbilled Client Revisions</div>
{uninvoicedRevisions.length > 0 && !loadingTasks && (
<button
className="btn btn-outline btn-sm"
onClick={() => uninvoicedRevisions.forEach(rev => { if (!items.some(i => i.submission_id === rev.id)) addRevisionAsItem(rev); })}
>
+ Add All
</button>
)}
</div>
{loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{uninvoicedRevisions.map(rev => {
const versionLabel = 'v' + String((rev.version_number || 1) - 1).padStart(2, '0');
const revServiceType = getRevisionServiceType(rev);
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
const alreadyAdded = items.some(i => i.submission_id === rev.id);
return (
<div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{revServiceType && rev.task?.title && revServiceType !== rev.task?.title
? `${revServiceType}${rev.task.title} — Revision ${versionLabel}`
: `${revServiceType || rev.task?.title || 'Revision'} — Revision ${versionLabel}`}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{rev.task?.project?.name} · {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</div>
</div>
<button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
onClick={() => !alreadyAdded && addRevisionAsItem(rev)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})}
</div>
)}
</div>
)}
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Line Items</div>
{items.some(i => i.description) && (
<select
onChange={e => { if (e.target.value) { sortItems(e.target.value); e.target.value = ''; } }}
defaultValue=""
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text-primary)', cursor: 'pointer' }}
>
<option value="" disabled>Sort by</option>
<option value="new-first">New first</option>
<option value="revision-first">Revision first</option>
<option value="az">Description AZ</option>
</select>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
{['Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
<div key={i} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 1 ? 'right' : 'left' }}>{h}</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
<div key={i} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map(item => (
<div key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}>
{items.map((item, index) => (
<div
key={item.id}
onDragOver={e => e.preventDefault()}
onDrop={() => handleDrop(index)}
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
>
<div
draggable
onDragStart={e => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }}
style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}
></div>
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
{item.submission_id ? 'Revision' : 'New'}
</span>
<input type="text" placeholder="Description..." value={item.description} onChange={e => updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} />
<input type="number" min="1" value={item.quantity} onChange={e => updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} />
<input type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
+116 -8
View File
@@ -34,11 +34,11 @@ function CompanyGroup({ company, tasks, projects }) {
{tasks.map(task => {
const project = projects.find(p => p.id === task.project_id);
return (
<div key={task.id} style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4 }}>
<Link key={task.id} to={`/tasks/${task.id}`} style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Link to={`/tasks/${task.id}`} className="table-link" style={{ fontSize: 13 }}>
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'v' + String(task.current_version).padStart(2, '0')}</span>
</Link>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'v' + String(task.current_version || 0).padStart(2, '0')}</span>
</span>
<StatusBadge status={task.status} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -47,7 +47,7 @@ function CompanyGroup({ company, tasks, projects }) {
{task.assigned_name || 'Unassigned'}
</span>
</div>
</div>
</Link>
);
})}
</div>
@@ -56,6 +56,99 @@ function CompanyGroup({ company, tasks, projects }) {
);
}
function ProjectGroup({ project, tasks }) {
const [open, setOpen] = useState(true);
return (
<div style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: '100%', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '10px 14px',
background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
borderBottom: open ? '1px solid var(--border)' : 'none',
fontFamily: 'inherit',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{project.name}</span>
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>
{tasks.length}
</span>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
</button>
{open && (
<div>
{tasks.map(task => (
<a key={task.id} href={`/tasks/${task.id}`} style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', textDecoration: 'none' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'v' + String(task.current_version || 0).padStart(2, '0')}</span>
</span>
<StatusBadge status={task.status} />
</a>
))}
</div>
)}
</div>
);
}
function ExternalDashboard({ currentUser, projects, tasks }) {
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
const completedTasks = tasks.filter(t => t.status === 'client_approved');
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
<div className="page-subtitle">Your assigned projects.</div>
</div>
</div>
{projects.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📋</div>
<h3>No projects assigned yet</h3>
<p>Your team lead will assign you to projects.</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
<div>
<div className="card-title">Active Jobs</div>
{activeTasks.length === 0 ? (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
No active jobs
</div>
) : (
projects.map(project => {
const projectTasks = activeTasks.filter(t => t.project_id === project.id);
if (projectTasks.length === 0) return null;
return <ProjectGroup key={project.id} project={project} tasks={projectTasks} />;
})
)}
</div>
<div>
<div className="card-title">Completed</div>
{completedTasks.length === 0 ? (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
No completed jobs yet
</div>
) : (
projects.map(project => {
const projectTasks = completedTasks.filter(t => t.project_id === project.id);
if (projectTasks.length === 0) return null;
return <ProjectGroup key={project.id} project={project} tasks={projectTasks} />;
})
)}
</div>
</div>
)}
</Layout>
);
}
function GroupedColumn({ tasks, companies, projects, emptyText }) {
if (tasks.length === 0) return (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
@@ -88,8 +181,18 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true);
const [showCompleted, setShowCompleted] = useState(false);
const isExternal = currentUser?.role === 'external';
useEffect(() => {
async function load() {
if (isExternal) {
const [{ data: p }, { data: t }] = await Promise.all([
supabase.from('projects').select('*').order('created_at', { ascending: false }),
supabase.from('tasks').select('*').order('submitted_at', { ascending: false }),
]);
setProjects(p || []);
setTasks(t || []);
} else {
const [{ data: t }, { data: p }, { data: co }] = await Promise.all([
supabase.from('tasks').select('*').order('submitted_at', { ascending: false }),
supabase.from('projects').select('*'),
@@ -98,13 +201,18 @@ export default function Dashboard() {
setTasks(t || []);
setProjects(p || []);
setCompanies(co || []);
}
setLoading(false);
}
load();
}, []);
}, [isExternal]);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (isExternal) {
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} />;
}
const activeTasks = tasks.filter(t => ['in_progress', 'on_hold', 'client_review'].includes(t.status));
const notStartedTasks = tasks.filter(t => t.status === 'not_started');
const completedTasks = tasks.filter(t => t.status === 'client_approved');
@@ -118,10 +226,10 @@ export default function Dashboard() {
<div className="page-subtitle">Here's what's happening across your projects.</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-outline" onClick={() => setShowCompleted(s => !s)}>
<button className="btn btn-outline btn-sm" onClick={() => setShowCompleted(s => !s)}>
{showCompleted ? 'Hide Completed' : 'Show Completed'}
</button>
<Link to="/requests" className="btn btn-primary">View Requests</Link>
<Link to="/requests" className="btn btn-primary btn-sm">View Requests</Link>
</div>
</div>
+33 -4
View File
@@ -19,8 +19,9 @@ export default function InvoiceDetail() {
useEffect(() => {
async function load() {
try {
const { data: inv } = await supabase.from('invoices').select('*').eq('id', id).single();
if (!inv) { setLoading(false); return; }
if (!inv) return;
setInvoice(inv);
const [{ data: co }, { data: its }] = await Promise.all([
@@ -29,8 +30,12 @@ export default function InvoiceDetail() {
]);
setCompany(co);
setItems(its || []);
} catch (error) {
console.error('InvoiceDetail load failed:', error);
} finally {
setLoading(false);
}
}
load();
}, [id]);
@@ -45,9 +50,11 @@ export default function InvoiceDetail() {
if (!window.confirm('Delete this invoice? This cannot be undone.')) return;
setSaving(true);
try {
const { data: freshItems } = await supabase.from('invoice_items').select('task_id').eq('invoice_id', id);
const taskIds = (freshItems || []).filter(i => i.task_id).map(i => i.task_id);
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false }).in('id', taskIds);
const submissionIds = (freshItems || []).filter(i => i.submission_id).map(i => i.submission_id);
if (submissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', submissionIds);
const { error } = await supabase.from('invoices').delete().eq('id', id);
if (error) throw error;
navigate('/invoices');
@@ -88,7 +95,7 @@ export default function InvoiceDetail() {
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">Bill To</div>
<div style={{ fontSize: 15, fontWeight: 700 }}>{company?.name}</div>
<div style={{ fontSize: 15, fontWeight: 700 }}>{invoice.bill_to || company?.name}</div>
<div style={{ marginTop: 12 }}>
<Link to={`/companies/${company?.id}`} className="btn btn-outline btn-sm">View Company</Link>
</div>
@@ -102,6 +109,12 @@ export default function InvoiceDetail() {
</div>
<div className="detail-item"><label>Terms</label><p>Net 30</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
{invoice.status === 'paid' && invoice.stripe_fee != null && (
<>
<div className="detail-item"><label>Stripe Fee</label><p style={{ color: 'var(--text-secondary)' }}>${Number(invoice.stripe_fee).toFixed(2)}</p></div>
<div className="detail-item"><label>Net Received</label><p style={{ fontWeight: 700 }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</p></div>
</>
)}
</div>
</div>
</div>
@@ -112,6 +125,7 @@ export default function InvoiceDetail() {
<table>
<thead>
<tr>
<th style={{ width: 100 }}>Type</th>
<th>Description</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'right' }}>Unit Price</th>
@@ -121,6 +135,11 @@ export default function InvoiceDetail() {
<tbody>
{items.map(item => (
<tr key={item.id}>
<td>
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
{item.submission_id ? 'Revision' : 'New'}
</span>
</td>
<td>{item.description}</td>
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
<td style={{ textAlign: 'right' }}>${Number(item.unit_price).toFixed(2)}</td>
@@ -134,6 +153,16 @@ export default function InvoiceDetail() {
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</div>
{invoice.status === 'paid' && invoice.stripe_fee != null && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
Stripe fee: <span style={{ color: 'var(--text-secondary)' }}>${Number(invoice.stripe_fee).toFixed(2)}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
Net received: <span style={{ fontWeight: 700, color: 'var(--text-primary)' }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</span>
</div>
</div>
)}
</div>
</div>
</div>
+3 -7
View File
@@ -39,7 +39,7 @@ export default function Invoices() {
<div className="page-title">Invoices</div>
<div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} total</div>
</div>
<button className="btn btn-primary" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
@@ -88,13 +88,12 @@ export default function Invoices() {
<th>Due</th>
<th>Status</th>
<th>Total</th>
<th></th>
</tr>
</thead>
<tbody>
{filtered.map(inv => (
<tr key={inv.id}>
<td><Link to={`/invoices/${inv.id}`} className="table-link">{inv.invoice_number}</Link></td>
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td>
<td>
<div style={{ fontWeight: 600 }}>{inv.company?.name}</div>
</td>
@@ -106,9 +105,6 @@ export default function Invoices() {
</td>
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td style={{ fontWeight: 700 }}>${Number(inv.total).toFixed(2)}</td>
<td>
<Link to={`/invoices/${inv.id}`} className="btn btn-outline btn-sm">View</Link>
</td>
</tr>
))}
</tbody>
+276 -11
View File
@@ -3,53 +3,265 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { serviceTypes } from '../../data/mockData';
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
export default function ProjectDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { currentUser } = useAuth();
const [project, setProject] = useState(null);
const [company, setCompany] = useState(null);
const [companyUsers, setCompanyUsers] = useState([]);
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const isExternal = currentUser?.role === 'external';
const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState('');
const [savingName, setSavingName] = useState(false);
const [showAddJob, setShowAddJob] = useState(false);
const [jobForm, setJobForm] = useState({ title: '', serviceType: '', deadline: '', description: '', requestedBy: '' });
const [savingJob, setSavingJob] = useState(false);
const [members, setMembers] = useState([]);
const [externalProfiles, setExternalProfiles] = useState([]);
const [selectedExternal, setSelectedExternal] = useState('');
const [addingMember, setAddingMember] = useState(false);
useEffect(() => {
async function load() {
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
if (!p) { setLoading(false); return; }
setProject(p);
const [{ data: co }, { data: t }] = await Promise.all([
const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }] = await Promise.all([
supabase.from('companies').select('*').eq('id', p.company_id).single(),
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
supabase.from('profiles').select('id, name, email').eq('company_id', p.company_id).eq('role', 'client'),
supabase.from('project_members').select('*, profile:profiles(id, name, email)').eq('project_id', id),
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
]);
setCompany(co);
setTasks(t || []);
setCompanyUsers(users || []);
setMembers(pm || []);
setExternalProfiles(ext || []);
setLoading(false);
}
load();
}, [id]);
const handleDeleteProject = async () => {
if (!window.confirm(`Delete project "${project.name}"? All jobs and files will be permanently deleted.`)) return;
await cleanupTaskStorage(tasks.map(t => t.id));
await supabase.from('projects').delete().eq('id', id);
navigate(`/companies/${company?.id}`);
};
const handleDeleteTask = async (taskId, e) => {
e.stopPropagation();
if (!window.confirm('Delete this job? All submissions and files will be permanently deleted.')) return;
await cleanupTaskStorage([taskId]);
await supabase.from('tasks').delete().eq('id', taskId);
setTasks(prev => prev.filter(t => t.id !== taskId));
};
const handleSaveName = async (e) => {
e.preventDefault();
if (!nameVal.trim()) return;
setSavingName(true);
await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
setProject(p => ({ ...p, name: nameVal.trim() }));
setEditingName(false);
setSavingName(false);
};
const handleAddJob = async (e) => {
e.preventDefault();
setSavingJob(true);
const { data: task } = await supabase.from('tasks').insert({
project_id: id,
title: jobForm.title.trim(),
status: 'not_started',
current_version: 0,
}).select().single();
if (task) {
const requestor = companyUsers.find(u => u.id === jobForm.requestedBy);
await supabase.from('submissions').insert({
task_id: task.id,
version_number: 1,
type: 'initial',
service_type: jobForm.serviceType,
deadline: jobForm.deadline || null,
description: jobForm.description.trim() || null,
submitted_by: requestor?.id || null,
submitted_by_name: requestor?.name || 'Team',
});
setTasks(prev => [task, ...prev]);
setJobForm({ title: '', serviceType: '', deadline: '', description: '', requestedBy: '' });
setShowAddJob(false);
}
setSavingJob(false);
};
const handleAddMember = async () => {
if (!selectedExternal) return;
const { data } = await supabase.from('project_members')
.insert({ project_id: id, profile_id: selectedExternal })
.select('*, profile:profiles(id, name, email)')
.single();
if (data) {
setMembers(prev => [...prev, data]);
setSelectedExternal('');
setAddingMember(false);
}
};
const handleRemoveMember = async (profileId) => {
await supabase.from('project_members').delete().eq('project_id', id).eq('profile_id', profileId);
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!project) return <Layout><p>Project not found.</p></Layout>;
return (
<Layout>
<button className="back-link" onClick={() => navigate(`/companies/${company?.id}`)}>
Back to {company?.name}
<button className="back-link" onClick={() => navigate(isExternal ? '/dashboard' : `/companies/${company?.id}`)}>
Back to {isExternal ? 'Dashboard' : company?.name}
</button>
<div className="page-header">
<div>
{editingName && !isExternal ? (
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input
type="text"
value={nameVal}
onChange={e => setNameVal(e.target.value)}
autoFocus
required
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 280 }}
/>
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="page-title">{project.name}</div>
{!isExternal && (
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(project.name); setEditingName(true); }}>Edit</button>
)}
</div>
)}
<div className="page-subtitle">
<Link to={`/companies/${company?.id}`} style={{ color: 'var(--accent)' }}>
{company?.name}
{!isExternal && company && (
<>
<Link to={`/companies/${company.id}`} style={{ color: 'var(--accent)' }}>
{company.name}
</Link>
{' · '}Started {new Date(project.created_at).toLocaleDateString()}
{' · '}
</>
)}
Started {new Date(project.created_at).toLocaleDateString()}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StatusBadge status={project.status} />
{!isExternal && (
<>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }}
onClick={handleDeleteProject}
>Delete Project</button>
<button className="btn btn-primary btn-sm" onClick={() => setShowAddJob(s => !s)}>
{showAddJob ? 'Cancel' : '+ Add Job'}
</button>
</>
)}
</div>
</div>
{showAddJob && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
<div className="card-title">Add Job {project.name}</div>
<form onSubmit={handleAddJob}>
<div className="grid-2">
<div className="form-group">
<label>Job Title *</label>
<input
type="text"
placeholder="e.g. Logo Design"
value={jobForm.title}
onChange={e => setJobForm(f => ({ ...f, title: e.target.value }))}
required
autoFocus
/>
</div>
<div className="form-group">
<label>Service Type *</label>
<select
value={jobForm.serviceType}
onChange={e => setJobForm(f => ({ ...f, serviceType: e.target.value }))}
required
>
<option value="">Select a service...</option>
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input
type="date"
value={jobForm.deadline}
onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))}
/>
</div>
{companyUsers.length > 0 && (
<div className="form-group">
<label>Requested By <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<select
value={jobForm.requestedBy}
onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))}
>
<option value="">Team (no client)</option>
{companyUsers.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</div>
)}
</div>
<div className="form-group">
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<textarea
placeholder="Any details about the job..."
value={jobForm.description}
onChange={e => setJobForm(f => ({ ...f, description: e.target.value }))}
style={{ minHeight: 80 }}
/>
</div>
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={savingJob}>
{savingJob ? 'Adding...' : 'Add Job'}
</button>
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm({ title: '', serviceType: '', deadline: '', description: '', requestedBy: '' }); }}>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
@@ -76,7 +288,8 @@ export default function ProjectDetail() {
<div className="empty-state">
<div className="empty-state-icon">📋</div>
<h3>No jobs yet</h3>
<p>Jobs will appear here when requests come in.</p>
<p>Add a job to get started.</p>
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowAddJob(true)}>+ Add Job</button>
</div>
) : (
<div className="table-wrapper">
@@ -93,11 +306,11 @@ export default function ProjectDetail() {
</thead>
<tbody>
{tasks.map(task => (
<tr key={task.id}>
<tr key={task.id} onClick={() => navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}>
<td>
{task.title}
<span style={{ marginLeft: 6, fontWeight: 600, color: 'var(--text-muted)', fontSize: 12 }}>
{'v' + String(task.current_version).padStart(2, '0')}
{'v' + String(task.current_version || 0).padStart(2, '0')}
</span>
</td>
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>
@@ -110,15 +323,67 @@ export default function ProjectDetail() {
<td style={{ color: 'var(--text-secondary)' }}>
{new Date(task.submitted_at).toLocaleDateString()}
</td>
<td>
<Link to={`/tasks/${task.id}`} className="btn btn-outline btn-sm">View</Link>
{!isExternal && (
<td onClick={e => e.stopPropagation()}>
<button
type="button"
onClick={e => handleDeleteTask(task.id, e)}
style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
title="Delete job"
></button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{!isExternal && (
<div className="card" style={{ marginTop: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div className="card-title" style={{ margin: 0 }}>External Members</div>
{!addingMember && (
<button className="btn btn-outline btn-sm" onClick={() => setAddingMember(true)}>+ Add</button>
)}
</div>
{addingMember && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<select
value={selectedExternal}
onChange={e => setSelectedExternal(e.target.value)}
style={{ flex: 1 }}
>
<option value="">Select external member...</option>
{externalProfiles
.filter(p => !members.find(m => m.profile_id === p.id))
.map(p => <option key={p.id} value={p.id}>{p.name} {p.email}</option>)}
</select>
<button className="btn btn-primary btn-sm" onClick={handleAddMember} disabled={!selectedExternal}>Add</button>
<button className="btn btn-outline btn-sm" onClick={() => { setAddingMember(false); setSelectedExternal(''); }}>Cancel</button>
</div>
)}
{members.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No external members assigned to this project.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{members.map(m => (
<div key={m.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{m.profile?.email}</div>
</div>
<button
onClick={() => handleRemoveMember(m.profile_id)}
style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
title="Remove from project"
></button>
</div>
))}
</div>
)}
</div>
)}
</Layout>
);
}
+35 -20
View File
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
export default function Requests() {
const navigate = useNavigate();
const [submissions, setSubmissions] = useState([]);
const [tasks, setTasks] = useState([]);
const [projects, setProjects] = useState([]);
@@ -98,47 +99,47 @@ export default function Requests() {
return bMax - aMax;
});
// Build a map of max version per task so we can flag superseded groups
const maxVersionPerTask = {};
Object.values(groupMap).forEach(g => {
const p = g.find(s => s.type !== 'amendment') || g[0];
const cur = maxVersionPerTask[p.task_id] || 0;
if (p.version_number > cur) maxVersionPerTask[p.task_id] = p.version_number;
// Group by company
const byCompany = {};
groups.forEach(group => {
const primary = group.find(s => s.type !== 'amendment') || group[0];
const task = tasks.find(t => t.id === primary.task_id);
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
const key = company?.id || '__none__';
if (!byCompany[key]) byCompany[key] = { company, groups: [] };
byCompany[key].groups.push(group);
});
return groups.map(group => {
const renderCard = (group) => {
const primary = group.find(s => s.type !== 'amendment') || group[0];
const amendments = group.filter(s => s.type === 'amendment');
const task = tasks.find(t => t.id === primary.task_id);
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
const isSuperseded = primary.version_number < maxVersionPerTask[primary.task_id];
const isCompleted = task?.status === 'client_approved';
return (
<div key={primary.id} className="request-card" style={{ opacity: isSuperseded ? 0.5 : 1 }}>
<div key={primary.id} className="request-card" style={{ cursor: task ? 'pointer' : 'default' }} onClick={() => task && navigate(`/tasks/${task.id}`)}>
<div className="request-card-header">
<div>
<div className="request-card-title">
{primary.service_type}
{task?.title || primary.service_type}
<StatusBadge status={primary.type} />
{isSuperseded && (
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 500 }}>Superseded</span>
)}
</div>
<div className="request-card-meta">
From <strong>{primary.submitted_by_name}</strong>
{company && (
<> · <Link to={`/companies/${company.id}`} className="table-link">{company.name}</Link></>
<> · <Link to={`/companies/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link></>
)}
{' · '}{new Date(primary.submitted_at).toLocaleDateString()}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isSuperseded
? <span className="badge badge-completed">Completed</span>
{isCompleted
? <span className="badge badge-client_approved">Completed</span>
: <StatusBadge status={task?.status || 'not_started'} />
}
{task && <Link to={`/tasks/${task.id}`} className="btn btn-outline btn-sm">View Job</Link>}
</div>
</div>
@@ -150,7 +151,7 @@ export default function Requests() {
<div>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Project</span>
<div style={{ fontSize: 13, marginTop: 2 }}>
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
{project ? <Link to={`/projects/${project.id}`} className="table-link" onClick={e => e.stopPropagation()}>{project.name}</Link> : '—'}
</div>
</div>
</div>
@@ -170,7 +171,21 @@ export default function Requests() {
))}
</div>
);
});
};
return Object.values(byCompany).map(({ company, groups: companyGroups }) => (
<div key={company?.id || '__none__'} style={{ marginBottom: 32 }}>
<div style={{ fontSize: 13, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.6px', color: 'var(--text-muted)', marginBottom: 10 }}>
{company
? <Link to={`/companies/${company.id}`} style={{ color: 'var(--text-muted)', textDecoration: 'none' }}>{company.name}</Link>
: 'No Company'
}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{companyGroups.map(renderCard)}
</div>
</div>
));
})()}
</Layout>
);
+802
View File
@@ -0,0 +1,802 @@
import { useState, useEffect, useRef } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { generateBrandBookEditorPDF } from '../../lib/signsurvey';
const BUCKET = 'brand-books';
const EMPTY_SIGN = () => ({
_key: Math.random().toString(36).slice(2),
signNumber: '',
type: '',
location: '',
width: '',
height: '',
material: '',
illumination: '',
condition: '',
mountType: '',
notes: '',
photo: null,
photoPath: '',
_photoPreview: '',
});
const EMPTY_BOOK_INFO = {
clientId: '',
clientName: '',
projectName: '',
siteAddress: '',
bookDate: new Date().toISOString().split('T')[0],
preparedBy: '',
revision: '01',
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
const getSignedUrl = async (path) => {
if (!path) return null;
const { data } = await supabase.storage.from(BUCKET).createSignedUrl(path, 86400 * 7);
return data?.signedUrl || null;
};
const uploadFile = async (file, path) => {
const { error } = await supabase.storage.from(BUCKET).upload(path, file, { upsert: true });
if (error) throw error;
return path;
};
// ─── Main Component ───────────────────────────────────────────────────────────
export default function BrandBook() {
const [view, setView] = useState('list');
const [savedBooks, setSavedBooks] = useState([]);
const [loadingBooks, setLoadingBooks] = useState(true);
const [currentId, setCurrentId] = useState(null);
const [clients, setClients] = useState([]);
const [bookInfo, setBookInfo] = useState(EMPTY_BOOK_INFO);
const [siteMapFile, setSiteMapFile] = useState(null);
const [siteMapPath, setSiteMapPath] = useState('');
const [siteMapPreview, setSiteMapPreview] = useState(null);
const [signs, setSigns] = useState([EMPTY_SIGN()]);
const [photoItems, setPhotoItems] = useState([]);
const [expandedSign, setExpandedSign] = useState(0);
const [generating, setGenerating] = useState(false);
const [saving, setSaving] = useState(false);
const [notification, setNotification] = useState(null);
const siteMapRef = useRef();
const sitePhotosRef = useRef();
useEffect(() => {
supabase.from('companies').select('id, name').order('name').then(({ data }) => setClients(data || []));
fetchBooks();
}, []);
const fetchBooks = async () => {
setLoadingBooks(true);
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
setSavedBooks(data || []);
setLoadingBooks(false);
};
const set = (field) => (e) => setBookInfo(b => ({ ...b, [field]: e.target.value }));
const handleClientChange = (e) => {
const id = e.target.value;
const client = clients.find(c => c.id === id);
setBookInfo(b => ({ ...b, clientId: id, clientName: client ? client.name : '' }));
};
const resetForm = () => {
setBookInfo(EMPTY_BOOK_INFO);
setSiteMapFile(null);
setSiteMapPath('');
setSiteMapPreview(null);
setSigns([EMPTY_SIGN()]);
setPhotoItems([]);
setCurrentId(null);
setExpandedSign(0);
setNotification(null);
};
const handleNew = () => { resetForm(); setView('form'); };
const buildLoadedState = async (book) => {
const siteMapSignedUrl = await getSignedUrl(book.site_map_path);
const signsWithPreviews = await Promise.all((book.signs || []).map(async (sign) => {
const preview = await getSignedUrl(sign.photoPath);
return { ...sign, photo: null, _photoPreview: preview || '' };
}));
const surveyItems = await Promise.all((book.survey_photo_paths || []).map(async (path) => {
const preview = await getSignedUrl(path);
return { file: null, path, preview: preview || '' };
}));
return { siteMapSignedUrl, signsWithPreviews, surveyItems };
};
const handleLoad = async (book) => {
setNotification(null);
const { siteMapSignedUrl, signsWithPreviews, surveyItems } = await buildLoadedState(book);
setBookInfo({
clientId: book.client_id || '',
clientName: book.client_name || '',
projectName: book.project_name || '',
siteAddress: book.site_address || '',
bookDate: book.book_date || new Date().toISOString().split('T')[0],
preparedBy: book.prepared_by || '',
revision: book.revision || '01',
});
setSiteMapFile(null);
setSiteMapPath(book.site_map_path || '');
setSiteMapPreview(siteMapSignedUrl);
setSigns(signsWithPreviews.length > 0 ? signsWithPreviews : [EMPTY_SIGN()]);
setPhotoItems(surveyItems);
setCurrentId(book.id);
setExpandedSign(null);
setView('form');
};
const handleAddRevision = async (book) => {
setNotification(null);
const { siteMapSignedUrl, signsWithPreviews, surveyItems } = await buildLoadedState(book);
const nextRev = String((parseInt(book.revision || '1', 10) + 1)).padStart(2, '0');
setBookInfo({
clientId: book.client_id || '',
clientName: book.client_name || '',
projectName: book.project_name || '',
siteAddress: book.site_address || '',
bookDate: new Date().toISOString().split('T')[0],
preparedBy: book.prepared_by || '',
revision: nextRev,
});
setSiteMapFile(null);
setSiteMapPath(book.site_map_path || '');
setSiteMapPreview(siteMapSignedUrl);
setSigns(signsWithPreviews.length > 0 ? signsWithPreviews : [EMPTY_SIGN()]);
setPhotoItems(surveyItems);
setCurrentId(null); // new entry on save
setExpandedSign(null);
setView('form');
};
const handleDelete = async (id) => {
if (!window.confirm('Delete this brand book? This cannot be undone.')) return;
await supabase.from('brand_books').delete().eq('id', id);
fetchBooks();
};
const handleSave = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
return;
}
setSaving(true);
setNotification(null);
try {
const bookId = currentId || crypto.randomUUID();
// Upload site map if new file
let finalSiteMapPath = siteMapPath;
if (siteMapFile) {
const ext = siteMapFile.name.split('.').pop().toLowerCase();
finalSiteMapPath = `${bookId}/site-map.${ext}`;
await uploadFile(siteMapFile, finalSiteMapPath);
}
// Upload sign photos if new file
const finalSigns = await Promise.all(signs.map(async (sign) => {
let photoPath = sign.photoPath || '';
if (sign.photo) {
const ext = sign.photo.name.split('.').pop().toLowerCase();
photoPath = `${bookId}/sign-${sign._key}.${ext}`;
await uploadFile(sign.photo, photoPath);
}
const { photo, _photoPreview, ...rest } = sign;
return { ...rest, photoPath };
}));
// Upload survey photos if new file
const finalSurveyPaths = await Promise.all(photoItems.map(async (item, i) => {
if (item.file) {
const ext = item.file.name.split('.').pop().toLowerCase();
const path = `${bookId}/survey-${i}-${Date.now()}.${ext}`;
await uploadFile(item.file, path);
return path;
}
return item.path;
}));
const dbData = {
id: bookId,
client_id: bookInfo.clientId || null,
client_name: bookInfo.clientName,
project_name: bookInfo.projectName,
site_address: bookInfo.siteAddress,
book_date: bookInfo.bookDate || null,
prepared_by: bookInfo.preparedBy,
revision: bookInfo.revision,
site_map_path: finalSiteMapPath,
signs: finalSigns,
survey_photo_paths: finalSurveyPaths.filter(Boolean),
updated_at: new Date().toISOString(),
};
await supabase.from('brand_books').upsert(dbData);
setCurrentId(bookId);
setSiteMapPath(finalSiteMapPath);
setSiteMapFile(null);
setSigns(finalSigns.map(s => ({ ...s, photo: null })));
setPhotoItems(finalSurveyPaths.filter(Boolean).map((path, i) => ({
file: null,
path,
preview: photoItems[i]?.preview || '',
})));
await fetchBooks();
setNotification({ type: 'success', msg: '✓ Brand book saved!' });
} catch (err) {
setNotification({ type: 'error', msg: `Save failed: ${err.message}` });
}
setSaving(false);
};
const handleGenerate = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
return;
}
setGenerating(true);
setNotification(null);
try {
const siteMapSource = siteMapFile || (siteMapPath ? await getSignedUrl(siteMapPath) : null);
const signsWithSource = await Promise.all(signs.map(async s => ({
...s,
photoSource: s.photo || (s.photoPath ? await getSignedUrl(s.photoPath) : null),
})));
const sitePhotoSources = await Promise.all(photoItems.map(async item =>
item.file || (item.path ? await getSignedUrl(item.path) : null)
));
await generateBrandBookEditorPDF({
clientName: bookInfo.clientName,
projectName: bookInfo.projectName,
siteAddress: bookInfo.siteAddress,
bookDate: bookInfo.bookDate,
preparedBy: bookInfo.preparedBy,
revision: bookInfo.revision,
siteMapSource,
signs: signsWithSource,
sitePhotoSources,
});
setNotification({ type: 'success', msg: '✓ Brand book PDF downloaded!' });
} catch (err) {
setNotification({ type: 'error', msg: `Failed to generate PDF: ${err.message}` });
}
setGenerating(false);
};
// ── Sign helpers ─────────────────────────────────────────────────────────────
const updateSign = (key, field, value) =>
setSigns(prev => prev.map(s => s._key === key ? { ...s, [field]: value } : s));
const addSign = () => { setSigns(prev => [...prev, EMPTY_SIGN()]); setExpandedSign(signs.length); };
const removeSign = (key) => setSigns(prev => prev.filter(s => s._key !== key));
const handleSignPhoto = (key, file) => {
updateSign(key, 'photo', file);
updateSign(key, '_photoPreview', URL.createObjectURL(file));
updateSign(key, 'photoPath', ''); // clear old path when new file selected
};
// ── Site map helpers ──────────────────────────────────────────────────────────
const handleSiteMapFile = (file) => {
setSiteMapFile(file);
setSiteMapPath('');
setSiteMapPreview(URL.createObjectURL(file));
};
// ── Survey photo helpers ──────────────────────────────────────────────────────
const handleSitePhotos = (files) => {
const newItems = Array.from(files)
.filter(f => f.type.startsWith('image/'))
.map(file => ({ file, path: '', preview: URL.createObjectURL(file) }));
setPhotoItems(prev => [...prev, ...newItems]);
};
const removeSitePhoto = (i) => setPhotoItems(prev => prev.filter((_, idx) => idx !== i));
// ── Unsaved changes indicator ─────────────────────────────────────────────────
const hasUnsavedPhotos = siteMapFile || signs.some(s => s.photo) || photoItems.some(i => i.file);
// ═══════════════════════════════════════════════════════════════════════════════
// LIST VIEW
// ═══════════════════════════════════════════════════════════════════════════════
if (view === 'list') {
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">
Brand Book
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--accent)', marginLeft: 8, padding: '2px 8px', border: '1px solid var(--accent)', borderRadius: 4 }}>beta</span>
</div>
<div className="page-subtitle">Saved brand books click to edit or add a revision.</div>
</div>
<button className="btn btn-primary btn-sm" onClick={handleNew}>+ New Brand Book</button>
</div>
{loadingBooks ? (
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
) : savedBooks.length === 0 ? (
<div className="empty-state">
<h3>No brand books yet</h3>
<p>Create your first brand book to get started.</p>
<button className="btn btn-primary" onClick={handleNew} style={{ marginTop: 16 }}>+ New Brand Book</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{savedBooks.map(book => (
<BookListItem
key={book.id}
book={book}
onEdit={() => handleLoad(book)}
onRevision={() => handleAddRevision(book)}
onDelete={() => handleDelete(book.id)}
/>
))}
</div>
)}
</Layout>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// FORM VIEW
// ═══════════════════════════════════════════════════════════════════════════════
return (
<Layout>
<div className="page-header">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
className="btn btn-outline btn-sm"
onClick={() => setView('list')}
style={{ fontSize: 12 }}
> All Brand Books</button>
<div className="page-title" style={{ margin: 0 }}>
{currentId
? `${bookInfo.clientName || 'Brand Book'}${bookInfo.projectName || ''} R${String(bookInfo.revision).padStart(2, '0')}`
: 'New Brand Book'}
</div>
</div>
<div className="page-subtitle" style={{ marginTop: 4 }}>
{currentId ? 'Editing saved brand book' : 'Unsaved — fill in details and save'}
{hasUnsavedPhotos && <span style={{ color: 'var(--accent)', marginLeft: 8 }}>· Unsaved photos</span>}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-outline btn-sm" onClick={() => { if (window.confirm('Discard changes?')) { resetForm(); setView('list'); } }}>Discard</button>
<button className="btn btn-outline btn-sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : '💾 Save'}
</button>
<button className="btn btn-primary btn-sm" onClick={handleGenerate} disabled={generating}>
{generating ? 'Generating...' : '⬇ Generate PDF'}
</button>
</div>
</div>
{notification && (
<div className={`notification ${notification.type === 'error' ? 'notification-error' : 'notification-success'}`} style={{ marginBottom: 24 }}>
{notification.msg}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* ── BRAND BOOK INFO ──────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Brand Book Info</div>
<div className="grid-2">
<div className="form-group">
<label>Client *</label>
<select value={bookInfo.clientId} onChange={handleClientChange}>
<option value=""> Select client </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Project Name</label>
<input type="text" placeholder="e.g. Bolchoz Sign Solutions 2025" value={bookInfo.projectName} onChange={set('projectName')} />
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Site Address</label>
<input type="text" placeholder="e.g. 123 Main St, City, State" value={bookInfo.siteAddress} onChange={set('siteAddress')} />
</div>
<div className="form-group">
<label>Date</label>
<input type="date" value={bookInfo.bookDate} onChange={set('bookDate')} />
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Prepared By</label>
<input type="text" placeholder="e.g. John Smith" value={bookInfo.preparedBy} onChange={set('preparedBy')} />
</div>
<div className="form-group">
<label>Revision #</label>
<input
type="number"
min="1"
max="99"
value={parseInt(bookInfo.revision, 10) || 1}
onChange={e => setBookInfo(b => ({ ...b, revision: String(e.target.value).padStart(2, '0') }))}
style={{ width: 100 }}
/>
</div>
</div>
</div>
{/* ── SITE MAP ──────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Site Map</div>
<div className="form-group">
<label>Upload Site Map Image <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional shown on page 2 beside the sign inventory)</span></label>
<SiteMapDropZone
preview={siteMapPreview}
onFile={handleSiteMapFile}
onClear={() => { setSiteMapFile(null); setSiteMapPath(''); setSiteMapPreview(null); }}
inputRef={siteMapRef}
/>
</div>
</div>
{/* ── SIGNS ─────────────────────────────────────────────────────────── */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div className="card-title" style={{ margin: 0 }}>Signs</div>
<button className="btn btn-outline btn-sm" onClick={addSign}>+ Add Sign</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{signs.map((sign, i) => (
<SignCard
key={sign._key}
sign={sign}
index={i}
expanded={expandedSign === i}
onToggle={() => setExpandedSign(expandedSign === i ? null : i)}
onChange={(field, val) => updateSign(sign._key, field, val)}
onPhotoChange={(file) => handleSignPhoto(sign._key, file)}
onRemove={() => removeSign(sign._key)}
canRemove={signs.length > 1}
/>
))}
</div>
<button className="btn btn-outline btn-sm" onClick={addSign} style={{ marginTop: 12 }}>+ Add Sign</button>
</div>
{/* ── SITE PHOTOS ───────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Site Photos</div>
<div className="form-group">
<label>General site photos <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(displayed as thumbnails on the last page up to 12 shown)</span></label>
<SitePhotosDropZone
photoItems={photoItems}
onFiles={handleSitePhotos}
onRemove={removeSitePhoto}
inputRef={sitePhotosRef}
/>
</div>
</div>
</div>
{/* Bottom actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 24, paddingBottom: 40 }}>
<button className="btn btn-outline" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : '💾 Save Brand Book'}
</button>
<button className="btn btn-primary btn-lg" onClick={handleGenerate} disabled={generating}>
{generating ? 'Generating...' : '⬇ Generate Brand Book PDF'}
</button>
</div>
</Layout>
);
}
// ─── Book List Item ───────────────────────────────────────────────────────────
function BookListItem({ book, onEdit, onRevision, onDelete }) {
const signCount = Array.isArray(book.signs) ? book.signs.length : 0;
const date = book.book_date
? new Date(book.book_date + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
const updated = book.updated_at
? new Date(book.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return (
<div style={{
padding: '12px 16px',
border: '1px solid var(--border)',
borderRadius: 8,
background: 'var(--card-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)' }}>{book.client_name}</span>
{book.project_name && (
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}> {book.project_name}</span>
)}
<span style={{
fontSize: 11, fontWeight: 700, padding: '1px 7px',
background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4,
}}>R{String(book.revision || '01').padStart(2, '0')}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{book.site_address && <span>📍 {book.site_address}</span>}
{signCount > 0 && <span>🪧 {signCount} sign{signCount !== 1 ? 's' : ''}</span>}
{date && <span>📅 {date}</span>}
{updated && <span style={{ color: 'var(--text-muted)', opacity: 0.7 }}>Saved {updated}</span>}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button className="btn btn-outline btn-sm" onClick={onEdit}>Edit</button>
<button
className="btn btn-outline btn-sm"
onClick={onRevision}
title="Create a new revision based on this one"
style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }}
>+ Revision</button>
<button
type="button"
onClick={onDelete}
style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
></button>
</div>
</div>
);
}
// ─── Sign Card ────────────────────────────────────────────────────────────────
function SignCard({ sign, index, expanded, onToggle, onChange, onPhotoChange, onRemove, canRemove }) {
const photoInputRef = useRef();
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
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 file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) onPhotoChange(file);
};
const summary = [sign.type, sign.location].filter(Boolean).join(' — ') || 'New Sign';
const hasPhoto = sign._photoPreview;
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<button
type="button"
onClick={onToggle}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 14px', background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
fontFamily: 'inherit', borderBottom: expanded ? '1px solid var(--border)' : 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4 }}>
#{sign.signNumber || (index + 1)}
</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{summary}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{hasPhoto && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>📷</span>}
{sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>}
{canRemove && (
<span role="button" onClick={e => { e.stopPropagation(); onRemove(); }}
style={{ fontSize: 13, color: 'var(--danger, #dc2626)', padding: '2px 6px', cursor: 'pointer' }}></span>
)}
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{expanded ? '▲' : '▼'}</span>
</div>
</button>
{expanded && (
<div style={{ padding: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div className="form-group" style={{ margin: 0 }}>
<label>Sign # / ID</label>
<input type="text" placeholder={`e.g. ${index + 1}`} value={sign.signNumber} onChange={e => onChange('signNumber', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Sign Type</label>
<input type="text" placeholder="e.g. Monument, Channel Letter, Pylon…" value={sign.type} onChange={e => onChange('type', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Location</label>
<input type="text" placeholder="e.g. Main entrance, North parking lot" value={sign.location} onChange={e => onChange('location', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Dimensions (inches)</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="text" placeholder='Width"' value={sign.width} onChange={e => onChange('width', e.target.value)} style={{ flex: 1 }} />
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>×</span>
<input type="text" placeholder='Height"' value={sign.height} onChange={e => onChange('height', e.target.value)} style={{ flex: 1 }} />
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, marginBottom: 12 }}>
<div className="form-group" style={{ margin: 0 }}>
<label>Material <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
<input type="text" placeholder="e.g. Aluminum, Acrylic" value={sign.material} onChange={e => onChange('material', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Illumination <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
<input type="text" placeholder="e.g. LED, None, Neon" value={sign.illumination} onChange={e => onChange('illumination', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Condition <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
<input type="text" placeholder="e.g. Good, Fair, Poor" value={sign.condition} onChange={e => onChange('condition', e.target.value)} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 12, marginBottom: 12 }}>
<div className="form-group" style={{ margin: 0 }}>
<label>Mount Type <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
<input type="text" placeholder="e.g. Wall, Post, Ground" value={sign.mountType} onChange={e => onChange('mountType', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Notes</label>
<input type="text" placeholder="Any additional observations…" value={sign.notes} onChange={e => onChange('notes', e.target.value)} />
</div>
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Sign Photo</label>
<div
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDrop={handleDrop}
onClick={() => photoInputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: 12, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12,
transition: 'border-color 0.15s, background 0.15s', minHeight: 60,
}}
>
{sign._photoPreview ? (
<>
<img src={sign._photoPreview} alt="sign" style={{ height: 60, width: 80, objectFit: 'cover', borderRadius: 4, flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>
{sign.photo ? sign.photo.name : 'Saved photo'}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>Click to replace · drag a new photo</div>
</div>
</>
) : (
<div style={{ color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13 }}>
{dragging ? '📂 Drop photo here' : '📷 Click to upload or drag & drop a photo'}
</div>
)}
</div>
<input ref={photoInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; if (f) onPhotoChange(f); e.target.value = ''; }} />
</div>
</div>
)}
</div>
);
}
// ─── Site Map Drop Zone ───────────────────────────────────────────────────────
function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
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 file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) onFile(file);
};
if (preview) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<img src={preview} alt="site map" style={{ maxHeight: 160, maxWidth: 280, borderRadius: 6, border: '1px solid var(--border)', objectFit: 'contain' }} />
<div>
<button className="btn btn-outline btn-sm" onClick={onClear}>Remove</button>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8 }}>Click to replace</div>
</div>
</div>
);
}
return (
<>
<div
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
}}
>
{dragging ? '📂 Drop site map here' : '🗺 Click to upload or drag & drop a site map image'}
</div>
<input ref={inputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; if (f) onFile(f); e.target.value = ''; }} />
</>
);
}
// ─── Survey Photos Drop Zone ──────────────────────────────────────────────────
function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
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); onFiles(e.dataTransfer.files); };
return (
<div>
<div
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s',
}}
>
{dragging ? '📂 Drop photos here' : '📷 Click to upload or drag & drop photos (select multiple)'}
</div>
<input ref={inputRef} type="file" accept="image/*" multiple style={{ display: 'none' }}
onChange={e => { if (e.target.files.length) { onFiles(e.target.files); e.target.value = ''; } }} />
{photoItems.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{photoItems.map((item, i) => (
<div key={i} style={{ position: 'relative' }}>
<img
src={item.preview}
alt={item.file?.name || `photo ${i + 1}`}
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }}
/>
{item.file && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(245,165,35,0.8)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 700 }}>NEW</div>
)}
<button
type="button"
onClick={() => onRemove(i)}
style={{
position: 'absolute', top: -6, right: -6,
background: '#dc2626', border: 'none', borderRadius: '50%',
width: 18, height: 18, fontSize: 10, color: '#fff',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
}}
></button>
</div>
))}
<div style={{ fontSize: 11, color: 'var(--text-muted)', alignSelf: 'flex-end', marginLeft: 4 }}>
{photoItems.length} photo{photoItems.length !== 1 ? 's' : ''}
{photoItems.length > 12 && <span style={{ color: 'var(--accent)' }}> (first 12 shown in PDF)</span>}
</div>
</div>
)}
</div>
);
}
+384 -18
View File
@@ -1,12 +1,14 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import JSZip from 'jszip';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { sendEmail } from '../../lib/email';
import { useAuth } from '../../context/AuthContext';
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
const vLabel = (v) => 'v' + String(v || 0).padStart(2, '0');
const MAX_FILES = 20;
const MAX_SIZE_MB = 10;
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
@@ -24,12 +26,26 @@ export default function TaskDetail() {
const [teamMembers, setTeamMembers] = useState([]);
const [loading, setLoading] = useState(true);
const isExternal = currentUser?.role === 'external';
const [editingTitle, setEditingTitle] = useState(false);
const [titleVal, setTitleVal] = useState('');
const [savingTitle, setSavingTitle] = useState(false);
const [notification, setNotification] = useState(null);
const [showSendForm, setShowSendForm] = useState(false);
const [sendForm, setSendForm] = useState({ files: [], message: '' });
const [fileErrors, setFileErrors] = useState([]);
const [dragging, setDragging] = useState(false);
const dragCounter = useRef(0);
const [saving, setSaving] = useState(false);
const [showWorkUpload, setShowWorkUpload] = useState(false);
const [workForm, setWorkForm] = useState({ files: [], description: '' });
const [workFileErrors, setWorkFileErrors] = useState([]);
const [workDragging, setWorkDragging] = useState(false);
const workDragCounter = useRef(0);
useEffect(() => {
async function load() {
const { data: t } = await supabase.from('tasks').select('*').eq('id', id).single();
@@ -38,7 +54,7 @@ export default function TaskDetail() {
const [{ data: p }, { data: subs }, { data: team }] = await Promise.all([
supabase.from('projects').select('*, company:companies(*)').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'),
supabase.from('profiles').select('*').eq('role', 'team'),
]);
setProject(p);
@@ -81,8 +97,7 @@ export default function TaskDetail() {
setNotification('✓ Job assigned.');
};
const handleFileChange = (e) => {
const incoming = Array.from(e.target.files);
const processFiles = (incoming) => {
const combined = [...sendForm.files, ...incoming];
const errors = [];
if (combined.length > MAX_FILES) errors.push(`Maximum ${MAX_FILES} files allowed.`);
@@ -90,9 +105,35 @@ export default function TaskDetail() {
if (errors.length > 0) { setFileErrors(errors); return; }
setFileErrors([]);
setSendForm(f => ({ ...f, files: combined }));
};
const handleFileChange = (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 removeFile = (index) => {
setSendForm(f => ({ ...f, files: f.files.filter((_, i) => i !== index) }));
setFileErrors([]);
@@ -134,11 +175,33 @@ export default function TaskDetail() {
const { data: subs } = 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(subs || []);
// Fetch company user emails to notify
const { data: companyUsers } = await supabase
.from('profiles')
.select('email')
.eq('company_id', company?.id)
.eq('role', 'client');
const clientEmails = (companyUsers || []).map(u => u.email).filter(Boolean);
// Fall back to sender if no client emails found
const emailRecipients = clientEmails.length > 0 ? clientEmails : (currentUser?.email ? [currentUser.email] : []);
if (emailRecipients.length > 0) {
await sendEmail('sent_to_client', emailRecipients, {
clientFirstName: company?.name,
serviceType: task.title,
projectName: project?.name,
message: sendForm.message,
taskId: id,
senderEmail: currentUser?.email,
});
}
setShowSendForm(false);
setSendForm({ files: [], message: '' });
setNotification(`✓ Sent to client — ${uploadedFiles.length} file${uploadedFiles.length !== 1 ? 's' : ''} delivered.`);
@@ -154,6 +217,109 @@ export default function TaskDetail() {
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 handleDeleteTask = async () => {
if (!window.confirm(`Delete "${task.title}"? All submissions and files will be permanently deleted.`)) return;
await cleanupTaskStorage([id]);
await supabase.from('tasks').delete().eq('id', id);
navigate(`/projects/${task.project_id}`);
};
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 processWorkFiles = (incoming) => {
const combined = [...workForm.files, ...incoming];
const errors = [];
if (combined.length > MAX_FILES) errors.push(`Maximum ${MAX_FILES} files allowed.`);
incoming.filter(f => f.size > MAX_SIZE_BYTES).forEach(f => errors.push(`"${f.name}" exceeds 10 MB limit.`));
if (errors.length > 0) { setWorkFileErrors(errors); return; }
setWorkFileErrors([]);
setWorkForm(f => ({ ...f, files: combined }));
};
const handleWorkUpload = async (e) => {
e.preventDefault();
setSaving(true);
try {
const maxVersion = submissions.reduce((max, s) => Math.max(max, s.version_number), 0);
const newVersion = maxVersion + 1;
const uploadedFiles = [];
for (const file of workForm.files) {
const path = `${id}/${Date.now()}_${file.name}`;
const { data: uploaded, error } = await supabase.storage.from('submissions').upload(path, file);
if (error) throw new Error(`Upload failed: ${error.message}`);
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
}
const { data: sub, error: subError } = await supabase.from('submissions').insert({
task_id: id,
version_number: newVersion,
type: 'initial',
service_type: submissions[0]?.service_type || '',
description: workForm.description.trim() || `Work uploaded by ${currentUser.name}`,
submitted_by: currentUser.id,
submitted_by_name: currentUser.name,
}).select().single();
if (subError) throw new Error(subError.message);
if (sub && uploadedFiles.length > 0) {
await supabase.from('submission_files').insert(
uploadedFiles.map(f => ({ ...f, submission_id: sub.id }))
);
}
await supabase.from('tasks').update({ current_version: newVersion - 1 }).eq('id', id);
setTask(t => ({ ...t, current_version: newVersion - 1 }));
const { data: subs } = await supabase
.from('submissions')
.select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))')
.eq('task_id', id)
.order('version_number');
setSubmissions(subs || []);
setWorkForm({ files: [], description: '' });
setShowWorkUpload(false);
setNotification(`✓ Work uploaded — ${uploadedFiles.length} file${uploadedFiles.length !== 1 ? 's' : ''} added.`);
} catch (err) {
setNotification(`✗ Error: ${err.message}`);
} finally {
setSaving(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 <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!task) return <Layout><p>Job not found.</p></Layout>;
@@ -167,14 +333,47 @@ export default function TaskDetail() {
<div className="page-header">
<div>
{editingTitle && !isExternal ? (
<form onSubmit={handleSaveTitle} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input
type="text"
value={titleVal}
onChange={e => setTitleVal(e.target.value)}
autoFocus
required
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 280 }}
/>
<button type="submit" className="btn btn-primary btn-sm" disabled={savingTitle}>{savingTitle ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingTitle(false)}>Cancel</button>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="page-title">{titleWithVersion}</div>
{!isExternal && (
<button className="btn btn-outline btn-sm" onClick={() => { setTitleVal(task.title); setEditingTitle(true); }}>Edit</button>
)}
</div>
)}
<div className="page-subtitle">
<Link to={`/companies/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
{!isExternal && company && (
<>
<Link to={`/companies/${company.id}`} style={{ color: 'var(--accent)' }}>{company.name}</Link>
{' '}
</>
)}
<Link to={`/projects/${project?.id}`} style={{ color: 'var(--accent)' }}>{project?.name}</Link>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StatusBadge status={task.status} />
{!isExternal && (
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }}
onClick={handleDeleteTask}
>Delete Job</button>
)}
</div>
</div>
{notification && <div className="notification notification-success">{notification}</div>}
@@ -193,16 +392,27 @@ export default function TaskDetail() {
Up to {MAX_FILES} files · Max {MAX_SIZE_MB} MB each
</span>
</label>
<div style={{
border: `2px dashed ${sendForm.files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : sendForm.files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8, padding: '20px 16px', textAlign: 'center',
background: 'var(--bg)',
}}>
background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)',
transition: 'all 0.15s',
}}
>
<input type="file" multiple onChange={handleFileChange} style={{ display: 'none' }} id="file-upload" />
<label htmlFor="file-upload" style={{ cursor: 'pointer' }}>
<div style={{ fontSize: 24, marginBottom: 6 }}>📎</div>
<div style={{ fontSize: 24, marginBottom: 6 }}>{dragging ? '📂' : '📎'}</div>
<div style={{ fontWeight: 600, fontSize: 13 }}>
{sendForm.files.length > 0 ? `${sendForm.files.length} file${sendForm.files.length !== 1 ? 's' : ''} added` : 'Click to add files'}
{dragging
? 'Drop files here'
: sendForm.files.length > 0
? `${sendForm.files.length} file${sendForm.files.length !== 1 ? 's' : ''} added — click or drag to add more`
: 'Click or drag files here'}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>Any file type accepted</div>
</label>
@@ -253,6 +463,7 @@ export default function TaskDetail() {
<div className="detail-item"><label>Submitted</label><p>{new Date(task.submitted_at).toLocaleDateString()}</p></div>
<div className="detail-item"><label>Completed</label><p>{task.completed_at ? new Date(task.completed_at).toLocaleDateString() : '—'}</p></div>
</div>
{!isExternal && (
<div className="form-group">
<label>Assigned To</label>
<select value={task.assigned_to || ''} onChange={handleAssign} style={{ width: '100%' }}>
@@ -260,18 +471,32 @@ export default function TaskDetail() {
{teamMembers.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
)}
{isExternal && task.assigned_name && (
<div className="form-group">
<label>Assigned To</label>
<p style={{ margin: 0, fontSize: 14, color: 'var(--text-primary)' }}>{task.assigned_name}</p>
</div>
)}
<div className="action-buttons" style={{ marginTop: 8 }}>
{task.status === 'not_started' && (
<button className="btn btn-primary" onClick={handleStart} disabled={saving}> Start Job</button>
<button className="btn btn-primary btn-sm" onClick={handleStart} disabled={saving}> Start Job</button>
)}
{task.status === 'in_progress' && !showSendForm && (
{task.status === 'in_progress' && (
<>
<button className="btn btn-success" onClick={() => setShowSendForm(true)}> Send to Client</button>
<button className="btn btn-outline" onClick={handleOnHold} disabled={saving}> Put On Hold</button>
{!isExternal && !showSendForm && (
<button className="btn btn-success btn-sm" onClick={() => setShowSendForm(true)}> Send to Client</button>
)}
{isExternal && (
<button className="btn btn-primary btn-sm" onClick={() => setShowWorkUpload(s => !s)} disabled={saving}>
{showWorkUpload ? 'Cancel Upload' : '⬆ Upload Work'}
</button>
)}
<button className="btn btn-outline btn-sm" onClick={handleOnHold} disabled={saving}> Put On Hold</button>
</>
)}
{task.status === 'on_hold' && (
<button className="btn btn-primary" onClick={handleResume} disabled={saving}> Resume</button>
<button className="btn btn-primary btn-sm" onClick={handleResume} disabled={saving}> Resume</button>
)}
{task.status === 'client_review' && (
<div style={{ padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, fontSize: 13, color: '#7c3aed', fontWeight: 500 }}>
@@ -324,14 +549,120 @@ export default function TaskDetail() {
{amendment.submitted_by_name} · {new Date(amendment.submitted_at).toLocaleDateString()}
</div>
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
{amendment.files?.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{amendment.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📎</span>
<span style={{ fontSize: 12, fontWeight: 500 }}>{file.name}</span>
</div>
<button className="btn btn-outline btn-sm" onClick={() => getSubmissionFileUrl(file.storage_path)}>📥 View</button>
</div>
))}
</div>
)}
</div>
))}
{primary.files?.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</label>
{primary.files.length > 1 && (
<button className="btn btn-outline btn-sm" onClick={() => downloadAllSubmissionFiles(primary.files, vLabel(primary.version_number - 1))}> Download All</button>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{primary.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📎</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
</div>
<button className="btn btn-outline btn-sm" onClick={() => getSubmissionFileUrl(file.storage_path)}>📥 View</button>
</div>
))}
</div>
</div>
)}
</>
);
})()}
</div>
</div>
{showWorkUpload && isExternal && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
<div className="card-title">Upload Work Files</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
Upload your completed files. The team will review and send to the client.
</p>
<form onSubmit={handleWorkUpload}>
<div className="form-group">
<label>
Files *
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>
Up to {MAX_FILES} files · Max {MAX_SIZE_MB} MB each
</span>
</label>
<div
onDragEnter={e => { e.preventDefault(); workDragCounter.current++; setWorkDragging(true); }}
onDragLeave={e => { e.preventDefault(); workDragCounter.current--; if (workDragCounter.current === 0) setWorkDragging(false); }}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); workDragCounter.current = 0; setWorkDragging(false); const dropped = Array.from(e.dataTransfer.files); if (dropped.length > 0) processWorkFiles(dropped); }}
style={{
border: `2px dashed ${workDragging ? 'var(--accent)' : workForm.files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8, padding: '20px 16px', textAlign: 'center',
background: workDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)',
transition: 'all 0.15s',
}}
>
<input type="file" multiple onChange={e => { processWorkFiles(Array.from(e.target.files)); e.target.value = ''; }} style={{ display: 'none' }} id="work-file-upload" />
<label htmlFor="work-file-upload" style={{ cursor: 'pointer' }}>
<div style={{ fontSize: 24, marginBottom: 6 }}>{workDragging ? '📂' : '📎'}</div>
<div style={{ fontWeight: 600, fontSize: 13 }}>
{workDragging ? 'Drop files here' : workForm.files.length > 0 ? `${workForm.files.length} file${workForm.files.length !== 1 ? 's' : ''} added — click or drag to add more` : 'Click or drag files here'}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>Any file type accepted</div>
</label>
</div>
{workFileErrors.map((err, i) => <div key={i} style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}> {err}</div>)}
{workForm.files.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
{workForm.files.map((file, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📄</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{file.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div>
</div>
</div>
<button type="button" onClick={() => setWorkForm(f => ({ ...f, files: f.files.filter((_, fi) => fi !== i) }))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16 }}></button>
</div>
))}
</div>
)}
</div>
<div className="form-group">
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<textarea
placeholder="Any notes for the team..."
value={workForm.description}
onChange={e => setWorkForm(f => ({ ...f, description: e.target.value }))}
style={{ minHeight: 80 }}
/>
</div>
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={workForm.files.length === 0 || saving}>
{saving ? 'Uploading...' : `⬆ Upload${workForm.files.length > 0 ? ` (${workForm.files.length} file${workForm.files.length !== 1 ? 's' : ''})` : ''}`}
</button>
<button type="button" className="btn btn-outline" onClick={() => { setShowWorkUpload(false); setWorkForm({ files: [], description: '' }); }}>Cancel</button>
</div>
</form>
</div>
)}
{submissions.length > 0 && (
<div className="card">
<div className="card-title">Version History</div>
@@ -353,6 +684,7 @@ export default function TaskDetail() {
<div style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10, borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
<span style={{ fontWeight: 700, fontSize: 13 }}>{vLabel(primary.version_number - 1)}</span>
<StatusBadge status={primary.type} />
{primary.revision_type && <StatusBadge status={primary.revision_type} />}
{isCurrent && <span style={{ fontSize: 11, color: 'var(--accent)', fontWeight: 600 }}>Current</span>}
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
{primary.submitted_by_name} · {new Date(primary.submitted_at).toLocaleDateString()}
@@ -371,6 +703,27 @@ export default function TaskDetail() {
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap', margin: 0 }}>{primary.description}</p>
{primary.files?.length > 0 && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</span>
{primary.files.length > 1 && (
<button className="btn btn-outline btn-sm" onClick={() => downloadAllSubmissionFiles(primary.files)}> Download All</button>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{primary.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📎</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
</div>
<button className="btn btn-outline btn-sm" onClick={() => getSubmissionFileUrl(file.storage_path)}>📥 View</button>
</div>
))}
</div>
</div>
)}
{amendments.map(amendment => (
<div key={amendment.id} style={{ marginTop: 12, padding: '12px 14px', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 6, letterSpacing: 0.5 }}>
@@ -380,6 +733,19 @@ export default function TaskDetail() {
{amendment.submitted_by_name} · {new Date(amendment.submitted_at).toLocaleDateString()}
</div>
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
{amendment.files?.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{amendment.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📎</span>
<span style={{ fontSize: 12, fontWeight: 500 }}>{file.name}</span>
</div>
<button className="btn btn-outline btn-sm" onClick={() => getSubmissionFileUrl(file.storage_path)}>📥 View</button>
</div>
))}
</div>
)}
</div>
))}
</div>
@@ -0,0 +1,74 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import Stripe from 'https://esm.sh/stripe@14?target=deno';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { apiVersion: '2023-10-16' });
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
// Find all paid invoices that don't have a stripe_fee yet
const { data: invoices, error } = await supabase
.from('invoices')
.select('id, invoice_number, total')
.eq('status', 'paid')
.is('stripe_fee', null);
if (error) {
return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: corsHeaders });
}
if (!invoices?.length) {
return new Response(JSON.stringify({ message: 'No invoices missing fees', updated: 0 }), { headers: corsHeaders });
}
const results: { invoice_id: string; invoice_number: string; status: string; fee?: number }[] = [];
for (const invoice of invoices) {
try {
// Search Stripe checkout sessions by invoice_id metadata
const sessions = await stripe.checkout.sessions.search({
query: `metadata["invoice_id"]:"${invoice.id}"`,
limit: 1,
expand: ['data.payment_intent.latest_charge.balance_transaction'],
});
const session = sessions.data[0];
if (!session) {
results.push({ invoice_id: invoice.id, invoice_number: invoice.invoice_number, status: 'no_session' });
continue;
}
const paymentIntent = session.payment_intent as Stripe.PaymentIntent | null;
const charge = paymentIntent?.latest_charge as Stripe.Charge | null;
const balanceTx = charge?.balance_transaction as Stripe.BalanceTransaction | null;
if (balanceTx?.fee == null) {
results.push({ invoice_id: invoice.id, invoice_number: invoice.invoice_number, status: 'no_balance_tx' });
continue;
}
const stripe_fee = balanceTx.fee / 100;
await supabase.from('invoices').update({ stripe_fee }).eq('id', invoice.id);
results.push({ invoice_id: invoice.id, invoice_number: invoice.invoice_number, status: 'updated', fee: stripe_fee });
} catch (err) {
results.push({ invoice_id: invoice.id, invoice_number: invoice.invoice_number, status: `error: ${err.message}` });
}
}
const updated = results.filter(r => r.status === 'updated').length;
return new Response(
JSON.stringify({ updated, total: invoices.length, results }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
);
});
@@ -0,0 +1,56 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import Stripe from 'https://esm.sh/stripe@14?target=deno';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { apiVersion: '2023-10-16' });
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!;
serve(async (req) => {
const body = await req.text();
const sig = req.headers.get('stripe-signature');
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(body, sig!, webhookSecret);
} catch (err) {
console.error('Webhook signature failed:', err.message);
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const invoice_id = session.metadata?.invoice_id;
if (invoice_id) {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
// Retrieve the Stripe processing fee from the balance transaction
let stripe_fee: number | null = null;
try {
const paymentIntentId = session.payment_intent as string;
if (paymentIntentId) {
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ['latest_charge.balance_transaction'],
});
const charge = paymentIntent.latest_charge as Stripe.Charge | null;
const balanceTx = charge?.balance_transaction as Stripe.BalanceTransaction | null;
if (balanceTx?.fee != null) {
stripe_fee = balanceTx.fee / 100;
}
}
} catch (err) {
console.error('Failed to retrieve Stripe fee:', err.message);
}
const updateData: Record<string, unknown> = { status: 'paid' };
if (stripe_fee !== null) updateData.stripe_fee = stripe_fee;
await supabase.from('invoices').update(updateData).eq('id', invoice_id);
}
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
});
@@ -0,0 +1,2 @@
-- Add stripe_fee column to store the Stripe processing fee on paid invoices
alter table public.invoices add column if not exists stripe_fee numeric(10,2);
@@ -0,0 +1,13 @@
-- Add cover page fields to brand_books table
ALTER TABLE brand_books
ADD COLUMN IF NOT EXISTS creation_date date,
ADD COLUMN IF NOT EXISTS revision_date date,
ADD COLUMN IF NOT EXISTS customer_name text,
ADD COLUMN IF NOT EXISTS customer_address text,
ADD COLUMN IF NOT EXISTS project_logo_path text,
ADD COLUMN IF NOT EXISTS client_logo_url text,
ADD COLUMN IF NOT EXISTS client_contact_name text,
ADD COLUMN IF NOT EXISTS client_contact_email text,
ADD COLUMN IF NOT EXISTS client_contact_phone text,
ADD COLUMN IF NOT EXISTS approved_date date,
ADD COLUMN IF NOT EXISTS approval_notes text;
@@ -0,0 +1,4 @@
-- Add template and inventory map fields to brand_books table
ALTER TABLE brand_books
ADD COLUMN IF NOT EXISTS template text DEFAULT 'fourge',
ADD COLUMN IF NOT EXISTS inventory_map_path text;
@@ -0,0 +1,29 @@
-- Add brand book / cover page fields to companies
ALTER TABLE companies
ADD COLUMN IF NOT EXISTS address text,
ADD COLUMN IF NOT EXISTS contact_name text,
ADD COLUMN IF NOT EXISTS contact_email text,
ADD COLUMN IF NOT EXISTS contact_phone text,
ADD COLUMN IF NOT EXISTS client_logo_url text;
-- Create public bucket for company logos
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM storage.buckets WHERE id = 'company-logos') THEN
INSERT INTO storage.buckets (id, name, public) VALUES ('company-logos', 'company-logos', true);
END IF;
END $$;
-- Storage policies for company-logos
DROP POLICY IF EXISTS "Authenticated users can manage company logos" ON storage.objects;
DROP POLICY IF EXISTS "Public can read company logos" ON storage.objects;
CREATE POLICY "Authenticated users can manage company logos"
ON storage.objects FOR ALL
TO authenticated
USING (bucket_id = 'company-logos')
WITH CHECK (bucket_id = 'company-logos');
CREATE POLICY "Public can read company logos"
ON storage.objects FOR SELECT
USING (bucket_id = 'company-logos');
+123
View File
@@ -0,0 +1,123 @@
-- ============================================================
-- Migration: Add external role and project_members table
-- Run this in Supabase → SQL Editor → Run
-- ============================================================
-- 1. Update profiles.role check constraint to include 'external'
do $$
declare
cname text;
begin
select constraint_name into cname
from information_schema.table_constraints
where table_schema = 'public'
and table_name = 'profiles'
and constraint_type = 'CHECK'
and constraint_name ilike '%role%';
if cname is not null then
execute 'alter table public.profiles drop constraint ' || quote_ident(cname);
end if;
end;
$$;
alter table public.profiles
add constraint profiles_role_check check (role in ('team', 'client', 'external'));
-- 2. project_members table
create table public.project_members (
id uuid default gen_random_uuid() primary key,
project_id uuid references public.projects(id) on delete cascade not null,
profile_id uuid references public.profiles(id) on delete cascade not null,
created_at timestamptz default now() not null,
unique(project_id, profile_id)
);
alter table public.project_members enable row level security;
-- 3. Helper function
create or replace function public.is_external()
returns boolean as $$
select get_my_role() = 'external';
$$ language sql security definer stable;
-- 4. RLS: project_members
create policy "Team all project_members" on public.project_members
for all using (get_my_role() = 'team');
create policy "External reads own memberships" on public.project_members
for select using (profile_id = auth.uid());
-- 5. RLS: projects (external reads assigned only)
create policy "External reads assigned projects" on public.projects
for select using (
get_my_role() = 'external' and
id in (select project_id from public.project_members where profile_id = auth.uid())
);
-- 6. RLS: tasks (external reads + updates assigned projects)
create policy "External reads assigned tasks" on public.tasks
for select using (
get_my_role() = 'external' and
project_id in (select project_id from public.project_members where profile_id = auth.uid())
);
create policy "External updates assigned tasks" on public.tasks
for update using (
get_my_role() = 'external' and
project_id in (select project_id from public.project_members where profile_id = auth.uid())
);
-- 7. RLS: submissions
create policy "External reads assigned submissions" on public.submissions
for select using (
get_my_role() = 'external' and
task_id in (
select t.id from public.tasks t
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
create policy "External inserts submissions" on public.submissions
for insert with check (
get_my_role() = 'external' and submitted_by = auth.uid()
);
-- 8. RLS: submission_files
create policy "External reads assigned submission_files" on public.submission_files
for select using (
get_my_role() = 'external' and
submission_id in (
select s.id from public.submissions s
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
create policy "External inserts submission_files" on public.submission_files
for insert with check (get_my_role() = 'external');
-- 9. RLS: deliveries (read only)
create policy "External reads assigned deliveries" on public.deliveries
for select using (
get_my_role() = 'external' and
submission_id in (
select s.id from public.submissions s
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
-- 10. RLS: delivery_files (read only)
create policy "External reads assigned delivery_files" on public.delivery_files
for select using (
get_my_role() = 'external' and
delivery_id in (
select d.id from public.deliveries d
join public.submissions s on s.id = d.submission_id
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
-- 11. RLS: profiles (external reads own profile only — already covered by existing policy)
-- "Own profile select" policy already handles this with: id = auth.uid()
-- No additional policy needed.
+17
View File
@@ -0,0 +1,17 @@
-- ============================================================
-- Migration: Add price_type to company_prices (new vs revision)
-- Run in Supabase → SQL Editor → Run
-- ============================================================
-- Add price_type column (existing rows default to 'new')
alter table public.company_prices
add column price_type text not null default 'new'
check (price_type in ('new', 'revision'));
-- Drop old unique constraint and add new one that includes price_type
alter table public.company_prices
drop constraint company_prices_company_id_service_type_key;
alter table public.company_prices
add constraint company_prices_company_id_service_type_price_type_key
unique (company_id, service_type, price_type);
@@ -0,0 +1,15 @@
-- ============================================================
-- Migration: Add revision billing tracking
-- Run in Supabase → SQL Editor → Run
-- ============================================================
-- Add revision_type and invoiced to submissions
alter table public.submissions
add column revision_type text check (revision_type in ('fourge_error', 'client_revision'));
alter table public.submissions
add column invoiced boolean not null default false;
-- Add submission_id to invoice_items (links a line item to a specific revision)
alter table public.invoice_items
add column submission_id uuid references public.submissions(id) on delete set null;
+308 -15
View File
@@ -9,6 +9,10 @@ create table public.companies (
name text not null,
phone text default '',
address text default '',
contact_name text,
contact_email text,
contact_phone text,
client_logo_url text,
created_at timestamptz default now() not null
);
alter table public.companies enable row level security;
@@ -18,7 +22,7 @@ create table public.profiles (
id uuid references auth.users on delete cascade primary key,
name text not null default '',
email text default '',
role text not null check (role in ('team', 'client')) default 'client',
role text not null check (role in ('team', 'client', 'external')) default 'client',
company_id uuid references public.companies(id) on delete set null,
created_at timestamptz default now() not null
);
@@ -56,6 +60,8 @@ create table public.submissions (
task_id uuid references public.tasks(id) on delete cascade not null,
version_number integer not null,
type text not null check (type in ('initial', 'revision', 'amendment')) default 'initial',
revision_type text check (revision_type in ('fourge_error', 'client_revision')),
invoiced boolean not null default false,
service_type text default '',
deadline date,
description text default '',
@@ -95,13 +101,24 @@ create table public.delivery_files (
);
alter table public.delivery_files enable row level security;
-- Company Prices (per service type, per company)
-- Project Members (external users assigned to specific projects)
create table public.project_members (
id uuid default gen_random_uuid() primary key,
project_id uuid references public.projects(id) on delete cascade not null,
profile_id uuid references public.profiles(id) on delete cascade not null,
created_at timestamptz default now() not null,
unique(project_id, profile_id)
);
alter table public.project_members enable row level security;
-- Company Prices (per service type, per company, per price type)
create table public.company_prices (
id uuid default gen_random_uuid() primary key,
company_id uuid references public.companies(id) on delete cascade not null,
service_type text not null,
price numeric(10,2) default 0,
unique(company_id, service_type)
price_type text not null default 'new' check (price_type in ('new', 'revision')),
unique(company_id, service_type, price_type)
);
alter table public.company_prices enable row level security;
@@ -115,6 +132,7 @@ create table public.invoices (
due_date date,
total numeric(10,2) default 0,
notes text default '',
stripe_fee numeric(10,2),
created_by uuid references public.profiles(id) on delete set null,
created_at timestamptz default now() not null
);
@@ -125,6 +143,7 @@ create table public.invoice_items (
id uuid default gen_random_uuid() primary key,
invoice_id uuid references public.invoices(id) on delete cascade not null,
task_id uuid references public.tasks(id) on delete set null,
submission_id uuid references public.submissions(id) on delete set null,
description text not null,
quantity numeric(10,2) default 1,
unit_price numeric(10,2) default 0,
@@ -132,6 +151,63 @@ create table public.invoice_items (
);
alter table public.invoice_items enable row level security;
-- Brand Books (sign survey / brand book records, team only)
create table public.brand_books (
id uuid default gen_random_uuid() primary key,
client_id uuid references public.companies(id) on delete set null,
client_name text not null default '',
project_name text default '',
site_address text default '',
book_date date,
prepared_by text default '',
revision text default '',
template text default 'fourge',
site_map_path text,
inventory_map_path text,
signs jsonb default '[]',
survey_photo_paths text[] default '{}',
updated_at timestamptz default now() not null,
-- Cover page fields
project_logo_path text,
creation_date date,
revision_date date,
customer_name text,
customer_address text,
client_logo_url text,
client_contact_name text,
client_contact_email text,
client_contact_phone text,
approved_date date,
approval_notes text
);
alter table public.brand_books enable row level security;
-- Server Status Overrides (team-managed usage inputs for metrics without live APIs)
create table public.server_status_overrides (
id boolean primary key default true,
supabase_egress_bytes bigint,
vercel_fast_data_transfer_bytes bigint,
vercel_edge_requests bigint,
vercel_function_invocations bigint,
vercel_active_cpu_hours numeric(10,4),
updated_at timestamptz not null default now()
);
alter table public.server_status_overrides enable row level security;
create table public.fourge_passwords (
id uuid default gen_random_uuid() primary key,
service_name text not null,
service_url text default '',
username text not null default '',
encrypted_password text not null,
password_iv text not null,
notes text default '',
created_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.fourge_passwords enable row level security;
-- ============================================================
-- Helpers
-- ============================================================
@@ -141,11 +217,48 @@ returns text as $$
select role from public.profiles where id = auth.uid();
$$ language sql security definer stable;
create or replace function public.is_external()
returns boolean as $$
select get_my_role() = 'external';
$$ language sql security definer stable;
create or replace function public.get_my_company_id()
returns uuid as $$
select company_id from public.profiles where id = auth.uid();
$$ language sql security definer stable;
create or replace function public.get_database_size_bytes()
returns bigint as $$
select pg_database_size(current_database());
$$ language sql security definer stable;
-- Prevents clients and externals from modifying protected task fields.
-- Fires before UPDATE so WITH CHECK sees the already-corrected new row.
create or replace function public.guard_task_update()
returns trigger as $$
declare
caller_role text;
begin
select role into caller_role from public.profiles where id = auth.uid();
if caller_role = 'client' then
new.project_id := old.project_id;
new.assigned_to := old.assigned_to;
new.assigned_name := old.assigned_name;
new.invoiced := old.invoiced;
elsif caller_role = 'external' then
new.project_id := old.project_id;
new.invoiced := old.invoiced;
end if;
return new;
end;
$$ language plpgsql security definer;
create trigger guard_task_update
before update on public.tasks
for each row execute function public.guard_task_update();
-- ============================================================
-- RLS Policies
-- ============================================================
@@ -153,17 +266,36 @@ $$ language sql security definer stable;
-- Companies
create policy "Team all companies" on public.companies for all using (get_my_role() = 'team');
create policy "Client reads own company" on public.companies for select using (id = get_my_company_id());
create policy "Client updates own company" on public.companies
for update using (id = get_my_company_id()) with check (id = get_my_company_id());
-- Project Members
create policy "Team all project_members" on public.project_members for all using (get_my_role() = 'team');
create policy "External reads own memberships" on public.project_members for select using (profile_id = auth.uid());
-- Profiles
create policy "Own profile select" on public.profiles for select using (id = auth.uid());
create policy "Team reads all profiles" on public.profiles for select using (get_my_role() = 'team');
create policy "Own profile update" on public.profiles for update using (id = auth.uid());
create policy "Own profile update" on public.profiles
for update
using (id = auth.uid())
with check (
id = auth.uid()
and role = (select role from public.profiles where id = auth.uid())
and company_id is not distinct from (select company_id from public.profiles where id = auth.uid())
);
create policy "Team update profiles" on public.profiles for update using (get_my_role() = 'team');
-- Projects
create policy "Team all projects" on public.projects for all using (get_my_role() = 'team');
create policy "Client reads company projects" on public.projects for select using (company_id = get_my_company_id());
create policy "Client inserts company projects" on public.projects for insert with check (company_id = get_my_company_id());
create policy "Client updates own company projects" on public.projects
for update using (company_id = get_my_company_id()) with check (company_id = get_my_company_id());
create policy "External reads assigned projects" on public.projects for select using (
get_my_role() = 'external' and
id in (select project_id from public.project_members where profile_id = auth.uid())
);
-- Tasks
create policy "Team all tasks" on public.tasks for all using (get_my_role() = 'team');
@@ -173,9 +305,30 @@ create policy "Client reads company tasks" on public.tasks for select using (
create policy "Client insert task" on public.tasks for insert with check (
project_id in (select id from public.projects where company_id = get_my_company_id())
);
create policy "Client updates company tasks" on public.tasks for update using (
project_id in (select id from public.projects where company_id = get_my_company_id())
create policy "Client updates company tasks" on public.tasks
for update
using (
get_my_role() = 'client'
and project_id in (select id from public.projects where company_id = get_my_company_id())
)
with check (
get_my_role() = 'client'
and project_id in (select id from public.projects where company_id = get_my_company_id())
);
create policy "External reads assigned tasks" on public.tasks for select using (
get_my_role() = 'external' and
project_id in (select project_id from public.project_members where profile_id = auth.uid())
);
create policy "External updates assigned tasks" on public.tasks
for update
using (
get_my_role() = 'external'
and project_id in (select project_id from public.project_members where profile_id = auth.uid())
)
with check (
get_my_role() = 'external'
and project_id in (select project_id from public.project_members where profile_id = auth.uid())
);
-- Submissions
create policy "Team all submissions" on public.submissions for all using (get_my_role() = 'team');
@@ -186,7 +339,32 @@ create policy "Client reads company submissions" on public.submissions for selec
where p.company_id = get_my_company_id()
)
);
create policy "Client inserts submissions" on public.submissions for insert with check (submitted_by = auth.uid());
create policy "Client inserts submissions" on public.submissions for insert with check (
get_my_role() = 'client'
and submitted_by = auth.uid()
and task_id in (
select t.id from public.tasks t
join public.projects p on p.id = t.project_id
where p.company_id = get_my_company_id()
)
);
create policy "External reads assigned submissions" on public.submissions for select using (
get_my_role() = 'external' and
task_id in (
select t.id from public.tasks t
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
create policy "External inserts submissions" on public.submissions for insert with check (
get_my_role() = 'external'
and submitted_by = auth.uid()
and task_id in (
select t.id from public.tasks t
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
-- Submission Files
create policy "Team all submission_files" on public.submission_files for all using (get_my_role() = 'team');
@@ -198,7 +376,35 @@ create policy "Client reads company submission_files" on public.submission_files
where p.company_id = get_my_company_id()
)
);
create policy "Client inserts submission_files" on public.submission_files for insert with check (true);
create policy "Client inserts submission_files" on public.submission_files for insert with check (
get_my_role() = 'client'
and submission_id in (
select s.id from public.submissions s
join public.tasks t on t.id = s.task_id
join public.projects p on p.id = t.project_id
where p.company_id = get_my_company_id()
and s.submitted_by = auth.uid()
)
);
create policy "External reads assigned submission_files" on public.submission_files for select using (
get_my_role() = 'external' and
submission_id in (
select s.id from public.submissions s
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
create policy "External inserts submission_files" on public.submission_files for insert with check (
get_my_role() = 'external'
and submission_id in (
select s.id from public.submissions s
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
and s.submitted_by = auth.uid()
)
);
-- Deliveries
create policy "Team all deliveries" on public.deliveries for all using (get_my_role() = 'team');
@@ -210,6 +416,15 @@ create policy "Client reads company deliveries" on public.deliveries for select
where p.company_id = get_my_company_id()
)
);
create policy "External reads assigned deliveries" on public.deliveries for select using (
get_my_role() = 'external' and
submission_id in (
select s.id from public.submissions s
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
-- Delivery Files
create policy "Team all delivery_files" on public.delivery_files for all using (get_my_role() = 'team');
@@ -222,6 +437,16 @@ create policy "Client reads company delivery_files" on public.delivery_files for
where p.company_id = get_my_company_id()
)
);
create policy "External reads assigned delivery_files" on public.delivery_files for select using (
get_my_role() = 'external' and
delivery_id in (
select d.id from public.deliveries d
join public.submissions s on s.id = d.submission_id
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
-- Company Prices
create policy "Team all company_prices" on public.company_prices for all using (get_my_role() = 'team');
@@ -237,21 +462,89 @@ create policy "Client reads company invoice_items" on public.invoice_items for s
invoice_id in (select id from public.invoices where company_id = get_my_company_id())
);
-- Brand Books
create policy "Team all brand_books" on public.brand_books for all using (get_my_role() = 'team');
create policy "Team all server_status_overrides" on public.server_status_overrides for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
create policy "Team all fourge_passwords" on public.fourge_passwords for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
-- ============================================================
-- Storage Buckets
-- ============================================================
insert into storage.buckets (id, name, public) values ('submissions', 'submissions', false);
insert into storage.buckets (id, name, public) values ('deliveries', 'deliveries', false);
insert into storage.buckets (id, name, public) values ('company-logos', 'company-logos', true);
insert into storage.buckets (id, name, public) values ('fourge-files', 'fourge-files', false);
create policy "Auth users upload to submissions" on storage.objects
for insert to authenticated with check (bucket_id = 'submissions');
create policy "Auth users read submissions" on storage.objects
for select to authenticated using (bucket_id = 'submissions');
-- Company Logos (public bucket — team manages, public can read for embedded URLs in PDFs)
create policy "Team manages company logos" on storage.objects
for all to authenticated
using (bucket_id = 'company-logos' and get_my_role() = 'team')
with check (bucket_id = 'company-logos' and get_my_role() = 'team');
create policy "Public can read company logos" on storage.objects
for select using (bucket_id = 'company-logos');
create policy "Team upload deliveries" on storage.objects
-- Submissions: SELECT
create policy "Team reads submissions storage" on storage.objects
for select to authenticated using (bucket_id = 'submissions' and get_my_role() = 'team');
create policy "Client reads submissions storage" on storage.objects
for select to authenticated using (
bucket_id = 'submissions' and get_my_role() = 'client'
and split_part(name, '/', 1) in (select t.id::text from public.tasks t join public.projects p on p.id = t.project_id where p.company_id = get_my_company_id())
);
create policy "External reads submissions storage" on storage.objects
for select to authenticated using (
bucket_id = 'submissions' and get_my_role() = 'external'
and split_part(name, '/', 1) in (select t.id::text from public.tasks t join public.project_members pm on pm.project_id = t.project_id where pm.profile_id = auth.uid())
);
-- Submissions: INSERT
create policy "Team inserts submissions storage" on storage.objects
for insert to authenticated with check (bucket_id = 'submissions' and get_my_role() = 'team');
create policy "Client inserts submissions storage" on storage.objects
for insert to authenticated with check (
bucket_id = 'submissions' and get_my_role() = 'client'
and split_part(name, '/', 1) in (select t.id::text from public.tasks t join public.projects p on p.id = t.project_id where p.company_id = get_my_company_id())
);
create policy "External inserts submissions storage" on storage.objects
for insert to authenticated with check (
bucket_id = 'submissions' and get_my_role() = 'external'
and split_part(name, '/', 1) in (select t.id::text from public.tasks t join public.project_members pm on pm.project_id = t.project_id where pm.profile_id = auth.uid())
);
-- Submissions: DELETE (team only)
create policy "Team deletes submissions storage" on storage.objects
for delete to authenticated using (bucket_id = 'submissions' and get_my_role() = 'team');
-- Deliveries: SELECT
create policy "Team reads deliveries storage" on storage.objects
for select to authenticated using (bucket_id = 'deliveries' and get_my_role() = 'team');
create policy "Client reads deliveries storage" on storage.objects
for select to authenticated using (
bucket_id = 'deliveries' and get_my_role() = 'client'
and split_part(name, '/', 1) in (select t.id::text from public.tasks t join public.projects p on p.id = t.project_id where p.company_id = get_my_company_id())
);
create policy "External reads deliveries storage" on storage.objects
for select to authenticated using (
bucket_id = 'deliveries' and get_my_role() = 'external'
and split_part(name, '/', 1) in (select t.id::text from public.tasks t join public.project_members pm on pm.project_id = t.project_id where pm.profile_id = auth.uid())
);
-- Deliveries: INSERT + DELETE (team only)
create policy "Team inserts deliveries storage" on storage.objects
for insert to authenticated with check (bucket_id = 'deliveries' and get_my_role() = 'team');
create policy "Auth users read deliveries" on storage.objects
for select to authenticated using (bucket_id = 'deliveries');
create policy "Team deletes deliveries storage" on storage.objects
for delete to authenticated using (bucket_id = 'deliveries' and get_my_role() = 'team');
-- Fourge Files: internal team-only company documents
create policy "Team reads fourge files storage" on storage.objects
for select to authenticated using (bucket_id = 'fourge-files' and get_my_role() = 'team');
create policy "Team inserts fourge files storage" on storage.objects
for insert to authenticated with check (bucket_id = 'fourge-files' and get_my_role() = 'team');
create policy "Team updates fourge files storage" on storage.objects
for update to authenticated using (bucket_id = 'fourge-files' and get_my_role() = 'team')
with check (bucket_id = 'fourge-files' and get_my_role() = 'team');
create policy "Team deletes fourge files storage" on storage.objects
for delete to authenticated using (bucket_id = 'fourge-files' and get_my_role() = 'team');
-- ============================================================
-- Trigger: auto-create profile on signup