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
@@ -0,0 +1,59 @@
/**
* Shared hook for loading filter options used across dashboard widgets.
* Loads clients, countries, roles, and chapters once with long cache TTL.
*/
"use client";
import { useMemo } from "react";
import { trpc } from "~/lib/trpc/client.js";
export interface FilterOption {
value: string;
label: string;
}
export function useWidgetFilterOptions() {
const { data: clientsRaw } = trpc.clientEntity.list.useQuery(
{ isActive: true },
{ staleTime: 300_000 },
);
const { data: countriesRaw } = trpc.country.list.useQuery(
{ isActive: true },
{ staleTime: 300_000 },
);
const { data: rolesRaw } = trpc.role.list.useQuery(
{ isActive: true },
{ staleTime: 300_000 },
);
const clients = useMemo<FilterOption[]>(() => {
const list = (Array.isArray(clientsRaw) ? clientsRaw : (clientsRaw as any)?.clients ?? []) as Array<{ id: string; name: string }>;
return list.map((c) => ({ value: c.id, label: c.name }));
}, [clientsRaw]);
const countries = useMemo<FilterOption[]>(() => {
const list = (Array.isArray(countriesRaw) ? countriesRaw : []) as Array<{ id: string; name: string }>;
return list.map((c) => ({ value: c.id, label: c.name }));
}, [countriesRaw]);
const roles = useMemo<FilterOption[]>(() => {
const list = (Array.isArray(rolesRaw) ? rolesRaw : []) as Array<{ id: string; name: string }>;
return list.map((r) => ({ value: r.id, label: r.name }));
}, [rolesRaw]);
// Chapters are derived from roles or can be hardcoded common ones
const chapters = useMemo<FilterOption[]>(() => {
const common = [
"Digital Content Production",
"Project Management",
"Art Direction",
"CGI-Dev",
"Product Data Management",
];
return common.map((c) => ({ value: c, label: c }));
}, []);
return { clients, countries, roles, chapters };
}