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,114 @@
"use client";
import { useMemo } from "react";
// ─── Types ──────────────────────────────────────────────────────────────────
export interface WidgetFilter {
type: "search" | "select" | "toggle";
key: string;
label?: string;
placeholder?: string;
options?: { value: string; label: string }[];
}
interface WidgetFilterBarProps {
filters: WidgetFilter[];
values: Record<string, unknown>;
onChange: (update: Record<string, unknown>) => void;
}
// ─── Component ──────────────────────────────────────────────────────────────
export function WidgetFilterBar({ filters, values, onChange }: WidgetFilterBarProps) {
const hasActiveFilters = useMemo(() => {
return filters.some((f) => {
const v = values[f.key];
if (f.type === "toggle") return v === true;
return typeof v === "string" && v.length > 0;
});
}, [filters, values]);
return (
<div className="flex flex-wrap items-center gap-1.5 px-1 pb-2">
{filters.map((filter) => {
switch (filter.type) {
case "search":
return (
<div key={filter.key} className="relative">
<svg
className="absolute left-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-400 dark:text-gray-500 pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
<input
type="text"
value={(values[filter.key] as string) ?? ""}
onChange={(e) => onChange({ [filter.key]: e.target.value })}
placeholder={filter.placeholder ?? "Search..."}
className="pl-6 pr-2 py-1 w-32 text-[11px] border border-gray-200 dark:border-gray-700 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
);
case "select":
return (
<select
key={filter.key}
value={(values[filter.key] as string) ?? ""}
onChange={(e) => onChange({ [filter.key]: e.target.value })}
className="py-1 px-1.5 text-[11px] border border-gray-200 dark:border-gray-700 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-brand-500 min-w-[80px] max-w-[130px]"
title={filter.label}
>
<option value="">{filter.label ?? "All"}</option>
{(filter.options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
case "toggle":
return (
<label
key={filter.key}
className="flex items-center gap-1 text-[11px] text-gray-600 dark:text-gray-400 cursor-pointer select-none"
>
<input
type="checkbox"
checked={!!values[filter.key]}
onChange={(e) => onChange({ [filter.key]: e.target.checked })}
className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500 h-3 w-3"
/>
{filter.label}
</label>
);
default:
return null;
}
})}
{hasActiveFilters && (
<button
type="button"
onClick={() => {
const reset: Record<string, unknown> = {};
for (const f of filters) {
reset[f.key] = f.type === "toggle" ? false : "";
}
onChange(reset);
}}
className="text-[10px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 px-1"
title="Reset filters"
>
Reset
</button>
)}
</div>
);
}
@@ -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 colorClass(pct: number): string {
if (pct > 90) return "bg-red-500";
@@ -16,12 +19,34 @@ function textColorClass(pct: number): string {
return "text-green-700";
}
export function BudgetForecastWidget(_props: WidgetProps) {
export function BudgetForecastWidget({ 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.getBudgetForecast.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">
@@ -36,67 +61,71 @@ export function BudgetForecastWidget(_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 with budgets.
<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 with budgets.
</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 with a defined budget" />
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
Budget Usage <InfoTooltip content="Percentage of total budget consumed by current allocations" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Burn/mo <InfoTooltip content="Monthly burn rate based on currently active allocations" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Exhaustion <InfoTooltip content="Projected date when budget will be fully consumed at the current burn rate" />
</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-[140px] 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 gap-2">
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${colorClass(row.pctUsed)}`}
style={{ width: `${Math.min(row.pctUsed, 100)}%` }}
/>
</div>
<span className={`text-[11px] font-semibold tabular-nums w-10 text-right ${textColorClass(row.pctUsed)}`}>
{row.pctUsed}%
</span>
</div>
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
{row.burnRate > 0
? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC`
: "\u2014"}
</td>
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums">
{row.estimatedExhaustionDate ?? "\u2014"}
</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 with a defined budget" />
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
Budget Usage <InfoTooltip content="Percentage of total budget consumed by current allocations" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Burn/mo <InfoTooltip content="Monthly burn rate based on currently active allocations" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Exhaustion <InfoTooltip content="Projected date when budget will be fully consumed at the current burn rate" />
</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-[140px] 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 gap-2">
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${colorClass(row.pctUsed)}`}
style={{ width: `${Math.min(row.pctUsed, 100)}%` }}
/>
</div>
<span className={`text-[11px] font-semibold tabular-nums w-10 text-right ${textColorClass(row.pctUsed)}`}>
{row.pctUsed}%
</span>
</div>
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
{row.burnRate > 0
? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC`
: "\u2014"}
</td>
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums">
{row.estimatedExhaustionDate ?? "\u2014"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -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
@@ -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>
);
}
@@ -1,15 +1,29 @@
"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";
export function SkillGapWidget(_props: WidgetProps) {
const FILTERS: WidgetFilter[] = [
{ type: "search", key: "search", placeholder: "Search skill..." },
];
export function SkillGapWidget({ config, onConfigChange }: WidgetProps) {
const { data, isLoading } = trpc.dashboard.getSkillGaps.useQuery(
undefined,
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
const search = ((config.search as string) ?? "").toLowerCase();
const rows = useMemo(() => {
const all = data ?? [];
if (!search) return all;
return all.filter((r) => r.skill.toLowerCase().includes(search));
}, [data, search]);
if (isLoading && !data) {
return (
<div className="flex flex-col gap-1 pt-1">
@@ -25,79 +39,83 @@ export function SkillGapWidget(_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 skill gaps detected.
<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 skill gaps detected.
</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">
Skill <InfoTooltip content="Skills required by open demand positions" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Demand <InfoTooltip content="Number of unfilled demand requirements needing this skill" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Supply <InfoTooltip content="Number of active resources with this skill at proficiency 3+" />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
Gap <InfoTooltip content="Supply minus Demand: negative (red) = shortage, positive (green) = surplus" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => {
const isShortage = row.gap < 0;
const isSurplus = row.gap > 0;
return (
<tr key={row.skill} 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-[180px] truncate">
{row.skill}
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
{row.demand}
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
{row.supply}
</td>
<td className="px-3 py-2 text-center">
<span className="inline-flex items-center gap-1.5">
<span
className={`inline-block w-2 h-2 rounded-full ${
isShortage
? "bg-red-500"
: isSurplus
? "bg-green-500"
: "bg-gray-400"
}`}
/>
<span
className={`font-semibold tabular-nums ${
isShortage
? "text-red-700"
: isSurplus
? "text-green-700"
: "text-gray-500"
}`}
>
{row.gap > 0 ? `+${row.gap}` : row.gap}
<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">
Skill <InfoTooltip content="Skills required by open demand positions" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Demand <InfoTooltip content="Number of unfilled demand requirements needing this skill" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Supply <InfoTooltip content="Number of active resources with this skill at proficiency 3+" />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
Gap <InfoTooltip content="Supply minus Demand: negative (red) = shortage, positive (green) = surplus" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => {
const isShortage = row.gap < 0;
const isSurplus = row.gap > 0;
return (
<tr key={row.skill} 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-[180px] truncate">
{row.skill}
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
{row.demand}
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
{row.supply}
</td>
<td className="px-3 py-2 text-center">
<span className="inline-flex items-center gap-1.5">
<span
className={`inline-block w-2 h-2 rounded-full ${
isShortage
? "bg-red-500"
: isSurplus
? "bg-green-500"
: "bg-gray-400"
}`}
/>
<span
className={`font-semibold tabular-nums ${
isShortage
? "text-red-700"
: isSurplus
? "text-green-700"
: "text-gray-500"
}`}
>
{row.gap > 0 ? `+${row.gap}` : row.gap}
</span>
</span>
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -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>
);
}