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>
This commit is contained in:
2026-03-23 09:21:46 +01:00
parent 47b2aeec72
commit 208f866d68
10 changed files with 771 additions and 462 deletions
@@ -1,14 +1,24 @@
"use client";
import { useState } from "react";
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 }: WidgetProps) {
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");
@@ -23,6 +33,28 @@ export function TopValueWidget({ config }: WidgetProps) {
{ 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">
@@ -40,122 +72,114 @@ export function TopValueWidget({ config }: WidgetProps) {
);
}
const list = (data ?? []) as Array<{ id: string; eid: string; displayName: string; chapter: string | null; lcrCents: number; valueScore: number | null }>;
if (list.length === 0) {
if (sorted.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 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>
);
}
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>;
? <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="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 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 ()<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 ?? "—"}
<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>
</td>
<td className="px-3 py-2 text-right text-gray-700">{(r.lcrCents / 100).toFixed(0)}</td>
</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>
))}
</tbody>
</table>
</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>
);
}