Files
fourge-portal/src/pages/ProjectDetailPage.jsx
T
Krao Hasanee 283511bf3a Session 2026-05-28: profile page overhaul, nav fixes, dashboard activity links
- Fix nav links not working from profile page (useEffect infinite re-render via unstable profile object ref)
- Fix nav hover/active: gold icon highlight, no background change; active links non-clickable
- Fix hover layout shift: add border: 1px solid transparent to all interactive elements
- Header icon buttons (search, theme toggle) now highlight gold on hover
- Profile page: replace calendar with activity feed (60/40 grid), add stat cards (tasks completed, active projects, revision requests, submissions)
- Profile card: title field, icon rows for location/email/linkedin, member since + role bottom-right, edit button top-right
- Profile portrait: remove wrapper column, fix left-gap alignment
- Add profiles.title migration
- Dashboard recent activity: name → /profile/{id}, task → /requests/{id} (clickable links)
- Icon-only sidebar with gold active/hover state, pointer-events: none on active links
- layout.md updated with profile page geometry rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:32:46 -04:00

463 lines
24 KiB
React

import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import StatusBadge from '../components/StatusBadge';
import FileBrowser from '../components/FileBrowser';
import SortTh from '../components/SortTh';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData';
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
import { logActivity } from '../lib/activityLog';
import { useSortable } from '../hooks/useSortable';
const safeFbName = v => String(v || '').trim().replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-').replace(/\s+/g, ' ').replace(/^-+|-+$/g, '');
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
export default function ProjectDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { currentUser } = useAuth();
const isClient = currentUser?.role === 'client';
const isExternal = currentUser?.role === 'external';
const isTeam = currentUser?.role === 'team';
const [project, setProject] = useState(null);
const [company, setCompany] = useState(null);
const [companyUsers, setCompanyUsers] = useState([]);
const [tasks, setTasks] = useState([]);
const [submissions, setSubmissions] = useState([]);
const [members, setMembers] = useState([]);
const [externalProfiles, setExternalProfiles] = useState([]);
const [loading, setLoading] = useState(true);
const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState('');
const [savingName, setSavingName] = useState(false);
const [showAddJob, setShowAddJob] = useState(false);
const [jobForm, setJobForm] = useState(emptyJobForm);
const [savingJob, setSavingJob] = useState(false);
const [selectedExternal, setSelectedExternal] = useState('');
const [addingMember, setAddingMember] = useState(false);
const [filter, setFilter] = useState('all');
const { sortKey, sortDir, toggle, sort } = useSortable('title');
const requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
...companyUsers.filter(u => u.id !== currentUser?.id),
];
useEffect(() => {
async function load() {
try {
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
if (!p) return;
setProject(p);
if (isClient) {
const [{ data: co }, { data: t }] = await Promise.all([
supabase.from('companies').select('*').eq('id', p.company_id).single(),
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
]);
setCompany(co);
setTasks(t || []);
if (t && t.length > 0) {
const { data: subs } = await supabase.from('submissions').select('id, task_id, submitted_by, submitted_by_name, version_number, type').in('task_id', t.map(task => task.id)).order('version_number');
setSubmissions(subs || []);
}
} else {
const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }] = await Promise.all([
supabase.from('companies').select('*').eq('id', p.company_id).single(),
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
supabase.from('profiles').select('id, name, email').eq('company_id', p.company_id).eq('role', 'client'),
supabase.from('project_members').select('*, profile:profiles(id, name, email, role)').eq('project_id', id),
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
]);
setCompany(co);
setTasks(t || []);
setCompanyUsers(users || []);
setMembers(pm || []);
setExternalProfiles(ext || []);
}
} catch (error) {
console.error('ProjectDetailPage load failed:', error);
} finally {
setLoading(false);
}
}
load();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSaveName = async (e) => {
e.preventDefault();
if (!nameVal.trim()) return;
setSavingName(true);
const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
if (!error) { setProject(p => ({ ...p, name: nameVal.trim() })); setEditingName(false); }
else alert('Failed to save name.');
setSavingName(false);
};
const handleDeleteProject = async () => {
if (!window.confirm(`Delete project "${project.name}"? All jobs and files will be permanently deleted.`)) return;
try {
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch(`/api/delete-project?id=${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${session?.access_token}` },
});
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); }
} catch (err) {
alert(`Failed to delete project: ${err.message}`);
return;
}
navigate(isClient ? '/projects' : `/company/${company?.id}`);
};
const handleDeleteTask = async (taskId, e) => {
e.stopPropagation();
if (!window.confirm('Delete this job? All submissions and files will be permanently deleted.')) return;
try {
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch(`/api/delete-task?id=${taskId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${session?.access_token}` },
});
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); }
const d = await res.json();
if (d.archiveError) console.warn('[delete-task] archive error:', d.archiveError);
} catch (err) {
alert(`Failed to delete job: ${err.message}`);
return;
}
setTasks(prev => prev.filter(t => t.id !== taskId));
};
const handleAddJob = async (e) => {
e.preventDefault();
setSavingJob(true);
const requestor = requesterOptions.find(u => u.id === jobForm.requestedBy);
if (!requestor) { setSavingJob(false); return; }
const { data: task } = await supabase.from('tasks').insert({ project_id: id, title: jobForm.title.trim(), status: 'not_started', current_version: 0 }).select().single();
if (task) {
await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', is_hot: jobForm.isHot, service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') });
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_created', taskId: task.id, taskTitle: task.title, projectId: id, projectName: project?.name });
setTasks(prev => [task, ...prev]);
setJobForm(emptyJobForm());
setShowAddJob(false);
}
setSavingJob(false);
};
const handleAddMember = async () => {
if (!selectedExternal) return;
const { data } = await supabase.from('project_members').insert({ project_id: id, profile_id: selectedExternal }).select('*, profile:profiles(id, name, email)').single();
if (data) { setMembers(prev => [...prev, data]); setSelectedExternal(''); setAddingMember(false); }
};
const handleRemoveMember = async (profileId) => {
await supabase.from('project_members').delete().eq('project_id', id).eq('profile_id', profileId);
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!project) return <Layout><p>Project not found.</p></Layout>;
const filteredTasks = isClient && filter === 'mine'
? tasks.filter(task => {
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
return initial?.submitted_by === currentUser.id;
})
: tasks;
const sortedTasks = sort(filteredTasks, (task, key) => {
if (key === 'title') return task.title || '';
if (key === 'assigned_name') return task.assigned_name || '';
if (key === 'current_version') return Number(task.current_version || 0);
if (key === 'status') return task.status || '';
if (key === 'submitted_at') return task.submitted_at || '';
return '';
});
return (
<Layout>
<button className="back-link" onClick={() => navigate(isClient ? '/projects' : isExternal ? '/dashboard' : `/company/${company?.id}`)}>
Back to {isClient ? 'Projects' : isExternal ? 'Dashboard' : company?.name}
</button>
<div className="page-header">
<div>
{editingName && (isTeam || isClient) ? (
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input type="text" value={nameVal} onChange={e => setNameVal(e.target.value)} autoFocus required style={{ fontSize: 22, fontWeight: 400, padding: '4px 10px', margin: 0, width: 280 }} />
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="page-title">{project.name}</div>
{(isTeam || isClient) && (
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(project.name); setEditingName(true); }}>Edit</button>
)}
</div>
)}
<div className="page-subtitle">
{!isClient && company && (
<>
{isExternal
? <span style={{ color: 'var(--text-secondary)' }}>{company.name}</span>
: <Link to={`/company/${company.id}`} style={{ color: 'var(--accent)' }}>{company.name}</Link>
}
{' · '}
</>
)}
{isClient
? `${tasks.length} request${tasks.length !== 1 ? 's' : ''} · Started ${new Date(project.created_at).toLocaleDateString()}`
: `Started ${new Date(project.created_at).toLocaleDateString()}`
}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StatusBadge status={project.status} />
{isClient && (
<>
{tasks.length === 0 && (
<button className="btn btn-outline btn-sm" style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }} onClick={handleDeleteProject}>Delete Project</button>
)}
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">+ Add Request</Link>
</>
)}
{isTeam && (
<>
<button className="btn btn-outline btn-sm" style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }} onClick={handleDeleteProject}>Delete Project</button>
<button className="btn btn-primary btn-sm" onClick={() => setShowAddJob(s => !s)}>{showAddJob ? 'Cancel' : '+ Add Job'}</button>
</>
)}
</div>
</div>
{/* Team: Add job form */}
{isTeam && showAddJob && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 4 }}>
<div className="card-title">Add Job {project.name}</div>
<form onSubmit={handleAddJob}>
<div className="grid-2">
<div className="form-group">
<label>Job Title *</label>
<input type="text" placeholder="e.g. Logo Design" value={jobForm.title} onChange={e => setJobForm(f => ({ ...f, title: e.target.value }))} required autoFocus />
</div>
<div className="form-group">
<label>Service Type *</label>
<select value={jobForm.serviceType} onChange={e => setJobForm(f => ({ ...f, serviceType: e.target.value }))} required>
<option value="">Select a service...</option>
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={jobForm.deadline} onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))} />
</div>
<div className="form-group">
<label>Requested By *</label>
<select value={jobForm.requestedBy} onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))} required>
<option value="">Select requester...</option>
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}{u.email ? ` (${u.email})` : ''}</option>)}
</select>
</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={jobForm.isHot} onChange={e => setJobForm(f => ({ ...f, isHot: e.target.checked }))} />
<span>Mark as Hot</span>
</label>
</div>
<div className="form-group">
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<textarea placeholder="Any details about the job..." value={jobForm.description} onChange={e => setJobForm(f => ({ ...f, description: e.target.value }))} style={{ minHeight: 80 }} />
</div>
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={savingJob}>{savingJob ? 'Adding...' : 'Add Job'}</button>
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>Cancel</button>
</div>
</form>
</div>
)}
{/* Team/External: Project info cards */}
{!isClient && (
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">Project Info</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Company</label><p>{company?.name || '—'}</p></div>
<div className="detail-item"><label>Status</label><p><StatusBadge status={project.status} /></p></div>
<div className="detail-item"><label>Started</label><p>{new Date(project.created_at).toLocaleDateString()}</p></div>
</div>
</div>
<div className="card">
<div className="card-title">Project Summary</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Total Tasks</label><p>{tasks.length}</p></div>
<div className="detail-item"><label>Completed</label><p>{tasks.filter(t => t.status === 'client_approved').length}</p></div>
<div className="detail-item"><label>In Progress</label><p>{tasks.filter(t => t.status === 'in_progress').length}</p></div>
<div className="detail-item"><label>Awaiting Review</label><p>{tasks.filter(t => t.status === 'client_review').length}</p></div>
</div>
</div>
</div>
)}
{/* Project Folder (FileBrowser) — team + client */}
{!isExternal && company?.name && project?.name && (() => {
const co = safeFbName(company.name);
const proj = safeFbName(project.name);
const fbRoot = isClient
? `/${co}/Projects/${proj}/00 Project Files`
: `/Clients/${co}/Projects/${proj}/00 Project Files`;
return (
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Project Files</div>
<FileBrowser initialPath={fbRoot} rootPath={fbRoot} />
</div>
);
})()}
{/* Client: mine/all filter */}
{isClient && (
<div className="card page-toolbar" style={{ marginBottom: 16 }}>
<div className="page-toolbar-grid">
<div className="page-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter</div>
<div className="page-toolbar-filters">
<button className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilter('all')}>All Requests</button>
<button className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilter('mine')}>Mine Only</button>
</div>
</div>
</div>
</div>
)}
{/* Tasks / Requests */}
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">{isClient ? 'Requests' : 'Tasks'}</div>
{filteredTasks.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📋</div>
<h3>{isClient && filter === 'mine' ? "You haven't submitted any requests to this project" : isClient ? 'No requests yet' : 'No jobs yet'}</h3>
{isClient ? (
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary" style={{ marginTop: 16 }}>Add Request</Link>
) : isTeam ? (
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowAddJob(true)}>+ Add Job</button>
) : null}
</div>
) : isClient ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{sortedTasks.map(task => {
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 = latestSub && initialSub && latestSub.id !== initialSub.id && latestSub.submitted_by_name !== initialSub.submitted_by_name;
const isMine = initialSub?.submitted_by === currentUser.id;
return (
<Link key={task.id} to={`/requests/${task.id}`} className="request-card" style={{ textDecoration: 'none', cursor: 'pointer', display: 'block' }}>
<div className="request-card-header">
<div>
<div className="request-card-title">
{task.title}{' '}
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: 13 }}>{rLabel(task.current_version)}</span>
{isMine && <span style={{ marginLeft: 8, fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 4, fontWeight: 400 }}>Mine</span>}
</div>
<div className="request-card-meta" style={{ marginTop: 4 }}>
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
</div>
</div>
<StatusBadge status={task.status} />
</div>
</Link>
);
})}
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Job</SortTh>
<SortTh col="assigned_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Assigned To</SortTh>
<SortTh col="current_version" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Revision</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
<th></th>
</tr>
</thead>
<tbody>
{sortedTasks.map(task => (
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 400 }}>
{task.title}
<span style={{ marginLeft: 6, fontWeight: 400, color: 'var(--text-muted)', fontSize: 12 }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
</td>
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>{task.assigned_name || 'Unassigned'}</td>
<td><span className="badge badge-not_started">R{String(task.current_version || 0).padStart(2, '0')}</span></td>
<td><StatusBadge status={task.status} /></td>
<td style={{ color: 'var(--text-muted)' }}>{new Date(task.submitted_at).toLocaleDateString()}</td>
{isTeam && (
<td onClick={e => e.stopPropagation()}>
<button type="button" onClick={e => handleDeleteTask(task.id, e)} style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }} title="Delete job"></button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Team: External members */}
{isTeam && (
<div className="card" style={{ marginTop: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div className="card-title" style={{ margin: 0 }}>External Members</div>
{!addingMember && <button className="btn btn-outline btn-sm" onClick={() => setAddingMember(true)}>+ Add</button>}
</div>
{addingMember && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<select value={selectedExternal} onChange={e => setSelectedExternal(e.target.value)} style={{ flex: 1 }}>
<option value="">Select external member...</option>
{externalProfiles.filter(p => !members.find(m => m.profile_id === p.id)).map(p => <option key={p.id} value={p.id}>{p.name} {p.email}</option>)}
</select>
<button className="btn btn-primary btn-sm" onClick={handleAddMember} disabled={!selectedExternal}>Add</button>
<button className="btn btn-outline btn-sm" onClick={() => { setAddingMember(false); setSelectedExternal(''); }}>Cancel</button>
</div>
)}
{members.filter(m => m.profile?.role === 'external').length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No external members assigned to this project.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{members.filter(m => m.profile?.role === 'external').map(m => (
<div key={m.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{m.profile?.email}</div>
</div>
<button onClick={() => handleRemoveMember(m.profile_id)} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }} title="Remove from project"></button>
</div>
))}
</div>
)}
</div>
)}
</Layout>
);
}