Session 2026-05-29: shared dashboard, team tasks page, requests card style, copy/paste files, loading modals, avatar cache bust
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -612,8 +612,11 @@ const CACHE_KEY = 'team_dashboard_v2';
|
||||
const CUTOFF_MONTHS = 6;
|
||||
|
||||
export default function TeamDashboard() {
|
||||
useAuth(); // ensures auth context loaded
|
||||
const cached = readPageCache(CACHE_KEY, 5 * 60_000);
|
||||
const { currentUser } = useAuth();
|
||||
const isTeam = currentUser?.role === 'team';
|
||||
const isClient = currentUser?.role === 'client';
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
const cached = isTeam ? readPageCache(CACHE_KEY, 5 * 60_000) : null;
|
||||
|
||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||
@@ -625,25 +628,18 @@ export default function TeamDashboard() {
|
||||
const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
|
||||
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
|
||||
const [teamExpenses, setTeamExpenses] = useState([]);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
const [myInvoices, setMyInvoices] = useState([]);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
|
||||
async function loadTeam() {
|
||||
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([
|
||||
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 }),
|
||||
@@ -652,60 +648,80 @@ export default function TeamDashboard() {
|
||||
supabase.from('companies').select('id, name').order('name'),
|
||||
supabase.from('company_members').select('company_id, profile_id'),
|
||||
]), 30000, 'TeamDashboard load');
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role]));
|
||||
const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role]));
|
||||
const clients = (profiles || []).filter(pr => pr.role === 'client');
|
||||
|
||||
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);
|
||||
setClientProfiles(clients);
|
||||
setAllProfiles(profiles || []);
|
||||
setAllCompanies(cos || []);
|
||||
setCompanyMemberships(memRows || []);
|
||||
setActivityLog(activity || []);
|
||||
|
||||
writePageCache(CACHE_KEY, {
|
||||
tasks: tasksWithDeadlines, projects: p || [], submissions: subsWithRole,
|
||||
clientProfiles: clients, 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);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
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);
|
||||
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); }
|
||||
finally { if (!cancelled) setLoading(false); }
|
||||
}
|
||||
load();
|
||||
|
||||
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();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
}, [isTeam, isClient, isExternal, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const teamHighlights = useMemo(() =>
|
||||
buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
|
||||
isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
|
||||
.sort((a, b) => b.openTaskCount - a.openTaskCount || b.projectCount - a.projectCount || a.company.name.localeCompare(b.company.name))
|
||||
.slice(0, 5),
|
||||
[allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices]);
|
||||
.slice(0, 5) : [],
|
||||
[isTeam, allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices]);
|
||||
|
||||
const activityEvents = useMemo(() =>
|
||||
(activityLog || []).map(e => ({
|
||||
@@ -741,23 +757,38 @@ export default function TeamDashboard() {
|
||||
isOverdue: deadlineKey && deadlineKey < getDateKey(new Date()) && !doneStatuses.includes(t.status),
|
||||
};
|
||||
});
|
||||
const dashRevenue = teamInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const dashOutstanding = teamInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const dashRevenue = teamInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const dashOutstanding = teamInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const dashExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||||
const revenueByMonth = Array.from({ length: 4 }, (_, i) => {
|
||||
const revenueByMonth = Array.from({ length: 4 }, (_, i) => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - (3 - i), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() - (3 - i) + 1, 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() - (3 - i) + 1, 1);
|
||||
return teamInvoices.filter(inv => inv.status === 'paid' && inv.created_at && new Date(inv.created_at) >= start && new Date(inv.created_at) < end).reduce((s, inv) => s + Number(inv.total || 0), 0);
|
||||
});
|
||||
|
||||
const myPaid = myInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
|
||||
const card3 = isTeam
|
||||
? { label: 'Net Profit', value: fmtMoney(dashRevenue - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
|
||||
: isClient
|
||||
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }
|
||||
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue };
|
||||
|
||||
const card4 = isTeam
|
||||
? { label: 'Revenue', value: fmtMoney(dashRevenue), sub: `${fmtMoney(dashOutstanding)} outstanding`, iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue, chartData: revenueByMonth }
|
||||
: isClient
|
||||
? { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid to Fourge', iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
|
||||
: { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid by Fourge', iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit };
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1.5fr', marginBottom: 0 }}>
|
||||
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.tasks} />
|
||||
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
|
||||
<DashStatCard label="Net Profit" value={fmtMoney(dashRevenue - dashExpensesTotal)} sub={`${fmtMoney(dashExpensesTotal)} expenses`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.profit} />
|
||||
<DashStatCard label="Revenue" value={fmtMoney(dashRevenue)} sub={`${fmtMoney(dashOutstanding)} outstanding`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.revenue} chartData={revenueByMonth} />
|
||||
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.tasks} />
|
||||
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
|
||||
<DashStatCard label={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} />
|
||||
<DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 280px', gap: 24, marginTop: 24 }}>
|
||||
<ActivityFeed events={activityEvents} />
|
||||
@@ -768,9 +799,11 @@ export default function TeamDashboard() {
|
||||
<HotItemsCard submissions={submissions} tasks={tasks} />
|
||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} />
|
||||
</div>
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<ClientHighlightCard highlights={teamHighlights} />
|
||||
</div>
|
||||
{isTeam && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<ClientHighlightCard highlights={teamHighlights} />
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user