70ad8d0cef
- ProjectDetail: full redesign — icon meta row, completion progress bar, main contact card, activity feed, subcontractor management modal with multi-select - ProjectDetail: task table matches Tasks.jsx (R#, assigned avatar, priority, type, deadline, status) with status tabs + counts - Realtime: useRefetchOnFocus + useRealtimeSubscription hooks wired to Tasks, TaskDetail, ProjectDetail, TeamDashboard - AuthContext: live profile updates via Supabase realtime channel - Tasks/ProjectDetail: assignee avatar join added for all roles (client, external, team) - RLS: allow all authenticated users to read profiles (fixes avatar display across roles) - RequestForm: lockedFields prop for pre-filled read-only company/project fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
887 lines
54 KiB
React
887 lines
54 KiB
React
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import Layout from '../../components/Layout';
|
|
import SortTh from '../../components/SortTh';
|
|
import { supabase } from '../../lib/supabase';
|
|
import { useAuth } from '../../context/AuthContext';
|
|
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
|
import { withTimeout } from '../../lib/withTimeout';
|
|
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
|
import { useSortable } from '../../hooks/useSortable';
|
|
import { useRefetchOnFocus } from '../../hooks/useRefetchOnFocus';
|
|
import { useRealtimeSubscription } from '../../hooks/useRealtimeSubscription';
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
const ICON_TONES = [
|
|
{ bg: 'rgba(245,165,35,0.15)', color: '#F5A523' },
|
|
{ bg: 'rgba(74,222,128,0.15)', color: '#4ade80' },
|
|
{ bg: 'rgba(96,165,250,0.15)', color: '#60a5fa' },
|
|
{ bg: 'rgba(167,139,250,0.15)', color: '#a78bfa' },
|
|
];
|
|
|
|
function iconTone(key) {
|
|
let h = 0;
|
|
for (let i = 0; i < (key || '').length; i++) h = (h * 31 + key.charCodeAt(i)) % ICON_TONES.length;
|
|
return ICON_TONES[h];
|
|
}
|
|
|
|
function fmtMoney(n) {
|
|
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
|
|
if (Math.abs(n) >= 10000) return `$${(n / 1000).toFixed(1)}k`;
|
|
return `$${Number(n).toFixed(2)}`;
|
|
}
|
|
|
|
function buildClientHighlights(companies, projects, tasks, clientProfiles, companyMemberships = [], invoices = []) {
|
|
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
|
return (companies || []).map(company => {
|
|
const companyProjects = (projects || []).filter(p => p.company_id === company.id);
|
|
const primaryContact = (clientProfiles || []).find(p =>
|
|
p.company_id === company.id ||
|
|
(companyMemberships || []).some(m => m.company_id === company.id && m.profile_id === p.id)
|
|
);
|
|
const openTasks = (tasks || []).filter(t => companyProjects.some(p => p.id === t.project_id) && !doneStatuses.includes(t.status));
|
|
const companyInvoices = (invoices || []).filter(i => i.company_id === company.id);
|
|
const outstandingTotal = companyInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
|
|
const paidTotal = companyInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
|
return { company, primaryContact, projectCount: companyProjects.length, openTaskCount: openTasks.length, outstandingTotal, paidTotal };
|
|
});
|
|
}
|
|
|
|
const ACTION_ICON = {
|
|
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <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"/></> },
|
|
};
|
|
|
|
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: <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>
|
|
);
|
|
}
|
|
|
|
function Avatar({ name, size = 27 }) {
|
|
const tone = iconTone(name);
|
|
return (
|
|
<div style={{ width: size, height: size, borderRadius: '50%', background: tone.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill={tone.color}>
|
|
<circle cx="12" cy="8" r="4" />
|
|
<path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" />
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 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>
|
|
);
|
|
}
|
|
|
|
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"/>',
|
|
profit: '<line x1="12" y1="20" x2="12" y2="4"/><polyline points="5 11 12 4 19 11"/>',
|
|
};
|
|
|
|
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: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
|
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
|
</div>
|
|
{sub && <div style={{ fontSize: 12, color: 'var(--text-muted)', 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>
|
|
);
|
|
}
|
|
|
|
function ActivityFeed({ events }) {
|
|
const navigate = useNavigate();
|
|
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: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
|
|
</div>
|
|
{visible.length === 0 ? (
|
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>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 }}>
|
|
{e.actorId
|
|
? <button type="button" className="dashboard-inline-link" onClick={() => navigate(`/profile/${e.actorId}`)}>{e.name}</button>
|
|
: <span style={{ color: 'var(--text-primary)' }}>{e.name}</span>
|
|
}
|
|
{e.action && <span style={{ color: 'var(--text-muted)' }}> {e.action}</span>}
|
|
{e.task && e.taskId
|
|
? <><span style={{ color: 'var(--text-muted)' }}> </span><button type="button" className="dashboard-inline-link" onClick={() => navigate(`/tasks/${e.taskId}`)}>{e.task}</button></>
|
|
: e.task && <span style={{ color: 'var(--text-primary)' }}> {e.task}</span>
|
|
}
|
|
</div>
|
|
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>{e.time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div style={{ position: 'relative', zIndex: activeKey ? 1001 : 0, overflow: 'visible', background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{monthLabel}</span>
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<button onClick={prev} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="15,18 9,12 15,6"/></svg>
|
|
</button>
|
|
<button onClick={next} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="9,18 15,12 9,6"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, textAlign: 'center' }}>
|
|
{['S','M','T','W','T','F','S'].map((d, i) => (
|
|
<div key={i} style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.5, paddingBottom: 6 }}>{d}</div>
|
|
))}
|
|
{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 (
|
|
<div key={i} style={{ position: 'relative' }} onMouseEnter={() => keepHover(key)} onMouseLeave={clearHover}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (dayItems.length === 1) navigate(`/tasks/${dayItems[0].id}`);
|
|
}}
|
|
disabled={!d}
|
|
style={{
|
|
position: 'relative',
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: '50%',
|
|
border: active && !isToday(d) && hasItems ? '1px solid rgba(245,165,35,0.5)' : '1px solid transparent',
|
|
color: d ? (isToday(d) ? '#0d0d0d' : 'var(--text-secondary)') : 'transparent',
|
|
background: d && isToday(d) ? '#F5A523' : hasItems ? 'rgba(255,255,255,0.035)' : 'transparent',
|
|
fontSize: 12,
|
|
lineHeight: 1,
|
|
fontWeight: isToday(d) ? 700 : 400,
|
|
cursor: d && hasItems ? 'pointer' : 'default',
|
|
fontFamily: 'inherit',
|
|
padding: 0,
|
|
}}
|
|
>
|
|
{d || ''}
|
|
{hasItems && (
|
|
<span style={{ position: 'absolute', left: '50%', bottom: 3, transform: 'translateX(-50%)', display: 'flex', gap: 2 }}>
|
|
{dayItems.slice(0, 3).map((item, idx) => (
|
|
<span key={`${item.id}-${idx}`} style={{ width: 3, height: 3, borderRadius: '50%', background: isToday(d) ? '#0d0d0d' : dotColor(item), display: 'block' }} />
|
|
))}
|
|
</span>
|
|
)}
|
|
</button>
|
|
{active && hasItems && (
|
|
<div onMouseEnter={() => 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' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{activeLabel}</span>
|
|
<span style={{ fontSize: 11, color: '#F5A523' }}>{activeItems.length} due</span>
|
|
</div>
|
|
{activeItems.slice(0, 4).map(item => (
|
|
<button key={item.id} type="button" onClick={() => navigate(`/tasks/${item.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '5px 0', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
|
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: dotColor(item), flexShrink: 0 }} />
|
|
<span style={{ flex: 1, minWidth: 0, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
|
<div style={{ marginBottom: rows.length > 0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Tasks In Progress</span>
|
|
{rows.length > 5 && (
|
|
<button type="button" className="dashboard-inline-link" style={{ fontSize: 11, fontWeight: 500, letterSpacing: 0.4, textTransform: 'uppercase' }} onClick={() => setShowAll((v) => !v)}>
|
|
{showAll ? 'Show less' : 'Show all'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{rows.length === 0 ? (
|
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
|
) : (
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
|
<colgroup>
|
|
<col style={{ width: '55%' }} />
|
|
<col style={{ width: '45%' }} />
|
|
</colgroup>
|
|
<thead style={{ background: 'transparent' }}>
|
|
<tr style={{ background: 'transparent' }}>
|
|
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
|
|
<SortTh col="assigned_to" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'right', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 5px 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Assigned To</SortTh>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visibleRows.map((t) => (
|
|
<tr key={t.id} style={{ background: 'transparent' }}>
|
|
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
|
|
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + t.id)}>{t.title}</button>
|
|
</td>
|
|
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'right' }}>
|
|
{t.assigned_to
|
|
? <button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(t.assigned_to, t.assignee_role))}>{t.assigned_name || 'Profile'}</button>
|
|
: (t.assigned_name || '—')}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HotItemsCard({ submissions, tasks, isClient = false }) {
|
|
const navigate = useNavigate();
|
|
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
|
|
const taskMap = new Map((tasks || []).map(t => [t.id, t]));
|
|
const seen = new Set();
|
|
const hotItems = (submissions || [])
|
|
.filter(s => {
|
|
const task = taskMap.get(s.task_id);
|
|
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
|
|
const activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
|
|
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 (key === 'task') return s.task?.title || '';
|
|
if (key === 'requested_by') return s.submitted_by_name || '';
|
|
if (key === 'deadline') return s.deadline || '';
|
|
return '';
|
|
});
|
|
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', flexDirection: 'column' }}>
|
|
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>
|
|
{isClient ? 'Tasks Ready For Review' : 'Hot Tasks'}
|
|
</span>
|
|
</div>
|
|
{hotItems.length === 0 ? (
|
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
|
|
{isClient ? 'No tasks ready for review' : 'No hot tasks'}
|
|
</div>
|
|
) : (
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
|
<colgroup>
|
|
<col style={{ width: '10%' }} />
|
|
<col style={{ width: '40%' }} />
|
|
<col style={{ width: '35%' }} />
|
|
<col style={{ width: '15%' }} />
|
|
</colgroup>
|
|
<thead style={{ background: 'transparent' }}>
|
|
<tr style={{ background: 'transparent' }}>
|
|
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} />
|
|
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
|
|
<SortTh col="requested_by" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Requested By</SortTh>
|
|
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedHotItems.map(s => (
|
|
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
|
|
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}>
|
|
{s.task.status === 'client_approved' ? (
|
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
|
|
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
|
|
</svg>
|
|
) : (
|
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
|
|
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
|
|
</svg>
|
|
)}
|
|
</td>
|
|
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
|
|
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
|
|
</td>
|
|
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
|
|
{s.submitted_by ? (
|
|
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{s.submitted_by_name || 'Profile'}</button>
|
|
) : (s.submitted_by_name || '—')}
|
|
</td>
|
|
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ClientHighlightCard({ highlights }) {
|
|
const navigate = useNavigate();
|
|
const { sortKey, sortDir, toggle, sort } = useSortable('company');
|
|
const fmt = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
|
|
const thStyle = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' };
|
|
const td = () => ({ padding: '5px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' });
|
|
const sortedHighlights = sort(highlights || [], (row, key) => {
|
|
if (key === 'company') return row.company?.name || '';
|
|
if (key === 'contact') return row.primaryContact?.name || '';
|
|
if (key === 'projects') return row.projectCount || 0;
|
|
if (key === 'open_tasks') return row.openTaskCount || 0;
|
|
if (key === 'outstanding') return Number(row.outstandingTotal || 0);
|
|
if (key === 'paid') return Number(row.paidTotal || 0);
|
|
return '';
|
|
});
|
|
return (
|
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
|
<div style={{ marginBottom: highlights && highlights.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Client Highlight</span>
|
|
</div>
|
|
{!highlights || highlights.length === 0 ? (
|
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No data</div>
|
|
) : (
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
|
<colgroup>
|
|
<col style={{ width: '5%' }} />
|
|
<col style={{ width: '22%' }} />
|
|
<col style={{ width: '23%' }} />
|
|
<col style={{ width: '13%' }} />
|
|
<col style={{ width: '13%' }} />
|
|
<col style={{ width: '12%' }} />
|
|
<col style={{ width: '12%' }} />
|
|
</colgroup>
|
|
<thead style={{ background: 'transparent' }}>
|
|
<tr style={{ background: 'transparent' }}>
|
|
<th style={{ ...thStyle, padding: '0 0 12px 5px' }} />
|
|
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...thStyle, textAlign: 'left', paddingLeft: 5 }}>Company</SortTh>
|
|
<SortTh col="contact" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Contact</SortTh>
|
|
<SortTh col="projects" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Projects</SortTh>
|
|
<SortTh col="open_tasks" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Open Tasks</SortTh>
|
|
<SortTh col="outstanding" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Outstanding</SortTh>
|
|
<SortTh col="paid" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Paid</SortTh>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }) => (
|
|
<tr key={company.id} style={{ background: 'transparent' }}>
|
|
<td style={{ ...td(), padding: '3px 5px 7px' }}>
|
|
<InitialPortrait name={company.name} />
|
|
</td>
|
|
<td style={{ ...td(), fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' }}>
|
|
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/company/${company.id}`)}>{company.name}</button>
|
|
</td>
|
|
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{primaryContact?.id ? (
|
|
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(primaryContact.id, primaryContact.role))}>{primaryContact.name || 'Profile'}</button>
|
|
) : (primaryContact?.name || '—')}
|
|
</td>
|
|
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)' }}>{projectCount}</td>
|
|
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)' }}>{openTaskCount || 0}</td>
|
|
<td style={{ ...td(), fontSize: 12, color: '#F5A523' }}>{fmt(outstandingTotal)}</td>
|
|
<td style={{ ...td(), fontSize: 12, color: '#4ade80' }}>{fmt(paidTotal)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TeamPerformanceCard({ tasks, profiles }) {
|
|
const navigate = useNavigate();
|
|
const profileMap = useMemo(() => {
|
|
const m = new Map();
|
|
(profiles || []).forEach(p => m.set(p.id, p));
|
|
return m;
|
|
}, [profiles]);
|
|
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
|
const allMonthOpts = useMemo(() => {
|
|
const opts = [];
|
|
const now = new Date();
|
|
for (let i = 0; i < 6; i++) {
|
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
opts.push({ value: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, label: i === 0 ? 'This Month' : d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) });
|
|
}
|
|
return opts;
|
|
}, []);
|
|
const monthsWithData = useMemo(() => new Set(
|
|
(tasks || []).filter(t => doneStatuses.includes(t.status) && t.completed_at).map(t => {
|
|
const d = new Date(t.completed_at);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
})
|
|
), [tasks]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
|
|
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
|
|
|
|
const { people, totalDone } = useMemo(() => {
|
|
const [year, month] = monthKey.split('-').map(Number);
|
|
const map = new Map();
|
|
(tasks || []).forEach(t => {
|
|
if (!doneStatuses.includes(t.status) || !t.completed_at) return;
|
|
const d = new Date(t.completed_at);
|
|
if (d.getFullYear() !== year || d.getMonth() + 1 !== month) return;
|
|
if (!t.assigned_name) return;
|
|
const entry = map.get(t.assigned_name) || { name: t.assigned_name, id: t.assigned_to || null, newCount: 0, revCount: 0 };
|
|
if ((t.current_version || 0) === 0) entry.newCount += 1;
|
|
else entry.revCount += 1;
|
|
map.set(t.assigned_name, entry);
|
|
});
|
|
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
|
|
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
|
|
}, [tasks, monthKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
return (
|
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexShrink: 0 }}>
|
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Team Performance</span>
|
|
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
|
|
<select value={monthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
|
|
{monthOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)}
|
|
</select>
|
|
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" style={{ position: 'absolute', right: 0, pointerEvents: 'none' }} stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,2 4,6 7,2"/></svg>
|
|
</div>
|
|
</div>
|
|
{people.slice(0, 5).length === 0 ? (
|
|
<div className="card-empty-center">No completed tasks this month</div>
|
|
) : (
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
|
{people.slice(0, 5).map((p, i) => {
|
|
const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0;
|
|
const tone = iconTone(p.name);
|
|
return (
|
|
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
|
{(() => { const prof = p.id ? profileMap.get(p.id) : null; const tone = iconTone(p.name); return prof?.avatar_url ? <div style={{ width: 27, height: 27, borderRadius: '50%', flexShrink: 0, overflow: 'hidden' }}><img src={prof.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div> : <Avatar name={p.name} />; })()}
|
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
{p.id ? <button type="button" className="dashboard-inline-link" style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} onClick={() => navigate(`/profile/${p.id}`)}>{p.name}</button> : <span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>}
|
|
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<div style={{ flex: 1, height: 4, borderRadius: 2, background: tone.bg, overflow: 'hidden' }}>
|
|
<div style={{ height: '100%', borderRadius: 2, background: tone.color, width: `${pct}%`, transition: 'width 0.3s ease' }} />
|
|
</div>
|
|
<span style={{ fontSize: 11, color: 'var(--text-muted)', minWidth: 28, textAlign: 'right' }}>{pct}%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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, setRefreshKey] = useState(0);
|
|
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
|
useRefetchOnFocus(refresh);
|
|
useRealtimeSubscription(['tasks', 'projects', 'submissions', 'activity_log', 'profiles'], refresh);
|
|
|
|
const cached = isTeam ? readPageCache(CACHE_KEY, 5 * 60_000) : null;
|
|
|
|
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
|
const [projects, setProjects] = useState(() => cached?.projects || []);
|
|
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
|
const [allCompanies, setAllCompanies] = useState(() => cached?.companies || []);
|
|
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
|
|
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
|
const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []);
|
|
const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
|
|
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
|
|
const [teamExpenses, setTeamExpenses] = useState([]);
|
|
const [myInvoices, setMyInvoices] = useState([]);
|
|
const [loading, setLoading] = useState(!cached);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function loadDashboard() {
|
|
try {
|
|
const cutoff = new Date();
|
|
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
|
|
const cutoffStr = cutoff.toISOString();
|
|
|
|
let scopedTaskIds = null;
|
|
let scopedProjectIds = null;
|
|
let scopedCompanyIds = null;
|
|
|
|
if (!isTeam && isClient) {
|
|
const companyIds = [...new Set([...(currentUser?.companies?.map(c => c.company?.id || c.id) || []), ...(currentUser?.company_id ? [currentUser.company_id] : [])])].filter(Boolean);
|
|
scopedCompanyIds = companyIds;
|
|
if (companyIds.length > 0) {
|
|
const { data: projRows } = await supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds);
|
|
scopedProjectIds = (projRows || []).map(p => p.id).filter(Boolean);
|
|
scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id)).filter(Boolean);
|
|
} else {
|
|
scopedProjectIds = [];
|
|
scopedTaskIds = [];
|
|
}
|
|
}
|
|
|
|
if (!isTeam && isExternal) {
|
|
const uid = currentUser?.id;
|
|
const { data: memberData } = await supabase.from('project_members').select('project_id').eq('profile_id', uid);
|
|
scopedProjectIds = (memberData || []).map(m => m.project_id).filter(Boolean);
|
|
if ((scopedProjectIds || []).length > 0) {
|
|
const { data: projectTasks } = await supabase.from('tasks').select('id').in('project_id', scopedProjectIds);
|
|
scopedTaskIds = (projectTasks || []).map(row => row.id).filter(Boolean);
|
|
} else {
|
|
scopedTaskIds = [];
|
|
}
|
|
}
|
|
|
|
const tasksQuery = supabase.from('tasks')
|
|
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, 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, status, company_id, created_at').in('status', ['sent', 'paid'])
|
|
: isClient
|
|
? ((scopedCompanyIds || []).length > 0
|
|
? supabase.from('invoices').select('total, 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 [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: cos }, { data: memRows }, { data: invs }, { data: exps }] = 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),
|
|
supabase.from('companies').select('id, name').order('name'),
|
|
supabase.from('company_members').select('company_id, profile_id'),
|
|
invoicesPromise,
|
|
expensesPromise,
|
|
]), 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 = (subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null }));
|
|
setTasks(tasksWithDeadlines);
|
|
setProjects(p || []);
|
|
setSubmissions(subsWithRole);
|
|
setClientProfiles((profiles || []).filter(pr => pr.role === 'client'));
|
|
setAllProfiles(profiles || []);
|
|
setAllCompanies(cos || []);
|
|
setCompanyMemberships(memRows || []);
|
|
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 || []);
|
|
|
|
if (isTeam) {
|
|
writePageCache(CACHE_KEY, {
|
|
tasks: tasksWithDeadlines,
|
|
projects: p || [],
|
|
submissions: subsWithRole,
|
|
clientProfiles: (profiles || []).filter(pr => pr.role === 'client'),
|
|
companies: cos || [],
|
|
companyMemberships: memRows || [],
|
|
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 teamHighlights = useMemo(() =>
|
|
isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
|
|
.sort((a, b) => b.openTaskCount - a.openTaskCount || b.projectCount - a.projectCount || a.company.name.localeCompare(b.company.name))
|
|
.slice(0, 5) : [],
|
|
[isTeam, allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices]);
|
|
|
|
const activityEvents = useMemo(() =>
|
|
(activityLog || []).map(e => ({
|
|
time: new Date(e.created_at),
|
|
name: e.actor_name || 'Fourge',
|
|
actorId: e.actor_id || null,
|
|
actionKey: e.action,
|
|
action: ACTION_LABEL[e.action] || e.action,
|
|
task: e.task_title || null,
|
|
taskId: e.task_id || null,
|
|
project: e.project_name || null,
|
|
projectId: e.project_id || null,
|
|
})).filter(e => !isNaN(e.time)).slice(0, 10),
|
|
[activityLog]);
|
|
|
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
|
|
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
|
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
|
|
const activeTasks = tasks.filter(t => !doneStatuses.includes(t.status));
|
|
const activeProjects = projects.filter(p => p.status !== 'completed' && p.status !== 'cancelled');
|
|
const hotTaskIds = new Set((submissions || []).filter(s => s.is_hot).map(s => s.task_id));
|
|
const calendarItems = tasks
|
|
.filter(t => t.deadline)
|
|
.map(t => {
|
|
const deadlineKey = getDateKey(t.deadline);
|
|
return {
|
|
id: t.id,
|
|
title: t.title,
|
|
deadline: t.deadline,
|
|
isHot: hotTaskIds.has(t.id),
|
|
isDone: doneStatuses.includes(t.status),
|
|
isOverdue: deadlineKey && deadlineKey < getDateKey(new Date()) && !doneStatuses.includes(t.status),
|
|
};
|
|
});
|
|
const dashRevenue = teamInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
|
const dashOutstanding = teamInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
|
|
const dashExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
|
|
const revenueByMonth = Array.from({ length: 4 }, (_, i) => {
|
|
const now = new Date();
|
|
const start = new Date(now.getFullYear(), now.getMonth() - (3 - i), 1);
|
|
const end = new Date(now.getFullYear(), now.getMonth() - (3 - i) + 1, 1);
|
|
return teamInvoices.filter(inv => inv.status === 'paid' && inv.created_at && new Date(inv.created_at) >= start && new Date(inv.created_at) < end).reduce((s, inv) => s + Number(inv.total || 0), 0);
|
|
});
|
|
|
|
const myPaid = myInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
|
const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
|
|
|
const card3 = isTeam
|
|
? { label: 'Net Profit', value: fmtMoney(dashRevenue - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
|
|
: isClient
|
|
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }
|
|
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue };
|
|
|
|
const card4 = isTeam
|
|
? { label: 'Revenue', value: fmtMoney(dashRevenue), sub: `${fmtMoney(dashOutstanding)} outstanding`, iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue, chartData: revenueByMonth }
|
|
: isClient
|
|
? { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid to Fourge', iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
|
|
: { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid by Fourge', iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit };
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1.5fr', marginBottom: 0 }}>
|
|
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.tasks} />
|
|
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
|
|
<DashStatCard label={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} />
|
|
<DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} />
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 280px', gap: 24, marginTop: 24 }}>
|
|
<ActivityFeed events={activityEvents} />
|
|
<TasksInProgressCard tasks={inProgressTasks} />
|
|
<MiniCalendar items={calendarItems} />
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
|
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} />
|
|
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} />
|
|
</div>
|
|
{isTeam && (
|
|
<div style={{ marginTop: 24 }}>
|
|
<ClientHighlightCard highlights={teamHighlights} />
|
|
</div>
|
|
)}
|
|
</Layout>
|
|
);
|
|
}
|