Files
fourge-portal/src/pages/team/Requests.jsx
T

561 lines
24 KiB
React
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}