import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import SortTh from '../../components/SortTh';
import ProfileAvatar from '../../components/ProfileAvatar';
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';
import { useLiveRefresh } from '../../hooks/useLiveRefresh';
import { resolveScopedWorkIds } from '../../lib/workScope';
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay';
// ─── 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)}`;
}
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 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, isClient = false, isExternal = false, currentUserId = null, cardHeight = null }) {
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 activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
const hotItems = isExternal
? (tasks || [])
.filter(task => task?.assigned_to === currentUserId && activeStatuses.includes(task?.status))
.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, 8)
: (submissions || [])
.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);
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) => {
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 (isExternal) {
if (key === 'task') return s.title || '';
if (key === 'project') return s.project_name || '';
if (key === 'status') return s.status || '';
if (key === 'deadline') return s.deadline || '';
return '';
}
if (key === 'task') return s.task?.title || '';
if (key === 'requested_by') return getSubmissionDisplayName(s) || '';
if (key === 'deadline') return s.deadline || '';
return '';
});
return (
0 ? 14 : 0, flexShrink: 0 }}>
{isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'Hot Tasks'}
{hotItems.length === 0 ? (
{isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No hot tasks'}
) : (
{isExternal ? (
<>
Task
Project
Status
Due By
{sortedHotItems.map((task) => (
|
|
{task.project?.name || '—'} |
{String(task.status || '').replace(/_/g, ' ') || '—'} |
{task.deadline ? new Date(task.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'} |
))}
>
) : (
<>
|
Task
Requested By
Due By
{sortedHotItems.map(s => (
|
{s.task.status === 'client_approved' ? (
) : (
)}
|
|
{s.submitted_by ? (
) : (getSubmissionDisplayName(s) || '—')}
|
{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'} |
))}
>
)}
)}
);
}
function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) {
const navigate = useNavigate();
const profileMap = useMemo(() => {
const m = new Map();
(profiles || []).forEach(p => m.set(p.id, p));
return m;
}, [profiles]);
const profileNameMap = useMemo(() => {
const m = new Map();
(profiles || []).forEach((profile) => {
const key = String(profile?.name || '').trim().toLowerCase();
if (key && !m.has(key)) m.set(key, profile);
});
return m;
}, [profiles]);
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' }));
const monthsWithData = useMemo(() => new Set(
(deliveries || []).filter(d => d.submission?.task).map(d => {
const dt = toEST(d.sent_at);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`;
})
), [deliveries]); // eslint-disable-line react-hooks/exhaustive-deps
const allMonthOpts = useMemo(() => {
const opts = [];
const now = toEST(new Date().toISOString());
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 monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
const effectiveMonthKey = monthOpts.some(option => option.value === monthKey)
? monthKey
: (monthOpts[0]?.value || allMonthOpts[0]?.value || '');
const { people, totalDone } = useMemo(() => {
const [year, month] = effectiveMonthKey.split('-').map(Number);
const map = new Map();
(deliveries || []).forEach(d => {
const task = d.submission?.task;
if (!task) return;
const dt = toEST(d.sent_at);
if (dt.getFullYear() !== year || dt.getMonth() + 1 !== month) return;
const name = d.sent_by || task.assigned_name;
if (!name) return;
const matchedProfile = (task.assigned_to && profileMap.get(task.assigned_to)) || profileNameMap.get(String(name).trim().toLowerCase()) || null;
const entry = map.get(name) || {
name,
id: matchedProfile?.id || task.assigned_to || null,
avatar_url: matchedProfile?.avatar_url || '',
newCount: 0,
revCount: 0,
};
if ((d.version_number || 0) === 0) entry.newCount += 1;
else entry.revCount += 1;
map.set(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) };
}, [deliveries, effectiveMonthKey, profileMap, profileNameMap]); // eslint-disable-line react-hooks/exhaustive-deps
return (
Team Performance
{people.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;
return (
0 ? 10 : 0 }}>
{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 { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions', 'activity_log', 'profiles']);
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 [activityLog, setActivityLog] = useState(() => cached?.activityLog || []);
const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
const [teamExpenses, setTeamExpenses] = useState([]);
const [teamSubcontractorPOs, setTeamSubcontractorPOs] = useState([]);
const [teamSubInvoices, setTeamSubInvoices] = useState([]);
const [myInvoices, setMyInvoices] = useState([]);
const [perfDeliveries, setPerfDeliveries] = useState([]);
const [loading, setLoading] = useState(!cached);
const bottomCardsRowRef = useRef(null);
const [bottomCardsHeight, setBottomCardsHeight] = useState(null);
useEffect(() => {
function updateBottomCardsHeight() {
const row = bottomCardsRowRef.current;
if (!row) return;
const rect = row.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const available = Math.floor(viewportHeight - rect.top - 24);
const nextHeight = available > 0 ? `${available}px` : null;
setBottomCardsHeight(prev => (prev === nextHeight ? prev : nextHeight));
}
updateBottomCardsHeight();
window.addEventListener('resize', updateBottomCardsHeight);
return () => window.removeEventListener('resize', updateBottomCardsHeight);
}, [loading, isTeam, isClient, isExternal]);
useEffect(() => {
let cancelled = false;
async function loadDashboard() {
try {
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
const cutoffStr = cutoff.toISOString();
const {
scopedTaskIds,
scopedProjectIds,
scopedCompanyIds,
} = isTeam
? { scopedTaskIds: null, scopedProjectIds: null, scopedCompanyIds: null }
: await resolveScopedWorkIds(currentUser, { isClient, isExternal });
const tasksQuery = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, project:projects(name), assignee:profiles!assigned_to(avatar_url)')
.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, stripe_fee, status, company_id, created_at').in('status', ['sent', 'paid'])
: isClient
? ((scopedCompanyIds || []).length > 0
? supabase.from('invoices').select('total, stripe_fee, 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 subcontractorPOsPromise = isTeam
? supabase.from('subcontractor_payments').select('amount, status, paid_at, date')
: Promise.resolve({ data: [] });
const subcontractorInvoicesPromise = isTeam
? supabase.from('subcontractor_invoices').select('status, paid_at, items:subcontractor_invoice_items(unit_price, quantity)')
: Promise.resolve({ data: [] });
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: invs }, { data: exps }, { data: subcontractorPOs }, { data: subcontractorInvoices }, { data: delivs }] = 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(isTeam ? 20 : 50),
invoicesPromise,
expensesPromise,
subcontractorPOsPromise,
subcontractorInvoicesPromise,
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task_id, task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
]), 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 = mergeSubmissionDisplayNames(
(subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null })),
profiles || []
);
setTasks(tasksWithDeadlines);
setProjects(p || []);
setSubmissions(subsWithRole);
setAllProfiles(profiles || []);
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 || []);
setTeamSubcontractorPOs(subcontractorPOs || []);
setTeamSubInvoices(subcontractorInvoices || []);
const scopedDeliveryTaskIds = new Set(scopedTaskIds || []);
const scopedDeliveries = isTeam
? (delivs || [])
: (delivs || []).filter(delivery => scopedDeliveryTaskIds.has(delivery.submission?.task_id));
setPerfDeliveries(scopedDeliveries);
if (isTeam) {
writePageCache(CACHE_KEY, {
tasks: tasksWithDeadlines,
projects: p || [],
submissions: subsWithRole,
activityLog: activity || [],
teamInvoices: invs || [],
allProfiles: profiles || [],
});
}
} catch (err) { console.error('Dashboard load failed:', err); }
finally { if (!cancelled) setLoading(false); }
}
loadDashboard();
return () => { cancelled = true; };
}, [isTeam, isClient, isExternal, currentUser?.id, currentUser?.company_id, currentUser?.companies, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
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 ;
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 dashOpsExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
const dashNetReceived = teamInvoices
.filter(i => i.status === 'paid')
.reduce((s, i) => s + Number(i.total || 0) - Number(i.stripe_fee || 0), 0);
const dashPaidSubcontractorPOsTotal = teamSubcontractorPOs
.filter(po => po.status === 'paid')
.reduce((s, po) => s + Number(po.amount || 0), 0);
const dashPaidSubInvoicesTotal = teamSubInvoices
.filter(inv => inv.status === 'paid')
.reduce((s, inv) => s + (inv.items || []).reduce((a, item) => a + Number(item.unit_price || 0) * Number(item.quantity || 1), 0), 0);
const dashSubcontractorExpensesTotal = dashPaidSubcontractorPOsTotal + dashPaidSubInvoicesTotal;
const dashExpensesTotal = dashOpsExpensesTotal + dashSubcontractorExpensesTotal;
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(dashNetReceived - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses + sub costs`, 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 (
);
}