From 208f866d6832455ea8945558150de09a425f12c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 23 Mar 2026 09:21:46 +0100 Subject: [PATCH] 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 --- .../components/dashboard/WidgetFilterBar.tsx | 114 +++++++++ .../widgets/BudgetForecastWidget.tsx | 137 ++++++---- .../dashboard/widgets/ChargeabilityWidget.tsx | 150 ++++++----- .../dashboard/widgets/ProjectHealthWidget.tsx | 135 ++++++---- .../dashboard/widgets/SkillGapWidget.tsx | 150 ++++++----- .../dashboard/widgets/TopValueWidget.tsx | 236 +++++++++-------- apps/web/src/hooks/useWidgetFilterOptions.ts | 59 +++++ .../dashboard/get-budget-forecast.ts | 6 + .../use-cases/dashboard/get-project-health.ts | 6 + plan.md | 240 +++++++++--------- 10 files changed, 771 insertions(+), 462 deletions(-) create mode 100644 apps/web/src/components/dashboard/WidgetFilterBar.tsx create mode 100644 apps/web/src/hooks/useWidgetFilterOptions.ts diff --git a/apps/web/src/components/dashboard/WidgetFilterBar.tsx b/apps/web/src/components/dashboard/WidgetFilterBar.tsx new file mode 100644 index 0000000..6eba3cf --- /dev/null +++ b/apps/web/src/components/dashboard/WidgetFilterBar.tsx @@ -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; + onChange: (update: Record) => 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 ( +
+ {filters.map((filter) => { + switch (filter.type) { + case "search": + return ( +
+ + + + 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" + /> +
+ ); + + case "select": + return ( + + ); + + case "toggle": + return ( + + ); + + default: + return null; + } + })} + + {hasActiveFilters && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx b/apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx index dd0367b..844d38b 100644 --- a/apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx @@ -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( + () => [ + { 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 (
@@ -36,67 +61,71 @@ export function BudgetForecastWidget(_props: WidgetProps) { ); } - const rows = data ?? []; - if (rows.length === 0) { return ( -
- No active projects with budgets. +
+ {})} /> +
+ No active projects with budgets. +
); } return ( -
- - - - - - - - - - - {rows.map((row) => ( - - - - - +
+ {})} /> +
+
- Project - - Budget Usage - - Burn/mo - - Exhaustion -
- {row.shortCode} - {row.projectName} - -
-
-
-
- - {row.pctUsed}% - -
-
- {row.burnRate > 0 - ? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC` - : "\u2014"} - - {row.estimatedExhaustionDate ?? "\u2014"} -
+ + + + + + - ))} - -
+ Project + + Budget Usage + + Burn/mo + + Exhaustion +
+ + + {rows.map((row) => ( + + + {row.shortCode} + {row.projectName} + + +
+
+
+
+ + {row.pctUsed}% + +
+ + + {row.burnRate > 0 + ? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC` + : "\u2014"} + + + {row.estimatedExhaustionDate ?? "\u2014"} + + + ))} + + +
); } diff --git a/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx b/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx index c418e47..bfbd638 100644 --- a/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx @@ -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( + () => [ + { 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([]); const [topSort, setTopSort] = useState("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 (
- {month && ( -
-
+
+ {})} /> +
+ {month && (

Period: {month}

- -
-
- - -
-
-

Countries

- {selectedCountryIds.length > 0 ? ( - - ) : null} -
-

- Empty selection means all countries are included. -

-
- {countries.map((country) => ( - - ))} -
-
-
-
+ )}
- )} +
+ + +
+
+

Countries

+ {selectedCountryIds.length > 0 ? ( + + ) : null} +
+

+ Empty selection means all countries are included. +

+
+ {countries.map((country) => ( + + ))} +
+
+
+
+
{/* Top list */}
= 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( + () => [ + { 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 (
@@ -41,66 +66,70 @@ export function ProjectHealthWidget(_props: WidgetProps) { ); } - const rows = data ?? []; - if (rows.length === 0) { return ( -
- No active projects found. +
+ {})} /> +
+ No active projects found. +
); } return ( -
- - - - - - - - - - {rows.map((row) => ( - - - - +
+ {})} /> +
+
- Project - - B / S / T - - Score -
- {row.shortCode} - {row.projectName} - -
- - - -
-
- - {row.compositeScore} - -
+ + + + + - ))} - -
+ Project + + B / S / T + + Score +
+ + + {rows.map((row) => ( + + + {row.shortCode} + {row.projectName} + + +
+ + + +
+ + + + {row.compositeScore} + + + + ))} + + +
); } diff --git a/apps/web/src/components/dashboard/widgets/SkillGapWidget.tsx b/apps/web/src/components/dashboard/widgets/SkillGapWidget.tsx index 6af5408..1061497 100644 --- a/apps/web/src/components/dashboard/widgets/SkillGapWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/SkillGapWidget.tsx @@ -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 (
@@ -25,79 +39,83 @@ export function SkillGapWidget(_props: WidgetProps) { ); } - const rows = data ?? []; - if (rows.length === 0) { return ( -
- No skill gaps detected. +
+ {})} /> +
+ No skill gaps detected. +
); } return ( -
- - - - - - - - - - - {rows.map((row) => { - const isShortage = row.gap < 0; - const isSurplus = row.gap > 0; - return ( - - - - - + + ); + })} + +
- Skill - - Demand - - Supply - - Gap -
- {row.skill} - - {row.demand} - - {row.supply} - - - - - {row.gap > 0 ? `+${row.gap}` : row.gap} +
+ {})} /> +
+ + + + + + + + + + + {rows.map((row) => { + const isShortage = row.gap < 0; + const isSurplus = row.gap > 0; + return ( + + + + + - - ); - })} - -
+ Skill + + Demand + + Supply + + Gap +
+ {row.skill} + + {row.demand} + + {row.supply} + + + + + {row.gap > 0 ? `+${row.gap}` : row.gap} + - -
+
+
); } diff --git a/apps/web/src/components/dashboard/widgets/TopValueWidget.tsx b/apps/web/src/components/dashboard/widgets/TopValueWidget.tsx index 395091c..241a634 100644 --- a/apps/web/src/components/dashboard/widgets/TopValueWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/TopValueWidget.tsx @@ -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( + () => [ + { type: "select", key: "chapter", label: "Chapter", options: chapters }, + ], + [chapters], + ); const [sortKey, setSortKey] = useState("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 (
@@ -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 ( -
-

No scores computed yet or you lack access.

-

Admins can recompute scores in Settings.

+
+ {})} /> +
+

