Files
fourge-portal/src/pages/RequestsPage.jsx
T
Krao Hasanee 283511bf3a Session 2026-05-28: profile page overhaul, nav fixes, dashboard activity links
- Fix nav links not working from profile page (useEffect infinite re-render via unstable profile object ref)
- Fix nav hover/active: gold icon highlight, no background change; active links non-clickable
- Fix hover layout shift: add border: 1px solid transparent to all interactive elements
- Header icon buttons (search, theme toggle) now highlight gold on hover
- Profile page: replace calendar with activity feed (60/40 grid), add stat cards (tasks completed, active projects, revision requests, submissions)
- Profile card: title field, icon rows for location/email/linkedin, member since + role bottom-right, edit button top-right
- Profile portrait: remove wrapper column, fix left-gap alignment
- Add profiles.title migration
- Dashboard recent activity: name → /profile/{id}, task → /requests/{id} (clickable links)
- Icon-only sidebar with gold active/hover state, pointer-events: none on active links
- layout.md updated with profile page geometry rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:32:46 -04:00

780 lines
50 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import { DashboardBanner } from '../lib/dashboardBanner';
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 { formatDateOnly, fmtShortDate } from '../lib/dates';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
import { sendEmail } from '../lib/email';
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
import FilterDropdown from '../components/FilterDropdown';
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>;
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 [loadError, setLoadError] = useState(false);
const [activeTab, setActiveTab] = useState('all');
const [viewMode, setViewMode] = useState(() => localStorage.getItem('requestsViewMode') || 'list');
const toggleView = () => setViewMode(v => { const n = v === 'list' ? 'grid' : 'list'; localStorage.setItem('requestsViewMode', n); return n; });
// ── 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 [filterCompany, setFilterCompany] = useState('');
const [filterUser, setFilterUser] = useState('');
const { sortKey: reqSortKey, sortDir: reqSortDir, toggle: reqToggle, sort: reqSort } = useSortable('submitted_at', 'desc');
const { sortKey: extSortKey, sortDir: extSortDir, toggle: extToggle, sort: extSort } = useSortable('submitted_at', 'desc');
const { sortKey: clientSortKey, sortDir: clientSortDir, toggle: clientToggle, sort: clientSort } = useSortable('submitted_at', 'desc');
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());
// ── 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('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'),
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);
setLoadError(true);
} finally {
setLoading(false);
}
}
if (teamCached) {
setSubmissions(teamCached.submissions || []);
setTasks(teamCached.tasks || []);
setProjects(teamCached.projects || []);
setCompanies(teamCached.companies || []);
setInvoices(teamCached.invoices || []);
setInvoiceItems(teamCached.invoiceItems || []);
setLoading(false);
}
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, company:companies(id, name)').order('created_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, invoiced, completed_at').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, company_id)').in('id', myTaskIds),
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type, service_type, deadline').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);
setLoadError(true);
} finally {
setLoading(false);
}
}
loadClient();
}
}, [isTeam, isExternal, isClient, currentUser?.id]); // 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 { 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);
}
};
const handleClientRequest = async (formData, files, existingProjects) => {
if (addSaving) return;
setAddSaving(true); setAddError('');
try {
const selectedCompany = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).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, deadline: formData.deadline,
description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name,
});
if (submission && files.length > 0) {
for (const file of files) {
const path = `${task.id}/${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); 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); throw new Error(`File record failed: ${fileErr.message}`); }
}
}
uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
}
sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: currentUser.name, clientEmail: currentUser.email,
company: selectedCompany?.name || '', serviceType: formData.serviceType,
projectName: formData.project, deadline: formData.deadline,
description: formData.description, taskId: task.id,
}).catch(() => {});
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').eq('submitted_by', currentUser.id).eq('type', 'initial'),
supabase.from('tasks').select('id, title, status, current_version, project_id, project:projects(name, company_id), invoiced').order('submitted_at', { ascending: false }),
]);
const myTaskIds = new Set((newSubs || []).map(s => s.task_id));
const myTasks = (newTasks || []).filter(t => myTaskIds.has(t.id));
setTasks(myTasks);
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>;
// ── Team render ────────────────────────────────────────────────────────
if (isTeam) {
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
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 }) => {
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 byStatus = (s) => filteredGroups.filter(({ task }) => task?.status === s);
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 serviceType = submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type
|| submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type
|| primary.service_type
|| '—';
return (
<tr key={task.id} onClick={() => task && navigate(`/requests/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
<td style={{ fontWeight: 400 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{task?.title || '—'}</span>
<span style={{ 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> : null}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{project?.name || '—'}</div>
</td>
<td style={{ color: 'var(--accent)' }}>{company ? <Link to={`/company/${company.id}`} className="table-link" style={{ color: 'var(--accent)' }} onClick={e => e.stopPropagation()}>{company.name}</Link> : 'No client'}</td>
<td>{serviceType}</td>
<td>{fmtShortDate(primary.deadline, 'Not specified')}</td>
<td style={{ color: 'var(--text-muted)' }}>{fmtShortDate(task?.completed_at)}</td>
<td><StatusBadge status={task?.status || 'not_started'} /></td>
</tr>
);
};
const teamTabs = [
{ id: 'all', label: 'All', groups: filteredGroups },
{ id: 'not_started', label: 'Not Started', groups: byStatus('not_started') },
{ id: 'in_progress', label: 'In Progress', groups: byStatus('in_progress') },
{ id: 'on_hold', label: 'On Hold', groups: byStatus('on_hold') },
{ id: 'client_review', label: 'In Review', groups: byStatus('client_review') },
{ id: 'client_approved', label: 'Approved', groups: byStatus('client_approved') },
{ id: 'invoiced', label: 'Invoiced', groups: byStatus('invoiced') },
{ id: 'paid', label: 'Paid', groups: byStatus('paid') },
];
const rawGroups = teamTabs.find(t => t.id === activeTab)?.groups || [];
const currentGroups = reqSort(rawGroups, ({ 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 === 'project') return project?.name || '';
if (key === 'title') return task?.title || '';
if (key === 'serviceType') return primary?.service_type || '';
if (key === 'revision') return primary?.version_number ?? 0;
if (key === 'client') return company?.name || '';
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 '';
});
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const teamActiveCount = latestTaskGroups.filter(({ task }) => !doneStatuses.has(task?.status)).length;
const teamCompletedCount = latestTaskGroups.filter(({ task }) => doneStatuses.has(task?.status)).length;
return (
<Layout>
<div className="page-header">
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Requests</div>
<div className="page-subtitle">{teamActiveCount} active &bull; {teamCompletedCount} completed</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
+ Add Request
</button>
</div>
{showAddForm && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 560, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)', maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>New Request</div>
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Add a request</div>
</div>
<button onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
</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>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
{companies.length > 0 && (
<select className="filter-select" style={{ width: 120 }} 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>
)}
{filterCompany && (() => { const co_projects = projects.filter(p => p.company_id === filterCompany); return co_projects.length > 0 ? (
<select className="filter-select" style={{ width: 120 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
<option value="">All Projects</option>
{co_projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
) : null; })()}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
<select className="filter-select" style={{ width: 120 }} value={activeTab} onChange={e => setActiveTab(e.target.value)}>
{teamTabs.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>
</div>
</div>
{submissions.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No requests yet.</div>
) : filteredGroups.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No matching requests.</div>
) : currentGroups.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {teamTabs.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 }}>
{currentGroups.map(({ task, primary }) => {
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
const serviceType = 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={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<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>{serviceType}</span>
<span>{fmtShortDate(primary?.deadline)}</span>
</div>
</div>
);
})}
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '25%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="title" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Name</SortTh>
<SortTh col="client" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Client</SortTh>
<SortTh col="serviceType" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Request Type</SortTh>
<SortTh col="deadline" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Deadline</SortTh>
<SortTh col="completed_at" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Approved</SortTh>
<SortTh col="status" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Status</SortTh>
</tr>
</thead>
<tbody>{currentGroups.map(group => renderRow(group))}</tbody>
</table>
</div>
)}
</Layout>
);
}
// ── External render ────────────────────────────────────────────────────
if (isExternal) {
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 byStatusExt = (s) => filteredGroupsExt.filter(({ task }) => task?.status === s);
const extTabs = [
{ id: 'all', label: 'All', groups: filteredGroupsExt },
{ id: 'not_started', label: 'Not Started', groups: byStatusExt('not_started') },
{ id: 'in_progress', label: 'In Progress', groups: byStatusExt('in_progress') },
{ id: 'on_hold', label: 'On Hold', groups: byStatusExt('on_hold') },
{ id: 'client_review', label: 'In Review', groups: byStatusExt('client_review') },
{ id: 'client_approved', label: 'Approved', groups: byStatusExt('client_approved') },
{ id: 'invoiced', label: 'Invoiced', groups: byStatusExt('invoiced') },
{ id: 'paid', label: 'Paid', groups: byStatusExt('paid') },
];
const rawExtGroups = extTabs.find(t => t.id === activeTab)?.groups || [];
const currentExtGroups = extSort(rawExtGroups, ({ task, primary }, key) => {
const project = projects.find(p => p.id === task?.project_id);
if (key === 'title') return task?.title || '';
if (key === 'serviceType') return submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary?.service_type || '';
if (key === 'revision') return primary?.version_number ?? 0;
if (key === 'client') return project?.company?.name || '';
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 '';
});
const renderExtRow = ({ task, primary }) => {
const project = projects.find(p => p.id === task?.project_id);
const extServiceType = submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary.service_type || '—';
return (
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 400 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{task?.title || '—'}</span>
<span style={{ 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> : null}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{project?.name || '—'}</div>
</td>
<td style={{ color: 'var(--accent)' }}>{project?.company?.name || '—'}</td>
<td>{extServiceType}</td>
<td>{fmtShortDate(primary.deadline, 'Not specified')}</td>
<td style={{ color: 'var(--text-muted)' }}>{fmtShortDate(task?.completed_at)}</td>
<td><StatusBadge status={task?.status || 'not_started'} /></td>
</tr>
);
};
const doneStatusesExt = new Set(['client_approved', 'invoiced', 'paid']);
const extActiveCount = latestTaskGroupsExt.filter(({ task }) => !doneStatusesExt.has(task?.status)).length;
const extCompletedCount = latestTaskGroupsExt.filter(({ task }) => doneStatusesExt.has(task?.status)).length;
return (
<Layout>
<div className="page-header">
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Requests</div>
<div className="page-subtitle">{extActiveCount} active &bull; {extCompletedCount} completed</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
{projectNames.length > 0 && (
<select className="filter-select" style={{ width: 120 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
<option value="">All Projects</option>
{projectNames.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: 120 }} value={activeTab} onChange={e => setActiveTab(e.target.value)}>
{extTabs.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>
</div>
</div>
{submissions.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No requests yet.</div>
) : filteredGroupsExt.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No matching requests.</div>
) : currentExtGroups.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {extTabs.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 }}>
{currentExtGroups.map(({ task, primary }) => {
const project = projects.find(p => p.id === task?.project_id);
const extServiceType = submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary?.service_type || '—';
return (
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<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>
{project?.company?.name && <div style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 6 }}>{project.company.name}</div>}
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', justifyContent: 'space-between' }}>
<span>{extServiceType}</span>
<span>{fmtShortDate(primary?.deadline)}</span>
</div>
</div>
);
})}
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '25%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="title" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Name</SortTh>
<SortTh col="client" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Client</SortTh>
<SortTh col="serviceType" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Request Type</SortTh>
<SortTh col="deadline" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Deadline</SortTh>
<SortTh col="completed_at" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Approved</SortTh>
<SortTh col="status" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Status</SortTh>
</tr>
</thead>
<tbody>{currentExtGroups.map(group => renderExtRow(group))}</tbody>
</table>
</div>
)}
{error && <div style={{ color: 'var(--danger)', marginTop: 16, fontSize: 13 }}>{error}</div>}
</Layout>
);
}
// ── Client render ──────────────────────────────────────────────────────
const clientCompanies = isClient
? (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name))
: [];
const clientRequesterNames = [...new Set(submissions.filter(s => s.type === 'initial').map(s => s.submitted_by_name).filter(Boolean))].sort();
const clientFilteredTasks = tasks.filter(task => {
if (filterCompany && task.project?.company_id !== filterCompany) return false;
if (filterProject && task.project?.id !== filterProject) return false;
return true;
});
const byStatusClientFiltered = (s) => clientFilteredTasks.filter(t => t.status === s);
const clientTabs = [
{ id: 'all', label: 'All', tasks: clientFilteredTasks },
{ id: 'not_started', label: 'Not Started', tasks: byStatusClientFiltered('not_started') },
{ id: 'in_progress', label: 'In Progress', tasks: byStatusClientFiltered('in_progress') },
{ id: 'on_hold', label: 'On Hold', tasks: byStatusClientFiltered('on_hold') },
{ id: 'client_review', label: 'In Review', tasks: byStatusClientFiltered('client_review') },
{ id: 'client_approved', label: 'Approved', tasks: byStatusClientFiltered('client_approved') },
{ id: 'invoiced', label: 'Invoiced', tasks: byStatusClientFiltered('invoiced') },
{ id: 'paid', label: 'Paid', tasks: byStatusClientFiltered('paid') },
];
const rawClientTasks = clientTabs.find(t => t.id === activeTab)?.tasks || [];
const currentClientTasks = clientSort(rawClientTasks, (task, key) => {
if (key === 'title') return task?.title || '';
if (key === 'serviceType') return submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type || '';
if (key === 'revision') return task?.current_version ?? 0;
if (key === 'client') return (clientCompanies.find(c => c.id === task.project?.company_id))?.name || '';
if (key === 'deadline') return submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.deadline || '';
if (key === 'completed_at') return task?.completed_at || '';
if (key === 'status') return task?.status || '';
if (key === 'submitted_at') return task?.submitted_at || '';
return '';
});
return (
<Layout>
<div className="page-header" style={{ flexShrink: 0 }}>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Requests</div>
<div className="page-subtitle">{clientFilteredTasks.filter(t => !['client_approved','invoiced','paid'].includes(t.status)).length} active &bull; {clientFilteredTasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length} completed</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
+ New Request
</button>
</div>
{showAddForm && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 560, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)', maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>New Request</div>
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Submit a request</div>
</div>
<button onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
</div>
<RequestForm
key={addFormKey}
companies={clientCompanies}
initialCompanyId={clientCompanies[0]?.id || ''}
showRequester={false}
onSubmit={handleClientRequest}
onCancel={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
saving={addSaving}
error={addError}
submitLabel="Submit Request"
/>
</div>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
{clientCompanies.length > 1 && (
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => { setFilterCompany(e.target.value); setFilterProject(''); }}>
<option value="">All Companies</option>
{clientCompanies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
)}
{filterCompany && (() => { const co_projects = projects.filter(p => p.company_id === filterCompany); return co_projects.length > 0 ? (
<select className="filter-select" style={{ width: 120 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
<option value="">All Projects</option>
{co_projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
) : null; })()}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
<select className="filter-select" style={{ width: 120 }} value={activeTab} onChange={e => setActiveTab(e.target.value)}>
{clientTabs.map(t => <option key={t.id} value={t.id}>{t.label} ({t.tasks.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>
</div>
</div>
{tasks.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No requests yet.</div>
) : currentClientTasks.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {clientTabs.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 }}>
{currentClientTasks.map(task => {
const clientSub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
const clientCo = clientCompanies.find(c => c.id === task.project?.company_id);
return (
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<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)' }}>{task.project?.name || '—'}</div>
</div>
<StatusBadge status={task.status} />
</div>
{clientCo && <div style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 6 }}>{clientCo.name}</div>}
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', justifyContent: 'space-between' }}>
<span>{clientSub?.service_type || '—'}</span>
<span>{fmtShortDate(clientSub?.deadline)}</span>
</div>
</div>
);
})}
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '25%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="title" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Name</SortTh>
<SortTh col="client" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Client</SortTh>
<SortTh col="serviceType" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Request Type</SortTh>
<SortTh col="deadline" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Deadline</SortTh>
<SortTh col="completed_at" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Approved</SortTh>
<SortTh col="status" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Status</SortTh>
</tr>
</thead>
<tbody>
{currentClientTasks.map(task => {
const clientSub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
const clientCo = clientCompanies.find(c => c.id === task.project?.company_id);
return (
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 400 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{task.title}</span>
<span style={{ color: 'var(--text-muted)' }}>{`R${String(task.current_version || 0).padStart(2, '0')}`}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{task.project?.name || '—'}</div>
</td>
<td style={{ color: 'var(--accent)' }}>{clientCo?.name || '—'}</td>
<td>{clientSub?.service_type || '—'}</td>
<td>{fmtShortDate(clientSub?.deadline)}</td>
<td style={{ color: 'var(--text-muted)' }}>{fmtShortDate(task.completed_at)}</td>
<td><StatusBadge status={task.status} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Layout>
);
}