Add files via upload
This commit is contained in:
166
frontend/src/components/Calendar/WorkloadHeatmap.jsx
Normal file
166
frontend/src/components/Calendar/WorkloadHeatmap.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { format, startOfWeek, addDays, addWeeks, isSameDay, parseISO, isToday } from 'date-fns'
|
||||
import useProjectStore from '../../store/useProjectStore'
|
||||
import useFocusStore from '../../store/useFocusStore'
|
||||
import Badge from '../UI/Badge'
|
||||
|
||||
const WEEKS = 20
|
||||
const DAY_INIT = ['M','T','W','T','F','S','S']
|
||||
|
||||
function getCellStyle(count) {
|
||||
if (count === 0) return 'bg-surface border-surface-border'
|
||||
if (count === 1) return 'bg-gold/25 border-gold/40'
|
||||
if (count === 2) return 'bg-gold/55 border-gold/70'
|
||||
return 'bg-gold border-gold shadow-gold'
|
||||
}
|
||||
|
||||
export default function WorkloadHeatmap() {
|
||||
const projects = useProjectStore(s => s.projects)
|
||||
const openFocus = useFocusStore(s => s.openFocus)
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
|
||||
const { weeks, stats } = useMemo(() => {
|
||||
const start = startOfWeek(addWeeks(new Date(), -10), { weekStartsOn: 1 })
|
||||
const map = {}
|
||||
projects.forEach(p => {
|
||||
(p.deliverables || []).forEach(d => {
|
||||
if (!map[d.due_date]) map[d.due_date] = []
|
||||
map[d.due_date].push({ deliverable: d, project: p })
|
||||
})
|
||||
})
|
||||
|
||||
const grid = Array.from({ length: WEEKS }, (_, wi) =>
|
||||
Array.from({ length: 7 }, (_, di) => {
|
||||
const date = addDays(start, wi * 7 + di)
|
||||
const key = format(date, 'yyyy-MM-dd')
|
||||
return { date, key, items: map[key] || [] }
|
||||
})
|
||||
)
|
||||
|
||||
const all = projects.flatMap(p => p.deliverables || [])
|
||||
const stats = {
|
||||
total: all.length,
|
||||
overdue: all.filter(d => d.status === 'overdue').length,
|
||||
in_progress: all.filter(d => d.status === 'in_progress').length,
|
||||
completed: all.filter(d => d.status === 'completed').length,
|
||||
upcoming: all.filter(d => d.status === 'upcoming').length,
|
||||
}
|
||||
return { weeks: grid, stats }
|
||||
}, [projects])
|
||||
|
||||
const monthLabels = useMemo(() => {
|
||||
const labels = []; let last = -1
|
||||
weeks.forEach((week, wi) => {
|
||||
const m = week[0].date.getMonth()
|
||||
if (m !== last) { labels.push({ wi, label: format(week[0].date, 'MMM') }); last = m }
|
||||
})
|
||||
return labels
|
||||
}, [weeks])
|
||||
|
||||
const CELL = 20, GAP = 3
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface overflow-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-gold font-bold text-lg tracking-wide">Workload Heatmap</h2>
|
||||
<p className="text-text-muted text-xs mt-0.5">{WEEKS} weeks of deliverable density</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span>Less</span>
|
||||
{['bg-surface border-surface-border','bg-gold/25 border-gold/40','bg-gold/55 border-gold/70','bg-gold border-gold'].map((c,i) => (
|
||||
<div key={i} className={`w-4 h-4 rounded-sm border ${c}`} />
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-5 gap-3 mb-6">
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, color: 'text-text-primary' },
|
||||
{ label: 'Upcoming', value: stats.upcoming, color: 'text-blue-400' },
|
||||
{ label: 'In Progress', value: stats.in_progress, color: 'text-amber-400' },
|
||||
{ label: 'Completed', value: stats.completed, color: 'text-green-400' },
|
||||
{ label: 'Overdue', value: stats.overdue, color: 'text-red-400' },
|
||||
].map(({ label, value, color }) => (
|
||||
<div key={label} className="bg-surface-elevated border border-surface-border rounded-xl p-4 text-center">
|
||||
<p className={`text-2xl font-bold ${color}`}>{value}</p>
|
||||
<p className="text-text-muted text-xs mt-1">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Heatmap grid */}
|
||||
<div className="flex gap-3 overflow-x-auto pb-4">
|
||||
{/* Day labels */}
|
||||
<div className="flex flex-col flex-shrink-0 pt-6" style={{ gap: GAP }}>
|
||||
{DAY_INIT.map((d, i) => (
|
||||
<div key={i} style={{ height: CELL }} className="flex items-center text-[10px] text-text-muted/50 font-mono w-4">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="flex flex-col flex-shrink-0">
|
||||
{/* Month labels */}
|
||||
<div className="relative h-5 mb-1">
|
||||
{monthLabels.map(({ wi, label }) => (
|
||||
<span key={label+wi} className="absolute text-[10px] text-text-muted/60 font-medium"
|
||||
style={{ left: wi * (CELL + GAP) }}>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Week columns */}
|
||||
<div className="flex" style={{ gap: GAP }}>
|
||||
{weeks.map((week, wi) => (
|
||||
<div key={wi} className="flex flex-col flex-shrink-0" style={{ gap: GAP }}>
|
||||
{week.map(({ date, key, items }) => (
|
||||
<div key={key}
|
||||
style={{ width: CELL, height: CELL }}
|
||||
className={`rounded-sm border cursor-pointer transition-all hover:scale-125 hover:z-10 relative
|
||||
${getCellStyle(items.length)}
|
||||
${isToday(date) ? 'ring-1 ring-white/40' : ''}
|
||||
`}
|
||||
onClick={() => items.length > 0 && openFocus(items[0].project.id, items[0].deliverable.id)}
|
||||
onMouseEnter={(e) => setTooltip({ x: e.clientX, y: e.clientY, date, items })}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div className="fixed z-[200] pointer-events-none bg-surface-elevated border border-surface-border rounded-xl shadow-2xl px-3.5 py-3 min-w-[200px] max-w-[280px]"
|
||||
style={{ left: Math.min(tooltip.x + 14, window.innerWidth - 290), top: Math.max(tooltip.y - 100, 8) }}>
|
||||
<p className={`text-xs font-bold mb-1.5 ${isToday(tooltip.date) ? 'text-gold' : 'text-text-primary'}`}>
|
||||
{isToday(tooltip.date) ? 'Today — ' : ''}{format(tooltip.date, 'EEE, MMM d, yyyy')}
|
||||
</p>
|
||||
{tooltip.items.length === 0 ? (
|
||||
<p className="text-text-muted/50 text-xs">No deliverables</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{tooltip.items.slice(0, 5).map(({ deliverable, project }) => (
|
||||
<div key={deliverable.id} className="flex items-start gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: project.color }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] text-text-primary truncate">{deliverable.title}</p>
|
||||
<p className="text-[10px] text-text-muted/60">{project.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{tooltip.items.length > 5 && (
|
||||
<p className="text-[10px] text-text-muted/50 pl-3">+{tooltip.items.length - 5} more</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user