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