Merge all role-dispatcher pages into single files; add FileBrowser with file-type icons

- DashboardPage, Projects, RequestsPage, ProjectDetailPage, RequestDetail: each now handles team/external/client in one file via role flags — removed 10 old role-specific sub-files
- Layout: client Company nav link goes directly to /company/:id when user has a single company
- FileBrowser: replace emoji icons with colored extension-text badges (square); folder icon stays 📁; Adobe/Figma/design-tool colors for design files
- CompaniesPage: merged team Companies + client company routing (single-company redirect, multi-company list)
- FileSharing: integrated FileBrowser component
- Removed: seafile API + lib, old ServerStatus, TaskDetail, role-split page files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-19 22:11:34 -04:00
parent f9e66dfced
commit b9a4c4a353
47 changed files with 5202 additions and 7217 deletions
+674
View File
@@ -0,0 +1,674 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import StatusBadge from '../components/StatusBadge';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData';
import { readPageCache, writePageCache } from '../lib/pageCache';
import { withTimeout } from '../lib/withTimeout';
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
import { addDaysToDateOnly, formatDateOnly, getTodayDateOnlyEST } from '../lib/dates';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
const EMPTY_FORM = () => ({ companyId: '', project: '', serviceType: '', title: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
export default function RequestsPage() {
const { currentUser } = useAuth();
const navigate = useNavigate();
const isTeam = currentUser?.role === 'team';
const isExternal = currentUser?.role === 'external';
const isClient = currentUser?.role === 'client';
// ── Shared state ───────────────────────────────────────────────────────
const [projects, setProjects] = useState([]);
const [tasks, setTasks] = useState([]);
const [submissions, setSubmissions] = useState([]);
const [loading, setLoading] = useState(() => {
if (isTeam) return !readPageCache('team_requests');
if (isExternal) return !readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000);
return true;
});
const [error, setError] = useState('');
const [activeTab, setActiveTab] = useState('active');
// ── Team-only state ────────────────────────────────────────────────────
const teamCached = isTeam ? readPageCache('team_requests') : null;
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
const [invoices, setInvoices] = useState(() => teamCached?.invoices || []);
const [invoiceItems, setInvoiceItems] = useState(() => teamCached?.invoiceItems || []);
const [companyUsers, setCompanyUsers] = useState([]);
const [filterCompany, setFilterCompany] = useState('');
const [filterUser, setFilterUser] = useState('');
const [showAddForm, setShowAddForm] = useState(false);
const [addForm, setAddForm] = useState(EMPTY_FORM());
const [formProjects, setFormProjects] = useState([]);
const [customProjectNames, setCustomProjectNames] = useState([]);
const [isTypingProject, setIsTypingProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [addSaving, setAddSaving] = useState(false);
const [addError, setAddError] = useState('');
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
// ── External-only state ────────────────────────────────────────────────
const extCacheKey = `ext-requests:${currentUser?.id}`;
const extCached = isExternal ? readPageCache(extCacheKey, 3 * 60_000) : null;
const [paidTaskIds, setPaidTaskIds] = useState(() => new Set(extCached?.paidTaskIds || []));
const [filterProject, setFilterProject] = useState('');
const [filterRequester, setFilterRequester] = useState('');
// ── Client-only state ──────────────────────────────────────────────────
const [clientInvoices, setClientInvoices] = useState([]);
const [clientInvoiceItems, setClientInvoiceItems] = useState([]);
useEffect(() => {
if (isTeam) {
async function loadTeam() {
try {
const [{ data: subs }, { data: t }, { data: p }, { data: co }, { data: inv }, { data: itemRows }] = await withTimeout(Promise.all([
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('*'),
supabase.from('projects').select('*'),
supabase.from('companies').select('id, name'),
supabase.from('invoices').select('id, status'),
supabase.from('invoice_items').select('task_id, invoice_id'),
]), 12000, 'Requests load');
setSubmissions(subs || []);
setTasks(t || []);
setProjects(p || []);
setCompanies(co || []);
setInvoices(inv || []);
setInvoiceItems(itemRows || []);
writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [], invoices: inv || [], invoiceItems: itemRows || [] });
} catch (err) {
console.error('Requests load failed:', err);
setSubmissions([]); setTasks([]); setProjects([]); setCompanies([]); setInvoices([]); setInvoiceItems([]);
} finally {
setLoading(false);
}
}
if (teamCached) {
setSubmissions(teamCached.submissions || []);
setTasks(teamCached.tasks || []);
setProjects(teamCached.projects || []);
setLoading(false);
} else {
loadTeam();
}
} else if (isExternal) {
async function loadExternal() {
if (!currentUser?.id) { setLoading(false); return; }
try {
const [{ data: projectData }, { data: taskData }, { data: subData }, { data: paidItems }] = await withTimeout(
Promise.all([
supabase.from('projects').select('id, name, company_id').order('created_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, invoiced').order('submitted_at', { ascending: false }),
supabase.from('submissions').select('task_id, submitted_by_name, version_number, deadline, is_hot, service_type, submitted_at').order('submitted_at', { ascending: false }),
supabase.from('subcontractor_invoice_items').select('task_id, invoice:subcontractor_invoices!inner(status)').eq('subcontractor_invoices.status', 'paid'),
]),
15000, 'External requests load'
);
const paid = new Set((paidItems || []).filter(item => item.invoice?.status === 'paid' && item.task_id).map(item => item.task_id));
setProjects(projectData || []);
setTasks(taskData || []);
setSubmissions(subData || []);
setPaidTaskIds(paid);
writePageCache(extCacheKey, { projects: projectData || [], tasks: taskData || [], submissions: subData || [], paidTaskIds: [...paid] });
setError('');
} catch (err) {
console.error('External requests load failed:', err);
setError(err.message || 'Failed to load requests.');
} finally {
setLoading(false);
}
}
if (extCached) {
setProjects(extCached.projects || []);
setTasks(extCached.tasks || []);
setSubmissions(extCached.submissions || []);
setLoading(false);
} else {
loadExternal();
}
} else if (isClient) {
async function loadClient() {
try {
const { data: mySubs } = await withTimeout(
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').eq('submitted_by', currentUser.id).eq('type', 'initial'),
10000, 'My submissions'
);
if (!mySubs || mySubs.length === 0) { setLoading(false); return; }
const myTaskIds = mySubs.map(s => s.task_id);
const [{ data: t }, { data: allSubs }, { data: inv }, { data: itemRows }] = await withTimeout(
Promise.all([
supabase.from('tasks').select('*, project:projects(id, name, created_at, status)').in('id', myTaskIds),
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').in('task_id', myTaskIds).order('version_number'),
supabase.from('invoices').select('id, status'),
supabase.from('invoice_items').select('task_id, invoice_id').in('task_id', myTaskIds),
]),
12000, 'My requests data'
);
const clientTasks = t || [];
setTasks(clientTasks);
setSubmissions(allSubs || []);
setClientInvoices(inv || []);
setClientInvoiceItems(itemRows || []);
const projectMap = {};
clientTasks.forEach(task => { const p = task.project; if (p && !projectMap[p.id]) projectMap[p.id] = { ...p }; });
setProjects(Object.values(projectMap));
} catch (err) {
console.error('MyRequests load failed:', err);
} finally {
setLoading(false);
}
}
loadClient();
}
}, [isTeam, isExternal, isClient, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Team: company change → reload form projects ────────────────────────
const requesterOptions = isTeam ? [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
...companyUsers.filter(user => user.id !== currentUser?.id),
] : [];
useEffect(() => {
if (!isTeam) return;
setFormProjects([]); setCustomProjectNames([]); setCompanyUsers([]);
setAddForm(f => ({ ...f, project: '', requestedBy: currentUser?.id || '' }));
setIsTypingProject(false); setNewProjectName('');
if (!addForm.companyId) return;
withTimeout(Promise.all([
supabase.from('projects').select('id, name').eq('company_id', addForm.companyId).order('name'),
supabase.from('profiles').select('id, name, email').eq('company_id', addForm.companyId).eq('role', 'client').order('name'),
]), 10000, 'Request form load').then(([projectsResult, usersResult]) => {
setFormProjects(projectsResult.data || []);
setCompanyUsers(usersResult.data || []);
}).catch(() => { setFormProjects([]); setCompanyUsers([]); });
}, [addForm.companyId]); // eslint-disable-line react-hooks/exhaustive-deps
const handleAddProjectName = () => {
const name = newProjectName.trim();
if (!name) return;
if (!customProjectNames.includes(name) && !formProjects.some(p => p.name === name)) setCustomProjectNames(prev => [...prev, name]);
setAddForm(f => ({ ...f, project: name }));
setIsTypingProject(false); setNewProjectName('');
};
const handleAddRequest = async (e) => {
e.preventDefault();
if (addSaving) return;
setAddSaving(true); setAddError('');
try {
const requester = requesterOptions.find(user => user.id === addForm.requestedBy);
if (!requester) throw new Error('Please select who requested this task.');
const projectName = addForm.project.trim();
if (!projectName) throw new Error('Please select or create a project.');
const resolvedProject = await findOrCreateProject(addForm.companyId, projectName, formProjects);
if (!formProjects.some(p => p.id === resolvedProject.id)) setFormProjects(prev => [...prev, { id: resolvedProject.id, name: resolvedProject.name }]);
if (!projects.some(p => p.id === resolvedProject.id)) setProjects(prev => [...prev, resolvedProject]);
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: addForm.title.trim() || addForm.serviceType, requestKey: addRequestKey });
if (!task) throw new Error('Failed to create task.');
const { submission: sub } = await createInitialSubmissionForRequest({ taskId: task.id, requestKey: addRequestKey, isHot: addForm.isHot, serviceType: addForm.serviceType, deadline: addForm.deadline, description: addForm.description, submittedBy: requester.id, submittedByName: requester.name.replace(' (You)', '') });
if (!sub) throw new Error('Failed to create submission.');
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('*'),
]);
setSubmissions(newSubs || []); setTasks(newTasks || []);
setShowAddForm(false); setAddForm(EMPTY_FORM()); setAddRequestKey(crypto.randomUUID());
setCustomProjectNames([]); setIsTypingProject(false); setNewProjectName('');
} catch (err) {
setAddError(err.message);
} finally {
setAddSaving(false);
}
};
const setAdd = (field) => (e) => setAddForm(f => ({ ...f, [field]: e.target.value }));
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
// ── Team render ────────────────────────────────────────────────────────
if (isTeam) {
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
const paidInvoiceIds = new Set(invoices.filter(inv => inv.status === 'paid').map(inv => inv.id));
const paidIds = new Set(invoiceItems.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id)).map(item => item.task_id));
const isFullyClosedTask = (task) => task?.status === 'client_approved' && Boolean(task?.invoiced) && paidIds.has(task.id);
const latestTaskGroups = 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);
return { task, primary: deadlineSource, group: latestGroup };
}).filter(Boolean);
const filteredGroups = latestTaskGroups.filter(({ task, group }) => {
const project = projects.find(p => p.id === task?.project_id);
if (filterCompany && project?.company_id !== filterCompany) return false;
if (filterUser && !group.some(s => s.submitted_by_name === filterUser)) 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 activeGroups = filteredGroups.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review');
const clientReviewGroups = filteredGroups.filter(({ task }) => task?.status === 'client_review');
const completedGroups = filteredGroups.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedTask(task));
const closedGroups = filteredGroups.filter(({ task }) => isFullyClosedTask(task));
const renderRow = ({ task, primary }) => {
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
const isCompleted = task?.status === 'client_approved';
const isFullyClosed = isFullyClosedTask(task);
return (
<tr key={task.id} onClick={() => task && navigate(`/requests/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
<td style={{ fontWeight: 600 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{task?.title || primary.service_type}</span>
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
</div>
</td>
<td>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</td>
<td>{primary.service_type || 'Request'}</td>
<td>{company ? <Link to={`/company/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link> : 'No client'}</td>
<td>{formatDateOnly(primary.deadline, 'Not specified')}</td>
<td>{isFullyClosed ? <span className="badge badge-client_approved">Paid & Closed</span> : isCompleted ? <span className="badge badge-client_approved">Completed</span> : <StatusBadge status={task?.status || 'not_started'} />}</td>
</tr>
);
};
const teamTabs = [
{ id: 'active', label: 'Active', groups: activeGroups },
{ id: 'client-review', label: 'Client Review', groups: clientReviewGroups },
{ id: 'completed', label: 'Completed', groups: completedGroups },
{ id: 'closed', label: 'Fully Closed', groups: closedGroups },
];
const currentGroups = teamTabs.find(t => t.id === activeTab)?.groups || [];
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Requests</div>
<div className="page-subtitle">Track incoming requests, revisions, and completed jobs in one place.</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
{showAddForm ? 'Cancel' : '+ Add Request'}
</button>
</div>
{showAddForm && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
<div className="card-title">Add Request</div>
<form onSubmit={handleAddRequest}>
<div className="grid-2">
<div className="form-group">
<label>Company *</label>
<select value={addForm.companyId} onChange={setAdd('companyId')} required>
<option value="">Select company...</option>
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Project *</label>
{isTypingProject ? (
<div style={{ display: 'flex', gap: 8 }}>
<input type="text" placeholder="Enter project name..." value={newProjectName} onChange={e => setNewProjectName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProjectName(); } }} autoFocus style={{ flex: 1 }} />
<button type="button" className="btn btn-primary btn-sm" onClick={handleAddProjectName} disabled={!newProjectName.trim()}>Add</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
</div>
) : (
<select value={addForm.project} onChange={e => { if (e.target.value === '__new__') { setIsTypingProject(true); setAddForm(f => ({ ...f, project: '' })); } else { setAddForm(f => ({ ...f, project: e.target.value })); } }} required disabled={!addForm.companyId}>
<option value="">{addForm.companyId ? 'Select project...' : 'Select company first'}</option>
{[...formProjects.map(p => p.name), ...customProjectNames.filter(n => !formProjects.some(p => p.name === n))].map(name => (
<option key={name} value={name}>{name}</option>
))}
{addForm.companyId && <option value="__new__"> Create new project...</option>}
</select>
)}
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Service Type *</label>
<select value={addForm.serviceType} onChange={setAdd('serviceType')} required>
<option value="">Select service...</option>
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div className="form-group">
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={addForm.deadline} onChange={setAdd('deadline')} />
</div>
</div>
<div className="form-group" style={{ marginTop: -4 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
<input type="checkbox" checked={addForm.isHot} onChange={e => setAddForm(f => ({ ...f, isHot: e.target.checked }))} />
<span>Mark as Hot</span>
</label>
</div>
<div className="form-group">
<label>Requested By *</label>
<select value={addForm.requestedBy} onChange={setAdd('requestedBy')} disabled={!addForm.companyId} required>
<option value="">{addForm.companyId ? 'Select requester...' : 'Select company first'}</option>
{requesterOptions.map(user => <option key={user.id} value={user.id}>{user.name}{user.email ? ` (${user.email})` : ''}</option>)}
</select>
</div>
<div className="form-group">
<label>Title <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional defaults to service type)</span></label>
<input type="text" placeholder={addForm.serviceType || 'e.g. Brand Book — 2026'} value={addForm.title} onChange={setAdd('title')} />
</div>
<div className="form-group">
<label>Description *</label>
<textarea placeholder="Notes on the request..." value={addForm.description} onChange={setAdd('description')} style={{ minHeight: 100 }} required />
</div>
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {addError}</div>}
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Adding...' : 'Add Request'}</button>
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm(EMPTY_FORM()); setCustomProjectNames([]); setIsTypingProject(false); setNewProjectName(''); setAddError(''); }}>Cancel</button>
</div>
</form>
</div>
)}
{(companies.length > 0 || requesterNames.length > 0) && (
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
<div className="request-toolbar-grid">
{companies.length > 0 && (
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
<div className="request-filter-row">
<button className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany('')}>All</button>
{companies.map(co => (
<button key={co.id} className={`btn btn-sm ${filterCompany === co.id ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany(f => f === co.id ? '' : co.id)}>{co.name}</button>
))}
</div>
</div>
)}
{requesterNames.length > 0 && (
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
<div className="request-filter-row">
<button className={`btn btn-sm ${!filterUser ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser('')}>All</button>
{requesterNames.map(name => (
<button key={name} className={`btn btn-sm ${filterUser === name ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser(f => f === name ? '' : name)}>{name}</button>
))}
</div>
</div>
)}
</div>
</div>
)}
{submissions.length === 0 ? (
<div className="empty-state"><h3>No requests yet</h3><p>Client requests will appear here.</p></div>
) : filteredGroups.length === 0 ? (
<div className="empty-state"><h3>No matching requests</h3><p>Try clearing the current company or requester filters.</p></div>
) : (
<div>
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{teamTabs.map(tab => (
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
{tab.label} ({tab.groups.length})
</button>
))}
</div>
{currentGroups.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 20px' }}>
<h3>No {teamTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests</h3>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Project</th><th>Name</th><th>Revision</th><th>Request Type</th><th>Client</th><th>Deadline</th><th>Status</th>
</tr>
</thead>
<tbody>{currentGroups.map(group => renderRow(group))}</tbody>
</table>
</div>
)}
</div>
)}
</Layout>
);
}
// ── External render ────────────────────────────────────────────────────
if (isExternal) {
const isFullyClosedExt = (task) => task?.status === 'client_approved' && paidTaskIds.has(task.id);
const latestTaskGroupsExt = 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);
return { task, primary: deadlineSource, group: latestGroup };
}).filter(Boolean);
const projectNames = [...new Map(latestTaskGroupsExt.map(({ task }) => { const p = projects.find(proj => proj.id === task.project_id); return p ? [p.id, p] : null; }).filter(Boolean)).values()];
const requesterNamesExt = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
const filteredGroupsExt = latestTaskGroupsExt.filter(({ task, group }) => {
if (filterProject && task.project_id !== filterProject) return false;
if (filterRequester && !group.some(s => s.submitted_by_name === filterRequester)) 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 extTabs = [
{ id: 'active', label: 'Active', groups: filteredGroupsExt.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review') },
{ id: 'client-review', label: 'Client Review', groups: filteredGroupsExt.filter(({ task }) => task?.status === 'client_review') },
{ id: 'completed', label: 'Completed', groups: filteredGroupsExt.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedExt(task)) },
{ id: 'closed', label: 'Fully Closed', groups: filteredGroupsExt.filter(({ task }) => isFullyClosedExt(task)) },
];
const currentExtGroups = extTabs.find(t => t.id === activeTab)?.groups || [];
const renderExtRow = ({ task, primary }) => {
const project = projects.find(p => p.id === task?.project_id);
const isCompleted = task?.status === 'client_approved';
const isFullyClosed = isFullyClosedExt(task);
return (
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
<td style={{ fontWeight: 600 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{task?.title || primary.service_type}</span>
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
</div>
</td>
<td>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</td>
<td>{primary.service_type || 'Request'}</td>
<td>{formatDateOnly(primary.deadline, 'Not specified')}</td>
<td>{isFullyClosed ? <span className="badge badge-client_approved">Paid & Closed</span> : isCompleted ? <span className="badge badge-client_approved">Completed</span> : <StatusBadge status={task?.status || 'not_started'} />}</td>
</tr>
);
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Requests</div>
<div className="page-subtitle">All tasks in your assigned projects.</div>
</div>
</div>
{(projectNames.length > 0 || requesterNamesExt.length > 0) && (
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
<div className="request-toolbar-grid">
{projectNames.length > 0 && (
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Project</div>
<div className="request-filter-row">
<button className={`btn btn-sm ${!filterProject ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterProject('')}>All</button>
{projectNames.map(p => (
<button key={p.id} className={`btn btn-sm ${filterProject === p.id ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterProject(f => f === p.id ? '' : p.id)}>{p.name}</button>
))}
</div>
</div>
)}
{requesterNamesExt.length > 0 && (
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
<div className="request-filter-row">
<button className={`btn btn-sm ${!filterRequester ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterRequester('')}>All</button>
{requesterNamesExt.map(name => (
<button key={name} className={`btn btn-sm ${filterRequester === name ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterRequester(f => f === name ? '' : name)}>{name}</button>
))}
</div>
</div>
)}
</div>
</div>
)}
{submissions.length === 0 ? (
<div className="empty-state"><h3>No requests yet</h3><p>Tasks will appear here once Fourge assigns you to a project.</p></div>
) : filteredGroupsExt.length === 0 ? (
<div className="empty-state"><h3>No matching requests</h3><p>Try clearing the current filters.</p></div>
) : (
<div>
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{extTabs.map(tab => (
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
{tab.label} ({tab.groups.length})
</button>
))}
</div>
{currentExtGroups.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 20px' }}>
<h3>No {extTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests</h3>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr><th>Project</th><th>Name</th><th>Revision</th><th>Request Type</th><th>Deadline</th><th>Status</th></tr>
</thead>
<tbody>{currentExtGroups.map(group => renderExtRow(group))}</tbody>
</table>
</div>
)}
</div>
)}
{error && <div className="card" style={{ color: 'var(--danger)', marginTop: 16 }}>{error}</div>}
</Layout>
);
}
// ── Client render ──────────────────────────────────────────────────────
const paidInvoiceIds = new Set(clientInvoices.filter(inv => inv.status === 'paid').map(inv => inv.id));
const clientPaidTaskIds = new Set(clientInvoiceItems.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id)).map(item => item.task_id));
const isFullyClosedClient = (task) => task?.status === 'client_approved' && Boolean(task?.invoiced) && clientPaidTaskIds.has(task.id);
const activeTasks = tasks.filter(t => t.status !== 'client_review' && t.status !== 'client_approved');
const reviewTasks = tasks.filter(t => t.status === 'client_review');
const completedTasks = tasks.filter(t => t.status === 'client_approved' && !isFullyClosedClient(t));
const closedTasks = tasks.filter(t => isFullyClosedClient(t));
const clientTabs = [
{ id: 'active', label: 'Active', count: activeTasks.length, tasks: activeTasks, closed: false, emptyTitle: 'No active requests' },
{ id: 'client-review', label: 'Client Review', count: reviewTasks.length, tasks: reviewTasks, closed: false, emptyTitle: 'No requests in review' },
{ id: 'completed', label: 'Completed', count: completedTasks.length, tasks: completedTasks, closed: false, emptyTitle: 'No completed requests' },
{ id: 'closed', label: 'Fully Closed', count: closedTasks.length, tasks: closedTasks, closed: true, emptyTitle: 'No fully closed requests' },
];
const renderClientTaskRow = (task, showClosedStatus = false, isLast = false) => {
const taskSubs = submissions.filter(s => s.task_id === task.id);
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
const latestSub = taskSubs[taskSubs.length - 1];
const hasRevision = taskSubs.length > 1 && latestSub?.submitted_by_name !== initialSub?.submitted_by_name;
return (
<div key={task.id} className="interactive-row" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: isLast ? 'none' : '1px solid var(--border)', cursor: 'pointer' }} onClick={() => navigate(`/requests/${task.id}`)}>
<div>
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
{task.title}{' '}
<span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
</span>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
{task.submitted_at && `${new Date(task.submitted_at).toLocaleDateString()} · `}Submitted by {initialSub?.submitted_by_name || 'Unknown'}
{hasRevision && ` · Last updated by ${latestSub.submitted_by_name}`}
</div>
</div>
<div className="flex items-center gap-3">
{showClosedStatus ? <span className="badge badge-client_approved">Paid & Closed</span> : <StatusBadge status={task.status} />}
</div>
</div>
);
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">My Requests</div>
<div className="page-subtitle">Requests you have submitted.</div>
</div>
<button className="btn btn-primary" onClick={() => navigate('/new-request')}>+ New Request</button>
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
<div className="stat-card stat-card-highlight">
<div className="stat-value">{projects.length}</div>
<div className="stat-label">Projects</div>
</div>
<div className="stat-card">
<div className="stat-value">{activeTasks.length + reviewTasks.length}</div>
<div className="stat-label">Active Requests</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: reviewTasks.length > 0 ? 'var(--accent)' : undefined }}>{reviewTasks.length}</div>
<div className="stat-label">Awaiting Review</div>
</div>
<div className="stat-card">
<div className="stat-value">{completedTasks.length}</div>
<div className="stat-label">Completed</div>
</div>
<div className="stat-card">
<div className="stat-value">{closedTasks.length}</div>
<div className="stat-label">Fully Closed</div>
</div>
</div>
{projects.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📋</div>
<h3>No requests yet</h3>
<p>Submit a new request to get started.</p>
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => navigate('/new-request')}>Submit Request</button>
</div>
) : (
<div>
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{clientTabs.map(tab => (
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
{tab.label} ({tab.count})
</button>
))}
</div>
{clientTabs.filter(tab => tab.id === activeTab).map(section => {
const sectionProjects = projects.filter(project => section.tasks.some(task => task.project?.id === project.id));
if (sectionProjects.length === 0) {
return (
<div key={section.id} className="empty-state">
<h3>{section.emptyTitle}</h3>
{section.closed && <p>Requests move here once they are completed, invoiced, and paid.</p>}
</div>
);
}
return (
<div key={section.id}>
{sectionProjects.map(project => {
const projectTasks = section.tasks.filter(task => task.project?.id === project.id);
return (
<div key={project.id} className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
<div className="interactive-panel-toggle" style={{ cursor: 'default', padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
<div className="request-card-title">{project.name}</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{projectTasks.map((task, index) => renderClientTaskRow(task, section.closed, index === projectTasks.length - 1))}
</div>
</div>
);
})}
</div>
);
})}
</div>
)}
</Layout>
);
}