import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import Layout from '../components/Layout'; import { supabase } from '../lib/supabase'; import { useAuth } from '../context/AuthContext'; import { readPageCache, writePageCache } from '../lib/pageCache'; import { withTimeout } from '../lib/withTimeout'; import SortTh from '../components/SortTh'; import { useSortable } from '../hooks/useSortable'; 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 InitialPortrait({ name }) { const tone = iconTone(name); return (
{(name || '?')[0].toUpperCase()}
); } function ExternalDashboard({ currentUser, projects, tasks, pos, submissions, clientProfiles, companyMemberships }) { const myTasks = tasks.filter(t => t.assigned_to === currentUser?.id); const myActiveTasks = myTasks.filter(t => !['client_approved', 'on_hold'].includes(t.status)); const myCompleted = myTasks.filter(t => t.status === 'client_approved'); const myCompletedRevisions = myCompleted.reduce((sum, t) => sum + Number(t.current_version || 0), 0); const myOnHold = myTasks.filter(t => t.status === 'on_hold'); const myAssignedProjectCount = new Set(myTasks.map(t => t.project_id).filter(Boolean)).size; const unpaidAmount = pos.filter(p => !['paid', 'cancelled'].includes(p.status)).reduce((s, p) => s + Number(p.amount || 0), 0); const paidAmount = pos.filter(p => p.status === 'paid').reduce((s, p) => s + Number(p.amount || 0), 0); const myProjectIds = new Set(myTasks.map(t => t.project_id).filter(Boolean)); const myProjects = myProjectIds.size > 0 ? projects.filter(p => myProjectIds.has(p.id)) : projects; const companyById = Object.fromEntries(myProjects.filter(p => p.company).map(p => [p.company_id, p.company])); const myProjectIdSet = new Set(myProjects.map(p => p.id)); const activityEvents = buildActivityEvents(submissions, myProjectIdSet); const externalHighlights = buildClientHighlights(Object.values(companyById), myProjects, tasks, clientProfiles, companyMemberships || []) .sort((a, b) => b.openTaskCount - a.openTaskCount || a.company.name.localeCompare(b.company.name)) .slice(0, 5); return (
{myProjects.length > 0 && }
); } // ─── Shared dashboard helpers ────────────────────────────────────────────── 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: <> }, }; function ActionIcon({ actionKey, size = 27 }) { const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: }; return (
{cfg.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 buildActivityEvents(activityData, projectIdSet = null) { return (activityData || []) .filter(e => !projectIdSet || !e.project_id || projectIdSet.has(e.project_id)) .map(e => ({ time: new Date(e.created_at), name: e.actor_name || 'Fourge', actionKey: e.action, action: ACTION_LABEL[e.action] || e.action, task: e.task_title || null, project: e.project_name || null, })).filter(e => !isNaN(e.time)).slice(0, 10); } 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 }; }); } 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 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 ( ); } function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) { return (
{label}
{value}
{sub &&
{sub}
}
{chartData && }
); } const DASH_ICONS = { revenue: '', projects: '', tasks: '', invoice: '', trending: '', profit: '', }; function StatBar({ items }) { return (
{items.map((item, i) => (
{item.label}
{item.value}
))}
); } function TaskFeed({ title, tasks, projects, emptyMessage }) { const navigate = useNavigate(); return (
{title} {tasks.length > 0 && {tasks.length}}
{tasks.length === 0 ? (
{emptyMessage}
) : tasks.map((task, i) => { const project = projects.find(p => p.id === task.project_id); return (
navigate(`/requests/${task.id}`)} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '10px 16px', borderBottom: i < tasks.length - 1 ? '1px solid var(--border)' : 'none', cursor: 'pointer' }}>
{task.title}
{project &&
{project.name}
}
{task.assigned_name ? <>Assigned to {task.assigned_name} : 'Unassigned'}
); })}
); } function ActivityFeed({ events }) { 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.name} {e.action && {e.action}} {e.task && {e.task}}
{e.time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
))}
); } function ClientHighlightTable({ highlights }) { const { sortKey, sortDir, toggle, sort } = useSortable('company'); if (!highlights || highlights.length === 0) return null; const fmtMoney = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; const sortedHighlights = sort(highlights, (row, key) => { if (key === 'company') return row.company?.name || ''; if (key === 'primaryContact') return row.primaryContact?.name || ''; if (key === 'projectCount') return row.projectCount || 0; if (key === 'openTaskCount') return row.openTaskCount || 0; if (key === 'outstandingTotal') return Number(row.outstandingTotal || 0); if (key === 'paidTotal') return Number(row.paidTotal || 0); return ''; }); return (
Client Highlight
CompanyPrimary ContactProjectsOpen TasksOutstandingPaid {sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }, i) => ( ))}
{company.name}
{primaryContact?.name || '—'} {projectCount} {openTaskCount} {fmtMoney(outstandingTotal)} {fmtMoney(paidTotal)}
); } // ─── Main export ─────────────────────────────────────────────────────────── export default function DashboardPage() { const { currentUser } = useAuth(); const isClient = currentUser?.role === 'client'; const isExternal = currentUser?.role === 'external'; // ── Client state ────────────────────────────────────────────────────── const hasCompany = Boolean(currentUser?.company_id || currentUser?.company?.id || currentUser?.companies?.length); const companies = isClient ? (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name)) : []; const [allClientTasks, setAllClientTasks] = useState([]); const [allClientProjects, setAllClientProjects] = useState([]); const [allClientInvoices, setAllClientInvoices] = useState([]); const [clientActivity, setClientActivity] = useState([]); // ── External state ──────────────────────────────────────────────────── const cacheKey = 'team_dashboard_external'; const cached = isExternal ? readPageCache(cacheKey, 5 * 60_000) : null; const [tasks, setTasks] = useState(() => cached?.tasks || []); const [projects, setProjects] = useState(() => cached?.projects || []); const [submissions, setSubmissions] = useState(() => cached?.submissions || []); const [pos, setPos] = useState(() => cached?.pos || []); const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []); const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []); const [loading, setLoading] = useState(() => isClient ? hasCompany : isExternal && !cached); useEffect(() => { let cancelled = false; if (isClient) { if (!hasCompany) { setLoading(false); return; } async function loadClient() { try { const [{ data: activeTasks }, { data: invoices }, { data: activityData }] = await withTimeout(Promise.all([ supabase.from('tasks').select('id, title, status, project_id, assigned_name, project:projects(id, name, company_id)').order('submitted_at', { ascending: false }), supabase.from('invoices').select('total, status, company_id').in('status', ['sent', 'paid']), supabase.from('activity_log').select('id, created_at, actor_name, action, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20), ]), 30000, 'Client dashboard load'); if (cancelled) return; const clientTasks = activeTasks || []; setAllClientTasks(clientTasks); setAllClientInvoices(invoices || []); setClientActivity(activityData || []); const projectMap = {}; clientTasks.forEach(t => { if (t.project?.id) projectMap[t.project.id] = t.project; }); setAllClientProjects(Object.values(projectMap)); } catch (error) { console.error('ClientDashboard load failed:', error); } finally { if (!cancelled) setLoading(false); } } loadClient(); } else if (isExternal) { async function loadExternal() { try { const [{ data: p }, { data: t }, { data: posData }, { data: memRows }, { data: clientProfiles }, { data: activityData }] = await withTimeout(Promise.all([ supabase.from('projects').select('id, name, company_id, company:companies(id, name)').order('created_at', { ascending: false }), supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_to, assigned_name').order('submitted_at', { ascending: false }), supabase.from('subcontractor_payments').select('id, amount, status').eq('profile_id', currentUser.id), supabase.from('company_members').select('company_id, profile_id'), supabase.from('profiles').select('id, name, email, company_id').eq('role', 'client'), supabase.from('activity_log').select('id, created_at, actor_name, action, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20), ]), 30000, 'External dashboard load'); if (!cancelled) { setProjects(p || []); setTasks(t || []); setPos(posData || []); setSubmissions(activityData || []); setClientProfiles(clientProfiles || []); setCompanyMemberships(memRows || []); writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: activityData || [], pos: posData || [], clientProfiles: clientProfiles || [], companyMemberships: memRows || [] }); } } catch (error) { console.error('External dashboard load failed:', error); } finally { if (!cancelled) setLoading(false); } } loadExternal(); } else { setLoading(false); } return () => { cancelled = true; }; }, [isClient, isExternal, hasCompany, cacheKey, currentUser?.id]); if (loading) return

