259 lines
11 KiB
React
259 lines
11 KiB
React
import { useEffect, useState } from 'react';
|
|
import { 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 { withTimeout } from '../../lib/withTimeout';
|
|
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
|
import { formatDateOnly } from '../../lib/dates';
|
|
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
|
|
|
export default function ExternalMyRequests() {
|
|
const { currentUser } = useAuth();
|
|
const navigate = useNavigate();
|
|
const cacheKey = `ext-requests:${currentUser?.id}`;
|
|
const cached = readPageCache(cacheKey, 3 * 60_000);
|
|
const [projects, setProjects] = useState(() => cached?.projects || []);
|
|
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
|
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
|
const [paidTaskIds, setPaidTaskIds] = useState(() => new Set(cached?.paidTaskIds || []));
|
|
const [loading, setLoading] = useState(() => !cached);
|
|
const [error, setError] = useState('');
|
|
const [activeTab, setActiveTab] = useState('active');
|
|
const [filterProject, setFilterProject] = useState('');
|
|
const [filterRequester, setFilterRequester] = useState('');
|
|
|
|
useEffect(() => {
|
|
async function load() {
|
|
if (!currentUser?.id) { setLoading(false); return; }
|
|
try {
|
|
const [
|
|
{ data: projectData, error: projectError },
|
|
{ data: taskData, error: taskError },
|
|
{ data: subData, error: subError },
|
|
{ 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('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'
|
|
);
|
|
|
|
if (projectError) throw projectError;
|
|
if (taskError) throw taskError;
|
|
if (subError) throw subError;
|
|
|
|
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(cacheKey, { 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);
|
|
}
|
|
}
|
|
load();
|
|
}, [currentUser?.id]);
|
|
|
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
|
|
const isFullyClosedTask = (task) => task?.status === 'client_approved' && paidTaskIds.has(task.id);
|
|
|
|
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 projectNames = [...new Map(
|
|
latestTaskGroups.map(({ task }) => {
|
|
const p = projects.find(p => p.id === task.project_id);
|
|
return p ? [p.id, p] : null;
|
|
}).filter(Boolean)
|
|
).values()];
|
|
|
|
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
|
|
|
const filteredGroups = latestTaskGroups.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) => {
|
|
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 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={() => navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}>
|
|
<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>{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>
|
|
);
|
|
};
|
|
|
|
const tabList = [
|
|
{ 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 },
|
|
];
|
|
const currentGroups = tabList.find(t => t.id === activeTab)?.groups || [];
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="page-header">
|
|
<div>
|
|
<div className="page-title">Requests</div>
|
|
<div className="page-subtitle">All tasks in your assigned projects.</div>
|
|
</div>
|
|
</div>
|
|
|
|
{(projectNames.length > 0 || requesterNames.length > 0) && (
|
|
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
|
<div className="request-toolbar-grid">
|
|
{projectNames.length > 0 && (
|
|
<div className="request-toolbar-section">
|
|
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Project</div>
|
|
<div className="request-filter-row">
|
|
<button className={`btn btn-sm ${!filterProject ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterProject('')}>All</button>
|
|
{projectNames.map(p => (
|
|
<button
|
|
key={p.id}
|
|
className={`btn btn-sm ${filterProject === p.id ? 'btn-primary' : 'btn-outline'}`}
|
|
onClick={() => setFilterProject(f => f === p.id ? '' : p.id)}
|
|
>
|
|
{p.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{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 ${!filterRequester ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterRequester('')}>All</button>
|
|
{requesterNames.map(name => (
|
|
<button
|
|
key={name}
|
|
className={`btn btn-sm ${filterRequester === name ? 'btn-primary' : 'btn-outline'}`}
|
|
onClick={() => setFilterRequester(f => f === name ? '' : name)}
|
|
>
|
|
{name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{submissions.length === 0 ? (
|
|
<div className="empty-state">
|
|
<h3>No requests yet</h3>
|
|
<p>Tasks will appear here once Fourge assigns you to a project.</p>
|
|
</div>
|
|
) : filteredGroups.length === 0 ? (
|
|
<div className="empty-state">
|
|
<h3>No matching requests</h3>
|
|
<p>Try clearing the current filters.</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
|
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
|
{tabList.map((tab, index) => (
|
|
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
|
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab(tab.id)}
|
|
style={{
|
|
background: 'none', border: 'none', padding: 0, margin: 0, cursor: 'pointer',
|
|
color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
|
font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit',
|
|
}}
|
|
>
|
|
{tab.label}
|
|
<span className="request-company-count" style={{ marginLeft: 6 }}>{tab.groups.length}</span>
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{currentGroups.length === 0 ? (
|
|
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
|
<h3>No {tabList.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>Deadline</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{currentGroups.map(group => renderRow(group))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{error && <div className="card" style={{ color: 'var(--danger)', marginTop: 16 }}>{error}</div>}
|
|
</Layout>
|
|
);
|
|
}
|