import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { withTimeout } from '../../lib/withTimeout';
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
import { useSortable } from '../../hooks/useSortable';
// ─── Helpers ──────────────────────────────────────────────────────────────
const ICON_TONES = [
{ bg: 'rgba(245,165,35,0.15)', color: '#F5A523' },
{ bg: 'rgba(74,222,128,0.15)', color: '#4ade80' },
{ bg: 'rgba(96,165,250,0.15)', color: '#60a5fa' },
{ bg: 'rgba(167,139,250,0.15)', color: '#a78bfa' },
];
function iconTone(key) {
let h = 0;
for (let i = 0; i < (key || '').length; i++) h = (h * 31 + key.charCodeAt(i)) % ICON_TONES.length;
return ICON_TONES[h];
}
function fmtMoney(n) {
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
if (Math.abs(n) >= 10000) return `$${(n / 1000).toFixed(1)}k`;
return `$${Number(n).toFixed(2)}`;
}
function buildClientHighlights(companies, projects, tasks, clientProfiles, companyMemberships = [], invoices = []) {
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
return (companies || []).map(company => {
const companyProjects = (projects || []).filter(p => p.company_id === company.id);
const primaryContact = (clientProfiles || []).find(p =>
p.company_id === company.id ||
(companyMemberships || []).some(m => m.company_id === company.id && m.profile_id === p.id)
);
const openTasks = (tasks || []).filter(t => companyProjects.some(p => p.id === t.project_id) && !doneStatuses.includes(t.status));
const companyInvoices = (invoices || []).filter(i => i.company_id === company.id);
const outstandingTotal = companyInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
const paidTotal = companyInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
return { company, primaryContact, projectCount: companyProjects.length, openTaskCount: openTasks.length, outstandingTotal, paidTotal };
});
}
const ACTION_ICON = {
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: },
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: },
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <>> },
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: },
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <>> },
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <>> },
project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <>> },
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <>> },
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <>> },
};
const ACTION_LABEL = {
task_created: 'created', task_started: 'started', task_on_hold: 'put on hold',
task_resumed: 'resumed', task_submitted: 'submitted', task_approved: 'approved',
project_created: 'created project', request_submitted: 'submitted', revision_requested: 'requested revision on',
};
function ActionIcon({ actionKey, size = 27 }) {
const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'var(--text-muted)', path: };
return (
);
}
function Avatar({ name, size = 27 }) {
const tone = iconTone(name);
return (
);
}
function InitialPortrait({ name }) {
const tone = iconTone(name);
return (
{(name || '?')[0].toUpperCase()}
);
}
function smoothCurve(pts) {
if (pts.length < 2) return '';
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
for (let i = 1; i < pts.length; i++) {
const [x0, y0] = pts[i - 1];
const [x1, y1] = pts[i];
const cpX = (x0 + x1) / 2;
d += ` C${cpX.toFixed(1)},${y0.toFixed(1)} ${cpX.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`;
}
return d;
}
function MiniAreaChart({ data }) {
const W = 90, H = 42;
if (!data || data.length < 2) return null;
const max = Math.max(...data, 1);
const pts = data.map((v, i) => [(i / (data.length - 1)) * W, 4 + (1 - v / max) * (H - 8)]);
const line = smoothCurve(pts);
const lastPt = pts[pts.length - 1];
const firstPt = pts[0];
const area = `${line} L${lastPt[0].toFixed(1)},${H} L${firstPt[0].toFixed(1)},${H} Z`;
return (
);
}
const DASH_ICONS = {
revenue: '',
projects: '',
tasks: '',
profit: '',
};
function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) {
return (
);
}
function ActivityFeed({ events }) {
const navigate = useNavigate();
const visible = events.slice(0, 5);
return (
0 ? 14 : 0 }}>
Recent Activity
{visible.length === 0 ? (
No recent activity
) : visible.map((e, i) => (
0 ? 10 : 0 }}>
{e.actorId
?
: {e.name}
}
{e.action && {e.action}}
{e.task && e.taskId
? <> >
: e.task && {e.task}
}
{e.time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
))}
);
}
function getDateKey(date) {
const d = new Date(date);
if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function profilePath(id) {
return `/profile/${id}`;
}
function MiniCalendar({ items = [] }) {
const navigate = useNavigate();
const today = new Date();
const [view, setView] = useState({ year: today.getFullYear(), month: today.getMonth() });
const [hoveredKey, setHoveredKey] = useState(null);
const hoverTimeout = useRef(null);
const clearHover = () => { hoverTimeout.current = setTimeout(() => setHoveredKey(null), 120); };
const keepHover = (k) => { clearTimeout(hoverTimeout.current); if (k) setHoveredKey(k); };
const { year, month } = view;
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells = [];
for (let i = 0; i < firstDay; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
const isToday = (d) => d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
const dayKey = (d) => `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const itemsByDay = useMemo(() => {
const map = new Map();
(items || []).forEach(item => {
const key = getDateKey(item.deadline);
if (!key) return;
const list = map.get(key) || [];
list.push(item);
map.set(key, list);
});
return map;
}, [items]);
const activeKey = hoveredKey;
const activeItems = (itemsByDay.get(activeKey) || []).slice().sort((a, b) => {
if (a.isOverdue !== b.isOverdue) return a.isOverdue ? -1 : 1;
if (a.isHot !== b.isHot) return a.isHot ? -1 : 1;
return (a.title || '').localeCompare(b.title || '');
});
const monthLabel = new Date(year, month, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
const prev = () => setView(v => v.month === 0 ? { year: v.year - 1, month: 11 } : { year: v.year, month: v.month - 1 });
const next = () => setView(v => v.month === 11 ? { year: v.year + 1, month: 0 } : { year: v.year, month: v.month + 1 });
const dotColor = (item) => {
if (item.isOverdue) return '#ef4444';
if (item.isHot) return '#F5A523';
if (item.isDone) return '#4ade80';
return '#60a5fa';
};
const activeLabel = activeKey
? new Date(`${activeKey}T12:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
: 'Selected day';
return (
{['S','M','T','W','T','F','S'].map((d, i) => (
{d}
))}
{cells.map((d, i) => {
const key = d ? dayKey(d) : null;
const dayItems = key ? itemsByDay.get(key) || [] : [];
const active = key && key === activeKey;
const hasItems = dayItems.length > 0;
return (
keepHover(key)} onMouseLeave={clearHover}>
{active && hasItems && (
keepHover(key)} onMouseLeave={clearHover} style={{ position: 'absolute', zIndex: 1002, right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)', width: 210, padding: '10px 12px', background: 'var(--sidebar-bg)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 12px 32px rgba(0,0,0,0.45)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', pointerEvents: 'auto' }}>
{activeLabel}
{activeItems.length} due
{activeItems.slice(0, 4).map(item => (
))}
)}
);
})}
);
}
function TasksInProgressCard({ tasks = [] }) {
const navigate = useNavigate();
const { sortKey, sortDir, toggle, sort } = useSortable('task');
const [showAll, setShowAll] = useState(false);
const rows = sort(tasks.slice(0, 10), (t, key) => {
if (key === 'task') return t.title || '';
if (key === 'assigned_to') return t.assigned_name || '';
return '';
});
const visibleRows = showAll ? rows : rows.slice(0, 5);
return (
0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
Tasks In Progress
{rows.length > 5 && (
)}
{rows.length === 0 ? (
No tasks in progress
) : (
Task
Assigned To
{visibleRows.map((t) => (
|
|
{t.assigned_to
?
: (t.assigned_name || '—')}
|
))}
)}
);
}
function HotItemsCard({ submissions, tasks }) {
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))
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
.filter(s => s.task)
.sort((a, b) => {
if (!a.deadline && !b.deadline) return 0;
if (!a.deadline) return 1;
if (!b.deadline) return -1;
return new Date(a.deadline) - new Date(b.deadline);
})
.slice(0, 7);
const sortedHotItems = sort(hotItems, (s, key) => {
if (key === 'task') return s.task?.title || '';
if (key === 'requested_by') return s.submitted_by_name || '';
if (key === 'deadline') return s.deadline || '';
return '';
});
return (
0 ? 14 : 0, flexShrink: 0 }}>
Hot Tasks
{hotItems.length === 0 ? (
No hot tasks
) : (
|
Task
Requested By
Due By
{sortedHotItems.map(s => (
|
{s.task.status === 'client_approved' ? (
) : (
)}
|
|
{s.submitted_by ? (
) : (s.submitted_by_name || '—')}
|
{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'} |
))}
)}
);
}
function ClientHighlightCard({ highlights }) {
const navigate = useNavigate();
const { sortKey, sortDir, toggle, sort } = useSortable('company');
const fmt = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
const thStyle = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' };
const td = () => ({ padding: '5px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' });
const sortedHighlights = sort(highlights || [], (row, key) => {
if (key === 'company') return row.company?.name || '';
if (key === 'contact') return row.primaryContact?.name || '';
if (key === 'projects') return row.projectCount || 0;
if (key === 'open_tasks') return row.openTaskCount || 0;
if (key === 'outstanding') return Number(row.outstandingTotal || 0);
if (key === 'paid') return Number(row.paidTotal || 0);
return '';
});
return (
0 ? 14 : 0, flexShrink: 0 }}>
Client Highlight
{!highlights || highlights.length === 0 ? (
No data
) : (
|
Company
Contact
Projects
Open Tasks
Outstanding
Paid
{sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }) => (
|
|
|
{primaryContact?.id ? (
) : (primaryContact?.name || '—')}
|
{projectCount} |
{openTaskCount || 0} |
{fmt(outstandingTotal)} |
{fmt(paidTotal)} |
))}
)}
);
}
function TeamPerformanceCard({ tasks, profiles }) {
const navigate = useNavigate();
const profileMap = useMemo(() => {
const m = new Map();
(profiles || []).forEach(p => m.set(p.id, p));
return m;
}, [profiles]);
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const allMonthOpts = useMemo(() => {
const opts = [];
const now = new Date();
for (let i = 0; i < 6; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
opts.push({ value: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, label: i === 0 ? 'This Month' : d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) });
}
return opts;
}, []);
const monthsWithData = useMemo(() => new Set(
(tasks || []).filter(t => doneStatuses.includes(t.status) && t.completed_at).map(t => {
const d = new Date(t.completed_at);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
})
), [tasks]); // eslint-disable-line react-hooks/exhaustive-deps
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
const { people, totalDone } = useMemo(() => {
const [year, month] = monthKey.split('-').map(Number);
const map = new Map();
(tasks || []).forEach(t => {
if (!doneStatuses.includes(t.status) || !t.completed_at) return;
const d = new Date(t.completed_at);
if (d.getFullYear() !== year || d.getMonth() + 1 !== month) return;
if (!t.assigned_name) return;
const entry = map.get(t.assigned_name) || { name: t.assigned_name, id: t.assigned_to || null, newCount: 0, revCount: 0 };
if ((t.current_version || 0) === 0) entry.newCount += 1;
else entry.revCount += 1;
map.set(t.assigned_name, entry);
});
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
}, [tasks, monthKey]); // eslint-disable-line react-hooks/exhaustive-deps
return (
Team Performance
{people.slice(0, 5).length === 0 ? (
No completed tasks this month
) : (
{people.slice(0, 5).map((p, i) => {
const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0;
const tone = iconTone(p.name);
return (
0 ? 10 : 0 }}>
{(() => { const prof = p.id ? profileMap.get(p.id) : null; const tone = iconTone(p.name); return prof?.avatar_url ?
:
; })()}
{p.id ? : {p.name}}
{p.newCount} new · {p.revCount} revision
);
})}
)}
);
}
// ─── Page ──────────────────────────────────────────────────────────────────
const CACHE_KEY = 'team_dashboard_v2';
const CUTOFF_MONTHS = 6;
export default function TeamDashboard() {
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 || []);
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
const [allCompanies, setAllCompanies] = useState(() => cached?.companies || []);
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []);
const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
const [teamExpenses, setTeamExpenses] = useState([]);
const [myInvoices, setMyInvoices] = useState([]);
const [loading, setLoading] = useState(!cached);
useEffect(() => {
let cancelled = false;
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([
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 }),
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('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 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); }
}
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(() =>
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) : [],
[isTeam, allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices]);
const activityEvents = useMemo(() =>
(activityLog || []).map(e => ({
time: new Date(e.created_at),
name: e.actor_name || 'Fourge',
actorId: e.actor_id || null,
actionKey: e.action,
action: ACTION_LABEL[e.action] || e.action,
task: e.task_title || null,
taskId: e.task_id || null,
project: e.project_name || null,
projectId: e.project_id || null,
})).filter(e => !isNaN(e.time)).slice(0, 10),
[activityLog]);
if (loading) return Loading...
;
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
const activeTasks = tasks.filter(t => !doneStatuses.includes(t.status));
const activeProjects = projects.filter(p => p.status !== 'completed' && p.status !== 'cancelled');
const hotTaskIds = new Set((submissions || []).filter(s => s.is_hot).map(s => s.task_id));
const calendarItems = tasks
.filter(t => t.deadline)
.map(t => {
const deadlineKey = getDateKey(t.deadline);
return {
id: t.id,
title: t.title,
deadline: t.deadline,
isHot: hotTaskIds.has(t.id),
isDone: doneStatuses.includes(t.status),
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 dashExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
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);
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 (
{isTeam && (
)}
);
}