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:
+5
-3
@@ -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>} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 +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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 A–Z</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' }} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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');
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user