- {showCompanySelect ? (
+ {lockedFields.includes('company') ? (
+
-
- {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 && (
+
+
+
+ )}
-
- {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 && | }
+ R#
+ Name
+ Assigned
+ Priority
+ Task Type
+ Deadline
+ Status
- {sortedTasks.map(task => (
- navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}>
- |
- e.stopPropagation()}>{task.title}
- |
- {task.assigned_name || 'Unassigned'} |
- R{String(task.current_version || 0).padStart(2, '0')} |
- |
- {fmtDate(task.submitted_at)} |
- {isTeam && (
- 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
+ ?
+ : {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 (
+
+
+
+
+
+
+
+
+
+
+ |
+ Name
+ Email
+ Projects
+ |
+
+
+
+ {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 (
+
+ |
+ {m.profile?.avatar_url
+ ?
+ : {initials}
+ }
+ |
+ {m.profile?.name || '—'} |
+ {m.profile?.email || '—'} |
+ {projCount} |
+ |
+
+ );
+ })}
+
+
+ );
+ })()}
)}
@@ -327,45 +507,63 @@ export default function ProjectDetailPage() {
{/* Right 30% */}
-
Project
-
-
- {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
+ ?
+ :
{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.
+ :
+
+
+
+
+
+
+
+
+ |
+ Name
+ Email
+ Assigned |
+
+
+
+ {[...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' }}>
+ |
+ {p.avatar_url
+ ?
+ :
+ {initials}
+
+ }
+ |
+ {p.name} |
+ {p.email || '—'} |
+
+
+ |
+
+ );
+ })}
+
+
+ }
+
+
+
+
+
+
+
+ )}
);
}
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 ;
- if (row.assignedName)
- return {initials} ;
+ if (row.assignedName && row.assignedTo) {
+ const portrait = row.assigneeAvatar
+ ?
+ : {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)