feat: shared widget filter system for all dashboard widgets
Shared infrastructure: - WidgetFilterBar: declarative filter component (search, select, toggle) - useWidgetFilterOptions: cached hook for clients, countries, roles, chapters Widget integration (5 widgets): - ProjectHealth: search (name) + select (client) - BudgetForecast: search (name) + select (client) - Chargeability: select (chapter) + toggle (include proposed) - SkillGap: search (skill name) - TopValue: select (chapter) Backend: added clientId/clientName to ProjectHealth and BudgetForecast query results for client-based filtering. Filter state persisted via widget config (survives page reload). All filters use compact 11px inputs with full dark theme support. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -1,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 `<WidgetFilterBar>` 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 },
|
||||
];
|
||||
|
||||
<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 |
|
||||
|-------|---------|------------------|
|
||||
| `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<string, unknown>;
|
||||
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?
|
||||
- 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
|
||||
|
||||
Reference in New Issue
Block a user