Add Resource Planning: team capacity, utilisation, workload overview
Build & Auto-Repair / build (push) Has been cancelled
Build & Auto-Repair / build (push) Has been cancelled
- Migration 024: adds capacity_hours column to users (default 40h/week) - /api/resource-planning: aggregates hours from time_entries, open tasks, active projects per user with utilisation calculations - /resource-planning page: overview stats cards, per-member capacity bars with colour-coded status (green=available, yellow=busy, orange=near capacity, red=overloaded) - Sidebar: new Resources nav item (BarChart3 icon)
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Users, Clock, Briefcase, AlertTriangle, TrendingUp } from "lucide-react"
|
||||
|
||||
interface Member {
|
||||
id: string
|
||||
name: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
avatar: string
|
||||
role: string
|
||||
isActive: boolean
|
||||
capacityHours: number
|
||||
totalHours: number
|
||||
billableHours: number
|
||||
utilization: number
|
||||
billableUtilization: number
|
||||
openTasks: number
|
||||
activeProjects: number
|
||||
}
|
||||
|
||||
interface Totals {
|
||||
totalCapacity: number
|
||||
totalHours: number
|
||||
totalBillable: number
|
||||
totalOpenTasks: number
|
||||
totalActiveProjects: number
|
||||
teamUtilization: number
|
||||
}
|
||||
|
||||
function utilizationColor(pct: number): string {
|
||||
if (pct > 100) return "bg-red-500"
|
||||
if (pct > 90) return "bg-orange-500"
|
||||
if (pct > 70) return "bg-yellow-500"
|
||||
return "bg-emerald-500"
|
||||
}
|
||||
|
||||
function utilizationTextColor(pct: number): string {
|
||||
if (pct > 100) return "text-red-600"
|
||||
if (pct > 90) return "text-orange-600"
|
||||
if (pct > 70) return "text-yellow-600"
|
||||
return "text-emerald-600"
|
||||
}
|
||||
|
||||
function utilizationBgColor(pct: number): string {
|
||||
if (pct > 100) return "bg-red-50 border-red-200"
|
||||
if (pct > 90) return "bg-orange-50 border-orange-200"
|
||||
if (pct > 70) return "bg-yellow-50 border-yellow-200"
|
||||
return "bg-emerald-50 border-emerald-200"
|
||||
}
|
||||
|
||||
function statusLabel(pct: number): { label: string; color: string } {
|
||||
if (pct > 100) return { label: "Overloaded", color: "bg-red-100 text-red-700 border-red-300" }
|
||||
if (pct > 90) return { label: "Near Capacity", color: "bg-orange-100 text-orange-700 border-orange-300" }
|
||||
if (pct > 70) return { label: "Busy", color: "bg-yellow-100 text-yellow-700 border-yellow-300" }
|
||||
if (pct > 50) return { label: "Available", color: "bg-emerald-100 text-emerald-700 border-emerald-300" }
|
||||
return { label: "Underutilized", color: "bg-slate-100 text-slate-600 border-slate-300" }
|
||||
}
|
||||
|
||||
export default function ResourcePlanningPage() {
|
||||
const [members, setMembers] = useState<Member[]>([])
|
||||
const [totals, setTotals] = useState<Totals | null>(null)
|
||||
const [period, setPeriod] = useState("week")
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetch(`/api/resource-planning?period=${period}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { setMembers(d.members || []); setTotals(d.totals || null) })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [period])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Resource Planning" description="Team capacity, utilisation, and workload overview">
|
||||
<Select value={period} onValueChange={setPeriod}>
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="week">This Week</SelectItem>
|
||||
<SelectItem value="month">This Month</SelectItem>
|
||||
<SelectItem value="all">All Time</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PageHeader>
|
||||
|
||||
{/* Overview cards */}
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
<Card className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users className="h-4 w-4" /> Team Size
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{members.filter(m => m.isActive).length}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" /> Total Hours
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{totals?.totalHours ?? 0}h</p>
|
||||
<p className="text-xs text-muted-foreground">of {totals?.totalCapacity ?? 0}h capacity</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<TrendingUp className="h-4 w-4" /> Utilisation
|
||||
</div>
|
||||
<p className={`text-2xl font-bold ${utilizationTextColor(totals?.teamUtilization ?? 0)}`}>
|
||||
{totals?.teamUtilization ?? 0}%
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Briefcase className="h-4 w-4" /> Active Projects
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{totals?.totalActiveProjects ?? 0}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertTriangle className="h-4 w-4" /> Open Tasks
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{totals?.totalOpenTasks ?? 0}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Team grid */}
|
||||
{loading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<Card key={i} className="p-4 space-y-3 animate-pulse">
|
||||
<div className="h-10 w-10 rounded-full bg-muted" />
|
||||
<div className="h-4 w-32 rounded bg-muted" />
|
||||
<div className="h-3 w-24 rounded bg-muted" />
|
||||
<div className="h-2 w-full rounded bg-muted" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : members.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">No team members found</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{members.map((m) => {
|
||||
const status = statusLabel(m.utilization)
|
||||
return (
|
||||
<Card key={m.id} className={`p-4 space-y-3 border ${utilizationBgColor(m.utilization)}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={m.avatar} />
|
||||
<AvatarFallback>{m.firstName[0]}{m.lastName[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{m.name}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{m.role?.replace("_", " ")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-[10px] ${status.color}`}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Utilisation bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Capacity: {m.capacityHours}h</span>
|
||||
<span>Logged: {m.totalHours}h</span>
|
||||
<span className={utilizationTextColor(m.utilization)}>{m.utilization}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${utilizationColor(m.utilization)}`}
|
||||
style={{ width: `${Math.min(m.utilization, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{m.utilization > 100 && (
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-red-500"
|
||||
style={{ width: `${Math.min(m.utilization - 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Briefcase className="h-3 w-3" />
|
||||
{m.activeProjects} projects
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{m.openTasks} tasks
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
{m.billableUtilization}% billable
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user