Loading...

; // ── Client render ────────────────────────────────────────────────────── if (isClient) { const myCompanyIds = new Set(companies.map(c => c.id)); const myTasks = myCompanyIds.size > 0 ? allClientTasks.filter(t => t.project?.company_id && myCompanyIds.has(t.project.company_id)) : allClientTasks; const myProjects = myCompanyIds.size > 0 ? allClientProjects.filter(p => myCompanyIds.has(p.company_id)) : allClientProjects; const myInvoices = myCompanyIds.size > 0 ? allClientInvoices.filter(i => myCompanyIds.has(i.company_id)) : allClientInvoices; const myProjectIds = new Set(myProjects.map(p => p.id)); const reviewTasks = myTasks.filter(t => t.status === 'client_review'); const inProgressTasks = myTasks.filter(t => t.status === 'in_progress'); const onHoldTasks = myTasks.filter(t => t.status === 'on_hold'); const notStartedTasks = myTasks.filter(t => t.status === 'not_started'); const outstandingInvoices = myInvoices.filter(i => i.status === 'sent').reduce((sum, inv) => sum + Number(inv.total || 0), 0); const paidInvoices = myInvoices.filter(i => i.status === 'paid').reduce((sum, inv) => sum + Number(inv.total || 0), 0); return (
); } // ── External render ──────────────────────────────────────────────────── if (isExternal) { return ; } return null; }