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>
This commit is contained in:
2026-03-18 23:43:51 +01:00
parent d0f04f13f8
commit ddec3a927a
67 changed files with 4930 additions and 1166 deletions
+296 -372
View File
@@ -1,397 +1,321 @@
# Enterprise Notification & Task Management System
# Right-Click Multi-Selection on Timeline
## Anforderungsanalyse
### Was wird gebaut?
Ein mehrstufiges Notification- und Task-Management-System, das die bestehende Notification-Infrastruktur (Prisma-Model, Bell-Icon, SSE, SMTP) zu einem vollwertigen Enterprise-System ausbaut. Vier Kernfähigkeiten:
Vier neue Funktionen für die Timeline:
1. **Personal Reminders** — User legen eigene Erinnerungen an (Datum/Zeit, optionale Wiederholung, verknüpft mit Entity)
2. **Targeted Notifications** — Admins/Manager senden Notifications an User, Rollen, Projektbeteiligte, OrgUnits
3. **Task Management** — Actionable Tasks mit Status-Tracking, Dashboard-Widget, Entity-Verknüpfung
4. **AI Assistant Integration** — Assistent liest offene Tasks, führt sie aus (Urlaub genehmigen, Allokation erstellen, etc.)
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).
### Bestehende Infrastruktur (wiederverwendbar)
| Komponente | Status | Datei |
|------------|--------|-------|
| `Notification` Prisma-Model | Vorhanden (einfach) | `packages/db/prisma/schema.prisma:1291` |
| Notification tRPC-Router | list, unreadCount, markRead, create | `packages/api/src/router/notification.ts` |
| NotificationBell + Drawer | Bell-Icon mit Badge, Dropdown-Panel | `apps/web/src/components/notifications/NotificationBell.tsx` |
| SSE EventBus (Redis Pub/Sub) | `NOTIFICATION_CREATED` Event | `packages/api/src/sse/event-bus.ts` |
| SMTP Email | `sendEmail()` + SystemSettings | `packages/api/src/lib/email.ts` |
| AI Assistant Tools | `list_notifications`, `mark_notification_read` | `packages/api/src/router/assistant-tools.ts` |
| Dashboard Widget-Registry | 8 Widgets, Pattern etabliert | `apps/web/src/components/dashboard/widgets/` |
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.
### Betroffene Pakete
- **packages/db** — Schema-Erweiterung (Notification -> Task-Felder, neues Broadcast-Model)
- **packages/shared** — Enums, Typen, Zod-Schemas
- **packages/api** — Router-Erweiterung (notification.ts, assistant-tools.ts), Targeting-Logik, Scheduler
- **apps/web** — UI (Task-Widget, Reminder-UI, Notification-Center, Admin-Panel)
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.
## Datenmodell-Design
### Erweiterung des bestehenden `Notification`-Models
Das bestehende Model wird um Task-/Reminder-/Targeting-Felder erweitert. Kein neues Model nötig — ein einheitliches System für Notifications + Tasks + Reminders.
```prisma
model Notification {
id String @id @default(cuid())
userId String
// -- Typ & Kategorie --
category NotificationCategory @default(NOTIFICATION) // NEU
type String // z.B. "VACATION_REQUESTED", "TASK_ASSIGNED", "REMINDER"
priority NotificationPriority @default(NORMAL) // NEU
// -- Inhalt --
title String
body String?
entityId String?
entityType String?
link String? // NEU: Deep-Link zur relevanten Seite
// -- Task-Felder (nur fuer category TASK / APPROVAL) --
taskStatus TaskStatus? // NEU: OPEN / IN_PROGRESS / DONE / DISMISSED
taskAction String? // NEU: maschinenlesbare Aktion z.B. "approve_vacation:clxyz123"
assigneeId String? // NEU: wem der Task zugewiesen ist
dueDate DateTime? // NEU: Faelligkeitsdatum
completedAt DateTime? // NEU: Zeitpunkt der Erledigung
completedBy String? // NEU: wer hat erledigt (User-ID, oder "ai-assistant")
// -- Reminder-Felder --
remindAt DateTime? // NEU: wann soll erinnert werden
recurrence String? // NEU: "daily" | "weekly" | "monthly" | null
nextRemindAt DateTime? // NEU: naechster Erinnerungszeitpunkt (berechnet)
// -- Targeting-Metadaten (fuer Bulk-Sends) --
sourceId String? // NEU: Referenz auf die urspruengliche Broadcast-Nachricht
senderId String? // NEU: wer hat die Notification erstellt (User-ID)
channel String @default("in_app") // NEU: "in_app" | "email" | "both"
// -- Timestamps --
readAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt // NEU
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
assignee User? @relation("taskAssignee", fields: [assigneeId], references: [id])
sender User? @relation("notificationSender", fields: [senderId], references: [id])
@@index([userId, readAt])
@@index([userId, category, taskStatus]) // NEU: Task-Queries
@@index([nextRemindAt]) // NEU: Reminder-Scheduler
@@index([assigneeId, taskStatus]) // NEU: Assigned-Tasks
@@map("notifications")
}
enum NotificationCategory {
NOTIFICATION // System/Admin-Benachrichtigung (read-only)
REMINDER // Persoenliche Erinnerung (self-created)
TASK // Actionable Task mit Status-Tracking
APPROVAL // Genehmigungsworkflow (approve/reject)
}
enum NotificationPriority {
LOW
NORMAL
HIGH
URGENT
}
enum TaskStatus {
OPEN
IN_PROGRESS
DONE
DISMISSED
}
```
### Broadcast-Model (fuer Gruppen-Notifications)
```prisma
model NotificationBroadcast {
id String @id @default(cuid())
senderId String
title String
body String?
link String?
category NotificationCategory @default(NOTIFICATION)
priority NotificationPriority @default(NORMAL)
channel String @default("in_app")
// -- Targeting --
targetType String // "user" | "role" | "project" | "orgUnit" | "all"
targetValue String? // Role-Name, Project-ID, OrgUnit-ID, oder null fuer "all"
// -- Scheduling --
scheduledAt DateTime? // null = sofort
sentAt DateTime?
recipientCount Int @default(0)
createdAt DateTime @default(now())
sender User @relation(fields: [senderId], references: [id])
@@index([senderId])
@@index([scheduledAt, sentAt])
@@map("notification_broadcasts")
}
```
### Task-Action Registry (Enterprise-Pattern)
Maschinenlesbare Aktionen ermoeglichen dem AI-Assistenten, Tasks direkt zu erledigen:
```typescript
// packages/api/src/lib/task-actions.ts
const TASK_ACTION_REGISTRY: Record<string, TaskActionHandler> = {
"approve_vacation": { permission: "manageVacations", execute: ... },
"reject_vacation": { permission: "manageVacations", execute: ... },
"fill_demand": { permission: "manageAllocations", execute: ... },
"confirm_allocation":{ permission: "manageAllocations", execute: ... },
"review_budget": { permission: "manageProjects", execute: ... },
};
```
Format: `"action_name:entity_id"` — einfach parsbar, erweiterbar.
---
**Betroffene Pakete:** `apps/web` (Frontend), `packages/api` (neuer `batchQuickAssign`-Endpoint)
## Betroffene Pakete & Dateien
| Paket | Dateien | Art |
|-------|---------|-----|
| `packages/db` | `prisma/schema.prisma` | edit — Notification erweitern, Enums, Broadcast-Model |
| `packages/shared` | `src/types/notification.ts` | create — Typen, Enums, Zod-Schemas |
| `packages/shared` | `src/types/enums.ts` | edit — re-exportieren |
| `packages/api` | `src/router/notification.ts` | edit — Task-CRUD, Reminder-CRUD, Broadcast, Targeting |
| `packages/api` | `src/router/index.ts` | edit — ggf. neuen Router registrieren |
| `packages/api` | `src/router/assistant-tools.ts` | edit — neue Tools: list_tasks, execute_task_action, etc. |
| `packages/api` | `src/router/assistant.ts` | edit — TOOL_PERMISSION_MAP + System-Prompt |
| `packages/api` | `src/sse/event-bus.ts` | edit — neue Event-Types |
| `packages/api` | `src/lib/email.ts` | edit — Notification-Email-Templates |
| `packages/api` | `src/lib/notification-targeting.ts` | create — Recipient-Aufloesung |
| `packages/api` | `src/lib/task-actions.ts` | create — Action-Registry |
| `packages/api` | `src/lib/reminder-scheduler.ts` | create — Reminder-Dispatcher |
| `apps/web` | `src/components/notifications/NotificationBell.tsx` | edit — Tabs, Task-Badge |
| `apps/web` | `src/components/notifications/NotificationCenter.tsx` | create — Full-Page |
| `apps/web` | `src/components/notifications/ReminderModal.tsx` | create |
| `apps/web` | `src/components/notifications/BroadcastModal.tsx` | create |
| `apps/web` | `src/components/notifications/TaskCard.tsx` | create |
| `apps/web` | `src/components/dashboard/widgets/TaskWidget.tsx` | create |
| `apps/web` | `src/app/(app)/notifications/page.tsx` | create |
| `apps/web` | `src/app/(app)/admin/notifications/page.tsx` | create |
| `apps/web` | `src/components/layout/AppShell.tsx` | edit — Nav-Links |
| `apps/web` | `src/hooks/useTimelineSSE.ts` | edit — Task-Events |
| 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
## Task-Liste (atomare Schritte)
### 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.
### Phase N.1 — Datenmodell & Shared Types
### Multi-Select State
Neuer State `MultiSelectState` im `useTimelineDrag`-Hook:
- [ ] **Task 1:** Shared-Typen erstellen -> `packages/shared/src/types/notification.ts`
- NotificationCategory, NotificationPriority, TaskStatus Enums
- CreateReminderSchema, CreateBroadcastSchema, UpdateTaskStatusSchema (Zod)
- TaskAction Interface
- [ ] **Task 2:** Prisma-Schema erweitern -> `packages/db/prisma/schema.prisma`
- Notification-Model: category, priority, taskStatus, taskAction, assigneeId, dueDate, completedAt, completedBy, remindAt, recurrence, nextRemindAt, sourceId, senderId, channel, link, updatedAt
- Enums: NotificationCategory, NotificationPriority, TaskStatus
- Model: NotificationBroadcast
- User-Relations: taskAssignee, notificationSender, broadcasts
- Indexes: [userId, category, taskStatus], [nextRemindAt], [assigneeId, taskStatus]
- [ ] **Task 3:** `pnpm db:push` + Dev-Server neu starten
### Phase N.2 — API: Router + Targeting + Scheduler
- [ ] **Task 4:** SSE Event-Types erweitern -> `packages/api/src/sse/event-bus.ts`
- TASK_ASSIGNED, TASK_COMPLETED, TASK_STATUS_CHANGED, REMINDER_DUE, BROADCAST_SENT
- Emit-Helper: emitTaskAssigned(), emitTaskCompleted(), emitReminderDue()
- [ ] **Task 5:** Notification-Router erweitern -> `packages/api/src/router/notification.ts`
- list: Filter nach category, taskStatus, priority
- listTasks (protectedProcedure): offene Tasks + zugewiesene Tasks
- taskCounts (protectedProcedure): Counts nach Status
- updateTaskStatus (protectedProcedure): OPEN->IN_PROGRESS->DONE/DISMISSED
- createReminder (protectedProcedure): eigene Erinnerung anlegen
- updateReminder / deleteReminder (protectedProcedure)
- createBroadcast (managerProcedure): Targeted Notification an Gruppe
- listBroadcasts (managerProcedure)
- createTask (managerProcedure): Task fuer User/Gruppe
- assignTask (managerProcedure): Task zuweisen
- delete (protectedProcedure): eigene Notifications loeschen
- [ ] **Task 6:** Broadcast-Targeting -> `packages/api/src/lib/notification-targeting.ts` (create)
- resolveRecipients(targetType, targetValue, db): User-IDs aufloesen
- "user" -> einzelner User
- "role" -> alle User mit SystemRole
- "project" -> Ressourcen mit aktiver Allokation -> verknuepfte User
- "orgUnit" -> Ressourcen in OrgUnit -> verknuepfte User
- "all" -> alle aktiven User
- [ ] **Task 7:** Email-Templates -> `packages/api/src/lib/email.ts` (edit)
- sendNotificationEmail(userId, notification): HTML mit Title, Body, Deep-Link
- sendTaskEmail(userId, task): Template mit Task-Details + Action-Link
- [ ] **Task 8:** Task-Action-Registry -> `packages/api/src/lib/task-actions.ts` (create)
- Registry-Pattern: action_name -> { permission, execute(entityId, ctx) }
- Initiale Actions: approve_vacation, reject_vacation, fill_demand, confirm_allocation
- [ ] **Task 9:** Reminder-Scheduler -> `packages/api/src/lib/reminder-scheduler.ts` (create)
- Intervall (60s): WHERE nextRemindAt <= NOW()
- Fuer jeden faelligen Reminder: In-App Notification + optional Email
- nextRemindAt neu berechnen oder null setzen
- Catch-up bei Start (ueberfaellige sofort ausloesen)
### Phase N.3 — AI Assistant Integration
- [ ] **Task 10:** Neue Tool-Definitionen -> `packages/api/src/router/assistant-tools.ts`
- list_tasks: offene Tasks/Approvals mit Filter
- get_task_detail: Details inkl. verknuepfter Entity
- update_task_status: Status aendern
- execute_task_action: maschinenlesbare Aktion ausfuehren
- create_reminder: Erinnerung anlegen
- create_task_for_user: Task fuer anderen User (Manager-only)
- send_broadcast: Notification an Gruppe (Manager-only)
- [ ] **Task 11:** Tool-Executors implementieren -> `packages/api/src/router/assistant-tools.ts`
- execute_task_action: parst taskAction-String, dispatcht an Action-Registry
- Permission-Check pro Action (nicht pauschal)
- [ ] **Task 12:** Permission-Map + Prompt -> `packages/api/src/router/assistant.ts`
- TOOL_PERMISSION_MAP erweitern
- System-Prompt: Tasks/Reminders als Faehigkeit beschreiben
### Phase N.4 — Frontend
- [ ] **Task 13:** NotificationBell erweitern -> `apps/web/src/components/notifications/NotificationBell.tsx`
- Zweiter Badge: Task-Count (orange) neben Notification-Count (rot)
- Tabs: "Alle" | "Tasks" | "Erinnerungen"
- Task-Items mit Quick-Actions (Done/Dismiss)
- Link zu "/notifications"
- [ ] **Task 14:** TaskCard-Komponente -> `apps/web/src/components/notifications/TaskCard.tsx` (create)
- Titel, Body, Due-Date, Priority-Badge, Entity-Link
- Aktionen: "Start" / "Done" / "Dismiss"
- Approval-Variante: "Approve" / "Reject"
- Priority-farbcodiert (URGENT=rot, HIGH=orange, NORMAL=blau, LOW=grau)
- [ ] **Task 15:** ReminderModal -> `apps/web/src/components/notifications/ReminderModal.tsx` (create)
- Titel, Body, Datum/Uhrzeit, Wiederholung (keine/taeglich/woechentlich/monatlich)
- Optional: Entity-Verknuepfung (Projekt/Ressource Dropdown)
- [ ] **Task 16:** BroadcastModal -> `apps/web/src/components/notifications/BroadcastModal.tsx` (create)
- Manager/Admin-only
- Targeting: Dropdown (Alle/Rolle/Projekt/OrgUnit) + Wert-Auswahl
- Inhalt: Titel, Body, Priority, Kategorie
- Kanal: In-App / Email / Beides
- Scheduling: Sofort / Zeitgesteuert
- Vorschau: "Wird an X Empfaenger gesendet"
- [ ] **Task 17:** NotificationCenter -> `apps/web/src/app/(app)/notifications/page.tsx` (create)
- Tabs: Alle | Notifications | Tasks | Reminders | Approvals
- Filter: Status, Priority, Zeitraum
- Bulk: "Alle lesen", "Alle erledigt"
- "Neue Erinnerung" Button
- [ ] **Task 18:** TaskWidget -> `apps/web/src/components/dashboard/widgets/TaskWidget.tsx` (create)
- Kompakte Liste offener Tasks (max 5-7)
- Sortiert: Priority -> Due-Date
- Quick-Actions: Done/Dismiss
- Footer: "X offene Tasks — Alle anzeigen"
- In Widget-Registry eintragen
- [ ] **Task 19:** Admin Broadcast-Seite -> `apps/web/src/app/(app)/admin/notifications/page.tsx` (create)
- Liste gesendeter Broadcasts
- "Neue Benachrichtigung senden" Button
- Statistiken: gesendet/gelesen pro Broadcast
- [ ] **Task 20:** AppShell Navigation -> `apps/web/src/components/layout/AppShell.tsx` (edit)
- "Notifications" fuer alle Rollen
- "Broadcast" unter Admin (ADMIN/MANAGER)
- [ ] **Task 21:** SSE-Hook -> `apps/web/src/hooks/useTimelineSSE.ts` (edit)
- Auf TASK_ASSIGNED, TASK_COMPLETED, REMINDER_DUE reagieren
- React-Query invalidieren: notification.listTasks, notification.taskCounts
### Phase N.5 — Auto-Tasks & Audit
- [ ] **Task 22:** Automatische Task-Erzeugung bei Business-Events
- vacation.create -> Task "Urlaubsantrag genehmigen" an Manager (APPROVAL)
- Ueberallokation -> Task "Ueberallokation aufloesen" an Manager
- Projekt-Deadline < 30 Tage + offene Demands -> Task "Demands besetzen"
- demand.create -> Task "Demand besetzen" an Manager
- [ ] **Task 23:** Audit-Trail -> `packages/api/src/lib/audit.ts` (create)
- logTaskAction(taskId, userId, action, details)
- completedBy: "ai-assistant" fuer AI-erledigte Tasks
---
## Abhaengigkeiten
```
Task 1 (Shared Types) ---+
Task 2 (Schema) ---------+--> Task 3 (db:push)
|
Task 3 --> Task 4 (SSE) |
Task 3 --> Task 5 (Router)|
Task 3 --> Task 6 (Targeting)
Task 3 --> Task 7 (Email) |
Task 3 --> Task 8 (Actions)|
|
Task 5 + 6 --> Task 9 (Scheduler)
Task 5 + 8 --> Task 10-12 (AI)
|
Task 5 --> Task 13-21 (Frontend, parallel moeglich)
Task 5 --> Task 22 (Auto-Tasks)
```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;
}
```
**Parallel:**
- Task 4 + 5 + 6 + 7 + 8 (verschiedene Dateien)
- Task 13-21 (verschiedene Dateien, 13+14 vor 17+18 empfohlen)
### 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`)
**Sequentiell:**
- Task 1 -> 2 -> 3 (Schema)
- Task 5 -> 10 -> 11 (Router -> Tools -> Executors)
**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 db:push` ohne Fehler
- [ ] `pnpm --filter @planarchy/api exec tsc --noEmit`0 Errors
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — 0 Errors
- [ ] `pnpm test:unit` — alle Tests gruen
- [ ] User kann eigene Erinnerung anlegen (Datum, Wiederholung, Entity)
- [ ] Admin/Manager kann Broadcast an Rolle/Projekt/OrgUnit senden
- [ ] Broadcast erzeugt individuelle Notifications pro Empfaenger
- [ ] Tasks im Dashboard-Widget, sortiert nach Priority + Due-Date
- [ ] Task-Status aenderbar ueber UI (Open -> In Progress -> Done/Dismissed)
- [ ] AI-Assistent kann list_tasks aufrufen und offene Tasks anzeigen
- [ ] AI-Assistent kann execute_task_action ausfuehren (z.B. Urlaub genehmigen)
- [ ] Erledigte Tasks zeigen completedBy (User oder "AI-Assistent")
- [ ] Email-Versand bei channel "email" oder "both"
- [ ] SSE-Events invalidieren React-Query-Caches
- [ ] Reminder-Scheduler erzeugt puenktlich Notifications
- [ ] RBAC: User sehen nur eigene; Manager zugewiesene; Admin Broadcasts
---
- [ ] `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
### Risiken
1. **Reminder-Scheduler Zuverlaessigkeit**: Node.js-setInterval kann bei Restart verpassen. Mitigation: Catch-up bei Start (alle ueberfaelligen sofort ausloesen).
2. **Broadcast-Skalierung**: "An alle" mit 500 Usern = 500 Rows. Mitigation: Batch-Insert (createMany).
3. **Task-Action-Sicherheit**: Permissions pro Action pruefen, nicht pauschal. Mitigation: Action-Registry mit Permission pro Handler.
4. **Schema-Migration**: Neue Felder nullable oder mit Default -> bestehende Notifications funktionieren weiter.
### Offene Fragen
1. **Scheduler**: setInterval im SSE-Handler oder separater Worker/Cron? Empfehlung: setInterval (reicht fuer <1000 User)
2. **Task-Delegation**: User duerfen Tasks an andere weiterdelegieren? Empfehlung: Ja (Manager-only)
3. **Retention**: Wie lange alte Notifications aufbewahren? Empfehlung: 90 Tage Auto-Cleanup
4. **Recurring Tasks**: Tasks wiederkehrend wie Reminders? Empfehlung: Phase 2
5. **Approval-Chains**: Mehrstufige Genehmigung? Empfehlung: Phase 2, erstmal einstufig
- **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.