fix: crash bugs in TeamInvoices + faster load
Bug fixes: - TeamInvoices: add useAuth import/currentUser (invoice-create crash) - TeamInvoices: setChartYear -> setExportYear (year-dropdown crash) - TeamDashboard: drop redundant setState-in-effect (cascading renders) - Companies: delete dead _UnusedClientCompanies (illegal hook calls) - annotate intentional empty PDF-fallback catches Load speed: - preconnect/dns-prefetch to Supabase origin - lazy-load heic-to in Converters: page chunk 2737KB -> 9KB - split recharts into its own 'charts' vendor chunk Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+228
-249
@@ -1,15 +1,18 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import ProfileAvatar from '../../components/ProfileAvatar';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { useRefetchOnFocus } from '../../hooks/useRefetchOnFocus';
|
||||
import { useRealtimeSubscription } from '../../hooks/useRealtimeSubscription';
|
||||
import { useLiveRefresh } from '../../hooks/useLiveRefresh';
|
||||
import { resolveScopedWorkIds } from '../../lib/workScope';
|
||||
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,22 +35,6 @@ function fmtMoney(n) {
|
||||
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"/> },
|
||||
@@ -75,25 +62,6 @@ function ActionIcon({ actionKey, size = 27 }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({ name, size = 27 }) {
|
||||
return (
|
||||
<div style={{ width: size, height: size, borderRadius: '50%', background: 'rgba(245,165,35,0.15)', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="#F5A523">
|
||||
<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 }) {
|
||||
return (
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'var(--accent)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#000', 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)}`;
|
||||
@@ -373,170 +341,159 @@ function TasksInProgressCard({ tasks = [] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function HotItemsCard({ submissions, tasks, isClient = false }) {
|
||||
function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false, currentUserId = null, cardHeight = null }) {
|
||||
const navigate = useNavigate();
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
|
||||
const taskMap = new Map((tasks || []).map(t => [t.id, t]));
|
||||
const seen = new Set();
|
||||
const 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 activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
|
||||
const hotItems = isExternal
|
||||
? (tasks || [])
|
||||
.filter(task => task?.assigned_to === currentUserId && activeStatuses.includes(task?.status))
|
||||
.sort((a, b) => {
|
||||
if (!a.deadline && !b.deadline) return 0;
|
||||
if (!a.deadline) return 1;
|
||||
if (!b.deadline) return -1;
|
||||
return new Date(a.deadline) - new Date(b.deadline);
|
||||
})
|
||||
.slice(0, 8)
|
||||
: (submissions || [])
|
||||
.filter(s => {
|
||||
const task = taskMap.get(s.task_id);
|
||||
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
|
||||
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
|
||||
})
|
||||
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
|
||||
.filter(s => s.task)
|
||||
.sort((a, b) => {
|
||||
if (!a.deadline && !b.deadline) return 0;
|
||||
if (!a.deadline) return 1;
|
||||
if (!b.deadline) return -1;
|
||||
return new Date(a.deadline) - new Date(b.deadline);
|
||||
})
|
||||
.slice(0, 7);
|
||||
const sortedHotItems = sort(hotItems, (s, key) => {
|
||||
if (isExternal) {
|
||||
if (key === 'task') return s.title || '';
|
||||
if (key === 'project') return s.project_name || '';
|
||||
if (key === 'status') return s.status || '';
|
||||
if (key === 'deadline') return s.deadline || '';
|
||||
return '';
|
||||
}
|
||||
if (key === 'task') return s.task?.title || '';
|
||||
if (key === 'requested_by') return s.submitted_by_name || '';
|
||||
if (key === 'requested_by') return getSubmissionDisplayName(s) || '';
|
||||
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={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
|
||||
<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'}
|
||||
{isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : '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 style={{ flex: 1, minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : '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 className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
{isExternal ? (
|
||||
<>
|
||||
<colgroup>
|
||||
<col style={{ width: '42%' }} />
|
||||
<col style={{ width: '24%' }} />
|
||||
<col style={{ width: '19%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</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="project" 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 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Project</SortTh>
|
||||
<SortTh col="status" 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' }}>Status</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((task) => (
|
||||
<tr key={task.id} style={{ verticalAlign: 'middle', 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/' + task.id)}>{task.title}</button>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>{task.project?.name || '—'}</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent', textTransform: 'capitalize' }}>{String(task.status || '').replace(/_/g, ' ') || '—'}</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{task.deadline ? new Date(task.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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))}>{getSubmissionDisplayName(s) || 'Profile'}</button>
|
||||
) : (getSubmissionDisplayName(s) || '—')}
|
||||
</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>
|
||||
)}
|
||||
</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, deliveries }) {
|
||||
function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) {
|
||||
const navigate = useNavigate();
|
||||
const profileMap = useMemo(() => {
|
||||
const m = new Map();
|
||||
(profiles || []).forEach(p => m.set(p.id, p));
|
||||
return m;
|
||||
}, [profiles]);
|
||||
const profileNameMap = useMemo(() => {
|
||||
const m = new Map();
|
||||
(profiles || []).forEach((profile) => {
|
||||
const key = String(profile?.name || '').trim().toLowerCase();
|
||||
if (key && !m.has(key)) m.set(key, profile);
|
||||
});
|
||||
return m;
|
||||
}, [profiles]);
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' }));
|
||||
const monthsWithData = useMemo(() => new Set(
|
||||
@@ -556,9 +513,12 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
|
||||
}, []);
|
||||
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
|
||||
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
|
||||
const effectiveMonthKey = monthOpts.some(option => option.value === monthKey)
|
||||
? monthKey
|
||||
: (monthOpts[0]?.value || allMonthOpts[0]?.value || '');
|
||||
|
||||
const { people, totalDone } = useMemo(() => {
|
||||
const [year, month] = monthKey.split('-').map(Number);
|
||||
const [year, month] = effectiveMonthKey.split('-').map(Number);
|
||||
const map = new Map();
|
||||
(deliveries || []).forEach(d => {
|
||||
const task = d.submission?.task;
|
||||
@@ -567,35 +527,50 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
|
||||
if (dt.getFullYear() !== year || dt.getMonth() + 1 !== month) return;
|
||||
const name = d.sent_by || task.assigned_name;
|
||||
if (!name) return;
|
||||
const entry = map.get(name) || { name, id: task.assigned_to || null, newCount: 0, revCount: 0 };
|
||||
const matchedProfile = (task.assigned_to && profileMap.get(task.assigned_to)) || profileNameMap.get(String(name).trim().toLowerCase()) || null;
|
||||
const entry = map.get(name) || {
|
||||
name,
|
||||
id: matchedProfile?.id || task.assigned_to || null,
|
||||
avatar_url: matchedProfile?.avatar_url || '',
|
||||
newCount: 0,
|
||||
revCount: 0,
|
||||
};
|
||||
if ((d.version_number || 0) === 0) entry.newCount += 1;
|
||||
else entry.revCount += 1;
|
||||
map.set(name, entry);
|
||||
});
|
||||
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
|
||||
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
|
||||
}, [deliveries, monthKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [deliveries, effectiveMonthKey, profileMap, profileNameMap]); // 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={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
|
||||
<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 }}>
|
||||
<select value={effectiveMonthKey} 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.length === 0 ? (
|
||||
<div className="card-empty-center">No completed tasks this month</div>
|
||||
<div className="card-empty-center" style={{ flex: 1 }}>No completed tasks this month</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{people.slice(0, 5).map((p, i) => {
|
||||
const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0;
|
||||
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; 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} />; })()}
|
||||
<ProfileAvatar
|
||||
profileId={p.id}
|
||||
name={p.name}
|
||||
avatarUrl={p.avatar_url}
|
||||
profilesById={profileMap}
|
||||
profilesByName={profileNameMap}
|
||||
size={27}
|
||||
fontSize={12}
|
||||
/>
|
||||
<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>}
|
||||
@@ -628,26 +603,40 @@ export default function TeamDashboard() {
|
||||
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 { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions', 'activity_log', 'profiles']);
|
||||
|
||||
const cached = isTeam ? readPageCache(CACHE_KEY, 5 * 60_000) : null;
|
||||
|
||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
||||
const [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 [teamSubcontractorPOs, setTeamSubcontractorPOs] = useState([]);
|
||||
const [teamSubInvoices, setTeamSubInvoices] = useState([]);
|
||||
const [myInvoices, setMyInvoices] = useState([]);
|
||||
const [perfDeliveries, setPerfDeliveries] = useState([]);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
const bottomCardsRowRef = useRef(null);
|
||||
const [bottomCardsHeight, setBottomCardsHeight] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
function updateBottomCardsHeight() {
|
||||
const row = bottomCardsRowRef.current;
|
||||
if (!row) return;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||
const available = Math.floor(viewportHeight - rect.top - 24);
|
||||
const nextHeight = available > 0 ? `${available}px` : null;
|
||||
setBottomCardsHeight(prev => (prev === nextHeight ? prev : nextHeight));
|
||||
}
|
||||
|
||||
updateBottomCardsHeight();
|
||||
window.addEventListener('resize', updateBottomCardsHeight);
|
||||
return () => window.removeEventListener('resize', updateBottomCardsHeight);
|
||||
}, [loading, isTeam, isClient, isExternal]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -658,37 +647,16 @@ export default function TeamDashboard() {
|
||||
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 {
|
||||
scopedTaskIds,
|
||||
scopedProjectIds,
|
||||
scopedCompanyIds,
|
||||
} = isTeam
|
||||
? { scopedTaskIds: null, scopedProjectIds: null, scopedCompanyIds: null }
|
||||
: await resolveScopedWorkIds(currentUser, { isClient, isExternal });
|
||||
|
||||
const tasksQuery = supabase.from('tasks')
|
||||
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, assignee:profiles!assigned_to(avatar_url)')
|
||||
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, project:projects(name), assignee:profiles!assigned_to(avatar_url)')
|
||||
.gte('submitted_at', cutoffStr)
|
||||
.order('submitted_at', { ascending: false });
|
||||
if (isClient) {
|
||||
@@ -724,10 +692,10 @@ export default function TeamDashboard() {
|
||||
}
|
||||
|
||||
const invoicesPromise = isTeam
|
||||
? supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid'])
|
||||
? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('status', ['sent', 'paid'])
|
||||
: isClient
|
||||
? ((scopedCompanyIds || []).length > 0
|
||||
? supabase.from('invoices').select('total, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid'])
|
||||
? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid'])
|
||||
: Promise.resolve({ data: [] }))
|
||||
: supabase.from('subcontractor_invoices').select('total, status').eq('profile_id', currentUser?.id).in('status', ['submitted', 'approved', 'paid']);
|
||||
|
||||
@@ -735,17 +703,25 @@ export default function TeamDashboard() {
|
||||
? 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 }, { data: delivs }] = await withTimeout(Promise.all([
|
||||
const subcontractorPOsPromise = isTeam
|
||||
? supabase.from('subcontractor_payments').select('amount, status, paid_at, date')
|
||||
: Promise.resolve({ data: [] });
|
||||
|
||||
const subcontractorInvoicesPromise = isTeam
|
||||
? supabase.from('subcontractor_invoices').select('status, paid_at, items:subcontractor_invoice_items(unit_price, quantity)')
|
||||
: Promise.resolve({ data: [] });
|
||||
|
||||
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: invs }, { data: exps }, { data: subcontractorPOs }, { data: subcontractorInvoices }, { data: delivs }] = await withTimeout(Promise.all([
|
||||
tasksQuery,
|
||||
projectsQuery,
|
||||
submissionsQuery,
|
||||
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate, avatar_url'),
|
||||
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(isTeam ? 20 : 50),
|
||||
supabase.from('companies').select('id, name').order('name'),
|
||||
supabase.from('company_members').select('company_id, profile_id'),
|
||||
invoicesPromise,
|
||||
expensesPromise,
|
||||
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
|
||||
subcontractorPOsPromise,
|
||||
subcontractorInvoicesPromise,
|
||||
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task_id, task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
|
||||
]), 30000, 'Dashboard load');
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -754,14 +730,14 @@ export default function TeamDashboard() {
|
||||
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 }));
|
||||
const subsWithRole = mergeSubmissionDisplayNames(
|
||||
(subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null })),
|
||||
profiles || []
|
||||
);
|
||||
setTasks(tasksWithDeadlines);
|
||||
setProjects(p || []);
|
||||
setSubmissions(subsWithRole);
|
||||
setClientProfiles((profiles || []).filter(pr => pr.role === 'client'));
|
||||
setAllProfiles(profiles || []);
|
||||
setAllCompanies(cos || []);
|
||||
setCompanyMemberships(memRows || []);
|
||||
const scopedActivity = isTeam
|
||||
? (activity || [])
|
||||
: (activity || []).filter((entry) => {
|
||||
@@ -773,16 +749,19 @@ export default function TeamDashboard() {
|
||||
setTeamInvoices(isTeam ? (invs || []) : []);
|
||||
setMyInvoices(!isTeam ? (invs || []) : []);
|
||||
setTeamExpenses(exps || []);
|
||||
setPerfDeliveries(delivs || []);
|
||||
setTeamSubcontractorPOs(subcontractorPOs || []);
|
||||
setTeamSubInvoices(subcontractorInvoices || []);
|
||||
const scopedDeliveryTaskIds = new Set(scopedTaskIds || []);
|
||||
const scopedDeliveries = isTeam
|
||||
? (delivs || [])
|
||||
: (delivs || []).filter(delivery => scopedDeliveryTaskIds.has(delivery.submission?.task_id));
|
||||
setPerfDeliveries(scopedDeliveries);
|
||||
|
||||
if (isTeam) {
|
||||
writePageCache(CACHE_KEY, {
|
||||
tasks: tasksWithDeadlines,
|
||||
projects: p || [],
|
||||
submissions: subsWithRole,
|
||||
clientProfiles: (profiles || []).filter(pr => pr.role === 'client'),
|
||||
companies: cos || [],
|
||||
companyMemberships: memRows || [],
|
||||
activityLog: activity || [],
|
||||
teamInvoices: invs || [],
|
||||
allProfiles: profiles || [],
|
||||
@@ -797,12 +776,6 @@ export default function TeamDashboard() {
|
||||
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),
|
||||
@@ -817,7 +790,7 @@ export default function TeamDashboard() {
|
||||
})).filter(e => !isNaN(e.time)).slice(0, 10),
|
||||
[activityLog]);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (loading) return <Layout><PageLoader /></Layout>;
|
||||
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
|
||||
@@ -839,7 +812,18 @@ export default function TeamDashboard() {
|
||||
});
|
||||
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 dashOpsExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||||
const dashNetReceived = teamInvoices
|
||||
.filter(i => i.status === 'paid')
|
||||
.reduce((s, i) => s + Number(i.total || 0) - Number(i.stripe_fee || 0), 0);
|
||||
const dashPaidSubcontractorPOsTotal = teamSubcontractorPOs
|
||||
.filter(po => po.status === 'paid')
|
||||
.reduce((s, po) => s + Number(po.amount || 0), 0);
|
||||
const dashPaidSubInvoicesTotal = teamSubInvoices
|
||||
.filter(inv => inv.status === 'paid')
|
||||
.reduce((s, inv) => s + (inv.items || []).reduce((a, item) => a + Number(item.unit_price || 0) * Number(item.quantity || 1), 0), 0);
|
||||
const dashSubcontractorExpensesTotal = dashPaidSubcontractorPOsTotal + dashPaidSubInvoicesTotal;
|
||||
const dashExpensesTotal = dashOpsExpensesTotal + dashSubcontractorExpensesTotal;
|
||||
const revenueByMonth = Array.from({ length: 4 }, (_, i) => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - (3 - i), 1);
|
||||
@@ -851,7 +835,7 @@ export default function TeamDashboard() {
|
||||
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 }
|
||||
? { label: 'Net Profit', value: fmtMoney(dashNetReceived - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses + sub costs`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
|
||||
: isClient
|
||||
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }
|
||||
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue };
|
||||
@@ -875,15 +859,10 @@ export default function TeamDashboard() {
|
||||
<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} deliveries={perfDeliveries} />
|
||||
<div ref={bottomCardsRowRef} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight={bottomCardsHeight} />
|
||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} cardHeight={bottomCardsHeight} />
|
||||
</div>
|
||||
{isTeam && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<ClientHighlightCard highlights={teamHighlights} />
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user