From 70ad8d0ceff85193b1b4f664f7c831ca3bdd6aac Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Sun, 31 May 2026 16:55:58 -0400 Subject: [PATCH] feat: project detail overhaul, realtime updates, avatar RLS fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/RequestForm.jsx | 31 +- src/context/AuthContext.jsx | 11 + src/hooks/useRealtimeSubscription.js | 17 + src/hooks/useRefetchOnFocus.js | 12 + src/pages/ProjectDetail.jsx | 625 +++++++++++++++++++-------- src/pages/TaskDetail.jsx | 10 +- src/pages/Tasks.jsx | 29 +- src/pages/team/TeamDashboard.jsx | 14 +- 8 files changed, 546 insertions(+), 203 deletions(-) create mode 100644 src/hooks/useRealtimeSubscription.js create mode 100644 src/hooks/useRefetchOnFocus.js diff --git a/src/components/RequestForm.jsx b/src/components/RequestForm.jsx index a1ac4c3..04dd1d0 100644 --- a/src/components/RequestForm.jsx +++ b/src/components/RequestForm.jsx @@ -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({
{/* Row 1: Company + Project */}
- {showCompanySelect ? ( + {lockedFields.includes('company') ? ( +
+ + c.id === companyId)?.name || companyId} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} /> +
+ ) : showCompanySelect ? (
- setForm(f => ({ ...f, companyId: e.target.value }))} required style={modalInputStyle}> {companies.map(co => )}
) :
}
- - {isTypingProject ? ( + + {lockedFields.includes('project') ? ( + + ) : isTypingProject ? (
- setNewProjectName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }} - autoFocus - style={{ ...modalInputStyle, flex: 1 }} - /> + setNewProjectName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }} autoFocus style={{ ...modalInputStyle, flex: 1 }} />
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index 3a3a09f..b0fc7c0 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -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 }; diff --git a/src/hooks/useRealtimeSubscription.js b/src/hooks/useRealtimeSubscription.js new file mode 100644 index 0000000..186d0d1 --- /dev/null +++ b/src/hooks/useRealtimeSubscription.js @@ -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 +} diff --git a/src/hooks/useRefetchOnFocus.js b/src/hooks/useRefetchOnFocus.js new file mode 100644 index 0000000..8b0d236 --- /dev/null +++ b/src/hooks/useRefetchOnFocus.js @@ -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); + }, []); +} diff --git a/src/pages/ProjectDetail.jsx b/src/pages/ProjectDetail.jsx index dcb2cc5..4f2e089 100644 --- a/src/pages/ProjectDetail.jsx +++ b/src/pages/ProjectDetail.jsx @@ -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 ; if (!project) return

Project not found.

