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:
@@ -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"] },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user