import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { Link } 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 { logActivity } from '../lib/activityLog'; import { fmtShortDate } from '../lib/dates'; import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission'; import { sendEmail } from '../lib/email'; import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFolders'; import SortTh from '../components/SortTh'; import { useSortable } from '../hooks/useSortable'; import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles'; import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus'; import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription'; const TASK_STAT_ICONS = { total: '', todo: '', progress: '', hold: '', review: '', done: '', }; const TASK_TABLE_TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }; const TASK_TABLE_TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const TASK_MODAL_TITLE_STYLE = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }; const TASK_MODAL_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 }; const TASK_MODAL_INPUT_STYLE = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 }; const TASK_MODAL_BUTTON_STYLE = { lineHeight: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }; const TASK_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(720px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' }; const PROJECT_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(434px, 100%)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' }; const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, client_review: 3, client_approved: 4, invoiced: 5, paid: 6 }; function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) { return (
{label}
{value}
); } function TasksStatsRow({ tasks = [] }) { const toDo = tasks.filter(t => t?.status === 'not_started').length; const inProgress = tasks.filter(t => t?.status === 'in_progress').length; const onHold = tasks.filter(t => t?.status === 'on_hold').length; const inReview = tasks.filter(t => t?.status === 'client_review').length; const completed = tasks.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length; return (
); } function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, children }) { const [projSortKey, setProjSortKey] = useState('status'); const [projSortDir, setProjSortDir] = useState('asc'); const [projTab, setProjTab] = useState('active'); const toggleProjSort = (col) => { if (projSortKey === col) setProjSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setProjSortKey(col); setProjSortDir('asc'); } }; const completedStatuses = new Set(['completed', 'cancelled', 'archived', 'done']); const projectTabs = [ { id: 'all', label: 'All Projects' }, { id: 'active', label: 'Active' }, { id: 'completed', label: 'Completed' }, ]; const visibleProjects = projects.filter(p => { if (projTab === 'all') return true; const done = completedStatuses.has((p?.status || '').toLowerCase()); return projTab === 'completed' ? done : !done; }); const sortedProjects = [...visibleProjects].sort((a, b) => { if (projSortKey === 'status') { const ra = completedStatuses.has((a.status || '').toLowerCase()) ? 1 : 0; const rb = completedStatuses.has((b.status || '').toLowerCase()) ? 1 : 0; if (ra !== rb) return projSortDir === 'asc' ? ra - rb : rb - ra; } const av = (a.name || '').toLowerCase(); const bv = (b.name || '').toLowerCase(); return projSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av); }); const projectCompletionPct = (project) => { const pt = statsTasks.filter(t => t?.project_id === project?.id); if (pt.length > 0) { return Math.round((pt.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length / pt.length) * 100); } const s = (project?.status || '').toLowerCase(); if (s === 'completed' || s === 'done') return 100; if (s === 'cancelled' || s === 'archived') return 0; return 35; }; return (
{children}
{projectTabs.map(t => ( ))} {onAddProject && (
)}
NameStatus {sortedProjects.length === 0 ? ( ) : sortedProjects.map(p => ( ))}
No projects.
{p.name} {projectCompletionPct(p)}%
); } export default function RequestsPage() { const { currentUser } = useAuth(); const isTeam = currentUser?.role === 'team'; const isExternal = currentUser?.role === 'external'; const isClient = currentUser?.role === 'client'; const [refreshKey, setRefreshKey] = useState(0); const refresh = useCallback(() => setRefreshKey(k => k + 1), []); useRefetchOnFocus(refresh); useRealtimeSubscription(['tasks', 'projects', 'submissions'], refresh); const teamCached = isTeam ? readPageCache('team_requests') : null; const extCached = isExternal ? readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000) : null; const [projects, setProjects] = useState(() => teamCached?.projects || extCached?.projects || []); const [tasks, setTasks] = useState(() => teamCached?.tasks || extCached?.tasks || []); const [submissions, setSubmissions] = useState(() => teamCached?.submissions || extCached?.submissions || []); const [companies, setCompanies] = useState(() => teamCached?.companies || []); const [loading, setLoading] = useState(() => { if (isTeam) return !teamCached; if (isExternal) return !extCached; return true; }); const [error, setError] = useState(''); const [loadError, setLoadError] = useState(false); const [activeTab, setActiveTab] = useState('not_started'); const [filterCompany, setFilterCompany] = useState(''); const [filterProject, setFilterProject] = useState(''); const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc'); const [showAddForm, setShowAddForm] = useState(false); const [addFormKey, setAddFormKey] = useState(0); const [addSaving, setAddSaving] = useState(false); const [addError, setAddError] = useState(''); const [uploadProgress, setUploadProgress] = useState({ active: false, label: '', percent: 0 }); const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID()); const [showAddProjectForm, setShowAddProjectForm] = useState(false); const [addProjectSaving, setAddProjectSaving] = useState(false); const [addProjectError, setAddProjectError] = useState(''); const [addProjectForm, setAddProjectForm] = useState({ name: '', companyId: '' }); const [companyFilterMenuOpen, setCompanyFilterMenuOpen] = useState(false); const companyFilterMenuRef = useRef(null); const [projectFilterMenuOpen, setProjectFilterMenuOpen] = useState(false); const projectFilterMenuRef = useRef(null); useEffect(() => { if (!companyFilterMenuOpen) return; function onDocClick(e) { if (companyFilterMenuRef.current && !companyFilterMenuRef.current.contains(e.target)) setCompanyFilterMenuOpen(false); } document.addEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick); }, [companyFilterMenuOpen]); useEffect(() => { if (!projectFilterMenuOpen) return; function onDocClick(e) { if (projectFilterMenuRef.current && !projectFilterMenuRef.current.contains(e.target)) setProjectFilterMenuOpen(false); } document.addEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick); }, [projectFilterMenuOpen]); useEffect(() => { let cancelled = false; async function loadTasksPage() { if (!currentUser?.id) { setLoading(false); return; } try { setError(''); setLoadError(false); let scopedProjectIds = null; let scopedTaskIds = null; if (isClient) { const clientCos = currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []); const companyIds = clientCos.map(c => c.id).filter(Boolean); if (companyIds.length > 0) { const { data: projRows } = await withTimeout( supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds), 10000, 'Client project scope' ); scopedProjectIds = (projRows || []).map(p => p.id); scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id)); } else { scopedProjectIds = []; scopedTaskIds = []; } } if (isExternal) { const { data: memberData } = await withTimeout( supabase.from('project_members').select('project_id').eq('profile_id', currentUser.id), 10000, 'External project scope' ); scopedProjectIds = (memberData || []).map(r => r.project_id).filter(Boolean); if (scopedProjectIds.length > 0) { const { data: taskRows } = await withTimeout( supabase.from('tasks').select('id').in('project_id', scopedProjectIds), 10000, 'External task scope' ); scopedTaskIds = (taskRows || []).map(r => r.id).filter(Boolean); } else { scopedTaskIds = []; } } const tasksQ = supabase.from('tasks') .select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)') .order('submitted_at', { ascending: false }); if (!isTeam) { if ((scopedProjectIds || []).length > 0) tasksQ.in('project_id', scopedProjectIds); else tasksQ.eq('id', '__none__'); } const projectsQ = supabase.from('projects').select('id, name, status, company_id, company:companies(id, name)'); if (!isTeam) { if ((scopedProjectIds || []).length > 0) projectsQ.in('id', scopedProjectIds); else projectsQ.eq('id', '__none__'); } const subsQ = 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 }); if (!isTeam) { if ((scopedTaskIds || []).length > 0) subsQ.in('task_id', scopedTaskIds); else subsQ.eq('id', '__none__'); } const [{ data: subs }, { data: t }, { data: p }, { data: co }] = await withTimeout( Promise.all([subsQ, tasksQ, projectsQ, supabase.from('companies').select('id, name')]), 15000, 'Tasks page load' ); if (cancelled) return; setProjects(p || []); setTasks(t || []); setSubmissions(subs || []); setCompanies(co || []); if (isTeam) { writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [] }); } else if (isExternal) { writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: subs || [] }); } } catch (err) { console.error('Tasks page load failed:', err); setError(err.message || 'Failed to load tasks.'); setLoadError(true); } finally { if (!cancelled) setLoading(false); } } loadTasksPage(); return () => { cancelled = true; }; }, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]); // 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, signFamily: formData.signFamily, signCount: formData.signCount, signs: formData.signs, deadline: formData.deadline, description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName, }); if (!sub) throw new Error('Failed to create submission.'); logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {}); const [{ data: newSubs }, { data: newTasks }] = await Promise.all([ supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }), supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)').order('submitted_at', { ascending: false }), ]); setSubmissions(newSubs || []); setTasks(newTasks || []); setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID()); } catch (err) { setAddError(err.message); } finally { setAddSaving(false); } }; const handleClientRequest = async (formData, files, existingProjects) => { if (addSaving) return; setAddSaving(true); setAddError(''); try { const clientCos = currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []); const selectedCompany = clientCos.find(c => c.id === formData.companyId); const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects); const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey }); if (!task) throw new Error('Failed to create task.'); createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {}); const { submission } = await createInitialSubmissionForRequest({ taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot, serviceType: formData.serviceType, signFamily: formData.signFamily, signCount: formData.signCount, signs: formData.signs, deadline: formData.deadline, description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name, }); if (submission && files.length > 0) { setShowAddForm(false); setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 }); for (let fi = 0; fi < files.length; fi++) { const file = files[fi]; setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) }); const path = `${task.id}/${Date.now()}_${file.name}`; const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file); if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`Upload failed: ${uploadError.message}`); } if (uploaded) { const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size }); if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`File record failed: ${fileErr.message}`); } } } uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {}); setUploadProgress({ active: false, label: '', percent: 0 }); } logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {}); sendEmail('new_request', 'hello@fourgebranding.com', { clientName: currentUser.name, clientEmail: currentUser.email, company: selectedCompany?.name || '', serviceType: formData.serviceType, projectName: formData.project, deadline: formData.deadline, description: formData.description, taskId: task.id, }).catch(() => {}); const refreshCompanyIds = clientCos.map(c => c.id).filter(Boolean); const { data: refreshProjects } = await supabase.from('projects').select('id, name, status, company_id, company:companies(id, name)').in('company_id', refreshCompanyIds); const refreshProjectIds = (refreshProjects || []).map(p => p.id); const { data: newTasks } = await supabase.from('tasks') .select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)') .in('project_id', refreshProjectIds.length > 0 ? refreshProjectIds : ['__none__']) .order('submitted_at', { ascending: false }); const refreshTaskIds = (newTasks || []).map(t => t.id); const { data: newSubs } = await supabase.from('submissions') .select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type') .in('task_id', refreshTaskIds.length > 0 ? refreshTaskIds : ['__none__']) .order('submitted_at', { ascending: false }); setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(newSubs || []); setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID()); } catch (err) { setAddError(err.message); } finally { setAddSaving(false); } }; const handleAddProject = async (e) => { e.preventDefault(); if (addProjectSaving) return; setAddProjectSaving(true); setAddProjectError(''); try { const name = addProjectForm.name.trim(); if (!name) throw new Error('Project name required.'); if (!addProjectForm.companyId) throw new Error('Company required.'); const { data: newProject, error: insertError } = await supabase .from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' }) .select('id, name, status, company_id, company:companies(id, name)').single(); if (insertError) throw insertError; setProjects(prev => [...prev, newProject]); setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); } catch (err) { setAddProjectError(err.message || 'Failed to create project.'); } finally { setAddProjectSaving(false); } }; // Companies available for filter menu and forms const filterableCompanies = useMemo(() => { if (isClient) { const cos = currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []); return cos.slice().sort((a, b) => a.name.localeCompare(b.name)); } return companies.slice().sort((a, b) => (a.name || '').localeCompare(b.name || '')); }, [isClient, companies, currentUser]); // eslint-disable-line react-hooks/exhaustive-deps // Normalize all tasks to a common row shape for unified table render const allRows = useMemo(() => { if (isClient) { return tasks.map(task => { const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial'); return { id: task.id, title: task.title, status: task.status, projectId: task.project_id, serviceType: sub?.service_type || '—', deadline: sub?.deadline || null, version: task.current_version || 0, isHot: false, assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null, submittedAt: task.submitted_at ? new Date(task.submitted_at).getTime() : 0, }; }); } return 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); const latestGroup = taskSubs.filter(s => s.version_number === currentVersion); const serviceType = submissions.find(s => s.task_id === task.id && s.type === 'initial')?.service_type || submissions.find(s => s.task_id === task.id && s.service_type)?.service_type || deadlineSource.service_type || '—'; return { id: task.id, title: task.title, status: task.status, projectId: task.project_id, serviceType, deadline: deadlineSource.deadline, version: deadlineSource.version_number ?? 0, isHot: deadlineSource.is_hot || false, assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null, submittedAt: latestGroup.length > 0 ? Math.max(...latestGroup.map(s => new Date(s.submitted_at).getTime())) : (task.submitted_at ? new Date(task.submitted_at).getTime() : 0), }; }).filter(Boolean); }, [tasks, submissions, isClient]); // eslint-disable-line react-hooks/exhaustive-deps const filteredRows = useMemo(() => { return allRows .filter(row => { const project = projects.find(p => p.id === row.projectId); if (filterCompany && project?.company_id !== filterCompany) return false; if (filterProject && row.projectId !== filterProject) return false; return true; }) .sort((a, b) => b.submittedAt - a.submittedAt); }, [allRows, projects, filterCompany, filterProject]); const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']); const tabs = [ { id: 'all', label: 'All Tasks' }, { id: 'not_started', label: 'To Do' }, { id: 'in_progress', label: 'In Progress' }, { id: 'on_hold', label: 'On Hold' }, { id: 'client_review', label: 'In Review' }, { id: 'completed', label: 'Completed' }, ]; const tabRows = activeTab === 'all' ? filteredRows : activeTab === 'completed' ? filteredRows.filter(r => doneStatuses.has(r.status)) : filteredRows.filter(r => r.status === activeTab); const sortedRows = sort(tabRows, (row, key) => { if (key === 'title') return row.title || ''; if (key === 'project') return projects.find(p => p.id === row.projectId)?.name || ''; if (key === 'serviceType') return row.serviceType || ''; if (key === 'revision') return row.version ?? 0; if (key === 'deadline') return row.deadline || ''; if (key === 'status') return TASK_STATUS_SORT_RANK[row.status] ?? 99; if (key === 'submitted_at') return row.submittedAt || 0; if (key === 'assigned') return row.assignedName || ''; if (key === 'priority') return row.isHot ? 0 : 1; return ''; }); const projectOptions = useMemo(() => { if (!isExternal) return []; return [...new Map( allRows.map(r => projects.find(p => p.id === r.projectId)).filter(Boolean).map(p => [p.id, p]) ).values()]; }, [isExternal, allRows, projects]); // eslint-disable-line react-hooks/exhaustive-deps const renderRow = (row) => { const project = projects.find(p => p.id === row.projectId); const initials = row.assignedName ? row.assignedName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : null; return ( {`R${String(row.version).padStart(2, '0')}`} {row.title || '—'} {project ? {project.name} : '—'} {(() => { const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 }; if (row.assignedName && row.assignedTo) { const portrait = row.assigneeAvatar ?
{row.assignedName}
:
{initials}
; return {portrait}; } return
; })()} {row.isHot ? 'HOT' : 'NO'} {row.serviceType} {fmtShortDate(row.deadline, 'Not specified')} ); }; if (loading) return

