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

16 KiB
Raw Blame History

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:

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

    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):

    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:

    {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

    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:

    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

    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.