ddec3a927a
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>
322 lines
16 KiB
Markdown
322 lines
16 KiB
Markdown
# 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 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.
|