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,8 +1,11 @@
"use client";
import { useMemo } 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";
function healthDot(value: number): string {
if (value >= 70) return "bg-green-500";
@@ -16,12 +19,34 @@ function scoreBadge(score: number): string {
return "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300";
}
export function ProjectHealthWidget(_props: WidgetProps) {
export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
const { clients } = useWidgetFilterOptions();
const filters = useMemo<WidgetFilter[]>(
() => [
{ type: "search", key: "search", placeholder: "Search project..." },
{ type: "select", key: "clientId", label: "Client", options: clients },
],
[clients],
);
const { data, isLoading } = trpc.dashboard.getProjectHealth.useQuery(
undefined,
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
const search = ((config.search as string) ?? "").toLowerCase();
const clientId = (config.clientId as string) ?? "";
const rows = useMemo(() => {
const all = data ?? [];
return all.filter((r) => {
if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false;
if (clientId && r.clientId !== clientId) return false;
return true;
});
}, [data, search, clientId]);
if (isLoading && !data) {
return (
<div className="flex flex-col gap-1 pt-1">
@@ -41,66 +66,70 @@ export function ProjectHealthWidget(_props: WidgetProps) {
);
}
const rows = data ?? [];
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No active projects found.
<div className="flex flex-col h-full">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="flex items-center justify-center flex-1 text-sm text-gray-400">
No active projects found.
</div>
</div>
);
}
return (
<div className="overflow-auto h-full">
<table className="w-full text-xs">
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
Project <InfoTooltip content="Active projects scored across three health dimensions" />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demands), Timeline health (within end date)" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Score <InfoTooltip content="Composite score: average of Budget, Staffing, and Timeline health (0-100)" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[160px] truncate">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
title={`Budget: ${row.budgetHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
title={`Staffing: ${row.staffingHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
title={`Timeline: ${row.timelineHealth}%`}
/>
</div>
</td>
<td className="px-3 py-2 text-right">
<span
className={`inline-block px-2 py-0.5 rounded-full font-semibold tabular-nums ${scoreBadge(row.compositeScore)}`}
>
{row.compositeScore}
</span>
</td>
<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 dark:bg-gray-800/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
Project <InfoTooltip content="Active projects scored across three health dimensions" />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demands), Timeline health (within end date)" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Score <InfoTooltip content="Composite score: average of Budget, Staffing, and Timeline health (0-100)" />
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[160px] truncate">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
title={`Budget: ${row.budgetHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
title={`Staffing: ${row.staffingHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
title={`Timeline: ${row.timelineHealth}%`}
/>
</div>
</td>
<td className="px-3 py-2 text-right">
<span
className={`inline-block px-2 py-0.5 rounded-full font-semibold tabular-nums ${scoreBadge(row.compositeScore)}`}
>
{row.compositeScore}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}