Add Stripe fee tracking on paid invoices + backfill function

- Store stripe_fee on invoices when webhook receives checkout.session.completed
- Display Stripe fee and net received in InvoiceDetail when paid via Stripe
- Add backfill-stripe-fees edge function to populate fee on existing paid invoices
- Migration: add stripe_fee column to invoices table
- Includes all pending portal changes (brand book, sign survey, task/project/company updates, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-04-14 12:16:22 -04:00
parent 906a0041a4
commit d6e49a4c67
39 changed files with 6618 additions and 300 deletions
+9 -4
View File
@@ -7,16 +7,21 @@ import { useAuth } from '../../context/AuthContext';
export default function ClientDashboard() {
const { currentUser } = useAuth();
const company = currentUser?.company;
const companyId = currentUser?.company_id || currentUser?.company?.id;
const [company, setCompany] = useState(currentUser?.company || null);
const [projects, setProjects] = useState([]);
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!company?.id) { setLoading(false); return; }
if (!companyId) { setLoading(false); return; }
async function load() {
if (!company) {
const { data: co } = await supabase.from('companies').select('*').eq('id', companyId).single();
if (co) setCompany(co);
}
const { data: p } = await supabase
.from('projects').select('*').eq('company_id', company.id).order('created_at', { ascending: false });
.from('projects').select('*').eq('company_id', companyId).order('created_at', { ascending: false });
const projectList = p || [];
setProjects(projectList);
@@ -29,7 +34,7 @@ export default function ClientDashboard() {
setLoading(false);
}
load();
}, [company?.id]);
}, [companyId]);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
+1 -1
View File
@@ -13,7 +13,7 @@ export default function MyInvoices() {
async function load() {
const { data } = await supabase
.from('invoices')
.select('*, company:companies(name, email), items:invoice_items(*)')
.select('*, company:companies(name), items:invoice_items(*)')
.order('created_at', { ascending: false });
setInvoices((data || []).filter(inv => inv.status !== 'draft'));
setLoading(false);
+4 -7
View File
@@ -5,7 +5,7 @@ import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
const vLabel = (v) => 'v' + String(v || 0).padStart(2, '0');
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>
<StatusBadge status={task.status} />
</div>
</div>
</Link>
);
})}
</div>
+6 -8
View File
@@ -5,7 +5,7 @@ import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
const vLabel = (v) => 'v' + String(v || 0).padStart(2, '0');
function ProjectGroup({ project, tasks, submissions, currentUserId, filter }) {
const [open, setOpen] = useState(true);
@@ -65,13 +65,14 @@ function ProjectGroup({ project, tasks, submissions, currentUserId, filter }) {
const isMine = initialSub?.submitted_by === currentUserId;
return (
<div
<Link
key={task.id}
to={`/my-requests/${task.id}`}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: i < filteredTasks.length - 1 ? '1px solid var(--border)' : 'none',
gap: 8,
gap: 8, textDecoration: 'none', cursor: 'pointer',
}}
>
<div style={{ minWidth: 0 }}>
@@ -93,11 +94,8 @@ function ProjectGroup({ project, tasks, submissions, currentUserId, filter }) {
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<StatusBadge status={task.status} />
<Link to={`/my-requests/${task.id}`} className="btn btn-outline btn-sm">Details</Link>
</div>
</div>
<StatusBadge status={task.status} />
</Link>
);
})
)}
+1 -1
View File
@@ -97,7 +97,7 @@ export default function MyRequests() {
<span style={{ fontWeight: 600, fontSize: 13 }}>
{task.title}{' '}
<span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>
{'v' + String(task.current_version).padStart(2, '0')}
{'v' + String(task.current_version || 0).padStart(2, '0')}
</span>
</span>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
+134 -5
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import JSZip from 'jszip';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import FileAttachment from '../../components/FileAttachment';
@@ -8,7 +9,7 @@ import { sendEmail } from '../../lib/email';
import { useAuth } from '../../context/AuthContext';
import { serviceTypes } from '../../data/mockData';
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
const vLabel = (v) => 'v' + String(v || 0).padStart(2, '0');
export default function RequestDetail() {
const { id } = useParams();
@@ -20,8 +21,12 @@ export default function RequestDetail() {
const [submissions, setSubmissions] = useState([]);
const [loading, setLoading] = useState(true);
const [editingTitle, setEditingTitle] = useState(false);
const [titleVal, setTitleVal] = useState('');
const [savingTitle, setSavingTitle] = useState(false);
const [action, setAction] = useState(null);
const [revisionForm, setRevisionForm] = useState({ serviceType: '', deadline: '', description: '' });
const [revisionForm, setRevisionForm] = useState({ serviceType: '', deadline: '', description: '', revisionType: 'client_revision' });
const [revisionFiles, setRevisionFiles] = useState([]);
const [submitted, setSubmitted] = useState(false);
const [saving, setSaving] = useState(false);
@@ -34,7 +39,7 @@ export default function RequestDetail() {
const [{ data: p }, { data: subs }] = await Promise.all([
supabase.from('projects').select('*').eq('id', t.project_id).single(),
supabase.from('submissions').select('*, delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'),
supabase.from('submissions').select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'),
]);
setProject(p);
setSubmissions(subs || []);
@@ -124,6 +129,7 @@ export default function RequestDetail() {
task_id: id,
version_number: newVersion + 1,
type: 'revision',
revision_type: revisionForm.revisionType,
service_type: revisionForm.serviceType,
deadline: revisionForm.deadline || null,
description: revisionForm.description,
@@ -158,7 +164,7 @@ export default function RequestDetail() {
const { data: refreshed } = await supabase
.from('submissions')
.select('*, delivery:deliveries(*, files:delivery_files(*))')
.select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))')
.eq('task_id', id)
.order('version_number');
setSubmissions(refreshed || []);
@@ -175,6 +181,40 @@ export default function RequestDetail() {
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
};
const getSubmissionFileUrl = async (path) => {
const { data } = await supabase.storage.from('submissions').createSignedUrl(path, 3600);
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
};
const handleSaveTitle = async (e) => {
e.preventDefault();
if (!titleVal.trim()) return;
setSavingTitle(true);
await supabase.from('tasks').update({ title: titleVal.trim() }).eq('id', id);
setTask(t => ({ ...t, title: titleVal.trim() }));
setEditingTitle(false);
setSavingTitle(false);
};
const downloadAllSubmissionFiles = async (files, versionLabel) => {
const zip = new JSZip();
for (const file of files) {
const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600);
if (data?.signedUrl) {
const response = await fetch(data.signedUrl);
const blob = await response.blob();
zip.file(file.name, blob);
}
}
const content = await zip.generateAsync({ type: 'blob' });
const zipName = `${project?.name || 'files'} ${versionLabel}.zip`.replace(/[^a-z0-9 ._-]/gi, '_');
const a = document.createElement('a');
a.href = URL.createObjectURL(content);
a.download = zipName;
a.click();
URL.revokeObjectURL(a.href);
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!task) return <Layout><p>Job not found.</p></Layout>;
@@ -199,7 +239,25 @@ export default function RequestDetail() {
<div className="page-header">
<div>
<div className="page-title">{titleWithVersion}</div>
{editingTitle ? (
<form onSubmit={handleSaveTitle} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input
type="text"
value={titleVal}
onChange={e => setTitleVal(e.target.value)}
autoFocus
required
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 280 }}
/>
<button type="submit" className="btn btn-primary btn-sm" disabled={savingTitle}>{savingTitle ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingTitle(false)}>Cancel</button>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="page-title">{titleWithVersion}</div>
<button className="btn btn-outline btn-sm" onClick={() => { setTitleVal(task.title); setEditingTitle(true); }}>Edit</button>
</div>
)}
<div className="page-subtitle">{project?.name}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
@@ -290,6 +348,41 @@ export default function RequestDetail() {
<input type="date" value={revisionForm.deadline} onChange={set('deadline')} />
</div>
</div>
{(action === 'revision' || action === 'reopen') && (
<div className="form-group">
<label>Revision Type *</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 4 }}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontWeight: 400 }}>
<input
type="radio"
name="revisionType"
value="client_revision"
checked={revisionForm.revisionType === 'client_revision'}
onChange={set('revisionType')}
style={{ marginTop: 2, flexShrink: 0 }}
/>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>Client Revision</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>I want changes made to the current work</div>
</div>
</label>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontWeight: 400 }}>
<input
type="radio"
name="revisionType"
value="fourge_error"
checked={revisionForm.revisionType === 'fourge_error'}
onChange={set('revisionType')}
style={{ marginTop: 2, flexShrink: 0 }}
/>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>Fourge Error</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Something was incorrect or not delivered as agreed</div>
</div>
</label>
</div>
</div>
)}
<div className="form-group">
<label>{action === 'edit' ? 'What would you like to change? *' : 'What needs to be changed? *'}</label>
<textarea placeholder={formPlaceholder} value={revisionForm.description} onChange={set('description')} required />
@@ -322,6 +415,7 @@ export default function RequestDetail() {
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="version-number">{vLabel(primary.version_number - 1)}</div>
<StatusBadge status={primary.type} />
{primary.revision_type && <StatusBadge status={primary.revision_type} />}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{primary.submitted_by_name && <span>{primary.submitted_by_name} · </span>}
@@ -337,6 +431,28 @@ export default function RequestDetail() {
<p style={{ marginTop: 4, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{primary.description}</p>
</div>
{primary.files?.length > 0 && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<label style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</label>
{primary.files.length > 1 && (
<button className="btn btn-outline btn-sm" onClick={() => downloadAllSubmissionFiles(primary.files, vLabel(primary.version_number - 1))}>⬇ Download All</button>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{primary.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📎</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
</div>
<button className="btn btn-outline btn-sm" onClick={() => getSubmissionFileUrl(file.storage_path)}>📥 View</button>
</div>
))}
</div>
</div>
)}
{amendments.map(amendment => (
<div key={amendment.id} style={{ marginTop: 12, padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 8, letterSpacing: 0.5 }}>
@@ -346,6 +462,19 @@ export default function RequestDetail() {
{amendment.submitted_by_name} · {new Date(amendment.submitted_at).toLocaleDateString()}
</div>
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
{amendment.files?.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{amendment.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📎</span>
<span style={{ fontSize: 12, fontWeight: 500 }}>{file.name}</span>
</div>
<button className="btn btn-outline btn-sm" onClick={() => getSubmissionFileUrl(file.storage_path)}>📥 View</button>
</div>
))}
</div>
)}
</div>
))}
File diff suppressed because it is too large Load Diff
+505
View File
@@ -0,0 +1,505 @@
import { useState, useEffect, useRef } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { generateBrandBookPDF } from '../../lib/brandbook';
const DEFAULT_COLORS = [
{ name: 'Primary', hex: '#1a1a1a' },
{ name: 'Accent', hex: '#F5A523' },
{ name: 'White', hex: '#ffffff' },
];
const EMPTY_FORM = {
// Cover page
selectedCompanyId: '',
projectLogoDataUrl: null,
creationDate: new Date().toISOString().slice(0, 10),
revisionDate: '',
customerName: '',
streetAddress: '',
clientLogoUrl: '',
clientContactName: '',
clientContactEmail: '',
clientContactPhone: '',
approvedDate: '',
approvalNotes: '',
// Brand identity
companyName: '',
brandName: '',
tagline: '',
brandStory: '',
brandValues: '',
colors: DEFAULT_COLORS,
primaryFont: '',
secondaryFont: '',
fontNotes: '',
brandVoice: '',
brandAdjectives: '',
logoNotes: '',
dos: '',
donts: '',
};
export default function BrandBook() {
const [companies, setCompanies] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
const [generating, setGenerating] = useState(false);
const [notification, setNotification] = useState(null);
const [uploadingClientLogo, setUploadingClientLogo] = useState(false);
const [savingClientInfo, setSavingClientInfo] = useState(false);
const [clientInfoSaved, setClientInfoSaved] = useState(false);
const projectLogoRef = useRef();
const clientLogoRef = useRef();
useEffect(() => {
supabase.from('companies').select('id, name').order('name').then(({ data }) => setCompanies(data || []));
}, []);
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
const handleCompanyChange = async (e) => {
const companyId = e.target.value;
if (!companyId) {
setForm(f => ({ ...f, selectedCompanyId: '', companyName: '', customerName: '', streetAddress: '', clientLogoUrl: '', clientContactName: '', clientContactEmail: '', clientContactPhone: '' }));
return;
}
const { data: co } = await supabase.from('companies').select('*').eq('id', companyId).single();
if (co) {
setForm(f => ({
...f,
selectedCompanyId: companyId,
companyName: co.name,
customerName: f.customerName || co.name,
streetAddress: co.address || f.streetAddress,
clientLogoUrl: co.client_logo_url || '',
clientContactName: co.contact_name || '',
clientContactEmail: co.contact_email || '',
clientContactPhone: co.contact_phone || '',
}));
}
};
const handleProjectLogoUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setForm(f => ({ ...f, projectLogoDataUrl: ev.target.result }));
reader.readAsDataURL(file);
};
const handleClientLogoUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!form.selectedCompanyId) {
setNotification({ type: 'error', msg: 'Select a company before uploading their logo.' });
return;
}
setUploadingClientLogo(true);
const ext = file.name.split('.').pop().toLowerCase();
const path = `${form.selectedCompanyId}/logo.${ext}`;
await supabase.storage.from('company-logos').remove([path]);
const { error } = await supabase.storage.from('company-logos').upload(path, file, { upsert: true });
if (!error) {
const { data: { publicUrl } } = supabase.storage.from('company-logos').getPublicUrl(path);
await supabase.from('companies').update({ client_logo_url: publicUrl }).eq('id', form.selectedCompanyId);
setForm(f => ({ ...f, clientLogoUrl: publicUrl }));
} else {
setNotification({ type: 'error', msg: 'Failed to upload client logo.' });
}
setUploadingClientLogo(false);
};
const handleSaveClientInfo = async () => {
if (!form.selectedCompanyId) return;
setSavingClientInfo(true);
await supabase.from('companies').update({
address: form.streetAddress || null,
contact_name: form.clientContactName || null,
contact_email: form.clientContactEmail || null,
contact_phone: form.clientContactPhone || null,
}).eq('id', form.selectedCompanyId);
setSavingClientInfo(false);
setClientInfoSaved(true);
setTimeout(() => setClientInfoSaved(false), 2500);
};
const setColor = (i, field, value) => {
setForm(f => ({
...f,
colors: f.colors.map((c, idx) => idx === i ? { ...c, [field]: value } : c),
}));
};
const addColor = () => setForm(f => ({ ...f, colors: [...f.colors, { name: '', hex: '#cccccc' }] }));
const removeColor = (i) => setForm(f => ({ ...f, colors: f.colors.filter((_, idx) => idx !== i) }));
const handleGenerate = async () => {
if (!form.brandName.trim()) {
setNotification({ type: 'error', msg: 'Brand name is required.' });
return;
}
setGenerating(true);
setNotification(null);
try {
await generateBrandBookPDF(form);
setNotification({ type: 'success', msg: '✓ Brand book PDF downloaded!' });
} catch (err) {
setNotification({ type: 'error', msg: `Failed to generate PDF: ${err.message}` });
}
setGenerating(false);
};
const handleReset = () => {
if (window.confirm('Clear all fields and start over?')) {
setForm(EMPTY_FORM);
setNotification(null);
}
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Brand Book <span style={{ fontSize: 13, fontWeight: 500, color: 'var(--accent)', marginLeft: 8, padding: '2px 8px', border: '1px solid var(--accent)', borderRadius: 4 }}>beta</span></div>
<div className="page-subtitle">Fill in the brand details below and generate a PDF brand book.</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-outline btn-sm" onClick={handleReset}>Reset</button>
<button className="btn btn-primary btn-sm" onClick={handleGenerate} disabled={generating}>
{generating ? 'Generating...' : '⬇ Generate PDF'}
</button>
</div>
</div>
{notification && (
<div className={`notification ${notification.type === 'error' ? 'notification-error' : 'notification-success'}`} style={{ marginBottom: 24 }}>
{notification.msg}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* ── COVER PAGE ─────────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Cover Page</div>
{/* Company + Brand Name */}
<div className="grid-2">
<div className="form-group">
<label>Client Company</label>
<select value={form.selectedCompanyId} onChange={handleCompanyChange}>
<option value=""> Select company </option>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Brand Name *</label>
<input type="text" placeholder="e.g. Acme Corp" value={form.brandName} onChange={set('brandName')} />
</div>
</div>
{/* Project Logo */}
<div className="form-group">
<label>Project Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(top left, 5"×5" area)</span></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{form.projectLogoDataUrl && (
<img
src={form.projectLogoDataUrl}
alt="Project logo preview"
style={{ maxHeight: 60, maxWidth: 120, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }}
/>
)}
<div>
<button className="btn btn-outline btn-sm" onClick={() => projectLogoRef.current?.click()}>
{form.projectLogoDataUrl ? 'Replace Logo' : 'Upload Logo'}
</button>
{form.projectLogoDataUrl && (
<button className="btn btn-outline btn-sm" style={{ marginLeft: 8, color: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={() => setForm(f => ({ ...f, projectLogoDataUrl: null }))}>
Remove
</button>
)}
<input ref={projectLogoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleProjectLogoUpload} />
</div>
</div>
</div>
{/* Dates */}
<div className="grid-2">
<div className="form-group">
<label>Creation Date</label>
<input type="date" value={form.creationDate} onChange={set('creationDate')} />
</div>
<div className="form-group">
<label>Revision Date <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={form.revisionDate} onChange={set('revisionDate')} />
</div>
</div>
{/* Customer info */}
<div className="grid-2">
<div className="form-group">
<label>Customer Name</label>
<input type="text" placeholder="e.g. Acme Corp" value={form.customerName} onChange={set('customerName')} />
</div>
<div className="form-group">
<label>Street Address <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="text" placeholder="e.g. 123 Main St, City, State 00000" value={form.streetAddress} onChange={set('streetAddress')} />
</div>
</div>
{/* Divider */}
<div style={{ borderTop: '1px solid var(--border)', margin: '8px 0 20px' }} />
{/* Client info (saved per company) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>Client Info</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Saved to the selected company reused across all brand books.</div>
</div>
{form.selectedCompanyId && (
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo}>
{savingClientInfo ? 'Saving...' : clientInfoSaved ? '✓ Saved' : 'Save to Company'}
</button>
)}
</div>
{/* Client logo */}
<div className="form-group">
<label>Client Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{form.clientLogoUrl && (
<img
src={form.clientLogoUrl}
alt="Client logo preview"
style={{ maxHeight: 50, maxWidth: 140, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }}
/>
)}
<div>
<button
className="btn btn-outline btn-sm"
onClick={() => clientLogoRef.current?.click()}
disabled={uploadingClientLogo}
>
{uploadingClientLogo ? 'Uploading...' : form.clientLogoUrl ? 'Replace Logo' : 'Upload Logo'}
</button>
{!form.selectedCompanyId && (
<span style={{ marginLeft: 10, fontSize: 12, color: 'var(--text-muted)' }}>Select a company first</span>
)}
<input ref={clientLogoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleClientLogoUpload} />
</div>
</div>
</div>
<div className="grid-2" style={{ marginTop: 4 }}>
<div className="form-group">
<label>Contact Name</label>
<input type="text" placeholder="e.g. Jane Smith" value={form.clientContactName} onChange={set('clientContactName')} />
</div>
<div className="form-group">
<label>Email</label>
<input type="email" placeholder="e.g. jane@client.com" value={form.clientContactEmail} onChange={set('clientContactEmail')} />
</div>
</div>
<div className="form-group" style={{ maxWidth: 320 }}>
<label>Phone</label>
<input type="text" placeholder="e.g. (555) 000-0000" value={form.clientContactPhone} onChange={set('clientContactPhone')} />
</div>
{/* Divider */}
<div style={{ borderTop: '1px solid var(--border)', margin: '8px 0 20px' }} />
{/* Approval */}
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 14 }}>Approval</div>
<div className="grid-2">
<div className="form-group">
<label>Approved Date <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={form.approvedDate} onChange={set('approvedDate')} />
</div>
</div>
<div className="form-group">
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<textarea
placeholder="Any approval notes or conditions..."
value={form.approvalNotes}
onChange={set('approvalNotes')}
style={{ minHeight: 70 }}
/>
</div>
</div>
{/* ── BRAND IDENTITY ─────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Brand Identity</div>
<div className="form-group">
<label>Tagline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="text" placeholder="e.g. Built for the bold." value={form.tagline} onChange={set('tagline')} />
</div>
</div>
{/* ── BRAND STORY ────────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Brand Story & Values</div>
<div className="form-group">
<label>Brand Story</label>
<textarea
placeholder="Describe the brand — its origin, mission, and what makes it unique..."
value={form.brandStory}
onChange={set('brandStory')}
style={{ minHeight: 120 }}
/>
</div>
<div className="form-group">
<label>
Brand Values
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>one per line</span>
</label>
<textarea
placeholder={"Innovation\nAuthenticity\nCommunity"}
value={form.brandValues}
onChange={set('brandValues')}
style={{ minHeight: 80 }}
/>
</div>
</div>
{/* ── COLOR PALETTE ──────────────────────────────────────────────────────── */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div className="card-title" style={{ margin: 0 }}>Color Palette</div>
<button className="btn btn-outline btn-sm" onClick={addColor}>+ Add Color</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{form.colors.map((color, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
type="color"
value={color.hex}
onChange={e => setColor(i, 'hex', e.target.value)}
style={{ width: 44, height: 38, padding: 2, borderRadius: 6, border: '1px solid var(--border)', cursor: 'pointer', background: 'none' }}
/>
<input
type="text"
value={color.hex}
onChange={e => setColor(i, 'hex', e.target.value)}
placeholder="#000000"
style={{ width: 100, margin: 0, fontFamily: 'monospace', fontSize: 13 }}
/>
<input
type="text"
value={color.name}
onChange={e => setColor(i, 'name', e.target.value)}
placeholder="Color name"
style={{ flex: 1, margin: 0 }}
/>
<div style={{ width: 38, height: 38, borderRadius: 6, background: color.hex, border: '1px solid var(--border)', flexShrink: 0 }} />
<button
type="button"
onClick={() => removeColor(i)}
style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px', flexShrink: 0 }}
></button>
</div>
))}
</div>
</div>
{/* ── TYPOGRAPHY ─────────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Typography</div>
<div className="grid-2">
<div className="form-group">
<label>Primary Font</label>
<input type="text" placeholder="e.g. Neue Haas Grotesk" value={form.primaryFont} onChange={set('primaryFont')} />
</div>
<div className="form-group">
<label>Secondary Font</label>
<input type="text" placeholder="e.g. Freight Text Pro" value={form.secondaryFont} onChange={set('secondaryFont')} />
</div>
</div>
<div className="form-group">
<label>Usage Notes</label>
<textarea
placeholder="e.g. Primary font used for headings and UI. Secondary font for body copy and long-form text..."
value={form.fontNotes}
onChange={set('fontNotes')}
style={{ minHeight: 80 }}
/>
</div>
</div>
{/* ── BRAND VOICE ────────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Brand Voice & Tone</div>
<div className="form-group">
<label>Voice Description</label>
<textarea
placeholder="Describe how the brand communicates — its personality, tone, and style of writing..."
value={form.brandVoice}
onChange={set('brandVoice')}
style={{ minHeight: 100 }}
/>
</div>
<div className="form-group">
<label>
Brand Adjectives
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>comma separated</span>
</label>
<input
type="text"
placeholder="e.g. Bold, Approachable, Modern, Trustworthy"
value={form.brandAdjectives}
onChange={set('brandAdjectives')}
/>
</div>
</div>
{/* ── LOGO GUIDELINES ────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Logo Usage Guidelines</div>
<div className="form-group">
<label>Guidelines</label>
<textarea
placeholder="e.g. Always use the full-color logo on light backgrounds. Use the white version on dark backgrounds. Minimum size 40px. Never stretch, rotate, or recolor the logo..."
value={form.logoNotes}
onChange={set('logoNotes')}
style={{ minHeight: 100 }}
/>
</div>
</div>
{/* ── DO'S & DON'TS ──────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Do's & Don'ts</div>
<div className="grid-2">
<div className="form-group">
<label style={{ color: '#16a34a' }}> Do's <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>one per line</span></label>
<textarea
placeholder={"Use brand colors consistently\nMaintain clear space around the logo\nUse approved fonts only"}
value={form.dos}
onChange={set('dos')}
style={{ minHeight: 120 }}
/>
</div>
<div className="form-group">
<label style={{ color: '#dc2626' }}>✕ Don'ts <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>one per line</span></label>
<textarea
placeholder={"Don't alter the logo colors\nDon't use unapproved fonts\nDon't place logo on busy backgrounds"}
value={form.donts}
onChange={set('donts')}
style={{ minHeight: 120 }}
/>
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 24, paddingBottom: 40 }}>
<button className="btn btn-outline" onClick={handleReset}>Reset</button>
<button className="btn btn-primary btn-lg" onClick={handleGenerate} disabled={generating}>
{generating ? 'Generating...' : '⬇ Generate Brand Book PDF'}
</button>
</div>
</Layout>
);
}
+99 -20
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
export default function Companies() {
const navigate = useNavigate();
@@ -13,9 +14,12 @@ export default function Companies() {
const [showNew, setShowNew] = useState(false);
const [newForm, setNewForm] = useState({ name: '', phone: '', address: '' });
const [showNewUser, setShowNewUser] = useState(false);
const [userForm, setUserForm] = useState({ name: '', email: '', password: '', company_id: '' });
const [userForm, setUserForm] = useState({ name: '', email: '', password: '', company_id: '', role: 'client' });
const [saving, setSaving] = useState(false);
const [userError, setUserError] = useState('');
const [editingUserId, setEditingUserId] = useState(null);
const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null);
useEffect(() => {
load();
@@ -52,6 +56,37 @@ export default function Companies() {
}
};
const handleDeleteCompany = async (company) => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return;
const companyProjects = projects.filter(p => p.company_id === company.id);
const projectIds = companyProjects.map(p => p.id);
if (projectIds.length) {
const companyTaskIds = tasks.filter(t => projectIds.includes(t.project_id)).map(t => t.id);
await cleanupTaskStorage(companyTaskIds);
}
await supabase.from('companies').delete().eq('id', company.id);
setCompanies(prev => prev.filter(c => c.id !== company.id));
};
const handleEditUserSave = async (userId) => {
if (!editUserVal.trim()) return;
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
setEditingUserId(null);
};
const handleDeleteUser = async (user) => {
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account. This cannot be undone.`)) return;
setDeletingUserId(user.id);
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
const errMsg = data?.error || error?.message;
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
setProfiles(prev => prev.filter(u => u.id !== user.id));
setDeletingUserId(null);
};
const handleCreateUser = async (e) => {
e.preventDefault();
setUserError('');
@@ -61,7 +96,8 @@ export default function Companies() {
name: userForm.name.trim(),
email: userForm.email.trim(),
password: userForm.password,
company_id: userForm.company_id || null,
role: userForm.role,
company_id: userForm.role === 'client' ? (userForm.company_id || null) : null,
},
});
setSaving(false);
@@ -71,7 +107,7 @@ export default function Companies() {
return;
}
setShowNewUser(false);
setUserForm({ name: '', email: '', password: '', company_id: '' });
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
load();
};
@@ -83,7 +119,7 @@ export default function Companies() {
<Layout>
<div className="page-header">
<div>
<div className="page-title">Companies</div>
<div className="page-title">Clients & Users</div>
<div className="page-subtitle">
{companies.length} {companies.length !== 1 ? 'companies' : 'company'}
{unassigned.length > 0 && (
@@ -94,10 +130,10 @@ export default function Companies() {
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-outline" onClick={() => { setShowNewUser(s => !s); setShowNew(false); setUserError(''); }}>
<button className="btn btn-outline btn-sm" onClick={() => { setShowNewUser(s => !s); setShowNew(false); setUserError(''); }}>
{showNewUser ? 'Cancel' : '+ New User'}
</button>
<button className="btn btn-primary" onClick={() => { setShowNew(s => !s); setShowNewUser(false); }}>
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(s => !s); setShowNewUser(false); }}>
{showNew ? 'Cancel' : '+ New Company'}
</button>
</div>
@@ -126,13 +162,23 @@ export default function Companies() {
onChange={e => setUserForm(f => ({ ...f, password: e.target.value }))} required minLength={6} />
</div>
<div className="form-group">
<label>Assign to Company</label>
<select value={userForm.company_id} onChange={e => setUserForm(f => ({ ...f, company_id: e.target.value }))}>
<option value="">No company yet</option>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
<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 }))}>
<option value="">No company yet</option>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</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={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</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>
<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>
+283 -56
View File
@@ -4,6 +4,7 @@ import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { serviceTypes } from '../../data/mockData';
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
export default function CompanyDetail() {
const { id } = useParams();
@@ -19,6 +20,15 @@ export default function CompanyDetail() {
const [tab, setTab] = useState('users');
const [savingPrice, setSavingPrice] = useState(null);
const [assigning, setAssigning] = useState(false);
const [showNewProject, setShowNewProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [savingProject, setSavingProject] = useState(false);
const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState('');
const [savingName, setSavingName] = useState(false);
const [editingUserId, setEditingUserId] = useState(null);
const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null);
useEffect(() => {
load();
@@ -42,6 +52,33 @@ export default function CompanyDetail() {
setLoading(false);
}
const handleCompanyNameSave = async (e) => {
e.preventDefault();
if (!nameVal.trim()) return;
setSavingName(true);
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
setCompany(c => ({ ...c, name: nameVal.trim() }));
setEditingName(false);
setSavingName(false);
};
const handleEditUserSave = async (userId) => {
if (!editUserVal.trim()) return;
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
setUsers(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
setEditingUserId(null);
};
const handleDeleteUser = async (user) => {
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account and all access. This cannot be undone.`)) return;
setDeletingUserId(user.id);
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
const errMsg = data?.error || error?.message;
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
setUsers(prev => prev.filter(u => u.id !== user.id));
setDeletingUserId(null);
};
const handleAssignUser = async (userId) => {
setAssigning(true);
await supabase.from('profiles').update({ company_id: id }).eq('id', userId);
@@ -64,29 +101,65 @@ export default function CompanyDetail() {
}
};
const getPrice = (serviceType) => prices.find(p => p.service_type === serviceType)?.price ?? '';
const handleDeleteCompany = async () => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data. This cannot be undone.`)) return;
await cleanupTaskStorage(tasks.map(t => t.id));
await supabase.from('companies').delete().eq('id', id);
navigate('/companies');
};
const handlePriceChange = (serviceType, value) => {
const handleDeleteProject = async (project) => {
if (!window.confirm(`Delete project "${project.name}"? All jobs and files in this project will be permanently deleted.`)) return;
const projectTaskIds = tasks.filter(t => t.project_id === project.id).map(t => t.id);
await cleanupTaskStorage(projectTaskIds);
await supabase.from('projects').delete().eq('id', project.id);
setProjects(prev => prev.filter(p => p.id !== project.id));
setTasks(prev => prev.filter(t => t.project_id !== project.id));
};
const handleCreateProject = async (e) => {
e.preventDefault();
if (!newProjectName.trim()) return;
setSavingProject(true);
const { data } = await supabase.from('projects').insert({
company_id: id,
name: newProjectName.trim(),
status: 'active',
}).select().single();
if (data) {
setProjects(prev => [data, ...prev]);
setNewProjectName('');
setShowNewProject(false);
}
setSavingProject(false);
};
const getPrice = (serviceType, priceType) =>
prices.find(p => p.service_type === serviceType && p.price_type === priceType)?.price ?? '';
const handlePriceChange = (serviceType, priceType, value) => {
setPrices(prev => {
const existing = prev.find(p => p.service_type === serviceType);
if (existing) return prev.map(p => p.service_type === serviceType ? { ...p, price: value } : p);
return [...prev, { service_type: serviceType, price: value, company_id: id }];
const existing = prev.find(p => p.service_type === serviceType && p.price_type === priceType);
if (existing) return prev.map(p => p.service_type === serviceType && p.price_type === priceType ? { ...p, price: value } : p);
return [...prev, { service_type: serviceType, price_type: priceType, price: value, company_id: id }];
});
};
const handlePriceSave = async (serviceType) => {
setSavingPrice(serviceType);
const priceVal = getPrice(serviceType);
const existing = prices.find(p => p.service_type === serviceType && p.id);
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),
}).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));
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 } = 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 (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>
<div className="page-title">{company.name}</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,18 +279,48 @@ export default function CompanyDetail() {
}}>
{user.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email || '—'}</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>
<button
className="btn btn-outline btn-sm"
style={{ fontSize: 11, color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleRemoveUser(user.id)}
>
Remove
</button>
{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"
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}
>
{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={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</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}
>
Assign to {company.name}
</button>
{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 }}>
<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 }}
/>
</div>
<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, 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>
+208 -52
View File
@@ -1,11 +1,11 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
function newItem(description = '', unit_price = '', quantity = 1, task_id = null) {
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id };
function newItem(description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) {
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id };
}
export default function CreateInvoice() {
@@ -15,11 +15,14 @@ export default function CreateInvoice() {
const [companies, setCompanies] = useState([]);
const [selectedCompanyId, setSelectedCompanyId] = useState('');
const [uninvoicedTasks, setUninvoicedTasks] = useState([]);
const [uninvoicedRevisions, setUninvoicedRevisions] = useState([]);
const [priceList, setPriceList] = useState([]);
const [items, setItems] = useState([newItem()]);
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
const [loadingTasks, setLoadingTasks] = useState(false);
const dragItem = useRef(null);
const dragOver = useRef(null);
const today = new Date().toISOString().split('T')[0];
const net30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
@@ -29,7 +32,7 @@ export default function CreateInvoice() {
}, []);
useEffect(() => {
if (!selectedCompanyId) { setUninvoicedTasks([]); setPriceList([]); setItems([newItem()]); return; }
if (!selectedCompanyId) { setUninvoicedTasks([]); setUninvoicedRevisions([]); setPriceList([]); setItems([newItem()]); return; }
setLoadingTasks(true);
Promise.all([
supabase.from('projects').select('id').eq('company_id', selectedCompanyId),
@@ -37,26 +40,68 @@ export default function CreateInvoice() {
]).then(async ([{ data: projects }, { data: prices }]) => {
setPriceList(prices || []);
if (projects && projects.length > 0) {
const { data: tasks } = await supabase
.from('tasks')
.select('*, project:projects(name)')
.in('project_id', projects.map(p => p.id))
.eq('invoiced', false);
setUninvoicedTasks(tasks || []);
const projectIds = projects.map(p => p.id);
const [{ data: tasks }, { data: revisions }] = await Promise.all([
supabase
.from('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,54 +197,109 @@ 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-title">Company</div>
<div className="form-group">
<label>Select Company *</label>
<select value={selectedCompanyId} onChange={e => setSelectedCompanyId(e.target.value)}>
<option value="">Choose a company...</option>
{companies.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Company</div>
<div className="form-group">
<label>Select Company *</label>
<select value={selectedCompanyId} onChange={e => setSelectedCompanyId(e.target.value)}>
<option value="">Choose a company...</option>
{companies.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
{selectedCompany && (
<div style={{ padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)', fontSize: 13 }}>
<div style={{ fontWeight: 600 }}>{selectedCompany.name}</div>
</div>
{selectedCompany && (
<div style={{ padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)', fontSize: 13 }}>
<div style={{ fontWeight: 600 }}>{selectedCompany.name}</div>
</div>
)}
</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 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 unbilled new requests.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{uninvoicedTasks.map(task => {
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.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
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
onClick={() => !alreadyAdded && addTaskAsItem(task)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})}
</div>
)}
</div>
<div className="card">
<div className="card-title">Uninvoiced Requests</div>
{!selectedCompanyId ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Select a company to see their uninvoiced requests.</p>
) : loadingTasks ? (
{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>
) : uninvoicedTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No uninvoiced requests found.</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);
{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={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<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 }}>{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 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 && addTaskAsItem(task)}
onClick={() => !alreadyAdded && addRevisionAsItem(rev)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
@@ -181,20 +310,47 @@ export default function CreateInvoice() {
</div>
)}
</div>
</div>
)}
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Line Items</div>
{items.some(i => i.description) && (
<select
onChange={e => { if (e.target.value) { sortItems(e.target.value); e.target.value = ''; } }}
defaultValue=""
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text-primary)', cursor: 'pointer' }}
>
<option value="" disabled>Sort by</option>
<option value="new-first">New first</option>
<option value="revision-first">Revision first</option>
<option value="az">Description AZ</option>
</select>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
{['Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
<div key={i} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 1 ? 'right' : 'left' }}>{h}</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
<div key={i} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map(item => (
<div key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}>
{items.map((item, index) => (
<div
key={item.id}
onDragOver={e => e.preventDefault()}
onDrop={() => handleDrop(index)}
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
>
<div
draggable
onDragStart={e => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }}
style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}
></div>
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
{item.submission_id ? 'Revision' : 'New'}
</span>
<input type="text" placeholder="Description..." value={item.description} onChange={e => updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} />
<input type="number" min="1" value={item.quantity} onChange={e => updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} />
<input type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
+124 -16
View File
@@ -34,11 +34,11 @@ function CompanyGroup({ company, tasks, projects }) {
{tasks.map(task => {
const project = projects.find(p => p.id === task.project_id);
return (
<div key={task.id} style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4 }}>
<Link key={task.id} to={`/tasks/${task.id}`} style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Link to={`/tasks/${task.id}`} className="table-link" style={{ fontSize: 13 }}>
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'v' + String(task.current_version).padStart(2, '0')}</span>
</Link>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'v' + String(task.current_version || 0).padStart(2, '0')}</span>
</span>
<StatusBadge status={task.status} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -47,7 +47,7 @@ function CompanyGroup({ company, tasks, projects }) {
{task.assigned_name || 'Unassigned'}
</span>
</div>
</div>
</Link>
);
})}
</div>
@@ -56,6 +56,99 @@ function CompanyGroup({ company, tasks, projects }) {
);
}
function ProjectGroup({ project, tasks }) {
const [open, setOpen] = useState(true);
return (
<div style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: '100%', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '10px 14px',
background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
borderBottom: open ? '1px solid var(--border)' : 'none',
fontFamily: 'inherit',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{project.name}</span>
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>
{tasks.length}
</span>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
</button>
{open && (
<div>
{tasks.map(task => (
<a key={task.id} href={`/tasks/${task.id}`} style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', textDecoration: 'none' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'v' + String(task.current_version || 0).padStart(2, '0')}</span>
</span>
<StatusBadge status={task.status} />
</a>
))}
</div>
)}
</div>
);
}
function ExternalDashboard({ currentUser, projects, tasks }) {
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
const completedTasks = tasks.filter(t => t.status === 'client_approved');
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
<div className="page-subtitle">Your assigned projects.</div>
</div>
</div>
{projects.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📋</div>
<h3>No projects assigned yet</h3>
<p>Your team lead will assign you to projects.</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
<div>
<div className="card-title">Active Jobs</div>
{activeTasks.length === 0 ? (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
No active jobs
</div>
) : (
projects.map(project => {
const projectTasks = activeTasks.filter(t => t.project_id === project.id);
if (projectTasks.length === 0) return null;
return <ProjectGroup key={project.id} project={project} tasks={projectTasks} />;
})
)}
</div>
<div>
<div className="card-title">Completed</div>
{completedTasks.length === 0 ? (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
No completed jobs yet
</div>
) : (
projects.map(project => {
const projectTasks = completedTasks.filter(t => t.project_id === project.id);
if (projectTasks.length === 0) return null;
return <ProjectGroup key={project.id} project={project} tasks={projectTasks} />;
})
)}
</div>
</div>
)}
</Layout>
);
}
function GroupedColumn({ tasks, companies, projects, emptyText }) {
if (tasks.length === 0) return (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
@@ -88,23 +181,38 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true);
const [showCompleted, setShowCompleted] = useState(false);
const isExternal = currentUser?.role === 'external';
useEffect(() => {
async function load() {
const [{ data: t }, { data: p }, { data: co }] = await Promise.all([
supabase.from('tasks').select('*').order('submitted_at', { ascending: false }),
supabase.from('projects').select('*'),
supabase.from('companies').select('*').order('name'),
]);
setTasks(t || []);
setProjects(p || []);
setCompanies(co || []);
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('*'),
supabase.from('companies').select('*').order('name'),
]);
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>
+42 -13
View File
@@ -19,17 +19,22 @@ export default function InvoiceDetail() {
useEffect(() => {
async function load() {
const { data: inv } = await supabase.from('invoices').select('*').eq('id', id).single();
if (!inv) { setLoading(false); return; }
setInvoice(inv);
try {
const { data: inv } = await supabase.from('invoices').select('*').eq('id', id).single();
if (!inv) return;
setInvoice(inv);
const [{ data: co }, { data: its }] = await Promise.all([
supabase.from('companies').select('*').eq('id', inv.company_id).single(),
supabase.from('invoice_items').select('*').eq('invoice_id', id).order('created_at'),
]);
setCompany(co);
setItems(its || []);
setLoading(false);
const [{ data: co }, { data: its }] = await Promise.all([
supabase.from('companies').select('*').eq('id', inv.company_id).single(),
supabase.from('invoice_items').select('*').eq('invoice_id', id).order('created_at'),
]);
setCompany(co);
setItems(its || []);
} catch (error) {
console.error('InvoiceDetail load failed:', error);
} finally {
setLoading(false);
}
}
load();
}, [id]);
@@ -45,9 +50,11 @@ export default function InvoiceDetail() {
if (!window.confirm('Delete this invoice? This cannot be undone.')) return;
setSaving(true);
try {
const { data: freshItems } = await supabase.from('invoice_items').select('task_id').eq('invoice_id', id);
const taskIds = (freshItems || []).filter(i => i.task_id).map(i => i.task_id);
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false }).in('id', taskIds);
const submissionIds = (freshItems || []).filter(i => i.submission_id).map(i => i.submission_id);
if (submissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', submissionIds);
const { error } = await supabase.from('invoices').delete().eq('id', id);
if (error) throw error;
navigate('/invoices');
@@ -88,7 +95,7 @@ export default function InvoiceDetail() {
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">Bill To</div>
<div style={{ fontSize: 15, fontWeight: 700 }}>{company?.name}</div>
<div style={{ fontSize: 15, fontWeight: 700 }}>{invoice.bill_to || company?.name}</div>
<div style={{ marginTop: 12 }}>
<Link to={`/companies/${company?.id}`} className="btn btn-outline btn-sm">View Company</Link>
</div>
@@ -102,6 +109,12 @@ export default function InvoiceDetail() {
</div>
<div className="detail-item"><label>Terms</label><p>Net 30</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
{invoice.status === 'paid' && invoice.stripe_fee != null && (
<>
<div className="detail-item"><label>Stripe Fee</label><p style={{ color: 'var(--text-secondary)' }}>${Number(invoice.stripe_fee).toFixed(2)}</p></div>
<div className="detail-item"><label>Net Received</label><p style={{ fontWeight: 700 }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</p></div>
</>
)}
</div>
</div>
</div>
@@ -112,6 +125,7 @@ export default function InvoiceDetail() {
<table>
<thead>
<tr>
<th style={{ width: 100 }}>Type</th>
<th>Description</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'right' }}>Unit Price</th>
@@ -121,6 +135,11 @@ export default function InvoiceDetail() {
<tbody>
{items.map(item => (
<tr key={item.id}>
<td>
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
{item.submission_id ? 'Revision' : 'New'}
</span>
</td>
<td>{item.description}</td>
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
<td style={{ textAlign: 'right' }}>${Number(item.unit_price).toFixed(2)}</td>
@@ -134,6 +153,16 @@ export default function InvoiceDetail() {
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</div>
{invoice.status === 'paid' && invoice.stripe_fee != null && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
Stripe fee: <span style={{ color: 'var(--text-secondary)' }}>${Number(invoice.stripe_fee).toFixed(2)}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
Net received: <span style={{ fontWeight: 700, color: 'var(--text-primary)' }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</span>
</div>
</div>
)}
</div>
</div>
</div>
+3 -7
View File
@@ -39,7 +39,7 @@ export default function Invoices() {
<div className="page-title">Invoices</div>
<div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} total</div>
</div>
<button className="btn btn-primary" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
@@ -88,13 +88,12 @@ export default function Invoices() {
<th>Due</th>
<th>Status</th>
<th>Total</th>
<th></th>
</tr>
</thead>
<tbody>
{filtered.map(inv => (
<tr key={inv.id}>
<td><Link to={`/invoices/${inv.id}`} className="table-link">{inv.invoice_number}</Link></td>
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td>
<td>
<div style={{ fontWeight: 600 }}>{inv.company?.name}</div>
</td>
@@ -106,9 +105,6 @@ export default function Invoices() {
</td>
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td style={{ fontWeight: 700 }}>${Number(inv.total).toFixed(2)}</td>
<td>
<Link to={`/invoices/${inv.id}`} className="btn btn-outline btn-sm">View</Link>
</td>
</tr>
))}
</tbody>
+279 -14
View File
@@ -3,54 +3,266 @@ 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>
<div className="page-title">{project.name}</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}
</Link>
{' · '}Started {new Date(project.created_at).toLocaleDateString()}
{!isExternal && company && (
<>
<Link to={`/companies/${company.id}`} style={{ color: 'var(--accent)' }}>
{company.name}
</Link>
{' · '}
</>
)}
Started {new Date(project.created_at).toLocaleDateString()}
</div>
</div>
<StatusBadge status={project.status} />
<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">
<div className="card-title">Project Info</div>
@@ -76,7 +288,8 @@ export default function ProjectDetail() {
<div className="empty-state">
<div className="empty-state-icon">📋</div>
<h3>No jobs yet</h3>
<p>Jobs will appear here when requests come in.</p>
<p>Add a job to get started.</p>
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowAddJob(true)}>+ Add Job</button>
</div>
) : (
<div className="table-wrapper">
@@ -93,11 +306,11 @@ export default function ProjectDetail() {
</thead>
<tbody>
{tasks.map(task => (
<tr key={task.id}>
<tr key={task.id} onClick={() => navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}>
<td>
{task.title}
<span style={{ marginLeft: 6, fontWeight: 600, color: 'var(--text-muted)', fontSize: 12 }}>
{'v' + String(task.current_version).padStart(2, '0')}
{'v' + String(task.current_version || 0).padStart(2, '0')}
</span>
</td>
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>
@@ -110,15 +323,67 @@ export default function ProjectDetail() {
<td style={{ color: 'var(--text-secondary)' }}>
{new Date(task.submitted_at).toLocaleDateString()}
</td>
<td>
<Link to={`/tasks/${task.id}`} className="btn btn-outline btn-sm">View</Link>
{!isExternal && (
<td onClick={e => e.stopPropagation()}>
<button
type="button"
onClick={e => handleDeleteTask(task.id, e)}
style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
title="Delete job"
></button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{!isExternal && (
<div className="card" style={{ marginTop: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div className="card-title" style={{ margin: 0 }}>External Members</div>
{!addingMember && (
<button className="btn btn-outline btn-sm" onClick={() => setAddingMember(true)}>+ Add</button>
)}
</div>
{addingMember && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<select
value={selectedExternal}
onChange={e => setSelectedExternal(e.target.value)}
style={{ flex: 1 }}
>
<option value="">Select external member...</option>
{externalProfiles
.filter(p => !members.find(m => m.profile_id === p.id))
.map(p => <option key={p.id} value={p.id}>{p.name} {p.email}</option>)}
</select>
<button className="btn btn-primary btn-sm" onClick={handleAddMember} disabled={!selectedExternal}>Add</button>
<button className="btn btn-outline btn-sm" onClick={() => { setAddingMember(false); setSelectedExternal(''); }}>Cancel</button>
</div>
)}
{members.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No external members assigned to this project.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{members.map(m => (
<div key={m.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{m.profile?.email}</div>
</div>
<button
onClick={() => handleRemoveMember(m.profile_id)}
style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
title="Remove from project"
></button>
</div>
))}
</div>
)}
</div>
)}
</Layout>
);
}
+35 -20
View File
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
export default function Requests() {
const navigate = useNavigate();
const [submissions, setSubmissions] = useState([]);
const [tasks, setTasks] = useState([]);
const [projects, setProjects] = useState([]);
@@ -98,47 +99,47 @@ export default function Requests() {
return bMax - aMax;
});
// Build a map of max version per task so we can flag superseded groups
const maxVersionPerTask = {};
Object.values(groupMap).forEach(g => {
const p = g.find(s => s.type !== 'amendment') || g[0];
const cur = maxVersionPerTask[p.task_id] || 0;
if (p.version_number > cur) maxVersionPerTask[p.task_id] = p.version_number;
// Group by company
const byCompany = {};
groups.forEach(group => {
const primary = group.find(s => s.type !== 'amendment') || group[0];
const task = tasks.find(t => t.id === primary.task_id);
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
const key = company?.id || '__none__';
if (!byCompany[key]) byCompany[key] = { company, groups: [] };
byCompany[key].groups.push(group);
});
return groups.map(group => {
const renderCard = (group) => {
const primary = group.find(s => s.type !== 'amendment') || group[0];
const amendments = group.filter(s => s.type === 'amendment');
const task = tasks.find(t => t.id === primary.task_id);
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
const isSuperseded = primary.version_number < maxVersionPerTask[primary.task_id];
const isCompleted = task?.status === 'client_approved';
return (
<div key={primary.id} className="request-card" style={{ opacity: isSuperseded ? 0.5 : 1 }}>
<div key={primary.id} className="request-card" style={{ cursor: task ? 'pointer' : 'default' }} onClick={() => task && navigate(`/tasks/${task.id}`)}>
<div className="request-card-header">
<div>
<div className="request-card-title">
{primary.service_type}
{task?.title || primary.service_type}
<StatusBadge status={primary.type} />
{isSuperseded && (
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 500 }}>Superseded</span>
)}
</div>
<div className="request-card-meta">
From <strong>{primary.submitted_by_name}</strong>
{company && (
<> · <Link to={`/companies/${company.id}`} className="table-link">{company.name}</Link></>
<> · <Link to={`/companies/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link></>
)}
{' · '}{new Date(primary.submitted_at).toLocaleDateString()}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isSuperseded
? <span className="badge badge-completed">Completed</span>
{isCompleted
? <span className="badge badge-client_approved">Completed</span>
: <StatusBadge status={task?.status || 'not_started'} />
}
{task && <Link to={`/tasks/${task.id}`} className="btn btn-outline btn-sm">View Job</Link>}
</div>
</div>
@@ -150,7 +151,7 @@ export default function Requests() {
<div>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Project</span>
<div style={{ fontSize: 13, marginTop: 2 }}>
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
{project ? <Link to={`/projects/${project.id}`} className="table-link" onClick={e => e.stopPropagation()}>{project.name}</Link> : '—'}
</div>
</div>
</div>
@@ -170,7 +171,21 @@ export default function Requests() {
))}
</div>
);
});
};
return Object.values(byCompany).map(({ company, groups: companyGroups }) => (
<div key={company?.id || '__none__'} style={{ marginBottom: 32 }}>
<div style={{ fontSize: 13, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.6px', color: 'var(--text-muted)', marginBottom: 10 }}>
{company
? <Link to={`/companies/${company.id}`} style={{ color: 'var(--text-muted)', textDecoration: 'none' }}>{company.name}</Link>
: 'No Company'
}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{companyGroups.map(renderCard)}
</div>
</div>
));
})()}
</Layout>
);
+802
View File
@@ -0,0 +1,802 @@
import { useState, useEffect, useRef } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { generateBrandBookEditorPDF } from '../../lib/signsurvey';
const BUCKET = 'brand-books';
const EMPTY_SIGN = () => ({
_key: Math.random().toString(36).slice(2),
signNumber: '',
type: '',
location: '',
width: '',
height: '',
material: '',
illumination: '',
condition: '',
mountType: '',
notes: '',
photo: null,
photoPath: '',
_photoPreview: '',
});
const EMPTY_BOOK_INFO = {
clientId: '',
clientName: '',
projectName: '',
siteAddress: '',
bookDate: new Date().toISOString().split('T')[0],
preparedBy: '',
revision: '01',
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
const getSignedUrl = async (path) => {
if (!path) return null;
const { data } = await supabase.storage.from(BUCKET).createSignedUrl(path, 86400 * 7);
return data?.signedUrl || null;
};
const uploadFile = async (file, path) => {
const { error } = await supabase.storage.from(BUCKET).upload(path, file, { upsert: true });
if (error) throw error;
return path;
};
// ─── Main Component ───────────────────────────────────────────────────────────
export default function BrandBook() {
const [view, setView] = useState('list');
const [savedBooks, setSavedBooks] = useState([]);
const [loadingBooks, setLoadingBooks] = useState(true);
const [currentId, setCurrentId] = useState(null);
const [clients, setClients] = useState([]);
const [bookInfo, setBookInfo] = useState(EMPTY_BOOK_INFO);
const [siteMapFile, setSiteMapFile] = useState(null);
const [siteMapPath, setSiteMapPath] = useState('');
const [siteMapPreview, setSiteMapPreview] = useState(null);
const [signs, setSigns] = useState([EMPTY_SIGN()]);
const [photoItems, setPhotoItems] = useState([]);
const [expandedSign, setExpandedSign] = useState(0);
const [generating, setGenerating] = useState(false);
const [saving, setSaving] = useState(false);
const [notification, setNotification] = useState(null);
const siteMapRef = useRef();
const sitePhotosRef = useRef();
useEffect(() => {
supabase.from('companies').select('id, name').order('name').then(({ data }) => setClients(data || []));
fetchBooks();
}, []);
const fetchBooks = async () => {
setLoadingBooks(true);
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
setSavedBooks(data || []);
setLoadingBooks(false);
};
const set = (field) => (e) => setBookInfo(b => ({ ...b, [field]: e.target.value }));
const handleClientChange = (e) => {
const id = e.target.value;
const client = clients.find(c => c.id === id);
setBookInfo(b => ({ ...b, clientId: id, clientName: client ? client.name : '' }));
};
const resetForm = () => {
setBookInfo(EMPTY_BOOK_INFO);
setSiteMapFile(null);
setSiteMapPath('');
setSiteMapPreview(null);
setSigns([EMPTY_SIGN()]);
setPhotoItems([]);
setCurrentId(null);
setExpandedSign(0);
setNotification(null);
};
const handleNew = () => { resetForm(); setView('form'); };
const buildLoadedState = async (book) => {
const siteMapSignedUrl = await getSignedUrl(book.site_map_path);
const signsWithPreviews = await Promise.all((book.signs || []).map(async (sign) => {
const preview = await getSignedUrl(sign.photoPath);
return { ...sign, photo: null, _photoPreview: preview || '' };
}));
const surveyItems = await Promise.all((book.survey_photo_paths || []).map(async (path) => {
const preview = await getSignedUrl(path);
return { file: null, path, preview: preview || '' };
}));
return { siteMapSignedUrl, signsWithPreviews, surveyItems };
};
const handleLoad = async (book) => {
setNotification(null);
const { siteMapSignedUrl, signsWithPreviews, surveyItems } = await buildLoadedState(book);
setBookInfo({
clientId: book.client_id || '',
clientName: book.client_name || '',
projectName: book.project_name || '',
siteAddress: book.site_address || '',
bookDate: book.book_date || new Date().toISOString().split('T')[0],
preparedBy: book.prepared_by || '',
revision: book.revision || '01',
});
setSiteMapFile(null);
setSiteMapPath(book.site_map_path || '');
setSiteMapPreview(siteMapSignedUrl);
setSigns(signsWithPreviews.length > 0 ? signsWithPreviews : [EMPTY_SIGN()]);
setPhotoItems(surveyItems);
setCurrentId(book.id);
setExpandedSign(null);
setView('form');
};
const handleAddRevision = async (book) => {
setNotification(null);
const { siteMapSignedUrl, signsWithPreviews, surveyItems } = await buildLoadedState(book);
const nextRev = String((parseInt(book.revision || '1', 10) + 1)).padStart(2, '0');
setBookInfo({
clientId: book.client_id || '',
clientName: book.client_name || '',
projectName: book.project_name || '',
siteAddress: book.site_address || '',
bookDate: new Date().toISOString().split('T')[0],
preparedBy: book.prepared_by || '',
revision: nextRev,
});
setSiteMapFile(null);
setSiteMapPath(book.site_map_path || '');
setSiteMapPreview(siteMapSignedUrl);
setSigns(signsWithPreviews.length > 0 ? signsWithPreviews : [EMPTY_SIGN()]);
setPhotoItems(surveyItems);
setCurrentId(null); // new entry on save
setExpandedSign(null);
setView('form');
};
const handleDelete = async (id) => {
if (!window.confirm('Delete this brand book? This cannot be undone.')) return;
await supabase.from('brand_books').delete().eq('id', id);
fetchBooks();
};
const handleSave = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
return;
}
setSaving(true);
setNotification(null);
try {
const bookId = currentId || crypto.randomUUID();
// Upload site map if new file
let finalSiteMapPath = siteMapPath;
if (siteMapFile) {
const ext = siteMapFile.name.split('.').pop().toLowerCase();
finalSiteMapPath = `${bookId}/site-map.${ext}`;
await uploadFile(siteMapFile, finalSiteMapPath);
}
// Upload sign photos if new file
const finalSigns = await Promise.all(signs.map(async (sign) => {
let photoPath = sign.photoPath || '';
if (sign.photo) {
const ext = sign.photo.name.split('.').pop().toLowerCase();
photoPath = `${bookId}/sign-${sign._key}.${ext}`;
await uploadFile(sign.photo, photoPath);
}
const { photo, _photoPreview, ...rest } = sign;
return { ...rest, photoPath };
}));
// Upload survey photos if new file
const finalSurveyPaths = await Promise.all(photoItems.map(async (item, i) => {
if (item.file) {
const ext = item.file.name.split('.').pop().toLowerCase();
const path = `${bookId}/survey-${i}-${Date.now()}.${ext}`;
await uploadFile(item.file, path);
return path;
}
return item.path;
}));
const dbData = {
id: bookId,
client_id: bookInfo.clientId || null,
client_name: bookInfo.clientName,
project_name: bookInfo.projectName,
site_address: bookInfo.siteAddress,
book_date: bookInfo.bookDate || null,
prepared_by: bookInfo.preparedBy,
revision: bookInfo.revision,
site_map_path: finalSiteMapPath,
signs: finalSigns,
survey_photo_paths: finalSurveyPaths.filter(Boolean),
updated_at: new Date().toISOString(),
};
await supabase.from('brand_books').upsert(dbData);
setCurrentId(bookId);
setSiteMapPath(finalSiteMapPath);
setSiteMapFile(null);
setSigns(finalSigns.map(s => ({ ...s, photo: null })));
setPhotoItems(finalSurveyPaths.filter(Boolean).map((path, i) => ({
file: null,
path,
preview: photoItems[i]?.preview || '',
})));
await fetchBooks();
setNotification({ type: 'success', msg: '✓ Brand book saved!' });
} catch (err) {
setNotification({ type: 'error', msg: `Save failed: ${err.message}` });
}
setSaving(false);
};
const handleGenerate = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
return;
}
setGenerating(true);
setNotification(null);
try {
const siteMapSource = siteMapFile || (siteMapPath ? await getSignedUrl(siteMapPath) : null);
const signsWithSource = await Promise.all(signs.map(async s => ({
...s,
photoSource: s.photo || (s.photoPath ? await getSignedUrl(s.photoPath) : null),
})));
const sitePhotoSources = await Promise.all(photoItems.map(async item =>
item.file || (item.path ? await getSignedUrl(item.path) : null)
));
await generateBrandBookEditorPDF({
clientName: bookInfo.clientName,
projectName: bookInfo.projectName,
siteAddress: bookInfo.siteAddress,
bookDate: bookInfo.bookDate,
preparedBy: bookInfo.preparedBy,
revision: bookInfo.revision,
siteMapSource,
signs: signsWithSource,
sitePhotoSources,
});
setNotification({ type: 'success', msg: '✓ Brand book PDF downloaded!' });
} catch (err) {
setNotification({ type: 'error', msg: `Failed to generate PDF: ${err.message}` });
}
setGenerating(false);
};
// ── Sign helpers ─────────────────────────────────────────────────────────────
const updateSign = (key, field, value) =>
setSigns(prev => prev.map(s => s._key === key ? { ...s, [field]: value } : s));
const addSign = () => { setSigns(prev => [...prev, EMPTY_SIGN()]); setExpandedSign(signs.length); };
const removeSign = (key) => setSigns(prev => prev.filter(s => s._key !== key));
const handleSignPhoto = (key, file) => {
updateSign(key, 'photo', file);
updateSign(key, '_photoPreview', URL.createObjectURL(file));
updateSign(key, 'photoPath', ''); // clear old path when new file selected
};
// ── Site map helpers ──────────────────────────────────────────────────────────
const handleSiteMapFile = (file) => {
setSiteMapFile(file);
setSiteMapPath('');
setSiteMapPreview(URL.createObjectURL(file));
};
// ── Survey photo helpers ──────────────────────────────────────────────────────
const handleSitePhotos = (files) => {
const newItems = Array.from(files)
.filter(f => f.type.startsWith('image/'))
.map(file => ({ file, path: '', preview: URL.createObjectURL(file) }));
setPhotoItems(prev => [...prev, ...newItems]);
};
const removeSitePhoto = (i) => setPhotoItems(prev => prev.filter((_, idx) => idx !== i));
// ── Unsaved changes indicator ─────────────────────────────────────────────────
const hasUnsavedPhotos = siteMapFile || signs.some(s => s.photo) || photoItems.some(i => i.file);
// ═══════════════════════════════════════════════════════════════════════════════
// LIST VIEW
// ═══════════════════════════════════════════════════════════════════════════════
if (view === 'list') {
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">
Brand Book
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--accent)', marginLeft: 8, padding: '2px 8px', border: '1px solid var(--accent)', borderRadius: 4 }}>beta</span>
</div>
<div className="page-subtitle">Saved brand books click to edit or add a revision.</div>
</div>
<button className="btn btn-primary btn-sm" onClick={handleNew}>+ New Brand Book</button>
</div>
{loadingBooks ? (
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
) : savedBooks.length === 0 ? (
<div className="empty-state">
<h3>No brand books yet</h3>
<p>Create your first brand book to get started.</p>
<button className="btn btn-primary" onClick={handleNew} style={{ marginTop: 16 }}>+ New Brand Book</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{savedBooks.map(book => (
<BookListItem
key={book.id}
book={book}
onEdit={() => handleLoad(book)}
onRevision={() => handleAddRevision(book)}
onDelete={() => handleDelete(book.id)}
/>
))}
</div>
)}
</Layout>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// FORM VIEW
// ═══════════════════════════════════════════════════════════════════════════════
return (
<Layout>
<div className="page-header">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
className="btn btn-outline btn-sm"
onClick={() => setView('list')}
style={{ fontSize: 12 }}
> All Brand Books</button>
<div className="page-title" style={{ margin: 0 }}>
{currentId
? `${bookInfo.clientName || 'Brand Book'}${bookInfo.projectName || ''} R${String(bookInfo.revision).padStart(2, '0')}`
: 'New Brand Book'}
</div>
</div>
<div className="page-subtitle" style={{ marginTop: 4 }}>
{currentId ? 'Editing saved brand book' : 'Unsaved — fill in details and save'}
{hasUnsavedPhotos && <span style={{ color: 'var(--accent)', marginLeft: 8 }}>· Unsaved photos</span>}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-outline btn-sm" onClick={() => { if (window.confirm('Discard changes?')) { resetForm(); setView('list'); } }}>Discard</button>
<button className="btn btn-outline btn-sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : '💾 Save'}
</button>
<button className="btn btn-primary btn-sm" onClick={handleGenerate} disabled={generating}>
{generating ? 'Generating...' : '⬇ Generate PDF'}
</button>
</div>
</div>
{notification && (
<div className={`notification ${notification.type === 'error' ? 'notification-error' : 'notification-success'}`} style={{ marginBottom: 24 }}>
{notification.msg}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* ── BRAND BOOK INFO ──────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Brand Book Info</div>
<div className="grid-2">
<div className="form-group">
<label>Client *</label>
<select value={bookInfo.clientId} onChange={handleClientChange}>
<option value=""> Select client </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Project Name</label>
<input type="text" placeholder="e.g. Bolchoz Sign Solutions 2025" value={bookInfo.projectName} onChange={set('projectName')} />
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Site Address</label>
<input type="text" placeholder="e.g. 123 Main St, City, State" value={bookInfo.siteAddress} onChange={set('siteAddress')} />
</div>
<div className="form-group">
<label>Date</label>
<input type="date" value={bookInfo.bookDate} onChange={set('bookDate')} />
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Prepared By</label>
<input type="text" placeholder="e.g. John Smith" value={bookInfo.preparedBy} onChange={set('preparedBy')} />
</div>
<div className="form-group">
<label>Revision #</label>
<input
type="number"
min="1"
max="99"
value={parseInt(bookInfo.revision, 10) || 1}
onChange={e => setBookInfo(b => ({ ...b, revision: String(e.target.value).padStart(2, '0') }))}
style={{ width: 100 }}
/>
</div>
</div>
</div>
{/* ── SITE MAP ──────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Site Map</div>
<div className="form-group">
<label>Upload Site Map Image <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional shown on page 2 beside the sign inventory)</span></label>
<SiteMapDropZone
preview={siteMapPreview}
onFile={handleSiteMapFile}
onClear={() => { setSiteMapFile(null); setSiteMapPath(''); setSiteMapPreview(null); }}
inputRef={siteMapRef}
/>
</div>
</div>
{/* ── SIGNS ─────────────────────────────────────────────────────────── */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div className="card-title" style={{ margin: 0 }}>Signs</div>
<button className="btn btn-outline btn-sm" onClick={addSign}>+ Add Sign</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{signs.map((sign, i) => (
<SignCard
key={sign._key}
sign={sign}
index={i}
expanded={expandedSign === i}
onToggle={() => setExpandedSign(expandedSign === i ? null : i)}
onChange={(field, val) => updateSign(sign._key, field, val)}
onPhotoChange={(file) => handleSignPhoto(sign._key, file)}
onRemove={() => removeSign(sign._key)}
canRemove={signs.length > 1}
/>
))}
</div>
<button className="btn btn-outline btn-sm" onClick={addSign} style={{ marginTop: 12 }}>+ Add Sign</button>
</div>
{/* ── SITE PHOTOS ───────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Site Photos</div>
<div className="form-group">
<label>General site photos <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(displayed as thumbnails on the last page up to 12 shown)</span></label>
<SitePhotosDropZone
photoItems={photoItems}
onFiles={handleSitePhotos}
onRemove={removeSitePhoto}
inputRef={sitePhotosRef}
/>
</div>
</div>
</div>
{/* Bottom actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 24, paddingBottom: 40 }}>
<button className="btn btn-outline" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : '💾 Save Brand Book'}
</button>
<button className="btn btn-primary btn-lg" onClick={handleGenerate} disabled={generating}>
{generating ? 'Generating...' : '⬇ Generate Brand Book PDF'}
</button>
</div>
</Layout>
);
}
// ─── Book List Item ───────────────────────────────────────────────────────────
function BookListItem({ book, onEdit, onRevision, onDelete }) {
const signCount = Array.isArray(book.signs) ? book.signs.length : 0;
const date = book.book_date
? new Date(book.book_date + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
const updated = book.updated_at
? new Date(book.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return (
<div style={{
padding: '12px 16px',
border: '1px solid var(--border)',
borderRadius: 8,
background: 'var(--card-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)' }}>{book.client_name}</span>
{book.project_name && (
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}> {book.project_name}</span>
)}
<span style={{
fontSize: 11, fontWeight: 700, padding: '1px 7px',
background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4,
}}>R{String(book.revision || '01').padStart(2, '0')}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{book.site_address && <span>📍 {book.site_address}</span>}
{signCount > 0 && <span>🪧 {signCount} sign{signCount !== 1 ? 's' : ''}</span>}
{date && <span>📅 {date}</span>}
{updated && <span style={{ color: 'var(--text-muted)', opacity: 0.7 }}>Saved {updated}</span>}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button className="btn btn-outline btn-sm" onClick={onEdit}>Edit</button>
<button
className="btn btn-outline btn-sm"
onClick={onRevision}
title="Create a new revision based on this one"
style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }}
>+ Revision</button>
<button
type="button"
onClick={onDelete}
style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
></button>
</div>
</div>
);
}
// ─── Sign Card ────────────────────────────────────────────────────────────────
function SignCard({ sign, index, expanded, onToggle, onChange, onPhotoChange, onRemove, canRemove }) {
const photoInputRef = useRef();
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (e) => { e.preventDefault(); dragCounter.current++; setDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); };
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (e) => {
e.preventDefault(); dragCounter.current = 0; setDragging(false);
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) onPhotoChange(file);
};
const summary = [sign.type, sign.location].filter(Boolean).join(' — ') || 'New Sign';
const hasPhoto = sign._photoPreview;
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<button
type="button"
onClick={onToggle}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 14px', background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
fontFamily: 'inherit', borderBottom: expanded ? '1px solid var(--border)' : 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4 }}>
#{sign.signNumber || (index + 1)}
</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{summary}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{hasPhoto && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>📷</span>}
{sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>}
{canRemove && (
<span role="button" onClick={e => { e.stopPropagation(); onRemove(); }}
style={{ fontSize: 13, color: 'var(--danger, #dc2626)', padding: '2px 6px', cursor: 'pointer' }}></span>
)}
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{expanded ? '▲' : '▼'}</span>
</div>
</button>
{expanded && (
<div style={{ padding: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div className="form-group" style={{ margin: 0 }}>
<label>Sign # / ID</label>
<input type="text" placeholder={`e.g. ${index + 1}`} value={sign.signNumber} onChange={e => onChange('signNumber', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Sign Type</label>
<input type="text" placeholder="e.g. Monument, Channel Letter, Pylon…" value={sign.type} onChange={e => onChange('type', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Location</label>
<input type="text" placeholder="e.g. Main entrance, North parking lot" value={sign.location} onChange={e => onChange('location', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Dimensions (inches)</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="text" placeholder='Width"' value={sign.width} onChange={e => onChange('width', e.target.value)} style={{ flex: 1 }} />
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>×</span>
<input type="text" placeholder='Height"' value={sign.height} onChange={e => onChange('height', e.target.value)} style={{ flex: 1 }} />
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, marginBottom: 12 }}>
<div className="form-group" style={{ margin: 0 }}>
<label>Material <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
<input type="text" placeholder="e.g. Aluminum, Acrylic" value={sign.material} onChange={e => onChange('material', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Illumination <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
<input type="text" placeholder="e.g. LED, None, Neon" value={sign.illumination} onChange={e => onChange('illumination', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Condition <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
<input type="text" placeholder="e.g. Good, Fair, Poor" value={sign.condition} onChange={e => onChange('condition', e.target.value)} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 12, marginBottom: 12 }}>
<div className="form-group" style={{ margin: 0 }}>
<label>Mount Type <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
<input type="text" placeholder="e.g. Wall, Post, Ground" value={sign.mountType} onChange={e => onChange('mountType', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Notes</label>
<input type="text" placeholder="Any additional observations…" value={sign.notes} onChange={e => onChange('notes', e.target.value)} />
</div>
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Sign Photo</label>
<div
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDrop={handleDrop}
onClick={() => photoInputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: 12, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12,
transition: 'border-color 0.15s, background 0.15s', minHeight: 60,
}}
>
{sign._photoPreview ? (
<>
<img src={sign._photoPreview} alt="sign" style={{ height: 60, width: 80, objectFit: 'cover', borderRadius: 4, flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>
{sign.photo ? sign.photo.name : 'Saved photo'}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>Click to replace · drag a new photo</div>
</div>
</>
) : (
<div style={{ color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13 }}>
{dragging ? '📂 Drop photo here' : '📷 Click to upload or drag & drop a photo'}
</div>
)}
</div>
<input ref={photoInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; if (f) onPhotoChange(f); e.target.value = ''; }} />
</div>
</div>
)}
</div>
);
}
// ─── Site Map Drop Zone ───────────────────────────────────────────────────────
function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (e) => { e.preventDefault(); dragCounter.current++; setDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); };
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (e) => {
e.preventDefault(); dragCounter.current = 0; setDragging(false);
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) onFile(file);
};
if (preview) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<img src={preview} alt="site map" style={{ maxHeight: 160, maxWidth: 280, borderRadius: 6, border: '1px solid var(--border)', objectFit: 'contain' }} />
<div>
<button className="btn btn-outline btn-sm" onClick={onClear}>Remove</button>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8 }}>Click to replace</div>
</div>
</div>
);
}
return (
<>
<div
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
}}
>
{dragging ? '📂 Drop site map here' : '🗺 Click to upload or drag & drop a site map image'}
</div>
<input ref={inputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; if (f) onFile(f); e.target.value = ''; }} />
</>
);
}
// ─── Survey Photos Drop Zone ──────────────────────────────────────────────────
function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (e) => { e.preventDefault(); dragCounter.current++; setDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); };
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (e) => { e.preventDefault(); dragCounter.current = 0; setDragging(false); onFiles(e.dataTransfer.files); };
return (
<div>
<div
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s',
}}
>
{dragging ? '📂 Drop photos here' : '📷 Click to upload or drag & drop photos (select multiple)'}
</div>
<input ref={inputRef} type="file" accept="image/*" multiple style={{ display: 'none' }}
onChange={e => { if (e.target.files.length) { onFiles(e.target.files); e.target.value = ''; } }} />
{photoItems.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{photoItems.map((item, i) => (
<div key={i} style={{ position: 'relative' }}>
<img
src={item.preview}
alt={item.file?.name || `photo ${i + 1}`}
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }}
/>
{item.file && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(245,165,35,0.8)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 700 }}>NEW</div>
)}
<button
type="button"
onClick={() => onRemove(i)}
style={{
position: 'absolute', top: -6, right: -6,
background: '#dc2626', border: 'none', borderRadius: '50%',
width: 18, height: 18, fontSize: 10, color: '#fff',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
}}
></button>
</div>
))}
<div style={{ fontSize: 11, color: 'var(--text-muted)', alignSelf: 'flex-end', marginLeft: 4 }}>
{photoItems.length} photo{photoItems.length !== 1 ? 's' : ''}
{photoItems.length > 12 && <span style={{ color: 'var(--accent)' }}> (first 12 shown in PDF)</span>}
</div>
</div>
)}
</div>
);
}
+395 -29
View File
@@ -1,12 +1,14 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import JSZip from 'jszip';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { sendEmail } from '../../lib/email';
import { useAuth } from '../../context/AuthContext';
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
const vLabel = (v) => 'v' + String(v || 0).padStart(2, '0');
const MAX_FILES = 20;
const MAX_SIZE_MB = 10;
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
@@ -24,12 +26,26 @@ export default function TaskDetail() {
const [teamMembers, setTeamMembers] = useState([]);
const [loading, setLoading] = useState(true);
const isExternal = currentUser?.role === 'external';
const [editingTitle, setEditingTitle] = useState(false);
const [titleVal, setTitleVal] = useState('');
const [savingTitle, setSavingTitle] = useState(false);
const [notification, setNotification] = useState(null);
const [showSendForm, setShowSendForm] = useState(false);
const [sendForm, setSendForm] = useState({ files: [], message: '' });
const [fileErrors, setFileErrors] = useState([]);
const [dragging, setDragging] = useState(false);
const dragCounter = useRef(0);
const [saving, setSaving] = useState(false);
const [showWorkUpload, setShowWorkUpload] = useState(false);
const [workForm, setWorkForm] = useState({ files: [], description: '' });
const [workFileErrors, setWorkFileErrors] = useState([]);
const [workDragging, setWorkDragging] = useState(false);
const workDragCounter = useRef(0);
useEffect(() => {
async function load() {
const { data: t } = await supabase.from('tasks').select('*').eq('id', id).single();
@@ -38,7 +54,7 @@ export default function TaskDetail() {
const [{ data: p }, { data: subs }, { data: team }] = await Promise.all([
supabase.from('projects').select('*, company:companies(*)').eq('id', t.project_id).single(),
supabase.from('submissions').select('*, delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'),
supabase.from('submissions').select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'),
supabase.from('profiles').select('*').eq('role', 'team'),
]);
setProject(p);
@@ -81,8 +97,7 @@ export default function TaskDetail() {
setNotification('✓ Job assigned.');
};
const handleFileChange = (e) => {
const incoming = Array.from(e.target.files);
const processFiles = (incoming) => {
const combined = [...sendForm.files, ...incoming];
const errors = [];
if (combined.length > MAX_FILES) errors.push(`Maximum ${MAX_FILES} files allowed.`);
@@ -90,9 +105,35 @@ export default function TaskDetail() {
if (errors.length > 0) { setFileErrors(errors); return; }
setFileErrors([]);
setSendForm(f => ({ ...f, files: combined }));
};
const handleFileChange = (e) => {
processFiles(Array.from(e.target.files));
e.target.value = '';
};
const handleDragEnter = (e) => {
e.preventDefault();
dragCounter.current++;
setDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
dragCounter.current--;
if (dragCounter.current === 0) setDragging(false);
};
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (e) => {
e.preventDefault();
dragCounter.current = 0;
setDragging(false);
const dropped = Array.from(e.dataTransfer.files);
if (dropped.length > 0) processFiles(dropped);
};
const removeFile = (index) => {
setSendForm(f => ({ ...f, files: f.files.filter((_, i) => i !== index) }));
setFileErrors([]);
@@ -134,11 +175,33 @@ export default function TaskDetail() {
const { data: subs } = await supabase
.from('submissions')
.select('*, delivery:deliveries(*, files:delivery_files(*))')
.select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))')
.eq('task_id', id)
.order('version_number');
setSubmissions(subs || []);
// Fetch company user emails to notify
const { data: companyUsers } = await supabase
.from('profiles')
.select('email')
.eq('company_id', company?.id)
.eq('role', 'client');
const clientEmails = (companyUsers || []).map(u => u.email).filter(Boolean);
// Fall back to sender if no client emails found
const emailRecipients = clientEmails.length > 0 ? clientEmails : (currentUser?.email ? [currentUser.email] : []);
if (emailRecipients.length > 0) {
await sendEmail('sent_to_client', emailRecipients, {
clientFirstName: company?.name,
serviceType: task.title,
projectName: project?.name,
message: sendForm.message,
taskId: id,
senderEmail: currentUser?.email,
});
}
setShowSendForm(false);
setSendForm({ files: [], message: '' });
setNotification(`✓ Sent to client — ${uploadedFiles.length} file${uploadedFiles.length !== 1 ? 's' : ''} delivered.`);
@@ -154,6 +217,109 @@ export default function TaskDetail() {
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
};
const getSubmissionFileUrl = async (path) => {
const { data } = await supabase.storage.from('submissions').createSignedUrl(path, 3600);
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
};
const handleDeleteTask = async () => {
if (!window.confirm(`Delete "${task.title}"? All submissions and files will be permanently deleted.`)) return;
await cleanupTaskStorage([id]);
await supabase.from('tasks').delete().eq('id', id);
navigate(`/projects/${task.project_id}`);
};
const handleSaveTitle = async (e) => {
e.preventDefault();
if (!titleVal.trim()) return;
setSavingTitle(true);
await supabase.from('tasks').update({ title: titleVal.trim() }).eq('id', id);
setTask(t => ({ ...t, title: titleVal.trim() }));
setEditingTitle(false);
setSavingTitle(false);
};
const processWorkFiles = (incoming) => {
const combined = [...workForm.files, ...incoming];
const errors = [];
if (combined.length > MAX_FILES) errors.push(`Maximum ${MAX_FILES} files allowed.`);
incoming.filter(f => f.size > MAX_SIZE_BYTES).forEach(f => errors.push(`"${f.name}" exceeds 10 MB limit.`));
if (errors.length > 0) { setWorkFileErrors(errors); return; }
setWorkFileErrors([]);
setWorkForm(f => ({ ...f, files: combined }));
};
const handleWorkUpload = async (e) => {
e.preventDefault();
setSaving(true);
try {
const maxVersion = submissions.reduce((max, s) => Math.max(max, s.version_number), 0);
const newVersion = maxVersion + 1;
const uploadedFiles = [];
for (const file of workForm.files) {
const path = `${id}/${Date.now()}_${file.name}`;
const { data: uploaded, error } = await supabase.storage.from('submissions').upload(path, file);
if (error) throw new Error(`Upload failed: ${error.message}`);
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
}
const { data: sub, error: subError } = await supabase.from('submissions').insert({
task_id: id,
version_number: newVersion,
type: 'initial',
service_type: submissions[0]?.service_type || '',
description: workForm.description.trim() || `Work uploaded by ${currentUser.name}`,
submitted_by: currentUser.id,
submitted_by_name: currentUser.name,
}).select().single();
if (subError) throw new Error(subError.message);
if (sub && uploadedFiles.length > 0) {
await supabase.from('submission_files').insert(
uploadedFiles.map(f => ({ ...f, submission_id: sub.id }))
);
}
await supabase.from('tasks').update({ current_version: newVersion - 1 }).eq('id', id);
setTask(t => ({ ...t, current_version: newVersion - 1 }));
const { data: subs } = await supabase
.from('submissions')
.select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))')
.eq('task_id', id)
.order('version_number');
setSubmissions(subs || []);
setWorkForm({ files: [], description: '' });
setShowWorkUpload(false);
setNotification(`✓ Work uploaded — ${uploadedFiles.length} file${uploadedFiles.length !== 1 ? 's' : ''} added.`);
} catch (err) {
setNotification(`✗ Error: ${err.message}`);
} finally {
setSaving(false);
}
};
const downloadAllSubmissionFiles = async (files, versionLabel) => {
const zip = new JSZip();
for (const file of files) {
const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600);
if (data?.signedUrl) {
const response = await fetch(data.signedUrl);
const blob = await response.blob();
zip.file(file.name, blob);
}
}
const content = await zip.generateAsync({ type: 'blob' });
const zipName = `${project?.name || 'files'} ${versionLabel}.zip`.replace(/[^a-z0-9 ._-]/gi, '_');
const a = document.createElement('a');
a.href = URL.createObjectURL(content);
a.download = zipName;
a.click();
URL.revokeObjectURL(a.href);
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!task) return <Layout><p>Job not found.</p></Layout>;
@@ -167,14 +333,47 @@ export default function TaskDetail() {
<div className="page-header">
<div>
<div className="page-title">{titleWithVersion}</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>
<StatusBadge status={task.status} />
<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)'}`,
borderRadius: 8, padding: '20px 16px', textAlign: 'center',
background: 'var(--bg)',
}}>
<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: 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,25 +463,40 @@ 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>
<div className="form-group">
<label>Assigned To</label>
<select value={task.assigned_to || ''} onChange={handleAssign} style={{ width: '100%' }}>
<option value="">Unassigned</option>
{teamMembers.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
{!isExternal && (
<div className="form-group">
<label>Assigned To</label>
<select value={task.assigned_to || ''} onChange={handleAssign} style={{ width: '100%' }}>
<option value="">Unassigned</option>
{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>