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:
+5
-8
@@ -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>} />
|
||||
|
||||
@@ -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' : ''}`}>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 • {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 • {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 • {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%' }} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user