From 607af1a8576ec5662cb27212c1cbc788c71c72f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 13:30:44 +0200 Subject: [PATCH] feat(bench): add Resource Bench Board page Shows resources with available capacity in a selected date window. - Filter by date range (with DateRangePresets), min hours/day slider, and free-text search - Cards show role, chapter, available h/day with color-coded capacity bar - Links to individual resource profiles - "Bench" nav entry added to Resources section in AppShell Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/(app)/bench/page.tsx | 5 + .../src/components/bench/BenchBoardClient.tsx | 187 ++++++++++++++++++ .../components/bench/BenchResourceCard.tsx | 101 ++++++++++ apps/web/src/components/layout/AppShell.tsx | 4 + 4 files changed, 297 insertions(+) create mode 100644 apps/web/src/app/(app)/bench/page.tsx create mode 100644 apps/web/src/components/bench/BenchBoardClient.tsx create mode 100644 apps/web/src/components/bench/BenchResourceCard.tsx diff --git a/apps/web/src/app/(app)/bench/page.tsx b/apps/web/src/app/(app)/bench/page.tsx new file mode 100644 index 0000000..87d996c --- /dev/null +++ b/apps/web/src/app/(app)/bench/page.tsx @@ -0,0 +1,5 @@ +import { BenchBoardClient } from "~/components/bench/BenchBoardClient.js"; + +export default function BenchPage() { + return ; +} diff --git a/apps/web/src/components/bench/BenchBoardClient.tsx b/apps/web/src/components/bench/BenchBoardClient.tsx new file mode 100644 index 0000000..f2fb213 --- /dev/null +++ b/apps/web/src/components/bench/BenchBoardClient.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { trpc } from "~/lib/trpc/client.js"; +import { EmptyState } from "~/components/ui/EmptyState.js"; +import { DateRangePresets } from "~/components/ui/DateRangePresets.js"; +import { BenchResourceCard } from "./BenchResourceCard.js"; + +function isoToday(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function isoOffsetDays(base: string, days: number): string { + const d = new Date(base); + d.setDate(d.getDate() + days); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +export function BenchBoardClient() { + const today = isoToday(); + const [startDate, setStartDate] = useState(today); + const [endDate, setEndDate] = useState(isoOffsetDays(today, 90)); + const [minHoursPerDay, setMinHoursPerDay] = useState(2); + const [roleFilter, setRoleFilter] = useState(""); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, isLoading, refetch } = (trpc.staffing.searchCapacity.useQuery as any)( + { + startDate: new Date(startDate), + endDate: new Date(endDate), + minHoursPerDay, + limit: 100, + }, + { staleTime: 60_000 }, + ) as { + data: { + results: Array<{ + id: string; + name: string; + eid: string; + role: string | null; + chapter: string | null; + workingDays: number; + availableHours: number; + availableHoursPerDay: number; + }>; + totalFound: number; + period: string; + } | undefined; + isLoading: boolean; + refetch: () => void; + }; + + const filtered = useMemo(() => { + if (!data?.results) return []; + const q = roleFilter.trim().toLowerCase(); + if (!q) return data.results; + return data.results.filter( + (r) => + r.role?.toLowerCase().includes(q) || + r.name.toLowerCase().includes(q) || + r.chapter?.toLowerCase().includes(q), + ); + }, [data?.results, roleFilter]); + + return ( +
+
+
+

Bench Board

+

+ Resources with available capacity in the selected window — ready to take on new work. +

+
+
+ + {/* Filter bar */} +
+ { setStartDate(s); setEndDate(e); }} /> +
+
+ + setStartDate(e.target.value)} + className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-2 py-1.5 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-brand-500" + /> +
+
+ + setEndDate(e.target.value)} + className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-2 py-1.5 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-brand-500" + /> +
+
+ + setMinHoursPerDay(Number(e.target.value))} + className="w-28 accent-brand-600" + /> +
+ setRoleFilter(e.target.value)} + placeholder="Filter by role, name, chapter…" + className="flex-1 min-w-32 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-brand-500" + /> + +
+ {data && ( +

+ {filtered.length} resource{filtered.length !== 1 ? "s" : ""} found + {roleFilter ? ` matching "${roleFilter}"` : ""} + {" "}— period: {data.period} +

+ )} +
+ + {/* Results grid */} + {isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) : filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map((resource) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/bench/BenchResourceCard.tsx b/apps/web/src/components/bench/BenchResourceCard.tsx new file mode 100644 index 0000000..d308a1e --- /dev/null +++ b/apps/web/src/components/bench/BenchResourceCard.tsx @@ -0,0 +1,101 @@ +"use client"; + +import Link from "next/link"; + +interface BenchResourceCardProps { + id: string; + name: string; + eid: string; + role: string | null; + chapter: string | null; + availableHours: number; + availableHoursPerDay: number; + workingDays: number; +} + +export function BenchResourceCard({ + id, + name, + eid, + role, + chapter, + availableHours, + availableHoursPerDay, +}: BenchResourceCardProps) { + const initials = name + .split(" ") + .slice(0, 2) + .map((w) => w[0]?.toUpperCase() ?? "") + .join(""); + + const availabilityLevel = + availableHoursPerDay >= 6 + ? "high" + : availableHoursPerDay >= 3 + ? "medium" + : "low"; + + const levelClass = + availabilityLevel === "high" + ? "border-emerald-300 dark:border-emerald-700 bg-emerald-50 dark:bg-emerald-950/20" + : availabilityLevel === "medium" + ? "border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/20" + : "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/20"; + + const barColor = + availabilityLevel === "high" + ? "bg-emerald-500" + : availabilityLevel === "medium" + ? "bg-amber-500" + : "bg-gray-400"; + + const barWidth = Math.min(100, Math.round((availableHoursPerDay / 8) * 100)); + + return ( +
+
+
+ {initials} +
+
+
{name}
+
{eid}
+
+
+ + {(role ?? chapter) && ( +
+ {role && ( + + {role} + + )} + {chapter && ( + + {chapter} + + )} +
+ )} + +
+
+ Available capacity + + {availableHoursPerDay.toFixed(1)}h/day · {availableHours.toFixed(0)}h total + +
+
+
+
+
+ + + View Profile + +
+ ); +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index e6f12df..cf57d7a 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -61,6 +61,9 @@ function MarketplaceIcon() { function ChargeabilityIcon() { return ; } +function BenchIcon() { + return ; +} function ReportBuilderIcon() { return ; } @@ -183,6 +186,7 @@ const navSections: NavSection[] = [ label: "Resources", items: [ { href: "/resources", label: "Resources", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + { href: "/bench", label: "Bench", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/projects", label: "Projects", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, { href: "/roles", label: "Roles", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, ],