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>
This commit is contained in:
Krao Hasanee
2026-05-28 15:32:46 -04:00
parent 565d2ed4bc
commit 283511bf3a
48 changed files with 4151 additions and 1889 deletions
+376 -225
View File
@@ -1,6 +1,7 @@
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';
@@ -8,10 +9,16 @@ import { useAuth } from '../context/AuthContext';
import { readPageCache, writePageCache } from '../lib/pageCache';
import { withTimeout } from '../lib/withTimeout';
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
import { formatDateOnly } from '../lib/dates';
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();
@@ -30,7 +37,10 @@ export default function RequestsPage() {
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;
@@ -39,6 +49,9 @@ export default function RequestsPage() {
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);
@@ -77,7 +90,7 @@ export default function RequestsPage() {
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([]);
setLoadError(true);
} finally {
setLoading(false);
}
@@ -86,17 +99,19 @@ export default function RequestsPage() {
setSubmissions(teamCached.submissions || []);
setTasks(teamCached.tasks || []);
setProjects(teamCached.projects || []);
setCompanies(teamCached.companies || []);
setInvoices(teamCached.invoices || []);
setInvoiceItems(teamCached.invoiceItems || []);
setLoading(false);
} else {
loadTeam();
}
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('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'),
@@ -136,8 +151,8 @@ export default function RequestsPage() {
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('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),
]),
@@ -153,6 +168,7 @@ export default function RequestsPage() {
setProjects(Object.values(projectMap));
} catch (err) {
console.error('MyRequests load failed:', err);
setLoadError(true);
} finally {
setLoading(false);
}
@@ -236,6 +252,7 @@ export default function RequestsPage() {
};
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) {
@@ -248,10 +265,10 @@ export default function RequestsPage() {
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
return { task, primary: deadlineSource, group: latestGroup };
}).filter(Boolean);
const filteredGroups = latestTaskGroups.filter(({ task, group }) => {
const filteredGroups = latestTaskGroups.filter(({ task }) => {
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;
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);
@@ -264,18 +281,18 @@ export default function RequestsPage() {
|| '—';
return (
<tr key={task.id} onClick={() => task && navigate(`/requests/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
<td style={{ color: 'var(--text-muted)' }}>{project?.name || 'No project'}</td>
<td style={{ fontWeight: 600 }}>
<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(--text-muted)' }}>{serviceType}</td>
<td>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</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 style={{ color: 'var(--text-muted)' }}>{task?.completed_at ? new Date(task.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</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>
);
@@ -290,89 +307,144 @@ export default function RequestsPage() {
{ id: 'invoiced', label: 'Invoiced', groups: byStatus('invoiced') },
{ id: 'paid', label: 'Paid', groups: byStatus('paid') },
];
const currentGroups = teamTabs.find(t => t.id === activeTab)?.groups || [];
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>
<div className="page-title">Requests</div>
<div className="page-subtitle">Track incoming requests, revisions, and completed jobs in one place.</div>
<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(''); }}>
{showAddForm ? 'Cancel' : '+ Add Request'}
+ Add Request
</button>
</div>
{showAddForm && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
<div className="card-title">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 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>
)}
{!showAddForm && (
<>
{(companies.length > 0 || requesterNames.length > 0) && (
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap', flexShrink: 0 }}>
{companies.length > 0 && (
<select className="filter-select" value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
<option value="">All Companies</option>
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select>
)}
{requesterNames.length > 0 && (
<select className="filter-select" value={filterUser} onChange={e => setFilterUser(e.target.value)}>
<option value="">All Requesters</option>
{requesterNames.map(name => <option key={name} value={name}>{name}</option>)}
</select>
)}
<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 className="empty-state"><h3>No requests yet</h3><p>Client requests will appear here.</p></div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No requests yet.</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 className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 16, flexShrink: 0 }}>
{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 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>
{currentGroups.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>
No {teamTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.
</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<thead>
<tr>
<th>Project</th><th>Name</th><th>Request Type</th><th>Revision</th><th>Client</th><th>Deadline</th><th>Approved</th><th>Status</th>
</tr>
</thead>
<tbody>{currentGroups.map(group => renderRow(group))}</tbody>
</table>
</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>
);
}
@@ -405,96 +477,129 @@ export default function RequestsPage() {
{ id: 'invoiced', label: 'Invoiced', groups: byStatusExt('invoiced') },
{ id: 'paid', label: 'Paid', groups: byStatusExt('paid') },
];
const currentExtGroups = extTabs.find(t => t.id === activeTab)?.groups || [];
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={{ color: 'var(--text-muted)' }}>{project?.name || 'No project'}</td>
<td style={{ fontWeight: 600 }}>
<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(--text-muted)' }}>{extServiceType}</td>
<td>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</td>
<td>{formatDateOnly(primary.deadline, 'Not specified')}</td>
<td style={{ color: 'var(--text-muted)' }}>{task?.completed_at ? new Date(task.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</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>
<div className="page-title">Requests</div>
<div className="page-subtitle">All tasks in your assigned projects.</div>
<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>
{(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 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 className="empty-state"><h3>No requests yet</h3><p>Tasks will appear here once Fourge assigns you to a project.</p></div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No requests yet.</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 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>
{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>Request Type</th><th>Revision</th><th>Deadline</th><th>Approved</th><th>Status</th></tr>
</thead>
<tbody>{currentExtGroups.map(group => renderExtRow(group))}</tbody>
</table>
</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 className="card" style={{ color: 'var(--danger)', marginTop: 16 }}>{error}</div>}
{error && <div style={{ color: 'var(--danger)', marginTop: 16, fontSize: 13 }}>{error}</div>}
</Layout>
);
}
@@ -506,10 +611,7 @@ export default function RequestsPage() {
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 (filterRequester) {
const initialSub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
if (initialSub?.submitted_by_name !== filterRequester) return false;
}
if (filterProject && task.project?.id !== filterProject) return false;
return true;
});
const byStatusClientFiltered = (s) => clientFilteredTasks.filter(t => t.status === s);
@@ -523,106 +625,155 @@ export default function RequestsPage() {
{ id: 'invoiced', label: 'Invoiced', tasks: byStatusClientFiltered('invoiced') },
{ id: 'paid', label: 'Paid', tasks: byStatusClientFiltered('paid') },
];
const currentClientTasks = clientTabs.find(t => t.id === activeTab)?.tasks || [];
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>
<div className="page-title">Requests</div>
<div className="page-subtitle">Track your active requests and their status.</div>
<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(''); }}>
{showAddForm ? 'Cancel' : '+ New Request'}
+ New Request
</button>
</div>
{showAddForm && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
<div className="card-title">New Request</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 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>
)}
{!showAddForm && (
<>
{(clientCompanies.length > 1 || clientRequesterNames.length > 0) && (
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap', flexShrink: 0 }}>
{clientCompanies.length > 1 && (
<select className="filter-select" value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
<option value="">All Companies</option>
{clientCompanies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
)}
{clientRequesterNames.length > 0 && (
<select className="filter-select" value={filterRequester} onChange={e => setFilterRequester(e.target.value)}>
<option value="">All Requesters</option>
{clientRequesterNames.map(name => <option key={name} value={name}>{name}</option>)}
</select>
)}
<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 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={() => setShowAddForm(true)}>Submit Request</button>
<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 className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 16, flexShrink: 0 }}>
{clientTabs.map(tab => (
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
{tab.label} ({tab.tasks.length})
</button>
))}
</div>
{currentClientTasks.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>
No {clientTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.
</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<thead>
<tr>
<th>Project</th>
<th>Name</th>
<th>Revision</th>
<th>Approved</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{currentClientTasks.map(task => (
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ color: 'var(--text-muted)' }}>{task.project?.name || '—'}</td>
<td style={{ fontWeight: 600 }}>{task.title}</td>
<td>{`R${String(task.current_version || 0).padStart(2, '0')}`}</td>
<td style={{ color: 'var(--text-muted)' }}>{task.completed_at ? formatDateOnly(task.completed_at) : '—'}</td>
<td><StatusBadge status={task.status} /></td>
</tr>
))}
</tbody>
</table>
</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>
);
}