feat: project detail overhaul, realtime updates, avatar RLS fix

- ProjectDetail: full redesign — icon meta row, completion progress bar, main contact card, activity feed, subcontractor management modal with multi-select
- ProjectDetail: task table matches Tasks.jsx (R#, assigned avatar, priority, type, deadline, status) with status tabs + counts
- Realtime: useRefetchOnFocus + useRealtimeSubscription hooks wired to Tasks, TaskDetail, ProjectDetail, TeamDashboard
- AuthContext: live profile updates via Supabase realtime channel
- Tasks/ProjectDetail: assignee avatar join added for all roles (client, external, team)
- RLS: allow all authenticated users to read profiles (fixes avatar display across roles)
- RequestForm: lockedFields prop for pre-filled read-only company/project fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-31 16:55:58 -04:00
parent 35d92d2176
commit 70ad8d0cef
8 changed files with 546 additions and 203 deletions
+13 -18
View File
@@ -41,6 +41,7 @@ export default function RequestForm({
submitLabel = 'Submit Request', submitLabel = 'Submit Request',
initialCompanyId = '', initialCompanyId = '',
initialValues = null, initialValues = null,
lockedFields = [],
}) { }) {
const [form, setForm] = useState(() => ({ ...emptyForm(initialCompanyId), ...(initialValues || {}) })); const [form, setForm] = useState(() => ({ ...emptyForm(initialCompanyId), ...(initialValues || {}) }));
const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null); const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null);
@@ -164,33 +165,27 @@ export default function RequestForm({
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{/* Row 1: Company + Project */} {/* Row 1: Company + Project */}
<div className="grid-2"> <div className="grid-2">
{showCompanySelect ? ( {lockedFields.includes('company') ? (
<div className="form-group">
<label style={modalLabelStyle}>Company</label>
<input value={companies.find(c => c.id === companyId)?.name || companyId} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
</div>
) : showCompanySelect ? (
<div className="form-group"> <div className="form-group">
<label style={modalLabelStyle}>Company *</label> <label style={modalLabelStyle}>Company *</label>
<select <select value={form.companyId} onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))} required style={modalInputStyle}>
value={form.companyId}
onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))}
required
style={modalInputStyle}
>
<option value="">Select company...</option> <option value="">Select company...</option>
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)} {companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select> </select>
</div> </div>
) : <div />} ) : <div />}
<div className="form-group"> <div className="form-group">
<label style={modalLabelStyle}>Project *</label> <label style={modalLabelStyle}>Project {!lockedFields.includes('project') && '*'}</label>
{isTypingProject ? ( {lockedFields.includes('project') ? (
<input value={form.project} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
) : isTypingProject ? (
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<input <input type="text" placeholder="Enter project name..." value={newProjectName} onChange={e => setNewProjectName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }} autoFocus style={{ ...modalInputStyle, flex: 1 }} />
type="text"
placeholder="Enter project name..."
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }}
autoFocus
style={{ ...modalInputStyle, flex: 1 }}
/>
<button type="button" className="btn btn-outline" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button> <button type="button" className="btn btn-outline" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
<button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button> <button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
</div> </div>
+11
View File
@@ -124,6 +124,17 @@ export function AuthProvider({ children }) {
}; };
}, []); }, []);
useEffect(() => {
if (!currentUser?.id) return;
const channel = supabase.channel(`profile-${currentUser.id}`)
.on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'profiles', filter: `id=eq.${currentUser.id}` }, async () => {
const { data: { session } } = await supabase.auth.getSession();
if (session?.user) fetchAndCacheProfile(session.user);
})
.subscribe();
return () => { supabase.removeChannel(channel); };
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const login = async (email, password) => { const login = async (email, password) => {
const { error } = await supabase.auth.signInWithPassword({ email, password }); const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: error.message }; if (error) return { error: error.message };
+17
View File
@@ -0,0 +1,17 @@
import { useEffect, useRef } from 'react';
import { supabase } from '../lib/supabase';
export function useRealtimeSubscription(tables, onchange) {
const onchangeRef = useRef(onchange);
useEffect(() => { onchangeRef.current = onchange; });
useEffect(() => {
const channelName = `rt-${tables.join('-')}-${Date.now()}`;
let channel = supabase.channel(channelName);
for (const table of tables) {
channel = channel.on('postgres_changes', { event: '*', schema: 'public', table }, () => onchangeRef.current());
}
channel.subscribe();
return () => { supabase.removeChannel(channel); };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}
+12
View File
@@ -0,0 +1,12 @@
import { useEffect, useRef } from 'react';
export function useRefetchOnFocus(refetch) {
const refetchRef = useRef(refetch);
useEffect(() => { refetchRef.current = refetch; });
useEffect(() => {
const onFocus = () => refetchRef.current();
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, []);
}
+455 -170
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader'; import PageLoader from '../components/PageLoader';
@@ -7,16 +7,24 @@ import SortTh from '../components/SortTh';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { logActivity } from '../lib/activityLog'; import { logActivity } from '../lib/activityLog';
import { createTaskFolder } from '../lib/filebrowserFolders'; import { createTaskFolder, uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
import { createInitialSubmissionForRequest, createTaskForRequest } from '../lib/requestSubmission';
import { sendEmail } from '../lib/email';
import RequestForm from '../components/RequestForm';
import { useSortable } from '../hooks/useSortable'; import { useSortable } from '../hooks/useSortable';
import { serviceTypes } from '../data/mockData'; import { serviceTypes } from '../data/mockData';
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates'; import { addDaysToDateOnly, getTodayDateOnlyEST, fmtShortDate } from '../lib/dates';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles'; import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
const CARD = { padding: '18px 21px', borderRadius: 8 }; const CARD = { padding: '18px 21px', borderRadius: 8 };
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 }; const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }; const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
const MODAL_LBL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }; const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
const MODAL_LBL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 };
const TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' };
const TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }; const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
const TAB_STYLE = (active) => ({ const TAB_STYLE = (active) => ({
fontFamily: "Fourge, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", fontFamily: "Fourge, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
@@ -37,6 +45,11 @@ export default function ProjectDetailPage() {
const isExternal = currentUser?.role === 'external'; const isExternal = currentUser?.role === 'external';
const isTeam = currentUser?.role === 'team'; const isTeam = currentUser?.role === 'team';
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'projects', 'project_members', 'profiles'], refresh);
const [project, setProject] = useState(null); const [project, setProject] = useState(null);
const [company, setCompany] = useState(null); const [company, setCompany] = useState(null);
const [tasks, setTasks] = useState([]); const [tasks, setTasks] = useState([]);
@@ -44,7 +57,8 @@ export default function ProjectDetailPage() {
const [externalProfiles, setExtProfs] = useState([]); const [externalProfiles, setExtProfs] = useState([]);
const [companyUsers, setCompanyUsers] = useState([]); const [companyUsers, setCompanyUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('tasks'); const [submissions, setSubmissions] = useState([]);
const [activeTab, setActiveTab] = useState('all');
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState(''); const [nameVal, setNameVal] = useState('');
@@ -53,9 +67,21 @@ export default function ProjectDetailPage() {
const [showAddJob, setShowAddJob] = useState(false); const [showAddJob, setShowAddJob] = useState(false);
const [jobForm, setJobForm] = useState(emptyJobForm()); const [jobForm, setJobForm] = useState(emptyJobForm());
const [savingJob, setSavingJob] = useState(false); const [savingJob, setSavingJob] = useState(false);
const [showClientTask, setShowClientTask] = useState(false);
const [clientTaskKey, setClientTaskKey] = useState(0);
const [clientTaskSaving, setClientTaskSaving] = useState(false);
const [clientTaskError, setClientTaskError] = useState('');
const [clientTaskRequestKey, setClientTaskRequestKey] = useState(() => crypto.randomUUID());
const [selectedExt, setSelectedExt] = useState('');
const [addingMember, setAddingMember] = useState(false); const [addingMember, setAddingMember] = useState(false);
const [selectedExts, setSelectedExts] = useState(new Set());
const [savingMembers, setSavingMembers] = useState(false);
const [subSortKey, setSubSortKey] = useState('name');
const [subSortDir, setSubSortDir] = useState('asc');
const toggleSubSort = (col) => { if (subSortKey === col) setSubSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSubSortKey(col); setSubSortDir('asc'); } };
const [subProjectCounts, setSubProjectCounts] = useState({});
const [activityLog, setActivityLog] = useState([]);
const [companyClients, setCompanyClients] = useState([]);
const { sortKey, sortDir, toggle, sort } = useSortable('submitted_at'); const { sortKey, sortDir, toggle, sort } = useSortable('submitted_at');
@@ -66,24 +92,44 @@ export default function ProjectDetailPage() {
setProject(p); setProject(p);
const [{ data: co }, { data: t }] = await Promise.all([ const [{ data: co }, { data: t }] = await Promise.all([
supabase.from('companies').select('*').eq('id', p.company_id).single(), 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('tasks').select('*, assignee:profiles!assigned_to(avatar_url)').eq('project_id', id).order('submitted_at', { ascending: false }),
]); ]);
setCompany(co); setCompany(co);
setTasks(t || []); setTasks(t || []);
const taskIds = (t || []).map(tk => tk.id);
if (taskIds.length > 0) {
const { data: subs } = await supabase.from('submissions').select('id, task_id, type, version_number, service_type, deadline, is_hot, submitted_at').in('task_id', taskIds).order('submitted_at', { ascending: false });
setSubmissions(subs || []);
}
const [{ data: users }, { data: pm }, { data: viaMembers }] = await Promise.all([
supabase.from('profiles').select('id, name, avatar_url, email').eq('company_id', p.company_id).eq('role', 'client'),
supabase.from('project_members').select('*, profile:profiles(id, name, avatar_url, email, role)').eq('project_id', id),
supabase.from('company_members').select('profile:profiles(id, name, avatar_url, email, role)').eq('company_id', p.company_id),
]);
setCompanyUsers(users || []);
setMembers(pm || []);
const seen = new Set();
const merged = [];
(users || []).forEach(u => { if (!seen.has(u.id)) { seen.add(u.id); merged.push(u); } });
(viaMembers || []).forEach(m => { if (m.profile?.role === 'client' && !seen.has(m.profile.id)) { seen.add(m.profile.id); merged.push(m.profile); } });
setCompanyClients(merged);
const { data: act, error: actErr } = await supabase.from('activity_log').select('id, created_at, actor_name, action, task_title').eq('project_id', id).order('created_at', { ascending: false }).limit(50);
if (actErr) console.error('activity_log fetch:', actErr);
setActivityLog((act || []).filter(e => ['task_started', 'task_on_hold', 'task_approved'].includes(e.action)));
if (isTeam) { if (isTeam) {
const [{ data: users }, { data: pm }, { data: ext }] = await Promise.all([ const { data: ext } = await supabase.from('profiles').select('id, name, avatar_url, email').eq('role', 'external').order('name');
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'),
]);
setCompanyUsers(users || []);
setMembers(pm || []);
setExtProfs(ext || []); setExtProfs(ext || []);
if ((ext || []).length > 0) {
const { data: pmRows } = await supabase.from('project_members').select('profile_id').in('profile_id', ext.map(p => p.id));
const counts = {};
(pmRows || []).forEach(r => { counts[r.profile_id] = (counts[r.profile_id] || 0) + 1; });
setSubProjectCounts(counts);
}
} }
setLoading(false); setLoading(false);
} }
load(); load();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps }, [id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSaveName = async (e) => { const handleSaveName = async (e) => {
e.preventDefault(); e.preventDefault();
@@ -130,9 +176,23 @@ export default function ProjectDetailPage() {
}; };
const handleAddMember = async () => { const handleAddMember = async () => {
if (!selectedExt) return; setSavingMembers(true);
const { data } = await supabase.from('project_members').insert({ project_id: id, profile_id: selectedExt }).select('*, profile:profiles(id, name, email)').single(); const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
if (data) { setMembers(prev => [...prev, data]); setSelectedExt(''); setAddingMember(false); } const toAdd = [...selectedExts].filter(pid => !currentIds.has(pid));
const toRemove = [...currentIds].filter(pid => !selectedExts.has(pid));
if (toAdd.length > 0) {
const { data } = await supabase.from('project_members').insert(toAdd.map(pid => ({ project_id: id, profile_id: pid }))).select('*, profile:profiles(id, name, avatar_url, email, role)');
if (data) setMembers(prev => [...prev, ...data]);
}
if (toRemove.length > 0) {
for (const pid of toRemove) {
await supabase.from('project_members').delete().eq('project_id', id).eq('profile_id', pid);
}
setMembers(prev => prev.filter(m => !toRemove.includes(m.profile_id)));
}
setSelectedExts(new Set());
setAddingMember(false);
setSavingMembers(false);
}; };
const handleRemoveMember = async (profileId) => { const handleRemoveMember = async (profileId) => {
@@ -140,6 +200,37 @@ export default function ProjectDetailPage() {
setMembers(prev => prev.filter(m => m.profile_id !== profileId)); setMembers(prev => prev.filter(m => m.profile_id !== profileId));
}; };
const handleClientTask = async (formData, files) => {
if (clientTaskSaving) return;
setClientTaskSaving(true); setClientTaskError('');
try {
const { task } = await createTaskForRequest({ projectId: project.id, title: formData.title.trim(), requestKey: clientTaskRequestKey });
if (!task) throw new Error('Failed to create task.');
createTaskFolder(company?.name, project.name, formData.title.trim()).catch(() => {});
const { submission } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: clientTaskRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily,
signCount: formData.signCount, signs: formData.signs,
deadline: formData.deadline, description: formData.description,
submittedBy: currentUser.id, submittedByName: currentUser.name,
});
if (submission && files.length > 0) {
for (const file of files) {
const path = `${task.id}/${Date.now()}_${file.name}`;
const { data: up } = await supabase.storage.from('submissions').upload(path, file);
if (up) await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
}
uploadFilesToRequestInfo(files, company?.name, project.name, formData.title.trim()).catch(() => {});
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: project.id, projectName: project.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', { clientName: currentUser.name, clientEmail: currentUser.email, company: company?.name || '', serviceType: formData.serviceType, projectName: project.name, deadline: formData.deadline, description: formData.description, taskId: task.id }).catch(() => {});
const { data: newTasks } = await supabase.from('tasks').select('*, assignee:profiles!assigned_to(avatar_url)').eq('project_id', id).order('submitted_at', { ascending: false });
setTasks(newTasks || []);
setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskRequestKey(crypto.randomUUID());
} catch (err) { setClientTaskError(err.message); }
finally { setClientTaskSaving(false); }
};
if (loading) return <Layout><PageLoader /></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></Layout>; if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></Layout>;
@@ -148,19 +239,43 @@ export default function ProjectDetailPage() {
...companyUsers.filter(u => u.id !== currentUser?.id), ...companyUsers.filter(u => u.id !== currentUser?.id),
]; ];
const sortedTasks = sort(tasks, (task, key) => { const rows = tasks.map(task => {
if (key === 'title') return task.title || ''; const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial') || submissions.find(s => s.task_id === task.id);
if (key === 'assigned_name') return task.assigned_name || ''; return {
if (key === 'current_version') return Number(task.current_version || 0); id: task.id, title: task.title, status: task.status,
if (key === 'status') return task.status || ''; version: task.current_version || 0,
if (key === 'submitted_at') return task.submitted_at || ''; assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null, isHot: sub?.is_hot || false,
submittedAt: task.submitted_at || '',
};
});
const sortedRows = sort(rows, (r, key) => {
if (key === 'title') return r.title || '';
if (key === 'assigned') return r.assignedName || '';
if (key === 'revision') return r.version;
if (key === 'status') return r.status || '';
if (key === 'serviceType') return r.serviceType || '';
if (key === 'deadline') return r.deadline || '';
if (key === 'priority') return r.isHot ? 0 : 1;
if (key === 'submitted_at') return r.submittedAt;
return ''; return '';
}); });
const tabs = [ const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
{ id: 'tasks', label: 'Tasks' }, const taskTabs = [
...(isTeam ? [{ id: 'members', label: 'Members' }] : []), { id: 'all', label: 'All Tasks' },
{ id: 'not_started', label: 'To Do' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'on_hold', label: 'On Hold' },
{ id: 'client_review', label: 'In Review' },
{ id: 'completed', label: 'Completed' },
...(isTeam ? [{ id: 'members', label: 'Subcontractors' }] : []),
]; ];
const visibleRows = activeTab === 'all' ? sortedRows
: activeTab === 'completed' ? sortedRows.filter(r => doneStatuses.has(r.status))
: activeTab === 'members' ? []
: sortedRows.filter(r => r.status === activeTab);
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
@@ -183,141 +298,206 @@ export default function ProjectDetailPage() {
) : ( ) : (
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{project.name}</div> <div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{project.name}</div>
)} )}
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}> {isClient && (
{isTeam && !editingName && ( <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button className="btn btn-outline" onClick={() => { setNameVal(project.name); setEditingName(true); }}>Edit</button> <button className="btn btn-outline" onClick={() => setShowClientTask(true)}>+ Task</button>
)} </div>
{isTeam && ( )}
<button className="btn btn-outline" onClick={() => setShowAddJob(s => !s)}>+ Task</button>
)}
{isClient && (
<Link to="/tasks" className="btn btn-outline">+ Task</Link>
)}
{(isTeam || (isClient && tasks.length === 0)) && (
<button className="btn btn-danger" onClick={handleDeleteProject}>Delete</button>
)}
</div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
<StatusBadge status={project.status} /> <StatusBadge status={project.status} />
</div> </div>
<div style={{ display: 'flex', gap: 30, flexWrap: 'wrap' }}> {(() => {
{!isClient && company && ( const approved = tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length;
<div> const pct = tasks.length > 0 ? Math.round((approved / tasks.length) * 100) : 0;
<div style={META_LABEL}>Company</div> return (
{isTeam <div style={{ display: 'flex', alignItems: 'center', gap: 30, flexWrap: 'wrap' }}>
? <Link to={`/company/${company.id}`} className="table-link" style={{ fontSize: 13 }}>{company.name}</Link> {!isClient && company && (
: <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{company.name}</div> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
} <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>
<div>
<div style={CARD_META_LABEL}>Company</div>
{isTeam
? <Link to={`/company/${company.id}`} className="table-link" style={{ fontSize: 13 }}>{company.name}</Link>
: <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{company.name}</div>
}
</div>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<div>
<div style={CARD_META_LABEL}>Started</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate(project.created_at)}</div>
</div>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 5, minWidth: 160 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<div style={CARD_META_LABEL}>Completion</div>
<div style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{pct}%</div>
</div>
<div style={{ width: '100%', height: 5, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)', borderRadius: 3, transition: 'width 400ms ease' }} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{approved} of {tasks.length} approved</div>
</div>
</div> </div>
)} );
<div> })()}
<div style={META_LABEL}>Started</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate(project.created_at)}</div>
</div>
<div>
<div style={META_LABEL}>Tasks</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{tasks.length}</div>
</div>
<div>
<div style={META_LABEL}>In Progress</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{tasks.filter(t => t.status === 'in_progress').length}</div>
</div>
<div>
<div style={META_LABEL}>In Review</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{tasks.filter(t => t.status === 'client_review').length}</div>
</div>
<div>
<div style={META_LABEL}>Approved</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length}</div>
</div>
</div>
</div> </div>
{/* Tabs + content card */} {/* Tabs + content card */}
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}> {(() => {
{tabs.map(tab => ( const tabCounts = {
<button key={tab.id} onClick={() => setActiveTab(tab.id)} style={TAB_STYLE(activeTab === tab.id)}>{tab.label}</button> all: tasks.length,
))} not_started: tasks.filter(t => t.status === 'not_started').length,
</div> in_progress: tasks.filter(t => t.status === 'in_progress').length,
on_hold: tasks.filter(t => t.status === 'on_hold').length,
client_review: tasks.filter(t => t.status === 'client_review').length,
completed: tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length,
members: members.filter(m => m.profile?.role === 'external').length,
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
{taskTabs.map(tab => {
const count = tabCounts[tab.id] ?? 0;
const active = activeTab === tab.id;
return (
<button key={tab.id} onClick={() => setActiveTab(tab.id)} style={TAB_STYLE(active)}>
{tab.label}
{count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: active ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
</button>
);
})}
{activeTab === 'members' && isTeam && (
<button className="btn btn-outline" style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 12px' }} onClick={() => { const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id)); setSelectedExts(currentIds); setAddingMember(true); }}>Manage</button>
)}
</div>
);
})()}
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
{activeTab === 'tasks' && ( {activeTab !== 'members' && (
tasks.length === 0 visibleRows.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)', fontSize: 13 }}>No tasks yet.</div> ? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)', fontSize: 13 }}>No tasks yet.</div>
: <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ overflowY: 'auto' }}> : <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}> <table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup> <colgroup>
<col style={{ width: '35%' }} /> <col style={{ width: '5%' }} />
<col style={{ width: isTeam ? '18%' : '20%' }} /> <col style={{ width: '28%' }} />
<col style={{ width: '12%' }} /> <col style={{ width: '13%' }} />
<col style={{ width: '17%' }} /> <col style={{ width: '8%' }} />
<col style={{ width: '14%' }} /> <col style={{ width: '16%' }} />
{isTeam && <col style={{ width: '4%' }} />} <col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task</SortTh> <SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TH_STYLE}>R#</SortTh>
<SortTh col="assigned_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Assigned</SortTh> <SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TH_STYLE}>Name</SortTh>
<SortTh col="current_version" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Rev</SortTh> <SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh> <SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Created</SortTh> <SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
{isTeam && <th />} <SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Status</SortTh>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sortedTasks.map(task => ( {visibleRows.map(row => {
<tr key={task.id} onClick={() => navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}> const initials = row.assignedName ? row.assignedName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : null;
<td style={{ padding: 5 }}> const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
<Link to={`/tasks/${task.id}`} className="table-link" style={{ fontSize: 13 }} onClick={e => e.stopPropagation()}>{task.title}</Link> return (
</td> <tr key={row.id}>
<td style={{ fontSize: 12, color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)', padding: 5 }}>{task.assigned_name || 'Unassigned'}</td> <td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
<td style={{ fontSize: 12, color: 'var(--text-primary)', padding: 5 }}>R{String(task.current_version || 0).padStart(2, '0')}</td> <Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
<td style={{ padding: 5 }}><StatusBadge status={task.status} /></td>
<td style={{ fontSize: 12, color: 'var(--text-muted)', padding: 5 }}>{fmtDate(task.submitted_at)}</td>
{isTeam && (
<td style={{ padding: 5 }} onClick={e => e.stopPropagation()}>
<button type="button" onClick={e => handleDeleteTask(task.id, e)} className="btn btn-danger" style={{ fontSize: 10, padding: '1px 8px', height: 22 }}>Del</button>
</td> </td>
)} <td style={{ ...TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
</tr> <Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
))} </td>
<td style={{ ...TD_BASE, textAlign: 'center' }}>
{row.assignedName && row.assignedTo
? <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>
{row.assigneeAvatar
? <div title={row.assignedName} style={avatarStyle}><img src={row.assigneeAvatar} alt={row.assignedName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div title={row.assignedName} style={{ ...avatarStyle, background: 'var(--accent)' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>
}
</Link>
: <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>
}
</td>
<td style={{ ...TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{row.isHot ? 'HOT' : 'NO'}
</td>
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td>
<td style={{ ...TD_BASE, textAlign: 'center' }}><StatusBadge status={row.status} /></td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
{activeTab === 'members' && isTeam && ( {activeTab === 'members' && isTeam && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> {(() => {
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>External members assigned to this project.</div> const subs = members.filter(m => m.profile?.role === 'external');
{!addingMember && <button className="btn btn-outline" onClick={() => setAddingMember(true)}>+ Add</button>} if (subs.length === 0) return <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 100, color: 'var(--text-muted)', fontSize: 13 }}>No subcontractors assigned.</div>;
</div> const sorted = [...subs].sort((a, b) => {
{addingMember && ( if (subSortKey === 'projects') {
<div style={{ display: 'flex', gap: 8 }}> const av = subProjectCounts[a.profile_id] || 0;
<select value={selectedExt} onChange={e => setSelectedExt(e.target.value)} style={{ ...MODAL_IN, flex: 1 }}> const bv = subProjectCounts[b.profile_id] || 0;
<option value="">Select external member</option> return subSortDir === 'asc' ? av - bv : bv - av;
{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> const av = subSortKey === 'name' ? (a.profile?.name || '') : (a.profile?.email || '');
<button className="btn btn-outline" onClick={handleAddMember} disabled={!selectedExt}>Add</button> const bv = subSortKey === 'name' ? (b.profile?.name || '') : (b.profile?.email || '');
<button className="btn btn-outline" onClick={() => { setAddingMember(false); setSelectedExt(''); }}>Cancel</button> return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
</div> });
)} return (
{members.filter(m => m.profile?.role === 'external').length === 0 <table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 80, color: 'var(--text-muted)', fontSize: 13 }}>No external members.</div> <colgroup>
: members.filter(m => m.profile?.role === 'external').map(m => ( <col style={{ width: '5%' }} />
<div key={m.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}> <col style={{ width: '30%' }} />
<div> <col style={{ width: '40%' }} />
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{m.profile?.name}</div> <col style={{ width: '15%' }} />
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{m.profile?.email}</div> <col style={{ width: '10%' }} />
</div> </colgroup>
<button className="btn btn-danger" onClick={() => handleRemoveMember(m.profile_id)} style={{ fontSize: 10, padding: '1px 8px', height: 22 }}>Remove</button> <thead>
</div> <tr>
)) <th style={{ ...TH_STYLE, padding: '0 0 12px' }} />
} <SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Name</SortTh>
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Email</SortTh>
<SortTh col="projects" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={{ ...TH_STYLE, textAlign: 'center' }}>Projects</SortTh>
<th style={{ ...TH_STYLE, padding: '0 0 12px' }} />
</tr>
</thead>
<tbody>
{sorted.map(m => {
const initials = (m.profile?.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
const projCount = subProjectCounts[m.profile_id] || 0;
return (
<tr key={m.id}>
<td style={{ ...TD_BASE }}>
{m.profile?.avatar_url
? <div style={{ width: 26, height: 26, borderRadius: '50%', overflow: 'hidden' }}><img src={m.profile.avatar_url} alt={m.profile.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div style={{ width: 26, height: 26, borderRadius: '50%', background: 'rgba(255,255,255,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1 }}>{initials}</span></div>
}
</td>
<td style={{ ...TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>{m.profile?.name || '—'}</td>
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-muted)' }}>{m.profile?.email || '—'}</td>
<td style={{ ...TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>{projCount}</td>
<td style={{ ...TD_BASE }} />
</tr>
);
})}
</tbody>
</table>
);
})()}
</div> </div>
)} )}
</div> </div>
@@ -327,45 +507,63 @@ export default function ProjectDetailPage() {
{/* Right 30% */} {/* Right 30% */}
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24 }}> <div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div className="card" style={CARD}> <div className="card" style={CARD}>
<div style={LABEL}>Project</div> <div style={LABEL}>Main Contact</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> {companyClients.length === 0
<div> ? <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No contacts found.</div>
<div style={META_LABEL}>Status</div> : (
<StatusBadge status={project.status} /> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
</div> {companyClients.map(person => {
{company && ( const initials = (person.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
<div> return (
<div style={META_LABEL}>Company</div> <div key={person.id} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{isTeam {person.avatar_url
? <Link to={`/company/${company.id}`} className="table-link" style={{ fontSize: 13 }}>{company.name}</Link> ? <div style={{ width: 32, height: 32, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}><img src={person.avatar_url} alt={person.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{company.name}</div> : <div style={{ width: 32, height: 32, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}><span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>
} }
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{person.name}</div>
{person.email && <div style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{person.email}</div>}
</div>
</div>
);
})}
</div> </div>
)} )
<div> }
<div style={META_LABEL}>Started</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate(project.created_at)}</div>
</div>
</div>
</div> </div>
<div className="card" style={{ ...CARD, flex: 1 }}> <div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={LABEL}>Summary</div> <div style={LABEL}>Activity</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> {(() => {
{[ const SHOW = { task_started: { label: 'started', color: '#60a5fa', bg: 'rgba(96,165,250,0.15)', path: 'M6 4l14 8-14 8V4z' }, task_on_hold: { label: 'placed on hold', color: '#ef4444', bg: 'rgba(239,68,68,0.15)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_approved: { label: 'approved', color: '#4ade80', bg: 'rgba(74,222,128,0.15)', path: 'M4 13l5 5L20 7' } };
{ label: 'Total', value: tasks.length }, const filtered = activityLog;
{ label: 'Not Started', value: tasks.filter(t => t.status === 'not_started').length }, if (filtered.length === 0) return <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No activity yet.</div>;
{ label: 'In Progress', value: tasks.filter(t => t.status === 'in_progress').length }, return (
{ label: 'On Hold', value: tasks.filter(t => t.status === 'on_hold').length }, <div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
{ label: 'In Review', value: tasks.filter(t => t.status === 'client_review').length }, {filtered.map(e => {
{ label: 'Approved', value: tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length }, const cfg = SHOW[e.action];
].map(({ label, value }) => ( const d = new Date(e.created_at);
<div key={label} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{label}</div> const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
<div style={{ fontSize: 13, fontWeight: 500, color: value > 0 ? 'var(--text-primary)' : 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{value}</div> return (
<div key={e.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: cfg.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 1 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={cfg.color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d={cfg.path} /></svg>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, lineHeight: 1.4 }}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{e.actor_name || 'Fourge'}</span>
<span style={{ color: 'var(--text-muted)' }}> {cfg.label} </span>
{e.task_title && <span style={{ color: 'var(--text-primary)' }}>{e.task_title}</span>}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{dateStr} · {timeStr}</div>
</div>
</div>
);
})}
</div> </div>
))} );
</div> })()}
</div> </div>
</div> </div>
</div> </div>
@@ -410,6 +608,93 @@ export default function ProjectDetailPage() {
</div> </div>
</div> </div>
)} )}
{showClientTask && isClient && (
<div style={popupOverlayStyle} onClick={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}>
<div style={{ ...popupSurfaceStyle, width: 'min(720px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 14 }}>New Task</div>
<RequestForm
key={clientTaskKey}
companies={company ? [company] : []}
initialCompanyId={company?.id || ''}
initialValues={{ companyId: company?.id || '', project: project.name }}
currentUser={currentUser}
showRequester={false}
lockedFields={['company', 'project']}
onSubmit={handleClientTask}
onCancel={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}
saving={clientTaskSaving}
error={clientTaskError}
submitLabel="Save"
/>
</div>
</div>
)}
{addingMember && isTeam && (
<div style={popupOverlayStyle} onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ padding: '18px 21px 0' }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{project.name}</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}>
{externalProfiles.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 80, fontSize: 13, color: 'var(--text-muted)' }}>No subcontractors found.</div>
: <table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '36%' }} />
<col style={{ width: '49%' }} />
<col style={{ width: '10%' }} />
</colgroup>
<thead>
<tr>
<th style={{ ...TH_STYLE, padding: '0 0 12px' }} />
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Name</SortTh>
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Email</SortTh>
<th style={{ ...TH_STYLE, padding: '0 0 12px', textAlign: 'center' }}>Assigned</th>
</tr>
</thead>
<tbody>
{[...externalProfiles].sort((a, b) => {
const av = subSortKey === 'name' ? (a.name || '') : (a.email || '');
const bv = subSortKey === 'name' ? (b.name || '') : (b.email || '');
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
}).map(p => {
const checked = selectedExts.has(p.id);
const initials = (p.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<tr key={p.id} onClick={() => setSelectedExts(prev => { const s = new Set(prev); s.has(p.id) ? s.delete(p.id) : s.add(p.id); return s; })} style={{ cursor: 'pointer' }}>
<td style={{ padding: '5px 0', border: 'none', background: 'transparent' }}>
{p.avatar_url
? <div style={{ width: 26, height: 26, borderRadius: '50%', overflow: 'hidden' }}><img src={p.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div style={{ width: 26, height: 26, borderRadius: '50%', background: checked ? 'var(--accent)' : 'rgba(255,255,255,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background 140ms' }}>
<span style={{ fontSize: 10, fontWeight: 600, color: checked ? '#000' : 'var(--text-primary)', lineHeight: 1 }}>{initials}</span>
</div>
}
</td>
<td style={{ padding: '5px 5px 5px 12px', border: 'none', background: 'transparent', fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</td>
<td style={{ padding: '5px', border: 'none', background: 'transparent', fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.email || '—'}</td>
<td style={{ padding: '5px 0', border: 'none', background: 'transparent', textAlign: 'center' }}>
<div style={{ width: 18, height: 18, borderRadius: 4, border: `2px solid ${checked ? 'var(--accent)' : 'var(--border)'}`, background: checked ? 'var(--accent)' : 'transparent', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', transition: 'all 140ms' }}>
{checked && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
}
</div>
<div style={{ padding: '0 21px 18px', display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-outline" disabled={savingMembers} onClick={handleAddMember}>{savingMembers ? 'Saving…' : 'Save'}</button>
<button className="btn btn-outline" onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>Cancel</button>
</div>
</div>
</div>
)}
</Layout> </Layout>
); );
} }
+8 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader'; import PageLoader from '../components/PageLoader';
@@ -11,6 +11,8 @@ import JSZip from 'jszip';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles'; import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { sendEmail } from '../lib/email'; import { sendEmail } from '../lib/email';
import { uploadFilesToRequestInfo, safeName } from '../lib/filebrowserFolders'; import { uploadFilesToRequestInfo, safeName } from '../lib/filebrowserFolders';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
const ACTION_LABEL = { const ACTION_LABEL = {
task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold', task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold',
@@ -201,6 +203,10 @@ function SubmissionFiles({ files, downloading, onDownloadAll }) {
export default function TaskDetail() { export default function TaskDetail() {
const { id } = useParams(); const { id } = useParams();
const { currentUser } = useAuth(); const { currentUser } = useAuth();
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'submissions', 'activity_log', 'task_comments'], refresh);
const [task, setTask] = useState(null); const [task, setTask] = useState(null);
const [submissions, setSubmissions] = useState([]); const [submissions, setSubmissions] = useState([]);
const [activityLog, setActivityLog] = useState([]); const [activityLog, setActivityLog] = useState([]);
@@ -255,7 +261,7 @@ export default function TaskDetail() {
setComments(commentData || []); setComments(commentData || []);
setLoading(false); setLoading(false);
}); });
}, [id]); }, [id, refreshKey]);
if (loading) return <Layout><PageLoader /></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
if (!task) return <Layout><p style={{ padding: 24 }}>Task not found.</p></Layout>; if (!task) return <Layout><p style={{ padding: 24 }}>Task not found.</p></Layout>;
+19 -10
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from 'react'; import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
@@ -16,6 +16,8 @@ import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFo
import SortTh from '../components/SortTh'; import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable'; import { useSortable } from '../hooks/useSortable';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles'; import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
const TASK_STAT_ICONS = { const TASK_STAT_ICONS = {
total: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>', total: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>',
@@ -179,6 +181,11 @@ export default function RequestsPage() {
const isExternal = currentUser?.role === 'external'; const isExternal = currentUser?.role === 'external';
const isClient = currentUser?.role === 'client'; const isClient = currentUser?.role === 'client';
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'projects', 'submissions'], refresh);
const teamCached = isTeam ? readPageCache('team_requests') : null; const teamCached = isTeam ? readPageCache('team_requests') : null;
const extCached = isExternal ? readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000) : null; const extCached = isExternal ? readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000) : null;
@@ -315,7 +322,7 @@ export default function RequestsPage() {
} }
loadTasksPage(); loadTasksPage();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [isTeam, isExternal, isClient, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps }, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
const handleAddRequest = async (formData, _files, existingProjects) => { const handleAddRequest = async (formData, _files, existingProjects) => {
if (addSaving) return; if (addSaving) return;
@@ -338,7 +345,7 @@ export default function RequestsPage() {
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {}); logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([ const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }), supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at').order('submitted_at', { ascending: false }), supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)').order('submitted_at', { ascending: false }),
]); ]);
setSubmissions(newSubs || []); setTasks(newTasks || []); setSubmissions(newSubs || []); setTasks(newTasks || []);
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID()); setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
@@ -391,7 +398,7 @@ export default function RequestsPage() {
const { data: refreshProjects } = await supabase.from('projects').select('id, name, status, company_id, company:companies(id, name)').in('company_id', refreshCompanyIds); const { data: refreshProjects } = await supabase.from('projects').select('id, name, status, company_id, company:companies(id, name)').in('company_id', refreshCompanyIds);
const refreshProjectIds = (refreshProjects || []).map(p => p.id); const refreshProjectIds = (refreshProjects || []).map(p => p.id);
const { data: newTasks } = await supabase.from('tasks') const { data: newTasks } = await supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at') .select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)')
.in('project_id', refreshProjectIds.length > 0 ? refreshProjectIds : ['__none__']) .in('project_id', refreshProjectIds.length > 0 ? refreshProjectIds : ['__none__'])
.order('submitted_at', { ascending: false }); .order('submitted_at', { ascending: false });
const refreshTaskIds = (newTasks || []).map(t => t.id); const refreshTaskIds = (newTasks || []).map(t => t.id);
@@ -441,7 +448,7 @@ export default function RequestsPage() {
id: task.id, title: task.title, status: task.status, projectId: task.project_id, id: task.id, title: task.title, status: task.status, projectId: task.project_id,
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null, serviceType: sub?.service_type || '—', deadline: sub?.deadline || null,
version: task.current_version || 0, isHot: false, version: task.current_version || 0, isHot: false,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
submittedAt: task.submitted_at ? new Date(task.submitted_at).getTime() : 0, submittedAt: task.submitted_at ? new Date(task.submitted_at).getTime() : 0,
}; };
}); });
@@ -460,7 +467,7 @@ export default function RequestsPage() {
serviceType, deadline: deadlineSource.deadline, serviceType, deadline: deadlineSource.deadline,
version: deadlineSource.version_number ?? 0, version: deadlineSource.version_number ?? 0,
isHot: deadlineSource.is_hot || false, isHot: deadlineSource.is_hot || false,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
submittedAt: latestGroup.length > 0 submittedAt: latestGroup.length > 0
? Math.max(...latestGroup.map(s => new Date(s.submitted_at).getTime())) ? Math.max(...latestGroup.map(s => new Date(s.submitted_at).getTime()))
: (task.submitted_at ? new Date(task.submitted_at).getTime() : 0), : (task.submitted_at ? new Date(task.submitted_at).getTime() : 0),
@@ -539,10 +546,12 @@ export default function RequestsPage() {
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}> <td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
{(() => { {(() => {
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 }; const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
if (row.assignedName && row.assigneeAvatar) if (row.assignedName && row.assignedTo) {
return <div title={row.assignedName} style={avatarStyle}><img src={row.assigneeAvatar} alt={row.assignedName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>; const portrait = row.assigneeAvatar
if (row.assignedName) ? <div title={row.assignedName} style={avatarStyle}><img src={row.assigneeAvatar} alt={row.assignedName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
return <div title={row.assignedName} style={{ ...avatarStyle, background: 'var(--accent)' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>; : <div title={row.assignedName} style={{ ...avatarStyle, background: 'var(--accent)' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>;
return <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>{portrait}</Link>;
}
return <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>; return <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
})()} })()}
</td> </td>
+11 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh'; import SortTh from '../../components/SortTh';
@@ -8,6 +8,8 @@ import { readPageCache, writePageCache } from '../../lib/pageCache';
import { withTimeout } from '../../lib/withTimeout'; import { withTimeout } from '../../lib/withTimeout';
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines'; import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import { useRefetchOnFocus } from '../../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../../hooks/useRealtimeSubscription';
// ─── Helpers ────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────
@@ -625,6 +627,12 @@ export default function TeamDashboard() {
const isTeam = currentUser?.role === 'team'; const isTeam = currentUser?.role === 'team';
const isClient = currentUser?.role === 'client'; const isClient = currentUser?.role === 'client';
const isExternal = currentUser?.role === 'external'; const isExternal = currentUser?.role === 'external';
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'projects', 'submissions', 'activity_log', 'profiles'], refresh);
const cached = isTeam ? readPageCache(CACHE_KEY, 5 * 60_000) : null; const cached = isTeam ? readPageCache(CACHE_KEY, 5 * 60_000) : null;
const [tasks, setTasks] = useState(() => cached?.tasks || []); const [tasks, setTasks] = useState(() => cached?.tasks || []);
@@ -679,7 +687,7 @@ export default function TeamDashboard() {
} }
const tasksQuery = supabase.from('tasks') const tasksQuery = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at') .select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, assignee:profiles!assigned_to(avatar_url)')
.gte('submitted_at', cutoffStr) .gte('submitted_at', cutoffStr)
.order('submitted_at', { ascending: false }); .order('submitted_at', { ascending: false });
if (isClient) { if (isClient) {
@@ -784,7 +792,7 @@ export default function TeamDashboard() {
loadDashboard(); loadDashboard();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [isTeam, isClient, isExternal, currentUser?.id, currentUser?.company_id, currentUser?.companies]); // eslint-disable-line react-hooks/exhaustive-deps }, [isTeam, isClient, isExternal, currentUser?.id, currentUser?.company_id, currentUser?.companies, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
const teamHighlights = useMemo(() => const teamHighlights = useMemo(() =>
isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices) isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)