561 lines
24 KiB
React
Executable File
561 lines
24 KiB
React
Executable File
import { useState, useEffect } from 'react';
|
||
import { Link, useNavigate } from 'react-router-dom';
|
||
import Layout from '../../components/Layout';
|
||
import StatusBadge from '../../components/StatusBadge';
|
||
import { supabase } from '../../lib/supabase';
|
||
import { useAuth } from '../../context/AuthContext';
|
||
import { serviceTypes } from '../../data/mockData';
|
||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||
import { withTimeout } from '../../lib/withTimeout';
|
||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||
import { addDaysToDateOnly, formatDateOnly, getTodayDateOnlyEST } from '../../lib/dates';
|
||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
|
||
|
||
const EMPTY_FORM = () => ({ companyId: '', project: '', serviceType: '', title: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
||
|
||
export default function Requests() {
|
||
const navigate = useNavigate();
|
||
const { currentUser } = useAuth();
|
||
const cached = readPageCache('team_requests');
|
||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||
const [companies, setCompanies] = useState(() => cached?.companies || []);
|
||
const [invoices, setInvoices] = useState(() => cached?.invoices || []);
|
||
const [invoiceItems, setInvoiceItems] = useState(() => cached?.invoiceItems || []);
|
||
const [companyUsers, setCompanyUsers] = useState([]);
|
||
const [loading, setLoading] = useState(() => !cached);
|
||
const [activeTab, setActiveTab] = useState('active');
|
||
const [filterCompany, setFilterCompany] = useState('');
|
||
const [filterUser, setFilterUser] = useState('');
|
||
|
||
const [showAddForm, setShowAddForm] = useState(false);
|
||
const [addForm, setAddForm] = useState(EMPTY_FORM());
|
||
const [formProjects, setFormProjects] = useState([]);
|
||
const [customProjectNames, setCustomProjectNames] = useState([]);
|
||
const [isTypingProject, setIsTypingProject] = useState(false);
|
||
const [newProjectName, setNewProjectName] = useState('');
|
||
const [addSaving, setAddSaving] = useState(false);
|
||
const [addError, setAddError] = useState('');
|
||
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
|
||
const requesterOptions = [
|
||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
||
...companyUsers.filter(user => user.id !== currentUser?.id),
|
||
];
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, []);
|
||
|
||
async function load() {
|
||
try {
|
||
const [{ data: subs }, { data: t }, { data: p }, { data: co }, { data: inv }, { data: itemRows }] = await withTimeout(Promise.all([
|
||
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
|
||
supabase.from('tasks').select('*'),
|
||
supabase.from('projects').select('*'),
|
||
supabase.from('companies').select('id, name'),
|
||
supabase.from('invoices').select('id, status'),
|
||
supabase.from('invoice_items').select('task_id, invoice_id'),
|
||
]), 12000, 'Requests load');
|
||
setSubmissions(subs || []);
|
||
setTasks(t || []);
|
||
setProjects(p || []);
|
||
setCompanies(co || []);
|
||
setInvoices(inv || []);
|
||
setInvoiceItems(itemRows || []);
|
||
writePageCache('team_requests', {
|
||
submissions: subs || [],
|
||
tasks: t || [],
|
||
projects: p || [],
|
||
companies: co || [],
|
||
invoices: inv || [],
|
||
invoiceItems: itemRows || [],
|
||
});
|
||
const paidInvoiceIds = new Set((inv || []).filter(invoice => invoice.status === 'paid').map(invoice => invoice.id));
|
||
const closedTaskIds = new Set(
|
||
(itemRows || [])
|
||
.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id))
|
||
.map(item => item.task_id)
|
||
);
|
||
} catch (error) {
|
||
console.error('Requests load failed:', error);
|
||
setSubmissions([]);
|
||
setTasks([]);
|
||
setProjects([]);
|
||
setCompanies([]);
|
||
setInvoices([]);
|
||
setInvoiceItems([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
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((error) => {
|
||
console.error('Request form load failed:', error);
|
||
setFormProjects([]);
|
||
setCompanyUsers([]);
|
||
});
|
||
}, [addForm.companyId]);
|
||
|
||
const setAdd = (field) => (e) => setAddForm(f => ({ ...f, [field]: e.target.value }));
|
||
|
||
const handleAddProjectName = () => {
|
||
const name = newProjectName.trim();
|
||
if (!name) return;
|
||
if (!customProjectNames.includes(name) && !formProjects.some(p => p.name === name)) {
|
||
setCustomProjectNames(prev => [...prev, name]);
|
||
}
|
||
setAddForm(f => ({ ...f, project: name }));
|
||
setIsTypingProject(false);
|
||
setNewProjectName('');
|
||
};
|
||
|
||
const handleAddRequest = async (e) => {
|
||
e.preventDefault();
|
||
if (addSaving) return;
|
||
setAddSaving(true);
|
||
setAddError('');
|
||
try {
|
||
const requester = requesterOptions.find(user => user.id === addForm.requestedBy);
|
||
if (!requester) throw new Error('Please select who requested this task.');
|
||
const projectName = addForm.project.trim();
|
||
if (!projectName) throw new Error('Please select or create a project.');
|
||
const resolvedProject = await findOrCreateProject(addForm.companyId, projectName, formProjects);
|
||
if (!formProjects.some(project => project.id === resolvedProject.id)) {
|
||
setFormProjects(prev => [...prev, { id: resolvedProject.id, name: resolvedProject.name }]);
|
||
}
|
||
if (!projects.some(project => project.id === resolvedProject.id)) {
|
||
setProjects(prev => [...prev, resolvedProject]);
|
||
}
|
||
|
||
const { task } = await createTaskForRequest({
|
||
projectId: resolvedProject.id,
|
||
title: addForm.title.trim() || addForm.serviceType,
|
||
requestKey: addRequestKey,
|
||
});
|
||
if (!task) throw new Error('Failed to create task.');
|
||
|
||
const { submission: sub } = await createInitialSubmissionForRequest({
|
||
taskId: task.id,
|
||
requestKey: addRequestKey,
|
||
isHot: addForm.isHot,
|
||
serviceType: addForm.serviceType,
|
||
deadline: addForm.deadline,
|
||
description: addForm.description,
|
||
submittedBy: requester.id,
|
||
submittedByName: requester.name.replace(' (You)', ''),
|
||
});
|
||
if (!sub) throw new Error('Failed to create submission.');
|
||
|
||
// Refresh list
|
||
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
|
||
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
|
||
supabase.from('tasks').select('*'),
|
||
]);
|
||
setSubmissions(newSubs || []);
|
||
setTasks(newTasks || []);
|
||
|
||
setShowAddForm(false);
|
||
setAddForm(EMPTY_FORM());
|
||
setAddRequestKey(crypto.randomUUID());
|
||
setCustomProjectNames([]);
|
||
setIsTypingProject(false);
|
||
setNewProjectName('');
|
||
} catch (err) {
|
||
setAddError(err.message);
|
||
} finally {
|
||
setAddSaving(false);
|
||
}
|
||
};
|
||
|
||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||
|
||
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
||
const paidInvoiceIds = new Set(invoices.filter(invoice => invoice.status === 'paid').map(invoice => invoice.id));
|
||
const paidTaskIds = 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) && paidTaskIds.has(task.id);
|
||
const latestTaskGroups = tasks.map(task => {
|
||
const taskSubs = submissions.filter(sub => sub.task_id === task.id);
|
||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||
if (!deadlineSource) return null;
|
||
|
||
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
||
const latestGroup = taskSubs.filter(sub => sub.version_number === currentVersion);
|
||
return { task, primary: deadlineSource, group: latestGroup };
|
||
}).filter(Boolean);
|
||
|
||
const filteredGroups = latestTaskGroups.filter(({ task, group }) => {
|
||
const project = projects.find(p => p.id === task?.project_id);
|
||
if (filterCompany && project?.company_id !== filterCompany) return false;
|
||
if (filterUser && !group.some(s => s.submitted_by_name === filterUser)) return false;
|
||
return true;
|
||
}).sort((a, b) => {
|
||
const aLatest = Math.max(...a.group.map(s => new Date(s.submitted_at).getTime()));
|
||
const bLatest = Math.max(...b.group.map(s => new Date(s.submitted_at).getTime()));
|
||
return bLatest - aLatest;
|
||
});
|
||
|
||
const activeGroups = filteredGroups.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review');
|
||
const clientReviewGroups = filteredGroups.filter(({ task }) => task?.status === 'client_review');
|
||
const completedGroups = filteredGroups.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedTask(task));
|
||
const closedGroups = filteredGroups.filter(({ task }) => isFullyClosedTask(task));
|
||
const renderRow = ({ task, primary }) => {
|
||
const project = projects.find(p => p.id === task?.project_id);
|
||
const company = companies.find(co => co.id === project?.company_id);
|
||
const isCompleted = task?.status === 'client_approved';
|
||
const isFullyClosed = isFullyClosedTask(task);
|
||
const revisionLabel = `R${String(primary.version_number ?? 0).padStart(2, '0')}`;
|
||
const deadline = formatDateOnly(primary.deadline, 'Not specified');
|
||
|
||
return (
|
||
<tr key={task.id} onClick={() => task && navigate(`/tasks/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
|
||
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
|
||
<td style={{ fontWeight: 600 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
<span>{task?.title || primary.service_type}</span>
|
||
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
||
</div>
|
||
</td>
|
||
<td>{revisionLabel}</td>
|
||
<td>{primary.service_type || 'Request'}</td>
|
||
<td>
|
||
{company
|
||
? <Link to={`/companies/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link>
|
||
: 'No client'}
|
||
</td>
|
||
<td>{deadline}</td>
|
||
<td>{isFullyClosed ? <span className="badge badge-client_approved">Paid & Closed</span> : isCompleted ? <span className="badge badge-client_approved">Completed</span> : <StatusBadge status={task?.status || 'not_started'} />}</td>
|
||
</tr>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Layout>
|
||
<div className="page-header">
|
||
<div>
|
||
<div className="page-title">Requests</div>
|
||
<div className="page-subtitle">Track incoming requests, revisions, and completed jobs in one place.</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
|
||
{showAddForm ? 'Cancel' : '+ Add Request'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{showAddForm && (
|
||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
|
||
<div className="card-title">Add Request</div>
|
||
<form onSubmit={handleAddRequest}>
|
||
<div className="grid-2">
|
||
<div className="form-group">
|
||
<label>Company *</label>
|
||
<select value={addForm.companyId} onChange={setAdd('companyId')} required>
|
||
<option value="">Select company...</option>
|
||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Project *</label>
|
||
{isTypingProject ? (
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<input
|
||
type="text"
|
||
placeholder="Enter project name..."
|
||
value={newProjectName}
|
||
onChange={e => setNewProjectName(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProjectName(); } }}
|
||
autoFocus
|
||
style={{ flex: 1 }}
|
||
/>
|
||
<button type="button" className="btn btn-primary btn-sm" onClick={handleAddProjectName} disabled={!newProjectName.trim()}>Add</button>
|
||
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
||
</div>
|
||
) : (
|
||
<select
|
||
value={addForm.project}
|
||
onChange={e => {
|
||
if (e.target.value === '__new__') { setIsTypingProject(true); setAddForm(f => ({ ...f, project: '' })); }
|
||
else { setAddForm(f => ({ ...f, project: e.target.value })); }
|
||
}}
|
||
required
|
||
disabled={!addForm.companyId}
|
||
>
|
||
<option value="">{addForm.companyId ? 'Select project...' : 'Select company first'}</option>
|
||
{[...formProjects.map(p => p.name), ...customProjectNames.filter(n => !formProjects.some(p => p.name === n))].map(name => (
|
||
<option key={name} value={name}>{name}</option>
|
||
))}
|
||
{addForm.companyId && <option value="__new__">+ Create new project...</option>}
|
||
</select>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid-2">
|
||
<div className="form-group">
|
||
<label>Service Type *</label>
|
||
<select value={addForm.serviceType} onChange={setAdd('serviceType')} required>
|
||
<option value="">Select service...</option>
|
||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||
<input type="date" value={addForm.deadline} onChange={setAdd('deadline')} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-group" style={{ marginTop: -4 }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={addForm.isHot}
|
||
onChange={e => setAddForm(f => ({ ...f, isHot: e.target.checked }))}
|
||
/>
|
||
<span>Mark as Hot</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>Requested By *</label>
|
||
<select value={addForm.requestedBy} onChange={setAdd('requestedBy')} disabled={!addForm.companyId} required>
|
||
<option value="">{addForm.companyId ? 'Select requester...' : 'Select company first'}</option>
|
||
{requesterOptions.map(user => (
|
||
<option key={user.id} value={user.id}>
|
||
{user.name}{user.email ? ` (${user.email})` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>Title <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional — defaults to service type)</span></label>
|
||
<input type="text" placeholder={addForm.serviceType || 'e.g. Brand Book — 2026'} value={addForm.title} onChange={setAdd('title')} />
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>Description *</label>
|
||
<textarea placeholder="Notes on the request..." value={addForm.description} onChange={setAdd('description')} style={{ minHeight: 100 }} required />
|
||
</div>
|
||
|
||
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {addError}</div>}
|
||
|
||
<div className="action-buttons">
|
||
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Adding...' : 'Add Request'}</button>
|
||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm(EMPTY_FORM()); setCustomProjectNames([]); setIsTypingProject(false); setNewProjectName(''); setAddError(''); }}>Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
{(companies.length > 0 || requesterNames.length > 0) && (
|
||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||
<div className="request-toolbar-grid">
|
||
{companies.length > 0 && (
|
||
<div className="request-toolbar-section">
|
||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
||
<div className="request-filter-row">
|
||
<button className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany('')}>All</button>
|
||
{companies.map(co => (
|
||
<button
|
||
key={co.id}
|
||
className={`btn btn-sm ${filterCompany === co.id ? 'btn-primary' : 'btn-outline'}`}
|
||
onClick={() => setFilterCompany(f => f === co.id ? '' : co.id)}
|
||
>
|
||
{co.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{requesterNames.length > 0 && (
|
||
<div className="request-toolbar-section">
|
||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
|
||
<div className="request-filter-row">
|
||
<button className={`btn btn-sm ${!filterUser ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser('')}>All</button>
|
||
{requesterNames.map(name => (
|
||
<button
|
||
key={name}
|
||
className={`btn btn-sm ${filterUser === name ? 'btn-primary' : 'btn-outline'}`}
|
||
onClick={() => setFilterUser(f => f === name ? '' : name)}
|
||
>
|
||
{name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{submissions.length === 0 ? (
|
||
<div className="empty-state">
|
||
<h3>No requests yet</h3>
|
||
<p>Client requests will appear here.</p>
|
||
</div>
|
||
) : filteredGroups.length === 0 ? (
|
||
<div className="empty-state">
|
||
<h3>No matching requests</h3>
|
||
<p>Try clearing the current company or requester filters.</p>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||
{[
|
||
{ id: 'active', label: 'Active', count: activeGroups.length },
|
||
{ id: 'client-review', label: 'Client Review', count: clientReviewGroups.length },
|
||
{ id: 'completed', label: 'Completed', count: completedGroups.length },
|
||
{ id: 'closed', label: 'Fully Closed', count: closedGroups.length },
|
||
].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>
|
||
|
||
{activeTab === 'active' && (
|
||
<div>
|
||
{activeGroups.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||
<h3>No active 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>
|
||
{activeGroups.map(group => renderRow(group))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'client-review' && (
|
||
<div>
|
||
{clientReviewGroups.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||
<h3>No requests in client review</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>
|
||
{clientReviewGroups.map(group => renderRow(group))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'completed' && (
|
||
<div>
|
||
{completedGroups.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||
<h3>No completed 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>
|
||
{completedGroups.map(group => renderRow(group))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'closed' && (
|
||
<div>
|
||
{closedGroups.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||
<h3>No fully closed requests</h3>
|
||
<p>Requests move here once they are completed, invoiced, and paid.</p>
|
||
</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>
|
||
{closedGroups.map(group => renderRow(group))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Layout>
|
||
);
|
||
}
|