Session 2026-05-29: shared dashboard, team tasks page, requests card style, copy/paste files, loading modals, avatar cache bust

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-29 15:23:20 -04:00
parent c9998d4a8d
commit 8d0a461e7a
8 changed files with 605 additions and 205 deletions
+5 -8
View File
@@ -63,6 +63,7 @@ const ProjectDetailPage = lazy(() => import('./pages/ProjectDetailPage'));
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const TeamDashboard = lazy(() => import('./pages/team/TeamDashboard'));
const RequestsPage = lazy(() => import('./pages/RequestsPage'));
const TeamTasksPage = lazy(() => import('./pages/team/TeamTasksPage'));
const MyInvoices = lazy(() => import('./pages/client/MyInvoices'));
const NewRequest = lazy(() => import('./pages/client/NewRequest'));
const NewProject = lazy(() => import('./pages/client/NewProject'));
@@ -82,11 +83,7 @@ function NavigateCompanyDetail() {
return <Navigate to={`/company/${id}`} replace />;
}
function DashboardRoute() {
const { currentUser } = useAuth();
if (currentUser?.role === 'team') return <Navigate to="/team/dashboard" replace />;
return <DashboardPage />;
}
export default function App() {
return (
@@ -97,8 +94,7 @@ export default function App() {
<Routes>
<Route path="/" element={<Login />} />
<Route path="/dashboard" element={<ProtectedRoute role={['external', 'client']}><DashboardRoute /></ProtectedRoute>} />
<Route path="/team/dashboard" element={<ProtectedRoute role={['team']}><TeamDashboard /></ProtectedRoute>} />
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} />
<Route path="/projects" element={<ProtectedRoute role={['team', 'external', 'client']}><Projects /></ProtectedRoute>} />
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
<Route path="/tasks/:id" element={<RedirectRequestDetail />} />
@@ -107,7 +103,8 @@ export default function App() {
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
<Route path="/companies" element={<Navigate to="/company" replace />} />
<Route path="/companies/:id" element={<NavigateCompanyDetail />} />
<Route path="/requests" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestsPage /></ProtectedRoute>} />
<Route path="/team/tasks" element={<ProtectedRoute role="team"><TeamTasksPage /></ProtectedRoute>} />
<Route path="/requests" element={<ProtectedRoute role={['external', 'client']}><RequestsPage /></ProtectedRoute>} />
<Route path="/team-projects" element={<Navigate to="/projects" replace />} />
<Route path="/invoices" element={<ProtectedRoute role="team"><Invoices /></ProtectedRoute>} />
<Route path="/invoices/new" element={<ProtectedRoute role="team"><CreateInvoice /></ProtectedRoute>} />
+21 -3
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import PageLoader from './PageLoader';
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
@@ -25,8 +26,8 @@ function TeamNav({ onNav }) {
const location = useLocation();
const primaryLinks = [
{ to: '/team/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/requests', label: 'Requests', icon: ICONS.requests },
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/team/tasks', label: 'Tasks', icon: ICONS.requests },
{ to: '/projects', label: 'Projects', icon: ICONS.projects },
{ to: '/invoices', label: 'Finances', icon: ICONS.invoices },
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
@@ -140,11 +141,17 @@ export default function Layout({ children }) {
const firstName = currentUser?.name?.split(' ')[0] || '';
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
const isFileSharingRoute = location.pathname === '/file-sharing';
const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}`;
const isRequestsRoute = location.pathname === '/requests';
const isTasksRoute = location.pathname === '/team/tasks';
const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isTasksRoute ? 'Tasks & Projects' : isRequestsRoute ? 'Requests' : `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}`;
const headerSubtitle = isProfileRoute
? 'Account details and security settings.'
: isFileSharingRoute
? 'Browse, share and manage files.'
: isTasksRoute
? 'Browse and manage all tasks and projects.'
: isRequestsRoute
? 'Browse and manage all your requests.'
: "Here's what's happening today.";
useEffect(() => {
@@ -174,6 +181,9 @@ export default function Layout({ children }) {
return () => document.removeEventListener('mousedown', handler);
}, [avatarOpen]);
const [navigating, setNavigating] = useState(false);
const navTimerRef = useRef(null);
const [lastPathname, setLastPathname] = useState(location.pathname);
if (lastPathname !== location.pathname) {
setLastPathname(location.pathname);
@@ -181,6 +191,13 @@ export default function Layout({ children }) {
setAvatarOpen(false);
}
useEffect(() => {
setNavigating(true);
clearTimeout(navTimerRef.current);
navTimerRef.current = setTimeout(() => setNavigating(false), 500);
return () => clearTimeout(navTimerRef.current);
}, [location.pathname]);
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
const handleLogout = async () => { await logout(); navigate('/'); };
@@ -188,6 +205,7 @@ export default function Layout({ children }) {
return (
<div className="app-layout">
{navigating && <PageLoader />}
{menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />}
<aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}>
+26 -8
View File
@@ -1,15 +1,33 @@
export default function PageLoader({ opacity = 0.6 }) {
export default function PageLoader() {
return (
<div
style={{
minHeight: '100vh',
background: 'var(--bg, #111)',
<div style={{
position: 'fixed',
inset: 0,
zIndex: 1200,
background: 'rgba(0,0,0,0.58)',
backdropFilter: 'blur(6px)',
WebkitBackdropFilter: 'blur(6px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img src="/fourge-logo.png" alt="Loading" style={{ width: 160, opacity }} />
padding: 24,
}}>
<div style={{
background: 'var(--card-bg, rgba(255,255,255,0.06))',
border: '1px solid var(--border, rgba(255,255,255,0.1))',
borderRadius: 8,
padding: '18px 21px',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
width: 'min(320px, 100%)',
}}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary, rgba(255,255,255,0.5))', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 14 }}>
Loading
</div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--border, rgba(255,255,255,0.1))', overflow: 'hidden' }}>
<div className="file-browser-progress-bar indeterminate" style={{ height: '100%', borderRadius: 2, background: 'var(--accent, #F5A523)' }} />
</div>
</div>
</div>
);
}
+80 -33
View File
@@ -161,6 +161,7 @@ export default function FileSharing() {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(null);
const [downloadingSelection, setDownloadingSelection] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(null);
const [ctxMenu, setCtxMenu] = useState(null);
const [clipboard, setClipboard] = useState(null);
const [hoveredNavPath, setHoveredNavPath] = useState(null);
@@ -420,18 +421,24 @@ export default function FileSharing() {
localStorage.setItem(pinsStorageKey, JSON.stringify(next));
}
function triggerDownload(url, filename) {
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
async function handleDownload(entry) {
const nextPath = entry.path ?? (path === '/' ? `/${entry.name}` : `${path}/${entry.name}`);
try {
const blob = await (await fbqProxy('download', nextPath)).blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = entry.name;
link.click();
URL.revokeObjectURL(url);
} catch {
setError('Download failed.');
triggerDownload(URL.createObjectURL(blob), entry.name);
} catch (err) {
setError(err?.message || 'Download failed.');
}
}
@@ -461,6 +468,7 @@ export default function FileSharing() {
async function handleDownloadSelected() {
if (!selected.size) return;
setDownloadingSelection(true);
setDownloadProgress(0);
setError('');
try {
const selectedEntries = [...selected]
@@ -475,53 +483,42 @@ export default function FileSharing() {
if (!isDir) {
const blob = await (await fbqProxy('download', targetPath)).blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = name;
link.click();
URL.revokeObjectURL(url);
setDownloadProgress(100);
triggerDownload(URL.createObjectURL(blob), name);
return;
}
const zip = new JSZip();
setDownloadProgress(50);
await addPathToZip(zip, targetPath, name);
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${name}.zip`;
link.click();
URL.revokeObjectURL(url);
setDownloadProgress(100);
triggerDownload(URL.createObjectURL(await zip.generateAsync({ type: 'blob' })), `${name}.zip`);
return;
}
const zip = new JSZip();
for (const entry of selectedEntries) {
for (let i = 0; i < selectedEntries.length; i++) {
const entry = selectedEntries[i];
const name = entry.name ?? entry.filename ?? '';
const targetPath = entry.path ?? (path === '/' ? `/${name}` : `${path}/${name}`);
const isDir = entry.type === 'directory' || entry.isDir;
if (isDir) {
await addPathToZip(zip, targetPath, name);
continue;
}
} else {
const blob = await (await fbqProxy('download', targetPath)).blob();
zip.file(name, blob);
}
setDownloadProgress(Math.round(((i + 1) / selectedEntries.length) * 100));
}
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const folderName = parsePath(path).at(-1) || 'files';
link.href = url;
link.download = `${folderName}-selection.zip`;
link.click();
URL.revokeObjectURL(url);
} catch {
setError('Download failed.');
triggerDownload(URL.createObjectURL(await zip.generateAsync({ type: 'blob' })), `${folderName}-selection.zip`);
} catch (err) {
setError(err?.message || 'Download failed.');
} finally {
setDownloadingSelection(false);
setDownloadProgress(null);
}
}
@@ -938,6 +935,56 @@ export default function FileSharing() {
</div>
</div>
{(loading || uploading || downloadingSelection) && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1200,
background: 'rgba(0,0,0,0.58)',
backdropFilter: 'blur(6px)',
WebkitBackdropFilter: 'blur(6px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
}}
>
<div
style={{
background: 'var(--card-bg)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: '18px 21px',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
width: 'min(320px, 100%)',
}}
>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 14 }}>
{uploading ? 'Uploading' : downloadingSelection ? 'Downloading' : 'Loading'}
</div>
{(() => {
const pct = uploading ? uploadProgress : downloadingSelection ? downloadProgress : null;
const hasProgress = (uploading || downloadingSelection) && pct !== null;
return (
<>
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', marginBottom: hasProgress ? 8 : 0 }}>
<div
className={hasProgress ? undefined : 'file-browser-progress-bar indeterminate'}
style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: hasProgress ? `${pct}%` : undefined, transition: 'width 0.2s ease' }}
/>
</div>
{hasProgress && (
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textAlign: 'right' }}>{pct}%</div>
)}
</>
);
})()}
</div>
</div>
)}
{ctxMenu && (() => {
const { x, y, entry, childPath } = ctxMenu;
const isDir = entry ? (entry.type === 'directory' || entry.isDir) : false;
+14 -55
View File
@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import { DashboardBanner } from '../lib/dashboardBanner';
import StatusBadge from '../components/StatusBadge';
import RequestForm from '../components/RequestForm';
import { supabase } from '../lib/supabase';
@@ -332,27 +331,10 @@ export default function RequestsPage() {
return (
<Layout>
<div className="page-header">
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Requests</div>
<div className="page-subtitle">{teamActiveCount} active &bull; {teamCompletedCount} completed</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
+ Add Request
</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); setAddFormKey(k => k + 1); setAddError(''); }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 560, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)', maxHeight: '90vh', overflowY: 'auto' }} 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 Request</div>
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Add a request</div>
</div>
<button onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
</div>
<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={{ background: 'var(--card-bg)', borderRadius: 8, padding: '18px 21px', width: '100%', maxWidth: 560, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', 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}
@@ -388,6 +370,7 @@ export default function RequestsPage() {
<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>
@@ -405,7 +388,7 @@ export default function RequestsPage() {
const company = companies.find(co => co.id === project?.company_id);
const serviceType = 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={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.id}`)} style={{ padding: '14px 16px', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
<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>
@@ -424,7 +407,7 @@ export default function RequestsPage() {
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: '28%' }} />
@@ -521,14 +504,6 @@ export default function RequestsPage() {
return (
<Layout>
<div className="page-header">
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Requests</div>
<div className="page-subtitle">{extActiveCount} active &bull; {extCompletedCount} completed</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
{projectNames.length > 0 && (
<select className="filter-select" style={{ width: 120 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
@@ -559,7 +534,7 @@ export default function RequestsPage() {
const project = projects.find(p => p.id === task?.project_id);
const extServiceType = submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary?.service_type || '—';
return (
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.id}`)} style={{ padding: '14px 16px', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
<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>
@@ -578,7 +553,7 @@ export default function RequestsPage() {
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: '28%' }} />
@@ -643,27 +618,10 @@ export default function RequestsPage() {
return (
<Layout>
<div className="page-header" style={{ flexShrink: 0 }}>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Requests</div>
<div className="page-subtitle">{clientFilteredTasks.filter(t => !['client_approved','invoiced','paid'].includes(t.status)).length} active &bull; {clientFilteredTasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length} completed</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
+ New Request
</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); setAddFormKey(k => k + 1); setAddError(''); }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 560, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)', maxHeight: '90vh', overflowY: 'auto' }} 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 Request</div>
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Submit a request</div>
</div>
<button onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
</div>
<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={{ background: 'var(--card-bg)', borderRadius: 8, padding: '18px 21px', width: '100%', maxWidth: 560, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', 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 }}>New Request</div>
<RequestForm
key={addFormKey}
companies={clientCompanies}
@@ -699,6 +657,7 @@ export default function RequestsPage() {
<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(''); }}>+ New Request</button>
</div>
</div>
@@ -713,7 +672,7 @@ export default function RequestsPage() {
const clientSub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
const clientCo = clientCompanies.find(c => c.id === task.project?.company_id);
return (
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.id}`)} style={{ padding: '14px 16px', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
<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>
@@ -732,7 +691,7 @@ export default function RequestsPage() {
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: '28%' }} />
+2 -1
View File
@@ -285,7 +285,8 @@ export default function ProfilePage() {
const { error: upErr } = await supabase.storage.from('avatars').upload(path, file, { upsert: true });
if (upErr) { setEditError(upErr.message); return; }
const { data: { publicUrl } } = supabase.storage.from('avatars').getPublicUrl(path);
const { data, error: dbErr } = await supabase.from('profiles').update({ avatar_url: publicUrl }).eq('id', currentUser.id).select('*').single();
const bustedUrl = `${publicUrl}?t=${Date.now()}`;
const { data, error: dbErr } = await supabase.from('profiles').update({ avatar_url: bustedUrl }).eq('id', currentUser.id).select('*').single();
if (dbErr) { setEditError(dbErr.message); return; }
setViewedProfile(data);
} finally {
+93 -60
View File
@@ -612,8 +612,11 @@ const CACHE_KEY = 'team_dashboard_v2';
const CUTOFF_MONTHS = 6;
export default function TeamDashboard() {
useAuth(); // ensures auth context loaded
const cached = readPageCache(CACHE_KEY, 5 * 60_000);
const { currentUser } = useAuth();
const isTeam = currentUser?.role === 'team';
const isClient = currentUser?.role === 'client';
const isExternal = currentUser?.role === 'external';
const cached = isTeam ? readPageCache(CACHE_KEY, 5 * 60_000) : null;
const [tasks, setTasks] = useState(() => cached?.tasks || []);
const [projects, setProjects] = useState(() => cached?.projects || []);
@@ -625,25 +628,18 @@ export default function TeamDashboard() {
const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
const [teamExpenses, setTeamExpenses] = useState([]);
const [myInvoices, setMyInvoices] = useState([]);
const [loading, setLoading] = useState(!cached);
useEffect(() => {
let cancelled = false;
async function load() {
async function loadTeam() {
try {
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
const cutoffStr = cutoff.toISOString();
const [
{ data: t },
{ data: p },
{ data: subs },
{ data: profiles },
{ data: activity },
{ data: cos },
{ data: memRows },
] = await withTimeout(Promise.all([
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: cos }, { data: memRows }] = await withTimeout(Promise.all([
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').gte('submitted_at', cutoffStr).order('submitted_at', { ascending: false }),
supabase.from('projects').select('id, name, status, company_id'),
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)').gte('submitted_at', cutoffStr).order('version_number', { ascending: false }),
@@ -652,60 +648,80 @@ export default function TeamDashboard() {
supabase.from('companies').select('id, name').order('name'),
supabase.from('company_members').select('company_id, profile_id'),
]), 30000, 'TeamDashboard load');
if (cancelled) return;
const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role]));
const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role]));
const clients = (profiles || []).filter(pr => pr.role === 'client');
const tasksWithDeadlines = (t || []).map(task => ({
...task,
deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null,
assignee_role: roleById.get(task.assigned_to) || null,
}));
const subsWithRole = (subs || []).map(sub => ({
...sub,
submitter_role: roleById.get(sub.submitted_by) || null,
delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null,
}));
setTasks(tasksWithDeadlines);
setProjects(p || []);
setSubmissions(subsWithRole);
setClientProfiles(clients);
setAllProfiles(profiles || []);
setAllCompanies(cos || []);
setCompanyMemberships(memRows || []);
setActivityLog(activity || []);
writePageCache(CACHE_KEY, {
tasks: tasksWithDeadlines, projects: p || [], submissions: subsWithRole,
clientProfiles: clients, companies: cos || [], companyMemberships: memRows || [],
activityLog: activity || [], teamInvoices: [],
});
supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid']).then(({ data: invs }) => {
if (invs && !cancelled) setTeamInvoices(invs);
});
supabase.from('expenses').select('amount').then(({ data: exps }) => {
if (exps && !cancelled) setTeamExpenses(exps);
});
} catch (err) {
console.error('TeamDashboard load failed:', err);
} finally {
if (!cancelled) setLoading(false);
const tasksWithDeadlines = (t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null, assignee_role: roleById.get(task.assigned_to) || null }));
const subsWithRole = (subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null }));
setTasks(tasksWithDeadlines); setProjects(p || []); setSubmissions(subsWithRole);
setClientProfiles((profiles || []).filter(pr => pr.role === 'client'));
setAllProfiles(profiles || []); setAllCompanies(cos || []); setCompanyMemberships(memRows || []); setActivityLog(activity || []);
writePageCache(CACHE_KEY, { tasks: tasksWithDeadlines, projects: p || [], submissions: subsWithRole, clientProfiles: (profiles || []).filter(pr => pr.role === 'client'), companies: cos || [], companyMemberships: memRows || [], activityLog: activity || [], teamInvoices: [] });
supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid']).then(({ data: invs }) => { if (invs && !cancelled) setTeamInvoices(invs); });
supabase.from('expenses').select('amount').then(({ data: exps }) => { if (exps && !cancelled) setTeamExpenses(exps); });
} catch (err) { console.error('TeamDashboard load failed:', err); }
finally { if (!cancelled) setLoading(false); }
}
async function loadClient() {
const uid = currentUser?.id;
const companyIds = [...new Set([...(currentUser?.companies?.map(c => c.company?.id || c.id) || []), ...(currentUser?.company_id ? [currentUser.company_id] : [])])].filter(Boolean);
try {
const { data: mySubData } = await supabase.from('submissions').select('task_id').eq('submitted_by', uid).eq('type', 'initial');
const myTaskIds = (mySubData || []).map(s => s.task_id);
const [{ data: t }, { data: p }, { data: subs }, { data: activity }, { data: invs }, { data: profiles }] = await withTimeout(Promise.all([
myTaskIds.length ? supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').in('id', myTaskIds) : Promise.resolve({ data: [] }),
companyIds.length ? supabase.from('projects').select('id, name, status, company_id').in('company_id', companyIds) : Promise.resolve({ data: [] }),
myTaskIds.length ? supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot').in('task_id', myTaskIds) : Promise.resolve({ data: [] }),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(50),
companyIds.length ? supabase.from('invoices').select('total, status').in('company_id', companyIds).in('status', ['sent', 'paid']) : Promise.resolve({ data: [] }),
supabase.from('profiles').select('id, name, avatar_url'),
]), 20000, 'ClientDashboard load');
if (cancelled) return;
const projectIds = new Set((p || []).map(proj => proj.id));
setTasks((t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null })));
setProjects(p || []); setSubmissions(subs || []); setAllProfiles(profiles || []);
setActivityLog((activity || []).filter(e => !e.project_id || projectIds.has(e.project_id)).slice(0, 20));
setMyInvoices(invs || []);
} catch (err) { console.error('ClientDashboard load failed:', err); }
finally { if (!cancelled) setLoading(false); }
}
load();
async function loadExternal() {
const uid = currentUser?.id;
try {
const { data: memberData } = await supabase.from('project_members').select('project_id').eq('profile_id', uid);
const myProjectIds = (memberData || []).map(m => m.project_id);
const [{ data: t }, { data: p }, { data: subs }, { data: activity }, { data: subInvs }, { data: profiles }] = await withTimeout(Promise.all([
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').eq('assigned_to', uid),
myProjectIds.length ? supabase.from('projects').select('id, name, status, company_id').in('id', myProjectIds) : Promise.resolve({ data: [] }),
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot').eq('submitted_by', uid),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(50),
supabase.from('subcontractor_invoices').select('total, status').eq('profile_id', uid).in('status', ['submitted', 'approved', 'paid']),
supabase.from('profiles').select('id, name, avatar_url'),
]), 20000, 'ExternalDashboard load');
if (cancelled) return;
const projectIdSet = new Set(myProjectIds);
setTasks((t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null })));
setProjects(p || []); setSubmissions(subs || []); setAllProfiles(profiles || []);
setActivityLog((activity || []).filter(e => !e.project_id || projectIdSet.has(e.project_id)).slice(0, 20));
setMyInvoices(subInvs || []);
} catch (err) { console.error('ExternalDashboard load failed:', err); }
finally { if (!cancelled) setLoading(false); }
}
if (isTeam) loadTeam();
else if (isClient) loadClient();
else if (isExternal) loadExternal();
return () => { cancelled = true; };
}, []);
}, [isTeam, isClient, isExternal, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const teamHighlights = useMemo(() =>
buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
.sort((a, b) => b.openTaskCount - a.openTaskCount || b.projectCount - a.projectCount || a.company.name.localeCompare(b.company.name))
.slice(0, 5),
[allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices]);
.slice(0, 5) : [],
[isTeam, allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices]);
const activityEvents = useMemo(() =>
(activityLog || []).map(e => ({
@@ -751,13 +767,28 @@ export default function TeamDashboard() {
return teamInvoices.filter(inv => inv.status === 'paid' && inv.created_at && new Date(inv.created_at) >= start && new Date(inv.created_at) < end).reduce((s, inv) => s + Number(inv.total || 0), 0);
});
const myPaid = myInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const card3 = isTeam
? { label: 'Net Profit', value: fmtMoney(dashRevenue - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
: isClient
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue };
const card4 = isTeam
? { label: 'Revenue', value: fmtMoney(dashRevenue), sub: `${fmtMoney(dashOutstanding)} outstanding`, iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue, chartData: revenueByMonth }
: isClient
? { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid to Fourge', iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
: { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid by Fourge', iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit };
return (
<Layout>
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1.5fr', marginBottom: 0 }}>
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
<DashStatCard label="Net Profit" value={fmtMoney(dashRevenue - dashExpensesTotal)} sub={`${fmtMoney(dashExpensesTotal)} expenses`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.profit} />
<DashStatCard label="Revenue" value={fmtMoney(dashRevenue)} sub={`${fmtMoney(dashOutstanding)} outstanding`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.revenue} chartData={revenueByMonth} />
<DashStatCard label={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} />
<DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 280px', gap: 24, marginTop: 24 }}>
<ActivityFeed events={activityEvents} />
@@ -768,9 +799,11 @@ export default function TeamDashboard() {
<HotItemsCard submissions={submissions} tasks={tasks} />
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} />
</div>
{isTeam && (
<div style={{ marginTop: 24 }}>
<ClientHighlightCard highlights={teamHighlights} />
</div>
)}
</Layout>
);
}
+327
View File
@@ -0,0 +1,327 @@
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>
);
}