Session 2026-05-30: task detail page overhaul
- New TaskDetail replaces /tasks/:id (was v2 at /requests/:id/v2) - Overview tab: R00 request info, Requested By/Date/Sign Count inline row, Notes, Sign Family, files, amendments - Revisions tab: R01+ from submissions, newest first, same layout as overview, per-entry Amend button - Comments tab: single-line input, post on Enter, delete own comments, Supabase task_comments table - Folder tab: placeholder for file sharing - Tab badges showing revision/comment counts - Amend Request modal: drag-drop file zone, popupOverlayStyle/Surface, Save+Cancel buttons - Request Revision modal for clients on approved/invoiced/paid tasks - JSZip download-all with progress popup for submission files - Upload progress popup for Add Task and amend file uploads - FileAttachment drop zone: 8px radius, transparent bg, label style fix (no all-caps override) - PageLoader on task detail load - task_comments migration with RLS policies Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,327 +0,0 @@
|
||||
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