Major timeline enhancements: - Right-click drag multi-selection with floating action bar (batch delete/assign) - DemandPopover for demand strip details (replaces broken "Loading" modal) - ResourceHoverCard on name hover showing skills, rates, role, chapter - Merged heatmap+vacation tooltips into unified TimelineTooltip component - Fixed overbooking blink animation (date normalization, z-index ordering) - Fixed dark mode sticky column bleed-through in project view - System roles admin page, notification task management, performance review docs Co-Authored-By: claude-flow <ruv@ruv.net>
16 KiB
Right-Click Multi-Selection on Timeline
Anforderungsanalyse
Vier neue Funktionen für die Timeline:
-
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).
-
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.
-
Floating Action Bar — Bei aktiver Multi-Selection erscheint eine schwebende Toolbar mit:
- "Delete / Cancel" — Batch-Löschung aller selektierten Allocations (
allocation.batchDeleteexistiert bereits) - "Assign" — Auf leeren Zeilen: Batch-Erstellung neuer Allocations über
timeline.quickAssign(eine pro selektierter Ressource-Zeile) - "Clear Selection" — Selektion aufheben
- "Delete / Cancel" — Batch-Löschung aller selektierten Allocations (
-
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 bestehendeonContextMenu-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:
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:
- Während des Drag wird ein visuelles Rechteck gezeichnet (semi-transparenter blauer Rahmen)
- Bei mouseUp wird berechnet, welche Allocation-Blocks innerhalb des Rechtecks liegen (Pixel-basiert: Block-Position vs. Selection-Rect)
- 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.batchDeleteexistiert 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:
MultiSelectStateInterface + Initial State definieren → Datei:useTimelineDrag.tsexport 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.tsNeuer
onCanvasRightMouseDownHandler:- Prüfe
e.button === 2 e.preventDefault()(verhindert nativen Kontextmenü)- Starte
multiSelectStatemitisSelecting: trueund Mausposition - Registriere
document.addEventListener("mousemove", ...)unddocument.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: falseaber behalteselectedAllocationIds/selectedResourceIds(werden vom Parent berechnet und reingesetzt)
Neuer
onCanvasContextMenuHandler:- Wird auf dem Canvas registriert, um
e.preventDefault()global zu setzen (verhindert Browser-Kontextmenü)
Return-Werte erweitern um
multiSelectState,setMultiSelectState,onCanvasRightMouseDown,clearMultiSelect. - Prüfe
-
Task 1c:
clearMultiSelectFunktion → Datei:useTimelineDrag.tsSetzt
multiSelectStateauf 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.tsIm bestehenden
onAllocMouseDown-Handler (und im mouseUp-Pfad wodaysDelta === 0als Click behandelt wird):Logik im mouseUp (daysDelta === 0):
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 logicWichtig: 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:
onAllocMouseDownstartet den Drag (button 0 check auf Zeile 311)- Im
handleUpclosure (Zeile 370): WenndaysDelta === 0→ aktuell wirdonBlockClickRef.currentaufgerufen - Änderung: Vor dem
onBlockClick-Call prüfen obshiftKeygedrückt war (muss im mouseDown-Event gespeichert werden, da mouseUp ein document-event ist und das Original-React-Event nicht mehr verfügbar) - →
shiftKeyRefim Closure capturen:const wasShift = e.shiftKey;imonAllocMouseDown
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.onBlockClickmachen, da dieser Callback bereits denmultiSelectState-Zugriff hat.
Task 2: Selection-Overlay in Panels rendern
-
Task 2a: Selection-Rectangle als visuelles Overlay rendern → Datei:
TimelineResourcePanel.tsxProps erweitern um
multiSelectState: MultiSelectState.Neues
<div>im Canvas-Bereich:{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
selectedAllocationIdssind: Extra CSS-Klassering-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
useMemoinTimelineViewContentberechnet. NimmtmultiSelectState+ Layout-Daten (toLeft,toWidth, Row-Höhen, Ressource-Reihenfolge) und gibt{ allocationIds: string[], resourceIds: string[], dateRange }zurück.Algorithmus:
- Konvertiere Pixel-Rechteck zu Datums-Range (via
xToDate) und Zeilen-Range (via Row-Index-Berechnung) - Für jede Ressource im Zeilen-Range: Prüfe alle Allocations ob sie zeitlich überlappen
- Sammle Treffer-IDs
Wichtig: Die Berechnung muss die Scroll-Position des Containers berücksichtigen (
scrollContainerRef.scrollLeft/scrollTop). - Konvertiere Pixel-Rechteck zu Datums-Range (via
-
Task 3b:
setMultiSelectStatemit berechneten IDs updaten → Datei:TimelineView.tsxNach der Intersection-Berechnung via
useEffect: WennmultiSelectStatesich ändert und nicht mehrisSelecting, update dieselectedAllocationIds/selectedResourceIds.
Task 4: Floating Action Bar
-
Task 4a:
FloatingActionBarKomponente erstellen → Datei:FloatingActionBar.tsxinterface 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-700etc. -
Task 4b: FloatingActionBar in
TimelineView.tsxeinbindenRendern 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.
- Delete →
Task 5: Batch-Assign Popover
-
Task 5a:
BatchAssignPopoverKomponente 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:
interface BatchAssignPopoverProps { resourceIds: string[]; startDate: Date; endDate: Date; onClose: () => void; onCreated: () => void; } -
Task 5b:
batchQuickAssignAPI-Endpoint erstellen → Datei:packages/api/src/router/timeline.tsbatchQuickAssign: 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:
onContextMenuauf Canvas-Levele.preventDefault()setzen → Datei:TimelineView.tsxDamit das Browser-Kontextmenü nicht erscheint während der Rechtsklick-Drag aktiv ist.
-
Task 6b: ESC-Handler Priorität anpassen → Datei:
TimelineView.tsxReihenfolge: Multi-Select clear → Popover → NewAllocPopover → DemandModal → ProjectPanel
-
Task 6c: Cursor-Style anpassen → Datei:
TimelineView.tsxcursor-crosshairwenn Multi-Select aktiv. -
Task 6d: Confirmation-Dialog vor Batch-Delete → Datei:
FloatingActionBar.tsxoderTimelineView.tsxEinfacher
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 Errorspnpm --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/scrollTopberücksichtigen. Sonst stimmen die Pixel-Koordinaten nicht mit den Allocation-Positionen überein. - Virtualisierung:
TimelineResourcePanelnutzt@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.
batchQuickAssignLimit: Max 50 Assignments pro Call, um DB-Last zu begrenzen.