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 (
);
}
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
) : (
Name
Status
{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(''); }}>
)}
{/* 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#
Name
Project
Assigned
Priority
Task Type
Deadline
{sortedRows.map(renderRow)}
)}
{error && {error}
}
{uploadProgress.active && (
Uploading
{uploadProgress.label}
{uploadProgress.percent}%
)}
);
}