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() {
|
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>;
|
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() {
|
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>;
|
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",
|
label: "Resources",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/resources", label: "Resources", icon: <ResourcesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
{ 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: "/projects", label: "Projects", icon: <ProjectsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
|
||||||
{ href: "/roles", label: "Roles", icon: <RolesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
{ href: "/roles", label: "Roles", icon: <RolesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user