Files
CapaKraken/apps/web/src/components/dashboard/widgets/TopValueWidget.tsx
T
Hartmut 208f866d68 feat: shared widget filter system for all dashboard widgets
Shared infrastructure:
- WidgetFilterBar: declarative filter component (search, select, toggle)
- useWidgetFilterOptions: cached hook for clients, countries, roles, chapters

Widget integration (5 widgets):
- ProjectHealth: search (name) + select (client)
- BudgetForecast: search (name) + select (client)
- Chargeability: select (chapter) + toggle (include proposed)
- SkillGap: search (skill name)
- TopValue: select (chapter)

Backend: added clientId/clientName to ProjectHealth and BudgetForecast
query results for client-based filtering.

Filter state persisted via widget config (survives page reload).
All filters use compact 11px inputs with full dark theme support.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-23 09:21:46 +01:00

186 lines
8.2 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 { useMemo, 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";
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
type SortKey = "eid" | "name" | "chapter" | "score" | "lcr";
export function TopValueWidget({ config, onConfigChange }: WidgetProps) {
const limit = (config.limit as number) || 10;
const { chapters } = useWidgetFilterOptions();
const filters = useMemo<WidgetFilter[]>(
() => [
{ type: "select", key: "chapter", label: "Chapter", options: chapters },
],
[chapters],
);
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 },
);
const chapter = (config.chapter as string) ?? "";
const list = useMemo(() => {
const all = (data ?? []) as Array<{ id: string; eid: string; displayName: string; chapter: string | null; lcrCents: number; valueScore: number | null }>;
if (!chapter) return all;
return all.filter((r) => r.chapter === chapter);
}, [data, chapter]);
const sorted = useMemo(() => {
return [...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;
}
});
}, [list, sortKey, sortDir]);
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>
);
}
if (sorted.length === 0) {
return (
<div className="flex flex-col h-full">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="flex flex-col items-center justify-center flex-1 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>
</div>
);
}
function Ind({ k }: { k: SortKey }) {
return sortKey === k
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "\u25B2" : "\u25BC"}</span>
: <span className="text-[10px] ml-0.5 text-gray-300">{"\u21C5"}</span>;
}
return (
<div className="flex flex-col h-full overflow-hidden">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="overflow-auto flex-1">
<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 0100.<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 ({"\u20AC"})<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 ?? "\u2014"}</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 ?? "\u2014"}
</span>
</td>
<td className="px-3 py-2 text-right text-gray-700">{(r.lcrCents / 100).toFixed(0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}