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); }} />
+
+ {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 (
+
+
+
+ {(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"] },
],