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:
@@ -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";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.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 {
|
function colorClass(pct: number): string {
|
||||||
if (pct > 90) return "bg-red-500";
|
if (pct > 90) return "bg-red-500";
|
||||||
@@ -16,12 +19,34 @@ function textColorClass(pct: number): string {
|
|||||||
return "text-green-700";
|
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(
|
const { data, isLoading } = trpc.dashboard.getBudgetForecast.useQuery(
|
||||||
undefined,
|
undefined,
|
||||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
{ 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) {
|
if (isLoading && !data) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 pt-1">
|
<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) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
<div className="flex flex-col h-full">
|
||||||
No active projects with budgets.
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
<table className="w-full text-xs">
|
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
<div className="overflow-auto flex-1">
|
||||||
<tr>
|
<table className="w-full text-xs">
|
||||||
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||||
Project <InfoTooltip content="Active projects with a defined budget" />
|
<tr>
|
||||||
</th>
|
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||||
<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" />
|
||||||
Budget Usage <InfoTooltip content="Percentage of total budget consumed by current allocations" />
|
</th>
|
||||||
</th>
|
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||||
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
Budget Usage <InfoTooltip content="Percentage of total budget consumed by current allocations" />
|
||||||
Burn/mo <InfoTooltip content="Monthly burn rate based on currently active allocations" />
|
</th>
|
||||||
</th>
|
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||||
<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" />
|
||||||
Exhaustion <InfoTooltip content="Projected date when budget will be fully consumed at the current burn rate" />
|
</th>
|
||||||
</th>
|
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||||
</tr>
|
Exhaustion <InfoTooltip content="Projected date when budget will be fully consumed at the current burn rate" />
|
||||||
</thead>
|
</th>
|
||||||
<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>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
</table>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { trpc } from "~/lib/trpc/client.js";
|
|||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { AnimatedNumber } from "~/components/ui/AnimatedNumber.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 }) {
|
function UtilizationBar({ percent }: { percent: number }) {
|
||||||
const barColor =
|
const barColor =
|
||||||
@@ -71,9 +73,20 @@ function FilterDropdown({ label, children }: { label: string; children: ReactNod
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetProps) {
|
||||||
const config = _config as { topN?: number; watchlistThreshold?: number };
|
const config = _config as { topN?: number; watchlistThreshold?: number; chapter?: string; includeProposed?: boolean };
|
||||||
const [includeProposed, setIncludeProposed] = useState(false);
|
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 [showDeparted, setShowDeparted] = useState(false);
|
||||||
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
|
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
|
||||||
const [topSort, setTopSort] = useState<TopSortKey>("actual");
|
const [topSort, setTopSort] = useState<TopSortKey>("actual");
|
||||||
@@ -162,7 +175,19 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
|||||||
const rawWatch = data?.watchlist ?? [];
|
const rawWatch = data?.watchlist ?? [];
|
||||||
const month = (data?.month as string) ?? "";
|
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;
|
const mult = topDir === "asc" ? 1 : -1;
|
||||||
switch (topSort) {
|
switch (topSort) {
|
||||||
case "name":
|
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;
|
const mult = watchDir === "asc" ? 1 : -1;
|
||||||
switch (watchSort) {
|
switch (watchSort) {
|
||||||
case "name":
|
case "name":
|
||||||
@@ -233,9 +258,10 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col gap-2 overflow-hidden">
|
<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="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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
{month && (
|
||||||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||||||
Period: {month}
|
Period: {month}
|
||||||
<InfoTooltip
|
<InfoTooltip
|
||||||
@@ -243,66 +269,56 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
|||||||
width="w-72"
|
width="w-72"
|
||||||
/>
|
/>
|
||||||
</p>
|
</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>
|
||||||
)}
|
<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 */}
|
{/* Top list */}
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.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 {
|
function healthDot(value: number): string {
|
||||||
if (value >= 70) return "bg-green-500";
|
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";
|
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(
|
const { data, isLoading } = trpc.dashboard.getProjectHealth.useQuery(
|
||||||
undefined,
|
undefined,
|
||||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
{ 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) {
|
if (isLoading && !data) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 pt-1">
|
<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) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
<div className="flex flex-col h-full">
|
||||||
No active projects found.
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
<table className="w-full text-xs">
|
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
<div className="overflow-auto flex-1">
|
||||||
<tr>
|
<table className="w-full text-xs">
|
||||||
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||||
Project <InfoTooltip content="Active projects scored across three health dimensions" />
|
<tr>
|
||||||
</th>
|
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||||
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
Project <InfoTooltip content="Active projects scored across three health dimensions" />
|
||||||
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demands), Timeline health (within end date)" />
|
</th>
|
||||||
</th>
|
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
||||||
<th className="px-3 py-2 text-right 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)" />
|
||||||
Score <InfoTooltip content="Composite score: average of Budget, Staffing, and Timeline health (0-100)" />
|
</th>
|
||||||
</th>
|
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||||
</tr>
|
Score <InfoTooltip content="Composite score: average of Budget, Staffing, and Timeline health (0-100)" />
|
||||||
</thead>
|
</th>
|
||||||
<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>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
</table>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.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(
|
const { data, isLoading } = trpc.dashboard.getSkillGaps.useQuery(
|
||||||
undefined,
|
undefined,
|
||||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
{ 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) {
|
if (isLoading && !data) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 pt-1">
|
<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) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
<div className="flex flex-col h-full">
|
||||||
No skill gaps detected.
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
<table className="w-full text-xs">
|
<WidgetFilterBar filters={FILTERS} values={config} onChange={onConfigChange ?? (() => {})} />
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
<div className="overflow-auto flex-1">
|
||||||
<tr>
|
<table className="w-full text-xs">
|
||||||
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||||
Skill <InfoTooltip content="Skills required by open demand positions" />
|
<tr>
|
||||||
</th>
|
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||||
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
Skill <InfoTooltip content="Skills required by open demand positions" />
|
||||||
Demand <InfoTooltip content="Number of unfilled demand requirements needing this skill" />
|
</th>
|
||||||
</th>
|
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||||
<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" />
|
||||||
Supply <InfoTooltip content="Number of active resources with this skill at proficiency 3+" />
|
</th>
|
||||||
</th>
|
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||||
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
Supply <InfoTooltip content="Number of active resources with this skill at proficiency 3+" />
|
||||||
Gap <InfoTooltip content="Supply minus Demand: negative (red) = shortage, positive (green) = surplus" />
|
</th>
|
||||||
</th>
|
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
||||||
</tr>
|
Gap <InfoTooltip content="Supply minus Demand: negative (red) = shortage, positive (green) = surplus" />
|
||||||
</thead>
|
</th>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
</tr>
|
||||||
{rows.map((row) => {
|
</thead>
|
||||||
const isShortage = row.gap < 0;
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
const isSurplus = row.gap > 0;
|
{rows.map((row) => {
|
||||||
return (
|
const isShortage = row.gap < 0;
|
||||||
<tr key={row.skill} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
const isSurplus = row.gap > 0;
|
||||||
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[180px] truncate">
|
return (
|
||||||
{row.skill}
|
<tr key={row.skill} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
||||||
</td>
|
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[180px] truncate">
|
||||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
|
{row.skill}
|
||||||
{row.demand}
|
</td>
|
||||||
</td>
|
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
|
||||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
|
{row.demand}
|
||||||
{row.supply}
|
</td>
|
||||||
</td>
|
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
|
||||||
<td className="px-3 py-2 text-center">
|
{row.supply}
|
||||||
<span className="inline-flex items-center gap-1.5">
|
</td>
|
||||||
<span
|
<td className="px-3 py-2 text-center">
|
||||||
className={`inline-block w-2 h-2 rounded-full ${
|
<span className="inline-flex items-center gap-1.5">
|
||||||
isShortage
|
<span
|
||||||
? "bg-red-500"
|
className={`inline-block w-2 h-2 rounded-full ${
|
||||||
: isSurplus
|
isShortage
|
||||||
? "bg-green-500"
|
? "bg-red-500"
|
||||||
: "bg-gray-400"
|
: isSurplus
|
||||||
}`}
|
? "bg-green-500"
|
||||||
/>
|
: "bg-gray-400"
|
||||||
<span
|
}`}
|
||||||
className={`font-semibold tabular-nums ${
|
/>
|
||||||
isShortage
|
<span
|
||||||
? "text-red-700"
|
className={`font-semibold tabular-nums ${
|
||||||
: isSurplus
|
isShortage
|
||||||
? "text-green-700"
|
? "text-red-700"
|
||||||
: "text-gray-500"
|
: isSurplus
|
||||||
}`}
|
? "text-green-700"
|
||||||
>
|
: "text-gray-500"
|
||||||
{row.gap > 0 ? `+${row.gap}` : row.gap}
|
}`}
|
||||||
|
>
|
||||||
|
{row.gap > 0 ? `+${row.gap}` : row.gap}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.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";
|
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 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 [sortKey, setSortKey] = useState<SortKey>("score");
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||||
@@ -23,6 +33,28 @@ export function TopValueWidget({ config }: WidgetProps) {
|
|||||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
{ 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 pt-1">
|
<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 (sorted.length === 0) {
|
||||||
|
|
||||||
if (list.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center py-8 text-gray-400 text-sm">
|
<div className="flex flex-col h-full">
|
||||||
<p>No scores computed yet or you lack access.</p>
|
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
|
||||||
<p className="text-xs mt-1">Admins can recompute scores in Settings.</p>
|
<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>
|
</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 }) {
|
function Ind({ k }: { k: SortKey }) {
|
||||||
return sortKey === k
|
return sortKey === k
|
||||||
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "▲" : "▼"}</span>
|
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "\u25B2" : "\u25BC"}</span>
|
||||||
: <span className="text-[10px] ml-0.5 text-gray-300">⇅</span>;
|
: <span className="text-[10px] ml-0.5 text-gray-300">{"\u21C5"}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
<table className="w-full text-xs">
|
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
|
||||||
<thead className="bg-gray-50 sticky top-0">
|
<div className="overflow-auto flex-1">
|
||||||
<tr>
|
<table className="w-full text-xs">
|
||||||
<th className="px-3 py-2 text-left font-medium text-gray-500 w-8">
|
<thead className="bg-gray-50 sticky top-0">
|
||||||
<span className="inline-flex items-center">
|
<tr>
|
||||||
#
|
<th className="px-3 py-2 text-left font-medium text-gray-500 w-8">
|
||||||
<InfoTooltip content="Rank position based on the current sort order." />
|
<span className="inline-flex items-center">
|
||||||
</span>
|
#
|
||||||
</th>
|
<InfoTooltip content="Rank position based on the current sort order." />
|
||||||
<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 0–100.<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 ?? "—"}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</th>
|
||||||
<td className="px-3 py-2 text-right text-gray-700">{(r.lcrCents / 100).toFixed(0)}</td>
|
<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 0–100.<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>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-gray-100">
|
||||||
</table>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Shared hook for loading filter options used across dashboard widgets.
|
||||||
|
* Loads clients, countries, roles, and chapters once with long cache TTL.
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
|
export interface FilterOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWidgetFilterOptions() {
|
||||||
|
const { data: clientsRaw } = trpc.clientEntity.list.useQuery(
|
||||||
|
{ isActive: true },
|
||||||
|
{ staleTime: 300_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: countriesRaw } = trpc.country.list.useQuery(
|
||||||
|
{ isActive: true },
|
||||||
|
{ staleTime: 300_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: rolesRaw } = trpc.role.list.useQuery(
|
||||||
|
{ isActive: true },
|
||||||
|
{ staleTime: 300_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const clients = useMemo<FilterOption[]>(() => {
|
||||||
|
const list = (Array.isArray(clientsRaw) ? clientsRaw : (clientsRaw as any)?.clients ?? []) as Array<{ id: string; name: string }>;
|
||||||
|
return list.map((c) => ({ value: c.id, label: c.name }));
|
||||||
|
}, [clientsRaw]);
|
||||||
|
|
||||||
|
const countries = useMemo<FilterOption[]>(() => {
|
||||||
|
const list = (Array.isArray(countriesRaw) ? countriesRaw : []) as Array<{ id: string; name: string }>;
|
||||||
|
return list.map((c) => ({ value: c.id, label: c.name }));
|
||||||
|
}, [countriesRaw]);
|
||||||
|
|
||||||
|
const roles = useMemo<FilterOption[]>(() => {
|
||||||
|
const list = (Array.isArray(rolesRaw) ? rolesRaw : []) as Array<{ id: string; name: string }>;
|
||||||
|
return list.map((r) => ({ value: r.id, label: r.name }));
|
||||||
|
}, [rolesRaw]);
|
||||||
|
|
||||||
|
// Chapters are derived from roles or can be hardcoded common ones
|
||||||
|
const chapters = useMemo<FilterOption[]>(() => {
|
||||||
|
const common = [
|
||||||
|
"Digital Content Production",
|
||||||
|
"Project Management",
|
||||||
|
"Art Direction",
|
||||||
|
"CGI-Dev",
|
||||||
|
"Product Data Management",
|
||||||
|
];
|
||||||
|
return common.map((c) => ({ value: c, label: c }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { clients, countries, roles, chapters };
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { calculateInclusiveDays, MILLISECONDS_PER_DAY } from "./shared.js";
|
|||||||
export interface BudgetForecastRow {
|
export interface BudgetForecastRow {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
shortCode: string;
|
shortCode: string;
|
||||||
|
clientId: string | null;
|
||||||
|
clientName: string | null;
|
||||||
budgetCents: number;
|
budgetCents: number;
|
||||||
spentCents: number;
|
spentCents: number;
|
||||||
burnRate: number;
|
burnRate: number;
|
||||||
@@ -23,6 +25,8 @@ export async function getDashboardBudgetForecast(
|
|||||||
budgetCents: true,
|
budgetCents: true,
|
||||||
startDate: true,
|
startDate: true,
|
||||||
endDate: true,
|
endDate: true,
|
||||||
|
clientId: true,
|
||||||
|
client: { select: { name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,6 +90,8 @@ export async function getDashboardBudgetForecast(
|
|||||||
return {
|
return {
|
||||||
projectName: p.name,
|
projectName: p.name,
|
||||||
shortCode: p.shortCode,
|
shortCode: p.shortCode,
|
||||||
|
clientId: p.clientId,
|
||||||
|
clientName: p.client?.name ?? null,
|
||||||
budgetCents: p.budgetCents,
|
budgetCents: p.budgetCents,
|
||||||
spentCents,
|
spentCents,
|
||||||
burnRate,
|
burnRate,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { calculateInclusiveDays } from "./shared.js";
|
|||||||
export interface ProjectHealthRow {
|
export interface ProjectHealthRow {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
shortCode: string;
|
shortCode: string;
|
||||||
|
clientId: string | null;
|
||||||
|
clientName: string | null;
|
||||||
budgetHealth: number;
|
budgetHealth: number;
|
||||||
staffingHealth: number;
|
staffingHealth: number;
|
||||||
timelineHealth: number;
|
timelineHealth: number;
|
||||||
@@ -21,6 +23,8 @@ export async function getDashboardProjectHealth(
|
|||||||
shortCode: true,
|
shortCode: true,
|
||||||
budgetCents: true,
|
budgetCents: true,
|
||||||
endDate: true,
|
endDate: true,
|
||||||
|
clientId: true,
|
||||||
|
client: { select: { name: true } },
|
||||||
demandRequirements: {
|
demandRequirements: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -92,6 +96,8 @@ export async function getDashboardProjectHealth(
|
|||||||
return {
|
return {
|
||||||
projectName: p.name,
|
projectName: p.name,
|
||||||
shortCode: p.shortCode,
|
shortCode: p.shortCode,
|
||||||
|
clientId: p.clientId,
|
||||||
|
clientName: p.client?.name ?? null,
|
||||||
budgetHealth,
|
budgetHealth,
|
||||||
staffingHealth,
|
staffingHealth,
|
||||||
timelineHealth,
|
timelineHealth,
|
||||||
|
|||||||
@@ -1,17 +1,61 @@
|
|||||||
# Duplicate Assignment Prevention — Plan
|
# Dashboard Widget Filter System — Plan
|
||||||
|
|
||||||
## Anforderungsanalyse
|
## 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):
|
**Design-Prinzip:** Ein shared `<WidgetFilterBar>` Komponente die verschiedene Filter-Typen als deklarative Config akzeptiert. Filter-State wird via `onConfigChange` im Widget-Config persistiert (bereits vorhanden).
|
||||||
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
|
|
||||||
|
|
||||||
**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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
<WidgetFilterBar
|
||||||
|
filters={filters}
|
||||||
|
values={config} // Aktueller Filter-State aus Widget-Config
|
||||||
|
onChange={onConfigChange} // Persistiert in localStorage via Dashboard-Layout
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 |
|
| Paket | Dateien | Art der Aenderung |
|
||||||
|-------|---------|------------------|
|
|-------|---------|------------------|
|
||||||
| `packages/engine` | `src/allocation/duplicate-check.ts` | **create** — Pure Funktion `checkDuplicateAssignment()` |
|
| `apps/web` | `src/components/dashboard/WidgetFilterBar.tsx` | **create** — Shared Filter-Komponente |
|
||||||
| `packages/engine` | `src/index.ts` | **edit** — Export hinzufuegen |
|
| `apps/web` | `src/hooks/useWidgetFilterOptions.ts` | **create** — Hook fuer gemeinsame Filter-Optionen (clients, countries, roles) |
|
||||||
| `packages/application` | `src/use-cases/allocation/create-assignment.ts` | **edit** — Duplicate-Check vor DB-Write |
|
| `apps/web` | `src/components/dashboard/widgets/ProjectHealthWidget.tsx` | **edit** — Filter integrieren |
|
||||||
| `packages/application` | `src/use-cases/allocation/fill-demand-requirement.ts` | **edit** — Duplicate-Check vor DB-Write |
|
| `apps/web` | `src/components/dashboard/widgets/BudgetForecastWidget.tsx` | **edit** — Filter integrieren |
|
||||||
| `packages/api` | `src/router/allocation.ts` | **edit** — Duplicate-Check in `create`, `createAssignment` Mutations |
|
| `apps/web` | `src/components/dashboard/widgets/ChargeabilityWidget.tsx` | **edit** — Filter integrieren |
|
||||||
| `packages/api` | `src/router/assistant-tools.ts` | **edit** — Check in `create_allocation`, `fill_demand` Tools |
|
| `apps/web` | `src/components/dashboard/widgets/SkillGapWidget.tsx` | **edit** — Optional: Skill-Suche |
|
||||||
| `packages/api` | `src/router/timeline.ts` | **edit** — Check in `batchShiftAllocations` (falls Shift Duplikat erzeugt) |
|
| `apps/web` | `src/components/dashboard/widgets/DemandWidget.tsx` | **edit** — Optional: Client/Chapter Filter |
|
||||||
| `apps/web` | `src/components/allocations/AllocationModal.tsx` | **edit** — Warning anzeigen wenn Duplikat erkannt |
|
| `apps/web` | `src/components/dashboard/widgets/TopValueWidget.tsx` | **edit** — Optional: Chapter Filter |
|
||||||
| `apps/web` | `src/components/staffing/StaffingPanel.tsx` | **edit** — Warning im Assign-Formular |
|
|
||||||
| `packages/engine` | `src/__tests__/duplicate-check.test.ts` | **create** — Unit Tests |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task-Liste
|
## 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
|
```typescript
|
||||||
interface ExistingAssignment {
|
interface WidgetFilter {
|
||||||
id: string;
|
type: "search" | "select" | "toggle";
|
||||||
resourceId: string;
|
key: string; // Config-Schluessel (z.B. "clientId")
|
||||||
projectId: string;
|
label?: string; // Display-Label
|
||||||
startDate: Date;
|
placeholder?: string; // Fuer search/select
|
||||||
endDate: Date;
|
options?: { value: string; label: string }[]; // Fuer select
|
||||||
status: string; // nur CONFIRMED, ACTIVE, PROPOSED zaehlen
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DuplicateCheckResult {
|
interface WidgetFilterBarProps {
|
||||||
isDuplicate: boolean;
|
filters: WidgetFilter[];
|
||||||
conflictingAssignment?: ExistingAssignment;
|
values: Record<string, unknown>;
|
||||||
message?: string; // z.B. "Resource Wong Wong is already assigned to Porsche Taycan (2026-03-01 to 2026-06-30)"
|
onChange: (update: Record<string, unknown>) => 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?
|
- Kompaktes Layout: horizontal, passt in Widget-Header-Bereich
|
||||||
- Ignoriert: CANCELLED Status, eigene ID (bei Updates)
|
- Kleine Inputs (text-xs, py-1) damit sie nicht zu viel Platz nehmen
|
||||||
- Overlap-Logik: `existingStart <= newEnd && existingEnd >= newStart`
|
- "Reset" Button wenn Filter aktiv
|
||||||
|
- Dark-Theme Support
|
||||||
|
|
||||||
- [ ] **Task 2:** Unit Tests → `packages/engine/src/__tests__/duplicate-check.test.ts`
|
### Phase 2: Widget Integration (parallel)
|
||||||
- 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)
|
|
||||||
|
|
||||||
- [ ] **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`
|
- [ ] **Task 5:** ChargeabilityWidget + Filter → `ChargeabilityWidget.tsx`
|
||||||
- Nach dem Laden von `existingBookings` (Zeile 101-106): `checkDuplicateAssignment()` aufrufen
|
- Filter: `select` (Country), `select` (Role/Chapter)
|
||||||
- Bei `isDuplicate: true`: `throw new TRPCError({ code: "CONFLICT", message: result.message })`
|
- Country/Role-Daten via useWidgetFilterOptions
|
||||||
- Bestehende Bookings bereits vorhanden — nur filtern auf gleichen `projectId`
|
- 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`
|
- [ ] **Task 6:** SkillGapWidget + Filter → `SkillGapWidget.tsx`
|
||||||
- Vor dem Assignment-Create: gleicher Check
|
- Filter: `search` (Skill-Name)
|
||||||
- DemandRequirement hat bereits `projectId` — diesen nutzen
|
- Einfache client-seitige Filterung der Skill-Liste
|
||||||
|
|
||||||
### Phase 3: API + AI Assistant
|
- [ ] **Task 7:** TopValueWidget + Filter → `TopValueWidget.tsx`
|
||||||
|
- Filter: `select` (Chapter) — bereits sortierbar, Chapter-Filter hinzufuegen
|
||||||
- [ ] **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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Abhaengigkeiten
|
## Abhaengigkeiten
|
||||||
|
|
||||||
```
|
```
|
||||||
Task 1 (Engine Funktion) → Task 2 (Tests) → Task 3 (Export)
|
Task 1 (Hook) + Task 2 (WidgetFilterBar) → koennen parallel
|
||||||
↘
|
Task 1+2 → Tasks 3-7 (alle parallel, verschiedene Dateien)
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Tasks 4+5 koennen **parallel** (verschiedene Dateien)
|
- Tasks 3-7 sind **vollstaendig parallel** (verschiedene Widget-Dateien)
|
||||||
- Tasks 6, 7, 8 koennen **parallel** (verschiedene Dateien)
|
- Tasks 1+2 muessen zuerst (shared Infrastructure)
|
||||||
- Task 9 sollte **nach** den anderen Tasks laufen (damit neue Duplikate verhindert werden)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] `pnpm test:unit` laeuft gruen (inkl. neue duplicate-check Tests)
|
|
||||||
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors
|
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors
|
||||||
- [ ] **API:** `createAssignment` wirft CONFLICT wenn Resource bereits zum gleichen Projekt zugewiesen
|
- [ ] **ProjectHealth** filterbar nach Projektname + Client
|
||||||
- [ ] **API:** `fillDemandRequirement` wirft CONFLICT bei Duplikat
|
- [ ] **BudgetForecast** filterbar nach Projektname + Client
|
||||||
- [ ] **AI Assistant:** `create_allocation` gibt klare Fehlermeldung bei Duplikat
|
- [ ] **Chargeability** filterbar nach Country + Role
|
||||||
- [ ] **AI Assistant:** `fill_demand` gibt klare Fehlermeldung bei Duplikat
|
- [ ] **SkillGap** filterbar nach Skill-Name
|
||||||
- [ ] **UI:** AllocationModal zeigt gelbe Warning bei erkanntem Duplikat
|
- [ ] **TopValue** filterbar nach Chapter
|
||||||
- [ ] **UI:** StaffingPanel AssignForm zeigt Warning bei Duplikat
|
- [ ] Filter-State wird in Widget-Config persistiert (bleibt nach Reload)
|
||||||
- [ ] **Cleanup:** Bestehende Duplikate in der DB bereinigt
|
- [ ] Reset-Button setzt alle Filter zurueck
|
||||||
- [ ] **Timeline:** Wong Wong hat keine doppelten Strips mehr am 15. April
|
- [ ] Dark-Theme funktioniert fuer alle Filter
|
||||||
|
- [ ] Filter-Optionen werden gecacht (nicht pro Widget neu geladen)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Risiken & offene Fragen
|
## Risiken & offene Fragen
|
||||||
|
|
||||||
### Risiken
|
### Risiken
|
||||||
- **False Positives:** Legitime Doppelbuchungen (z.B. verschiedene Rollen auf demselben Projekt) werden blockiert
|
- **Widget-Groesse:** Filter-Bar braucht Platz — bei kleinen Widgets koennte es eng werden
|
||||||
→ Mitigation: Warning im UI, Error nur im API — User kann override-en, AI Assistant gibt Hinweis
|
→ Mitigation: Kompakte Inputs (xs), collapsible Filter-Bar mit Funnel-Icon
|
||||||
- **Race Condition:** Zwei gleichzeitige Requests koennten beide den Check passieren
|
- **Performance:** Zusaetzliche tRPC-Queries fuer Client/Country/Role Listen
|
||||||
→ Mitigation: DB-Level unique constraint ist nicht moeglich (flexible Zeitraeume), aber Transaction-Isolation schuetzt
|
→ Mitigation: Ein shared Hook mit 5-Minuten staleTime, von allen Widgets geteilt
|
||||||
|
|
||||||
### Offene Fragen
|
### Offene Fragen
|
||||||
1. **Soll der Check nur warnen oder blockieren?**
|
1. **Server-side vs Client-side Filtering?**
|
||||||
→ Empfehlung: API blockiert (CONFLICT), UI warnt (gelbe Box, Submit moeglich), AI blockiert
|
→ Empfehlung: Client-seitig, da Widget-Daten bereits komplett geladen sind (max 30-50 Rows)
|
||||||
2. **Was passiert bei Updates/Shifts?**
|
2. **Soll der Filter-Bar im Widget-Header oder darunter angezeigt werden?**
|
||||||
→ excludeAssignmentId nutzen um die eigene Zuweisung auszuschliessen
|
→ Empfehlung: Direkt unter dem Titel, ueber der Tabelle — kompakt mit kleinen Inputs
|
||||||
3. **Welche Status zaehlen als "aktiv"?**
|
3. **Sollen Filter-Optionen leer sein wenn keine Daten vorhanden?**
|
||||||
→ CONFIRMED, ACTIVE, PROPOSED — nicht CANCELLED, DRAFT
|
→ Ja, leere Dropdowns zeigen "(All)" als Default
|
||||||
4. **Sollen verschiedene Rollen erlaubt sein?**
|
|
||||||
→ Vorschlag: Ja, aber mit Warning. Gleiche Rolle + gleiches Projekt = Block, verschiedene Rolle = Warning only
|
|
||||||
|
|||||||
Reference in New Issue
Block a user