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',
initialCompanyId = '',
initialValues = null,
lockedFields = [],
}) {
const [form, setForm] = useState(() => ({ ...emptyForm(initialCompanyId), ...(initialValues || {}) }));
const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null);
@@ -164,33 +165,27 @@ export default function RequestForm({
<form onSubmit={handleSubmit}>
{/* Row 1: Company + Project */}
<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">
<label style={modalLabelStyle}>Company *</label>
<select
value={form.companyId}
onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))}
required
style={modalInputStyle}
>
<select value={form.companyId} onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))} required style={modalInputStyle}>
<option value="">Select company...</option>
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select>
</div>
) : <div />}
<div className="form-group">
<label style={modalLabelStyle}>Project *</label>
{isTypingProject ? (
<label style={modalLabelStyle}>Project {!lockedFields.includes('project') && '*'}</label>
{lockedFields.includes('project') ? (
<input value={form.project} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
) : 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(); handleAddProject(); } }}
autoFocus
style={{ ...modalInputStyle, flex: 1 }}
/>
<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 }} />
<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>
</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 { error } = await supabase.auth.signInWithPassword({ email, password });
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 Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
@@ -7,16 +7,24 @@ import SortTh from '../components/SortTh';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
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 { serviceTypes } from '../data/mockData';
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
import { addDaysToDateOnly, getTodayDateOnlyEST, fmtShortDate } from '../lib/dates';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
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 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 = { padding: '18px 21px', borderRadius: 8 };
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 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 TAB_STYLE = (active) => ({
fontFamily: "Fourge, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
@@ -37,6 +45,11 @@ export default function ProjectDetailPage() {
const isExternal = currentUser?.role === 'external';
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 [company, setCompany] = useState(null);
const [tasks, setTasks] = useState([]);
@@ -44,7 +57,8 @@ export default function ProjectDetailPage() {
const [externalProfiles, setExtProfs] = useState([]);
const [companyUsers, setCompanyUsers] = useState([]);
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 [nameVal, setNameVal] = useState('');
@@ -53,9 +67,21 @@ export default function ProjectDetailPage() {
const [showAddJob, setShowAddJob] = useState(false);
const [jobForm, setJobForm] = useState(emptyJobForm());
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 [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');
@@ -66,24 +92,44 @@ export default function ProjectDetailPage() {
setProject(p);
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 }),
supabase.from('tasks').select('*, assignee:profiles!assigned_to(avatar_url)').eq('project_id', id).order('submitted_at', { ascending: false }),
]);
setCompany(co);
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) {
const [{ data: users }, { data: pm }, { data: ext }] = await Promise.all([
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 || []);
const { data: ext } = await supabase.from('profiles').select('id, name, avatar_url, email').eq('role', 'external').order('name');
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);
}
load();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
}, [id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSaveName = async (e) => {
e.preventDefault();
@@ -130,9 +176,23 @@ export default function ProjectDetailPage() {
};
const handleAddMember = async () => {
if (!selectedExt) return;
const { data } = await supabase.from('project_members').insert({ project_id: id, profile_id: selectedExt }).select('*, profile:profiles(id, name, email)').single();
if (data) { setMembers(prev => [...prev, data]); setSelectedExt(''); setAddingMember(false); }
setSavingMembers(true);
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
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) => {
@@ -140,6 +200,37 @@ export default function ProjectDetailPage() {
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 (!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),
];
const sortedTasks = sort(tasks, (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 || '';
const rows = tasks.map(task => {
const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial') || submissions.find(s => s.task_id === task.id);
return {
id: task.id, title: task.title, status: task.status,
version: task.current_version || 0,
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 '';
});
const tabs = [
{ id: 'tasks', label: 'Tasks' },
...(isTeam ? [{ id: 'members', label: 'Members' }] : []),
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const taskTabs = [
{ 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' }) : '—';
@@ -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={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{isTeam && !editingName && (
<button className="btn btn-outline" onClick={() => { setNameVal(project.name); setEditingName(true); }}>Edit</button>
)}
{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>
{isClient && (
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button className="btn btn-outline" onClick={() => setShowClientTask(true)}>+ Task</button>
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
<StatusBadge status={project.status} />
</div>
<div style={{ display: 'flex', gap: 30, flexWrap: 'wrap' }}>
{!isClient && company && (
<div>
<div style={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>
}
{(() => {
const approved = tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length;
const pct = tasks.length > 0 ? Math.round((approved / tasks.length) * 100) : 0;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 30, flexWrap: 'wrap' }}>
{!isClient && company && (
<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 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>
{/* Tabs + content card */}
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
{tabs.map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)} style={TAB_STYLE(activeTab === tab.id)}>{tab.label}</button>
))}
</div>
{(() => {
const tabCounts = {
all: tasks.length,
not_started: tasks.filter(t => t.status === 'not_started').length,
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' }}>
{activeTab === 'tasks' && (
tasks.length === 0
{activeTab !== 'members' && (
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 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%' }}>
<colgroup>
<col style={{ width: '35%' }} />
<col style={{ width: isTeam ? '18%' : '20%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '17%' }} />
<col style={{ width: '14%' }} />
{isTeam && <col style={{ width: '4%' }} />}
<col style={{ width: '5%' }} />
<col style={{ width: '28%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '8%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task</SortTh>
<SortTh col="assigned_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Assigned</SortTh>
<SortTh col="current_version" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Rev</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Created</SortTh>
{isTeam && <th />}
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TH_STYLE}>R#</SortTh>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TH_STYLE}>Name</SortTh>
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
<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>
</thead>
<tbody>
{sortedTasks.map(task => (
<tr key={task.id} onClick={() => navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ padding: 5 }}>
<Link to={`/tasks/${task.id}`} className="table-link" style={{ fontSize: 13 }} onClick={e => e.stopPropagation()}>{task.title}</Link>
</td>
<td style={{ fontSize: 12, color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)', padding: 5 }}>{task.assigned_name || 'Unassigned'}</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', padding: 5 }}>R{String(task.current_version || 0).padStart(2, '0')}</td>
<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>
{visibleRows.map(row => {
const initials = row.assignedName ? row.assignedName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : null;
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
return (
<tr key={row.id}>
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
</td>
)}
</tr>
))}
<td style={{ ...TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
<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>
</table>
</div>
)}
{activeTab === 'members' && isTeam && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>External members assigned to this project.</div>
{!addingMember && <button className="btn btn-outline" onClick={() => setAddingMember(true)}>+ Add</button>}
</div>
{addingMember && (
<div style={{ display: 'flex', gap: 8 }}>
<select value={selectedExt} onChange={e => setSelectedExt(e.target.value)} style={{ ...MODAL_IN, 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-outline" onClick={handleAddMember} disabled={!selectedExt}>Add</button>
<button className="btn btn-outline" onClick={() => { setAddingMember(false); setSelectedExt(''); }}>Cancel</button>
</div>
)}
{members.filter(m => m.profile?.role === 'external').length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 80, color: 'var(--text-muted)', fontSize: 13 }}>No external members.</div>
: members.filter(m => m.profile?.role === 'external').map(m => (
<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)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{m.profile?.email}</div>
</div>
<button className="btn btn-danger" onClick={() => handleRemoveMember(m.profile_id)} style={{ fontSize: 10, padding: '1px 8px', height: 22 }}>Remove</button>
</div>
))
}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{(() => {
const subs = members.filter(m => m.profile?.role === 'external');
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>;
const sorted = [...subs].sort((a, b) => {
if (subSortKey === 'projects') {
const av = subProjectCounts[a.profile_id] || 0;
const bv = subProjectCounts[b.profile_id] || 0;
return subSortDir === 'asc' ? av - bv : bv - av;
}
const av = subSortKey === 'name' ? (a.profile?.name || '') : (a.profile?.email || '');
const bv = subSortKey === 'name' ? (b.profile?.name || '') : (b.profile?.email || '');
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
});
return (
<table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '30%' }} />
<col style={{ width: '40%' }} />
<col style={{ width: '15%' }} />
<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>
<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>
@@ -327,45 +507,63 @@ export default function ProjectDetailPage() {
{/* Right 30% */}
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div className="card" style={CARD}>
<div style={LABEL}>Project</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={META_LABEL}>Status</div>
<StatusBadge status={project.status} />
</div>
{company && (
<div>
<div style={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 style={LABEL}>Main Contact</div>
{companyClients.length === 0
? <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No contacts found.</div>
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{companyClients.map(person => {
const initials = (person.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<div key={person.id} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{person.avatar_url
? <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={{ 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 style={META_LABEL}>Started</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate(project.created_at)}</div>
</div>
</div>
)
}
</div>
<div className="card" style={{ ...CARD, flex: 1 }}>
<div style={LABEL}>Summary</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{[
{ label: 'Total', value: tasks.length },
{ label: 'Not Started', value: tasks.filter(t => t.status === 'not_started').length },
{ label: 'In Progress', value: tasks.filter(t => t.status === 'in_progress').length },
{ label: 'On Hold', value: tasks.filter(t => t.status === 'on_hold').length },
{ label: 'In Review', value: tasks.filter(t => t.status === 'client_review').length },
{ label: 'Approved', value: tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length },
].map(({ label, value }) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{label}</div>
<div style={{ fontSize: 13, fontWeight: 500, color: value > 0 ? 'var(--text-primary)' : 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{value}</div>
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={LABEL}>Activity</div>
{(() => {
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' } };
const filtered = activityLog;
if (filtered.length === 0) return <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No activity yet.</div>;
return (
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map(e => {
const cfg = SHOW[e.action];
const d = new Date(e.created_at);
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
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>
@@ -410,6 +608,93 @@ export default function ProjectDetailPage() {
</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>
);
}
+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 Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
@@ -11,6 +11,8 @@ import JSZip from 'jszip';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { sendEmail } from '../lib/email';
import { uploadFilesToRequestInfo, safeName } from '../lib/filebrowserFolders';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
const ACTION_LABEL = {
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() {
const { id } = useParams();
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 [submissions, setSubmissions] = useState([]);
const [activityLog, setActivityLog] = useState([]);
@@ -255,7 +261,7 @@ export default function TaskDetail() {
setComments(commentData || []);
setLoading(false);
});
}, [id]);
}, [id, refreshKey]);
if (loading) return <Layout><PageLoader /></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 Layout from '../components/Layout';
import StatusBadge from '../components/StatusBadge';
@@ -16,6 +16,8 @@ import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFo
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
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"/>',
@@ -179,6 +181,11 @@ export default function RequestsPage() {
const isExternal = currentUser?.role === 'external';
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 extCached = isExternal ? readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000) : null;
@@ -315,7 +322,7 @@ export default function RequestsPage() {
}
loadTasksPage();
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) => {
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(() => {});
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('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 || []);
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 refreshProjectIds = (refreshProjects || []).map(p => p.id);
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__'])
.order('submitted_at', { ascending: false });
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,
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null,
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,
};
});
@@ -460,7 +467,7 @@ export default function RequestsPage() {
serviceType, deadline: deadlineSource.deadline,
version: deadlineSource.version_number ?? 0,
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
? Math.max(...latestGroup.map(s => new Date(s.submitted_at).getTime()))
: (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' }}>
{(() => {
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)
return <div title={row.assignedName} style={avatarStyle}><img src={row.assigneeAvatar} alt={row.assignedName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>;
if (row.assignedName)
return <div title={row.assignedName} style={{ ...avatarStyle, background: 'var(--accent)' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>;
if (row.assignedName && row.assignedTo) {
const portrait = 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>;
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>;
})()}
</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 Layout from '../../components/Layout';
import SortTh from '../../components/SortTh';
@@ -8,6 +8,8 @@ import { readPageCache, writePageCache } from '../../lib/pageCache';
import { withTimeout } from '../../lib/withTimeout';
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
import { useSortable } from '../../hooks/useSortable';
import { useRefetchOnFocus } from '../../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../../hooks/useRealtimeSubscription';
// ─── Helpers ──────────────────────────────────────────────────────────────
@@ -625,6 +627,12 @@ export default function TeamDashboard() {
const isTeam = currentUser?.role === 'team';
const isClient = currentUser?.role === 'client';
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 [tasks, setTasks] = useState(() => cached?.tasks || []);
@@ -679,7 +687,7 @@ export default function TeamDashboard() {
}
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)
.order('submitted_at', { ascending: false });
if (isClient) {
@@ -784,7 +792,7 @@ export default function TeamDashboard() {
loadDashboard();
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(() =>
isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)