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,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;
|
||||||
@@ -88,5 +88,8 @@ BEGIN;
|
|||||||
\echo '=== Running 023_client_portal.sql (Client Portal) ==='
|
\echo '=== Running 023_client_portal.sql (Client Portal) ==='
|
||||||
\i ../../src/app/client-portal/database/020_client_portal.sql
|
\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 ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
FolderKanban,
|
FolderKanban,
|
||||||
FileText,
|
FileText,
|
||||||
Clock,
|
Clock,
|
||||||
|
BarChart3,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { useUser } from "@/providers/user-provider"
|
import { useUser } from "@/providers/user-provider"
|
||||||
@@ -38,6 +39,7 @@ const navItems = [
|
|||||||
{ href: "/chats", label: "Chats", icon: MessageSquare },
|
{ href: "/chats", label: "Chats", icon: MessageSquare },
|
||||||
{ href: "/proposals", label: "Proposals", icon: FileText },
|
{ href: "/proposals", label: "Proposals", icon: FileText },
|
||||||
{ href: "/time-tracking", label: "Time", icon: Clock },
|
{ 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: "/ai-assistant", label: "AI Assistant", icon: Bot, roles: ["sales", "admin", "super_admin"] },
|
||||||
{ href: "/users", label: "Users", icon: Building2 },
|
{ href: "/users", label: "Users", icon: Building2 },
|
||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||||||
|
|||||||
Reference in New Issue
Block a user