import { useState, useEffect, useRef, useMemo } from 'react'; import { Link } from 'react-router-dom'; import Layout from '../components/Layout'; import PageLoader from '../components/PageLoader'; import ProfileAvatar from '../components/ProfileAvatar'; 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 { getTaskDerivedState } from '../lib/taskVersions'; import { logActivity } from '../lib/activityLog'; import { fmtShortDate } from '../lib/dates'; import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission'; import { ensureProjectFolder, ensureRequestFolder, uploadRequestFileToSurvey } from '../lib/folderSync'; import { sendEmail } from '../lib/email'; import SortTh from '../components/SortTh'; import { useSortable } from '../hooks/useSortable'; import { popupOverlayStyle } from '../lib/popupStyles'; import { useLiveRefresh } from '../hooks/useLiveRefresh'; import { resolveScopedWorkIds } from '../lib/workScope'; import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay'; import { TASK_TABLE_TH_STYLE, TASK_TABLE_TD_BASE, TASK_MODAL_TITLE_STYLE, TASK_MODAL_LABEL_STYLE, TASK_MODAL_INPUT_STYLE, TASK_MODAL_BUTTON_STYLE, TASK_MODAL_SHELL_STYLE, PROJECT_MODAL_SHELL_STYLE, } from '../lib/taskUi'; const TASK_STAT_ICONS = { total: '', todo: '', progress: '', hold: '', review: '', done: '', }; 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, sub, iconBg, iconColor, iconPath }) { return (
{label}
{value}
{sub}
); } 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; const total = tasks.length; const open = Math.max(total - completed, 0); const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total'; 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' }, { 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 projectTabCounts = { all: projects.length, active: projects.filter((p) => !completedStatuses.has((p?.status || '').toLowerCase())).length, completed: projects.filter((p) => completedStatuses.has((p?.status || '').toLowerCase())).length, }; 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 && (
)}
{sortedProjects.length === 0 ? (
No projects
) : (
NameStatus {sortedProjects.map(p => ( ))}
{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 } = useLiveRefresh(['tasks', 'projects', 'submissions']); 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 [deliveries, setDeliveries] = useState([]); 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('new_requests'); 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); const { scopedProjectIds, scopedTaskIds } = await withTimeout( resolveScopedWorkIds(currentUser, { isClient, isExternal }), 10000, 'Tasks scope' ); 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; const allSubs = subs || []; const submitterIds = [...new Set(allSubs.map((submission) => submission.submitted_by).filter(Boolean))]; let submitterProfiles = []; if (submitterIds.length > 0) { const { data: profileRows } = await withTimeout( supabase.from('profiles').select('id, name').in('id', submitterIds), 10000, 'Tasks submitter profiles load' ); submitterProfiles = profileRows || []; } const hydratedSubs = mergeSubmissionDisplayNames(allSubs, submitterProfiles); let deliveryRows = []; if (hydratedSubs.length > 0) { const { data: delRows } = await withTimeout( supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', hydratedSubs.map(sub => sub.id)), 10000, 'Tasks deliveries load' ); deliveryRows = delRows || []; } setProjects(p || []); setTasks(t || []); setSubmissions(hydratedSubs); setDeliveries(deliveryRows); setCompanies(co || []); if (isTeam) { writePageCache('team_requests', { submissions: hydratedSubs, tasks: t || [], projects: p || [], companies: co || [] }); } else if (isExternal) { writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: hydratedSubs }); } } 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); 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.'); try { await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() }); } catch (folderError) { console.error('Request folder sync failed:', folderError); } if (files.length > 0) { const taskCompany = companies?.find(c => c.id === formData.companyId); 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}/Survey/${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: sub.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}`); } try { await uploadRequestFileToSurvey({ projectId: resolvedProject.id, taskTitle: formData.title.trim(), storagePath: path, fileName: file.name, }); } catch (mirrorError) { console.error('Survey folder mirror failed:', mirrorError); } } } 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: formData.requestedByName || currentUser.name, clientEmail: currentUser.email || 'hello@fourgebranding.com', company: taskCompany?.name || '', serviceType: formData.serviceType, projectName: resolvedProject.name, projectId: resolvedProject.id, deadline: formData.deadline, description: formData.description, taskId: task.id, }).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 }), ]); const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))]; const { data: submitterProfiles } = submitterIds.length > 0 ? await supabase.from('profiles').select('id, name').in('id', submitterIds) : { data: [] }; setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || [])); 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.'); 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, }); try { await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() }); } catch (folderError) { console.error('Request folder sync failed:', folderError); } 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}/Survey/${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}`); } try { await uploadRequestFileToSurvey({ projectId: resolvedProject.id, taskTitle: formData.title.trim(), storagePath: path, fileName: file.name, }); } catch (mirrorError) { console.error('Survey folder mirror failed:', mirrorError); } } } 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, projectId: resolvedProject.id, 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 }); const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))]; const { data: submitterProfiles } = submitterIds.length > 0 ? await supabase.from('profiles').select('id, name').in('id', submitterIds) : { data: [] }; setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || [])); 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; try { await ensureProjectFolder({ projectId: newProject.id }); } catch (folderError) { console.error('Project folder sync failed:', folderError); } 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 derived = getTaskDerivedState(task, submissions, deliveries); return { id: task.id, title: task.title, status: task.status, projectId: task.project_id, serviceType: derived.serviceType, deadline: derived.initialSubmission?.deadline || derived.deadline || null, version: derived.currentVersion, isHot: false, assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null, submittedAt: derived.latestActivityAt, }; }); } return tasks.map(task => { const derived = getTaskDerivedState(task, submissions, deliveries); if (derived.visibleTaskSubs.length === 0 || !derived.deadlineSource) return null; return { id: task.id, title: task.title, status: task.status, projectId: task.project_id, serviceType: derived.serviceType, deadline: derived.deadline, version: derived.currentVersion, isHot: derived.isHot, assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null, submittedAt: derived.latestActivityAt, }; }).filter(Boolean); }, [tasks, submissions, deliveries, 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' }, { id: 'new_requests', label: 'New Requests' }, { id: 'revisions', label: 'Revisions' }, { id: 'in_progress', label: 'In Progress' }, { id: 'on_hold', label: 'On Hold' }, { id: 'client_review', label: 'In Review' }, { id: 'completed', label: 'Completed' }, ]; const tabCounts = { all: filteredRows.length, new_requests: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length, revisions: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length, in_progress: filteredRows.filter(r => r.status === 'in_progress').length, on_hold: filteredRows.filter(r => r.status === 'on_hold').length, client_review: filteredRows.filter(r => r.status === 'client_review').length, completed: filteredRows.filter(r => doneStatuses.has(r.status)).length, }; const tabRows = activeTab === 'all' ? filteredRows : activeTab === 'completed' ? filteredRows.filter(r => doneStatuses.has(r.status)) : activeTab === 'new_requests' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0) : activeTab === 'revisions' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1) : 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); return ( {`R${String(row.version).padStart(2, '0')}`} {row.title || '—'} {project ? {project.name} : '—'} {(() => { if (row.assignedName && row.assignedTo) { const portrait = ; return {portrait}; } return
; })()} {row.isHot ? 'HOT' : 'NO'} {row.serviceType} {fmtShortDate(row.deadline, 'Not specified')} ); }; if (loading) return ; 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
) : sortedRows.length === 0 ? (
No tasks
) : (
R#NameProjectAssignedPriorityTask TypeDeadline{sortedRows.map(renderRow)}
)}
{error &&
{error}
}
{uploadProgress.active && (
Uploading
{uploadProgress.label}
{uploadProgress.percent}%
)} ); }