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>
162 lines
6.9 KiB
TypeScript
162 lines
6.9 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";
|
||
|
||
type SortKey = "eid" | "name" | "chapter" | "score" | "lcr";
|
||
|
||
export function TopValueWidget({ config }: WidgetProps) {
|
||
const limit = (config.limit as number) || 10;
|
||
|
||
const [sortKey, setSortKey] = useState<SortKey>("score");
|
||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||
|
||
function toggleSort(key: SortKey) {
|
||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||
else { setSortKey(key); setSortDir(key === "score" ? "desc" : "asc"); }
|
||
}
|
||
|
||
const { data, isLoading } = trpc.dashboard.getTopValueResources.useQuery(
|
||
{ limit },
|
||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
||
);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex flex-col gap-1 pt-1">
|
||
{[...Array(8)].map((_, i) => (
|
||
<div key={i} className="flex items-center gap-3 px-3 py-2">
|
||
<div className="h-3 w-4 shimmer-skeleton rounded" />
|
||
<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-5 w-10 shimmer-skeleton rounded-full" />
|
||
<div className="h-3 w-12 shimmer-skeleton rounded" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const list = data ?? [];
|
||
|
||
if (list.length === 0) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center h-full text-center py-8 text-gray-400 text-sm">
|
||
<p>No scores computed yet or you lack access.</p>
|
||
<p className="text-xs mt-1">Admins can recompute scores in Settings.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 "score": return mult * ((a.valueScore ?? 0) - (b.valueScore ?? 0));
|
||
case "lcr": return mult * (a.lcrCents - b.lcrCents);
|
||
default: return 0;
|
||
}
|
||
});
|
||
|
||
function Ind({ k }: { k: SortKey }) {
|
||
return sortKey === k
|
||
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "▲" : "▼"}</span>
|
||
: <span className="text-[10px] ml-0.5 text-gray-300">⇅</span>;
|
||
}
|
||
|
||
return (
|
||
<div className="overflow-auto h-full">
|
||
<table className="w-full text-xs">
|
||
<thead className="bg-gray-50 sticky top-0">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left font-medium text-gray-500 w-8">
|
||
<span className="inline-flex items-center">
|
||
#
|
||
<InfoTooltip content="Rank position based on the current sort order." />
|
||
</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("eid")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||
EID<Ind k="eid" />
|
||
</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 hover:text-gray-700 cursor-pointer">
|
||
Name<Ind k="name" />
|
||
</button>
|
||
<InfoTooltip content="Display name of the 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("chapter")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||
Chapter<Ind k="chapter" />
|
||
</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("score")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||
Score<Ind k="score" />
|
||
</button>
|
||
<InfoTooltip
|
||
content={
|
||
<span>
|
||
Composite price/quality score 0–100.<br />
|
||
Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%.<br />
|
||
Recompute in Admin → Settings.
|
||
</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("lcr")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||
LCR (€)<Ind k="lcr" />
|
||
</button>
|
||
<InfoTooltip content="Labour Cost Rate — hourly cost in EUR. Lower LCR = better cost efficiency score." />
|
||
</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{sorted.map((r, i) => (
|
||
<tr key={r.id} className="hover:bg-gray-50">
|
||
<td className="px-3 py-2 text-gray-400 font-medium">{i + 1}</td>
|
||
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
|
||
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
|
||
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "—"}</td>
|
||
<td className="px-3 py-2 text-right">
|
||
<span
|
||
className={`inline-block px-2 py-0.5 rounded-full font-semibold ${
|
||
(r.valueScore ?? 0) >= 70
|
||
? "bg-green-100 text-green-700"
|
||
: (r.valueScore ?? 0) >= 40
|
||
? "bg-amber-100 text-amber-700"
|
||
: "bg-red-100 text-red-700"
|
||
}`}
|
||
>
|
||
{r.valueScore ?? "—"}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2 text-right text-gray-700">{(r.lcrCents / 100).toFixed(0)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|