Session 2026-05-30: tasks/projects unification, code consolidation, file renames

- Unified Tasks page: 3 role render blocks → 1, normalized row shape, single renderRow/sort/tabs
- Fixed role scoping: external = all tasks in member projects, client = all tasks in company projects
- Fixed client bug: was scoped by submitted_by, now company→project→tasks
- Aligned dashboard client scope to match tasks page
- Hot tasks dashboard: now filters to active statuses only (not completed/invoiced/paid)
- Removed Projects.jsx (dead), fixed /project/ → /projects/ links
- Renamed all page files to match folder path (Team*, External*, Client* prefixes)
- Renamed: RequestsPage→Tasks, Settings→Profile, CompaniesPage→Companies, ProjectDetailPage→ProjectDetail
- Dropped dead fetches from Tasks page (invoices, invoice_items, subcontractor_invoice_items)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-30 10:19:23 -04:00
parent 6fe7ab1059
commit 13ef1f4ded
30 changed files with 1177 additions and 1258 deletions
+142 -73
View File
@@ -165,7 +165,7 @@ function ActivityFeed({ events }) {
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
</div>
{visible.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 14 }}>No recent activity</div>
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No recent activity</div>
) : visible.map((e, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
<ActionIcon actionKey={e.actionKey} />
@@ -340,7 +340,7 @@ function TasksInProgressCard({ tasks = [] }) {
)}
</div>
{rows.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
@@ -373,13 +373,18 @@ function TasksInProgressCard({ tasks = [] }) {
);
}
function HotItemsCard({ submissions, tasks }) {
function HotItemsCard({ submissions, tasks, isClient = false }) {
const navigate = useNavigate();
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
const taskMap = new Map((tasks || []).map(t => [t.id, t]));
const seen = new Set();
const hotItems = (submissions || [])
.filter(s => s.is_hot && !seen.has(s.task_id) && seen.add(s.task_id))
.filter(s => {
const task = taskMap.get(s.task_id);
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
const activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
})
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
.filter(s => s.task)
.sort((a, b) => {
@@ -398,10 +403,14 @@ function HotItemsCard({ submissions, tasks }) {
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Hot Tasks</span>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>
{isClient ? 'Tasks Ready For Review' : 'Hot Tasks'}
</span>
</div>
{hotItems.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No hot tasks</div>
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
{isClient ? 'No tasks ready for review' : 'No hot tasks'}
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
@@ -471,7 +480,7 @@ function ClientHighlightCard({ highlights }) {
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Client Highlight</span>
</div>
{!highlights || highlights.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No data</div>
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No data</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
@@ -576,7 +585,7 @@ function TeamPerformanceCard({ tasks, profiles }) {
</div>
</div>
{people.slice(0, 5).length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', flex: 1 }}>No completed tasks this month</div>
<div className="card-empty-center">No completed tasks this month</div>
) : (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{people.slice(0, 5).map((p, i) => {
@@ -634,88 +643,148 @@ export default function TeamDashboard() {
useEffect(() => {
let cancelled = false;
async function loadTeam() {
async function loadDashboard() {
try {
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
const cutoffStr = cutoff.toISOString();
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: cos }, { data: memRows }] = await withTimeout(Promise.all([
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').gte('submitted_at', cutoffStr).order('submitted_at', { ascending: false }),
supabase.from('projects').select('id, name, status, company_id'),
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)').gte('submitted_at', cutoffStr).order('version_number', { ascending: false }),
let scopedTaskIds = null;
let scopedProjectIds = null;
let scopedCompanyIds = null;
if (!isTeam && isClient) {
const companyIds = [...new Set([...(currentUser?.companies?.map(c => c.company?.id || c.id) || []), ...(currentUser?.company_id ? [currentUser.company_id] : [])])].filter(Boolean);
scopedCompanyIds = companyIds;
if (companyIds.length > 0) {
const { data: projRows } = await supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds);
scopedProjectIds = (projRows || []).map(p => p.id).filter(Boolean);
scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id)).filter(Boolean);
} else {
scopedProjectIds = [];
scopedTaskIds = [];
}
}
if (!isTeam && isExternal) {
const uid = currentUser?.id;
const { data: memberData } = await supabase.from('project_members').select('project_id').eq('profile_id', uid);
scopedProjectIds = (memberData || []).map(m => m.project_id).filter(Boolean);
if ((scopedProjectIds || []).length > 0) {
const { data: projectTasks } = await supabase.from('tasks').select('id').in('project_id', scopedProjectIds);
scopedTaskIds = (projectTasks || []).map(row => row.id).filter(Boolean);
} else {
scopedTaskIds = [];
}
}
const tasksQuery = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at')
.gte('submitted_at', cutoffStr)
.order('submitted_at', { ascending: false });
if (isClient) {
if ((scopedProjectIds || []).length > 0) tasksQuery.in('project_id', scopedProjectIds);
else tasksQuery.eq('id', '__none__');
}
if (isExternal) {
if ((scopedProjectIds || []).length > 0) tasksQuery.in('project_id', scopedProjectIds);
else tasksQuery.eq('id', '__none__');
}
const projectsQuery = supabase.from('projects').select('id, name, status, company_id');
if (isClient) {
if ((scopedCompanyIds || []).length > 0) projectsQuery.in('company_id', scopedCompanyIds);
else projectsQuery.eq('id', '__none__');
}
if (isExternal) {
if ((scopedProjectIds || []).length > 0) projectsQuery.in('id', scopedProjectIds);
else projectsQuery.eq('id', '__none__');
}
const submissionsQuery = supabase.from('submissions')
.select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)')
.gte('submitted_at', cutoffStr)
.order('version_number', { ascending: false });
if (isClient) {
if ((scopedTaskIds || []).length > 0) submissionsQuery.in('task_id', scopedTaskIds);
else submissionsQuery.eq('task_id', '__none__');
}
if (isExternal) {
if ((scopedTaskIds || []).length > 0) submissionsQuery.in('task_id', scopedTaskIds);
else submissionsQuery.eq('task_id', '__none__');
}
const invoicesPromise = isTeam
? supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid'])
: isClient
? ((scopedCompanyIds || []).length > 0
? supabase.from('invoices').select('total, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid'])
: Promise.resolve({ data: [] }))
: supabase.from('subcontractor_invoices').select('total, status').eq('profile_id', currentUser?.id).in('status', ['submitted', 'approved', 'paid']);
const expensesPromise = isTeam
? supabase.from('expenses').select('amount')
: Promise.resolve({ data: [] });
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: cos }, { data: memRows }, { data: invs }, { data: exps }] = await withTimeout(Promise.all([
tasksQuery,
projectsQuery,
submissionsQuery,
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate, avatar_url'),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(isTeam ? 20 : 50),
supabase.from('companies').select('id, name').order('name'),
supabase.from('company_members').select('company_id, profile_id'),
]), 30000, 'TeamDashboard load');
invoicesPromise,
expensesPromise,
]), 30000, 'Dashboard load');
if (cancelled) return;
const projectIds = new Set((p || []).map(proj => proj.id));
const taskIds = new Set((t || []).map(task => task.id));
const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role]));
const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role]));
const tasksWithDeadlines = (t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null, assignee_role: roleById.get(task.assigned_to) || null }));
const subsWithRole = (subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null }));
setTasks(tasksWithDeadlines); setProjects(p || []); setSubmissions(subsWithRole);
setTasks(tasksWithDeadlines);
setProjects(p || []);
setSubmissions(subsWithRole);
setClientProfiles((profiles || []).filter(pr => pr.role === 'client'));
setAllProfiles(profiles || []); setAllCompanies(cos || []); setCompanyMemberships(memRows || []); setActivityLog(activity || []);
writePageCache(CACHE_KEY, { tasks: tasksWithDeadlines, projects: p || [], submissions: subsWithRole, clientProfiles: (profiles || []).filter(pr => pr.role === 'client'), companies: cos || [], companyMemberships: memRows || [], activityLog: activity || [], teamInvoices: [] });
supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid']).then(({ data: invs }) => { if (invs && !cancelled) setTeamInvoices(invs); });
supabase.from('expenses').select('amount').then(({ data: exps }) => { if (exps && !cancelled) setTeamExpenses(exps); });
} catch (err) { console.error('TeamDashboard load failed:', err); }
setAllProfiles(profiles || []);
setAllCompanies(cos || []);
setCompanyMemberships(memRows || []);
const scopedActivity = isTeam
? (activity || [])
: (activity || []).filter((entry) => {
if (entry.project_id && projectIds.has(entry.project_id)) return true;
if (entry.task_id && taskIds.has(entry.task_id)) return true;
return false;
});
setActivityLog(scopedActivity.slice(0, 20));
setTeamInvoices(isTeam ? (invs || []) : []);
setMyInvoices(!isTeam ? (invs || []) : []);
setTeamExpenses(exps || []);
if (isTeam) {
writePageCache(CACHE_KEY, {
tasks: tasksWithDeadlines,
projects: p || [],
submissions: subsWithRole,
clientProfiles: (profiles || []).filter(pr => pr.role === 'client'),
companies: cos || [],
companyMemberships: memRows || [],
activityLog: activity || [],
teamInvoices: invs || [],
allProfiles: profiles || [],
});
}
} catch (err) { console.error('Dashboard load failed:', err); }
finally { if (!cancelled) setLoading(false); }
}
async function loadClient() {
const uid = currentUser?.id;
const companyIds = [...new Set([...(currentUser?.companies?.map(c => c.company?.id || c.id) || []), ...(currentUser?.company_id ? [currentUser.company_id] : [])])].filter(Boolean);
try {
const { data: mySubData } = await supabase.from('submissions').select('task_id').eq('submitted_by', uid).eq('type', 'initial');
const myTaskIds = (mySubData || []).map(s => s.task_id);
const [{ data: t }, { data: p }, { data: subs }, { data: activity }, { data: invs }, { data: profiles }] = await withTimeout(Promise.all([
myTaskIds.length ? supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').in('id', myTaskIds) : Promise.resolve({ data: [] }),
companyIds.length ? supabase.from('projects').select('id, name, status, company_id').in('company_id', companyIds) : Promise.resolve({ data: [] }),
myTaskIds.length ? supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot').in('task_id', myTaskIds) : Promise.resolve({ data: [] }),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(50),
companyIds.length ? supabase.from('invoices').select('total, status').in('company_id', companyIds).in('status', ['sent', 'paid']) : Promise.resolve({ data: [] }),
supabase.from('profiles').select('id, name, avatar_url'),
]), 20000, 'ClientDashboard load');
if (cancelled) return;
const projectIds = new Set((p || []).map(proj => proj.id));
setTasks((t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null })));
setProjects(p || []); setSubmissions(subs || []); setAllProfiles(profiles || []);
setActivityLog((activity || []).filter(e => !e.project_id || projectIds.has(e.project_id)).slice(0, 20));
setMyInvoices(invs || []);
} catch (err) { console.error('ClientDashboard load failed:', err); }
finally { if (!cancelled) setLoading(false); }
}
async function loadExternal() {
const uid = currentUser?.id;
try {
const { data: memberData } = await supabase.from('project_members').select('project_id').eq('profile_id', uid);
const myProjectIds = (memberData || []).map(m => m.project_id);
const [{ data: t }, { data: p }, { data: subs }, { data: activity }, { data: subInvs }, { data: profiles }] = await withTimeout(Promise.all([
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').eq('assigned_to', uid),
myProjectIds.length ? supabase.from('projects').select('id, name, status, company_id').in('id', myProjectIds) : Promise.resolve({ data: [] }),
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot').eq('submitted_by', uid),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(50),
supabase.from('subcontractor_invoices').select('total, status').eq('profile_id', uid).in('status', ['submitted', 'approved', 'paid']),
supabase.from('profiles').select('id, name, avatar_url'),
]), 20000, 'ExternalDashboard load');
if (cancelled) return;
const projectIdSet = new Set(myProjectIds);
setTasks((t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null })));
setProjects(p || []); setSubmissions(subs || []); setAllProfiles(profiles || []);
setActivityLog((activity || []).filter(e => !e.project_id || projectIdSet.has(e.project_id)).slice(0, 20));
setMyInvoices(subInvs || []);
} catch (err) { console.error('ExternalDashboard load failed:', err); }
finally { if (!cancelled) setLoading(false); }
}
if (isTeam) loadTeam();
else if (isClient) loadClient();
else if (isExternal) loadExternal();
loadDashboard();
return () => { cancelled = true; };
}, [isTeam, isClient, isExternal, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
}, [isTeam, isClient, isExternal, currentUser?.id, currentUser?.company_id, currentUser?.companies]); // eslint-disable-line react-hooks/exhaustive-deps
const teamHighlights = useMemo(() =>
isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
@@ -796,7 +865,7 @@ export default function TeamDashboard() {
<MiniCalendar items={calendarItems} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<HotItemsCard submissions={submissions} tasks={tasks} />
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} />
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} />
</div>
{isTeam && (
@@ -9,6 +9,7 @@ import { supabase } from '../../lib/supabase';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -707,8 +708,8 @@ export default function Invoices() {
</div>
{showExpenseForm && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={cancelExpenseEdit}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 520, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)', maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
<div style={{ ...popupSurfaceStyle, padding: 28, width: '100%', maxWidth: 520, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>{editingExpenseId ? 'Edit Expense' : 'New Expense'}</div>