Add Resource Planning: team capacity, utilisation, workload overview
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:
2026-07-01 13:12:48 +02:00
parent 9f4dd4fc6b
commit ef75d16bf4
5 changed files with 337 additions and 0 deletions
@@ -0,0 +1,10 @@
-- ============================================================================
-- Resource Planning — Team capacity, utilisation rates, availability
-- ============================================================================
ALTER TABLE users ADD COLUMN IF NOT EXISTS capacity_hours DECIMAL(5,2) NOT NULL DEFAULT 40;
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_encrypted TEXT;
-- Set default capacities for existing users
UPDATE users SET capacity_hours = 40 WHERE capacity_hours IS NULL;
+3
View File
@@ -88,5 +88,8 @@ BEGIN;
\echo '=== Running 023_client_portal.sql (Client Portal) ==='
\i ../../src/app/client-portal/database/020_client_portal.sql
\echo '=== Running 024_resource_planning.sql (Resource Planning) ==='
\i 024_resource_planning.sql
\echo '=== Migration Complete ==='
COMMIT;
@@ -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>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const period = searchParams.get("period") || "week"
const now = new Date()
let startDate: string
if (period === "week") {
const monday = new Date(now); monday.setDate(monday.getDate() - monday.getDay() + 1)
startDate = monday.toISOString().split("T")[0]
} else if (period === "month") {
startDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`
} else {
startDate = new Date(0).toISOString().split("T")[0]
}
const usersResult = await query(
`SELECT u.id, u.first_name, u.last_name, u.email, u.avatar_url, u.is_active, u.capacity_hours,
r.name AS role_name
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE u.deleted_at IS NULL
ORDER BY u.first_name`
)
const completedStatusResult = await query(`SELECT id FROM project_statuses WHERE name = 'Completed'`)
const completedStatusId = completedStatusResult.rows[0]?.id
const members = await Promise.all(usersResult.rows.map(async (u: any) => {
const timeResult = await query(
`SELECT COALESCE(SUM(duration_minutes), 0) AS total_minutes,
COALESCE(SUM(CASE WHEN billable THEN duration_minutes ELSE 0 END), 0) AS billable_minutes
FROM time_entries
WHERE user_id = $1 AND date >= $2`,
[u.id, startDate]
)
const tasksResult = await query(
`SELECT COUNT(*) AS open_tasks
FROM tasks
WHERE assigned_to = $1 AND status IN ('pending', 'in_progress') AND deleted_at IS NULL`,
[u.id]
)
const projectsResult = await query(
`SELECT COUNT(*) AS active_projects
FROM projects
WHERE owner_id = $1 AND status_id != $2 AND deleted_at IS NULL`,
[u.id, completedStatusId]
)
const capacity = parseFloat(u.capacity_hours) || 40
const totalHours = Math.round(timeResult.rows[0].total_minutes / 60 * 10) / 10
const billableHours = Math.round(timeResult.rows[0].billable_minutes / 60 * 10) / 10
const utilization = capacity > 0 ? Math.round((totalHours / capacity) * 100) : 0
const billableUtilization = capacity > 0 ? Math.round((billableHours / capacity) * 100) : 0
return {
id: u.id,
firstName: u.first_name,
lastName: u.last_name,
email: u.email,
name: `${u.first_name} ${u.last_name}`,
avatar: u.avatar_url,
role: u.role_name?.toLowerCase(),
isActive: u.is_active,
capacityHours: capacity,
totalHours,
billableHours,
utilization,
billableUtilization,
openTasks: parseInt(tasksResult.rows[0].open_tasks),
activeProjects: parseInt(projectsResult.rows[0].active_projects),
}
}))
const totals = {
totalCapacity: members.reduce((s, m) => s + m.capacityHours, 0),
totalHours: members.reduce((s, m) => s + m.totalHours, 0),
totalBillable: members.reduce((s, m) => s + m.billableHours, 0),
totalOpenTasks: members.reduce((s, m) => s + m.openTasks, 0),
totalActiveProjects: members.reduce((s, m) => s + m.activeProjects, 0),
teamUtilization: members.reduce((s, m) => s + m.capacityHours, 0) > 0
? Math.round((members.reduce((s, m) => s + m.totalHours, 0) / members.reduce((s, m) => s + m.capacityHours, 0)) * 100)
: 0,
}
return NextResponse.json({ members, totals, period })
} catch (error) {
console.error("Resource planning API error:", error)
return NextResponse.json({ error: "Failed to load resource data" }, { status: 500 })
}
}
+2
View File
@@ -24,6 +24,7 @@ import {
FolderKanban,
FileText,
Clock,
BarChart3,
} from "lucide-react"
import { useUser } from "@/providers/user-provider"
@@ -38,6 +39,7 @@ const navItems = [
{ href: "/chats", label: "Chats", icon: MessageSquare },
{ href: "/proposals", label: "Proposals", icon: FileText },
{ href: "/time-tracking", label: "Time", icon: Clock },
{ href: "/resource-planning", label: "Resources", icon: BarChart3 },
{ href: "/ai-assistant", label: "AI Assistant", icon: Bot, roles: ["sales", "admin", "super_admin"] },
{ href: "/users", label: "Users", icon: Building2 },
{ href: "/settings", label: "Settings", icon: Settings },