283511bf3a
- 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>
780 lines
50 KiB
React
780 lines
50 KiB
React
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 • {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 • {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 • {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>
|
||
);
|
||
}
|