Session 2026-05-20: UI fixes, invoice filtering, file browser, request approvals, sub invoice task scope
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+274
-320
@@ -2,16 +2,16 @@ import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import RequestForm from '../components/RequestForm';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||
import { withTimeout } from '../lib/withTimeout';
|
||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
|
||||
import { addDaysToDateOnly, formatDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
||||
import { formatDateOnly } from '../lib/dates';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
|
||||
|
||||
const EMPTY_FORM = () => ({ companyId: '', project: '', serviceType: '', title: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
||||
import { sendEmail } from '../lib/email';
|
||||
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
|
||||
|
||||
export default function RequestsPage() {
|
||||
const { currentUser } = useAuth();
|
||||
@@ -30,22 +30,17 @@ export default function RequestsPage() {
|
||||
return true;
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
|
||||
// ── Team-only state ────────────────────────────────────────────────────
|
||||
const teamCached = isTeam ? readPageCache('team_requests') : null;
|
||||
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
|
||||
const [invoices, setInvoices] = useState(() => teamCached?.invoices || []);
|
||||
const [invoiceItems, setInvoiceItems] = useState(() => teamCached?.invoiceItems || []);
|
||||
const [companyUsers, setCompanyUsers] = useState([]);
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [filterUser, setFilterUser] = useState('');
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addForm, setAddForm] = useState(EMPTY_FORM());
|
||||
const [formProjects, setFormProjects] = useState([]);
|
||||
const [customProjectNames, setCustomProjectNames] = useState([]);
|
||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [addFormKey, setAddFormKey] = useState(0);
|
||||
const [addSaving, setAddSaving] = useState(false);
|
||||
const [addError, setAddError] = useState('');
|
||||
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
|
||||
@@ -66,9 +61,9 @@ export default function RequestsPage() {
|
||||
async function loadTeam() {
|
||||
try {
|
||||
const [{ data: subs }, { data: t }, { data: p }, { data: co }, { data: inv }, { data: itemRows }] = await withTimeout(Promise.all([
|
||||
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
|
||||
supabase.from('tasks').select('*'),
|
||||
supabase.from('projects').select('*'),
|
||||
supabase.from('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'),
|
||||
@@ -102,7 +97,7 @@ export default function RequestsPage() {
|
||||
const [{ data: projectData }, { data: taskData }, { data: subData }, { data: paidItems }] = await withTimeout(
|
||||
Promise.all([
|
||||
supabase.from('projects').select('id, name, company_id').order('created_at', { ascending: false }),
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, invoiced').order('submitted_at', { ascending: false }),
|
||||
supabase.from('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'),
|
||||
]),
|
||||
@@ -166,58 +161,27 @@ export default function RequestsPage() {
|
||||
}
|
||||
}, [isTeam, isExternal, isClient, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Team: company change → reload form projects ────────────────────────
|
||||
const requesterOptions = isTeam ? [
|
||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
||||
...companyUsers.filter(user => user.id !== currentUser?.id),
|
||||
] : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTeam) return;
|
||||
setFormProjects([]); setCustomProjectNames([]); setCompanyUsers([]);
|
||||
setAddForm(f => ({ ...f, project: '', requestedBy: currentUser?.id || '' }));
|
||||
setIsTypingProject(false); setNewProjectName('');
|
||||
if (!addForm.companyId) return;
|
||||
withTimeout(Promise.all([
|
||||
supabase.from('projects').select('id, name').eq('company_id', addForm.companyId).order('name'),
|
||||
supabase.from('profiles').select('id, name, email').eq('company_id', addForm.companyId).eq('role', 'client').order('name'),
|
||||
]), 10000, 'Request form load').then(([projectsResult, usersResult]) => {
|
||||
setFormProjects(projectsResult.data || []);
|
||||
setCompanyUsers(usersResult.data || []);
|
||||
}).catch(() => { setFormProjects([]); setCompanyUsers([]); });
|
||||
}, [addForm.companyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleAddProjectName = () => {
|
||||
const name = newProjectName.trim();
|
||||
if (!name) return;
|
||||
if (!customProjectNames.includes(name) && !formProjects.some(p => p.name === name)) setCustomProjectNames(prev => [...prev, name]);
|
||||
setAddForm(f => ({ ...f, project: name }));
|
||||
setIsTypingProject(false); setNewProjectName('');
|
||||
};
|
||||
|
||||
const handleAddRequest = async (e) => {
|
||||
e.preventDefault();
|
||||
const handleAddRequest = async (formData, _files, existingProjects) => {
|
||||
if (addSaving) return;
|
||||
setAddSaving(true); setAddError('');
|
||||
try {
|
||||
const requester = requesterOptions.find(user => user.id === addForm.requestedBy);
|
||||
if (!requester) throw new Error('Please select who requested this task.');
|
||||
const projectName = addForm.project.trim();
|
||||
if (!projectName) throw new Error('Please select or create a project.');
|
||||
const resolvedProject = await findOrCreateProject(addForm.companyId, projectName, formProjects);
|
||||
if (!formProjects.some(p => p.id === resolvedProject.id)) setFormProjects(prev => [...prev, { id: resolvedProject.id, name: resolvedProject.name }]);
|
||||
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: addForm.title.trim() || addForm.serviceType, requestKey: addRequestKey });
|
||||
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: addForm.isHot, serviceType: addForm.serviceType, deadline: addForm.deadline, description: addForm.description, submittedBy: requester.id, submittedByName: requester.name.replace(' (You)', '') });
|
||||
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('*').order('submitted_at', { ascending: false }),
|
||||
supabase.from('tasks').select('*'),
|
||||
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); setAddForm(EMPTY_FORM()); setAddRequestKey(crypto.randomUUID());
|
||||
setCustomProjectNames([]); setIsTypingProject(false); setNewProjectName('');
|
||||
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
|
||||
} catch (err) {
|
||||
setAddError(err.message);
|
||||
} finally {
|
||||
@@ -225,16 +189,57 @@ export default function RequestsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const setAdd = (field) => (e) => setAddForm(f => ({ ...f, [field]: e.target.value }));
|
||||
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>;
|
||||
|
||||
// ── Team render ────────────────────────────────────────────────────────
|
||||
if (isTeam) {
|
||||
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
||||
const paidInvoiceIds = new Set(invoices.filter(inv => inv.status === 'paid').map(inv => inv.id));
|
||||
const paidIds = new Set(invoiceItems.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id)).map(item => item.task_id));
|
||||
const isFullyClosedTask = (task) => task?.status === 'client_approved' && Boolean(task?.invoiced) && paidIds.has(task.id);
|
||||
const latestTaskGroups = tasks.map(task => {
|
||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||
@@ -249,37 +254,41 @@ export default function RequestsPage() {
|
||||
if (filterUser && !group.some(s => s.submitted_by_name === filterUser)) return false;
|
||||
return true;
|
||||
}).sort((a, b) => Math.max(...b.group.map(s => new Date(s.submitted_at).getTime())) - Math.max(...a.group.map(s => new Date(s.submitted_at).getTime())));
|
||||
const activeGroups = filteredGroups.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review');
|
||||
const clientReviewGroups = filteredGroups.filter(({ task }) => task?.status === 'client_review');
|
||||
const completedGroups = filteredGroups.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedTask(task));
|
||||
const closedGroups = filteredGroups.filter(({ task }) => isFullyClosedTask(task));
|
||||
const 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 isCompleted = task?.status === 'client_approved';
|
||||
const isFullyClosed = isFullyClosedTask(task);
|
||||
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: 600 }}>{project?.name || 'No project'}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{project?.name || 'No project'}</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{task?.title || primary.service_type}</span>
|
||||
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
||||
<span>{task?.title || '—'}</span>
|
||||
{primary.is_hot ? <span className="badge badge-needs_revision">HOT</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{serviceType}</td>
|
||||
<td>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</td>
|
||||
<td>{primary.service_type || 'Request'}</td>
|
||||
<td>{company ? <Link to={`/company/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link> : 'No client'}</td>
|
||||
<td>{formatDateOnly(primary.deadline, 'Not specified')}</td>
|
||||
<td>{isFullyClosed ? <span className="badge badge-client_approved">Paid & Closed</span> : isCompleted ? <span className="badge badge-client_approved">Completed</span> : <StatusBadge status={task?.status || 'not_started'} />}</td>
|
||||
<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><StatusBadge status={task?.status || 'not_started'} /></td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
const teamTabs = [
|
||||
{ id: 'active', label: 'Active', groups: activeGroups },
|
||||
{ id: 'client-review', label: 'Client Review', groups: clientReviewGroups },
|
||||
{ id: 'completed', label: 'Completed', groups: completedGroups },
|
||||
{ id: 'closed', label: 'Fully Closed', groups: closedGroups },
|
||||
{ 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 currentGroups = teamTabs.find(t => t.id === activeTab)?.groups || [];
|
||||
|
||||
@@ -296,138 +305,73 @@ export default function RequestsPage() {
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
|
||||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
|
||||
<div className="card-title">Add Request</div>
|
||||
<form onSubmit={handleAddRequest}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Company *</label>
|
||||
<select value={addForm.companyId} onChange={setAdd('companyId')} required>
|
||||
<option value="">Select company...</option>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Project *</label>
|
||||
{isTypingProject ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input type="text" placeholder="Enter project name..." value={newProjectName} onChange={e => setNewProjectName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProjectName(); } }} autoFocus style={{ flex: 1 }} />
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={handleAddProjectName} disabled={!newProjectName.trim()}>Add</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<select value={addForm.project} onChange={e => { if (e.target.value === '__new__') { setIsTypingProject(true); setAddForm(f => ({ ...f, project: '' })); } else { setAddForm(f => ({ ...f, project: e.target.value })); } }} required disabled={!addForm.companyId}>
|
||||
<option value="">{addForm.companyId ? 'Select project...' : 'Select company first'}</option>
|
||||
{[...formProjects.map(p => p.name), ...customProjectNames.filter(n => !formProjects.some(p => p.name === n))].map(name => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
{addForm.companyId && <option value="__new__">+ Create new project...</option>}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Service Type *</label>
|
||||
<select value={addForm.serviceType} onChange={setAdd('serviceType')} required>
|
||||
<option value="">Select service...</option>
|
||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
)}
|
||||
{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>
|
||||
<div className="form-group">
|
||||
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<input type="date" value={addForm.deadline} onChange={setAdd('deadline')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginTop: -4 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={addForm.isHot} onChange={e => setAddForm(f => ({ ...f, isHot: e.target.checked }))} />
|
||||
<span>Mark as Hot</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Requested By *</label>
|
||||
<select value={addForm.requestedBy} onChange={setAdd('requestedBy')} disabled={!addForm.companyId} required>
|
||||
<option value="">{addForm.companyId ? 'Select requester...' : 'Select company first'}</option>
|
||||
{requesterOptions.map(user => <option key={user.id} value={user.id}>{user.name}{user.email ? ` (${user.email})` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Title <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional — defaults to service type)</span></label>
|
||||
<input type="text" placeholder={addForm.serviceType || 'e.g. Brand Book — 2026'} value={addForm.title} onChange={setAdd('title')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Description *</label>
|
||||
<textarea placeholder="Notes on the request..." value={addForm.description} onChange={setAdd('description')} style={{ minHeight: 100 }} required />
|
||||
</div>
|
||||
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {addError}</div>}
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Adding...' : 'Add Request'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm(EMPTY_FORM()); setCustomProjectNames([]); setIsTypingProject(false); setNewProjectName(''); setAddError(''); }}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(companies.length > 0 || requesterNames.length > 0) && (
|
||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||
<div className="request-toolbar-grid">
|
||||
{companies.length > 0 && (
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
||||
<div className="request-filter-row">
|
||||
<button className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany('')}>All</button>
|
||||
{companies.map(co => (
|
||||
<button key={co.id} className={`btn btn-sm ${filterCompany === co.id ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany(f => f === co.id ? '' : co.id)}>{co.name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{requesterNames.length > 0 && (
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
|
||||
<div className="request-filter-row">
|
||||
<button className={`btn btn-sm ${!filterUser ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser('')}>All</button>
|
||||
{requesterNames.map(name => (
|
||||
<button key={name} className={`btn btn-sm ${filterUser === name ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser(f => f === name ? '' : name)}>{name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submissions.length === 0 ? (
|
||||
<div className="empty-state"><h3>No requests yet</h3><p>Client requests will appear here.</p></div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="empty-state"><h3>No matching requests</h3><p>Try clearing the current company or requester filters.</p></div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{teamTabs.map(tab => (
|
||||
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
|
||||
{tab.label} ({tab.groups.length})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{currentGroups.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3>No {teamTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th><th>Name</th><th>Revision</th><th>Request Type</th><th>Client</th><th>Deadline</th><th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{currentGroups.map(group => renderRow(group))}</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{submissions.length === 0 ? (
|
||||
<div className="empty-state"><h3>No requests yet</h3><p>Client requests will appear here.</p></div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="empty-state"><h3>No matching requests</h3><p>Try clearing the current company or requester filters.</p></div>
|
||||
) : (
|
||||
<div 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>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
@@ -435,7 +379,6 @@ export default function RequestsPage() {
|
||||
|
||||
// ── External render ────────────────────────────────────────────────────
|
||||
if (isExternal) {
|
||||
const isFullyClosedExt = (task) => task?.status === 'client_approved' && paidTaskIds.has(task.id);
|
||||
const latestTaskGroupsExt = tasks.map(task => {
|
||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||
@@ -451,30 +394,35 @@ export default function RequestsPage() {
|
||||
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: 'active', label: 'Active', groups: filteredGroupsExt.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review') },
|
||||
{ id: 'client-review', label: 'Client Review', groups: filteredGroupsExt.filter(({ task }) => task?.status === 'client_review') },
|
||||
{ id: 'completed', label: 'Completed', groups: filteredGroupsExt.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedExt(task)) },
|
||||
{ id: 'closed', label: 'Fully Closed', groups: filteredGroupsExt.filter(({ task }) => isFullyClosedExt(task)) },
|
||||
{ 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 currentExtGroups = extTabs.find(t => t.id === activeTab)?.groups || [];
|
||||
const renderExtRow = ({ task, primary }) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const isCompleted = task?.status === 'client_approved';
|
||||
const isFullyClosed = isFullyClosedExt(task);
|
||||
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: 600 }}>{project?.name || 'No project'}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{project?.name || 'No project'}</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{task?.title || primary.service_type}</span>
|
||||
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
||||
<span>{task?.title || '—'}</span>
|
||||
{primary.is_hot ? <span className="badge badge-needs_revision">HOT</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{extServiceType}</td>
|
||||
<td>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</td>
|
||||
<td>{primary.service_type || 'Request'}</td>
|
||||
<td>{formatDateOnly(primary.deadline, 'Not specified')}</td>
|
||||
<td>{isFullyClosed ? <span className="badge badge-client_approved">Paid & Closed</span> : isCompleted ? <span className="badge badge-client_approved">Completed</span> : <StatusBadge status={task?.status || 'not_started'} />}</td>
|
||||
<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><StatusBadge status={task?.status || 'not_started'} /></td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -538,7 +486,7 @@ export default function RequestsPage() {
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Project</th><th>Name</th><th>Revision</th><th>Request Type</th><th>Deadline</th><th>Status</th></tr>
|
||||
<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>
|
||||
@@ -552,122 +500,128 @@ export default function RequestsPage() {
|
||||
}
|
||||
|
||||
// ── Client render ──────────────────────────────────────────────────────
|
||||
const paidInvoiceIds = new Set(clientInvoices.filter(inv => inv.status === 'paid').map(inv => inv.id));
|
||||
const clientPaidTaskIds = new Set(clientInvoiceItems.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id)).map(item => item.task_id));
|
||||
const isFullyClosedClient = (task) => task?.status === 'client_approved' && Boolean(task?.invoiced) && clientPaidTaskIds.has(task.id);
|
||||
const activeTasks = tasks.filter(t => t.status !== 'client_review' && t.status !== 'client_approved');
|
||||
const reviewTasks = tasks.filter(t => t.status === 'client_review');
|
||||
const completedTasks = tasks.filter(t => t.status === 'client_approved' && !isFullyClosedClient(t));
|
||||
const closedTasks = tasks.filter(t => isFullyClosedClient(t));
|
||||
const 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 (filterRequester) {
|
||||
const initialSub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||
if (initialSub?.submitted_by_name !== filterRequester) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const byStatusClientFiltered = (s) => clientFilteredTasks.filter(t => t.status === s);
|
||||
const clientTabs = [
|
||||
{ id: 'active', label: 'Active', count: activeTasks.length, tasks: activeTasks, closed: false, emptyTitle: 'No active requests' },
|
||||
{ id: 'client-review', label: 'Client Review', count: reviewTasks.length, tasks: reviewTasks, closed: false, emptyTitle: 'No requests in review' },
|
||||
{ id: 'completed', label: 'Completed', count: completedTasks.length, tasks: completedTasks, closed: false, emptyTitle: 'No completed requests' },
|
||||
{ id: 'closed', label: 'Fully Closed', count: closedTasks.length, tasks: closedTasks, closed: true, emptyTitle: 'No fully closed requests' },
|
||||
{ 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 renderClientTaskRow = (task, showClosedStatus = false, isLast = false) => {
|
||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
|
||||
const latestSub = taskSubs[taskSubs.length - 1];
|
||||
const hasRevision = taskSubs.length > 1 && latestSub?.submitted_by_name !== initialSub?.submitted_by_name;
|
||||
return (
|
||||
<div key={task.id} className="interactive-row" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: isLast ? 'none' : '1px solid var(--border)', cursor: 'pointer' }} onClick={() => navigate(`/requests/${task.id}`)}>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{task.title}{' '}
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
|
||||
</span>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{task.submitted_at && `${new Date(task.submitted_at).toLocaleDateString()} · `}Submitted by {initialSub?.submitted_by_name || 'Unknown'}
|
||||
{hasRevision && ` · Last updated by ${latestSub.submitted_by_name}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{showClosedStatus ? <span className="badge badge-client_approved">Paid & Closed</span> : <StatusBadge status={task.status} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const currentClientTasks = clientTabs.find(t => t.id === activeTab)?.tasks || [];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div className="page-header" style={{ flexShrink: 0 }}>
|
||||
<div>
|
||||
<div className="page-title">My Requests</div>
|
||||
<div className="page-subtitle">Requests you have submitted.</div>
|
||||
<div className="page-title">Requests</div>
|
||||
<div className="page-subtitle">Track your active requests and their status.</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/new-request')}>+ New Request</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
|
||||
{showAddForm ? 'Cancel' : '+ New Request'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
<div className="stat-card stat-card-highlight">
|
||||
<div className="stat-value">{projects.length}</div>
|
||||
<div className="stat-label">Projects</div>
|
||||
{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>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{activeTasks.length + reviewTasks.length}</div>
|
||||
<div className="stat-label">Active Requests</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: reviewTasks.length > 0 ? 'var(--accent)' : undefined }}>{reviewTasks.length}</div>
|
||||
<div className="stat-label">Awaiting Review</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{completedTasks.length}</div>
|
||||
<div className="stat-label">Completed</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{closedTasks.length}</div>
|
||||
<div className="stat-label">Fully Closed</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<h3>No requests yet</h3>
|
||||
<p>Submit a new request to get started.</p>
|
||||
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => navigate('/new-request')}>Submit Request</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{clientTabs.map(tab => (
|
||||
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
|
||||
{tab.label} ({tab.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{clientTabs.filter(tab => tab.id === activeTab).map(section => {
|
||||
const sectionProjects = projects.filter(project => section.tasks.some(task => task.project?.id === project.id));
|
||||
if (sectionProjects.length === 0) {
|
||||
return (
|
||||
<div key={section.id} className="empty-state">
|
||||
<h3>{section.emptyTitle}</h3>
|
||||
{section.closed && <p>Requests move here once they are completed, invoiced, and paid.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={section.id}>
|
||||
{sectionProjects.map(project => {
|
||||
const projectTasks = section.tasks.filter(task => task.project?.id === project.id);
|
||||
return (
|
||||
<div key={project.id} className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
||||
<div className="interactive-panel-toggle" style={{ cursor: 'default', padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
|
||||
<div className="request-card-title">{project.name}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{projectTasks.map((task, index) => renderClientTaskRow(task, section.closed, index === projectTasks.length - 1))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user