; @@ -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() { ) : (
{project.name}
)} -
- {isTeam && !editingName && ( - - )} - {isTeam && ( - - )} - {isClient && ( - + Task - )} - {(isTeam || (isClient && tasks.length === 0)) && ( - - )} -
+ {isClient && ( +
+ +
+ )}
-
- {!isClient && company && ( -
-
Company
- {isTeam - ? {company.name} - :
{company.name}
- } + {(() => { + 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 ( +
+ {!isClient && company && ( +
+ +
+
Company
+ {isTeam + ? {company.name} + :
{company.name}
+ } +
+
+ )} +
+ +
+
Started
+
{fmtDate(project.created_at)}
+
+
+
+
+
Completion
+
{pct}%
+
+
+
+
+
{approved} of {tasks.length} approved
+
- )} -
-
Started
-
{fmtDate(project.created_at)}
-
-
-
Tasks
-
{tasks.length}
-
-
-
In Progress
-
{tasks.filter(t => t.status === 'in_progress').length}
-
-
-
In Review
-
{tasks.filter(t => t.status === 'client_review').length}
-
-
-
Approved
-
{tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length}
-
-
+ ); + })()}
{/* Tabs + content card */}
-
- {tabs.map(tab => ( - - ))} -
+ {(() => { + 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 ( +
+ {taskTabs.map(tab => { + const count = tabCounts[tab.id] ?? 0; + const active = activeTab === tab.id; + return ( + + ); + })} + {activeTab === 'members' && isTeam && ( + + )} +
+ ); + })()}
- {activeTab === 'tasks' && ( - tasks.length === 0 + {activeTab !== 'members' && ( + visibleRows.length === 0 ?
No tasks yet.
:
- - - - - - {isTeam && } + + + + + + + - Task - Assigned - Rev - Status - Created - {isTeam && - {sortedTasks.map(task => ( - navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}> - - - - - - {isTeam && ( - + - )} - - ))} + + + + + + + + ); + })}
} + R# + Name + Assigned + Priority + Task Type + Deadline + Status
- e.stopPropagation()}>{task.title} - {task.assigned_name || 'Unassigned'}R{String(task.current_version || 0).padStart(2, '0')}{fmtDate(task.submitted_at)} e.stopPropagation()}> - + {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 ( +
+ {`R${String(row.version).padStart(2, '0')}`}
+ {row.title || '—'} + + {row.assignedName && row.assignedTo + ? + {row.assigneeAvatar + ?
{row.assignedName}
+ :
{initials}
+ } + + :
+ } +
+ {row.isHot ? 'HOT' : 'NO'} + {row.serviceType}{fmtShortDate(row.deadline, 'Not specified')}
)} {activeTab === 'members' && isTeam && ( -
-
-
External members assigned to this project.
- {!addingMember && } -
- {addingMember && ( -
- - - -
- )} - {members.filter(m => m.profile?.role === 'external').length === 0 - ?
No external members.
- : members.filter(m => m.profile?.role === 'external').map(m => ( -
-
-
{m.profile?.name}
-
{m.profile?.email}
-
- -
- )) - } +
+ {(() => { + const subs = members.filter(m => m.profile?.role === 'external'); + if (subs.length === 0) return
No subcontractors assigned.
; + 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 ( + + + + + + + + + + + + + + {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 ( + + + + + + + ); + })} + +
+ Name + Email + Projects + +
+ {m.profile?.avatar_url + ?
{m.profile.name}
+ :
{initials}
+ } +
{m.profile?.name || '—'}{m.profile?.email || '—'}{projCount} +
+ ); + })()}
)}
@@ -327,45 +507,63 @@ export default function ProjectDetailPage() { {/* Right 30% */}
-
Project
-
-
-
Status
- -
- {company && ( -
-
Company
- {isTeam - ? {company.name} - :
{company.name}
- } +
Main Contact
+ {companyClients.length === 0 + ?
No contacts found.
+ : ( +
+ {companyClients.map(person => { + const initials = (person.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); + return ( +
+ {person.avatar_url + ?
{person.name}
+ :
{initials}
+ } +
+
{person.name}
+ {person.email &&
{person.email}
} +
+
+ ); + })}
- )} -
-
Started
-
{fmtDate(project.created_at)}
-
-
+ ) + }
-
-
Summary
-
- {[ - { 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 }) => ( -
-
{label}
-
0 ? 'var(--text-primary)' : 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{value}
+
+
Activity
+ {(() => { + 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
No activity yet.
; + return ( +
+ {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 ( +
+
+ +
+
+
+ {e.actor_name || 'Fourge'} + {cfg.label} + {e.task_title && {e.task_title}} +
+
{dateStr} · {timeStr}
+
+
+ ); + })}
- ))} -
+ ); + })()}
@@ -410,6 +608,93 @@ export default function ProjectDetailPage() {
)} + + {showClientTask && isClient && ( +
{ setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}> +
e.stopPropagation()}> +
New Task
+ { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }} + saving={clientTaskSaving} + error={clientTaskError} + submitLabel="Save" + /> +
+
+ )} + + {addingMember && isTeam && ( +
{ setAddingMember(false); setSelectedExts(new Set()); }}> +
e.stopPropagation()}> +
+
Manage Subcontractors
+
{project.name}
+
+
+ {externalProfiles.length === 0 + ?
No subcontractors found.
+ : + + + + + + + + + + + + + {[...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 ( + setSelectedExts(prev => { const s = new Set(prev); s.has(p.id) ? s.delete(p.id) : s.add(p.id); return s; })} style={{ cursor: 'pointer' }}> + + + + + + ); + })} + +
+ Name + Email + Assigned
+ {p.avatar_url + ?
{p.name}
+ :
+ {initials} +
+ } +
{p.name}{p.email || '—'} +
+ {checked && } +
+
+ } +
+
+ + +
+
+
+ )} ); } diff --git a/src/pages/TaskDetail.jsx b/src/pages/TaskDetail.jsx index ad195d0..7c0c4f5 100644 --- a/src/pages/TaskDetail.jsx +++ b/src/pages/TaskDetail.jsx @@ -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 ; if (!task) return

Task not found.

; diff --git a/src/pages/Tasks.jsx b/src/pages/Tasks.jsx index 80405a0..1a0ce4d 100644 --- a/src/pages/Tasks.jsx +++ b/src/pages/Tasks.jsx @@ -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: '', @@ -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() { {(() => { 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
{row.assignedName}
; - if (row.assignedName) - return
{initials}
; + if (row.assignedName && row.assignedTo) { + const portrait = row.assigneeAvatar + ?
{row.assignedName}
+ :
{initials}
; + return {portrait}; + } return
; })()} diff --git a/src/pages/team/TeamDashboard.jsx b/src/pages/team/TeamDashboard.jsx index d813d63..6f9632e 100644 --- a/src/pages/team/TeamDashboard.jsx +++ b/src/pages/team/TeamDashboard.jsx @@ -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)