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 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 13:30:44 +02:00
parent 1df208dbcc
commit 607af1a857
4 changed files with 297 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
import { BenchBoardClient } from "~/components/bench/BenchBoardClient.js";
export default function BenchPage() {
return <BenchBoardClient />;
}
@@ -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 (
<div className="app-page space-y-6">
<div className="app-page-header">
<div>
<h1 className="app-page-title">Bench Board</h1>
<p className="app-page-subtitle mt-1">
Resources with available capacity in the selected window ready to take on new work.
</p>
</div>
</div>
{/* Filter bar */}
<div className="app-surface p-4 space-y-3">
<DateRangePresets onSelect={(s, e) => { setStartDate(s); setEndDate(e); }} />
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-600 dark:text-gray-400 whitespace-nowrap">
From
</label>
<input
type="date"
value={startDate}
onChange={(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"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-600 dark:text-gray-400 whitespace-nowrap">
To
</label>
<input
type="date"
value={endDate}
min={startDate}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-600 dark:text-gray-400 whitespace-nowrap">
Min. {minHoursPerDay}h/day
</label>
<input
type="range"
min={0.5}
max={8}
step={0.5}
value={minHoursPerDay}
onChange={(e) => setMinHoursPerDay(Number(e.target.value))}
className="w-28 accent-brand-600"
/>
</div>
<input
type="text"
value={roleFilter}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => refetch()}
className="rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Refresh
</button>
</div>
{data && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{filtered.length} resource{filtered.length !== 1 ? "s" : ""} found
{roleFilter ? ` matching "${roleFilter}"` : ""}
{" "} period: {data.period}
</p>
)}
</div>
{/* Results grid */}
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-3">
<div className="flex items-center gap-3">
<div className="h-10 w-10 shimmer-skeleton rounded-full" />
<div className="flex-1 space-y-1.5">
<div className="h-3.5 w-32 shimmer-skeleton rounded" />
<div className="h-2.5 w-16 shimmer-skeleton rounded" />
</div>
</div>
<div className="h-2 shimmer-skeleton rounded-full" />
</div>
))}
</div>
) : filtered.length === 0 ? (
<EmptyState
title="No resources on bench"
detail={
roleFilter
? "No resources match your filter. Try a different role or name."
: "Everyone is fully booked in this period. Try a wider date range or lower the minimum hours threshold."
}
/>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((resource) => (
<BenchResourceCard
key={resource.id}
id={resource.id}
name={resource.name}
eid={resource.eid}
role={resource.role}
chapter={resource.chapter}
workingDays={resource.workingDays}
availableHours={resource.availableHours}
availableHoursPerDay={resource.availableHoursPerDay}
/>
))}
</div>
)}
</div>
);
}
@@ -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 (
<div className={`rounded-xl border p-4 space-y-3 ${levelClass}`}>
<div className="flex items-start gap-3">
<div className="h-10 w-10 shrink-0 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center">
<span className="text-sm font-semibold text-brand-700 dark:text-brand-300">{initials}</span>
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">{name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{eid}</div>
</div>
</div>
{(role ?? chapter) && (
<div className="flex flex-wrap gap-1.5">
{role && (
<span className="rounded-full bg-brand-100 dark:bg-brand-900/40 px-2 py-0.5 text-[11px] font-medium text-brand-700 dark:text-brand-300">
{role}
</span>
)}
{chapter && (
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2 py-0.5 text-[11px] text-gray-600 dark:text-gray-400">
{chapter}
</span>
)}
</div>
)}
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-500 dark:text-gray-400">Available capacity</span>
<span className="font-semibold text-gray-800 dark:text-gray-200">
{availableHoursPerDay.toFixed(1)}h/day · {availableHours.toFixed(0)}h total
</span>
</div>
<div className="h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${barWidth}%` }} />
</div>
</div>
<Link
href={`/resources/${id}`}
className="block w-full rounded-lg border border-gray-200 dark:border-gray-700 py-1.5 text-center text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
View Profile
</Link>
</div>
);
}
@@ -61,6 +61,9 @@ function MarketplaceIcon() {
function ChargeabilityIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M5 17l4-4 3 3 7-8M19 19H5V5" /></svg>;
}
function BenchIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M20 7H4a1 1 0 00-1 1v2h18V8a1 1 0 00-1-1zM3 10v4h18v-4M5 14v3m14-3v3M8 17h8" /></svg>;
}
function ReportBuilderIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>;
}
@@ -183,6 +186,7 @@ const navSections: NavSection[] = [
label: "Resources",
items: [
{ href: "/resources", label: "Resources", icon: <ResourcesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/bench", label: "Bench", icon: <BenchIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/projects", label: "Projects", icon: <ProjectsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/roles", label: "Roles", icon: <RolesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
],