No scores computed yet or you lack access.

+

Admins can recompute scores in Settings.

+
); } - 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 - ? {sortDir === "asc" ? "▲" : "▼"} - : ; + ? {sortDir === "asc" ? "\u25B2" : "\u25BC"} + : {"\u21C5"}; } return ( -
- - - - - - - - - - - - - {sorted.map((r, i) => ( - - - - - - + {sorted.map((r, i) => ( + + + + + + + + + ))} + +
- - # - - - - - - - - - - - - - - - - - - - - - - Composite price/quality score 0–100.
- Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%.
- Recompute in Admin → Settings. -
- } - width="w-72" - /> - -
- - - - -
{i + 1}{r.eid}{r.displayName}{r.chapter ?? "—"} - = 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 ?? "—"} +
+ {})} /> +
+ + + + + + + + + + - ))} - -
+ + # + - - {(r.lcrCents / 100).toFixed(0)} + + + + + + + + + + + + + + + + + + + Composite price/quality score 0–100.
+ Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%.
+ Recompute in Admin → Settings. +
+ } + width="w-72" + /> + +
+ + + + +
+ +
{i + 1}{r.eid}{r.displayName}{r.chapter ?? "\u2014"} + = 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"} + + {(r.lcrCents / 100).toFixed(0)}
+
); } diff --git a/apps/web/src/hooks/useWidgetFilterOptions.ts b/apps/web/src/hooks/useWidgetFilterOptions.ts new file mode 100644 index 0000000..3fd751a --- /dev/null +++ b/apps/web/src/hooks/useWidgetFilterOptions.ts @@ -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(() => { + 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(() => { + 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(() => { + 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(() => { + 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 }; +} diff --git a/packages/application/src/use-cases/dashboard/get-budget-forecast.ts b/packages/application/src/use-cases/dashboard/get-budget-forecast.ts index fdd1814..6d43620 100644 --- a/packages/application/src/use-cases/dashboard/get-budget-forecast.ts +++ b/packages/application/src/use-cases/dashboard/get-budget-forecast.ts @@ -4,6 +4,8 @@ import { calculateInclusiveDays, MILLISECONDS_PER_DAY } from "./shared.js"; export interface BudgetForecastRow { projectName: string; shortCode: string; + clientId: string | null; + clientName: string | null; budgetCents: number; spentCents: number; burnRate: number; @@ -23,6 +25,8 @@ export async function getDashboardBudgetForecast( budgetCents: true, startDate: true, endDate: true, + clientId: true, + client: { select: { name: true } }, }, }); @@ -86,6 +90,8 @@ export async function getDashboardBudgetForecast( return { projectName: p.name, shortCode: p.shortCode, + clientId: p.clientId, + clientName: p.client?.name ?? null, budgetCents: p.budgetCents, spentCents, burnRate, diff --git a/packages/application/src/use-cases/dashboard/get-project-health.ts b/packages/application/src/use-cases/dashboard/get-project-health.ts index 9169230..efd90d4 100644 --- a/packages/application/src/use-cases/dashboard/get-project-health.ts +++ b/packages/application/src/use-cases/dashboard/get-project-health.ts @@ -4,6 +4,8 @@ import { calculateInclusiveDays } from "./shared.js"; export interface ProjectHealthRow { projectName: string; shortCode: string; + clientId: string | null; + clientName: string | null; budgetHealth: number; staffingHealth: number; timelineHealth: number; @@ -21,6 +23,8 @@ export async function getDashboardProjectHealth( shortCode: true, budgetCents: true, endDate: true, + clientId: true, + client: { select: { name: true } }, demandRequirements: { select: { id: true, @@ -92,6 +96,8 @@ export async function getDashboardProjectHealth( return { projectName: p.name, shortCode: p.shortCode, + clientId: p.clientId, + clientName: p.client?.name ?? null, budgetHealth, staffingHealth, timelineHealth, diff --git a/plan.md b/plan.md index bd545e0..566f7c8 100644 --- a/plan.md +++ b/plan.md @@ -1,17 +1,61 @@ -# Duplicate Assignment Prevention — Plan +# Dashboard Widget Filter System — Plan ## Anforderungsanalyse -**Problem:** Ressourcen koennen demselben Projekt mehrfach zugewiesen werden mit ueberlappenden Zeitraeumen. Beispiel: Wong Wong ist zweimal fuer "Porsche Taycan Sport Film" am 15. April eingetragen. +**Was:** Einheitliches Filter-System fuer alle Dashboard-Widgets. Filter-Logik soll geteilt werden statt pro Widget dupliziert. -**Ursache:** Weder die Application-Layer-Funktionen (`createAssignment`, `fillDemandRequirement`) noch die API-Router pruefen, ob dieselbe Resource bereits eine aktive Zuweisung zum selben Projekt im selben Zeitraum hat. Die bestehende `validateAvailability` prueft nur die Gesamt-Stunden (Overbooking), nicht Projekt-Duplikate. +**Anforderungen pro Widget:** +| Widget | Filter benoetigt | +|--------|-----------------| +| **Project Health** | Projektname (Suche), Client | +| **Budget Forecast** | Projektname (Suche), Client | +| **Chargeability Overview** | Country, Role/Chapter | +| **Skill Gap** | (optional: Skill-Suche) | +| **Resource Table** | Hat bereits: Chapter-Filter | +| **Project Table** | Hat bereits: Suche + Status-Filter | +| **Peak Times** | Hat bereits: Granularity + GroupBy | -**Loesung:** Duplicate-Check an **3 Stellen** einfuegen (defense-in-depth): -1. **Application Layer** — `checkDuplicateAssignment()` Funktion im Engine-Paket -2. **API Layer** — Validierung in den Mutations vor dem Create -3. **AI Assistant** — `create_allocation` und `fill_demand` Tools pruefen vor Ausfuehrung +**Design-Prinzip:** Ein shared `` Komponente die verschiedene Filter-Typen als deklarative Config akzeptiert. Filter-State wird via `onConfigChange` im Widget-Config persistiert (bereits vorhanden). -**Scope:** Betrifft `packages/engine`, `packages/application`, `packages/api`, UI (Warnmeldung). +--- + +## Architektur + +### Shared Filter Component + +```tsx +// Deklarative Filter-Konfiguration pro Widget +const filters: WidgetFilter[] = [ + { type: "search", key: "search", placeholder: "Filter projects..." }, + { type: "select", key: "clientId", label: "Client", options: clients }, + { type: "select", key: "countryId", label: "Country", options: countries }, + { type: "select", key: "roleId", label: "Role", options: roles }, +]; + + +``` + +### Filter-Typen + +| Typ | UI-Element | Use Cases | +|-----|-----------|-----------| +| `search` | Text-Input mit Lupe | Projektname, Ressourcenname | +| `select` | Dropdown | Client, Country, Role, Status | +| `toggle` | Checkbox | Include Proposed, Show Inactive | + +### Daten-Flow + +``` +Widget Config (localStorage) + ↓ values +WidgetFilterBar → onChange → onConfigChange → persistiert + ↓ values +Widget Query (tRPC) → gefilterte Daten +``` --- @@ -19,150 +63,114 @@ | Paket | Dateien | Art der Aenderung | |-------|---------|------------------| -| `packages/engine` | `src/allocation/duplicate-check.ts` | **create** — Pure Funktion `checkDuplicateAssignment()` | -| `packages/engine` | `src/index.ts` | **edit** — Export hinzufuegen | -| `packages/application` | `src/use-cases/allocation/create-assignment.ts` | **edit** — Duplicate-Check vor DB-Write | -| `packages/application` | `src/use-cases/allocation/fill-demand-requirement.ts` | **edit** — Duplicate-Check vor DB-Write | -| `packages/api` | `src/router/allocation.ts` | **edit** — Duplicate-Check in `create`, `createAssignment` Mutations | -| `packages/api` | `src/router/assistant-tools.ts` | **edit** — Check in `create_allocation`, `fill_demand` Tools | -| `packages/api` | `src/router/timeline.ts` | **edit** — Check in `batchShiftAllocations` (falls Shift Duplikat erzeugt) | -| `apps/web` | `src/components/allocations/AllocationModal.tsx` | **edit** — Warning anzeigen wenn Duplikat erkannt | -| `apps/web` | `src/components/staffing/StaffingPanel.tsx` | **edit** — Warning im Assign-Formular | -| `packages/engine` | `src/__tests__/duplicate-check.test.ts` | **create** — Unit Tests | +| `apps/web` | `src/components/dashboard/WidgetFilterBar.tsx` | **create** — Shared Filter-Komponente | +| `apps/web` | `src/hooks/useWidgetFilterOptions.ts` | **create** — Hook fuer gemeinsame Filter-Optionen (clients, countries, roles) | +| `apps/web` | `src/components/dashboard/widgets/ProjectHealthWidget.tsx` | **edit** — Filter integrieren | +| `apps/web` | `src/components/dashboard/widgets/BudgetForecastWidget.tsx` | **edit** — Filter integrieren | +| `apps/web` | `src/components/dashboard/widgets/ChargeabilityWidget.tsx` | **edit** — Filter integrieren | +| `apps/web` | `src/components/dashboard/widgets/SkillGapWidget.tsx` | **edit** — Optional: Skill-Suche | +| `apps/web` | `src/components/dashboard/widgets/DemandWidget.tsx` | **edit** — Optional: Client/Chapter Filter | +| `apps/web` | `src/components/dashboard/widgets/TopValueWidget.tsx` | **edit** — Optional: Chapter Filter | --- ## Task-Liste -### Phase 1: Engine — Pure Duplicate-Check Funktion +### Phase 1: Shared Infrastructure -- [ ] **Task 1:** Duplicate-Check Funktion erstellen → `packages/engine/src/allocation/duplicate-check.ts` +- [ ] **Task 1:** `useWidgetFilterOptions` Hook erstellen → `src/hooks/useWidgetFilterOptions.ts` + - Cached Queries fuer Clients, Countries, Roles (alle mit `staleTime: 300_000`) + - Gibt `{ clients, countries, roles, chapters }` als `{ value: string, label: string }[]` zurueck + - Chapters extrahiert aus Resource-Daten oder als dedizierte Query + - Nur einmal pro Dashboard geladen, von allen Widgets geteilt + +- [ ] **Task 2:** `WidgetFilterBar` Komponente erstellen → `src/components/dashboard/WidgetFilterBar.tsx` ```typescript - interface ExistingAssignment { - id: string; - resourceId: string; - projectId: string; - startDate: Date; - endDate: Date; - status: string; // nur CONFIRMED, ACTIVE, PROPOSED zaehlen + interface WidgetFilter { + type: "search" | "select" | "toggle"; + key: string; // Config-Schluessel (z.B. "clientId") + label?: string; // Display-Label + placeholder?: string; // Fuer search/select + options?: { value: string; label: string }[]; // Fuer select } - interface DuplicateCheckResult { - isDuplicate: boolean; - conflictingAssignment?: ExistingAssignment; - message?: string; // z.B. "Resource Wong Wong is already assigned to Porsche Taycan (2026-03-01 to 2026-06-30)" + interface WidgetFilterBarProps { + filters: WidgetFilter[]; + values: Record; + onChange: (update: Record) => void; } - - export function checkDuplicateAssignment( - resourceId: string, - projectId: string, - startDate: Date, - endDate: Date, - existingAssignments: ExistingAssignment[], - excludeAssignmentId?: string, // fuer Updates: eigene ID ausschliessen - ): DuplicateCheckResult ``` - - Prueft: Gibt es in `existingAssignments` eine Zuweisung mit **gleicher resourceId + gleicher projectId** deren Zeitraum sich mit [startDate, endDate] ueberschneidet? - - Ignoriert: CANCELLED Status, eigene ID (bei Updates) - - Overlap-Logik: `existingStart <= newEnd && existingEnd >= newStart` + - Kompaktes Layout: horizontal, passt in Widget-Header-Bereich + - Kleine Inputs (text-xs, py-1) damit sie nicht zu viel Platz nehmen + - "Reset" Button wenn Filter aktiv + - Dark-Theme Support -- [ ] **Task 2:** Unit Tests → `packages/engine/src/__tests__/duplicate-check.test.ts` - - Kein Duplikat: verschiedene Projekte - - Kein Duplikat: gleicher Projekt, aber nicht ueberlappend (vor/nach) - - Duplikat: gleicher Projekt, vollstaendig ueberlappend - - Duplikat: gleicher Projekt, teilweise ueberlappend - - Kein Duplikat: gleicher Projekt, aber CANCELLED - - Kein Duplikat: Update der eigenen Zuweisung (excludeAssignmentId) +### Phase 2: Widget Integration (parallel) -- [ ] **Task 3:** Export → `packages/engine/src/index.ts` +- [ ] **Task 3:** ProjectHealthWidget + Filter → `ProjectHealthWidget.tsx` + - Filter: `search` (Projektname), `select` (Client) + - Client-Daten laden via useWidgetFilterOptions + - Filtern: `rows.filter(r => matchesSearch && matchesClient)` + - Filter-State via `config.search`, `config.clientId` -### Phase 2: Application Layer — Integration in Create-Flows +- [ ] **Task 4:** BudgetForecastWidget + Filter → `BudgetForecastWidget.tsx` + - Gleiche Filter wie ProjectHealth: search + clientId + - Gleiche Logik, gleiche WidgetFilterBar Config -- [ ] **Task 4:** `createAssignment` erweitern → `packages/application/src/use-cases/allocation/create-assignment.ts` - - Nach dem Laden von `existingBookings` (Zeile 101-106): `checkDuplicateAssignment()` aufrufen - - Bei `isDuplicate: true`: `throw new TRPCError({ code: "CONFLICT", message: result.message })` - - Bestehende Bookings bereits vorhanden — nur filtern auf gleichen `projectId` +- [ ] **Task 5:** ChargeabilityWidget + Filter → `ChargeabilityWidget.tsx` + - Filter: `select` (Country), `select` (Role/Chapter) + - Country/Role-Daten via useWidgetFilterOptions + - Filtern ueber die Resource-Daten in der Chargeability-Antwort + - `toggle` (Include Proposed) — bereits vorhanden, in WidgetFilterBar integrieren -- [ ] **Task 5:** `fillDemandRequirement` erweitern → `packages/application/src/use-cases/allocation/fill-demand-requirement.ts` - - Vor dem Assignment-Create: gleicher Check - - DemandRequirement hat bereits `projectId` — diesen nutzen +- [ ] **Task 6:** SkillGapWidget + Filter → `SkillGapWidget.tsx` + - Filter: `search` (Skill-Name) + - Einfache client-seitige Filterung der Skill-Liste -### Phase 3: API + AI Assistant - -- [ ] **Task 6:** AI Assistant Tools erweitern → `packages/api/src/router/assistant-tools.ts` - - `create_allocation` Tool: Vor `createAssignment` Call, bestehende Assignments pruefen - - `fill_demand` Tool: Gleicher Check - - Bei Duplikat: Tool gibt klare Fehlermeldung zurueck statt Exception: - `"Cannot assign: Wong Wong is already assigned to Porsche Taycan Sport Film from 2026-01-15 to 2026-06-30. Use update_allocation_status to modify the existing assignment instead."` - -### Phase 4: UI Warnungen - -- [ ] **Task 7:** AllocationModal Warning → `apps/web/src/components/allocations/AllocationModal.tsx` - - Wenn User Resource + Project + Dates auswaehlt: pruefen ob Duplikat existiert - - Query: `trpc.allocation.listView({ projectId })` — bereits geladen - - Gelbe Warning-Box: "This resource is already assigned to this project from X to Y" - - Submit-Button nicht blockieren (Warning, nicht Error) — User kann bewusst doppelt buchen - -- [ ] **Task 8:** StaffingPanel Assign Warning → `apps/web/src/components/staffing/StaffingPanel.tsx` - - Im AssignForm: nach Project-Auswahl pruefen ob Resource bereits dort zugewiesen - - Gleiche Warning-Box wie AllocationModal - -### Phase 5: Bereinigung bestehender Duplikate - -- [ ] **Task 9:** Cleanup-Script → `packages/db/scripts/deduplicate-assignments.ts` - - Findet alle Duplikate: gleiche resourceId + projectId mit ueberlappenden Dates - - Merged sie: behaelt die aeltere Zuweisung, entfernt die neuere (oder merged Zeitraeume) - - Dry-run Modus: zeigt was geaendert wuerde ohne zu aendern - - Kann via `pnpm --filter @planarchy/db exec tsx scripts/deduplicate-assignments.ts` ausgefuehrt werden +- [ ] **Task 7:** TopValueWidget + Filter → `TopValueWidget.tsx` + - Filter: `select` (Chapter) — bereits sortierbar, Chapter-Filter hinzufuegen --- ## Abhaengigkeiten ``` -Task 1 (Engine Funktion) → Task 2 (Tests) → Task 3 (Export) - ↘ -Task 3 → Task 4 + Task 5 (parallel, Application Layer) -Task 3 → Task 6 (AI Assistant) -Task 3 → Task 7 + Task 8 (parallel, UI Warnungen) -Task 9 (Cleanup) ist unabhaengig, kann jederzeit ausgefuehrt werden +Task 1 (Hook) + Task 2 (WidgetFilterBar) → koennen parallel +Task 1+2 → Tasks 3-7 (alle parallel, verschiedene Dateien) ``` -- Tasks 4+5 koennen **parallel** (verschiedene Dateien) -- Tasks 6, 7, 8 koennen **parallel** (verschiedene Dateien) -- Task 9 sollte **nach** den anderen Tasks laufen (damit neue Duplikate verhindert werden) +- Tasks 3-7 sind **vollstaendig parallel** (verschiedene Widget-Dateien) +- Tasks 1+2 muessen zuerst (shared Infrastructure) --- ## Akzeptanzkriterien -- [ ] `pnpm test:unit` laeuft gruen (inkl. neue duplicate-check Tests) - [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors -- [ ] **API:** `createAssignment` wirft CONFLICT wenn Resource bereits zum gleichen Projekt zugewiesen -- [ ] **API:** `fillDemandRequirement` wirft CONFLICT bei Duplikat -- [ ] **AI Assistant:** `create_allocation` gibt klare Fehlermeldung bei Duplikat -- [ ] **AI Assistant:** `fill_demand` gibt klare Fehlermeldung bei Duplikat -- [ ] **UI:** AllocationModal zeigt gelbe Warning bei erkanntem Duplikat -- [ ] **UI:** StaffingPanel AssignForm zeigt Warning bei Duplikat -- [ ] **Cleanup:** Bestehende Duplikate in der DB bereinigt -- [ ] **Timeline:** Wong Wong hat keine doppelten Strips mehr am 15. April +- [ ] **ProjectHealth** filterbar nach Projektname + Client +- [ ] **BudgetForecast** filterbar nach Projektname + Client +- [ ] **Chargeability** filterbar nach Country + Role +- [ ] **SkillGap** filterbar nach Skill-Name +- [ ] **TopValue** filterbar nach Chapter +- [ ] Filter-State wird in Widget-Config persistiert (bleibt nach Reload) +- [ ] Reset-Button setzt alle Filter zurueck +- [ ] Dark-Theme funktioniert fuer alle Filter +- [ ] Filter-Optionen werden gecacht (nicht pro Widget neu geladen) --- ## Risiken & offene Fragen ### Risiken -- **False Positives:** Legitime Doppelbuchungen (z.B. verschiedene Rollen auf demselben Projekt) werden blockiert - → Mitigation: Warning im UI, Error nur im API — User kann override-en, AI Assistant gibt Hinweis -- **Race Condition:** Zwei gleichzeitige Requests koennten beide den Check passieren - → Mitigation: DB-Level unique constraint ist nicht moeglich (flexible Zeitraeume), aber Transaction-Isolation schuetzt +- **Widget-Groesse:** Filter-Bar braucht Platz — bei kleinen Widgets koennte es eng werden + → Mitigation: Kompakte Inputs (xs), collapsible Filter-Bar mit Funnel-Icon +- **Performance:** Zusaetzliche tRPC-Queries fuer Client/Country/Role Listen + → Mitigation: Ein shared Hook mit 5-Minuten staleTime, von allen Widgets geteilt ### Offene Fragen -1. **Soll der Check nur warnen oder blockieren?** - → Empfehlung: API blockiert (CONFLICT), UI warnt (gelbe Box, Submit moeglich), AI blockiert -2. **Was passiert bei Updates/Shifts?** - → excludeAssignmentId nutzen um die eigene Zuweisung auszuschliessen -3. **Welche Status zaehlen als "aktiv"?** - → CONFIRMED, ACTIVE, PROPOSED — nicht CANCELLED, DRAFT -4. **Sollen verschiedene Rollen erlaubt sein?** - → Vorschlag: Ja, aber mit Warning. Gleiche Rolle + gleiches Projekt = Block, verschiedene Rolle = Warning only +1. **Server-side vs Client-side Filtering?** + → Empfehlung: Client-seitig, da Widget-Daten bereits komplett geladen sind (max 30-50 Rows) +2. **Soll der Filter-Bar im Widget-Header oder darunter angezeigt werden?** + → Empfehlung: Direkt unter dem Titel, ueber der Tabelle — kompakt mit kleinen Inputs +3. **Sollen Filter-Optionen leer sein wenn keine Daten vorhanden?** + → Ja, leere Dropdowns zeigen "(All)" als Default