Files
CapaKraken/plan.md
T
Hartmut ddec3a927a feat: timeline multi-select, demand popover, resource hover card, merged tooltips, dark mode fixes
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>
2026-03-18 23:43:51 +01:00

322 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<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.