Session 2026-05-30: task detail page overhaul
- New TaskDetail replaces /tasks/:id (was v2 at /requests/:id/v2) - Overview tab: R00 request info, Requested By/Date/Sign Count inline row, Notes, Sign Family, files, amendments - Revisions tab: R01+ from submissions, newest first, same layout as overview, per-entry Amend button - Comments tab: single-line input, post on Enter, delete own comments, Supabase task_comments table - Folder tab: placeholder for file sharing - Tab badges showing revision/comment counts - Amend Request modal: drag-drop file zone, popupOverlayStyle/Surface, Save+Cancel buttons - Request Revision modal for clients on approved/invoiced/paid tasks - JSZip download-all with progress popup for submission files - Upload progress popup for Add Task and amend file uploads - FileAttachment drop zone: 8px radius, transparent bg, label style fix (no all-caps override) - PageLoader on task detail load - task_comments migration with RLS policies Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,8 @@ export default function CompanyDetail() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [availableUsers, setAvailableUsers] = useState([]);
|
||||
const [prices, setPrices] = useState([]);
|
||||
const [signFamilies, setSignFamilies] = useState([]);
|
||||
const [savingSignFamilyPrice, setSavingSignFamilyPrice] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState('users');
|
||||
const [savingPrice, setSavingPrice] = useState(null);
|
||||
@@ -35,6 +37,10 @@ export default function CompanyDetail() {
|
||||
const [editUserVal, setEditUserVal] = useState('');
|
||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
const [{ data: co }, { data: p }, { data: pr }, { data: memberRows }, { data: allUsers }, { data: t }] = await Promise.all([
|
||||
supabase.from('companies').select('*').eq('id', id).single(),
|
||||
@@ -212,6 +218,36 @@ export default function CompanyDetail() {
|
||||
setSavingPrice(null);
|
||||
};
|
||||
|
||||
const getSignFamilyPrice = (signFamily, priceType) =>
|
||||
prices.find(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type)?.price ?? '';
|
||||
|
||||
const handleSignFamilyPriceChange = (signFamily, priceType, value) => {
|
||||
setPrices(prev => {
|
||||
const existing = prev.find(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type);
|
||||
if (existing) return prev.map(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type ? { ...p, price: value } : p);
|
||||
return [...prev, { sign_family: signFamily, price_type: priceType, price: value, company_id: id }];
|
||||
});
|
||||
};
|
||||
|
||||
const handleSignFamilyPriceSave = async (signFamily) => {
|
||||
setSavingSignFamilyPrice(signFamily);
|
||||
for (const priceType of ['new', 'revision']) {
|
||||
const priceVal = getSignFamilyPrice(signFamily, priceType);
|
||||
const existing = prices.find(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type && p.id);
|
||||
if (existing) {
|
||||
const { error } = await supabase.from('company_prices').update({ price: Number(priceVal) }).eq('id', existing.id);
|
||||
if (error) { setSavingSignFamilyPrice(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, sign_family: signFamily, price_type: priceType, price: Number(priceVal),
|
||||
}).select().single();
|
||||
if (error) { setSavingSignFamilyPrice(null); alert('Failed to save price. Please try again.'); return; }
|
||||
if (data) setPrices(prev => [...prev.filter(p => !(p.sign_family === signFamily && p.price_type === priceType && !p.service_type && !p.id)), data]);
|
||||
}
|
||||
}
|
||||
setSavingSignFamilyPrice(null);
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!company) return <Layout><p>Company not found.</p></Layout>;
|
||||
|
||||
@@ -538,6 +574,48 @@ export default function CompanyDetail() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 32 }}>
|
||||
<div className="card-title" style={{ fontSize: 14, marginBottom: 8 }}>Sign Family Prices</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 }}>
|
||||
Set prices per sign family for this company.
|
||||
</p>
|
||||
<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: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
|
||||
))}
|
||||
<div />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{signFamilies.map(sf => (
|
||||
<div key={sf} style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{sf}</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={getSignFamilyPrice(sf, priceType)}
|
||||
onChange={e => handleSignFamilyPriceChange(sf, priceType, e.target.value)}
|
||||
style={{ margin: 0, width: '100%', textAlign: 'right' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => handleSignFamilyPriceSave(sf)}
|
||||
disabled={savingSignFamilyPrice === sf}
|
||||
>
|
||||
{savingSignFamilyPrice === sf ? '...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||
import { withTimeout } from '../lib/withTimeout';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
|
||||
const ICON_TONES = [
|
||||
{ bg: 'rgba(245,165,35,0.15)', color: '#F5A523' },
|
||||
{ bg: 'rgba(74,222,128,0.15)', color: '#4ade80' },
|
||||
{ bg: 'rgba(96,165,250,0.15)', color: '#60a5fa' },
|
||||
{ bg: 'rgba(167,139,250,0.15)', color: '#a78bfa' },
|
||||
];
|
||||
|
||||
function iconTone(key) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < (key || '').length; i++) h = (h * 31 + key.charCodeAt(i)) % ICON_TONES.length;
|
||||
return ICON_TONES[h];
|
||||
}
|
||||
|
||||
function InitialPortrait({ name }) {
|
||||
const tone = iconTone(name);
|
||||
return (
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: tone.bg, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, color: tone.color, lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalDashboard({ currentUser, projects, tasks, pos, submissions, clientProfiles, companyMemberships }) {
|
||||
const myTasks = tasks.filter(t => t.assigned_to === currentUser?.id);
|
||||
const myActiveTasks = myTasks.filter(t => !['client_approved', 'on_hold'].includes(t.status));
|
||||
const myCompleted = myTasks.filter(t => t.status === 'client_approved');
|
||||
const myCompletedRevisions = myCompleted.reduce((sum, t) => sum + Number(t.current_version || 0), 0);
|
||||
const myOnHold = myTasks.filter(t => t.status === 'on_hold');
|
||||
const myAssignedProjectCount = new Set(myTasks.map(t => t.project_id).filter(Boolean)).size;
|
||||
const unpaidAmount = pos.filter(p => !['paid', 'cancelled'].includes(p.status)).reduce((s, p) => s + Number(p.amount || 0), 0);
|
||||
const paidAmount = pos.filter(p => p.status === 'paid').reduce((s, p) => s + Number(p.amount || 0), 0);
|
||||
|
||||
const myProjectIds = new Set(myTasks.map(t => t.project_id).filter(Boolean));
|
||||
const myProjects = myProjectIds.size > 0
|
||||
? projects.filter(p => myProjectIds.has(p.id))
|
||||
: projects;
|
||||
const companyById = Object.fromEntries(myProjects.filter(p => p.company).map(p => [p.company_id, p.company]));
|
||||
const myProjectIdSet = new Set(myProjects.map(p => p.id));
|
||||
const activityEvents = buildActivityEvents(submissions, myProjectIdSet);
|
||||
const externalHighlights = buildClientHighlights(Object.values(companyById), myProjects, tasks, clientProfiles, companyMemberships || [])
|
||||
.sort((a, b) => b.openTaskCount - a.openTaskCount || a.company.name.localeCompare(b.company.name))
|
||||
.slice(0, 5);
|
||||
return (
|
||||
<Layout>
|
||||
<div className="dash-stat-grid">
|
||||
<DashStatCard label="Active Tasks" value={myActiveTasks.length} sub={`${myOnHold.length} on hold`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.tasks} />
|
||||
<DashStatCard label="Completed Tasks" value={myCompleted.length} sub={`${myCompletedRevisions} total revisions`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.trending} />
|
||||
<DashStatCard label="Active Projects" value={myProjects.length} sub={`${myAssignedProjectCount} assigned to me`} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.projects} />
|
||||
<DashStatCard label="Pending Payment" value={fmtMoney(unpaidAmount)} sub={`${fmtMoney(paidAmount)} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.invoice} />
|
||||
</div>
|
||||
<div className="dashboard-bottom-grid">
|
||||
<ActivityFeed events={activityEvents} />
|
||||
{myProjects.length > 0 && <ClientHighlightTable highlights={externalHighlights} />}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared dashboard helpers ──────────────────────────────────────────────
|
||||
|
||||
const ACTION_ICON = {
|
||||
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
|
||||
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
|
||||
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
|
||||
project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
|
||||
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
|
||||
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||
};
|
||||
function ActionIcon({ actionKey, size = 27 }) {
|
||||
const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: <circle cx="12" cy="12" r="3" fill="currentColor"/> };
|
||||
return (
|
||||
<div style={{ width: size, height: size, borderRadius: '50%', background: cfg.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" stroke={cfg.color} fill="none" style={{ color: cfg.color }}>{cfg.path}</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_LABEL = {
|
||||
task_created: 'created',
|
||||
task_started: 'started',
|
||||
task_on_hold: 'put on hold',
|
||||
task_resumed: 'resumed',
|
||||
task_submitted: 'submitted',
|
||||
task_approved: 'approved',
|
||||
project_created: 'created project',
|
||||
request_submitted: 'submitted',
|
||||
revision_requested: 'requested revision on',
|
||||
};
|
||||
|
||||
function buildActivityEvents(activityData, projectIdSet = null) {
|
||||
return (activityData || [])
|
||||
.filter(e => !projectIdSet || !e.project_id || projectIdSet.has(e.project_id))
|
||||
.map(e => ({
|
||||
time: new Date(e.created_at),
|
||||
name: e.actor_name || 'Fourge',
|
||||
actionKey: e.action,
|
||||
action: ACTION_LABEL[e.action] || e.action,
|
||||
task: e.task_title || null,
|
||||
project: e.project_name || null,
|
||||
})).filter(e => !isNaN(e.time)).slice(0, 10);
|
||||
}
|
||||
|
||||
function buildClientHighlights(companies, projects, tasks, clientProfiles, companyMemberships = [], invoices = []) {
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
return (companies || []).map(company => {
|
||||
const companyProjects = (projects || []).filter(p => p.company_id === company.id);
|
||||
const primaryContact = (clientProfiles || []).find(p =>
|
||||
p.company_id === company.id ||
|
||||
(companyMemberships || []).some(m => m.company_id === company.id && m.profile_id === p.id)
|
||||
);
|
||||
const openTasks = (tasks || []).filter(t => companyProjects.some(p => p.id === t.project_id) && !doneStatuses.includes(t.status));
|
||||
const companyInvoices = (invoices || []).filter(i => i.company_id === company.id);
|
||||
const outstandingTotal = companyInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const paidTotal = companyInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
return { company, primaryContact, projectCount: companyProjects.length, openTaskCount: openTasks.length, outstandingTotal, paidTotal };
|
||||
});
|
||||
}
|
||||
|
||||
function fmtMoney(n) {
|
||||
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
|
||||
if (Math.abs(n) >= 10000) return `$${(n / 1000).toFixed(1)}k`;
|
||||
return `$${Number(n).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function smoothCurve(pts) {
|
||||
if (pts.length < 2) return '';
|
||||
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const [x0, y0] = pts[i - 1];
|
||||
const [x1, y1] = pts[i];
|
||||
const cpX = (x0 + x1) / 2;
|
||||
d += ` C${cpX.toFixed(1)},${y0.toFixed(1)} ${cpX.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
function MiniAreaChart({ data }) {
|
||||
const W = 90, H = 42;
|
||||
if (!data || data.length < 2) return null;
|
||||
const max = Math.max(...data, 1);
|
||||
const pts = data.map((v, i) => [
|
||||
(i / (data.length - 1)) * W,
|
||||
4 + (1 - v / max) * (H - 8),
|
||||
]);
|
||||
const line = smoothCurve(pts);
|
||||
const lastPt = pts[pts.length - 1];
|
||||
const firstPt = pts[0];
|
||||
const area = `${line} L${lastPt[0].toFixed(1)},${H} L${firstPt[0].toFixed(1)},${H} Z`;
|
||||
return (
|
||||
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
|
||||
<defs>
|
||||
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#F5A523" stopOpacity="0.3" />
|
||||
<stop offset="95%" stopColor="#F5A523" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#areaGrad)" />
|
||||
<path d={line} fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
|
||||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'rgba(255,255,255,0.7)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: '#ffffff', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
</div>
|
||||
{sub && <div style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)', marginTop: 5 }}>{sub}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', ...(chartData ? { flex: 1, minWidth: 0 } : { flexShrink: 0 }) }}>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
|
||||
</div>
|
||||
{chartData && <MiniAreaChart data={chartData} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DASH_ICONS = {
|
||||
revenue: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>',
|
||||
projects: '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
|
||||
tasks: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>',
|
||||
invoice: '<rect x="2" y="2" width="20" height="20" rx="2"/><line x1="12" y1="6" x2="12" y2="18"/><path d="M16 8H9.5a2.5 2.5 0 000 5h5a2.5 2.5 0 010 5H8"/>',
|
||||
trending: '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
|
||||
profit: '<line x1="12" y1="20" x2="12" y2="4"/><polyline points="5 11 12 4 19 11"/>',
|
||||
};
|
||||
|
||||
function StatBar({ items }) {
|
||||
return (
|
||||
<div className="stat-bar">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="stat-bar-item">
|
||||
<div className="stat-bar-header">
|
||||
<div className="stat-bar-label">{item.label}</div>
|
||||
<div className="stat-bar-dot" style={{ background: item.color }} />
|
||||
</div>
|
||||
<div className="stat-bar-value">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskFeed({ title, tasks, projects, emptyMessage }) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden', borderRadius: 4, flexShrink: 0 }}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>{title}</span>
|
||||
{tasks.length > 0 && <span style={{ fontSize: 11, fontWeight: 400, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>}
|
||||
</div>
|
||||
{tasks.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)', fontSize: 13 }}>{emptyMessage}</div>
|
||||
) : tasks.map((task, i) => {
|
||||
const project = projects.find(p => p.id === task.project_id);
|
||||
return (
|
||||
<div key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '10px 16px', borderBottom: i < tasks.length - 1 ? '1px solid var(--border)' : 'none', cursor: 'pointer' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{task.title}</div>
|
||||
{project && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{project.name}</div>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{task.assigned_name ? <>Assigned to <span style={{ color: 'var(--text-primary)' }}>{task.assigned_name}</span></> : 'Unassigned'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityFeed({ events }) {
|
||||
const visible = events.slice(0, 5);
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
|
||||
<div style={{ marginBottom: visible.length > 0 ? 14 : 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'rgba(255,255,255,0.7)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
|
||||
</div>
|
||||
{visible.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.5)', marginTop: 14 }}>No recent activity</div>
|
||||
) : visible.map((e, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
||||
<ActionIcon actionKey={e.actionKey} />
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
|
||||
<span style={{ color: '#ffffff', fontWeight: 400 }}>{e.name}</span>
|
||||
{e.action && <span style={{ color: 'rgba(255,255,255,0.5)' }}> {e.action}</span>}
|
||||
{e.task && <span style={{ color: '#ffffff' }}> {e.task}</span>}
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', whiteSpace: 'nowrap', flexShrink: 0 }}>{e.time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientHighlightTable({ highlights }) {
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('company');
|
||||
if (!highlights || highlights.length === 0) return null;
|
||||
const fmtMoney = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
const sortedHighlights = sort(highlights, (row, key) => {
|
||||
if (key === 'company') return row.company?.name || '';
|
||||
if (key === 'primaryContact') return row.primaryContact?.name || '';
|
||||
if (key === 'projectCount') return row.projectCount || 0;
|
||||
if (key === 'openTaskCount') return row.openTaskCount || 0;
|
||||
if (key === 'outstandingTotal') return Number(row.outstandingTotal || 0);
|
||||
if (key === 'paidTotal') return Number(row.paidTotal || 0);
|
||||
return '';
|
||||
});
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden', borderRadius: 4, flexShrink: 0 }}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Client Highlight</span>
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--card-bg-2)' }}>
|
||||
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'left', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Company</SortTh>
|
||||
<SortTh col="primaryContact" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Primary Contact</SortTh>
|
||||
<SortTh col="projectCount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Projects</SortTh>
|
||||
<SortTh col="openTaskCount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Open Tasks</SortTh>
|
||||
<SortTh col="outstandingTotal" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'right', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Outstanding</SortTh>
|
||||
<SortTh col="paidTotal" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'right', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Paid</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }, i) => (
|
||||
<tr key={company.id} style={{ borderBottom: i < sortedHighlights.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
<td style={{ padding: '10px 16px', fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<InitialPortrait name={company.name} />
|
||||
{company.name}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-secondary)', textAlign: 'center' }}>{primaryContact?.name || '—'}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-secondary)', textAlign: 'center' }}>{projectCount}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{openTaskCount}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--accent)', textAlign: 'right' }}>{fmtMoney(outstandingTotal)}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--accent)', textAlign: 'right' }}>{fmtMoney(paidTotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main export ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { currentUser } = useAuth();
|
||||
const isClient = currentUser?.role === 'client';
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
|
||||
// ── Client state ──────────────────────────────────────────────────────
|
||||
const hasCompany = Boolean(currentUser?.company_id || currentUser?.company?.id || currentUser?.companies?.length);
|
||||
const companies = isClient
|
||||
? (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
: [];
|
||||
const [allClientTasks, setAllClientTasks] = useState([]);
|
||||
const [allClientProjects, setAllClientProjects] = useState([]);
|
||||
const [allClientInvoices, setAllClientInvoices] = useState([]);
|
||||
const [clientActivity, setClientActivity] = useState([]);
|
||||
|
||||
// ── External state ────────────────────────────────────────────────────
|
||||
const cacheKey = 'team_dashboard_external';
|
||||
const cached = isExternal ? readPageCache(cacheKey, 5 * 60_000) : null;
|
||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
||||
const [pos, setPos] = useState(() => cached?.pos || []);
|
||||
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
|
||||
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
||||
|
||||
const [loading, setLoading] = useState(() => isClient ? hasCompany : isExternal && !cached);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (isClient) {
|
||||
if (!hasCompany) { setLoading(false); return; }
|
||||
async function loadClient() {
|
||||
try {
|
||||
const [{ data: activeTasks }, { data: invoices }, { data: activityData }] = await withTimeout(Promise.all([
|
||||
supabase.from('tasks').select('id, title, status, project_id, assigned_name, project:projects(id, name, company_id)').order('submitted_at', { ascending: false }),
|
||||
supabase.from('invoices').select('total, status, company_id').in('status', ['sent', 'paid']),
|
||||
supabase.from('activity_log').select('id, created_at, actor_name, action, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
|
||||
]), 30000, 'Client dashboard load');
|
||||
if (cancelled) return;
|
||||
const clientTasks = activeTasks || [];
|
||||
setAllClientTasks(clientTasks);
|
||||
setAllClientInvoices(invoices || []);
|
||||
setClientActivity(activityData || []);
|
||||
const projectMap = {};
|
||||
clientTasks.forEach(t => { if (t.project?.id) projectMap[t.project.id] = t.project; });
|
||||
setAllClientProjects(Object.values(projectMap));
|
||||
} catch (error) {
|
||||
console.error('ClientDashboard load failed:', error);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
loadClient();
|
||||
} else if (isExternal) {
|
||||
async function loadExternal() {
|
||||
try {
|
||||
const [{ data: p }, { data: t }, { data: posData }, { data: memRows }, { data: clientProfiles }, { data: activityData }] = await withTimeout(Promise.all([
|
||||
supabase.from('projects').select('id, name, company_id, company:companies(id, name)').order('created_at', { ascending: false }),
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_to, assigned_name').order('submitted_at', { ascending: false }),
|
||||
supabase.from('subcontractor_payments').select('id, amount, status').eq('profile_id', currentUser.id),
|
||||
supabase.from('company_members').select('company_id, profile_id'),
|
||||
supabase.from('profiles').select('id, name, email, company_id').eq('role', 'client'),
|
||||
supabase.from('activity_log').select('id, created_at, actor_name, action, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
|
||||
]), 30000, 'External dashboard load');
|
||||
if (!cancelled) {
|
||||
setProjects(p || []);
|
||||
setTasks(t || []);
|
||||
setPos(posData || []);
|
||||
setSubmissions(activityData || []);
|
||||
setClientProfiles(clientProfiles || []);
|
||||
setCompanyMemberships(memRows || []);
|
||||
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: activityData || [], pos: posData || [], clientProfiles: clientProfiles || [], companyMemberships: memRows || [] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('External dashboard load failed:', error);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
loadExternal();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
}, [isClient, isExternal, hasCompany, cacheKey, currentUser?.id]);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
// ── Client render ──────────────────────────────────────────────────────
|
||||
if (isClient) {
|
||||
const myCompanyIds = new Set(companies.map(c => c.id));
|
||||
const myTasks = myCompanyIds.size > 0
|
||||
? allClientTasks.filter(t => t.project?.company_id && myCompanyIds.has(t.project.company_id))
|
||||
: allClientTasks;
|
||||
const myProjects = myCompanyIds.size > 0
|
||||
? allClientProjects.filter(p => myCompanyIds.has(p.company_id))
|
||||
: allClientProjects;
|
||||
const myInvoices = myCompanyIds.size > 0
|
||||
? allClientInvoices.filter(i => myCompanyIds.has(i.company_id))
|
||||
: allClientInvoices;
|
||||
const myProjectIds = new Set(myProjects.map(p => p.id));
|
||||
const reviewTasks = myTasks.filter(t => t.status === 'client_review');
|
||||
const inProgressTasks = myTasks.filter(t => t.status === 'in_progress');
|
||||
const onHoldTasks = myTasks.filter(t => t.status === 'on_hold');
|
||||
const notStartedTasks = myTasks.filter(t => t.status === 'not_started');
|
||||
const outstandingInvoices = myInvoices.filter(i => i.status === 'sent').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
|
||||
const paidInvoices = myInvoices.filter(i => i.status === 'paid').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="dash-stat-grid">
|
||||
<DashStatCard label="Active Projects" value={myProjects.length} sub={`${myTasks.length} total tasks`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
|
||||
<DashStatCard label="Outstanding" value={fmtMoney(outstandingInvoices)} sub={`${fmtMoney(paidInvoices)} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.invoice} />
|
||||
<DashStatCard label="In Progress" value={inProgressTasks.length} sub={`${notStartedTasks.length} not started`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.tasks} />
|
||||
<DashStatCard label="Awaiting Review" value={reviewTasks.length} sub={`${onHoldTasks.length} on hold`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.trending} />
|
||||
</div>
|
||||
<ActivityFeed events={buildActivityEvents(clientActivity, myProjectIds)} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||
<TaskFeed title="Awaiting Your Review" tasks={reviewTasks} projects={myProjects} emptyMessage="No items need your review." />
|
||||
<TaskFeed title="In Progress" tasks={inProgressTasks} projects={myProjects} emptyMessage="No items currently in progress." />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ── External render ────────────────────────────────────────────────────
|
||||
if (isExternal) {
|
||||
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} pos={pos} submissions={submissions} clientProfiles={clientProfiles} companyMemberships={companyMemberships} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+126
-35
@@ -108,12 +108,21 @@ function FileIcon({ name, isDir }) {
|
||||
);
|
||||
}
|
||||
|
||||
const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`;
|
||||
async function getAccessTokenOrThrow() {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session?.access_token) return session.access_token;
|
||||
|
||||
const { data: refreshed, error } = await supabase.auth.refreshSession();
|
||||
if (error) throw new Error('Session expired. Please sign in again.');
|
||||
if (refreshed?.session?.access_token) return refreshed.session.access_token;
|
||||
|
||||
throw new Error('Session expired. Please sign in again.');
|
||||
}
|
||||
|
||||
async function downloadViaLocalFilebrowser(path, session) {
|
||||
if (!session?.access_token) throw new Error('Missing session token for download.');
|
||||
const response = await fetch(`/api/filebrowser?action=download-blob&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(session.access_token)}`, {
|
||||
headers: { Authorization: `Bearer ${session?.access_token ?? ''}` },
|
||||
const accessToken = session?.access_token || await getAccessTokenOrThrow();
|
||||
const response = await fetch(`/api/filebrowser?action=download-blob&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
@@ -123,10 +132,9 @@ async function downloadViaLocalFilebrowser(path, session) {
|
||||
}
|
||||
|
||||
async function downloadViaApiToken(path, filename) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) throw new Error('Missing session token for download.');
|
||||
const metaResponse = await fetch(`/api/filebrowser?action=download&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(session.access_token)}`, {
|
||||
headers: { Authorization: `Bearer ${session.access_token}` },
|
||||
const accessToken = await getAccessTokenOrThrow();
|
||||
const metaResponse = await fetch(`/api/filebrowser?action=download&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!metaResponse.ok) {
|
||||
const text = await metaResponse.text();
|
||||
@@ -147,36 +155,119 @@ async function downloadViaApiToken(path, filename) {
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
async function downloadBlobViaLocalFilebrowser(path) {
|
||||
const accessToken = await getAccessTokenOrThrow();
|
||||
const response = await downloadViaLocalFilebrowser(path, { access_token: accessToken });
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async function fbqProxy(op, path, extra = {}) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const headers = {
|
||||
Authorization: `Bearer ${session?.access_token ?? ''}`,
|
||||
'X-Operation': op,
|
||||
'X-Path': path,
|
||||
...extra.headers,
|
||||
};
|
||||
const response = await fetch(FBQ_FN, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: extra.body ?? undefined,
|
||||
});
|
||||
const accessToken = await getAccessTokenOrThrow();
|
||||
const authHeaders = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
if (!response.ok && op === 'download') {
|
||||
const text = await response.text();
|
||||
const shouldFallbackToLocalDownload =
|
||||
response.status === 404
|
||||
|| (response.status === 400 && text.toLowerCase().includes('no files specified'));
|
||||
if (shouldFallbackToLocalDownload) {
|
||||
return downloadViaLocalFilebrowser(path, session);
|
||||
if (op === 'list') {
|
||||
const response = await fetch(`/api/filebrowser?action=list&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
}
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
const data = await response.json();
|
||||
const entries = Array.isArray(data?.entries) ? data.entries : [];
|
||||
const folders = entries
|
||||
.filter((entry) => entry.type === 'dir' || entry.type === 'directory' || entry.isDir)
|
||||
.map((entry) => ({ name: entry.name, filename: entry.name, path: entry.path, type: 'directory', isDir: true, size: 0, modified: entry.mtime || null }));
|
||||
const files = entries
|
||||
.filter((entry) => !(entry.type === 'dir' || entry.type === 'directory' || entry.isDir))
|
||||
.map((entry) => ({ name: entry.name, filename: entry.name, path: entry.path, type: 'file', isDir: false, size: entry.size || 0, modified: entry.mtime || null }));
|
||||
return new Response(JSON.stringify({ folders, files }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
if (op === 'download') {
|
||||
return downloadViaLocalFilebrowser(path, { access_token: accessToken });
|
||||
}
|
||||
return response;
|
||||
|
||||
if (op === 'mkdir') {
|
||||
const normalized = String(path || '/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
const name = parts.pop() || '';
|
||||
const parentPath = `/${parts.join('/')}`;
|
||||
const response = await fetch(`/api/filebrowser?action=mkdir&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: parentPath || '/', name }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (op === 'upload') {
|
||||
const normalized = String(path || '/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
const fileName = parts.pop() || '';
|
||||
const parentPath = `/${parts.join('/')}` || '/';
|
||||
const tokenResponse = await fetch(`/api/filebrowser?action=upload-token&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: parentPath }),
|
||||
});
|
||||
if (!tokenResponse.ok) {
|
||||
const text = await tokenResponse.text();
|
||||
throw new Error(text || `Error ${tokenResponse.status}`);
|
||||
}
|
||||
const { token, url, fbPath } = await tokenResponse.json();
|
||||
if (!token || !url || !fbPath) throw new Error('Upload token unavailable.');
|
||||
const file = extra.body?.get?.('file');
|
||||
if (!file) throw new Error('No file provided.');
|
||||
const uploadResponse = await fetch(`${url}/api/resources?source=srv&path=${encodeURIComponent(`${fbPath}/${fileName}`)}&override=true&auth=${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: file,
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
const text = await uploadResponse.text();
|
||||
throw new Error(text || `Error ${uploadResponse.status}`);
|
||||
}
|
||||
return uploadResponse;
|
||||
}
|
||||
|
||||
if (op === 'delete') {
|
||||
const files = JSON.parse(extra.headers?.['X-Files'] || '[]');
|
||||
for (const filePath of files) {
|
||||
const response = await fetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(filePath)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (op === 'move' || op === 'copy') {
|
||||
const files = JSON.parse(extra.headers?.['X-Files'] || '[]');
|
||||
for (const srcPath of files) {
|
||||
const response = await fetch(`/api/filebrowser?action=move&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ srcPath, dstPath: path, mode: op }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported operation: ${op}`);
|
||||
}
|
||||
|
||||
function rootPath(user) {
|
||||
@@ -513,7 +604,7 @@ export default function FileSharing() {
|
||||
const fileName = file.name ?? file.filename ?? '';
|
||||
if (!fileName) continue;
|
||||
const filePath = file.path ?? (targetPath === '/' ? `/${fileName}` : `${targetPath}/${fileName}`);
|
||||
const blob = await (await fbqProxy('download', filePath)).blob();
|
||||
const blob = await downloadBlobViaLocalFilebrowser(filePath);
|
||||
zip.file(zipPath ? `${zipPath}/${fileName}` : fileName, blob);
|
||||
}
|
||||
}
|
||||
@@ -535,7 +626,7 @@ export default function FileSharing() {
|
||||
const isDir = entry.type === 'directory' || entry.isDir;
|
||||
|
||||
if (!isDir) {
|
||||
const blob = await (await fbqProxy('download', targetPath)).blob();
|
||||
const blob = await downloadBlobViaLocalFilebrowser(targetPath);
|
||||
setDownloadProgress(100);
|
||||
triggerDownload(URL.createObjectURL(blob), name);
|
||||
return;
|
||||
@@ -559,7 +650,7 @@ export default function FileSharing() {
|
||||
if (isDir) {
|
||||
await addPathToZip(zip, targetPath, name);
|
||||
} else {
|
||||
const blob = await (await fbqProxy('download', targetPath)).blob();
|
||||
const blob = await downloadBlobViaLocalFilebrowser(targetPath);
|
||||
zip.file(name, blob);
|
||||
}
|
||||
setDownloadProgress(Math.round(((i + 1) / selectedEntries.length) * 100));
|
||||
|
||||
@@ -1,590 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { 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 { withTimeout } from '../lib/withTimeout';
|
||||
import { DashboardBanner } from '../lib/dashboardBanner';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
import FilterDropdown from '../components/FilterDropdown';
|
||||
|
||||
// ─── Client helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
||||
|
||||
function ClientProjectGroup({ project, tasks, submissions, currentUserId, filter }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const filteredTasks = filter === 'mine'
|
||||
? tasks.filter(task => {
|
||||
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||
return initial?.submitted_by === currentUserId;
|
||||
})
|
||||
: tasks;
|
||||
if (filter === 'mine' && filteredTasks.length === 0) return null;
|
||||
return (
|
||||
<div className="interactive-surface" style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden', marginBottom: 8 }}>
|
||||
<button
|
||||
className="interactive-panel-toggle"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', 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: 10 }}>
|
||||
<Link to={`/projects/${project.id}`} onClick={e => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', textDecoration: 'none' }}>
|
||||
{project.name}
|
||||
</Link>
|
||||
<span style={{ fontSize: 11, fontWeight: 400, padding: '2px 8px', borderRadius: 4, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)' }}>
|
||||
{filteredTasks.length} request{filteredTasks.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<StatusBadge status={project.status} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ background: 'var(--card-bg)' }}>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div style={{ padding: '16px', fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No requests in this project yet.</div>
|
||||
) : filteredTasks.map((task, i) => {
|
||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
|
||||
const latestSub = taskSubs[taskSubs.length - 1];
|
||||
const hasRevision = latestSub && initialSub && latestSub.id !== initialSub.id && latestSub.submitted_by_name !== initialSub.submitted_by_name;
|
||||
const isMine = initialSub?.submitted_by === currentUserId;
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
to={`/requests/${task.id}`}
|
||||
className="interactive-row"
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: i < filteredTasks.length - 1 ? '1px solid var(--border)' : 'none', gap: 8, textDecoration: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)' }}>{task.title}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{rLabel(task.current_version)}</span>
|
||||
{isMine && <span style={{ fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '1px 7px', borderRadius: 4, fontWeight: 400 }}>Mine</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
|
||||
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
|
||||
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={task.status} />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBar({ tasks = [], noPad = false }) {
|
||||
const total = tasks.length;
|
||||
if (total === 0) return <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>—</span>;
|
||||
const done = tasks.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t.status)).length;
|
||||
const pct = Math.round((done / total) * 100);
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingRight: noPad ? 0 : '10%' }}>
|
||||
<div style={{ flex: 1, minWidth: 60, height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', borderRadius: 3, background: pct === 100 ? '#4ade80' : 'var(--accent)', transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', textAlign: 'right' }}>{done}/{total} <span style={{ opacity: 0.65 }}>({pct}%)</span></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ListViewIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<line x1="1" y1="3" x2="13" y2="3"/><line x1="1" y1="7" x2="13" y2="7"/><line x1="1" y1="11" x2="13" y2="11"/>
|
||||
</svg>
|
||||
);
|
||||
const GridViewIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<rect x="1" y="1" width="5" height="5" rx="1"/><rect x="8" y="1" width="5" height="5" rx="1"/>
|
||||
<rect x="1" y="8" width="5" height="5" rx="1"/><rect x="8" y="8" width="5" height="5" rx="1"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ─── Main export ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function Projects() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const isTeam = currentUser?.role === 'team';
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
const isClient = currentUser?.role === 'client';
|
||||
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [viewMode, setViewMode] = useState(() => localStorage.getItem('projectsViewMode') || 'list');
|
||||
const toggleView = () => setViewMode(v => { const n = v === 'list' ? 'grid' : 'list'; localStorage.setItem('projectsViewMode', n); return n; });
|
||||
|
||||
// Team/External state
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
|
||||
// Team add-project form
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ name: '', companyId: '' });
|
||||
const [addSaving, setAddSaving] = useState(false);
|
||||
const [addError, setAddError] = useState('');
|
||||
const [allCompanies, setAllCompanies] = useState([]);
|
||||
|
||||
// Client-specific state
|
||||
const [filter, setFilter] = useState('all');
|
||||
const companies = isClient
|
||||
? (currentUser.companies?.length ? currentUser.companies : (currentUser.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
: [];
|
||||
const [activeCompanyId, setActiveCompanyId] = useState(() => companies[0]?.id || null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
if (isTeam) {
|
||||
const [{ data }, { data: cos }] = await Promise.all([
|
||||
supabase.from('projects').select('id, name, status, created_at, company:companies(id, name), tasks:tasks(id,status)').order('created_at', { ascending: false }),
|
||||
supabase.from('companies').select('id, name').order('name'),
|
||||
]);
|
||||
setProjects(data || []);
|
||||
setAllCompanies(cos || []);
|
||||
} else if (isExternal) {
|
||||
if (!currentUser?.id) { setLoading(false); return; }
|
||||
const { data, error: err } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name, status, created_at, company:companies(name), tasks:tasks(id,status)')
|
||||
.order('created_at', { ascending: false });
|
||||
if (err) setError(err.message);
|
||||
else setProjects(data || []);
|
||||
} else if (isClient) {
|
||||
const [{ data: p }, { data: t }] = await withTimeout(Promise.all([
|
||||
supabase.from('projects').select('id, name, status, company_id, created_at').order('created_at', { ascending: false }),
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, submitted_at').order('submitted_at', { ascending: false }),
|
||||
]), 12000, 'Projects load');
|
||||
setProjects(p || []);
|
||||
setTasks(t || []);
|
||||
if (t && t.length > 0) {
|
||||
const { data: subs } = await withTimeout(
|
||||
supabase.from('submissions').select('id, task_id, submitted_by, submitted_by_name, version_number, type').in('task_id', t.map(task => task.id)).order('version_number'),
|
||||
12000,
|
||||
'Project submissions load'
|
||||
);
|
||||
setSubmissions(subs || []);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Projects load failed:', err);
|
||||
setError(err.message || 'Failed to load.');
|
||||
setProjects([]);
|
||||
setTasks([]);
|
||||
setSubmissions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [isTeam, isExternal, isClient, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const { sortKey: projSortKey, sortDir: projSortDir, toggle: projToggle, sort: projSort } = useSortable('status', 'asc');
|
||||
const { sortKey: extSortKey, sortDir: extSortDir, toggle: extToggle, sort: extSort } = useSortable('status', 'asc');
|
||||
const { sortKey: clientSortKey, sortDir: clientSortDir, toggle: clientToggle, sort: clientSort } = useSortable('status', 'asc');
|
||||
|
||||
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||
|
||||
const teamCompanies = useMemo(() => {
|
||||
if (!isTeam) return [];
|
||||
const seen = new Map();
|
||||
projects.forEach(p => { if (p.company?.id && !seen.has(p.company.id)) seen.set(p.company.id, p.company.name); });
|
||||
return [...seen.entries()].sort((a, b) => a[1].localeCompare(b[1]));
|
||||
}, [isTeam, projects]);
|
||||
|
||||
const handleAddProject = async (e) => {
|
||||
e.preventDefault();
|
||||
if (addSaving) return;
|
||||
setAddSaving(true); setAddError('');
|
||||
try {
|
||||
const name = addForm.name.trim();
|
||||
if (!name) throw new Error('Project name required.');
|
||||
if (!addForm.companyId) throw new Error('Company required.');
|
||||
const { data: newProject, error: err } = await supabase
|
||||
.from('projects')
|
||||
.insert({ name, company_id: addForm.companyId, status: 'active' })
|
||||
.select('id, name, status, created_at, company:companies(id, name)')
|
||||
.single();
|
||||
if (err) throw err;
|
||||
navigate(`/projects/${newProject.id}`);
|
||||
} catch (err) {
|
||||
setAddError(err.message);
|
||||
setAddSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
// ── Team render ────────────────────────────────────────────────────────
|
||||
if (isTeam) {
|
||||
const companyFiltered = filterCompany ? projects.filter(p => p.company?.id === filterCompany) : projects;
|
||||
const statusFiltered = projSort(
|
||||
filterStatus === 'all' ? companyFiltered : companyFiltered.filter(p => (p.status || 'active') === filterStatus),
|
||||
(p, key) => key === 'client' ? (p.company?.name || '') : key === 'status' ? (p.status || 'active') : p[key] || ''
|
||||
);
|
||||
const allCount = companyFiltered.length;
|
||||
const activeCount = companyFiltered.filter(p => !p.status || p.status === 'active').length;
|
||||
const completedCount = companyFiltered.filter(p => p.status === 'completed').length;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header" style={{ flexShrink: 0 }}>
|
||||
<div className="page-header-left">
|
||||
<DashboardBanner />
|
||||
<div className="page-title dashboard-greeting">Projects</div>
|
||||
<div className="page-subtitle">{activeCount} active • {completedCount} completed</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(true); setAddError(''); }}>
|
||||
+ Add Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 480, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>New Project</div>
|
||||
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Add a project</div>
|
||||
</div>
|
||||
<button onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
|
||||
</div>
|
||||
<form onSubmit={handleAddProject}>
|
||||
<div className="form-group">
|
||||
<label>Company *</label>
|
||||
<select value={addForm.companyId} onChange={e => setAddForm(f => ({ ...f, companyId: e.target.value }))} required>
|
||||
<option value="">Select company...</option>
|
||||
{allCompanies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Project Name *</label>
|
||||
<input type="text" placeholder="e.g. Brand Identity 2026" value={addForm.name} onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
|
||||
</div>
|
||||
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {addError}</div>}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Creating...' : 'Create Project'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
{teamCompanies.length > 0 && (
|
||||
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
|
||||
<option value="">All Companies</option>
|
||||
{teamCompanies.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
|
||||
<select className="filter-select" style={{ width: 120 }} value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="active">Active ({activeCount})</option>
|
||||
<option value="completed">Completed ({completedCount})</option>
|
||||
<option value="all">All ({allCount})</option>
|
||||
</select>
|
||||
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
|
||||
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statusFiltered.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No projects found.</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
|
||||
{statusFiltered.map(p => (
|
||||
<div key={p.id} className="grid-card" onClick={() => navigate(`/projects/${p.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: filterCompany ? 0 : 2 }}>{p.name}</div>
|
||||
{!filterCompany && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{p.company?.name || '—'}</div>}
|
||||
</div>
|
||||
<StatusBadge status={p.status || 'active'} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 10, textAlign: 'right' }}>Created {fmtDate(p.created_at)}</div>
|
||||
<ProgressBar tasks={p.tasks || []} noPad />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<table style={{ tableLayout: 'fixed', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: filterCompany ? '30%' : '25%' }} />
|
||||
{!filterCompany && <col style={{ width: '20%' }} />}
|
||||
<col style={{ width: filterCompany ? '35%' : '25%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '20%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={projToggle}>Project</SortTh>
|
||||
{!filterCompany && <SortTh col="client" sortKey={projSortKey} sortDir={projSortDir} onSort={projToggle}>Client</SortTh>}
|
||||
<th>Progress</th>
|
||||
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={projToggle}>Status</SortTh>
|
||||
<SortTh col="created_at" sortKey={projSortKey} sortDir={projSortDir} onSort={projToggle}>Date Created</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statusFiltered.map(p => (
|
||||
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
||||
<td style={{ fontWeight: 400 }}>{p.name}</td>
|
||||
{!filterCompany && <td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>}
|
||||
<td><ProgressBar tasks={p.tasks || []} /></td>
|
||||
<td><StatusBadge status={p.status} /></td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmtDate(p.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ── External render ────────────────────────────────────────────────────
|
||||
if (isExternal) {
|
||||
const extBase = filterStatus === 'all' ? projects : projects.filter(p => (p.status || 'active') === filterStatus);
|
||||
const extFiltered = extSort(extBase, (p, key) => key === 'client' ? (p.company?.name || '') : p[key] || '');
|
||||
const extActiveCount = projects.filter(p => !p.status || p.status === 'active').length;
|
||||
const extCompletedCount = projects.filter(p => p.status === 'completed').length;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header" style={{ flexShrink: 0 }}>
|
||||
<div className="page-header-left">
|
||||
<DashboardBanner />
|
||||
<div className="page-title dashboard-greeting">Projects</div>
|
||||
<div className="page-subtitle">{extActiveCount} active • {extCompletedCount} completed</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="card" style={{ color: 'var(--danger)', marginBottom: 16, flexShrink: 0 }}>{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
|
||||
<select className="filter-select" style={{ width: 120 }} value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="active">Active ({extActiveCount})</option>
|
||||
<option value="completed">Completed ({extCompletedCount})</option>
|
||||
<option value="all">All ({projects.length})</option>
|
||||
</select>
|
||||
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
|
||||
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No projects yet. Team will assign you to one.</div>
|
||||
) : extFiltered.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {filterStatus} projects.</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
|
||||
{extFiltered.map(p => (
|
||||
<div key={p.id} className="grid-card" onClick={() => navigate(`/projects/${p.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>{p.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{p.company?.name || '—'}</div>
|
||||
</div>
|
||||
<StatusBadge status={p.status || 'active'} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 10, textAlign: 'right' }}>Created {fmtDate(p.created_at)}</div>
|
||||
<ProgressBar tasks={p.tasks || []} noPad />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<table style={{ tableLayout: 'fixed', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '25%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '30%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Project</SortTh>
|
||||
<SortTh col="client" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Client</SortTh>
|
||||
<th>Progress</th>
|
||||
<SortTh col="status" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Status</SortTh>
|
||||
<SortTh col="created_at" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Date Created</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{extFiltered.map(p => (
|
||||
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
||||
<td style={{ fontWeight: 400 }}>{p.name}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>
|
||||
<td><ProgressBar tasks={p.tasks || []} /></td>
|
||||
<td><StatusBadge status={p.status || 'active'} /></td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmtDate(p.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Client render ──────────────────────────────────────────────────────
|
||||
const clientBase = companies.length > 1 && activeCompanyId
|
||||
? projects.filter(p => p.company_id === activeCompanyId)
|
||||
: projects;
|
||||
const clientFiltered = clientSort(
|
||||
filterStatus === 'all' ? clientBase : clientBase.filter(p => (p.status || 'active') === filterStatus),
|
||||
(p, key) => p[key] || ''
|
||||
);
|
||||
const clientActiveCount = clientBase.filter(p => !p.status || p.status === 'active').length;
|
||||
const clientCompletedCount = clientBase.filter(p => p.status === 'completed').length;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header" style={{ flexShrink: 0 }}>
|
||||
<div className="page-header-left">
|
||||
<DashboardBanner />
|
||||
<div className="page-title dashboard-greeting">Projects</div>
|
||||
<div className="page-subtitle">{clientActiveCount} active • {clientCompletedCount} completed</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(true); setAddError(''); if (companies.length === 1) setAddForm(f => ({ ...f, companyId: companies[0].id })); }}>
|
||||
+ Add Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 480, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>New Project</div>
|
||||
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Add a project</div>
|
||||
</div>
|
||||
<button onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
|
||||
</div>
|
||||
<form onSubmit={handleAddProject}>
|
||||
{companies.length > 1 && (
|
||||
<div className="form-group">
|
||||
<label>Company *</label>
|
||||
<select value={addForm.companyId} onChange={e => setAddForm(f => ({ ...f, companyId: e.target.value }))} required>
|
||||
<option value="">Select company...</option>
|
||||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>Project Name *</label>
|
||||
<input type="text" placeholder="e.g. Brand Identity 2026" value={addForm.name} onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
|
||||
</div>
|
||||
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {addError}</div>}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Creating...' : 'Create Project'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
{companies.length > 1 && (
|
||||
<select className="filter-select" style={{ width: 120 }} value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)}>
|
||||
<option value="">All Companies</option>
|
||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
|
||||
<select className="filter-select" style={{ width: 120 }} value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="active">Active ({clientActiveCount})</option>
|
||||
<option value="completed">Completed ({clientCompletedCount})</option>
|
||||
<option value="all">All ({clientBase.length})</option>
|
||||
</select>
|
||||
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
|
||||
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{clientBase.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No projects yet.</div>
|
||||
) : clientFiltered.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {filterStatus} projects.</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
|
||||
{clientFiltered.map(p => {
|
||||
const projectTasks = tasks.filter(t => t.project_id === p.id);
|
||||
const co = companies.find(c => c.id === p.company_id);
|
||||
return (
|
||||
<div key={p.id} className="grid-card" onClick={() => navigate(`/projects/${p.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>{p.name}</div>
|
||||
{co && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{co.name}</div>}
|
||||
</div>
|
||||
<StatusBadge status={p.status || 'active'} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 10, textAlign: 'right' }}>Created {fmtDate(p.created_at)}</div>
|
||||
<ProgressBar tasks={projectTasks} noPad />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<table style={{ tableLayout: 'fixed', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '30%' }} />
|
||||
<col style={{ width: '35%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '20%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Project</SortTh>
|
||||
<th>Progress</th>
|
||||
<SortTh col="status" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Status</SortTh>
|
||||
<SortTh col="created_at" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Date Created</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clientFiltered.map(p => {
|
||||
const projectTasks = tasks.filter(t => t.project_id === p.id);
|
||||
return (
|
||||
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
||||
<td style={{ fontWeight: 400 }}>{p.name}</td>
|
||||
<td><ProgressBar tasks={projectTasks} /></td>
|
||||
<td><StatusBadge status={p.status || 'active'} /></td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmtDate(p.created_at)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import JSZip from 'jszip';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||
import { sendEmail } from '../lib/email';
|
||||
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
|
||||
|
||||
const ACTION_LABEL = {
|
||||
task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold',
|
||||
task_resumed: 'resumed this task', task_submitted: 'submitted this task', task_approved: 'approved this task',
|
||||
revision_requested: 'rejected this task', request_submitted: 'submitted this request',
|
||||
};
|
||||
|
||||
const ACTION_ICON = {
|
||||
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: 'M6 4l14 8-14 8V4z' },
|
||||
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: 'M6 4l14 8-14 8V4z' },
|
||||
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M12 19V5M5 12l7-7 7 7' },
|
||||
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M4 13l5 5L20 7' },
|
||||
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: 'M6 4h4v16H6zM14 4h4v16h-4z' },
|
||||
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M12 5v14M5 12h14' },
|
||||
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: 'M4 12a8 8 0 018-8v0a8 8 0 018 8' },
|
||||
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M9 12h6M9 8h6M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z' },
|
||||
};
|
||||
|
||||
function ActivityItem({ e }) {
|
||||
const icon = ACTION_ICON[e.action] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: null };
|
||||
const label = ACTION_LABEL[e.action] || e.action;
|
||||
const actor = e.actor_name || 'Fourge';
|
||||
const d = new Date(e.created_at);
|
||||
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: icon.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 1 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={icon.color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
{icon.path && <path d={icon.path} />}
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.4 }}>
|
||||
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{actor}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}> {label}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{dateStr} · {timeStr}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CARD = { padding: '18px 21px', borderRadius: 8 };
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
|
||||
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
|
||||
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
|
||||
const TAB_STYLE = (active) => ({
|
||||
fontFamily: "Fourge, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
fontSize: 13, fontWeight: 500, letterSpacing: 0.2, padding: '2px 0 5px', margin: '0 8px',
|
||||
borderRadius: 0, border: 'none', borderBottom: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
cursor: 'pointer', background: 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)', transition: 'all 160ms',
|
||||
});
|
||||
|
||||
const TABS = ['Overview', 'Revisions', 'Comments', 'Folder'];
|
||||
|
||||
function TimelineCard({ task, activityLog, currentSubmission }) {
|
||||
const status = task?.status;
|
||||
let currentStage;
|
||||
if (['client_approved', 'invoiced', 'paid'].includes(status)) currentStage = 4;
|
||||
else if (status === 'client_review') currentStage = 2;
|
||||
else if (status === 'in_progress' || status === 'on_hold') currentStage = 1;
|
||||
else currentStage = 0;
|
||||
|
||||
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : null;
|
||||
|
||||
// Only look at activity from the current revision cycle
|
||||
const lastRevision = activityLog.find(e => e.action === 'revision_requested');
|
||||
const cutoff = lastRevision ? new Date(lastRevision.created_at).getTime() : 0;
|
||||
const currentLog = activityLog.filter(e => new Date(e.created_at).getTime() > cutoff);
|
||||
|
||||
const startedEntry = [...currentLog].reverse().find(e => e.action === 'task_started');
|
||||
const reviewEntry = [...currentLog].reverse().find(e => e.action === 'task_submitted');
|
||||
|
||||
const stages = [
|
||||
{ label: 'Task Created', date: fmtDate(currentSubmission?.submitted_at || task?.submitted_at) },
|
||||
{ label: 'In Progress', date: fmtDate(startedEntry?.created_at) },
|
||||
{ label: 'In Review', date: fmtDate(reviewEntry?.created_at) },
|
||||
{ label: 'Approved', date: fmtDate(task?.completed_at) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="card" style={CARD}>
|
||||
<div style={LABEL}>Timeline</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{stages.map((stage, i) => {
|
||||
const isDone = i < currentStage;
|
||||
const isCurrent = i === currentStage;
|
||||
let circle;
|
||||
if (isDone) {
|
||||
circle = (
|
||||
<div style={{ width: 22, height: 22, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>
|
||||
</div>
|
||||
);
|
||||
} else if (isCurrent) {
|
||||
circle = (
|
||||
<div style={{ width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
circle = <div style={{ width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--border)', flexShrink: 0 }} />;
|
||||
}
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
{circle}
|
||||
{i < stages.length - 1 && (
|
||||
<div style={{ width: 1, height: 26, background: isDone ? 'var(--accent)' : 'var(--border)', margin: '3px 0' }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ paddingTop: 2, paddingBottom: i < stages.length - 1 ? 0 : 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 500, color: isDone || isCurrent ? 'var(--text-primary)' : 'var(--text-muted)' }}>{stage.label}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1, marginBottom: 8 }}>{stage.date || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssigneePortrait({ name, avatarUrl }) {
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}>
|
||||
<img src={avatarUrl} alt={name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const initials = (name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
return (
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fmtSize = (bytes) => {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const FILE_EXT_COLOR = { pdf: '#ef4444', jpg: '#f59e0b', jpeg: '#f59e0b', png: '#3b82f6', gif: '#8b5cf6', svg: '#10b981', ai: '#f97316', psd: '#3b82f6', zip: '#6b7280', rar: '#6b7280', doc: '#2563eb', docx: '#2563eb', xls: '#16a34a', xlsx: '#16a34a', mp4: '#ec4899', mov: '#ec4899' };
|
||||
const getExt = (name = '') => (name.split('.').pop() || '').toLowerCase();
|
||||
|
||||
function SubmissionFiles({ files, downloading, onDownloadAll }) {
|
||||
if (!files || files.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={META_LABEL}>Attached Files ({files.length})</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{files.map((file) => {
|
||||
const ext = getExt(file.name);
|
||||
const color = FILE_EXT_COLOR[ext] || 'var(--text-muted)';
|
||||
const label = ext ? ext.toUpperCase() : '—';
|
||||
return (
|
||||
<div key={file.id} title={`${file.name}${file.size ? ' · ' + fmtSize(file.size) : ''}`} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3, width: 40 }}>
|
||||
<div style={{ width: 32, height: 38, borderRadius: 5, background: 'var(--card-bg-2)', border: '1px solid var(--border)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<span style={{ fontSize: 6, fontWeight: 700, color, letterSpacing: 0.3 }}>{label}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text-muted)', width: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>{file.name.replace(/\.[^.]+$/, '')}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
disabled={Boolean(downloading)}
|
||||
onClick={() => onDownloadAll(files)}
|
||||
style={{ marginTop: 10, fontSize: 10, padding: '2px 10px', letterSpacing: 0.6, textTransform: 'uppercase' }}
|
||||
>
|
||||
{downloading === 'all' ? 'Downloading…' : 'Download All'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskDetail() {
|
||||
const { id } = useParams();
|
||||
const { currentUser } = useAuth();
|
||||
const [task, setTask] = useState(null);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [activityLog, setActivityLog] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('Overview');
|
||||
const [statusSaving, setStatusSaving] = useState(false);
|
||||
const [downloading, setDownloading] = useState('');
|
||||
const [revisionModal, setRevisionModal] = useState(false);
|
||||
const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' });
|
||||
const [revisionFiles, setRevisionFiles] = useState([]);
|
||||
const [revisionSaving, setRevisionSaving] = useState(false);
|
||||
const revisionFileRef = useRef(null);
|
||||
const [amendModal, setAmendModal] = useState(false);
|
||||
const [amendForm, setAmendForm] = useState({ description: '' });
|
||||
const [amendFiles, setAmendFiles] = useState([]);
|
||||
const [amendSaving, setAmendSaving] = useState(false);
|
||||
const [amendDragging, setAmendDragging] = useState(false);
|
||||
const amendDragCounter = useRef(0);
|
||||
const amendFileRef = useRef(null);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [commentBody, setCommentBody] = useState('');
|
||||
const [commentSaving, setCommentSaving] = useState(false);
|
||||
const [dlProgress, setDlProgress] = useState({ active: false, label: '', percent: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
|
||||
.eq('id', id)
|
||||
.single(),
|
||||
supabase
|
||||
.from('submissions')
|
||||
.select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)')
|
||||
.eq('task_id', id)
|
||||
.order('submitted_at', { ascending: true }),
|
||||
supabase
|
||||
.from('activity_log')
|
||||
.select('id, created_at, actor_id, actor_name, action, task_id, task_title')
|
||||
.eq('task_id', id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20),
|
||||
supabase
|
||||
.from('task_comments')
|
||||
.select('id, author_id, author_name, body, created_at')
|
||||
.eq('task_id', id)
|
||||
.order('created_at', { ascending: true }),
|
||||
]).then(([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => {
|
||||
setTask(taskData);
|
||||
setSubmissions(subRows || []);
|
||||
setActivityLog(actData || []);
|
||||
setComments(commentData || []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Layout><PageLoader /></Layout>;
|
||||
if (!task) return <Layout><p style={{ padding: 24 }}>Task not found.</p></Layout>;
|
||||
|
||||
const submission = submissions.find(s => s.type === 'initial') || submissions[0] || null;
|
||||
const amendments = submissions.filter(s => s.type === 'amendment');
|
||||
const revisions = submissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
|
||||
|
||||
const assignee = task.assignee;
|
||||
const assigneeName = assignee?.name || task.assigned_name || null;
|
||||
const assigneeAvatar = assignee?.avatar_url || null;
|
||||
const project = task.project;
|
||||
const company = project?.company;
|
||||
const requester = submission?.submitted_by_name || null;
|
||||
const role = currentUser?.role;
|
||||
const isClient = role === 'client';
|
||||
const isTeamOrSub = role === 'team' || role === 'external';
|
||||
const status = task.status;
|
||||
|
||||
const log = (action) => logActivity({ actorId: currentUser.id, actorName: currentUser.name, action, taskId: id, taskTitle: task?.title, projectId: task?.project?.id, projectName: task?.project?.name });
|
||||
|
||||
const updateStatus = async (newStatus, extra = {}, action = null) => {
|
||||
setStatusSaving(true);
|
||||
await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id);
|
||||
setTask(t => ({ ...t, status: newStatus, ...extra }));
|
||||
if (action) {
|
||||
await log(action);
|
||||
const { data } = await supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action').eq('task_id', id).order('created_at', { ascending: false }).limit(20);
|
||||
setActivityLog(data || []);
|
||||
}
|
||||
setStatusSaving(false);
|
||||
};
|
||||
|
||||
const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started');
|
||||
const handleOnHold = () => updateStatus('on_hold', {}, 'task_on_hold');
|
||||
const handleResume = () => updateStatus('in_progress', {}, 'task_resumed');
|
||||
const handleSendToReview = () => updateStatus('client_review', {}, 'task_submitted');
|
||||
const handleClientApprove = () => updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
|
||||
const handleClientReject = () => updateStatus('not_started', { current_version: (task.current_version || 0) + 1, assigned_to: null, assigned_name: null }, 'revision_requested');
|
||||
|
||||
const handleSubmitRevision = async () => {
|
||||
setRevisionSaving(true);
|
||||
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
|
||||
const newVersion = baseline + 1;
|
||||
await supabase.from('tasks').update({ status: 'not_started', current_version: newVersion, assigned_to: null, assigned_name: null }).eq('id', id);
|
||||
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: newVersion, type: 'revision', revision_type: 'client_revision', service_type: task.title, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
|
||||
if (newSub && revisionFiles.length > 0) {
|
||||
const uploadedFiles = [];
|
||||
for (const file of revisionFiles) {
|
||||
const path = `${id}/${newVersion}/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
|
||||
}
|
||||
if (uploadedFiles.length > 0) await supabase.from('submission_files').insert(uploadedFiles.map(f => ({ ...f, submission_id: newSub.id })));
|
||||
if (project?.name && task?.title) uploadFilesToRequestInfo(revisionFiles, company?.name, project.name, task.title, newVersion).catch(() => {});
|
||||
}
|
||||
await logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'revision_requested', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
|
||||
sendEmail('revision_submitted', ['hello@fourgebranding.com'], { clientName: currentUser.name, serviceType: task.title, projectName: project?.name, version: `R${String(newVersion).padStart(2, '0')}`, deadline: revisionForm.deadline, description: revisionForm.description, taskId: id }).catch(() => {});
|
||||
const [{ data: taskData }, { data: subRows }, { data: actData }] = await Promise.all([
|
||||
supabase.from('tasks').select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)').eq('id', id).single(),
|
||||
supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true }),
|
||||
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title').eq('task_id', id).order('created_at', { ascending: false }).limit(20),
|
||||
]);
|
||||
setTask(taskData);
|
||||
setSubmissions(subRows || []);
|
||||
setActivityLog(actData || []);
|
||||
setRevisionModal(false);
|
||||
setRevisionForm({ description: '', deadline: '' });
|
||||
setRevisionFiles([]);
|
||||
setRevisionSaving(false);
|
||||
};
|
||||
|
||||
const handleSubmitAmendment = async () => {
|
||||
if (!amendForm.description.trim()) return;
|
||||
setAmendSaving(true);
|
||||
setAmendModal(false);
|
||||
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
|
||||
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: baseline, type: 'amendment', service_type: task.title, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
|
||||
if (newSub && amendFiles.length > 0) {
|
||||
setDlProgress({ active: true, label: 'Uploading files…', percent: 0 });
|
||||
const uploaded = [];
|
||||
for (let i = 0; i < amendFiles.length; i++) {
|
||||
const file = amendFiles[i];
|
||||
setDlProgress({ active: true, label: file.name, percent: Math.round((i / amendFiles.length) * 100) });
|
||||
const path = `${id}/${Date.now()}_${file.name}`;
|
||||
const { data: up } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size });
|
||||
}
|
||||
if (uploaded.length > 0) await supabase.from('submission_files').insert(uploaded.map(f => ({ ...f, submission_id: newSub.id })));
|
||||
if (project?.name && task?.title) uploadFilesToRequestInfo(amendFiles, company?.name, project.name, task.title, baseline).catch(() => {});
|
||||
setDlProgress({ active: false, label: '', percent: 0 });
|
||||
}
|
||||
const { data: subRows } = await supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true });
|
||||
setSubmissions(subRows || []);
|
||||
setAmendForm({ description: '' });
|
||||
setAmendFiles([]);
|
||||
setAmendSaving(false);
|
||||
};
|
||||
|
||||
const handlePostComment = async () => {
|
||||
if (!commentBody.trim()) return;
|
||||
setCommentSaving(true);
|
||||
const { data } = await supabase.from('task_comments').insert({ task_id: id, author_id: currentUser.id, author_name: currentUser.name, body: commentBody.trim() }).select().single();
|
||||
if (data) setComments(prev => [...prev, data]);
|
||||
setCommentBody('');
|
||||
setCommentSaving(false);
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (commentId) => {
|
||||
await supabase.from('task_comments').delete().eq('id', commentId);
|
||||
setComments(prev => prev.filter(c => c.id !== commentId));
|
||||
};
|
||||
|
||||
const downloadAllFiles = async (files, label = 'files') => {
|
||||
if (downloading) return;
|
||||
setDownloading('all');
|
||||
setDlProgress({ active: true, label: 'Preparing…', percent: 0 });
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
setDlProgress({ active: true, label: file.name, percent: Math.round((i / (files.length + 1)) * 100) });
|
||||
try {
|
||||
const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600, { download: file.name });
|
||||
if (data?.signedUrl) {
|
||||
const response = await fetch(data.signedUrl);
|
||||
if (response.ok) zip.file(file.name, await response.blob());
|
||||
}
|
||||
} catch { /* skip bad file */ }
|
||||
}
|
||||
setDlProgress({ active: true, label: 'Building zip…', percent: 95 });
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
const zipName = `${task?.title || 'files'} ${label}.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);
|
||||
} finally {
|
||||
setDlProgress({ active: false, label: '', percent: 0 });
|
||||
setDownloading('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
|
||||
<div className="card" style={{ ...CARD, position: 'relative', ...(submission?.is_hot ? { background: 'radial-gradient(ellipse 90% 55% at 50% 120%, rgba(239,80,10,0.18) 0%, rgba(245,165,35,0.09) 45%, transparent 70%), var(--card-bg)' } : {}) }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
{isTeamOrSub && status === 'not_started' && (
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleStart}>Start Task</button>
|
||||
)}
|
||||
{isTeamOrSub && status === 'in_progress' && (
|
||||
<>
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleOnHold}>Place on Hold</button>
|
||||
<button className="btn btn-outline" style={{ fontSize: 12, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={statusSaving} onClick={handleSendToReview}>Place in Review</button>
|
||||
</>
|
||||
)}
|
||||
{isTeamOrSub && status === 'on_hold' && (
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleResume}>Resume</button>
|
||||
)}
|
||||
{isClient && status === 'client_review' && (
|
||||
<>
|
||||
<button className="btn btn-outline" style={{ fontSize: 12, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={statusSaving} onClick={handleClientApprove}>Approve</button>
|
||||
<button className="btn btn-danger" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleClientReject}>Reject</button>
|
||||
</>
|
||||
)}
|
||||
{isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && (
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => setRevisionModal(true)}>Request Revision</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
|
||||
{submission?.service_type && (
|
||||
<span className="badge badge-status" style={{ background: 'rgba(245,165,35,0.12)', color: 'var(--accent)', border: '1px solid rgba(245,165,35,0.25)', minWidth: 'unset' }}>{submission.service_type}</span>
|
||||
)}
|
||||
<StatusBadge status={task.status || 'not_started'} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 30, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>
|
||||
<div>
|
||||
<div style={CARD_META_LABEL}>Task ID</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{task.task_number || task.id.slice(0, 8).toUpperCase()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><polyline points="17,1 21,5 17,9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7,23 3,19 7,15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
<div>
|
||||
<div style={CARD_META_LABEL}>Revision</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{(task.current_version ?? 0) === 0 ? 'New' : String(task.current_version).padStart(2, '0')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<div>
|
||||
<div style={CARD_META_LABEL}>Project</div>
|
||||
{project
|
||||
? <Link to={`/projects/${project.id}`} className="table-link" style={{ fontSize: 13 }}>{project.name}</Link>
|
||||
: <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>—</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<div>
|
||||
<div style={CARD_META_LABEL}>Requested</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{submission?.submitted_at || task.submitted_at
|
||||
? new Date(submission?.submitted_at || task.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></svg>
|
||||
<div>
|
||||
<div style={CARD_META_LABEL}>Due Date</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{submission?.deadline
|
||||
? new Date(submission.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
|
||||
{TABS.map(tab => {
|
||||
const count = tab === 'Revisions' ? revisions.length : tab === 'Comments' ? comments.length : 0;
|
||||
return (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)} style={TAB_STYLE(activeTab === tab)}>
|
||||
{tab}{count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === tab ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
{activeTab === 'Overview' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
|
||||
<div>
|
||||
<div style={META_LABEL}>Requested By</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{requester || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Requested</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{submission?.submitted_at ? new Date(submission.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Sign Count</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission?.sign_count > 0 ? submission.sign_count : 'N/A'}</div>
|
||||
</div>
|
||||
{isClient && ['not_started', 'in_progress'].includes(status) && (
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => setAmendModal(true)}>Amend Request</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{submission?.description && (
|
||||
<div>
|
||||
<div style={META_LABEL}>Notes</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{submission.description}</div>
|
||||
</div>
|
||||
)}
|
||||
{submission?.sign_family && (
|
||||
<div>
|
||||
<div style={META_LABEL}>Sign Family</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission.sign_family}</div>
|
||||
</div>
|
||||
)}
|
||||
<SubmissionFiles files={submission?.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
|
||||
{amendments.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: 0.8, color: 'var(--text-secondary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 4, padding: '2px 8px', textTransform: 'uppercase' }}>Amendment</span>
|
||||
</div>
|
||||
{amendments.map((am) => (
|
||||
<div key={am.id} style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{am.description && (
|
||||
<div>
|
||||
<div style={META_LABEL}>Notes</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{am.description}</div>
|
||||
</div>
|
||||
)}
|
||||
<SubmissionFiles files={am.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'Revisions' && (
|
||||
revisions.length === 0
|
||||
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)' }}>No revisions yet.</div>
|
||||
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{[...revisions].reverse().map((rev, i, arr) => {
|
||||
const rNum = `R${String(rev.version_number).padStart(2, '0')}`;
|
||||
const fmtDate = rev.submitted_at
|
||||
? new Date(rev.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—';
|
||||
return (
|
||||
<div key={rev.id} style={{ display: 'flex', flexDirection: 'column', gap: 20, borderBottom: i < arr.length - 1 ? '1px solid var(--border)' : 'none', paddingBottom: i < arr.length - 1 ? 20 : 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
|
||||
<div>
|
||||
<div style={META_LABEL}>Requested By</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.submitted_by_name || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Requested</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Sign Count</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_count > 0 ? rev.sign_count : 'N/A'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Revision</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rNum}</div>
|
||||
</div>
|
||||
{isClient && i === 0 && ['not_started', 'in_progress'].includes(status) && (
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => setAmendModal(true)}>Amend</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{rev.description && (
|
||||
<div>
|
||||
<div style={META_LABEL}>Description</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{rev.description}</div>
|
||||
</div>
|
||||
)}
|
||||
{rev.sign_family && (
|
||||
<div>
|
||||
<div style={META_LABEL}>Sign Family</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_family}</div>
|
||||
</div>
|
||||
)}
|
||||
{!rev.description && !rev.sign_family && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No notes provided.</div>
|
||||
)}
|
||||
<SubmissionFiles files={rev.files} downloading={downloading} onDownloadAll={(f) => downloadAllFiles(f, rNum)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'Comments' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Write a comment…"
|
||||
value={commentBody}
|
||||
onChange={e => setCommentBody(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handlePostComment(); }}
|
||||
style={{ flex: 1, fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', fontFamily: 'inherit', height: 30 }}
|
||||
/>
|
||||
<button className="btn btn-outline" disabled={commentSaving || !commentBody.trim()} onClick={handlePostComment} style={{ fontSize: 11, padding: '0 12px', alignSelf: 'flex-end', height: 30 }}>Post</button>
|
||||
</div>
|
||||
{comments.length === 0
|
||||
? <div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No comments yet.</div>
|
||||
: comments.map((c, i) => {
|
||||
const d = new Date(c.created_at);
|
||||
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
const isOwn = c.author_id === currentUser?.id;
|
||||
return (
|
||||
<div key={c.id} style={{ borderTop: '1px solid var(--border)', paddingTop: 20, paddingBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{c.author_name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{dateStr} · {timeStr}</span>
|
||||
</div>
|
||||
{isOwn && (
|
||||
<button className="btn btn-danger" onClick={() => handleDeleteComment(c.id)} style={{ fontSize: 11, padding: '0 10px', height: 26 }}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{c.body}</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'Folder' && <div style={{ color: 'var(--text-muted)' }}>Folder view coming soon.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
|
||||
<div className="card" style={CARD}>
|
||||
<div style={LABEL}>Assigned To</div>
|
||||
{assigneeName ? (
|
||||
<Link to={`/profile/${assignee?.id}`} className="dashboard-inline-link" style={{ display: 'flex', alignItems: 'center', gap: 10, textDecoration: 'none' }}>
|
||||
<AssigneePortrait name={assigneeName} avatarUrl={assigneeAvatar} />
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{assigneeName}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Unassigned</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TimelineCard task={task} activityLog={activityLog} currentSubmission={submission} />
|
||||
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={LABEL}>Activity</div>
|
||||
{activityLog.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No activity yet.</div>
|
||||
) : (
|
||||
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{activityLog.map(e => (
|
||||
<ActivityItem key={e.id} e={e} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{revisionModal && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Revision Notes</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
placeholder="Describe what needs to be changed…"
|
||||
value={revisionForm.description}
|
||||
onChange={e => setRevisionForm(f => ({ ...f, description: e.target.value }))}
|
||||
style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Deadline</label>
|
||||
<input
|
||||
type="date"
|
||||
value={revisionForm.deadline}
|
||||
onChange={e => setRevisionForm(f => ({ ...f, deadline: e.target.value }))}
|
||||
style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', display: 'block', marginBottom: 6 }}>Attach Files</label>
|
||||
<input ref={revisionFileRef} type="file" multiple style={{ display: 'none' }} onChange={e => setRevisionFiles(prev => [...prev, ...Array.from(e.target.files)])} />
|
||||
<button type="button" className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => revisionFileRef.current?.click()}>+ Add Files</button>
|
||||
{revisionFiles.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{revisionFiles.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<span>{f.name}</span>
|
||||
<button type="button" onClick={() => setRevisionFiles(prev => prev.filter((_, idx) => idx !== i))} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 14, padding: '0 4px' }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
|
||||
{revisionSaving ? 'Submitting…' : 'Submit Revision'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{amendModal && (
|
||||
<div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes</div>
|
||||
<textarea rows={4} placeholder="Describe what you'd like to add or change…" value={amendForm.description} onChange={e => setAmendForm(f => ({ ...f, description: e.target.value }))} style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attach Files</div>
|
||||
<input ref={amendFileRef} type="file" multiple id="amend-file-input" style={{ display: 'none' }} onChange={e => { setAmendFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = ''; }} />
|
||||
<div
|
||||
onDragEnter={e => { e.preventDefault(); amendDragCounter.current++; setAmendDragging(true); }}
|
||||
onDragLeave={e => { e.preventDefault(); amendDragCounter.current--; if (amendDragCounter.current === 0) setAmendDragging(false); }}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={e => { e.preventDefault(); amendDragCounter.current = 0; setAmendDragging(false); setAmendFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); }}
|
||||
style={{ border: `2px dashed ${amendDragging ? 'var(--accent)' : amendFiles.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '16px', textAlign: 'center', background: amendDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent', transition: 'all 160ms', cursor: 'pointer' }}
|
||||
onClick={() => amendFileRef.current?.click()}
|
||||
>
|
||||
<div style={{ fontSize: 20, marginBottom: 4 }}>{amendDragging ? '📂' : '📎'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{amendDragging ? 'Drop files here' : amendFiles.length > 0 ? `${amendFiles.length} file${amendFiles.length !== 1 ? 's' : ''} attached — click or drag to add more` : 'Click or drag files here'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
|
||||
</div>
|
||||
{amendFiles.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{amendFiles.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{f.name}</span>
|
||||
<button type="button" className="btn btn-danger" onClick={() => setAmendFiles(prev => prev.filter((_, idx) => idx !== i))} style={{ fontSize: 10, padding: '1px 8px', height: 22 }}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
|
||||
<button className="btn btn-outline" disabled={amendSaving} onClick={handleSubmitAmendment}>{amendSaving ? 'Saving…' : 'Save'}</button>
|
||||
<button className="btn btn-outline" disabled={amendSaving} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dlProgress.active && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 340, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Downloading</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{dlProgress.label}</div>
|
||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${dlProgress.percent}%`, transition: 'width 200ms ease' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'right' }}>{dlProgress.percent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
+39
-15
@@ -8,6 +8,7 @@ import { useAuth } from '../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||
import { withTimeout } from '../lib/withTimeout';
|
||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import { fmtShortDate } from '../lib/dates';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
|
||||
import { sendEmail } from '../lib/email';
|
||||
@@ -31,21 +32,20 @@ const TASK_MODAL_TITLE_STYLE = { fontSize: 11, fontWeight: 500, textTransform: '
|
||||
const TASK_MODAL_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
|
||||
const TASK_MODAL_INPUT_STYLE = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
|
||||
const TASK_MODAL_BUTTON_STYLE = { lineHeight: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' };
|
||||
const TASK_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(434px, 100%)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
|
||||
const TASK_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(720px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
|
||||
const PROJECT_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(434px, 100%)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
|
||||
const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, client_review: 3, client_approved: 4, invoiced: 5, paid: 6 };
|
||||
|
||||
function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 21px', minHeight: 86 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{label}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -202,6 +202,7 @@ export default function RequestsPage() {
|
||||
const [addFormKey, setAddFormKey] = useState(0);
|
||||
const [addSaving, setAddSaving] = useState(false);
|
||||
const [addError, setAddError] = useState('');
|
||||
const [uploadProgress, setUploadProgress] = useState({ active: false, label: '', percent: 0 });
|
||||
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
|
||||
const [showAddProjectForm, setShowAddProjectForm] = useState(false);
|
||||
const [addProjectSaving, setAddProjectSaving] = useState(false);
|
||||
@@ -317,10 +318,13 @@ export default function RequestsPage() {
|
||||
createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
const { submission: sub } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, deadline: formData.deadline,
|
||||
serviceType: formData.serviceType, signFamily: formData.signFamily,
|
||||
signCount: formData.signCount, signs: formData.signs,
|
||||
deadline: formData.deadline,
|
||||
description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName,
|
||||
});
|
||||
if (!sub) throw new Error('Failed to create submission.');
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
|
||||
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
|
||||
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at').order('submitted_at', { ascending: false }),
|
||||
@@ -343,21 +347,29 @@ export default function RequestsPage() {
|
||||
createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
const { submission } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, deadline: formData.deadline,
|
||||
serviceType: formData.serviceType, signFamily: formData.signFamily,
|
||||
signCount: formData.signCount, signs: formData.signs,
|
||||
deadline: formData.deadline,
|
||||
description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name,
|
||||
});
|
||||
if (submission && files.length > 0) {
|
||||
for (const file of files) {
|
||||
setShowAddForm(false);
|
||||
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
|
||||
for (let fi = 0; fi < files.length; fi++) {
|
||||
const file = files[fi];
|
||||
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
|
||||
const path = `${task.id}/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); throw new Error(`Upload failed: ${uploadError.message}`); }
|
||||
if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`Upload failed: ${uploadError.message}`); }
|
||||
if (uploaded) {
|
||||
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
|
||||
if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); throw new Error(`File record failed: ${fileErr.message}`); }
|
||||
if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`File record failed: ${fileErr.message}`); }
|
||||
}
|
||||
}
|
||||
uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
setUploadProgress({ active: false, label: '', percent: 0 });
|
||||
}
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
|
||||
sendEmail('new_request', 'hello@fourgebranding.com', {
|
||||
clientName: currentUser.name, clientEmail: currentUser.email,
|
||||
company: selectedCompany?.name || '', serviceType: formData.serviceType,
|
||||
@@ -501,7 +513,7 @@ export default function RequestsPage() {
|
||||
<tr key={row.id}>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Link to={`/requests/${row.id}`} className="table-link">{row.title || '—'}</Link>
|
||||
<Link to={`/requests/${row.id}/v2`} className="table-link">{row.title || '—'}</Link>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{`R${String(row.version).padStart(2, '0')}`}</span>
|
||||
{row.isHot && <span className="badge badge-needs_revision">HOT</span>}
|
||||
</div>
|
||||
@@ -510,13 +522,13 @@ export default function RequestsPage() {
|
||||
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||
<Link to={`/requests/${row.id}`} className="table-link">{row.serviceType}</Link>
|
||||
<Link to={`/requests/${row.id}/v2`} className="table-link">{row.serviceType}</Link>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||
<Link to={`/requests/${row.id}`} className="table-link">{fmtShortDate(row.deadline, 'Not specified')}</Link>
|
||||
<Link to={`/requests/${row.id}/v2`} className="table-link">{fmtShortDate(row.deadline, 'Not specified')}</Link>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
|
||||
<Link to={`/requests/${row.id}`} className="table-link"><StatusBadge status={row.status || 'not_started'} /></Link>
|
||||
<Link to={`/requests/${row.id}/v2`} className="table-link"><StatusBadge status={row.status || 'not_started'} /></Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -541,7 +553,7 @@ export default function RequestsPage() {
|
||||
{/* Add project modal */}
|
||||
{showAddProjectForm && (
|
||||
<div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>
|
||||
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
||||
<div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
|
||||
<div style={TASK_MODAL_TITLE_STYLE}>Add Project</div>
|
||||
</div>
|
||||
@@ -673,6 +685,18 @@ export default function RequestsPage() {
|
||||
</div>
|
||||
{error && <div style={{ color: 'var(--danger)', marginTop: 16, fontSize: 13 }}>{error}</div>}
|
||||
</TasksPageShell>
|
||||
{uploadProgress.active && (
|
||||
<div style={{ ...popupOverlayStyle, zIndex: 1200 }}>
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', width: '100%', maxWidth: 340, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Uploading</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{uploadProgress.label}</div>
|
||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${uploadProgress.percent}%`, transition: 'width 200ms ease' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'right' }}>{uploadProgress.percent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import RequestForm from '../../components/RequestForm';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||
import { fmtShortDate } from '../../lib/dates';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
|
||||
import { createTaskFolder } from '../../lib/filebrowserFolders';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
const CARD = {
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
};
|
||||
|
||||
const ListViewIcon = () => (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<line x1="3" y1="4" x2="13" y2="4"/><line x1="3" y1="8" x2="13" y2="8"/><line x1="3" y1="12" x2="13" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
const GridViewIcon = () => (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/>
|
||||
<rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'not_started', label: 'Not Started' },
|
||||
{ id: 'in_progress', label: 'In Progress' },
|
||||
{ id: 'on_hold', label: 'On Hold' },
|
||||
{ id: 'client_review', label: 'In Review' },
|
||||
{ id: 'client_approved', label: 'Approved' },
|
||||
{ id: 'invoiced', label: 'Invoiced' },
|
||||
{ id: 'paid', label: 'Paid' },
|
||||
];
|
||||
|
||||
export default function TeamTasksPage() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const cached = readPageCache('team_requests');
|
||||
|
||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
||||
const [companies, setCompanies] = useState(() => cached?.companies || []);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [viewMode, setViewMode] = useState(() => localStorage.getItem('requestsViewMode') || 'list');
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [filterProject, setFilterProject] = useState('');
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addFormKey, setAddFormKey] = useState(0);
|
||||
const [addSaving, setAddSaving] = useState(false);
|
||||
const [addError, setAddError] = useState('');
|
||||
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
|
||||
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('submitted_at', 'desc');
|
||||
|
||||
const toggleView = () => setViewMode(v => {
|
||||
const n = v === 'list' ? 'grid' : 'list';
|
||||
localStorage.setItem('requestsViewMode', n);
|
||||
return n;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [{ data: subs }, { data: t }, { data: p }, { data: co }] = await withTimeout(Promise.all([
|
||||
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at'),
|
||||
supabase.from('projects').select('id, name, status, company_id'),
|
||||
supabase.from('companies').select('id, name'),
|
||||
]), 12000, 'Requests load');
|
||||
setSubmissions(subs || []);
|
||||
setTasks(t || []);
|
||||
setProjects(p || []);
|
||||
setCompanies(co || []);
|
||||
writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [] });
|
||||
} catch {
|
||||
setLoadError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleAddRequest = async (formData, _files, existingProjects) => {
|
||||
if (addSaving) return;
|
||||
setAddSaving(true);
|
||||
setAddError('');
|
||||
try {
|
||||
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
|
||||
if (!projects.some(p => p.id === resolvedProject.id)) setProjects(prev => [...prev, resolvedProject]);
|
||||
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
||||
if (!task) throw new Error('Failed to create task.');
|
||||
const taskCompany = companies?.find(c => c.id === formData.companyId);
|
||||
createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
const { submission: sub } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, deadline: formData.deadline,
|
||||
description: formData.description, submittedBy: formData.requestedBy,
|
||||
submittedByName: formData.requestedByName,
|
||||
});
|
||||
if (!sub) throw new Error('Failed to create submission.');
|
||||
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
|
||||
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at'),
|
||||
]);
|
||||
setSubmissions(newSubs || []);
|
||||
setTasks(newTasks || []);
|
||||
setShowAddForm(false);
|
||||
setAddFormKey(k => k + 1);
|
||||
setAddRequestKey(crypto.randomUUID());
|
||||
} catch (err) {
|
||||
setAddError(err.message);
|
||||
} finally {
|
||||
setAddSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (loadError) return (
|
||||
<Layout>
|
||||
<div style={{ padding: 24 }}>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p>
|
||||
<button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
// ── Derived data ──────────────────────────────────────────────────────
|
||||
const latestGroups = tasks.map(task => {
|
||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||
if (!deadlineSource) return null;
|
||||
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
||||
return { task, primary: deadlineSource, group: taskSubs.filter(s => s.version_number === currentVersion) };
|
||||
}).filter(Boolean);
|
||||
|
||||
const coProjects = filterCompany ? projects.filter(p => p.company_id === filterCompany) : [];
|
||||
|
||||
const filtered = latestGroups.filter(({ task }) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
if (filterCompany && project?.company_id !== filterCompany) return false;
|
||||
if (filterProject && task?.project_id !== filterProject) return false;
|
||||
return true;
|
||||
}).sort((a, b) =>
|
||||
Math.max(...b.group.map(s => new Date(s.submitted_at).getTime())) -
|
||||
Math.max(...a.group.map(s => new Date(s.submitted_at).getTime()))
|
||||
);
|
||||
|
||||
const tabs = STATUS_TABS.map(t => ({
|
||||
...t,
|
||||
groups: t.id === 'all' ? filtered : filtered.filter(({ task }) => task?.status === t.id),
|
||||
}));
|
||||
|
||||
const activeGroups = sort(
|
||||
tabs.find(t => t.id === activeTab)?.groups || [],
|
||||
({ task, primary }, key) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const company = companies.find(co => co.id === project?.company_id);
|
||||
if (key === 'title') return task?.title || '';
|
||||
if (key === 'client') return company?.name || '';
|
||||
if (key === 'serviceType') return primary?.service_type || '';
|
||||
if (key === 'deadline') return primary?.deadline || '';
|
||||
if (key === 'completed_at') return task?.completed_at || '';
|
||||
if (key === 'status') return task?.status || '';
|
||||
if (key === 'submitted_at') return primary?.submitted_at || '';
|
||||
return '';
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
{showAddForm && (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.58)', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}
|
||||
onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
|
||||
>
|
||||
<div
|
||||
style={{ ...CARD, padding: '18px 21px', width: '100%', maxWidth: 560, maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 14 }}>Add Request</div>
|
||||
<RequestForm
|
||||
key={addFormKey}
|
||||
companies={companies}
|
||||
currentUser={currentUser}
|
||||
showRequester={true}
|
||||
onSubmit={handleAddRequest}
|
||||
onCancel={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
|
||||
saving={addSaving}
|
||||
error={addError}
|
||||
submitLabel="Add Request"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter bar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{companies.length > 0 && (
|
||||
<select className="filter-select" style={{ width: 140 }} value={filterCompany} onChange={e => { setFilterCompany(e.target.value); setFilterProject(''); }}>
|
||||
<option value="">All Companies</option>
|
||||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{coProjects.length > 0 && (
|
||||
<select className="filter-select" style={{ width: 140 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
|
||||
<option value="">All Projects</option>
|
||||
{coProjects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
|
||||
<select className="filter-select" style={{ width: 130 }} value={activeTab} onChange={e => setActiveTab(e.target.value)}>
|
||||
{tabs.map(t => <option key={t.id} value={t.id}>{t.label} ({t.groups.length})</option>)}
|
||||
</select>
|
||||
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
|
||||
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
|
||||
</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>+ Add Request</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{submissions.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No requests yet.</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No matching requests.</div>
|
||||
) : activeGroups.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {tabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
|
||||
{activeGroups.map(({ task, primary }) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const company = companies.find(co => co.id === project?.company_id);
|
||||
const svcType = submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type || primary?.service_type || '—';
|
||||
return (
|
||||
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.id}`)} style={{ ...CARD, padding: '14px 16px', cursor: 'pointer' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>{task?.title || '—'}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{project?.name || '—'}</div>
|
||||
</div>
|
||||
<StatusBadge status={task?.status || 'not_started'} />
|
||||
</div>
|
||||
{company && <div style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 6 }}>{company.name}</div>}
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{svcType}</span>
|
||||
<span>{fmtShortDate(primary?.deadline)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ ...CARD, padding: '18px 21px', flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table style={{ tableLayout: 'fixed', width: '100%', borderCollapse: 'collapse' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '28%' }} />
|
||||
<col style={{ width: '22%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Name</SortTh>
|
||||
<SortTh col="client" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Client</SortTh>
|
||||
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Type</SortTh>
|
||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Deadline</SortTh>
|
||||
<SortTh col="completed_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Approved</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeGroups.map(({ task, primary }) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const company = companies.find(co => co.id === project?.company_id);
|
||||
const svcType = submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type || primary?.service_type || '—';
|
||||
return (
|
||||
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ padding: '5px', border: 'none', fontWeight: 400 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{task?.title || '—'}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</span>
|
||||
{primary.is_hot && <span className="badge badge-needs_revision">HOT</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{project?.name || '—'}</div>
|
||||
</td>
|
||||
<td style={{ padding: '5px', border: 'none', fontSize: 13 }}>
|
||||
{company
|
||||
? <Link to={`/company/${company.id}`} className="table-link" style={{ color: 'var(--accent)' }} onClick={e => e.stopPropagation()}>{company.name}</Link>
|
||||
: <span style={{ color: 'var(--text-muted)' }}>No client</span>}
|
||||
</td>
|
||||
<td style={{ padding: '5px', border: 'none', fontSize: 13, color: 'var(--text-primary)' }}>{svcType}</td>
|
||||
<td style={{ padding: '5px', border: 'none', fontSize: 12, color: 'var(--text-primary)' }}>{fmtShortDate(primary.deadline, '—')}</td>
|
||||
<td style={{ padding: '5px', border: 'none', fontSize: 12, color: 'var(--text-muted)' }}>{fmtShortDate(task?.completed_at)}</td>
|
||||
<td style={{ padding: '5px', border: 'none' }}><StatusBadge status={task?.status || 'not_started'} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user