chore: update plan and fix useMultiSelectIntersection types

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 00:37:14 +01:00
parent e7b74f13bd
commit 407266bc28
2 changed files with 252 additions and 317 deletions
+244 -307
View File
@@ -1,321 +1,258 @@
# Right-Click Multi-Selection on Timeline
# Refactor v2 — Code Optimization, De-duplication & Maintainability
## Anforderungsanalyse
Vier neue Funktionen für die Timeline:
Vollstaendiger Optimierungsdurchlauf des Planarchy-Monorepos. Ziel: Code-Duplikate eliminieren,
Performance verbessern, Wartbarkeit erhoehen. Betroffen sind alle Pakete: `apps/web`, `packages/api`,
`packages/db`, `packages/shared`.
1. **Right-click + Drag Multi-Selection** — Rechtsklick + Drag auf dem Canvas, um mehrere Allocation-Blocks auszuwählen. Die Selektion kann über mehrere Ressourcen-Zeilen hinweggehen (Lasso/Rectangle-Selection).
Die Analyse identifiziert **7 unabhaengige Arbeitsstroeme (Waves)**, die von Agenten parallel
bearbeitet werden koennen, plus eine abschliessende Schema-Migration.
2. **Shift+Click Additive Selection** — Shift+Klick auf einen Allocation-Block fügt ihn zur bestehenden Multi-Selection hinzu (oder entfernt ihn, wenn bereits selektiert = Toggle). Ermöglicht präzise Einzelauswahl ohne Lasso. Funktioniert auch als Einstieg: Erster Shift+Click startet die Multi-Selection, weitere Shift+Clicks erweitern sie.
3. **Floating Action Bar** — Bei aktiver Multi-Selection erscheint eine schwebende Toolbar mit:
- **"Delete / Cancel"** — Batch-Löschung aller selektierten Allocations (`allocation.batchDelete` existiert bereits)
- **"Assign"** — Auf leeren Zeilen: Batch-Erstellung neuer Allocations über `timeline.quickAssign` (eine pro selektierter Ressource-Zeile)
- **"Clear Selection"** — Selektion aufheben
4. **Multi-Resource Assignment** — Right-click + Drag über leere Zeilen mehrerer Ressourcen → öffnet einen Popover/Bar, der ein Projekt wählen lässt und dann Allocations für alle selektierten Ressourcen auf einmal erstellt.
**Betroffene Pakete:** `apps/web` (Frontend), `packages/api` (neuer `batchQuickAssign`-Endpoint)
---
## Betroffene Pakete & Dateien
| Paket | Dateien | Art der Änderung |
|-------|---------|-----------------:|
| apps/web | `src/hooks/useTimelineDrag.ts` | edit — neuer `multiSelectState`, right-click drag handler |
| apps/web | `src/components/timeline/TimelineView.tsx` | edit — multiSelectState empfangen, FloatingActionBar rendern, batch-Actions verdrahten |
| apps/web | `src/components/timeline/FloatingActionBar.tsx` | create — schwebende Toolbar-Komponente |
| apps/web | `src/components/timeline/BatchAssignPopover.tsx` | create — Projekt-Picker für Multi-Resource-Assignment |
| apps/web | `src/components/timeline/TimelineResourcePanel.tsx` | edit — Selection-Overlay rendern, onContextMenu anpassen |
| apps/web | `src/components/timeline/TimelineProjectPanel.tsx` | edit — Selection-Overlay rendern, onContextMenu anpassen |
| packages/api | `src/router/timeline.ts` | edit — neuer `batchQuickAssign`-Endpoint |
| Paket | Dateien | Art |
|-------|---------|-----|
| `apps/web` | `src/lib/format.ts`, 10+ Consumer-Dateien | edit |
| `apps/web` | `src/lib/status-styles.ts`, `src/components/timeline/timelineConstants.ts`, 6 Consumer-Dateien | edit |
| `apps/web` | `src/hooks/useInvalidatePlanningViews.ts`, 14 Consumer-Dateien | edit |
| `apps/web` | `src/components/timeline/renderHelpers.ts` | create |
| `apps/web` | `src/components/timeline/TimelineResourcePanel.tsx`, `TimelineProjectPanel.tsx` | edit |
| `apps/web` | `src/components/timeline/TimelineView.tsx` | edit |
| `apps/web` | `src/hooks/useTimelineDrag.ts` | edit |
| `packages/api` | `src/db/helpers.ts`, 7+ Router-Dateien | edit |
| `packages/api` | `src/db/selects.ts`, 6+ Router-Dateien | edit |
| `packages/db` | `prisma/schema.prisma` | edit |
## Architektur-Entscheidungen
---
### Right-click vs. Left-click vs. Shift-click Abgrenzung
- **Left-click drag** (button 0): Bestehende Funktionen — Alloc-Move, Alloc-Resize, Range-Select (NewAllocationPopover)
- **Right-click drag** (button 2): Neue Multi-Selection (Lasso-Rectangle)
- **Right-click ohne Drag** auf bestehende Allocation: Öffnet weiterhin `AllocationPopover` (Einzelbearbeitung) — das ist der bestehende `onContextMenu`-Handler
- **Shift+Click** auf Allocation-Block: Toggle-Selektion (addiert/entfernt Block zur/aus Multi-Selection). Startet Multi-Selection wenn noch keine aktiv. Kein Drag nötig — sofortige Einzelauswahl.
## Wave 1 — Centralize `formatMoney()` / `formatCents()` (Agent: format-consolidator)
### Multi-Select State
Neuer State `MultiSelectState` im `useTimelineDrag`-Hook:
**Problem:** Zentralisierte Funktionen existieren in `apps/web/src/lib/format.ts`, aber 10+
Stellen nutzen Inline-`(x / 100).toLocaleString("de-DE", ...)` statt der zentralen Imports.
Zusaetzlich gibt es 2 lokale `fmtEur()` Helfer in API-Routern.
```ts
interface MultiSelectState {
isSelecting: boolean;
// Rectangle coordinates (canvas-relative pixels)
startX: number;
startY: number;
currentX: number;
currentY: number;
// Resolved after mouseUp:
selectedAllocationIds: string[];
selectedResourceIds: string[]; // Resources within the rectangle (for empty-row assign)
dateRange: { start: Date; end: Date } | null;
}
**Dateien die keine Aenderung brauchen:**
- `ShiftPreviewTooltip.tsx` — spezialisierter Delta-Formatter mit +/- Prefix (bleibt)
### Tasks
- [ ] **1.1** Inline-Formatierung in `FillOpenDemandModal.tsx` (5 Stellen, Lines ~214/417/434/444/452) durch `formatCents()` Import ersetzen → `apps/web/src/components/allocations/FillOpenDemandModal.tsx`
- [ ] **1.2** Inline-Formatierung in `DemandPopover.tsx` (Lines ~146/152) durch `formatCents()` Import ersetzen → `apps/web/src/components/timeline/DemandPopover.tsx`
- [ ] **1.3** Inline-Formatierung in `ResourceHoverCard.tsx` (Lines ~123/130) durch `formatCents()` Import ersetzen → `apps/web/src/components/timeline/ResourceHoverCard.tsx`
- [ ] **1.4** Inline-Formatierung in `ProjectWizard.tsx` (Lines ~509-511) ersetzen → `apps/web/src/components/projects/ProjectWizard.tsx`
- [ ] **1.5** Inline-Formatierung in `ProjectAssignmentsTable.tsx` (Line ~130) ersetzen → `apps/web/src/components/projects/ProjectAssignmentsTable.tsx`
- [ ] **1.6** Inline-Formatierung in `ProjectDemandsTable.tsx` (Lines ~135/138-139) ersetzen → `apps/web/src/components/projects/ProjectDemandsTable.tsx`
- [ ] **1.7** Inline-Formatierung in `ProjectsClient.tsx` (Line ~403) ersetzen → `apps/web/src/app/(app)/projects/ProjectsClient.tsx`
- [ ] **1.8** Inline-Formatierung in `ProjectTableWidget.tsx` (Lines ~274/283) ersetzen → `apps/web/src/components/dashboard/widgets/ProjectTableWidget.tsx`
- [ ] **1.9** Inline-`.toFixed(0)` in `ResourcesClient.tsx` (Line ~1178) und `ResourceDetail.tsx` (Lines ~279/286) durch `formatMoney()` ersetzen → 2 Dateien
- [ ] **1.10** Duplizierte `fmtEur()` in `assistant-tools.ts` (Line ~44-46) und `computation-graph.ts` (Line ~53-55) entfernen — gemeinsamen Helfer in `packages/api/src/lib/format-utils.ts` erstellen (da API-Router keinen Zugriff auf `apps/web/src/lib/format.ts` haben) → `packages/api/src/lib/format-utils.ts` (create), 2 Router editieren
### Akzeptanzkriterien
- Kein `/ 100).toLocaleString("de-DE"` mehr in Component-Dateien (ausser ShiftPreviewTooltip)
- Kein lokales `fmtEur()` mehr in API-Routern
---
## Wave 2 — Adopt `findUniqueOrThrow()` Helper (Agent: db-helper-consolidator)
**Problem:** `packages/api/src/db/helpers.ts` existiert mit `findUniqueOrThrow<T>()`, aber 7 Router-Dateien nutzen ihn nicht und haben manuelle `findUnique` + `if (!x) throw` Bloecke.
### Tasks
- [ ] **2.1** `entitlement.ts` — 7 manuelle Stellen durch `findUniqueOrThrow()` ersetzen → `packages/api/src/router/entitlement.ts`
- [ ] **2.2** `calculation-rules.ts` — 3 manuelle if+throw Stellen (Lines ~24-26, 62-64, 89-91) ersetzen → `packages/api/src/router/calculation-rules.ts`
- [ ] **2.3** `notification.ts` — 3 Stellen migrieren → `packages/api/src/router/notification.ts`
- [ ] **2.4** `settings.ts` — 3 Stellen migrieren → `packages/api/src/router/settings.ts`
- [ ] **2.5** `user.ts` — verbleibende manuelle Stellen migrieren → `packages/api/src/router/user.ts`
- [ ] **2.6** `resource.ts` — 8 verbleibende manuelle Stellen migrieren → `packages/api/src/router/resource.ts`
- [ ] **2.7** `vacation.ts` — 12 verbleibende manuelle Stellen migrieren → `packages/api/src/router/vacation.ts`
- [ ] **2.8** `timeline.ts` — 4 verbleibende Stellen migrieren → `packages/api/src/router/timeline.ts`
- [ ] **2.9** `assistant.ts` — 1 Stelle migrieren → `packages/api/src/router/assistant.ts`
**Hinweis:** `assistant-tools.ts` nutzt `{ error: "..." }` Return-Pattern statt throw — NICHT migrieren (anderes Error-Handling).
### Akzeptanzkriterien
- Alle Router (ausser assistant-tools.ts) nutzen `findUniqueOrThrow()` fuer NOT_FOUND Checks
- `pnpm --filter @planarchy/api exec tsc --noEmit` — gruen
---
## Wave 3 — Prisma Select Constants adoptieren (Agent: select-consolidator)
**Problem:** `packages/api/src/db/selects.ts` definiert `ROLE_BRIEF_SELECT`, `PROJECT_BRIEF_SELECT`, `RESOURCE_BRIEF_SELECT`, aber Adoption ist gering (nur `allocation.ts` nutzt alle drei).
### Tasks
- [ ] **3.1** `vacation.ts` — 5 Inline-Resource-Selects (Lines ~102/123/213/542/579) durch `RESOURCE_BRIEF_SELECT` ersetzen → `packages/api/src/router/vacation.ts`
- [ ] **3.2** `role.ts` — 1 Inline-Resource-Select (Line ~92) ersetzen → `packages/api/src/router/role.ts`
- [ ] **3.3** `project-planning-read-model.ts` — 1 Inline-Role-Select (Line ~34) durch `ROLE_BRIEF_SELECT` ersetzen → `packages/api/src/router/project-planning-read-model.ts`
- [ ] **3.4** `resource.ts` — 1 Inline-Role-Select (Line ~313) durch `ROLE_BRIEF_SELECT` ersetzen → `packages/api/src/router/resource.ts`
- [ ] **3.5** `calculation-rules.ts` — 2 Inline-Project-Selects (Lines ~13/22) durch `PROJECT_BRIEF_SELECT` ersetzen → `packages/api/src/router/calculation-rules.ts`
- [ ] **3.6** `entitlement.ts` — 1 Inline-Resource-Select (Line ~269) durch `RESOURCE_BRIEF_SELECT` (+ spread `chapter: true`) ersetzen → `packages/api/src/router/entitlement.ts`
### Akzeptanzkriterien
- Keine `{ id: true, name: true, color: true }` Inline-Selects mehr in Routern (ausser dort wo erweitert)
- `pnpm --filter @planarchy/api exec tsc --noEmit` — gruen
---
## Wave 4 — Status Badge & Vacation Constant Consolidation (Agent: style-consolidator)
**Problem:** Mehrere duplizierte Konstanten-Maps fuer Status-Badges und Vacation-Type-Farben:
- `VACATION_TYPE_LABELS` dupliziert in `VacationModal.tsx` (Line ~13-18)
- `TYPE_COLORS` + `TYPE_BORDER` + `TYPE_LABELS_SHORT` identisch in `TimelineResourcePanel.tsx` (Lines ~563-580) und `TimelineProjectPanel.tsx` (Lines ~1299-1316)
- `TYPE_COLOR` fuer Kalender dupliziert in `VacationCalendar.tsx` (Line ~21) und `TeamCalendar.tsx` (Line ~8) — mit Inkonsistenz bei PUBLIC_HOLIDAY
### Tasks
- [ ] **4.1** Vacation-Timeline-Konstanten (`VACATION_TIMELINE_COLORS`, `VACATION_TIMELINE_BORDER`, `VACATION_TYPE_LABELS_SHORT`) in `status-styles.ts` als Exports hinzufuegen → `apps/web/src/lib/status-styles.ts`
- [ ] **4.2** Vacation-Kalender-Konstanten (`VACATION_CALENDAR_COLORS`) in `status-styles.ts` hinzufuegen, PUBLIC_HOLIDAY-Inkonsistenz auf `emerald-500` vereinheitlichen → `apps/web/src/lib/status-styles.ts`
- [ ] **4.3** `TimelineResourcePanel.tsx` — lokale `TYPE_COLORS/TYPE_BORDER/TYPE_LABELS_SHORT` (Lines ~563-580) entfernen, Import aus `status-styles.ts``apps/web/src/components/timeline/TimelineResourcePanel.tsx`
- [ ] **4.4** `TimelineProjectPanel.tsx` — lokale `TYPE_COLORS/TYPE_BORDER/TYPE_LABELS_SHORT` (Lines ~1299-1316) entfernen, Import aus `status-styles.ts``apps/web/src/components/timeline/TimelineProjectPanel.tsx`
- [ ] **4.5** `VacationModal.tsx` — lokales `VACATION_TYPE_LABELS` (Lines ~13-18) entfernen, Import aus `status-styles.ts``apps/web/src/components/vacations/VacationModal.tsx`
- [ ] **4.6** `VacationCalendar.tsx` — lokales `TYPE_COLOR` (Line ~21) ersetzen durch Import → `apps/web/src/components/vacations/VacationCalendar.tsx`
- [ ] **4.7** `TeamCalendar.tsx` — lokales `TYPE_COLOR` (Line ~8) ersetzen durch Import → `apps/web/src/components/vacations/TeamCalendar.tsx`
### Akzeptanzkriterien
- Keine duplizierten Vacation/Status-Konstanten mehr in Komponenten
- `VacationCalendar` und `TeamCalendar` nutzen identische Farben
---
## Wave 5 — Adopt `useInvalidatePlanningViews()` Hook (Agent: invalidation-consolidator)
**Problem:** Hook existiert in `apps/web/src/hooks/useInvalidatePlanningViews.ts` mit 8 Queries,
wird aber von KEINER Mutation genutzt. 14+ Stellen kopieren die 4-Query-Timeline-Invalidierung manuell.
Ausserdem fehlt `getProjectContext` in `useTimelineDrag.ts` (Line ~238).
### Tasks
- [ ] **5.1** `useInvalidatePlanningViews` in eine `useInvalidateTimeline()` (4 Timeline-Queries) und `useInvalidateAllAllocViews()` (alle 8) aufspalten, da manche Stellen nur Timeline invalidieren → `apps/web/src/hooks/useInvalidatePlanningViews.ts` (edit)
- [ ] **5.2** `TimelineView.tsx` — 2 manuelle Invalidierungsbloecke (Lines ~73-76, ~333-336) durch Hook ersetzen → `apps/web/src/components/timeline/TimelineView.tsx`
- [ ] **5.3** `AllocationPopover.tsx` — Invalidierungsblock (Lines ~58-62) durch Hook ersetzen → `apps/web/src/components/timeline/AllocationPopover.tsx`
- [ ] **5.4** `NewAllocationPopover.tsx` — Invalidierungsblock (Lines ~63-66) ersetzen → `apps/web/src/components/timeline/NewAllocationPopover.tsx`
- [ ] **5.5** `BatchAssignPopover.tsx` — Invalidierungsblock (Lines ~54-57) ersetzen → `apps/web/src/components/timeline/BatchAssignPopover.tsx`
- [ ] **5.6** `ProjectPanel.tsx` — 3 Invalidierungsbloecke (Lines ~106-109, 115-118, 124-127) ersetzen → `apps/web/src/components/timeline/ProjectPanel.tsx`
- [ ] **5.7** `useAllocationHistory.ts` — 3 Invalidierungsbloecke (Lines ~31-34, 40-43, 62-65) ersetzen → `apps/web/src/hooks/useAllocationHistory.ts`
- [ ] **5.8** `useTimelineDrag.ts` — 2 Bloecke (Lines ~238-241, 254-257) ersetzen + fehlende `getProjectContext` Invalidierung fixen → `apps/web/src/hooks/useTimelineDrag.ts`
- [ ] **5.9** `FillOpenDemandModal.tsx` — Invalidierungsblock (Lines ~76-79) ersetzen → `apps/web/src/components/allocations/FillOpenDemandModal.tsx`
### Akzeptanzkriterien
- Keine manuellen 4-Query-Timeline-Invalidierungsbloecke mehr
- `useTimelineDrag.ts` invalidiert alle 4 Timeline-Queries (inkl. `getProjectContext`)
---
## Wave 6 — Timeline Render-Helpers & React.memo (Agent: timeline-optimizer)
**Problem:** 3 identische Render-Funktionen in beiden Panel-Komponenten (Vacation-Blocks, Range-Overlay, Overbooking-Blink). Keine `React.memo` auf den grossen Panel-Komponenten. `useTimelineDrag.ts` ist 883 Zeilen Monolith.
### Tasks
- [ ] **6.1** `renderHelpers.ts` erstellen mit extrahierten Shared-Funktionen: `renderVacationBlocks()`, `renderRangeOverlay()`, `renderOverbookingBlink()``apps/web/src/components/timeline/renderHelpers.ts` (create)
- [ ] **6.2** `TimelineResourcePanel.tsx` — lokale `renderVacationBlocksForRow`, `renderRangeOverlay`, `renderOverbookingBlink` (Lines ~582-636, 895-930) durch Imports aus `renderHelpers.ts` ersetzen → edit
- [ ] **6.3** `TimelineProjectPanel.tsx` — lokale `renderVacationBlocksForProjectRow`, `renderRangeOverlayProject`, `renderOverbookingBlinkProject` durch Imports ersetzen → edit
- [ ] **6.4** `React.memo()` auf `TimelineResourcePanel` wrappen → `TimelineResourcePanel.tsx`
- [ ] **6.5** `React.memo()` auf `TimelineProjectPanel` wrappen → `TimelineProjectPanel.tsx`
- [ ] **6.6** Multi-Select-Intersection-Logic (Lines ~573-634 in `TimelineView.tsx`) in eigenen Hook `useMultiSelectIntersection.ts` extrahieren → `apps/web/src/hooks/useMultiSelectIntersection.ts` (create), `TimelineView.tsx` (edit)
- [ ] **6.7** `useTimelineDrag.ts` — Drag-Math-Utilities (`pixelsToDays`, `constrainToGrid`, `clampDate`) in `dragMath.ts` extrahieren → `apps/web/src/components/timeline/dragMath.ts` (create), `useTimelineDrag.ts` (edit)
### Akzeptanzkriterien
- Keine duplizierten Render-Funktionen zwischen den Panel-Komponenten
- Beide Panels als `React.memo()` exportiert
- `useTimelineDrag.ts` unter 800 Zeilen
- Timeline rendert korrekt in beiden Views + Overbooking-Blink funktioniert
---
## Wave 7 — Composite Database Indexes (Agent: db-index-optimizer)
**Problem:** Mehrere haeufig abgefragte Modelle haben keine optimalen Composite-Indexes fuer
die kombinierten WHERE-Bedingungen der tRPC-Router.
### Tasks
- [ ] **7.1** `DemandRequirement``@@index([projectId, status, startDate, endDate])` hinzufuegen (ersetzt/ergaenzt bestehenden 3-Feld-Index) → `packages/db/prisma/schema.prisma`
- [ ] **7.2** `Resource``@@index([isActive, orgUnitId])` hinzufuegen fuer "aktive Ressourcen pro OrgUnit" Queries → `packages/db/prisma/schema.prisma`
- [ ] **7.3** `Project``@@index([status, startDate, endDate])` hinzufuegen fuer Timeline-Filterung → `packages/db/prisma/schema.prisma`
- [ ] **7.4** `Estimate``@@index([projectId, status])` hinzufuegen → `packages/db/prisma/schema.prisma`
- [ ] **7.5** `pnpm db:push` ausfuehren, `.next/` Cache loeschen, Dev-Server neu starten
**Bereits optimal:**
- `Vacation` — hat `@@index([resourceId, status, startDate, endDate])`
- `Assignment` — hat `@@index([resourceId, status, startDate])` + `@@index([projectId, startDate, endDate])`
### Akzeptanzkriterien
- Neue Indexes in Schema sichtbar
- `pnpm db:push` erfolgreich
- Dev-Server startet sauber
---
## Abhaengigkeiten & Parallelisierung
```
Wave 1 (format) ─┐
Wave 2 (findUniqueOrThrow) ─┤
Wave 3 (selects) ─┤── Alle parallel ausfuehrbar (verschiedene Dateien/Domains)
Wave 4 (status-styles) ─┤
Wave 5 (invalidation) ─┤
Wave 6 (timeline render) ─┘
Wave 7 (DB indexes) ── Sequentiell NACH Wave 1-6 (erfordert db:push + Restart)
```
### Intersection-Logik
Die Selektion geschieht als **Rectangle Intersection**:
1. Während des Drag wird ein visuelles Rechteck gezeichnet (semi-transparenter blauer Rahmen)
2. Bei mouseUp wird berechnet, welche Allocation-Blocks innerhalb des Rechtecks liegen (Pixel-basiert: Block-Position vs. Selection-Rect)
3. Selektierte Blocks erhalten einen visuellen Highlight (z.B. `ring-2 ring-brand-500`)
**Berechnung der Intersection:** Da die Allocation-Blocks als absolute `left/width/top/height` positioniert sind, können wir die Intersection über die `toLeft()`/`toWidth()`-Funktionen + Zeilen-Index berechnen. Die Berechnung geschieht im `TimelineResourcePanel`/`TimelineProjectPanel` und gibt IDs zurück.
### Batch-API
- **Delete:** `allocation.batchDelete` existiert bereits (max 100 IDs)
- **Assign:** Neuer `timeline.batchQuickAssign`-Endpoint, der ein Array von `{ resourceId, projectId, startDate, endDate, hoursPerDay }` akzeptiert und in einer Transaktion erstellt
## Task-Liste
### Task 1: Multi-Select State im Drag-Hook
- [ ] **Task 1a:** `MultiSelectState` Interface + Initial State definieren → Datei: `useTimelineDrag.ts`
```ts
export interface MultiSelectState {
isSelecting: boolean;
startX: number;
startY: number;
currentX: number;
currentY: number;
selectedAllocationIds: string[];
selectedResourceIds: string[];
dateRange: { start: Date; end: Date } | null;
}
```
- [ ] **Task 1b:** Right-click drag handlers implementieren → Datei: `useTimelineDrag.ts`
Neuer `onCanvasRightMouseDown` Handler:
- Prüfe `e.button === 2`
- `e.preventDefault()` (verhindert nativen Kontextmenü)
- Starte `multiSelectState` mit `isSelecting: true` und Mausposition
- Registriere `document.addEventListener("mousemove", ...)` und `document.addEventListener("mouseup", ...)` (analog zum AllocDrag-Pattern)
- Bei mousemove: Update `currentX/currentY`
- Bei mouseUp ohne Bewegung (< 5px): Fallback auf bestehenden `onAllocationContextMenu` (Einzelblock-Rechtsklick)
- Bei mouseUp mit Bewegung: Setze `isSelecting: false` aber behalte `selectedAllocationIds`/`selectedResourceIds` (werden vom Parent berechnet und reingesetzt)
Neuer `onCanvasContextMenu` Handler:
- Wird auf dem Canvas registriert, um `e.preventDefault()` global zu setzen (verhindert Browser-Kontextmenü)
Return-Werte erweitern um `multiSelectState`, `setMultiSelectState`, `onCanvasRightMouseDown`, `clearMultiSelect`.
- [ ] **Task 1c:** `clearMultiSelect` Funktion → Datei: `useTimelineDrag.ts`
Setzt `multiSelectState` auf Initial zurück. Wird von ESC-Handler und FloatingActionBar genutzt.
### Task 1d: Shift+Click Toggle-Selection
- [ ] **Task 1d:** Shift+Click Handler für Allocation-Blocks → Datei: `useTimelineDrag.ts`
Im bestehenden `onAllocMouseDown`-Handler (und im mouseUp-Pfad wo `daysDelta === 0` als Click behandelt wird):
**Logik im mouseUp (daysDelta === 0):**
```ts
if (e.shiftKey) {
// Toggle this allocation in multi-select
setMultiSelectState(prev => {
const ids = new Set(prev.selectedAllocationIds);
if (ids.has(alloc.allocationId)) {
ids.delete(alloc.allocationId); // Deselect
} else {
ids.add(alloc.allocationId); // Add to selection
}
return { ...prev, isSelecting: false, selectedAllocationIds: [...ids] };
});
return; // Don't open popover
}
// ... existing click → popover logic
```
**Wichtig:** Der Shift-Key-Check muss im mouseUp geschehen (nicht mouseDown), weil erst dort feststeht ob es ein Click (daysDelta === 0) oder Drag war.
**Interaktion mit bestehendem Code:**
- `onAllocMouseDown` startet den Drag (button 0 check auf Zeile 311)
- Im `handleUp` closure (Zeile 370): Wenn `daysDelta === 0` → aktuell wird `onBlockClickRef.current` aufgerufen
- **Änderung:** Vor dem `onBlockClick`-Call prüfen ob `shiftKey` gedrückt war (muss im mouseDown-Event gespeichert werden, da mouseUp ein document-event ist und das Original-React-Event nicht mehr verfügbar)
- → `shiftKeyRef` im Closure capturen: `const wasShift = e.shiftKey;` im `onAllocMouseDown`
Neuer Callback in Hook-Return: `onShiftClickAllocation?: (allocationId: string) => void` — wird vom Parent (`TimelineView`) gesetzt und toggelt den multiSelectState.
**Alternativ (einfacher):** Den Shift-Check direkt in `TimelineView.onBlockClick` machen, da dieser Callback bereits den `multiSelectState`-Zugriff hat.
### Task 2: Selection-Overlay in Panels rendern
- [ ] **Task 2a:** Selection-Rectangle als visuelles Overlay rendern → Datei: `TimelineResourcePanel.tsx`
Props erweitern um `multiSelectState: MultiSelectState`.
Neues `<div>` im Canvas-Bereich:
```tsx
{multiSelectState.isSelecting && (
<div
className="absolute border-2 border-brand-500 bg-brand-500/10 pointer-events-none z-30 rounded"
style={{
left: Math.min(multiSelectState.startX, multiSelectState.currentX),
top: Math.min(multiSelectState.startY, multiSelectState.currentY),
width: Math.abs(multiSelectState.currentX - multiSelectState.startX),
height: Math.abs(multiSelectState.currentY - multiSelectState.startY),
}}
/>
)}
```
Allocation-Blocks die in `selectedAllocationIds` sind: Extra CSS-Klasse `ring-2 ring-brand-500 ring-offset-1 z-20`.
- [ ] **Task 2b:** Dasselbe für `TimelineProjectPanel.tsx`
### Task 3: Intersection-Berechnung
- [ ] **Task 3a:** Funktion `computeSelectedAllocations` → Datei: `TimelineResourcePanel.tsx` (oder neue Utility-Datei)
Wird als `useMemo` in `TimelineViewContent` berechnet. Nimmt `multiSelectState` + Layout-Daten (`toLeft`, `toWidth`, Row-Höhen, Ressource-Reihenfolge) und gibt `{ allocationIds: string[], resourceIds: string[], dateRange }` zurück.
**Algorithmus:**
1. Konvertiere Pixel-Rechteck zu Datums-Range (via `xToDate`) und Zeilen-Range (via Row-Index-Berechnung)
2. Für jede Ressource im Zeilen-Range: Prüfe alle Allocations ob sie zeitlich überlappen
3. Sammle Treffer-IDs
**Wichtig:** Die Berechnung muss die Scroll-Position des Containers berücksichtigen (`scrollContainerRef.scrollLeft/scrollTop`).
- [ ] **Task 3b:** `setMultiSelectState` mit berechneten IDs updaten → Datei: `TimelineView.tsx`
Nach der Intersection-Berechnung via `useEffect`: Wenn `multiSelectState` sich ändert und nicht mehr `isSelecting`, update die `selectedAllocationIds`/`selectedResourceIds`.
### Task 4: Floating Action Bar
- [ ] **Task 4a:** `FloatingActionBar` Komponente erstellen → Datei: `FloatingActionBar.tsx`
```tsx
interface FloatingActionBarProps {
selectedCount: number;
selectedResourceCount: number;
onDelete: () => void;
onAssign: () => void;
onClear: () => void;
isDeleting: boolean;
}
```
Positionierung: `fixed bottom-6 left-1/2 -translate-x-1/2` — zentriert am unteren Bildschirmrand.
UI: Pill-förmige Bar mit:
- Zähler: "3 allocations selected" oder "5 resources × 10 days selected"
- Delete-Button (rot, nur wenn Allocations selektiert)
- Assign-Button (brand, nur wenn leere Ressource-Zeilen im Bereich)
- Clear-Button (grau)
- Keyboard hint: "ESC to clear"
Dark-Mode: `dark:bg-gray-800 dark:border-gray-700` etc.
- [ ] **Task 4b:** FloatingActionBar in `TimelineView.tsx` einbinden
Rendern wenn `multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0`.
Actions verdrahten:
- Delete → `allocation.batchDelete.mutate({ ids: selectedAllocationIds })`
- Assign → öffnet `BatchAssignPopover`
- Clear → `clearMultiSelect()`
ESC-Handler erweitern: Multi-Select hat Priorität vor anderen Overlays.
### Task 5: Batch-Assign Popover
- [ ] **Task 5a:** `BatchAssignPopover` Komponente erstellen → Datei: `BatchAssignPopover.tsx`
Ähnlich wie `NewAllocationPopover`, aber für mehrere Ressourcen:
- Projekt-Picker (Dropdown mit Suche)
- Hours/day Selector
- Anzeige: "Assigning to N resources, Start End"
- "Assign All" Button
Props:
```tsx
interface BatchAssignPopoverProps {
resourceIds: string[];
startDate: Date;
endDate: Date;
onClose: () => void;
onCreated: () => void;
}
```
- [ ] **Task 5b:** `batchQuickAssign` API-Endpoint erstellen → Datei: `packages/api/src/router/timeline.ts`
```ts
batchQuickAssign: managerProcedure
.input(z.object({
assignments: z.array(z.object({
resourceId: z.string(),
projectId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
role: z.string().min(1).max(200).default("Team Member"),
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
})).min(1).max(50),
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
// Validate all, then create in single transaction
})
```
### Task 6: Integration & Polish
- [ ] **Task 6a:** `onContextMenu` auf Canvas-Level `e.preventDefault()` setzen → Datei: `TimelineView.tsx`
Damit das Browser-Kontextmenü nicht erscheint während der Rechtsklick-Drag aktiv ist.
- [ ] **Task 6b:** ESC-Handler Priorität anpassen → Datei: `TimelineView.tsx`
Reihenfolge: Multi-Select clear → Popover → NewAllocPopover → DemandModal → ProjectPanel
- [ ] **Task 6c:** Cursor-Style anpassen → Datei: `TimelineView.tsx`
`cursor-crosshair` wenn Multi-Select aktiv.
- [ ] **Task 6d:** Confirmation-Dialog vor Batch-Delete → Datei: `FloatingActionBar.tsx` oder `TimelineView.tsx`
Einfacher `window.confirm()` oder inline-Bestätigung: "Delete 5 allocations? This cannot be undone."
## Abhängigkeiten
- Task 1 (Hook) muss vor Task 26 abgeschlossen sein (State-Grundlage)
- Task 2 (Overlay) und Task 3 (Intersection) können parallel entwickelt werden, aber Task 3 hängt logisch von Task 2 ab (Overlay-Koordinaten)
- Task 4 (ActionBar) hängt von Task 1 ab (braucht multiSelectState)
- Task 5a (BatchAssignPopover) ist unabhängig vom Rest
- Task 5b (API) ist unabhängig vom Rest
- Task 6 (Integration) kommt zuletzt
**Empfohlene Reihenfolge:** Task 1a → 1b → 1c → parallel(Task 2a+2b, Task 5b) → Task 3a → 3b → Task 4a → 4b → Task 5a → Task 6a6d
## Akzeptanzkriterien
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors
- [ ] `pnpm --filter @planarchy/api exec tsc --noEmit` — keine neuen Errors
- [ ] Resource View: Rechtsklick + Drag zeichnet Selection-Rechteck über Canvas
- [ ] Resource View: Allocation-Blocks innerhalb des Rechtecks werden highlighted
- [ ] Floating Action Bar erscheint mit korrektem Zähler
- [ ] "Delete" löscht alle selektierten Allocations nach Bestätigung
- [ ] "Assign" öffnet BatchAssignPopover mit korrekten Ressourcen und Datumrange
- [ ] BatchAssign erstellt Allocations für alle selektierten Ressourcen in einer Transaktion
- [ ] Project View: Multi-Selection funktioniert analog
- [ ] Rechtsklick auf einzelnen Block ohne Drag: Öffnet weiterhin AllocationPopover
- [ ] Shift+Click auf Allocation-Block: Fügt ihn zur Multi-Selection hinzu (FloatingActionBar erscheint)
- [ ] Shift+Click auf bereits selektierten Block: Entfernt ihn aus der Selection
- [ ] Shift+Click + Rechtsklick-Drag kombinierbar: Erst Shift-Clicks, dann Lasso erweitert die Selection
- [ ] ESC räumt Multi-Selection auf
- [ ] Dark Mode: Alle neuen Komponenten haben `dark:` Klassen
- [ ] Browser-Kontextmenü wird unterdrückt während Multi-Select aktiv
## Risiken & offene Fragen
- **Scroll-Position:** Die Intersection-Berechnung muss `scrollLeft`/`scrollTop` berücksichtigen. Sonst stimmen die Pixel-Koordinaten nicht mit den Allocation-Positionen überein.
- **Virtualisierung:** `TimelineResourcePanel` nutzt `@tanstack/react-virtual`. Nicht-sichtbare Zeilen sind nicht im DOM → die Intersection-Berechnung muss auf Daten-Ebene (nicht DOM-Ebene) erfolgen.
- **Performance:** Bei vielen Allocations könnte die Intersection-Berechnung während des Drag teuer werden. Lösung: Nur bei mouseUp berechnen, nicht während mousemove.
- **Rechtsklick-Einzelblock:** Muss sauber vom Drag unterschieden werden (< 5px Threshold). Der bestehende `onContextMenu`-Handler auf Allocation-Blocks (`e.stopPropagation()`) sollte erhalten bleiben.
- **Touch-Support:** Rechtsklick hat kein Touch-Äquivalent. Long-press wäre möglich, ist aber ein separates Feature. Zunächst nur Mouse.
- **`batchQuickAssign` Limit:** Max 50 Assignments pro Call, um DB-Last zu begrenzen.
**Innerhalb der Waves:**
- Wave 1: Task 1.10 (API format-utils) kann parallel zu Tasks 1.1-1.9 (web components)
- Wave 2: Alle Tasks unabhaengig (verschiedene Router-Dateien)
- Wave 4: Task 4.1-4.2 ZUERST (erstellt Exports), dann Tasks 4.3-4.7 parallel
- Wave 5: Task 5.1 ZUERST (Hook-Refactoring), dann Tasks 5.2-5.9 parallel
- Wave 6: Task 6.1 ZUERST (erstellt renderHelpers.ts), dann Tasks 6.2-6.7 parallel
**Dateikonflikte vermeiden:**
- Wave 2 und Wave 3 editieren teilweise gleiche Router-Dateien (`vacation.ts`, `entitlement.ts`, `calculation-rules.ts`) — diese Tasks SEQUENTIELL innerhalb eines Agents ausfuehren
- Wave 4 und Wave 6 editieren beide Panel-Dateien — unterschiedliche Abschnitte, koennen aber sicherer sequentiell sein
---
## Akzeptanzkriterien (Gesamt)
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — null Errors
- [ ] `pnpm --filter @planarchy/api exec tsc --noEmit` — null Errors
- [ ] `pnpm --filter @planarchy/engine exec vitest run` — alle Tests gruen
- [ ] `pnpm --filter @planarchy/staffing exec vitest run` — alle Tests gruen
- [ ] Dev-Server startet, Timeline rendert in beiden Views
- [ ] Overbooking-Blink funktioniert
- [ ] Demand-Popover und Resource-Hover-Card funktionieren
- [ ] Keine duplizierten `formatMoney/formatCents/fmtEur` Funktionsdefinitionen
- [ ] Keine manuellen `findUnique` + throw Bloecke in Routern (ausser assistant-tools.ts)
- [ ] Keine duplizierten Vacation-Type/Status-Konstanten
- [ ] Keine manuellen 4-Query Timeline-Invalidierungsbloecke
---
## Risiken & Offene Fragen
1. **Wave 2+3 Dateikonflikt:** `vacation.ts`, `entitlement.ts`, `calculation-rules.ts` werden in Wave 2 UND Wave 3 editiert. Loesung: Ein Agent bearbeitet beide Waves sequentiell fuer diese Dateien.
2. **Wave 5 Type-Cast:** `useInvalidatePlanningViews` hat einen TypeScript-Cast fuer `allocation.listView`. Wenn der Cast nach Refactoring bricht, muss der tRPC-Output-Type geprueft werden.
3. **Wave 6 memo-Props:** `React.memo()` auf Panels erfordert stabile Prop-Referenzen. Wenn Inline-Callbacks als Props uebergeben werden, muss der Parent `useCallback` nutzen — pruefe `TimelineViewContent`.
4. **Wave 7 DB-Migration:** `db:push` auf Produktions-DB erfordert Maintenance-Window fuer Index-Erstellung. Auf Dev-DB unproblematisch.
5. **assistant-tools.ts:** 45 `findUnique` Stellen mit `{ error: "..." }` Return-Pattern — NICHT auf `findUniqueOrThrow` migrieren, da das Error-Handling grundlegend anders ist (Return vs Throw).
---
## Metriken (erwartet)
| Metrik | Vorher | Nachher |
|--------|--------|---------|
| Duplizierte Format-Funktionen | 12+ Inline | 0 (1 zentrale Lib + 1 API-Helfer) |
| Manuelle findUnique+throw | ~35 Stellen | 0 (alle via Helper) |
| Inline Prisma-Selects | ~20 Duplikate | 0 (via Shared Constants) |
| Duplizierte Status-Konstanten | 7 Stellen | 0 (1 zentrale Datei) |
| Manuelle Invalidierungsbloecke | 14+ Stellen | 0 (via Hooks) |
| Duplizierte Render-Funktionen | 3 Paare (6 total) | 3 Shared (renderHelpers.ts) |
| useTimelineDrag.ts Zeilen | 883 | ~800 |
| Fehlende DB-Composite-Indexes | 4 | 0 |