From ef75d16bf496efc544df08276425cf77d51563a2 Mon Sep 17 00:00:00 2001 From: Rene Date: Wed, 1 Jul 2026 13:12:48 +0200 Subject: [PATCH] Add Resource Planning: team capacity, utilisation, workload overview - 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) --- database/migrations/024_resource_planning.sql | 10 + database/migrations/run_all.sql | 3 + .../(dashboard)/resource-planning/page.tsx | 221 ++++++++++++++++++ src/app/api/resource-planning/route.ts | 101 ++++++++ src/components/layout/sidebar.tsx | 2 + 5 files changed, 337 insertions(+) create mode 100644 database/migrations/024_resource_planning.sql create mode 100644 src/app/(dashboard)/resource-planning/page.tsx create mode 100644 src/app/api/resource-planning/route.ts diff --git a/database/migrations/024_resource_planning.sql b/database/migrations/024_resource_planning.sql new file mode 100644 index 0000000..cbb1b2b --- /dev/null +++ b/database/migrations/024_resource_planning.sql @@ -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; diff --git a/database/migrations/run_all.sql b/database/migrations/run_all.sql index da761a6..9aa2729 100644 --- a/database/migrations/run_all.sql +++ b/database/migrations/run_all.sql @@ -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; diff --git a/src/app/(dashboard)/resource-planning/page.tsx b/src/app/(dashboard)/resource-planning/page.tsx new file mode 100644 index 0000000..2751218 --- /dev/null +++ b/src/app/(dashboard)/resource-planning/page.tsx @@ -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([]) + const [totals, setTotals] = useState(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 ( +
+ + + + + {/* Overview cards */} +
+ +
+ Team Size +
+

{members.filter(m => m.isActive).length}

+
+ +
+ Total Hours +
+

{totals?.totalHours ?? 0}h

+

of {totals?.totalCapacity ?? 0}h capacity

+
+ +
+ Utilisation +
+

+ {totals?.teamUtilization ?? 0}% +

+
+ +
+ Active Projects +
+

{totals?.totalActiveProjects ?? 0}

+
+ +
+ Open Tasks +
+

{totals?.totalOpenTasks ?? 0}

+
+
+ + {/* Team grid */} + {loading ? ( +
+ {[1, 2, 3, 4].map(i => ( + +
+
+
+
+ + ))} +
+ ) : members.length === 0 ? ( +
No team members found
+ ) : ( +
+ {members.map((m) => { + const status = statusLabel(m.utilization) + return ( + +
+
+ + + {m.firstName[0]}{m.lastName[0]} + +
+

{m.name}

+

{m.role?.replace("_", " ")}

+
+
+ + {status.label} + +
+ + {/* Utilisation bar */} +
+
+ Capacity: {m.capacityHours}h + Logged: {m.totalHours}h + {m.utilization}% +
+
+
+
+ {m.utilization > 100 && ( +
+
+
+ )} +
+ + {/* Stats row */} +
+ + + {m.activeProjects} projects + + + + {m.openTasks} tasks + + + + {m.billableUtilization}% billable + +
+ + ) + })} +
+ )} +
+ ) +} diff --git a/src/app/api/resource-planning/route.ts b/src/app/api/resource-planning/route.ts new file mode 100644 index 0000000..fb342a3 --- /dev/null +++ b/src/app/api/resource-planning/route.ts @@ -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 }) + } +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 8806420..28a538b 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -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 },