ae92923c28
Animation primitives (6 new components): - AnimatedNumber: count-up with easeOutExpo, de-DE locale formatting - ShimmerSkeleton: diagonal gradient sweep replacing animate-pulse - FadeIn: framer-motion viewport-triggered fade + slide - StaggerList/StaggerItem: staggered children entrance - Sparkline: pure SVG inline trend chart with draw-in animation - ProgressRing: animated circular progress with CSS transitions Sidebar & page transitions: - Sliding nav indicator (framer-motion layoutId animation) - Icon frame hover glow (brand-color shadow) - Smooth section collapse/expand (AnimatePresence height animation) - PageTransition wrapper (fade-up on route change) - AnimatedModal component (scale + fade with custom bezier) - Notification badge bounce on count increase Dashboard animations: - StatCards: AnimatedNumber count-up + staggered FadeIn + budget color tinting - WidgetContainer: fade-slide-up on mount - Chargeability: animated percentages + inline utilization bars - ProjectTable/MyProjects: animated numbers + staggered row entrance Shimmer skeletons & table animations: - Replaced animate-pulse across 20+ loading states with shimmer gradient - Staggered row entrance (fadeSlideIn) on Resources, Projects, Allocations tables - hover-lift utility class for subtle card/row elevation on hover - Content-shaped skeletons (avatars, text bars, badges) Light mode surface depth: - Mesh gradient page background (subtle accent-tinted corners) - Enhanced card shadows (two-layer depth) - Sidebar glassmorphism upgrade (bg-white/60, backdrop-blur-2xl, saturate-150) - Toolbar sticky backdrop blur - Enhanced focus ring with brand-color glow Co-Authored-By: claude-flow <ruv@ruv.net>
323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { trpc } from "~/lib/trpc/client.js";
|
||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||
|
||
interface ResourceRow {
|
||
id: string;
|
||
eid: string;
|
||
displayName: string;
|
||
chapter: string | null;
|
||
chargeabilityTarget: number;
|
||
bookingCount: number;
|
||
utilizationPercent: number;
|
||
isOverbooked: boolean;
|
||
}
|
||
|
||
export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
|
||
const chapter = (config.chapter as string) || "";
|
||
const [includeProposed, setIncludeProposed] = useState(false);
|
||
|
||
const now = new Date();
|
||
const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
||
const endDate = new Date(now.getFullYear(), now.getMonth() + 3, 0).toISOString();
|
||
|
||
const { data: resources, isLoading } = trpc.resource.listWithUtilization.useQuery(
|
||
{ chapter: chapter || undefined, includeProposed, startDate, endDate },
|
||
{ staleTime: 60_000 },
|
||
);
|
||
|
||
const { data: chapterData } = trpc.resource.chapters.useQuery(undefined, { staleTime: 120_000 });
|
||
const chapters = chapterData ?? [];
|
||
|
||
type SortKey = "eid" | "name" | "chapter" | "bookings" | "utilization" | "target";
|
||
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||
|
||
function toggleSort(key: SortKey) {
|
||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||
else {
|
||
setSortKey(key);
|
||
setSortDir("asc");
|
||
}
|
||
}
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex flex-col gap-2 pt-1">
|
||
{/* header row */}
|
||
<div className="flex gap-3 px-3 py-2">
|
||
{[40, 120, 80, 60, 60].map((w, i) => (
|
||
<div
|
||
key={i}
|
||
className="h-2.5 shimmer-skeleton rounded"
|
||
style={{ width: w }}
|
||
/>
|
||
))}
|
||
</div>
|
||
{/* data rows */}
|
||
{[...Array(6)].map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex gap-3 px-3 py-2 border-t border-gray-100 dark:border-gray-800"
|
||
>
|
||
<div className="h-3 w-10 shimmer-skeleton rounded font-mono" />
|
||
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
||
<div className="h-3 w-16 shimmer-skeleton rounded" />
|
||
<div className="h-3 w-12 shimmer-skeleton rounded" />
|
||
<div className="h-3 w-10 shimmer-skeleton rounded" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const list = (resources ?? []) as unknown as ResourceRow[];
|
||
|
||
const sorted = [...list].sort((a, b) => {
|
||
const mult = sortDir === "asc" ? 1 : -1;
|
||
switch (sortKey) {
|
||
case "eid":
|
||
return mult * a.eid.localeCompare(b.eid);
|
||
case "name":
|
||
return mult * a.displayName.localeCompare(b.displayName);
|
||
case "chapter":
|
||
return mult * (a.chapter ?? "").localeCompare(b.chapter ?? "");
|
||
case "bookings":
|
||
return mult * (a.bookingCount - b.bookingCount);
|
||
case "utilization":
|
||
return mult * (a.utilizationPercent - b.utilizationPercent);
|
||
case "target":
|
||
return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
|
||
default:
|
||
return 0;
|
||
}
|
||
});
|
||
|
||
return (
|
||
<div className="flex flex-col h-full gap-3">
|
||
{/* Filter */}
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
{chapters.length > 0 && (
|
||
<select
|
||
value={chapter}
|
||
onChange={(e) => onConfigChange?.({ chapter: e.target.value })}
|
||
className="app-select w-44 text-xs"
|
||
>
|
||
<option value="">All Chapters</option>
|
||
{chapters.map((c) => (
|
||
<option key={c} value={c}>
|
||
{c}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={includeProposed}
|
||
onChange={(event) => setIncludeProposed(event.target.checked)}
|
||
className="rounded border-gray-300"
|
||
/>
|
||
Include proposed
|
||
<InfoTooltip content="When enabled, PROPOSED bookings are counted toward booking count and utilization." />
|
||
</label>
|
||
</div>
|
||
|
||
{/* Table */}
|
||
<div className="app-data-table flex-1 overflow-auto">
|
||
<table className="w-full text-xs">
|
||
<thead className="sticky top-0">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||
<span className="inline-flex items-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleSort("eid")}
|
||
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
|
||
>
|
||
EID
|
||
<span className="text-[10px] ml-0.5">
|
||
{sortKey === "eid" ? (
|
||
sortDir === "asc" ? (
|
||
"▲"
|
||
) : (
|
||
"▼"
|
||
)
|
||
) : (
|
||
<span className="text-gray-300">⇅</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
<InfoTooltip content="Employee ID — unique identifier for each resource." />
|
||
</span>
|
||
</th>
|
||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||
<span className="inline-flex items-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleSort("name")}
|
||
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
|
||
>
|
||
Name
|
||
<span className="text-[10px] ml-0.5">
|
||
{sortKey === "name" ? (
|
||
sortDir === "asc" ? (
|
||
"▲"
|
||
) : (
|
||
"▼"
|
||
)
|
||
) : (
|
||
<span className="text-gray-300">⇅</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
<InfoTooltip content="Display name of the resource. Rows highlighted in amber indicate overbooking." />
|
||
</span>
|
||
</th>
|
||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||
<span className="inline-flex items-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleSort("chapter")}
|
||
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
|
||
>
|
||
Chapter
|
||
<span className="text-[10px] ml-0.5">
|
||
{sortKey === "chapter" ? (
|
||
sortDir === "asc" ? (
|
||
"▲"
|
||
) : (
|
||
"▼"
|
||
)
|
||
) : (
|
||
<span className="text-gray-300">⇅</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
<InfoTooltip content="Organizational chapter (team/department) the resource belongs to." />
|
||
</span>
|
||
</th>
|
||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||
<span className="inline-flex items-center justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleSort("bookings")}
|
||
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
|
||
>
|
||
Bookings
|
||
<span className="text-[10px] ml-0.5">
|
||
{sortKey === "bookings" ? (
|
||
sortDir === "asc" ? (
|
||
"▲"
|
||
) : (
|
||
"▼"
|
||
)
|
||
) : (
|
||
<span className="text-gray-300">⇅</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
<InfoTooltip content="Number of non-cancelled assignments in the period. Proposed rows are only counted when the toggle is enabled." />
|
||
</span>
|
||
</th>
|
||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||
<span className="inline-flex items-center justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleSort("utilization")}
|
||
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
|
||
>
|
||
Utilization
|
||
<span className="text-[10px] ml-0.5">
|
||
{sortKey === "utilization" ? (
|
||
sortDir === "asc" ? (
|
||
"▲"
|
||
) : (
|
||
"▼"
|
||
)
|
||
) : (
|
||
<span className="text-gray-300">⇅</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
<InfoTooltip
|
||
content={
|
||
<span>
|
||
Booked hours ÷ available hours × 100 for the period.
|
||
<br />
|
||
Available hours = working days × hours from personal schedule.
|
||
<br />
|
||
Proposed rows are only counted when the toggle is enabled.
|
||
<br />
|
||
<span className="text-orange-300">Orange</span> = >85% ·{" "}
|
||
<span className="text-red-300">Red</span> = >100%
|
||
</span>
|
||
}
|
||
width="w-72"
|
||
/>
|
||
</span>
|
||
</th>
|
||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||
<span className="inline-flex items-center justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleSort("target")}
|
||
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
|
||
>
|
||
Target
|
||
<span className="text-[10px] ml-0.5">
|
||
{sortKey === "target" ? (
|
||
sortDir === "asc" ? (
|
||
"▲"
|
||
) : (
|
||
"▼"
|
||
)
|
||
) : (
|
||
<span className="text-gray-300">⇅</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
<InfoTooltip content="Chargeability target set by management per resource. Not a computed value." />
|
||
</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||
{sorted.map((r) => (
|
||
<tr
|
||
key={r.id}
|
||
className={`transition hover:bg-gray-50 dark:hover:bg-gray-800/60 ${r.isOverbooked ? "bg-amber-50 dark:bg-amber-950/20" : ""}`}
|
||
>
|
||
<td className="px-3 py-2 font-mono text-gray-600 dark:text-gray-300">{r.eid}</td>
|
||
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
|
||
{r.displayName}
|
||
</td>
|
||
<td className="px-3 py-2 text-gray-500 dark:text-gray-400">{r.chapter ?? "—"}</td>
|
||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
|
||
{r.bookingCount}
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
<span
|
||
className={`font-semibold ${r.utilizationPercent > 100 ? "text-red-600" : r.utilizationPercent > 85 ? "text-orange-600" : "text-green-700"}`}
|
||
>
|
||
{r.utilizationPercent}%
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400">
|
||
{r.chargeabilityTarget}%
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{list.length === 0 && (
|
||
<div className="py-8 text-center text-sm text-gray-400">No resources found.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|