Loading...

; if (loadError) return

Failed to load. Check your connection and try again.

; const filteredStatTasks = filteredRows.map(r => tasks.find(t => t.id === r.id)).filter(Boolean); const filteredProjectsShell = filterCompany ? projects.filter(p => p.company_id === filterCompany) : projects; return ( { setAddProjectForm(f => ({ ...f, companyId: !isTeam && filterableCompanies.length === 1 ? filterableCompanies[0].id : f.companyId })); setShowAddProjectForm(true); setAddProjectError(''); } : null} > {/* Add project modal */} {showAddProjectForm && (
{ setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>
e.stopPropagation()}>
Add Project
setAddProjectForm(f => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} />
{addProjectError &&
{addProjectError}
}
)} {/* Add task modal */} {showAddForm && (
{ setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}>
e.stopPropagation()}>
{isTeam ? 'Add Task' : 'New Task'}
{ setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }} saving={addSaving} error={addError} submitLabel="Save" />
)} {/* Controls bar: tabs left, filters + actions right */}
{tabs.map(t => ( ))}
{(isTeam || isClient) && ( )} {isExternal && projectOptions.length > 0 && (
{projectFilterMenuOpen && (
{projectOptions.map(p => ( ))}
)}
)} {(isTeam || isClient) && (
{companyFilterMenuOpen && (
{filterableCompanies.map(co => ( ))}
)}
)}
{/* Task table */}
{allRows.length === 0 ? (
No tasks yet.
) : sortedRows.length === 0 ? (
No tasks.
) : (
R#NameProjectAssignedPriorityTask TypeDeadline{sortedRows.map(renderRow)}
)}
{error &&
{error}
}
{uploadProgress.active && (
Uploading
{uploadProgress.label}
{uploadProgress.percent}%
)} ); }