Files
fourge-portal/src/pages/DashboardPage.jsx
T
Krao Hasanee 283511bf3a Session 2026-05-28: profile page overhaul, nav fixes, dashboard activity links
- Fix nav links not working from profile page (useEffect infinite re-render via unstable profile object ref)
- Fix nav hover/active: gold icon highlight, no background change; active links non-clickable
- Fix hover layout shift: add border: 1px solid transparent to all interactive elements
- Header icon buttons (search, theme toggle) now highlight gold on hover
- Profile page: replace calendar with activity feed (60/40 grid), add stat cards (tasks completed, active projects, revision requests, submissions)
- Profile card: title field, icon rows for location/email/linkedin, member since + role bottom-right, edit button top-right
- Profile portrait: remove wrapper column, fix left-gap alignment
- Add profiles.title migration
- Dashboard recent activity: name → /profile/{id}, task → /requests/{id} (clickable links)
- Icon-only sidebar with gold active/hover state, pointer-events: none on active links
- layout.md updated with profile page geometry rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:32:46 -04:00

458 lines
29 KiB
React

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 (
<div style={{ width: 27, height: 27, borderRadius: '50%', background: tone.bg, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: tone.color, lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
</div>
);
}
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 (
<Layout>
<div className="dash-stat-grid">
<DashStatCard label="Active Tasks" value={myActiveTasks.length} sub={`${myOnHold.length} on hold`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Completed Tasks" value={myCompleted.length} sub={`${myCompletedRevisions} total revisions`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.trending} />
<DashStatCard label="Active Projects" value={myProjects.length} sub={`${myAssignedProjectCount} assigned to me`} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.projects} />
<DashStatCard label="Pending Payment" value={fmtMoney(unpaidAmount)} sub={`${fmtMoney(paidAmount)} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.invoice} />
</div>
<div className="dashboard-bottom-grid">
<ActivityFeed events={activityEvents} />
{myProjects.length > 0 && <ClientHighlightTable highlights={externalHighlights} />}
</div>
</Layout>
);
}
// ─── Shared dashboard helpers ──────────────────────────────────────────────
const ACTION_ICON = {
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
};
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: <circle cx="12" cy="12" r="3" fill="currentColor"/> };
return (
<div style={{ width: size, height: size, borderRadius: '50%', background: cfg.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" stroke={cfg.color} fill="none" style={{ color: cfg.color }}>{cfg.path}</svg>
</div>
);
}
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 (
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F5A523" stopOpacity="0.3" />
<stop offset="95%" stopColor="#F5A523" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill="url(#areaGrad)" />
<path d={line} fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) {
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', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'rgba(255,255,255,0.7)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<div style={{ fontSize: 30, fontWeight: 400, color: '#ffffff', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
</div>
{sub && <div style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)', marginTop: 5 }}>{sub}</div>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', ...(chartData ? { flex: 1, minWidth: 0 } : { flexShrink: 0 }) }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
</div>
{chartData && <MiniAreaChart data={chartData} />}
</div>
</div>
);
}
const DASH_ICONS = {
revenue: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>',
projects: '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
tasks: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>',
invoice: '<rect x="2" y="2" width="20" height="20" rx="2"/><line x1="12" y1="6" x2="12" y2="18"/><path d="M16 8H9.5a2.5 2.5 0 000 5h5a2.5 2.5 0 010 5H8"/>',
trending: '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
profit: '<line x1="12" y1="20" x2="12" y2="4"/><polyline points="5 11 12 4 19 11"/>',
};
function StatBar({ items }) {
return (
<div className="stat-bar">
{items.map((item, i) => (
<div key={i} className="stat-bar-item">
<div className="stat-bar-header">
<div className="stat-bar-label">{item.label}</div>
<div className="stat-bar-dot" style={{ background: item.color }} />
</div>
<div className="stat-bar-value">{item.value}</div>
</div>
))}
</div>
);
}
function TaskFeed({ title, tasks, projects, emptyMessage }) {
const navigate = useNavigate();
return (
<div className="card" style={{ padding: 0, overflow: 'hidden', borderRadius: 4, flexShrink: 0 }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>{title}</span>
{tasks.length > 0 && <span style={{ fontSize: 11, fontWeight: 400, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>}
</div>
{tasks.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)', fontSize: 13 }}>{emptyMessage}</div>
) : tasks.map((task, i) => {
const project = projects.find(p => p.id === task.project_id);
return (
<div key={task.id} onClick={() => 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' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{task.title}</div>
{project && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{project.name}</div>}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{task.assigned_name ? <>Assigned to <span style={{ color: 'var(--text-primary)' }}>{task.assigned_name}</span></> : 'Unassigned'}
</div>
</div>
);
})}
</div>
);
}
function ActivityFeed({ events }) {
const visible = events.slice(0, 5);
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
<div style={{ marginBottom: visible.length > 0 ? 14 : 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'rgba(255,255,255,0.7)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
</div>
{visible.length === 0 ? (
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.5)', marginTop: 14 }}>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} />
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
<span style={{ color: '#ffffff', fontWeight: 400 }}>{e.name}</span>
{e.action && <span style={{ color: 'rgba(255,255,255,0.5)' }}> {e.action}</span>}
{e.task && <span style={{ color: '#ffffff' }}> {e.task}</span>}
</div>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', whiteSpace: 'nowrap', flexShrink: 0 }}>{e.time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
</div>
))}
</div>
);
}
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 (
<div className="card" style={{ padding: 0, overflow: 'hidden', borderRadius: 4, flexShrink: 0 }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Client Highlight</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--card-bg-2)' }}>
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'left', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Company</SortTh>
<SortTh col="primaryContact" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Primary Contact</SortTh>
<SortTh col="projectCount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Projects</SortTh>
<SortTh col="openTaskCount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Open Tasks</SortTh>
<SortTh col="outstandingTotal" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'right', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Outstanding</SortTh>
<SortTh col="paidTotal" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'right', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Paid</SortTh>
</tr>
</thead>
<tbody>
{sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }, i) => (
<tr key={company.id} style={{ borderBottom: i < sortedHighlights.length - 1 ? '1px solid var(--border)' : 'none' }}>
<td style={{ padding: '10px 16px', fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<InitialPortrait name={company.name} />
{company.name}
</div>
</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-secondary)', textAlign: 'center' }}>{primaryContact?.name || '—'}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-secondary)', textAlign: 'center' }}>{projectCount}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{openTaskCount}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--accent)', textAlign: 'right' }}>{fmtMoney(outstandingTotal)}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--accent)', textAlign: 'right' }}>{fmtMoney(paidTotal)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ─── 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 <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
// ── 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 (
<Layout>
<div className="dash-stat-grid">
<DashStatCard label="Active Projects" value={myProjects.length} sub={`${myTasks.length} total tasks`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
<DashStatCard label="Outstanding" value={fmtMoney(outstandingInvoices)} sub={`${fmtMoney(paidInvoices)} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.invoice} />
<DashStatCard label="In Progress" value={inProgressTasks.length} sub={`${notStartedTasks.length} not started`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Awaiting Review" value={reviewTasks.length} sub={`${onHoldTasks.length} on hold`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.trending} />
</div>
<ActivityFeed events={buildActivityEvents(clientActivity, myProjectIds)} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<TaskFeed title="Awaiting Your Review" tasks={reviewTasks} projects={myProjects} emptyMessage="No items need your review." />
<TaskFeed title="In Progress" tasks={inProgressTasks} projects={myProjects} emptyMessage="No items currently in progress." />
</div>
</Layout>
);
}
// ── External render ────────────────────────────────────────────────────
if (isExternal) {
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} pos={pos} submissions={submissions} clientProfiles={clientProfiles} companyMemberships={companyMemberships} />;
}
return null;
}