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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user