Files
fourge-portal/src/pages/team/TeamTasksPage.jsx
T

328 lines
18 KiB
React

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>
);
}