Files
CapaKraken/apps/web/src/components/dashboard/widgets/ResourceTableWidget.tsx
T
Hartmut ae92923c28 feat: Sprint 1 — Alive Enterprise animation foundation
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>
2026-03-19 00:48:55 +01:00

323 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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> = &gt;85% ·{" "}
<span className="text-red-300">Red</span> = &gt;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>
);
}