283511bf3a
- 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>
458 lines
29 KiB
React
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;
|
|
}
|