# Right-Click Multi-Selection on Timeline ## Anforderungsanalyse Vier neue Funktionen für die Timeline: 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). 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 | ## 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. ### Multi-Select State Neuer State `MultiSelectState` im `useTimelineDrag`-Hook: ```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; } ``` ### 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 `
` im Canvas-Bereich: ```tsx {multiSelectState.isSelecting && (
)} ``` 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 2–6 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 6a–6d ## 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.