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
@@ -5,6 +5,8 @@ import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { AnimatedNumber } from "~/components/ui/AnimatedNumber.js";
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
function UtilizationBar({ percent }: { percent: number }) {
const barColor =
@@ -71,9 +73,20 @@ function FilterDropdown({ label, children }: { label: string; children: ReactNod
);
}
export function ChargeabilityWidget({ config: _config }: WidgetProps) {
const config = _config as { topN?: number; watchlistThreshold?: number };
const [includeProposed, setIncludeProposed] = useState(false);
export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetProps) {
const config = _config as { topN?: number; watchlistThreshold?: number; chapter?: string; includeProposed?: boolean };
const { chapters } = useWidgetFilterOptions();
const widgetFilters = useMemo<WidgetFilter[]>(
() => [
{ type: "select", key: "chapter", label: "Chapter", options: chapters },
{ type: "toggle", key: "includeProposed", label: "Include Proposed" },
],
[chapters],
);
const includeProposed = !!config.includeProposed;
const chapterFilter = (config.chapter as string) ?? "";
const [showDeparted, setShowDeparted] = useState(false);
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
const [topSort, setTopSort] = useState<TopSortKey>("actual");
@@ -162,7 +175,19 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
const rawWatch = data?.watchlist ?? [];
const month = (data?.month as string) ?? "";
const top = ([...rawTop] as ChargeabilityRow[]).sort((a, b) => {
const filteredTop = useMemo(() => {
const arr = rawTop as ChargeabilityRow[];
if (!chapterFilter) return arr;
return arr.filter((r) => r.chapter === chapterFilter);
}, [rawTop, chapterFilter]);
const filteredWatch = useMemo(() => {
const arr = rawWatch as ChargeabilityRow[];
if (!chapterFilter) return arr;
return arr.filter((r) => r.chapter === chapterFilter);
}, [rawWatch, chapterFilter]);
const top = ([...filteredTop]).sort((a, b) => {
const mult = topDir === "asc" ? 1 : -1;
switch (topSort) {
case "name":
@@ -176,7 +201,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
}
});
const watchlist = ([...rawWatch] as ChargeabilityRow[]).sort((a, b) => {
const watchlist = ([...filteredWatch]).sort((a, b) => {
const mult = watchDir === "asc" ? 1 : -1;
switch (watchSort) {
case "name":
@@ -233,9 +258,10 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
return (
<div className="h-full flex flex-col gap-2 overflow-hidden">
{month && (
<div className="px-1 flex-shrink-0 flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<div className="px-1 flex-shrink-0 flex flex-col gap-2">
<WidgetFilterBar filters={widgetFilters} values={_config} onChange={onConfigChange ?? (() => {})} />
<div className="flex items-center justify-between gap-3">
{month && (
<p className="text-xs text-gray-400 flex items-center gap-1">
Period: {month}
<InfoTooltip
@@ -243,66 +269,56 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
width="w-72"
/>
</p>
<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 and imported TBD planning rows are also counted toward chargeability." />
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<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={showDeparted}
onChange={(event) => setShowDeparted(event.target.checked)}
className="rounded border-gray-300"
/>
Show departed
<InfoTooltip content="When enabled, resources who have left the company are included in the lists." />
</label>
<FilterDropdown label={selectedCountryLabel}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-gray-600">Countries</p>
{selectedCountryIds.length > 0 ? (
<button
type="button"
onClick={() => setSelectedCountryIds([])}
className="text-[11px] text-brand-600 hover:text-brand-700"
>
Clear
</button>
) : null}
</div>
<p className="text-[11px] text-gray-400">
Empty selection means all countries are included.
</p>
<div className="max-h-48 space-y-1 overflow-y-auto pr-1">
{countries.map((country) => (
<label
key={country.id}
className="flex items-center gap-2 text-xs text-gray-700"
>
<input
type="checkbox"
checked={selectedCountryIds.includes(country.id)}
onChange={(event) => toggleCountry(country.id, event.target.checked)}
className="rounded border-gray-300"
/>
<span>{country.name}</span>
</label>
))}
</div>
</div>
</FilterDropdown>
</div>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<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={showDeparted}
onChange={(event) => setShowDeparted(event.target.checked)}
className="rounded border-gray-300"
/>
Show departed
<InfoTooltip content="When enabled, resources who have left the company are included in the lists." />
</label>
<FilterDropdown label={selectedCountryLabel}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-gray-600">Countries</p>
{selectedCountryIds.length > 0 ? (
<button
type="button"
onClick={() => setSelectedCountryIds([])}
className="text-[11px] text-brand-600 hover:text-brand-700"
>
Clear
</button>
) : null}
</div>
<p className="text-[11px] text-gray-400">
Empty selection means all countries are included.
</p>
<div className="max-h-48 space-y-1 overflow-y-auto pr-1">
{countries.map((country) => (
<label
key={country.id}
className="flex items-center gap-2 text-xs text-gray-700"
>
<input
type="checkbox"
checked={selectedCountryIds.includes(country.id)}
onChange={(event) => toggleCountry(country.id, event.target.checked)}
className="rounded border-gray-300"
/>
<span>{country.name}</span>
</label>
))}
</div>
</div>
</FilterDropdown>
</div>
</div>
{/* Top list */}
<section