Add Project Files section and show company name for external users on project detail

This commit is contained in:
Krao Hasanee
2026-05-14 15:01:32 -04:00
parent eee0885811
commit c32f9d1366
24 changed files with 1729 additions and 771 deletions
+188 -169
View File
@@ -1,60 +1,47 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
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';
const STATUS_ORDER = ['in_progress', 'not_started', 'client_review', 'needs_revision', 'on_hold', 'client_approved'];
function sortTasks(tasks) {
return [...tasks].sort((a, b) => {
const ai = STATUS_ORDER.indexOf(a.status);
const bi = STATUS_ORDER.indexOf(b.status);
if (ai !== bi) return ai - bi;
return String(a.title || '').localeCompare(String(b.title || ''));
});
}
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 [projects, setProjects] = useState([]);
const [tasks, setTasks] = useState([]);
const [poItemsByTaskId, setPoItemsByTaskId] = useState({});
const [loading, setLoading] = useState(true);
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 {
// 1. All projects this sub is a member of (RLS filters via project_members)
// 2. All tasks in those projects (RLS filters via project_members)
// 3. Their PO items — to show PO context (due date, PO#, amount) per task
const [
{ data: projectData, error: projectError },
{ data: taskData, error: taskError },
{ data: poItems, error: poError },
{ 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('projects')
.select('id, name, company:companies(name)')
.order('created_at', { ascending: false }),
supabase
.from('tasks')
.select('id, title, status, current_version, project_id')
.order('submitted_at', { ascending: false }),
supabase
.from('subcontractor_po_items')
.select(`
id, description, amount,
task_id,
po:subcontractor_payments!inner(
id, po_number, status, due_date
)
`),
.from('subcontractor_invoice_items')
.select('task_id, invoice:subcontractor_invoices!inner(status)')
.eq('subcontractor_invoices.status', 'paid'),
]),
15000,
'External requests load'
@@ -62,19 +49,19 @@ export default function ExternalMyRequests() {
if (projectError) throw projectError;
if (taskError) throw taskError;
if (poError) throw poError;
if (subError) throw subError;
// Build a map of task_id → PO item for quick lookup
const byTask = {};
(poItems || []).forEach(item => {
if (item.task_id && item.po?.status !== 'cancelled') {
byTask[item.task_id] = item;
}
});
const paid = new Set(
(paidItems || [])
.filter(item => item.invoice?.status === 'paid' && item.task_id)
.map(item => item.task_id)
);
setProjects(projectData || []);
setTasks(taskData || []);
setPoItemsByTaskId(byTask);
setSubmissions(subData || []);
setPaidTaskIds(paid);
writePageCache(cacheKey, { projects: projectData || [], tasks: taskData || [], submissions: subData || [], paidTaskIds: [...paid] });
setError('');
} catch (err) {
console.error('External requests load failed:', err);
@@ -86,47 +73,74 @@ export default function ExternalMyRequests() {
load();
}, [currentUser?.id]);
const activeTasks = useMemo(() => tasks.filter(t => t.status !== 'client_approved'), [tasks]);
const completedTasks = useMemo(() => tasks.filter(t => t.status === 'client_approved'), [tasks]);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
function renderTask(task) {
const po = poItemsByTaskId[task.id];
const dueDate = po?.po?.due_date
? new Date(po.po.due_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
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 (
<Link
key={task.id}
to={`/tasks/${task.id}`}
className="interactive-row"
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
gap: 12,
alignItems: 'center',
padding: '12px 14px',
borderBottom: '1px solid var(--border)',
textDecoration: 'none',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 3 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)' }}>{task.title}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
R{String(task.current_version || 0).padStart(2, '0')}
</span>
<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>
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{po && <span style={{ color: 'var(--accent)', fontWeight: 600 }}>{po.po?.po_number}</span>}
{po?.amount != null && <span>${Number(po.amount).toFixed(2)}</span>}
{dueDate && <span>Due {dueDate}</span>}
</div>
</div>
<StatusBadge status={task.status} />
</Link>
</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>
@@ -137,103 +151,108 @@ export default function ExternalMyRequests() {
</div>
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
<div className="stat-card stat-card-highlight">
<div className="stat-value">{tasks.length}</div>
<div className="stat-label">Total Tasks</div>
</div>
<div className="stat-card">
<div className="stat-value">{activeTasks.length}</div>
<div className="stat-label">Active</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: activeTasks.filter(t => t.status === 'client_review').length > 0 ? 'var(--accent)' : undefined }}>
{activeTasks.filter(t => t.status === 'client_review').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>
{(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>
)}
{loading ? (
<p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p>
) : error ? (
<div className="card" style={{ color: 'var(--danger)' }}>{error}</div>
) : tasks.length === 0 ? (
{submissions.length === 0 ? (
<div className="empty-state">
<h3>No tasks yet</h3>
<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 style={{ display: 'grid', gap: 16 }}>
{projects.map(project => {
const projectTasks = sortTasks(tasks.filter(t => t.project_id === project.id));
if (!projectTasks.length) return null;
const active = projectTasks.filter(t => t.status !== 'client_approved');
const done = projectTasks.filter(t => t.status === 'client_approved');
<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>
return (
<div key={project.id} className="card" style={{ padding: 0, overflow: 'hidden' }}>
{/* Project header */}
<div style={{
padding: '12px 16px',
background: 'var(--card-bg-2)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
}}>
<div>
<div style={{ fontWeight: 700, fontSize: 14 }}>{project.name}</div>
{project.company?.name && (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{project.company.name}</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, fontSize: 12, color: 'var(--text-muted)' }}>
{active.length > 0 && <span style={{ fontWeight: 600, color: 'var(--accent)' }}>{active.length} active</span>}
{done.length > 0 && <span>{done.length} done</span>}
</div>
</div>
{/* Active tasks */}
{active.length > 0 && (
<div>
{active.map(task => renderTask(task))}
</div>
)}
{/* Completed tasks — collapsed by default */}
{done.length > 0 && (
<CompletedGroup tasks={done} renderTask={renderTask} />
)}
</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>
);
}
function CompletedGroup({ tasks, renderTask }) {
const [open, setOpen] = useState(false);
return (
<div style={{ borderTop: tasks.length > 0 ? '1px solid var(--border)' : 'none' }}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: '100%', padding: '8px 16px', background: 'none', border: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-muted)',
}}
>
<span style={{ fontWeight: 600 }}>{open ? '▲' : '▼'} {tasks.length} completed</span>
</button>
{open && tasks.map(task => renderTask(task))}
</div>
);
}