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:
+19
-10
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user