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:
+455
-170
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user