feat: performance dashboard counts per delivery, submission tab shows deliveries, task row hover fix
- Team performance: count each delivery record individually (sent_by + version_number), not per task — R00/R01/R02 by different people all count separately - Performance uses deliveries table directly, EST timezone bucketing, month-gated tabs - TaskDetail Submissions tab: reads from deliveries table, shows R## version, sent_by, sent_at - Submit for Review: writes to deliveries + delivery_files (deliveries bucket), stores version_number - Revision request popup: uses FileAttachment drag-drop component - Task table: only Name and Project columns highlight on hover - deliveries.version_number column added and backfilled Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { sendEmail } from '../lib/email';
|
|||||||
import { uploadFilesToRequestInfo, safeName } from '../lib/filebrowserFolders';
|
import { uploadFilesToRequestInfo, safeName } from '../lib/filebrowserFolders';
|
||||||
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
|
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
|
||||||
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
|
||||||
|
import FileAttachment from '../components/FileAttachment';
|
||||||
|
|
||||||
const ACTION_LABEL = {
|
const ACTION_LABEL = {
|
||||||
task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold',
|
task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold',
|
||||||
@@ -855,19 +856,7 @@ export default function TaskDetail() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', display: 'block', marginBottom: 6 }}>Attach Files</label>
|
<FileAttachment files={revisionFiles} onChange={setRevisionFiles} />
|
||||||
<input ref={revisionFileRef} type="file" multiple style={{ display: 'none' }} onChange={e => setRevisionFiles(prev => [...prev, ...Array.from(e.target.files)])} />
|
|
||||||
<button type="button" className="btn btn-outline" onClick={() => revisionFileRef.current?.click()}>+ Add Files</button>
|
|
||||||
{revisionFiles.length > 0 && (
|
|
||||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
{revisionFiles.map((f, i) => (
|
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-secondary)' }}>
|
|
||||||
<span>{f.name}</span>
|
|
||||||
<button type="button" onClick={() => setRevisionFiles(prev => prev.filter((_, idx) => idx !== i))} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 14, padding: '0 4px' }}>✕</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
<button className="btn btn-outline" disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
|
<button className="btn btn-outline" disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ function ClientHighlightCard({ highlights }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TeamPerformanceCard({ tasks, profiles }) {
|
function TeamPerformanceCard({ tasks, profiles, deliveries }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const profileMap = useMemo(() => {
|
const profileMap = useMemo(() => {
|
||||||
const m = new Map();
|
const m = new Map();
|
||||||
@@ -538,40 +538,43 @@ function TeamPerformanceCard({ tasks, profiles }) {
|
|||||||
return m;
|
return m;
|
||||||
}, [profiles]);
|
}, [profiles]);
|
||||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
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(
|
||||||
|
(deliveries || []).filter(d => d.submission?.task).map(d => {
|
||||||
|
const dt = toEST(d.sent_at);
|
||||||
|
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
})
|
||||||
|
), [deliveries]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
const allMonthOpts = useMemo(() => {
|
const allMonthOpts = useMemo(() => {
|
||||||
const opts = [];
|
const opts = [];
|
||||||
const now = new Date();
|
const now = toEST(new Date().toISOString());
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
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' }) });
|
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;
|
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 monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
|
||||||
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
|
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
|
||||||
|
|
||||||
const { people, totalDone } = useMemo(() => {
|
const { people, totalDone } = useMemo(() => {
|
||||||
const [year, month] = monthKey.split('-').map(Number);
|
const [year, month] = monthKey.split('-').map(Number);
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
(tasks || []).forEach(t => {
|
(deliveries || []).forEach(d => {
|
||||||
if (!doneStatuses.includes(t.status) || !t.completed_at) return;
|
const task = d.submission?.task;
|
||||||
const d = new Date(t.completed_at);
|
if (!task) return;
|
||||||
if (d.getFullYear() !== year || d.getMonth() + 1 !== month) return;
|
const dt = toEST(d.sent_at);
|
||||||
if (!t.assigned_name) return;
|
if (dt.getFullYear() !== year || dt.getMonth() + 1 !== month) return;
|
||||||
const entry = map.get(t.assigned_name) || { name: t.assigned_name, id: t.assigned_to || null, newCount: 0, revCount: 0 };
|
const name = d.sent_by || task.assigned_name;
|
||||||
if ((t.current_version || 0) === 0) entry.newCount += 1;
|
if (!name) return;
|
||||||
|
const entry = map.get(name) || { name, id: task.assigned_to || null, newCount: 0, revCount: 0 };
|
||||||
|
if ((d.version_number || 0) === 0) entry.newCount += 1;
|
||||||
else entry.revCount += 1;
|
else entry.revCount += 1;
|
||||||
map.set(t.assigned_name, entry);
|
map.set(name, entry);
|
||||||
});
|
});
|
||||||
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
|
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) };
|
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
|
||||||
}, [tasks, monthKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [deliveries, monthKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
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' }}>
|
||||||
@@ -584,7 +587,7 @@ function TeamPerformanceCard({ tasks, profiles }) {
|
|||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{people.slice(0, 5).length === 0 ? (
|
{people.length === 0 ? (
|
||||||
<div className="card-empty-center">No completed tasks this month</div>
|
<div className="card-empty-center">No completed tasks this month</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
@@ -643,6 +646,7 @@ export default function TeamDashboard() {
|
|||||||
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
|
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
|
||||||
const [teamExpenses, setTeamExpenses] = useState([]);
|
const [teamExpenses, setTeamExpenses] = useState([]);
|
||||||
const [myInvoices, setMyInvoices] = useState([]);
|
const [myInvoices, setMyInvoices] = useState([]);
|
||||||
|
const [perfDeliveries, setPerfDeliveries] = useState([]);
|
||||||
const [loading, setLoading] = useState(!cached);
|
const [loading, setLoading] = useState(!cached);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -731,7 +735,7 @@ export default function TeamDashboard() {
|
|||||||
? supabase.from('expenses').select('amount')
|
? supabase.from('expenses').select('amount')
|
||||||
: Promise.resolve({ data: [] });
|
: 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([
|
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([
|
||||||
tasksQuery,
|
tasksQuery,
|
||||||
projectsQuery,
|
projectsQuery,
|
||||||
submissionsQuery,
|
submissionsQuery,
|
||||||
@@ -741,6 +745,7 @@ export default function TeamDashboard() {
|
|||||||
supabase.from('company_members').select('company_id, profile_id'),
|
supabase.from('company_members').select('company_id, profile_id'),
|
||||||
invoicesPromise,
|
invoicesPromise,
|
||||||
expensesPromise,
|
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),
|
||||||
]), 30000, 'Dashboard load');
|
]), 30000, 'Dashboard load');
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
@@ -768,6 +773,7 @@ export default function TeamDashboard() {
|
|||||||
setTeamInvoices(isTeam ? (invs || []) : []);
|
setTeamInvoices(isTeam ? (invs || []) : []);
|
||||||
setMyInvoices(!isTeam ? (invs || []) : []);
|
setMyInvoices(!isTeam ? (invs || []) : []);
|
||||||
setTeamExpenses(exps || []);
|
setTeamExpenses(exps || []);
|
||||||
|
setPerfDeliveries(delivs || []);
|
||||||
|
|
||||||
if (isTeam) {
|
if (isTeam) {
|
||||||
writePageCache(CACHE_KEY, {
|
writePageCache(CACHE_KEY, {
|
||||||
@@ -871,7 +877,7 @@ export default function TeamDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||||
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} />
|
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} />
|
||||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} />
|
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} />
|
||||||
</div>
|
</div>
|
||||||
{isTeam && (
|
{isTeam && (
|
||||||
<div style={{ marginTop: 24 }}>
|
<div style={{ marginTop: 24 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user