AP1.9a: Arbeitsmodi-Shell mit Scope, Objekt-Routen und Legacy-Redirects.
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Failing after 1m46s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped

Ersetzt die Vorhaben-Tab-Navigation durch Cockpit/Ausführen/Planen/Kontrolle/Team, Deep Links für APs/Projekte/Gates und Programm-Scope im Header.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-08 12:55:37 +02:00
parent 33831a8c2c
commit e42edd86fa
54 changed files with 2489 additions and 150 deletions

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.15.1-ap1.5e" APP_VERSION = "0.16.0-ap1.9a"
DB_SCHEMA_VERSION = "014" DB_SCHEMA_VERSION = "014"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -0,0 +1,146 @@
# ADP — AP1.9 PM Work Modes & Frontend IA Reset v0.1
**Status:** vorgeschlagen (PO-Freigabe ausstehend)
**Stand:** 2026-07-08
**Autor:** Product/Architektur (Cursor-Session, PM-Feedback)
**UI-Konzept:** `docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md`
**Auslöser:** Frontend strukturiert nach Objekten (Vorhaben-Tabs), nicht nach Programmmanager-Arbeitslogik
---
## Problem
Nach AP1.2cAP1.6 existiert ein IA-Skeleton (Workspace, Initiative-Übersicht, Unterseiten), aber:
1. **Alle Arbeitsflächen hängen unter `/initiatives/:id/...`** — Querschnitt (alle Tasks, alle Blocker, Next Step portfolio-weit) fehlt als eigene Fläche.
2. Tab **Ausführung** vermischt Struktur (Projects), Ist (Actions), Blocker — widerspricht Plan/Ist-Trennung und PM-Use-Cases.
3. **Workspace** ist Widget-Sammlung ohne klaren Modus „Cockpit“.
4. Weitere UI-Patches auf Unterseiten **verstärken** das Anti-Pattern (Vision §7, Canonical OM §8.1).
Ohne Entscheidung wird jede weitere Feature-Arbeit (Tasks, KI, Portfolio-Prio) erneut „irgendwo unter Vorhaben“ landen.
---
## Betroffene Regeln
| Dokument | Regel |
|----------|--------|
| Vision v0.2 §7 | Drei Ebenen: Workspace → Initiative-Übersicht → Unterseiten |
| Vision v0.2 §7.17.2 | Workspace = Querschnitt; Initiative-Übersicht = Steuerung im Alltag |
| Canonical OM §8.18.4 | Übersicht entscheidet; getrennte Hauptflächen Plan / Ausführung / Eingang / Journey |
| ADP PO Product Layering | Gates ≠ operativer Plan; Ausführung = Ist-Kern |
| Principle Gate / Sprint0 | Kein weiteres InitiativeDetail-Omnibus |
**Erweiterung (dieses ADP):** Vision-Ebenen bleiben fachlich; **Präsentation** wird um **App-weite Arbeitsmodi** ergänzt. Unterseiten-Inhalte **migrieren in Modi + Scope**, nicht in tiefere Verschachtelung.
---
## Optionen
| Option | Kurzbeschreibung | Pro | Contra |
|--------|------------------|-----|--------|
| **A** | Status quo + weiter patchen | Kein Migrationsaufwand | PM-Use-Cases bleiben unbedienbar; KI ohne Einstieg |
| **B** | Nur Widgets auf Workspace erweitern | Schnell | Kein Plan/Kontrolle-Modus; Vorhaben-Tabs bleiben |
| **C** | **Fünf App-Modi + Scope-Selector** (UI Concept v0.1) | Entspricht PM-Logik; Querschnitt; Agent-ready | Einmaliger IA-Umbau; Redirects nötig |
---
## Entscheidung (Empfehlung: Option C)
### 1. App-Hauptnavigation = Arbeitsmodi
| Modus | Route | Ersetzt / absorbiert |
|-------|-------|----------------------|
| Cockpit | `/cockpit` | `/workspace`, Portfolio-Widgets |
| Ausführen | `/work/*` | `/my-actions`, Tab Ausführung (Ist-Teil) |
| Planen | `/plan/*` | Tabs Plan, Eingang, Project-Struktur |
| Kontrolle | `/control/*` | Initiative-Steuerung, Journey, Plan-Ist-Aggregate |
| Team | `/team` | Workload (neu) |
**Objekt-Routen (first-class, parallel zur Modus-Nav):** `/actions/:id`, `/projects/:id`, `/gates/:id`, `/tasks/:id` (AP1.5d). Beim Öffnen: Scope und aktiver Modus aus Objektdaten ableiten; Redirects von legacy nested URLs.
Vorhaben-Katalog: `/initiatives`**Scope setzen**, nicht primärer Arbeitscontainer.
### 2. Scope-Selector (global)
- Portfolio | Initiative | optional Project
- URL: `?initiative=&project=` (Query) — **Scope Lock** bis ADP v0.2
- React Context: `ProgramScopeProvider` in `AppLayout`
### 3. InitiativeSubNav — deprecate
- Tabs `Steuerung | Zielzustände | Ausführung | Eingang | Nachvollziehbarkeit` werden **entfernt** (nach AP1.9a Redirects)
- Inhalt wandert in Modi (siehe UI Concept §11)
### 4. Harte Guardrails (Scope Lock)
| Verboten | Erlaubt |
|----------|---------|
| Neue Sektionen auf `InitiativeExecutionPage` | Hotfix bis AP1.9a |
| Inline-CRUD auf Modus-Übersichten | Detail-Route / Modal |
| Dritte parallele Navigationshierarchie | Scope + Modus nur |
| Project-Lifecycle / Steering pro Project | ADP Recursive Containers |
### 5. Implementierungspakete
```text
AP1.9a Shell, Routen, Scope, Redirects, Platzhalter-Screens
AP1.9b Cockpit + Work/Heute (AP-Querschnitt)
AP1.9c Control (Snapshot, Journey, Plan-Ist)
AP1.9d Plan (Structure, Gates, Inbox)
AP1.9e Team (Workload)
```
AP1.5d (Tasks) **füllt** `/work` — ändert IA nicht.
AP1.8 (Portfolio-Prio) **erweitert** `/cockpit`.
AP1.7 (Agent API) **mappt** auf Modus-Endpunkte.
---
## Risiko
| Risiko | Mitigation |
|--------|------------|
| Große Migration / gebrochene Bookmarks | Redirect-Tabelle, 6 Monate |
| Doppelpflege alt/neu | Freeze auf alten Tabs ab AP1.9a Start |
| Scope in URL vs. State | Ein Provider, Tests für Deep-Links |
| Überengineering Mobile | 9a nur Desktop+Mobile Nav-Parität; Detail polish later |
---
## Rückbaubarkeit
- Modi-Routen isoliert unter `frontend/src/modes/` (neu)
- Alte Initiative-Routen als Redirect-Layer bis Entfernung
- Scope-Provider optional abschaltbar für Tests
- ADP v0.2 kann Modus-Namen anpassen ohne OM-Änderung
---
## Auswirkung auf Roadmap
| Paket | Änderung |
|-------|----------|
| AP1.5d | Bleibt; liefert Task-Daten für `/work` |
| AP1.4c Gate-UI | Gehört in `/plan/gates`, nicht Initiative-Tab |
| AP1.8 | Cockpit-Feature |
| AP1.7 | Nach AP1.9b+c (Leseschnittstellen stabil) |
**Nächstes Code-Paket nach PO-Freigabe:** AP1.9a (Shell) — **nicht** weiter AP1.5e-UI.
---
## PO-Freigabe
- [ ] Option C angenommen
- [ ] UI Concept v0.1 bestätigt
- [ ] AP1.9ae Reihenfolge OK
- [ ] InitiativeSubNav-Abschaffung OK
---
## Referenzen
- `docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md`
- `docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md` (Abschnitt AP1.9)
- `docs/product/Kairo_Implementation_Truth_Table_v0.1.md`

View File

@ -52,7 +52,24 @@ Siehe `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`.
Siehe `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`. Siehe `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`.
**Reihenfolge:** AP1.5c → AP1.6 → AP1.5d **Reihenfolge:** AP1.5c → AP1.6 → **AP1.9 (PM Work Modes IA)** → AP1.5d
---
## 1.3 PO-Entscheidung 2026-07-08 — PM Work Modes (Frontend IA)
**Auslöser:** Programmmanager-Feedback — Vorhaben-Unterseiten decken Arbeitslogik nicht ab; Querschnitt fehlt.
| Entscheidung | |
|--------------|--|
| App-Navigation = **Arbeitsmodi** (Cockpit, Ausführen, Planen, Kontrolle, Team) | vorgeschlagen |
| Scope = Filter (Portfolio / Vorhaben / Projekt), nicht Navigationstiefe | vorgeschlagen |
| Initiative-Tabs (`InitiativeSubNav`) abschaffen | vorgeschlagen, mit Redirects |
| Kein weiteres UI-Patching auf Ausführungs-Tab | Scope Lock ab AP1.9a |
Siehe `Kairo_PM_Frontend_UI_Concept_v0.1.md`, `ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md`.
**Reihenfolge Code:** AP1.9a (Shell) → 9b9e (Modus-Inhalte); AP1.5d parallel nach 9b wo sinnvoll.
--- ---
@ -69,8 +86,9 @@ Phase F Gate-Backend AP1.4 + AP1.4b ✓ (Backend); UI minimal
Phase G Ist-Hierarchie AP1.5 ✓ Phase G Ist-Hierarchie AP1.5 ✓
Phase G2 Rekursive Container AP1.5c5d ✓ 5c / → 5d nach AP1.6 Phase G2 Rekursive Container AP1.5c5d ✓ 5c / → 5d nach AP1.6
Phase H Plan/Ist-Verknüpfung AP1.6 ✓ Phase H Plan/Ist-Verknüpfung AP1.6 ✓
Phase H2 Rekursive Tasks AP1.5d ← **NÄCHSTES CODE** Phase H2 PM Work Modes IA AP1.9 ← **NÄCHSTES CODE** (PO 2026-07-08)
Phase I Gate-Graph AP1.4d4e nach AP1.6 Phase H3 Rekursive Tasks AP1.5d nach AP1.9b
Phase I Gate-Graph AP1.4d4e nach AP1.9d
Phase J Portfolio & Kontext AP1.8 geplant Phase J Portfolio & Kontext AP1.8 geplant
Phase K Agent Interface AP1.7 geplant (unverändert) Phase K Agent Interface AP1.7 geplant (unverändert)
Phase L Validation AP0.10d (laufend PO) parallel Phase L Validation AP0.10d (laufend PO) parallel
@ -94,6 +112,8 @@ Phase L Validation AP0.10d (laufend PO) parallel
| AP1.4b | 0.13.1-ap1.4b | Gate-Checkliste Backend + Detail-Route | | AP1.4b | 0.13.1-ap1.4b | Gate-Checkliste Backend + Detail-Route |
| AP1.5c | 0.14.1-ap1.5c | Rekursive Projects | | AP1.5c | 0.14.1-ap1.5c | Rekursive Projects |
| AP1.6 | 0.15.0-ap1.6 | Plan/Ist, Journey, Gate-Beiträge | | AP1.6 | 0.15.0-ap1.6 | Plan/Ist, Journey, Gate-Beiträge |
| AP1.5e | 0.15.1-ap1.5e | Projekt-Detail, Lifecycle-Chip (Übergang — ersetzt durch AP1.9) |
| DOC PM UI | 2026-07-08 | PM Frontend UI Concept v0.1, ADP AP1.9 |
| DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset | | DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset |
| DOC Container | 2026-07-06 | ADP Recursive Containers & Execution Granularity | | DOC Container | 2026-07-06 | ADP Recursive Containers & Execution Granularity |
@ -156,6 +176,26 @@ Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline.
--- ---
### AP1.9 — PM Work Modes & Frontend IA Reset ← **NÄCHSTES CODE**
**Ziel:** App-weite Arbeitsmodi statt Vorhaben-Tab-Omnibus; Querschnitt für PM-Use-Cases.
| Teil | Scope |
|------|--------|
| **9a** | App-Shell, Routen `/cockpit`, `/work`, `/plan`, `/control`, `/team`, Scope-Provider, Redirects |
| **9b** | Cockpit + Ausführen/Heute (AP-Querschnitt) |
| **9c** | Kontrolle: Snapshot, Journey, Plan-Ist-Aggregate |
| **9d** | Planen: Struktur, Gates, Inbox (Migration von Initiative-Tabs) |
| **9e** | Team: Workload, Zuweisungsübersicht |
**ADP:** `ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md`
**UI Concept:** `Kairo_PM_Frontend_UI_Concept_v0.1.md`
**Version-Ziel:** `0.16.0-ap1.9a` (Shell), dann inkrementell
**Explizit nicht:** Weitere Features unter `InitiativeExecutionPage` / `InitiativeSubNav`.
---
### AP1.5d — Rekursive Tasks & Roll-up ### AP1.5d — Rekursive Tasks & Roll-up
**Ziel:** Task-Baum (`parent_task_id`); Mensch UI max. ~3 Ebenen; Agent tiefer. **Ziel:** Task-Baum (`parent_task_id`); Mensch UI max. ~3 Ebenen; Agent tiefer.
@ -169,8 +209,8 @@ Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline.
| **Kein** nested Action (Default) | | **Kein** nested Action (Default) |
**ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md` **ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`
**Nach AP1.6** **Nach AP1.9b** (Tasks füllen `/work`, IA bleibt)
**Version:** `0.15.1-ap1.5d` **Version:** `0.16.x-ap1.5d`
--- ---

View File

@ -0,0 +1,444 @@
# Kairo — PM Frontend UI Concept v0.1
**Status:** PO-Arbeitsentwurf (Entscheidung ausstehend)
**Stand:** 2026-07-08
**Auslöser:** Programmmanager-Feedback — Objekt-Hierarchie ohne Arbeitslogik; Vorhaben-Unterseiten als Sackgasse
**Bezug:** Vision v0.2 §7, Canonical OM v0.2 §8, `ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md`
**Ersetzt als UI-Leitbild:** Ad-hoc-Layout unter `InitiativeExecutionPage` / Tab-Omnibus
---
## 1. Problem in einem Satz
Kairo hat **Objekte** (Vorhaben, Projekt, Arbeitspaket) und **Tabs**, aber keine **Arbeitsmodi** — deshalb findet ein Programmmanager weder Querschnitt noch Planung noch Kontrolle, und KI/Agenten haben keinen kanonischen Einstieg.
---
## 2. Leitprinzipien (verbindlich für UI)
| # | Prinzip | Konsequenz |
|---|---------|------------|
| P1 | **Modi für Überblick, Objekte direkt** | Modi beantworten Querschnittsfragen; **bekannte** APs/Projekte/Gates sind per **Deep Link** sofort erreichbar — ohne vorher Modus wählen. |
| P2 | **Scope ist Filter, kein Gefängnis** | Portfolio, ein Vorhaben oder ein Projekt sind **Filter** auf dieselben Modi — nicht separate Welten. |
| P3 | **Querschnitt auf Top-Level** | „Alle meine Tasks“, „Was klemmt?“, „Nächster Schritt?“ leben **global**, nicht unter `/initiatives/:id/...`. |
| P4 | **Übersicht entscheidet, Detail pflegt** | Modi zeigen Steuerungsantworten; Anlage/Bearbeitung nur auf **Objekt-Detail-Routen** oder Modal — nie als dauerhafte Inline-CRUD-Wand. |
| P5 | **Plan ≠ Ausführung ≠ Kontrolle** | Drei kognitive Tätigkeiten — drei Flächen. Nicht in einem Tab stapeln. |
| P6 | **Methodenneutral** | Gates optional; flache Vorhaben ohne Roadmap müssen in jedem Modus sinnvoll leer oder reduziert sein (`generic_operating`). |
| P7 | **Gleiche Logik für Mensch und Agent** | Jeder Modus mappt auf lesbare/schriftbare API-Oberflächen (`steering/`, Workspace Data Layer). |
| P8 | **Stabile Objekt-URLs** | `/actions/:id`, `/projects/:id` usw. sind bookmarkbar; Scope und Modus werden aus dem Objekt **abgeleitet**, nicht manuell vorher gesetzt. |
---
## 3. Navigationsmodell — drei Ebenen
```text
┌─────────────────────────────────────────────────────────────┐
│ EBENE 1 — MODUS (Sidebar): Querschnitt & Arbeitsintention │
│ Cockpit │ Ausführen │ Planen │ Kontrolle │ Team │
└─────────────────────────────────────────────────────────────┘
+
┌─────────────────────────────────────────────────────────────┐
│ EBENE 2 — SCOPE (Header): Filter auf Modus-Inhalte │
│ Portfolio > Vorhaben X > [ optional Projekt Y ] │
└─────────────────────────────────────────────────────────────┘
+
┌─────────────────────────────────────────────────────────────┐
│ EBENE 3 — OBJEKTFokus (Route): Konkretes Ding bearbeiten │
│ /actions/:id /projects/:id /gates/:id … │
└─────────────────────────────────────────────────────────────┘
```
**Modus allein reicht nicht** — du springst oft **direkt** auf ein Arbeitspaket oder Projekt (Link, Lesezeichen, Next-Action-Klick, Agent).
**Modus allein reicht auch nicht ohne Querschnitt** — „Was ist heute dran über alles?“ braucht `/work/heute` ohne vorher ein Vorhaben zu öffnen.
| Ebene | Wann | Beispiel |
|-------|------|----------|
| **Modus** | Ich weiß noch nicht *welches* Objekt, aber *welche Aufgabe* | „Heute“, „Was klemmt?“, „Planen“ |
| **Scope** | Ich will alles in einem Vorhaben/Projekt sehen | Filter auf Queue, Kontrolle, Plan |
| **Objekt** | Ich kenne die ID / den Namen | AP „API-Auth“, Projekt „Phase 2“ |
**Regel:** Objekt-Route hat **Vorrang**. Beim Öffnen von `/actions/:id` setzt die App automatisch Scope (Initiative, ggf. Project) und markiert den passenden Modus in der Sidebar — du musst nicht „erst Ausführen klicken“.
Wechsel des Modus **behält** Scope, solange sinnvoll. Wechsel des Objekts **aktualisiert** Scope aus den Objektdaten.
---
## 4. Die fünf Arbeitsmodi (Product Language)
### 4.1 Cockpit — *Wo stehe ich im Portfolio?*
**Leitfrage:** Welches Vorhaben braucht Aufmerksamkeit — und was ist programmweit als Nächstes dran?
| Inhalt | Quelle (heute / geplant) |
|--------|---------------------------|
| Portfolio-Kacheln mit Lifecycle, Signal, Prio | `InitiativePortfolioWidget`, AP1.8 Prio |
| Attention / Showstopper quer | `AttentionWidget`, Snapshot-Signale |
| Next Action (Portfolio-Scope) | `NextActionWidget` scope=portfolio |
| Kurz-KPIs (offen, blockiert, Gates at risk) | `WorkspaceSummaryWidget` |
**Nicht hier:** Gate-CRUD, Projektbaum, Sprint-Planung im Detail.
**Route:** `/cockpit` (Alias `/workspace` → Redirect)
---
### 4.2 Ausführen — *Was mache ich jetzt konkret?*
**Leitfrage:** Welche committete Arbeit (Arbeitspaket / Task) ist dran — **über alle Vorhaben und Projekte**?
| Inhalt | Beschreibung |
|--------|--------------|
| **Heute** | Mir zugewiesen, überfällig zuerst, Next-Action-Ranking |
| **Meine Queue** | Alle offenen Tasks/APs (filterbar) |
| **Nach Vorhaben / Projekt** | Gruppierung optional — kein Navigationszwang |
| Detail-Drill | Link zu AP-/Task-Detail |
**Nicht hier:** Programmstruktur anlegen, Gates definieren, Portfolio-Priorität setzen.
**Route:** `/work` (+ Unterrouten `/work/today`, `/work/mine`, `/work/queue`)
**Abhängigkeit:** Task-Querschnitt braucht AP1.5d; MVP von 4.2 startet mit **Arbeitspaketen**, Tasks folgen ohne IA-Wechsel.
---
### 4.3 Planen — *Wie soll das Programm aufgebaut sein?*
**Leitfrage:** Struktur, Gates, Eingang — was ist geplant, noch nicht committet?
| Zone | Inhalt |
|------|--------|
| **Struktur** | Project-Baum (Container), optional flach |
| **Zielzustände** | Gates / RoadmapItems, Kriterien, Abhängigkeiten |
| **Eingang** | Backlog, Triage, Convert-to-Action |
Planen ist **scope-pflichtig auf mindestens ein Vorhaben** (Struktur hängt an Initiative). Portfolio-Scope zeigt **Planungs-Inbox**: „Vorhaben ohne Struktur“, „Gates ohne Kriterien“ — Einstieg, nicht Vollplanung.
**Nicht hier:** Tagesausführung, Blocker-Status aller APs, Team-Workload.
**Route:** `/plan` (+ `/plan/structure`, `/plan/gates`, `/plan/inbox`)
**Migration:** Heutige Tabs `plan`, `inbox` und Projektbaum aus `execution` **wandern hierher**.
---
### 4.4 Kontrolle — *Erreichen wir die Ziele? Was blockiert den Fortschritt?*
**Leitfrage:** Plan vs. Ist, Zielerreichung, Risiken, kritischer Pfad, Showstopper.
| Zone | Inhalt |
|------|--------|
| **Programm-Status** | Lifecycle, Snapshot, Signale (pro Scope) |
| **Plan vs. Ist** | Gate-Beiträge, offene Kriterien, Journey |
| **Roadblocker** | Blockierte APs, offene Blocker, Gates at risk |
| **Kritischer Pfad** | Gate-Graph + Ist-Blocker (AP1.4d+, progressive) |
**Nicht hier:** Neue APs anlegen, Backlog triagieren.
**Route:** `/control` (+ `/control/status`, `/control/plan-ist`, `/control/journey`)
**Migration:** Teile von Initiative-Übersicht + Journey + Gate-Detail-Contributions.
---
### 4.5 Team — *Wer arbeitet an was?*
**Leitfrage:** Zuweisungen, Kapazität, Delegation.
| Inhalt | Quelle |
|--------|--------|
| Actor-Workload | `GET /api/workspace/actors/workload` |
| Offene Zuweisungen ohne Fortschritt | Snapshot / Assignments |
| Delegation (später) | Bulk-Zuweisung, Umhängen |
**Route:** `/team`
---
## 5. Vorhaben-Katalog — nicht der Arbeitsort
`/initiatives` wird **Katalog & Scope-Auswahl**, kein Omnibus:
- Liste / Suche aller Vorhaben
- Anlegen neues Vorhaben
- Klick setzt **Scope-Filter** und springt in sinnvollen Default-Modus (Cockpit scoped oder Kontrolle scoped) — **nicht** in verschachtelte Tab-Welt
**Abschaffen (Ziel):** Vorhaben als „App-in-App“ mit fünf Tabs, in denen alles passiert.
**Legacy-Redirects:** Alte URLs `/initiatives/:id/plan``/plan/gates?initiative=:id` etc. (siehe ADP AP1.9).
---
## 6. Direkte Objektnavigation (Deep Links)
### 6.1 Stabile Objekt-Routen (first-class)
Diese URLs funktionieren **ohne** vorherigen Modus- oder Scope-Schritt. Sie sind sharebar, bookmarkbar, Agent-Ziele.
| Objekt | Route (Ziel) | Modus (Sidebar) | Scope (auto) |
|--------|--------------|-----------------|--------------|
| **Arbeitspaket** | `/actions/:actionId` | Ausführen | Initiative + Project aus AP |
| **Task** (AP1.5d) | `/tasks/:taskId` | Ausführen | über Action → Initiative |
| **Projekt** | `/projects/:projectId` | Planen | Initiative + Project |
| **Gate** | `/gates/:gateId` | Planen (Detail) / Kontrolle (Ist) | Initiative |
| **Vorhaben** | `/initiatives/:id` | Kontrolle (Status-Home scoped) | Initiative |
| **Backlog-Item** | `/backlog/:id` | Planen → Inbox | Initiative |
**Bestehende Route** `/initiatives/:id/actions/:actionId` → Redirect auf `/actions/:actionId` (kanonische URL).
API-IDs sind global im Tenant — Pfad **ohne** `/initiatives/:id/...`-Präfix ist gewollt (kürzer, agentenfreundlich).
### 6.2 Verhalten beim Direktsprung
```text
Nutzer öffnet /actions/abc-123
GET /api/actions/abc-123
├── Scope: initiative_id, project_id gesetzt
├── Sidebar: „Ausführen“ aktiv
├── Breadcrumb: Ausführen {Vorhaben} {AP-Titel}
└── Inhalt: AP-Detail (wie heute ActionDetailPage)
```
**Zurück-Navigation:** „Zur Queue“ → `/work/mine?initiative=` (Scope bleibt), nicht in Tab-Hölle.
### 6.3 Schnellzugriff (App-Chrome)
Neben Modus-Nav braucht es **Sprung-Hilfen** — unabhängig vom Modus:
| Element | Funktion | Phase |
|---------|----------|-------|
| **Springen zu…** (Suche) | Titel/ID: AP, Projekt, Gate, Vorhaben | AP1.9a minimal (Liste), 9f Volltext |
| **Zuletzt geöffnet** | 510 Objekte | AP1.9a |
| **Angepinnt** (optional) | PM pinnt laufende APs/Projekte | AP1.9b+ |
| Links aus Widgets | Next Action, Attention, Queue → Objekt-Route | AP1.9b |
```text
┌──────────┬──────────────────────────────────────────────────┐
│ … │ [ 🔍 Springen zu… ] Scope: Portfolio ▼ │
│ Cockpit ├──────────────────────────────────────────────────┤
```
Command Palette (⌘K) ist **optional später** — MVP: Suchfeld mit Typ-Ahead über offene APs/Projekte/Vorhaben.
### 6.4 Zwei Wege zum gleichen AP (beide gültig)
| Weg | Nutzen |
|-----|--------|
| **Querschnitt** | `/work/heute` → Liste → Klick | „Was ist überall dran?“ |
| **Direkt** | `/actions/:id` oder Suche | „Ich weiß schon, welches AP“ |
Das ist kein Widerspruch zu Modus-Nav — **Modi listen auf, Objekt-Routen fokussieren**.
---
## 7. Scope-Selector (globale Komponente)
Im **App-Chrome** (Sidebar-Header / Mobile-Header), in allen Modi sichtbar:
```text
┌─────────────────────────────────────────────┐
│ Scope: [ Portfolio ▼ ] > [ optional ] │
└─────────────────────────────────────────────┘
```
| Scope | Filterwirkung |
|-------|----------------|
| **Portfolio** | Alle Initiativen im Tenant |
| **Vorhaben** | Eine Initiative (+ implizit deren Projekte/APs) |
| **Projekt** | Subtree eines Blatt-/Container-Projects |
Scope wird in URL codiert: Query `?initiative=&project=` auf Modus-Listen; **Objekt-Routen** brauchen keinen Query (Scope aus Objekt). Bei Wechsel Scope → Modus-Liste aktualisieren; bei Objekt-Route Scope read-only anzeigen (änderbar über Scope-Dropdown → navigiert zur scoped Modus-Liste).
---
## 8. Routen-Zielbild (v0.1)
```text
── Modus-Listen (Querschnitt / scoped) ──
/cockpit Portfolio-Cockpit
/cockpit?initiative=:id Programm-Cockpit (scoped)
/work Ausführen — Default: Heute
/work/mine Meine Queue (APs → später Tasks)
/work/today Heute (überfällig, Next Action)
/work?initiative=:id Queue scoped
/plan/structure?initiative=:id Project-Baum
/plan/gates?initiative=:id Zielzustände
/plan/inbox?initiative=:id Backlog / Triage
/control/status?initiative=:id Lifecycle, Snapshot
/control/plan-ist?initiative=:id Gate-Beiträge
/control/journey?initiative=:id Timeline
/team Workload & Zuweisungen
/initiatives Katalog
/initiatives/new Anlage
── Objekt-Routen (Direktsprung, Scope auto) ──
/actions/:actionId Arbeitspaket-Detail ★
/tasks/:taskId Task-Detail (AP1.5d)
/projects/:projectId Projekt-Detail ★
/gates/:gateId Gate-Detail
/backlog/:backlogId Backlog-Detail
/initiatives/:id Vorhaben-Status (Kontrolle scoped)
── Legacy-Redirects ──
/initiatives/:id/actions/:aid → /actions/:aid
/initiatives/:id/execution/projects/:pid → /projects/:pid
/initiatives/:id/plan/items/:gid → /gates/:gid
```
★ = häufigste Direktsprünge für PM im Alltag.
**Objekt-Details** sind **keine Unterseiten eines Vorhabens** — sie sind tenant-globale Routen mit automatischem Kontext.
---
## 9. Screen-Matrix (PM Use Cases → Modus)
| PM Use Case | Primärer Modus | Sekundär |
|-------------|----------------|----------|
| Programm/Vorhaben strukturieren | **Planen** → Struktur | Planen → Gates |
| Meilensteine / Gates planen | **Planen** → Gates | Kontrolle (Ist-Beitrag lesen) |
| Roadmap / Abhängigkeiten | **Planen** → Gates | AP1.4d Graph |
| Sprint / nächste APs planen | **Planen** → Inbox + Struktur | Ausführen (Queue) |
| Konkrete nächste Schritte tun | **Ausführen** | Cockpit (Next Action) |
| Status innerhalb AP | **Ausführen** → Detail | — |
| Gesamtfortschritt / Zielerreichung | **Kontrolle** | Cockpit |
| Risiken / Showstopper | **Kontrolle** + Cockpit Attention | — |
| Kritischer Pfad | **Kontrolle** (später Graph) | — |
| Wer arbeitet an was | **Team** | Ausführen (Filter Actor) |
| Nächsten besten Schritt | **Cockpit** + **Ausführen/Heute** | Steering API |
| Beauftragung / Delegation | **Team** | AP-Detail |
| Direkt zu AP / Projekt springen | **Objekt-Route** `/actions/:id`, `/projects/:id` | Suche, Zuletzt |
| Alle Tasks quer (alle Projekte) | **Ausführen** → Queue | AP1.5d Tasks |
---
## 10. Layout-Anatomie (Wireframe)
### 10.1 App-Shell (Desktop)
```text
┌──────────┬──────────────────────────────────────────────────┐
│ Kairo │ [ Springen zu… ] Scope: Portfolio ▼ │
│ ├──────────────────────────────────────────────────┤
│ Cockpit │ MODUS- oder OBJEKT-INHALT │
│ Ausführen│ (Liste/Übersicht ODER AP/Projekt-Detail) │
│ Planen │ │
│ Kontrolle│ Breadcrumb: Ausführen Vorhaben AP-Titel │
│ Team │ │
│──────────│ Zuletzt: AP Auth · Projekt Phase 2 · … │
│ Vorhaben │ │
│ User │ │
└──────────┴──────────────────────────────────────────────────┘
```
### 10.2 Mobile
- Bottom-Nav: **Cockpit | Ausführen | Planen | Mehr** (Kontrolle + Team unter „Mehr“)
- Scope-Selector im Header
### 10.3 Anti-Pattern (ab AP1.9 verboten)
```text
✗ InitiativeExecutionPage = Projektbaum + AP-Hub + Blocker + Inline-Forms
✗ Tab „Ausführung“ als Müllhalde für Planung und Ist
✗ Querschnitt nur als kleines Widget ohne eigene Fläche
✗ AP-Detail nur unter /initiatives/:id/actions/:id erreichbar (kein /actions/:id)
```
---
## 11. Daten & API — Modus-Map (keine Parallel-Logik)
| Modus | Read Models / APIs |
|-------|---------------------|
| Cockpit | `workspace/*`, `attention`, `next-actions`, Initiative-Liste |
| Ausführen | `workspace/actions/*`, `actions/me/open`, Tasks (AP1.5d) |
| Planen | `projects`, `roadmap/*`, `backlog/*` |
| Kontrolle | `steering-snapshot`, `journey`, `plan-ist` contributions |
| Team | `actors`, `workspace/actors/workload`, Assignments |
Alles weiter über `TenantContext` + Capabilities — Modus ist **pure IA**, keine neue Domäne.
---
## 12. Migrationsplan — vom Ist zum Zielbild
| Heute | Ziel | Phase |
|-------|------|-------|
| `/workspace` Widget-Grid | `/cockpit` strukturierte Flächen | 9b |
| `/my-actions` | `/work/mine` | 9b |
| `/initiatives/:id` Steuerung | `/control/status?initiative=` | 9c |
| Tab Plan | `/plan/gates?initiative=` | 9d |
| Tab Eingang | `/plan/inbox?initiative=` | 9d |
| Tab Ausführung (Stack) | `/work?initiative=` + AP-Detail | 9c |
| Tab Journey | `/control/journey?initiative=` | 9c |
| Projekt-Detail unter execution | `/projects/:projectId` | 9d |
| AP-Detail nested unter initiative | `/actions/:actionId` (kanonisch) | 9a |
| `InitiativeSubNav` Tabs | **Entfernen** — Modus-Nav global | 9a |
Redirects mindestens **6 Monate** oder bis PO schließt.
---
## 13. Umsetzungspakete (Roadmap)
| Paket | Ergebnis | Nutzen für PM |
|-------|----------|----------------|
| **AP1.9a** | App-Shell, Modi-Routen, **Objekt-Routen**, Scope-Provider, Springen-zu (minimal), Redirects | Navigation + Direktsprung |
| **AP1.9b** | Cockpit + Ausführen/Heute (AP-Querschnitt) | Täglicher Einstieg |
| **AP1.9c** | Kontrolle (Snapshot, Journey, Plan-Ist) | Status & Zielerreichung |
| **AP1.9d** | Planen (Struktur, Gates, Inbox) | Planung ausgelagert |
| **AP1.9e** | Team + Delegation-Grundlagen | Wer macht was |
| **AP1.5d** | Tasks in Ausführen-Queue | Feingranulare Querschnittsliste |
| **AP1.8** | Portfolio-Priorität im Cockpit | Welches Vorhaben zuerst |
**Kein weiteres AP1.5x-UI-Patch** auf `InitiativeExecutionPage` außer Hotfixes bis AP1.9a live ist.
---
## 14. MVP-Nutzbarkeit (neu formuliert)
Kairos Frontend ist **für Programmmanager nutzbar**, wenn:
1. **Ausführen/Heute** in ≤30s die nächste Arbeit **portfolio-weit** zeigt
2. **Cockpit** in ≤30s zeigt, welches Vorhaben Aufmerksamkeit braucht
3. **Planen** und **Ausführen** ohne gemeinsame Seite erreichbar sind
4. **Kontrolle** Plan vs. Ist und Blocker **ohne CRUD-Listen** aggregiert
5. Scope-Wechsel von Portfolio → Vorhaben **ohne Modus-Wechsel** funktioniert
6. Agent-API kann dieselben aggregierten Read Models ansprechen (Vorbereitung AP1.7)
7. **Direktsprung** auf AP/Projekt per URL oder Suche — Scope korrekt gesetzt
---
## 15. Offene PO-Entscheide (vor AP1.9a Code)
| # | Frage | Empfehlung |
|---|-------|------------|
| 1 | URL-Scope: Query vs. Pfad | Query `?initiative=` + Context |
| 2 | Default nach Vorhaben-Klick | Cockpit scoped |
| 3 | `/workspace` Redirect | Ja → `/cockpit` |
| 4 | Tab-InitiativeSubNav entfernen | Ja, mit Redirects |
| 5 | Planen ohne Scope | Inbox leer + CTA „Vorhaben wählen“ |
| 6 | Kanonische AP-URL | `/actions/:id` (Redirect von nested) |
| 7 | Kanonische Projekt-URL | `/projects/:id` |
---
## 16. Referenzen
- `Kairo_Vision_and_Product_Direction_v0.2.md` §7
- `Kairo_Canonical_Operating_Model_v0.2.md` §8
- `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`
- `ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md` (Scope Lock)
- `Kairo_Implementation_Truth_Table_v0.1.md` (wird mit AP1.9 aktualisiert)
---
*Dieses Dokument ist das führende UI-Zielbild ab 2026-07-08 bis PO-Freigabe oder v0.2. Implementierung ohne Alignment hier → Principle Gate.*

View File

@ -1,24 +1,35 @@
import { Navigate, Route, Routes, useParams } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom'
import { SessionProvider, useSession } from './context/SessionContext.jsx' import { SessionProvider, useSession } from './context/SessionContext.jsx'
import { ProgramScopeProvider } from './context/ProgramScopeContext.jsx'
import { AppLayout } from './layout/AppLayout.jsx' import { AppLayout } from './layout/AppLayout.jsx'
import { InitiativeLayout } from './layout/InitiativeLayout.jsx'
import { AuthPanel } from './pages/AuthPanel.jsx' import { AuthPanel } from './pages/AuthPanel.jsx'
import { ActionDetailPage } from './pages/ActionDetailPage.jsx' import { ActionDetailPage } from './pages/ActionDetailPage.jsx'
import { InitiativeOverviewPage } from './pages/initiative/InitiativeOverviewPage.jsx' import { ProjectObjectPage } from './pages/objects/ProjectObjectPage.jsx'
import { InitiativePlanPage } from './pages/initiative/InitiativePlanPage.jsx' import { GateObjectPage } from './pages/objects/GateObjectPage.jsx'
import { InitiativeExecutionPage } from './pages/initiative/InitiativeExecutionPage.jsx' import {
import { ProjectDetailPage } from './pages/initiative/ProjectDetailPage.jsx' VIEWS,
import { InitiativeInboxPage } from './pages/initiative/InitiativeInboxPage.jsx' WorkLayout,
import { InitiativeJourneyPage } from './pages/initiative/InitiativeJourneyPage.jsx' WorkIndexRedirect,
import { RoadmapItemDetailPage } from './pages/initiative/RoadmapItemDetailPage.jsx' PlanLayout,
import { VIEWS } from './registry/viewRegistry.js' PlanIndexRedirect,
ControlLayout,
ControlIndexRedirect,
MODE_ROUTE_COMPONENTS,
} from './registry/viewRegistry.js'
import {
WorkspaceRedirect,
MyActionsRedirect,
InitiativeRootRedirect,
InitiativePlanRedirect,
InitiativeExecutionRedirect,
InitiativeInboxRedirect,
InitiativeJourneyRedirect,
InitiativeGateRedirect,
InitiativeProjectRedirect,
InitiativeActionRedirect,
} from './pages/LegacyRedirects.jsx'
import './app.css' import './app.css'
function InitiativeLegacyDetailRedirect() {
const { id } = useParams()
return <Navigate to={`/initiatives/${id}`} replace />
}
function AppRoutes() { function AppRoutes() {
const { isAuthenticated, loading } = useSession() const { isAuthenticated, loading } = useSession()
@ -30,30 +41,69 @@ function AppRoutes() {
return <AuthPanel /> return <AuthPanel />
} }
const topLevelViews = VIEWS.filter((v) => v.key !== 'kairo.initiative_detail') const topLevelViews = VIEWS.filter(
(v) =>
![
'kairo.workspace',
'kairo.my_actions',
'kairo.work',
'kairo.plan',
'kairo.control',
].includes(v.key),
)
return ( return (
<ProgramScopeProvider>
<Routes> <Routes>
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
{topLevelViews.map((view) => ( {topLevelViews.map((view) => (
<Route key={view.key} path={view.path} element={<view.component />} /> <Route key={view.key} path={view.path} element={<view.component />} />
))} ))}
<Route path="/initiatives/:id" element={<InitiativeLayout />}>
<Route index element={<InitiativeOverviewPage />} /> <Route path="/work" element={<WorkLayout />}>
<Route path="plan" element={<InitiativePlanPage />} /> <Route index element={<WorkIndexRedirect />} />
<Route path="plan/items/:itemId" element={<RoadmapItemDetailPage />} /> <Route path="today" element={<MODE_ROUTE_COMPONENTS.workToday />} />
<Route path="execution" element={<InitiativeExecutionPage />} /> <Route path="mine" element={<MODE_ROUTE_COMPONENTS.workMine />} />
<Route path="execution/projects/:projectId" element={<ProjectDetailPage />} />
<Route path="inbox" element={<InitiativeInboxPage />} />
<Route path="journey" element={<InitiativeJourneyPage />} />
<Route path="actions/:actionId" element={<ActionDetailPage nested />} />
</Route> </Route>
<Route path="/initiatives/:id/detail" element={<InitiativeLegacyDetailRedirect />} />
<Route path="/plan" element={<PlanLayout />}>
<Route index element={<PlanIndexRedirect />} />
<Route path="structure" element={<MODE_ROUTE_COMPONENTS.planStructure />} />
<Route path="gates" element={<MODE_ROUTE_COMPONENTS.planGates />} />
<Route path="inbox" element={<MODE_ROUTE_COMPONENTS.planInbox />} />
</Route>
<Route path="/control" element={<ControlLayout />}>
<Route index element={<ControlIndexRedirect />} />
<Route path="status" element={<MODE_ROUTE_COMPONENTS.controlStatus />} />
<Route path="journey" element={<MODE_ROUTE_COMPONENTS.controlJourney />} />
</Route>
<Route path="/projects/:projectId" element={<ProjectObjectPage />} />
<Route path="/gates/:gateId" element={<GateObjectPage />} />
<Route path="/actions/:actionId" element={<ActionDetailPage />} /> <Route path="/actions/:actionId" element={<ActionDetailPage />} />
<Route path="/" element={<Navigate to="/workspace" replace />} />
<Route path="*" element={<Navigate to="/workspace" replace />} /> <Route path="/workspace" element={<WorkspaceRedirect />} />
<Route path="/my-actions" element={<MyActionsRedirect />} />
<Route path="/initiatives/:id" element={<InitiativeRootRedirect />} />
<Route path="/initiatives/:id/plan" element={<InitiativePlanRedirect />} />
<Route path="/initiatives/:id/plan/items/:itemId" element={<InitiativeGateRedirect />} />
<Route path="/initiatives/:id/execution" element={<InitiativeExecutionRedirect />} />
<Route
path="/initiatives/:id/execution/projects/:projectId"
element={<InitiativeProjectRedirect />}
/>
<Route path="/initiatives/:id/inbox" element={<InitiativeInboxRedirect />} />
<Route path="/initiatives/:id/journey" element={<InitiativeJourneyRedirect />} />
<Route path="/initiatives/:id/actions/:actionId" element={<InitiativeActionRedirect />} />
<Route path="/initiatives/:id/detail" element={<InitiativeRootRedirect />} />
<Route path="/" element={<Navigate to="/cockpit" replace />} />
<Route path="*" element={<Navigate to="/cockpit" replace />} />
</Route> </Route>
</Routes> </Routes>
</ProgramScopeProvider>
) )
} }

View File

@ -2,3 +2,4 @@
@import './styles/shell.css'; @import './styles/shell.css';
@import './styles/components.css'; @import './styles/components.css';
@import './styles/pages.css'; @import './styles/pages.css';
@import './styles/program-chrome.css';

View File

@ -1,25 +1,30 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { scopedPath } from '../utils/routes.js'
const AREAS = [ const AREAS = [
{ {
to: 'plan', to: '/plan/gates',
title: 'Plan', title: 'Plan',
description: 'Roadmap, Gates und Plan-Ist — methodenneutral pflegen.', description: 'Roadmap, Gates und Plan-Ist — methodenneutral pflegen.',
countKey: 'roadmapItems',
}, },
{ {
to: 'execution', to: '/work/today',
title: 'Ausführung', title: 'Ausführung',
description: 'Arbeitspakete, Zuweisungen, Blocker am Objekt.', description: 'Arbeitspakete, Zuweisungen, Blocker am Objekt.',
countKey: 'actionsOpen',
}, },
{ {
to: 'inbox', to: '/plan/inbox',
title: 'Eingang', title: 'Eingang',
description: 'Backlog triagieren und in Arbeitspakete überführen.', description: 'Backlog triagieren und in Arbeitspakete überführen.',
countKey: 'backlog',
}, },
{ {
to: 'journey', to: '/control/journey',
title: 'Nachvollziehbarkeit', title: 'Nachvollziehbarkeit',
description: 'Entscheidungen, Reviews, Nachweise über die Zeit.', description: 'Entscheidungen, Reviews, Nachweise über die Zeit.',
countKey: null,
}, },
] ]
@ -30,17 +35,17 @@ export function InitiativeNavCards({ initiativeId, counts = {} }) {
<div className="initiative-nav-cards-grid"> <div className="initiative-nav-cards-grid">
{AREAS.map((area) => { {AREAS.map((area) => {
const hint = const hint =
area.to === 'inbox' && counts.backlog area.countKey === 'backlog' && counts.backlog
? `${counts.backlog} Backlog` ? `${counts.backlog} Backlog`
: area.to === 'execution' && counts.actionsOpen : area.countKey === 'actionsOpen' && counts.actionsOpen
? `${counts.actionsOpen} offen` ? `${counts.actionsOpen} offen`
: area.to === 'plan' && counts.roadmapItems : area.countKey === 'roadmapItems' && counts.roadmapItems
? `${counts.roadmapItems} Plan-Elemente` ? `${counts.roadmapItems} Plan-Elemente`
: null : null
return ( return (
<Link <Link
key={area.to} key={area.to}
to={`/initiatives/${initiativeId}/${area.to}`} to={scopedPath(area.to, { initiativeId })}
className="initiative-nav-card card" className="initiative-nav-card card"
> >
<h3>{area.title}</h3> <h3>{area.title}</h3>

View File

@ -0,0 +1,16 @@
import { MODE_META } from '../utils/routes.js'
export function ModeShell({ modeKey, actions }) {
const meta = MODE_META[modeKey]
if (!meta) return null
return (
<header className="page-header mode-shell">
<div>
<h1>{meta.title}</h1>
<p className="page-lead">{meta.lead}</p>
</div>
{actions ? <div className="page-header__actions">{actions}</div> : null}
</header>
)
}

View File

@ -0,0 +1,26 @@
import { NavLink } from 'react-router-dom'
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
/**
* @param {{ items: { to: string, label: string, end?: boolean }[] }} props
*/
export function ModeSubNav({ items }) {
const { hrefWithScope } = useProgramScope()
return (
<nav className="mode-subnav" aria-label="Modus-Unternavigation">
{items.map((item) => (
<NavLink
key={item.to}
to={hrefWithScope(item.to)}
end={item.end !== false}
className={({ isActive }) =>
'mode-subnav__link' + (isActive ? ' mode-subnav__link--active' : '')
}
>
{item.label}
</NavLink>
))}
</nav>
)
}

View File

@ -0,0 +1,62 @@
import { Link } from 'react-router-dom'
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
import { initiativeCatalogPath, scopedPath } from '../utils/routes.js'
export function ProgramScopeBar() {
const {
initiativeId,
projectId,
initiativeTitle,
projectTitle,
clearScope,
setScope,
} = useProgramScope()
return (
<div className="program-scope-bar" aria-label="Programm-Scope">
<span className="program-scope-bar__label">Scope</span>
<nav className="program-scope-bar__trail">
<Link
to={scopedPath('/cockpit')}
className={
'program-scope-bar__crumb' +
(!initiativeId ? ' program-scope-bar__crumb--active' : '')
}
onClick={() => clearScope()}
>
Portfolio
</Link>
{initiativeId && (
<>
<span className="program-scope-bar__sep" aria-hidden="true">
</span>
<Link
to={scopedPath('/control/status', { initiativeId })}
className={
'program-scope-bar__crumb' +
(!projectId ? ' program-scope-bar__crumb--active' : '')
}
onClick={() => setScope({ projectId: '', clearProject: true })}
>
{initiativeTitle || 'Vorhaben'}
</Link>
</>
)}
{projectId && (
<>
<span className="program-scope-bar__sep" aria-hidden="true">
</span>
<span className="program-scope-bar__crumb program-scope-bar__crumb--active">
{projectTitle || 'Projekt'}
</span>
</>
)}
</nav>
<Link to={initiativeCatalogPath()} className="program-scope-bar__catalog link-inline">
Vorhaben-Katalog
</Link>
</div>
)
}

View File

@ -33,7 +33,7 @@ function ProjectTreeNodes({
> >
<div className="list-item-main project-tree-item-main"> <div className="list-item-main project-tree-item-main">
<Link <Link
to={`/initiatives/${initiativeId}/execution/projects/${project.id}`} to={projectPath(project.id)}
className="project-tree-title-link" className="project-tree-title-link"
> >
<strong>{project.title}</strong> <strong>{project.title}</strong>
@ -117,7 +117,7 @@ export function ProjectsSection({
{canManage && ( {canManage && (
<Link <Link
className="btn btn-primary btn-block-mobile" className="btn btn-primary btn-block-mobile"
to={`/initiatives/${initiativeId}/execution/projects/new`} to={`${projectPath('new')}?initiative=${initiativeId}`}
> >
Neues Projekt Neues Projekt
</Link> </Link>

View File

@ -0,0 +1,179 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { listInitiatives } from '../api/initiatives.js'
import { listWorkspaceOpenActions } from '../api/workspace.js'
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
import { loadRecentObjects } from '../utils/recentObjects.js'
import {
actionPath,
gatePath,
initiativeCatalogPath,
projectPath,
scopedPath,
} from '../utils/routes.js'
const TYPE_LABELS = {
action: 'Arbeitspaket',
project: 'Projekt',
gate: 'Gate',
initiative: 'Vorhaben',
}
function matchQuery(text, query) {
return (text || '').toLowerCase().includes(query.toLowerCase())
}
export function QuickJump() {
const navigate = useNavigate()
const { applyFromInitiative } = useProgramScope()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [initiatives, setInitiatives] = useState([])
const [actions, setActions] = useState([])
const [recent, setRecent] = useState([])
const rootRef = useRef(null)
useEffect(() => {
let cancelled = false
Promise.all([
listInitiatives().catch(() => []),
listWorkspaceOpenActions().catch(() => []),
]).then(([initData, actionData]) => {
if (cancelled) return
setInitiatives(initData)
setActions(actionData)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
setRecent(loadRecentObjects())
}, [open])
useEffect(() => {
function onDocClick(e) {
if (rootRef.current && !rootRef.current.contains(e.target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [])
const results = useMemo(() => {
const q = query.trim()
if (!q) return []
const hits = []
for (const item of initiatives) {
if (matchQuery(item.title, q)) {
hits.push({
key: `initiative-${item.id}`,
label: item.title,
meta: 'Vorhaben',
href: scopedPath('/control/status', { initiativeId: item.id }),
onSelect: () => applyFromInitiative(item.id),
})
}
}
for (const action of actions) {
if (matchQuery(action.title, q)) {
hits.push({
key: `action-${action.id}`,
label: action.title,
meta: 'Arbeitspaket',
href: actionPath(action.id),
})
}
}
return hits.slice(0, 8)
}, [query, initiatives, actions, applyFromInitiative])
const go = useCallback(
(href, onSelect) => {
if (onSelect) onSelect()
navigate(href)
setOpen(false)
setQuery('')
},
[navigate],
)
return (
<div className="quick-jump" ref={rootRef}>
<button
type="button"
className="quick-jump__trigger"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="listbox"
>
Springen zu
</button>
{open && (
<div className="quick-jump__panel">
<input
type="search"
className="quick-jump__input"
placeholder="Vorhaben, Arbeitspaket …"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
<ul className="quick-jump__list" role="listbox">
{query.trim() === '' &&
recent.map((item) => (
<li key={`recent-${item.type}-${item.id}`}>
<button
type="button"
className="quick-jump__item"
onClick={() => go(item.href)}
>
<span className="quick-jump__item-label">{item.title}</span>
<span className="quick-jump__item-meta">
{TYPE_LABELS[item.type] || item.type} · zuletzt
</span>
</button>
</li>
))}
{query.trim() !== '' &&
results.map((item) => (
<li key={item.key}>
<button
type="button"
className="quick-jump__item"
onClick={() => go(item.href, item.onSelect)}
>
<span className="quick-jump__item-label">{item.label}</span>
<span className="quick-jump__item-meta">{item.meta}</span>
</button>
</li>
))}
{query.trim() !== '' && results.length === 0 && (
<li className="quick-jump__empty">Keine Treffer</li>
)}
{query.trim() === '' && recent.length === 0 && (
<li className="quick-jump__empty">Tippe zum Suchen oder öffne ein Objekt.</li>
)}
</ul>
<div className="quick-jump__footer">
<button
type="button"
className="link-inline"
onClick={() => go(initiativeCatalogPath())}
>
Vorhaben-Katalog
</button>
</div>
</div>
)}
</div>
)
}
export { actionPath, projectPath, gatePath }

View File

@ -0,0 +1,33 @@
import { Link } from 'react-router-dom'
import { loadRecentObjects } from '../utils/recentObjects.js'
const TYPE_LABELS = {
action: 'AP',
project: 'Projekt',
gate: 'Gate',
initiative: 'Vorhaben',
}
export function RecentlyOpened() {
const items = loadRecentObjects().slice(0, 5)
if (items.length === 0) return null
return (
<div className="recently-opened" aria-label="Zuletzt geöffnet">
<span className="recently-opened__label">Zuletzt</span>
<div className="recently-opened__items">
{items.map((item) => (
<Link
key={`${item.type}-${item.id}`}
to={item.href}
className="recently-opened__chip"
title={item.title}
>
<span className="recently-opened__type">{TYPE_LABELS[item.type] || item.type}</span>
<span className="recently-opened__title">{item.title}</span>
</Link>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,62 @@
import { Link, useLocation } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { listInitiatives } from '../api/initiatives.js'
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
import { LoadingState } from './LoadingState.jsx'
import { scopedPath } from '../utils/routes.js'
export function RequireInitiativeScope({ children, lead }) {
const location = useLocation()
const { initiativeId, applyFromInitiative } = useProgramScope()
const [initiatives, setInitiatives] = useState(null)
const basePath = location.pathname.replace(/\/$/, '') || location.pathname
useEffect(() => {
if (initiativeId) return
let cancelled = false
listInitiatives()
.then((data) => {
if (!cancelled) setInitiatives(data)
})
.catch(() => {
if (!cancelled) setInitiatives([])
})
return () => {
cancelled = true
}
}, [initiativeId])
if (initiativeId) {
return children
}
if (initiatives === null) {
return <LoadingState message="Lade Vorhaben …" />
}
return (
<section className="card scope-picker">
<h2 className="card-title">Vorhaben wählen</h2>
<p className="page-lead">
{lead || 'Dieser Modus braucht ein Vorhaben als Scope-Filter.'}
</p>
{initiatives.length === 0 ? (
<p className="muted">Noch keine Vorhaben angelegt.</p>
) : (
<ul className="scope-picker__list">
{initiatives.map((item) => (
<li key={item.id}>
<Link
to={scopedPath(basePath, { initiativeId: item.id })}
className="scope-picker__link"
onClick={() => applyFromInitiative(item.id)}
>
{item.title}
</Link>
</li>
))}
</ul>
)}
</section>
)
}

View File

@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { gatePath } from '../utils/routes.js'
import { import {
MILESTONE_STATUSES, MILESTONE_STATUSES,
MILESTONE_STATUS_LABELS, MILESTONE_STATUS_LABELS,
@ -152,7 +153,7 @@ export function RoadmapPlanSection({
<div className="list-item-main"> <div className="list-item-main">
<strong> <strong>
{initiativeId ? ( {initiativeId ? (
<Link to={`/initiatives/${initiativeId}/plan/items/${item.id}`}> <Link to={gatePath(item.id)}>
{item.title} {item.title}
</Link> </Link>
) : ( ) : (

View File

@ -1,12 +1,22 @@
import { LayoutDashboard, FolderKanban, ListChecks } from 'lucide-react' import {
Gauge,
LayoutDashboard,
Map,
Play,
Users,
FolderKanban,
} from 'lucide-react'
import { getNavViews } from '../registry/viewRegistry.js' import { getNavViews } from '../registry/viewRegistry.js'
/** @typedef {{ key: string, to: string, label: string, shortLabel: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent, matchPrefix?: string }} AppNavItem */ /** @typedef {{ key: string, to: string, label: string, shortLabel: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent, matchPrefix?: string, objectPrefixes?: string[] }} AppNavItem */
const ICON_BY_KEY = { const ICON_BY_KEY = {
'kairo.workspace': LayoutDashboard, 'kairo.cockpit': LayoutDashboard,
'kairo.work': Play,
'kairo.plan': Map,
'kairo.control': Gauge,
'kairo.team': Users,
'kairo.initiatives': FolderKanban, 'kairo.initiatives': FolderKanban,
'kairo.my_actions': ListChecks,
} }
/** /**
@ -19,9 +29,10 @@ export function getAppNavItems(capabilities) {
to: view.path, to: view.path,
label: view.navLabel || view.title, label: view.navLabel || view.title,
shortLabel: view.shortLabel || view.title, shortLabel: view.shortLabel || view.title,
end: view.path !== '/initiatives', end: view.path === '/cockpit' || view.path === '/team',
Icon: ICON_BY_KEY[view.key] || LayoutDashboard, Icon: ICON_BY_KEY[view.key] || LayoutDashboard,
matchPrefix: view.matchPrefix, matchPrefix: view.matchPrefix,
objectPrefixes: view.objectPrefixes,
})) }))
} }
@ -31,8 +42,14 @@ export function getAppNavItems(capabilities) {
* @param {boolean} routerIsActive * @param {boolean} routerIsActive
*/ */
export function isNavItemActive(pathname, item, routerIsActive) { export function isNavItemActive(pathname, item, routerIsActive) {
if (item.matchPrefix) { if (item.matchPrefix && pathname.startsWith(item.matchPrefix)) {
return pathname.startsWith(item.matchPrefix) return true
}
if (item.objectPrefixes?.some((prefix) => pathname.startsWith(prefix))) {
return true
}
if (item.key === 'kairo.initiatives' && pathname.startsWith('/initiatives')) {
return true
} }
return routerIsActive return routerIsActive
} }

View File

@ -68,12 +68,15 @@ import { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/stee
import { useCapabilities } from '../hooks/useCapabilities.js' import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js' import { useActors } from '../hooks/useActors.js'
import { useSession } from './SessionContext.jsx' import { useSession } from './SessionContext.jsx'
import { useOptionalProgramScope } from './ProgramScopeContext.jsx'
import { collectProjectSubtreeIds } from '../utils/projectTree.js' import { collectProjectSubtreeIds } from '../utils/projectTree.js'
const InitiativeOperationsContext = createContext(null) const InitiativeOperationsContext = createContext(null)
export function InitiativeOperationsProvider({ children }) { export function InitiativeOperationsProvider({ children, initiativeId: initiativeIdProp }) {
const { id } = useParams() const { id: routeId } = useParams()
const scope = useOptionalProgramScope()
const id = initiativeIdProp || routeId || scope?.initiativeId || null
const { context, loading: sessionLoading } = useSession() const { context, loading: sessionLoading } = useSession()
const { capabilities } = useCapabilities() const { capabilities } = useCapabilities()
const actorsState = useActors() const actorsState = useActors()

View File

@ -0,0 +1,164 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { useSearchParams } from 'react-router-dom'
import { getInitiative } from '../api/initiatives.js'
import { getProject } from '../api/projects.js'
import { scopedPath } from '../utils/routes.js'
const ProgramScopeContext = createContext(null)
export function ProgramScopeProvider({ children }) {
const [searchParams, setSearchParams] = useSearchParams()
const initiativeId = searchParams.get('initiative') || ''
const projectId = searchParams.get('project') || ''
const [initiativeTitle, setInitiativeTitle] = useState('')
const [projectTitle, setProjectTitle] = useState('')
useEffect(() => {
if (!initiativeId) {
setInitiativeTitle('')
return
}
let cancelled = false
getInitiative(initiativeId)
.then((data) => {
if (!cancelled) setInitiativeTitle(data.title || '')
})
.catch(() => {
if (!cancelled) setInitiativeTitle('')
})
return () => {
cancelled = true
}
}, [initiativeId])
useEffect(() => {
if (!projectId) {
setProjectTitle('')
return
}
let cancelled = false
getProject(projectId)
.then((data) => {
if (!cancelled) setProjectTitle(data.title || '')
})
.catch(() => {
if (!cancelled) setProjectTitle('')
})
return () => {
cancelled = true
}
}, [projectId])
const setScope = useCallback(
(next) => {
const params = new URLSearchParams(searchParams)
if ('initiativeId' in next) {
if (next.initiativeId) params.set('initiative', next.initiativeId)
else params.delete('initiative')
}
if ('projectId' in next) {
if (next.projectId) params.set('project', next.projectId)
else params.delete('project')
}
if (next.clearProject) params.delete('project')
setSearchParams(params, { replace: true })
},
[searchParams, setSearchParams],
)
const clearScope = useCallback(() => {
setSearchParams({}, { replace: true })
}, [setSearchParams])
const applyFromInitiative = useCallback(
(id, { keepProject = false } = {}) => {
if (!id) return
setScope({
initiativeId: id,
projectId: keepProject ? projectId : '',
clearProject: !keepProject,
})
},
[projectId, setScope],
)
const applyFromProject = useCallback(
(project) => {
if (!project?.initiative_id) return
setScope({
initiativeId: project.initiative_id,
projectId: project.id,
})
},
[setScope],
)
const applyFromAction = useCallback(
(action) => {
if (!action?.initiative_id) return
setScope({
initiativeId: action.initiative_id,
projectId: action.project_id || '',
clearProject: !action.project_id,
})
},
[setScope],
)
const hrefWithScope = useCallback(
(path) => scopedPath(path, { initiativeId, projectId }),
[initiativeId, projectId],
)
const value = useMemo(
() => ({
initiativeId,
projectId,
initiativeTitle,
projectTitle,
setScope,
clearScope,
applyFromInitiative,
applyFromProject,
applyFromAction,
hrefWithScope,
hasScope: Boolean(initiativeId),
}),
[
initiativeId,
projectId,
initiativeTitle,
projectTitle,
setScope,
clearScope,
applyFromInitiative,
applyFromProject,
applyFromAction,
hrefWithScope,
],
)
return (
<ProgramScopeContext.Provider value={value}>{children}</ProgramScopeContext.Provider>
)
}
export function useProgramScope() {
const ctx = useContext(ProgramScopeContext)
if (!ctx) {
throw new Error('useProgramScope must be used within ProgramScopeProvider')
}
return ctx
}
export function useOptionalProgramScope() {
return useContext(ProgramScopeContext)
}

View File

@ -5,6 +5,9 @@ import { LoadingState } from '../components/LoadingState.jsx'
import { DesktopSidebar } from '../components/DesktopSidebar.jsx' import { DesktopSidebar } from '../components/DesktopSidebar.jsx'
import { BottomNav } from '../components/BottomNav.jsx' import { BottomNav } from '../components/BottomNav.jsx'
import { ContextIndicator } from '../components/ContextIndicator.jsx' import { ContextIndicator } from '../components/ContextIndicator.jsx'
import { ProgramScopeBar } from '../components/ProgramScopeBar.jsx'
import { QuickJump } from '../components/QuickJump.jsx'
import { RecentlyOpened } from '../components/RecentlyOpened.jsx'
export function AppLayout() { export function AppLayout() {
const { me, signOut, loading } = useSession() const { me, signOut, loading } = useSession()
@ -36,9 +39,17 @@ export function AppLayout() {
<span className="app-brand__name">Kairo</span> <span className="app-brand__name">Kairo</span>
<span className="app-brand__family">Jinkendo Program Director</span> <span className="app-brand__family">Jinkendo Program Director</span>
</div> </div>
<QuickJump />
</div> </div>
<ContextIndicator variant="mobile" /> <ContextIndicator variant="mobile" />
<ProgramScopeBar />
<RecentlyOpened />
</header> </header>
<div className="app-chrome app-chrome--desktop">
<ProgramScopeBar />
<QuickJump />
<RecentlyOpened />
</div>
<main className="app-main"> <main className="app-main">
<Outlet /> <Outlet />
</main> </main>

View File

@ -0,0 +1,13 @@
import { InitiativeOperationsProvider } from '../context/InitiativeOperationsContext.jsx'
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
export function ScopedInitiativeProvider({ initiativeId: initiativeIdProp, children }) {
const { initiativeId: scopeInitiativeId } = useProgramScope()
const initiativeId = initiativeIdProp || scopeInitiativeId
return (
<InitiativeOperationsProvider initiativeId={initiativeId}>
{children}
</InitiativeOperationsProvider>
)
}

View File

@ -12,8 +12,11 @@ import { TasksSection } from '../components/TasksSection.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx' import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
import { useCapabilities } from '../hooks/useCapabilities.js' import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js' import { useActors } from '../hooks/useActors.js'
import { recordRecentObject } from '../utils/recentObjects.js'
import { actionPath, scopedPath } from '../utils/routes.js'
function ActionDetailBody({ function ActionDetailBody({
action, action,
@ -81,6 +84,7 @@ function ActionDetailBody({
function ActionDetailStandalone() { function ActionDetailStandalone() {
const { actionId } = useParams() const { actionId } = useParams()
const { applyFromAction } = useProgramScope()
const { capabilities } = useCapabilities() const { capabilities } = useCapabilities()
const actorsState = useActors() const actorsState = useActors()
const [action, setAction] = useState(null) const [action, setAction] = useState(null)
@ -98,6 +102,14 @@ function ActionDetailStandalone() {
try { try {
const actionData = await getAction(actionId) const actionData = await getAction(actionId)
setAction(actionData) setAction(actionData)
applyFromAction(actionData)
recordRecentObject({
type: 'action',
id: actionData.id,
title: actionData.title,
href: actionPath(actionData.id),
initiativeId: actionData.initiative_id,
})
const [snap, projectData, roadmapData] = await Promise.all([ const [snap, projectData, roadmapData] = await Promise.all([
getInitiativeSteeringSnapshot(actionData.initiative_id), getInitiativeSteeringSnapshot(actionData.initiative_id),
listInitiativeProjects(actionData.initiative_id).catch(() => []), listInitiativeProjects(actionData.initiative_id).catch(() => []),
@ -111,7 +123,7 @@ function ActionDetailStandalone() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [actionId]) }, [actionId, applyFromAction])
useEffect(() => { useEffect(() => {
load() load()
@ -190,11 +202,15 @@ function ActionDetailStandalone() {
return ( return (
<div className="app-page"> <div className="app-page">
<nav className="breadcrumb"> <nav className="breadcrumb">
<Link to="/workspace">Workspace</Link> <Link to="/cockpit">Cockpit</Link>
<span aria-hidden="true"> / </span> <span aria-hidden="true"> / </span>
<Link to={`/initiatives/${action.initiative_id}`}>Vorhaben</Link> <Link to={scopedPath('/control/status', { initiativeId: action.initiative_id })}>
Vorhaben
</Link>
<span aria-hidden="true"> / </span> <span aria-hidden="true"> / </span>
<Link to={`/initiatives/${action.initiative_id}/execution`}>Ausführung</Link> <Link to={scopedPath('/work/today', { initiativeId: action.initiative_id })}>
Ausführen
</Link>
<span aria-hidden="true"> / </span> <span aria-hidden="true"> / </span>
<span>{action.title}</span> <span>{action.title}</span>
</nav> </nav>

View File

@ -10,6 +10,7 @@ import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
import { useCapabilities } from '../hooks/useCapabilities.js' import { useCapabilities } from '../hooks/useCapabilities.js'
import { useSession } from '../context/SessionContext.jsx' import { useSession } from '../context/SessionContext.jsx'
import { scopedPath } from '../utils/routes.js'
export function InitiativesPage() { export function InitiativesPage() {
const { context } = useSession() const { context } = useSession()
@ -105,7 +106,7 @@ export function InitiativesPage() {
{initiatives.map((item) => ( {initiatives.map((item) => (
<li key={item.id} className="list-item card-list-item"> <li key={item.id} className="list-item card-list-item">
<div className="list-item-main"> <div className="list-item-main">
<Link to={`/initiatives/${item.id}`} className="list-item-link"> <Link to={scopedPath('/control/status', { initiativeId: item.id })} className="list-item-link">
<strong>{item.title}</strong> <strong>{item.title}</strong>
</Link> </Link>
{item.goal && <p className="list-item-desc">{item.goal}</p>} {item.goal && <p className="list-item-desc">{item.goal}</p>}

View File

@ -0,0 +1,61 @@
import { Navigate, useParams, useSearchParams } from 'react-router-dom'
import { actionPath, gatePath, projectPath, scopedPath } from '../utils/routes.js'
export function WorkspaceRedirect() {
return <Navigate to="/cockpit" replace />
}
export function MyActionsRedirect() {
return <Navigate to="/work/mine" replace />
}
export function InitiativeRootRedirect() {
const { id } = useParams()
return <Navigate to={scopedPath('/control/status', { initiativeId: id })} replace />
}
export function InitiativePlanRedirect() {
const { id } = useParams()
return <Navigate to={scopedPath('/plan/gates', { initiativeId: id })} replace />
}
export function InitiativeExecutionRedirect() {
const { id } = useParams()
return <Navigate to={scopedPath('/plan/structure', { initiativeId: id })} replace />
}
export function InitiativeInboxRedirect() {
const { id } = useParams()
return <Navigate to={scopedPath('/plan/inbox', { initiativeId: id })} replace />
}
export function InitiativeJourneyRedirect() {
const { id } = useParams()
return <Navigate to={scopedPath('/control/journey', { initiativeId: id })} replace />
}
export function InitiativeGateRedirect() {
const { itemId } = useParams()
return <Navigate to={gatePath(itemId)} replace />
}
export function InitiativeProjectRedirect() {
const { projectId } = useParams()
return <Navigate to={projectPath(projectId)} replace />
}
export function InitiativeActionRedirect() {
const { actionId } = useParams()
return <Navigate to={actionPath(actionId)} replace />
}
export function ProjectNewRedirect() {
const [searchParams] = useSearchParams()
const initiativeId = searchParams.get('initiative') || ''
const parent = searchParams.get('parent') || ''
const params = new URLSearchParams()
if (initiativeId) params.set('initiative', initiativeId)
if (parent) params.set('parent', parent)
const qs = params.toString()
return <Navigate to={qs ? `/projects/new?${qs}` : '/projects/new'} replace />
}

View File

@ -7,7 +7,7 @@ import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
import { WidgetCard } from '../components/WidgetCard.jsx' import { WidgetCard } from '../components/WidgetCard.jsx'
export function MyActionsPage() { export function MyActionsPage({ embedded = false }) {
const [actions, setActions] = useState([]) const [actions, setActions] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@ -34,8 +34,9 @@ export function MyActionsPage() {
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded') ? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
: actions : actions
return ( const content = (
<div className="app-page"> <>
{!embedded && (
<header className="page-header"> <header className="page-header">
<div> <div>
<h1>Meine Maßnahmen</h1> <h1>Meine Maßnahmen</h1>
@ -50,9 +51,24 @@ export function MyActionsPage() {
Erledigte ausblenden Erledigte ausblenden
</label> </label>
</header> </header>
)}
{embedded && (
<div className="section-header section-header--compact">
<h2>Meine Queue</h2>
<label className="checkbox-label">
<input
type="checkbox"
checked={hideDone}
onChange={(e) => setHideDone(e.target.checked)}
/>
Erledigte ausblenden
</label>
</div>
)}
<WidgetCard <WidgetCard
title="Zugewiesene Maßnahmen" title={embedded ? undefined : 'Zugewiesene Maßnahmen'}
actions={ actions={
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}> <button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
Aktualisieren Aktualisieren
@ -65,7 +81,7 @@ export function MyActionsPage() {
<ActionList <ActionList
actions={visible} actions={visible}
showInitiative showInitiative
getManageLink={(a) => `/initiatives/${a.initiative_id}`} getManageLink={(a) => `/actions/${a.id}`}
empty={ empty={
<EmptyState message="Keine offenen Maßnahmen. Du kannst eine neue Maßnahme in einem Vorhaben anlegen." /> <EmptyState message="Keine offenen Maßnahmen. Du kannst eine neue Maßnahme in einem Vorhaben anlegen." />
} }
@ -73,11 +89,19 @@ export function MyActionsPage() {
)} )}
</WidgetCard> </WidgetCard>
{!embedded && (
<p className="page-footer-link"> <p className="page-footer-link">
<Link to="/initiatives" className="link-inline"> <Link to="/initiatives" className="link-inline">
Zu den Vorhaben Zum Vorhaben-Katalog
</Link> </Link>
</p> </p>
</div> )}
</>
) )
if (embedded) {
return content
}
return <div className="app-page">{content}</div>
} }

View File

@ -1,4 +1,5 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { scopedPath } from '../../utils/routes.js'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx' import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx'
import { NextActionWidget } from '../../widgets/NextActionWidget.jsx' import { NextActionWidget } from '../../widgets/NextActionWidget.jsx'
@ -60,7 +61,7 @@ export function InitiativeOverviewPage() {
<span>{counts.blockers_open} offene Blocker</span> <span>{counts.blockers_open} offene Blocker</span>
)} )}
</p> </p>
<Link to={`/initiatives/${initiativeId}/execution`} className="btn btn-secondary btn-sm"> <Link to={scopedPath('/work/today', { initiativeId })} className="btn btn-secondary btn-sm">
Zur Ausführung Zur Ausführung
</Link> </Link>
</section> </section>

View File

@ -1,10 +1,9 @@
import { useParams } from 'react-router-dom'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx' import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx'
export function InitiativePlanPage() { export function InitiativePlanPage() {
const { id } = useParams()
const { const {
initiativeId,
roadmapItems, roadmapItems,
capabilities, capabilities,
formBusy, formBusy,
@ -24,7 +23,7 @@ export function InitiativePlanPage() {
<> <>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<RoadmapPlanSection <RoadmapPlanSection
initiativeId={id} initiativeId={initiativeId}
items={roadmapItems} items={roadmapItems}
canManage={capabilities.has('kairo.milestone.manage')} canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateRoadmapItem} onCreate={handleCreateRoadmapItem}

View File

@ -6,6 +6,7 @@ import { EmptyState } from '../../components/EmptyState.jsx'
import { ErrorState } from '../../components/ErrorState.jsx' import { ErrorState } from '../../components/ErrorState.jsx'
import { gateTitleById } from '../../components/GateSelect.jsx' import { gateTitleById } from '../../components/GateSelect.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { gatePath, projectPath, actionPath, scopedPath } from '../../utils/routes.js'
import { import {
buildProjectPath, buildProjectPath,
buildProjectsByParent, buildProjectsByParent,
@ -65,7 +66,7 @@ function ProjectDetailView({
<dt>Gate</dt> <dt>Gate</dt>
<dd> <dd>
{gateTitle ? ( {gateTitle ? (
<Link to={`/initiatives/${initiativeId}/plan/items/${project.roadmap_item_id}`}> <Link to={gatePath(project.roadmap_item_id)}>
{gateTitle} {gateTitle}
</Link> </Link>
) : ( ) : (
@ -96,7 +97,7 @@ function ProjectDetailView({
{index === path.length - 1 ? ( {index === path.length - 1 ? (
<strong>{node.title}</strong> <strong>{node.title}</strong>
) : ( ) : (
<Link to={`/initiatives/${initiativeId}/execution/projects/${node.id}`}> <Link to={projectPath(node.id)}>
{node.title} {node.title}
</Link> </Link>
)} )}
@ -112,7 +113,7 @@ function ProjectDetailView({
<ul className="item-list"> <ul className="item-list">
{childProjects.map((child) => ( {childProjects.map((child) => (
<li key={child.id} className="list-item card-list-item"> <li key={child.id} className="list-item card-list-item">
<Link to={`/initiatives/${initiativeId}/execution/projects/${child.id}`}> <Link to={projectPath(child.id)}>
<strong>{child.title}</strong> <strong>{child.title}</strong>
</Link> </Link>
<StatusBadge kind="project" status={child.status} /> <StatusBadge kind="project" status={child.status} />
@ -136,7 +137,7 @@ function ProjectDetailView({
<ul className="item-list"> <ul className="item-list">
{linkedActions.map((action) => ( {linkedActions.map((action) => (
<li key={action.id} className="list-item card-list-item"> <li key={action.id} className="list-item card-list-item">
<Link to={`/initiatives/${initiativeId}/actions/${action.id}`}> <Link to={actionPath(action.id)}>
<strong>{action.title}</strong> <strong>{action.title}</strong>
</Link> </Link>
<StatusBadge kind="action" status={action.status} /> <StatusBadge kind="action" status={action.status} />
@ -158,7 +159,7 @@ function ProjectDetailView({
{canManage && ( {canManage && (
<Link <Link
className="btn btn-secondary" className="btn btn-secondary"
to={`/initiatives/${initiativeId}/execution/projects/new?parent=${project.id}`} to={`${projectPath('new')}?initiative=${initiativeId}&parent=${project.id}`}
> >
Unterprojekt Unterprojekt
</Link> </Link>
@ -212,7 +213,7 @@ export function ProjectDetailPage() {
setLocalError(null) setLocalError(null)
const created = await ops.handleCreateProject(payload) const created = await ops.handleCreateProject(payload)
if (created?.id) { if (created?.id) {
navigate(`/initiatives/${ops.initiativeId}/execution/projects/${created.id}`, { replace: true }) navigate(projectPath(created.id), { replace: true })
} else if (ops.error) { } else if (ops.error) {
setLocalError(ops.error) setLocalError(ops.error)
} }
@ -233,7 +234,7 @@ export function ProjectDetailPage() {
setLocalError(null) setLocalError(null)
const ok = await ops.handleDeleteProject(project.id) const ok = await ops.handleDeleteProject(project.id)
if (ok) { if (ok) {
navigate(`/initiatives/${ops.initiativeId}/execution`) navigate(scopedPath('/plan/structure', { initiativeId: ops.initiativeId }))
} else if (ops.error) { } else if (ops.error) {
setLocalError(ops.error) setLocalError(ops.error)
} }
@ -241,7 +242,7 @@ export function ProjectDetailPage() {
function handleFilter() { function handleFilter() {
if (project) ops.setSelectedProjectId(project.id) if (project) ops.setSelectedProjectId(project.id)
navigate(`/initiatives/${ops.initiativeId}/execution`) navigate(scopedPath('/plan/structure', { initiativeId: ops.initiativeId }))
} }
const title = isNew ? 'Neues Projekt' : project?.title || 'Projekt' const title = isNew ? 'Neues Projekt' : project?.title || 'Projekt'
@ -249,7 +250,7 @@ export function ProjectDetailPage() {
return ( return (
<section className="card project-detail-page"> <section className="card project-detail-page">
<nav className="breadcrumb breadcrumb--inline"> <nav className="breadcrumb breadcrumb--inline">
<Link to={`/initiatives/${ops.initiativeId}/execution`}>Ausführung</Link> <Link to={scopedPath('/plan/structure', { initiativeId: ops.initiativeId })}>Struktur</Link>
<span aria-hidden="true"> / </span> <span aria-hidden="true"> / </span>
<span>{title}</span> <span>{title}</span>
</nav> </nav>
@ -298,7 +299,7 @@ export function ProjectDetailPage() {
onSubmit={isNew ? handleCreate : handleUpdate} onSubmit={isNew ? handleCreate : handleUpdate}
onCancel={ onCancel={
isNew isNew
? () => navigate(`/initiatives/${ops.initiativeId}/execution`) ? () => navigate(scopedPath('/plan/structure', { initiativeId: ops.initiativeId }))
: () => setEditing(false) : () => setEditing(false)
} }
busy={ops.formBusy} busy={ops.formBusy}

View File

@ -25,6 +25,8 @@ import {
ROADMAP_ITEM_TYPE_LABELS, ROADMAP_ITEM_TYPE_LABELS,
} from '../../constants/status.js' } from '../../constants/status.js'
import { useCapabilities } from '../../hooks/useCapabilities.js' import { useCapabilities } from '../../hooks/useCapabilities.js'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { scopedPath } from '../../utils/routes.js'
import { listGateContributions } from '../../api/journey.js' import { listGateContributions } from '../../api/journey.js'
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx' import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
@ -71,8 +73,11 @@ function CriterionDecisionForm({ label, onSubmit, busy }) {
) )
} }
export function RoadmapItemDetailPage() { export function RoadmapItemDetailPage({ overrideItemId }) {
const { id: initiativeId, itemId } = useParams() const { id: routeInitiativeId, itemId: routeItemId, gateId } = useParams()
const ops = useInitiativeOperations()
const initiativeId = routeInitiativeId || ops?.initiativeId
const itemId = overrideItemId || routeItemId || gateId
const { capabilities } = useCapabilities() const { capabilities } = useCapabilities()
const canManage = capabilities.has('kairo.milestone.manage') const canManage = capabilities.has('kairo.milestone.manage')
const canReopen = capabilities.has('kairo.roadmap.reopen') const canReopen = capabilities.has('kairo.roadmap.reopen')
@ -152,7 +157,7 @@ export function RoadmapItemDetailPage() {
return ( return (
<section className="card roadmap-item-detail"> <section className="card roadmap-item-detail">
<p className="breadcrumb muted"> <p className="breadcrumb muted">
<Link to={`/initiatives/${initiativeId}/plan`}>Zielzustände</Link> <Link to={scopedPath('/plan/gates', { initiativeId })}>Zielzustände</Link>
{' · '} {' · '}
{item.title} {item.title}
</p> </p>

View File

@ -0,0 +1,84 @@
import { useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useCapabilities } from '../../hooks/useCapabilities.js'
import { getWidgetsForArea } from '../../registry/widgetRegistry.js'
import { useSession } from '../../context/SessionContext.jsx'
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
import { createInitiative } from '../../api/initiatives.js'
import { ModeShell } from '../../components/ModeShell.jsx'
import { initiativeCatalogPath } from '../../utils/routes.js'
export function CockpitPage() {
const navigate = useNavigate()
const { context } = useSession()
const { capabilities, hasCapability } = useCapabilities()
const widgets = getWidgetsForArea('workspace', capabilities)
const [showForm, setShowForm] = useState(false)
const [formBusy, setFormBusy] = useState(false)
const [formError, setFormError] = useState(null)
async function handleCreateInitiative(payload) {
setFormBusy(true)
setFormError(null)
try {
await createInitiative({
...payload,
owner_actor_id: context?.actor?.id,
})
setShowForm(false)
navigate(initiativeCatalogPath())
} catch (err) {
setFormError(err.message)
} finally {
setFormBusy(false)
}
}
return (
<div className="app-page">
<ModeShell
modeKey="cockpit"
actions={
hasCapability('kairo.initiative.manage') ? (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Abbrechen' : 'Vorhaben anlegen'}
</button>
) : null
}
/>
{showForm && (
<section className="card inline-form-card">
<h2 className="card-title card-title--lg">Neues Vorhaben</h2>
{formError && <p className="error">{formError}</p>}
<InitiativeForm
onSubmit={handleCreateInitiative}
onCancel={() => setShowForm(false)}
busy={formBusy}
submitLabel="Vorhaben anlegen"
/>
</section>
)}
<div className="widget-grid">
{widgets.map((widget) => {
const Component = widget.component
return <Component key={widget.key} />
})}
</div>
{hasCapability('kairo.initiative.read') && (
<p className="page-footer-link">
<Link to={initiativeCatalogPath()} className="link-inline">
Zum Vorhaben-Katalog
</Link>
</p>
)}
</div>
)
}

View File

@ -0,0 +1,13 @@
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { InitiativeJourneyPage } from '../initiative/InitiativeJourneyPage.jsx'
export function ControlJourneyPage() {
return (
<RequireInitiativeScope lead="Journey ist vorhabenspezifisch.">
<ScopedInitiativeProvider>
<InitiativeJourneyPage />
</ScopedInitiativeProvider>
</RequireInitiativeScope>
)
}

View File

@ -0,0 +1,22 @@
import { Navigate, Outlet } from 'react-router-dom'
import { ModeShell } from '../../components/ModeShell.jsx'
import { ModeSubNav } from '../../components/ModeSubNav.jsx'
const CONTROL_NAV = [
{ to: '/control/status', label: 'Steuerung', end: true },
{ to: '/control/journey', label: 'Journey', end: true },
]
export function ControlLayout() {
return (
<div className="app-page">
<ModeShell modeKey="control" />
<ModeSubNav items={CONTROL_NAV} />
<Outlet />
</div>
)
}
export function ControlIndexRedirect() {
return <Navigate to="/control/status" replace />
}

View File

@ -0,0 +1,13 @@
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { InitiativeOverviewPage } from '../initiative/InitiativeOverviewPage.jsx'
export function ControlStatusPage() {
return (
<RequireInitiativeScope lead="Steuerung und Lagebild brauchen ein Vorhaben im Scope.">
<ScopedInitiativeProvider>
<InitiativeOverviewPage />
</ScopedInitiativeProvider>
</RequireInitiativeScope>
)
}

View File

@ -0,0 +1,13 @@
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { InitiativePlanPage } from '../initiative/InitiativePlanPage.jsx'
export function PlanGatesPage() {
return (
<RequireInitiativeScope lead="Gates und Roadmap gehören zu einem Vorhaben.">
<ScopedInitiativeProvider>
<InitiativePlanPage />
</ScopedInitiativeProvider>
</RequireInitiativeScope>
)
}

View File

@ -0,0 +1,13 @@
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { InitiativeInboxPage } from '../initiative/InitiativeInboxPage.jsx'
export function PlanInboxPage() {
return (
<RequireInitiativeScope lead="Eingang und Triage sind vorhabenspezifisch.">
<ScopedInitiativeProvider>
<InitiativeInboxPage />
</ScopedInitiativeProvider>
</RequireInitiativeScope>
)
}

View File

@ -0,0 +1,23 @@
import { Navigate, Outlet } from 'react-router-dom'
import { ModeShell } from '../../components/ModeShell.jsx'
import { ModeSubNav } from '../../components/ModeSubNav.jsx'
const PLAN_NAV = [
{ to: '/plan/structure', label: 'Struktur', end: true },
{ to: '/plan/gates', label: 'Zielzustände', end: true },
{ to: '/plan/inbox', label: 'Eingang', end: true },
]
export function PlanLayout() {
return (
<div className="app-page">
<ModeShell modeKey="plan" />
<ModeSubNav items={PLAN_NAV} />
<Outlet />
</div>
)
}
export function PlanIndexRedirect() {
return <Navigate to="/plan/structure" replace />
}

View File

@ -0,0 +1,40 @@
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { ProjectsSection } from '../../components/ProjectsSection.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
function PlanStructureInner() {
const {
initiativeId,
projects,
roadmapItems,
selectedProjectId,
setSelectedProjectId,
capabilities,
error,
} = useInitiativeOperations()
return (
<>
{error && <p className="error">{error}</p>}
<ProjectsSection
initiativeId={initiativeId}
projects={projects}
roadmapItems={roadmapItems}
selectedProjectId={selectedProjectId}
onSelectProject={setSelectedProjectId}
canManage={capabilities.has('kairo.project.manage')}
/>
</>
)
}
export function PlanStructurePage() {
return (
<RequireInitiativeScope lead="Projektstruktur hängt an einem Vorhaben — wähle den Scope oben oder hier.">
<ScopedInitiativeProvider>
<PlanStructureInner />
</ScopedInitiativeProvider>
</RequireInitiativeScope>
)
}

View File

@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { getActorWorkload } from '../../api/workspace.js'
import { ModeShell } from '../../components/ModeShell.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { ErrorState } from '../../components/ErrorState.jsx'
import { scopedPath } from '../../utils/routes.js'
export function TeamPage() {
const [rows, setRows] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
getActorWorkload()
.then((data) => {
if (!cancelled) setRows(data)
})
.catch((err) => {
if (!cancelled) setError(err.message)
})
return () => {
cancelled = true
}
}, [])
return (
<div className="app-page">
<ModeShell modeKey="team" />
{rows === null && !error && <LoadingState message="Lade Team-Auslastung …" />}
{error && <ErrorState message={error} />}
{rows && (
<section className="card">
<h2 className="card-title">Auslastung nach Actor</h2>
{rows.length === 0 ? (
<p className="muted">Keine Zuweisungen im Portfolio.</p>
) : (
<ul className="team-workload-list">
{rows.map((row) => (
<li key={row.actor_id} className="team-workload-list__item">
<div>
<strong>{row.actor_name || row.actor_id}</strong>
<span className="muted">
{' '}
· {row.open_actions ?? 0} offen
{(row.blocked_actions ?? 0) > 0
? ` · ${row.blocked_actions} blockiert`
: ''}
</span>
</div>
<Link to={scopedPath('/work/mine')} className="link-inline">
Queue ansehen
</Link>
</li>
))}
</ul>
)}
<p className="page-footer-link muted">
AP1.9b erweitert Team um Filter nach Vorhaben und direkte Zuweisungslinks.
</p>
</section>
)}
</div>
)
}

View File

@ -0,0 +1,22 @@
import { Navigate, Outlet } from 'react-router-dom'
import { ModeShell } from '../../components/ModeShell.jsx'
import { ModeSubNav } from '../../components/ModeSubNav.jsx'
const WORK_NAV = [
{ to: '/work/today', label: 'Heute', end: true },
{ to: '/work/mine', label: 'Meine Queue', end: true },
]
export function WorkLayout() {
return (
<div className="app-page">
<ModeShell modeKey="work" />
<ModeSubNav items={WORK_NAV} />
<Outlet />
</div>
)
}
export function WorkIndexRedirect() {
return <Navigate to="/work/today" replace />
}

View File

@ -0,0 +1,5 @@
import { MyActionsPage } from '../MyActionsPage.jsx'
export function WorkMinePage() {
return <MyActionsPage embedded />
}

View File

@ -0,0 +1,9 @@
import { WorkspaceTodayWidget } from '../../widgets/WorkspaceTodayWidget.jsx'
export function WorkTodayPage() {
return (
<section className="card">
<WorkspaceTodayWidget />
</section>
)
}

View File

@ -0,0 +1,70 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { getRoadmapItem } from '../../api/roadmap.js'
import { LoadingState } from '../../components/LoadingState.jsx'
import { ErrorState } from '../../components/ErrorState.jsx'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { RoadmapItemDetailPage } from '../initiative/RoadmapItemDetailPage.jsx'
import { recordRecentObject } from '../../utils/recentObjects.js'
import { gatePath } from '../../utils/routes.js'
export function GateObjectPage() {
const { gateId } = useParams()
const { applyFromInitiative } = useProgramScope()
const [gate, setGate] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
getRoadmapItem(gateId)
.then((data) => {
if (cancelled) return
setGate(data)
applyFromInitiative(data.initiative_id)
recordRecentObject({
type: 'gate',
id: data.id,
title: data.title,
href: gatePath(data.id),
initiativeId: data.initiative_id,
})
})
.catch((err) => {
if (!cancelled) setError(err.message)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [gateId, applyFromInitiative])
if (loading) {
return (
<div className="app-page">
<LoadingState message="Lade Gate …" />
</div>
)
}
if (error || !gate) {
return (
<div className="app-page">
<ErrorState message={error || 'Gate nicht gefunden.'} />
</div>
)
}
return (
<div className="app-page">
<ScopedInitiativeProvider initiativeId={gate.initiative_id}>
<RoadmapItemDetailPage overrideItemId={gateId} />
</ScopedInitiativeProvider>
</div>
)
}

View File

@ -0,0 +1,89 @@
import { useEffect, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import { getProject } from '../../api/projects.js'
import { LoadingState } from '../../components/LoadingState.jsx'
import { ErrorState } from '../../components/ErrorState.jsx'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { ProjectDetailPage } from '../initiative/ProjectDetailPage.jsx'
import { recordRecentObject } from '../../utils/recentObjects.js'
import { projectPath } from '../../utils/routes.js'
export function ProjectObjectPage() {
const { projectId } = useParams()
const [searchParams] = useSearchParams()
const { initiativeId: scopeInitiativeId, applyFromInitiative, applyFromProject } =
useProgramScope()
const initiativeFromQuery = searchParams.get('initiative') || scopeInitiativeId
const [project, setProject] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
if (projectId === 'new') {
if (initiativeFromQuery) applyFromInitiative(initiativeFromQuery)
setProject(null)
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setError(null)
getProject(projectId)
.then((data) => {
if (cancelled) return
setProject(data)
applyFromProject(data)
recordRecentObject({
type: 'project',
id: data.id,
title: data.title,
href: projectPath(data.id),
initiativeId: data.initiative_id,
})
})
.catch((err) => {
if (!cancelled) setError(err.message)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [projectId, initiativeFromQuery, applyFromInitiative, applyFromProject])
if (loading) {
return (
<div className="app-page">
<LoadingState message="Lade Projekt …" />
</div>
)
}
if (error) {
return (
<div className="app-page">
<ErrorState message={error} />
</div>
)
}
const initiativeId = project?.initiative_id || initiativeFromQuery
if (projectId === 'new' && !initiativeId) {
return (
<div className="app-page">
<ErrorState message="Für ein neues Projekt braucht es ein Vorhaben im Scope (?initiative=)." />
</div>
)
}
return (
<div className="app-page">
<ScopedInitiativeProvider initiativeId={initiativeId}>
<ProjectDetailPage />
</ScopedInitiativeProvider>
</div>
)
}

View File

@ -32,16 +32,19 @@ describe('widgetRegistry', () => {
}) })
describe('viewRegistry', () => { describe('viewRegistry', () => {
it('contains workspace, initiatives and my actions views', () => { it('contains cockpit, work modes and initiatives catalog', () => {
const keys = VIEWS.map((v) => v.key) const keys = VIEWS.map((v) => v.key)
expect(keys).toContain('kairo.workspace') expect(keys).toContain('kairo.cockpit')
expect(keys).toContain('kairo.work')
expect(keys).toContain('kairo.plan')
expect(keys).toContain('kairo.control')
expect(keys).toContain('kairo.team')
expect(keys).toContain('kairo.initiatives') expect(keys).toContain('kairo.initiatives')
expect(keys).toContain('kairo.my_actions')
}) })
it('filters nav views by capability', () => { it('filters nav views by capability', () => {
const nav = getNavViews(['kairo.initiative.read']) const nav = getNavViews(['kairo.initiative.read'])
expect(nav.some((v) => v.key === 'kairo.workspace')).toBe(false) expect(nav.some((v) => v.key === 'kairo.cockpit')).toBe(false)
expect(nav.some((v) => v.key === 'kairo.initiatives')).toBe(true) expect(nav.some((v) => v.key === 'kairo.initiatives')).toBe(true)
}) })
}) })
@ -52,15 +55,19 @@ describe('appNav', () => {
'kairo.action.read', 'kairo.action.read',
'kairo.initiative.read', 'kairo.initiative.read',
]) ])
expect(items.length).toBe(3) expect(items.length).toBeGreaterThanOrEqual(5)
expect(items[0].Icon).toBeTruthy() expect(items[0].Icon).toBeTruthy()
}) })
it('highlights initiatives for detail route', () => { it('highlights plan mode for project object route', () => {
const items = getAppNavItems(['kairo.action.read', 'kairo.initiative.read']) const items = getAppNavItems(['kairo.action.read', 'kairo.initiative.read'])
const initiatives = items.find((i) => i.key === 'kairo.initiatives') const plan = items.find((i) => i.key === 'kairo.plan')
expect( expect(isNavItemActive('/projects/abc-123', plan, false)).toBe(true)
isNavItemActive('/initiatives/abc-123', initiatives, false), })
).toBe(true)
it('highlights work mode for action object route', () => {
const items = getAppNavItems(['kairo.action.read', 'kairo.initiative.read'])
const work = items.find((i) => i.key === 'kairo.work')
expect(isNavItemActive('/actions/abc-123', work, false)).toBe(true)
}) })
}) })

View File

@ -1,17 +1,74 @@
import { WorkspacePage } from '../pages/WorkspacePage.jsx' import { CockpitPage } from '../pages/modes/CockpitPage.jsx'
import { WorkLayout, WorkIndexRedirect } from '../pages/modes/WorkLayout.jsx'
import { WorkTodayPage } from '../pages/modes/WorkTodayPage.jsx'
import { WorkMinePage } from '../pages/modes/WorkMinePage.jsx'
import { PlanLayout, PlanIndexRedirect } from '../pages/modes/PlanLayout.jsx'
import { PlanStructurePage } from '../pages/modes/PlanStructurePage.jsx'
import { PlanGatesPage } from '../pages/modes/PlanGatesPage.jsx'
import { PlanInboxPage } from '../pages/modes/PlanInboxPage.jsx'
import { ControlLayout, ControlIndexRedirect } from '../pages/modes/ControlLayout.jsx'
import { ControlStatusPage } from '../pages/modes/ControlStatusPage.jsx'
import { ControlJourneyPage } from '../pages/modes/ControlJourneyPage.jsx'
import { TeamPage } from '../pages/modes/TeamPage.jsx'
import { InitiativesPage } from '../pages/InitiativesPage.jsx' import { InitiativesPage } from '../pages/InitiativesPage.jsx'
import { MyActionsPage } from '../pages/MyActionsPage.jsx'
/** @type {import('./viewRegistry.js').ViewDefinition[]} */ /** @type {import('./viewRegistry.js').ViewDefinition[]} */
export const VIEWS = [ export const VIEWS = [
{ {
key: 'kairo.workspace', key: 'kairo.cockpit',
path: '/workspace', path: '/cockpit',
title: 'Workspace', title: 'Cockpit',
navLabel: 'Workspace', navLabel: 'Cockpit',
shortLabel: 'Start', shortLabel: 'Cockpit',
matchPrefix: '/cockpit',
requiredCapability: 'kairo.action.read', requiredCapability: 'kairo.action.read',
component: WorkspacePage, component: CockpitPage,
showInNav: true,
},
{
key: 'kairo.work',
path: '/work',
title: 'Ausführen',
navLabel: 'Ausführen',
shortLabel: 'Arbeit',
matchPrefix: '/work',
objectPrefixes: ['/actions'],
requiredCapability: 'kairo.action.read',
component: WorkLayout,
showInNav: true,
},
{
key: 'kairo.plan',
path: '/plan',
title: 'Planen',
navLabel: 'Planen',
shortLabel: 'Plan',
matchPrefix: '/plan',
objectPrefixes: ['/projects', '/gates'],
requiredCapability: 'kairo.initiative.read',
component: PlanLayout,
showInNav: true,
},
{
key: 'kairo.control',
path: '/control',
title: 'Kontrolle',
navLabel: 'Kontrolle',
shortLabel: 'Kontrolle',
matchPrefix: '/control',
requiredCapability: 'kairo.initiative.read',
component: ControlLayout,
showInNav: true,
},
{
key: 'kairo.team',
path: '/team',
title: 'Team',
navLabel: 'Team',
shortLabel: 'Team',
matchPrefix: '/team',
requiredCapability: 'kairo.action.read',
component: TeamPage,
showInNav: true, showInNav: true,
}, },
{ {
@ -25,15 +82,19 @@ export const VIEWS = [
component: InitiativesPage, component: InitiativesPage,
showInNav: true, showInNav: true,
}, },
{
key: 'kairo.workspace',
path: '/workspace',
title: 'Workspace',
component: CockpitPage,
showInNav: false,
},
{ {
key: 'kairo.my_actions', key: 'kairo.my_actions',
path: '/my-actions', path: '/my-actions',
title: 'Meine Maßnahmen', title: 'Meine Maßnahmen',
navLabel: 'Meine Maßnahmen', component: WorkMinePage,
shortLabel: 'Maßnahmen', showInNav: false,
requiredCapability: 'kairo.action.read',
component: MyActionsPage,
showInNav: true,
}, },
] ]
@ -45,6 +106,7 @@ export const VIEWS = [
* @property {string} [navLabel] * @property {string} [navLabel]
* @property {string} [shortLabel] * @property {string} [shortLabel]
* @property {string} [matchPrefix] * @property {string} [matchPrefix]
* @property {string[]} [objectPrefixes]
* @property {string} [requiredCapability] * @property {string} [requiredCapability]
* @property {import('react').ComponentType<any>} component * @property {import('react').ComponentType<any>} component
* @property {boolean} [showInNav] * @property {boolean} [showInNav]
@ -59,3 +121,22 @@ export function getNavViews(capabilities) {
export function getViewByKey(key) { export function getViewByKey(key) {
return VIEWS.find((v) => v.key === key) return VIEWS.find((v) => v.key === key)
} }
export const MODE_ROUTE_COMPONENTS = {
workToday: WorkTodayPage,
workMine: WorkMinePage,
planStructure: PlanStructurePage,
planGates: PlanGatesPage,
planInbox: PlanInboxPage,
controlStatus: ControlStatusPage,
controlJourney: ControlJourneyPage,
}
export {
WorkLayout,
WorkIndexRedirect,
PlanLayout,
PlanIndexRedirect,
ControlLayout,
ControlIndexRedirect,
}

View File

@ -0,0 +1,293 @@
/* AP1.9a — Program scope bar, quick jump, mode sub-nav */
.app-chrome {
display: none;
}
.app-chrome--desktop {
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 12px 32px;
background: var(--jk-surface);
border-bottom: 1px solid var(--jk-border);
}
@media (min-width: 1024px) {
.app-chrome--desktop {
display: flex;
}
}
.program-scope-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
width: 100%;
padding: 0 16px 8px;
font-size: 13px;
}
@media (min-width: 1024px) {
.program-scope-bar {
width: auto;
flex: 1;
padding: 0;
}
}
.program-scope-bar__label {
font-weight: 600;
color: var(--jk-text-subtle);
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.06em;
}
.program-scope-bar__trail {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.program-scope-bar__crumb {
color: var(--jk-text-muted);
text-decoration: none;
font-weight: 500;
}
.program-scope-bar__crumb:hover {
color: var(--jk-primary);
}
.program-scope-bar__crumb--active {
color: var(--jk-text);
font-weight: 600;
}
.program-scope-bar__sep {
color: var(--jk-text-subtle);
}
.program-scope-bar__catalog {
margin-left: auto;
font-size: 12px;
}
.quick-jump {
position: relative;
flex-shrink: 0;
}
.quick-jump__trigger {
border: 1px solid var(--jk-border);
background: var(--jk-surface-raised);
color: var(--jk-text);
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.quick-jump__trigger:hover {
border-color: var(--jk-primary);
color: var(--jk-primary);
}
.quick-jump__panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: min(360px, calc(100vw - 32px));
background: var(--jk-surface);
border: 1px solid var(--jk-border);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
z-index: 40;
padding: 10px;
}
.quick-jump__input {
width: 100%;
margin-bottom: 8px;
}
.quick-jump__list {
list-style: none;
margin: 0;
padding: 0;
max-height: 280px;
overflow-y: auto;
}
.quick-jump__item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
text-align: left;
border: none;
background: transparent;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
}
.quick-jump__item:hover {
background: var(--jk-primary-soft);
}
.quick-jump__item-label {
font-weight: 600;
font-size: 14px;
}
.quick-jump__item-meta {
font-size: 12px;
color: var(--jk-text-subtle);
}
.quick-jump__empty {
padding: 12px;
color: var(--jk-text-subtle);
font-size: 13px;
}
.quick-jump__footer {
border-top: 1px solid var(--jk-border);
margin-top: 8px;
padding-top: 8px;
}
.recently-opened {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
width: 100%;
padding: 0 16px 8px;
}
@media (min-width: 1024px) {
.recently-opened {
width: auto;
padding: 0;
}
}
.recently-opened__label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--jk-text-subtle);
}
.recently-opened__items {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.recently-opened__chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 180px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--jk-border);
background: var(--jk-surface-raised);
text-decoration: none;
color: var(--jk-text);
font-size: 12px;
}
.recently-opened__chip:hover {
border-color: var(--jk-primary);
}
.recently-opened__type {
font-weight: 700;
color: var(--jk-primary);
font-size: 10px;
}
.recently-opened__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mode-subnav {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.mode-subnav__link {
padding: 8px 14px;
border-radius: 999px;
text-decoration: none;
font-size: 13px;
font-weight: 600;
color: var(--jk-text-muted);
border: 1px solid var(--jk-border);
background: var(--jk-surface);
}
.mode-subnav__link--active {
color: var(--jk-primary);
border-color: var(--jk-primary);
background: var(--jk-primary-soft);
}
.scope-picker__list {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: grid;
gap: 8px;
}
.scope-picker__link {
display: block;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--jk-border);
text-decoration: none;
font-weight: 600;
color: var(--jk-text);
}
.scope-picker__link:hover {
border-color: var(--jk-primary);
background: var(--jk-primary-soft);
}
.team-workload-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.team-workload-list__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 10px 0;
border-bottom: 1px solid var(--jk-border);
}
.section-header--compact {
margin-bottom: 12px;
}

View File

@ -0,0 +1,34 @@
const STORAGE_KEY = 'kairo.recentObjects'
const MAX_ITEMS = 12
/**
* @typedef {{ type: string, id: string, title: string, href: string, initiativeId?: string }} RecentObject
*/
/** @returns {RecentObject[]} */
export function loadRecentObjects() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
/** @param {RecentObject} entry */
export function recordRecentObject(entry) {
if (!entry?.id || !entry?.href) return
const items = loadRecentObjects().filter(
(item) => !(item.type === entry.type && item.id === entry.id),
)
items.unshift({
type: entry.type,
id: entry.id,
title: entry.title || entry.id,
href: entry.href,
initiativeId: entry.initiativeId || '',
})
localStorage.setItem(STORAGE_KEY, JSON.stringify(items.slice(0, MAX_ITEMS)))
}

View File

@ -0,0 +1,57 @@
/** @typedef {{ initiativeId?: string, projectId?: string }} ProgramScope */
export const MODE_META = {
cockpit: {
key: 'cockpit',
title: 'Cockpit',
lead: 'Portfolio-Überblick — welches Vorhaben braucht Aufmerksamkeit?',
},
work: {
key: 'work',
title: 'Ausführen',
lead: 'Committete Arbeit — was ist jetzt dran?',
},
plan: {
key: 'plan',
title: 'Planen',
lead: 'Struktur, Gates und Eingang — was ist geplant?',
},
control: {
key: 'control',
title: 'Kontrolle',
lead: 'Steuerung und Lagebild — wo steht das Vorhaben?',
},
team: {
key: 'team',
title: 'Team',
lead: 'Auslastung und Zuweisungen über alle Vorhaben.',
},
}
/**
* @param {string} path
* @param {ProgramScope} [scope]
*/
export function scopedPath(path, scope = {}) {
const params = new URLSearchParams()
if (scope.initiativeId) params.set('initiative', scope.initiativeId)
if (scope.projectId) params.set('project', scope.projectId)
const qs = params.toString()
return qs ? `${path}?${qs}` : path
}
export function actionPath(actionId) {
return `/actions/${actionId}`
}
export function projectPath(projectId) {
return `/projects/${projectId}`
}
export function gatePath(gateId) {
return `/gates/${gateId}`
}
export function initiativeCatalogPath() {
return '/initiatives'
}

View File

@ -7,6 +7,7 @@ import { WidgetCard } from '../components/WidgetCard.jsx'
import { EmptyState } from '../components/EmptyState.jsx' import { EmptyState } from '../components/EmptyState.jsx'
import { ATTENTION_KIND_LABELS } from '../constants/operating.js' import { ATTENTION_KIND_LABELS } from '../constants/operating.js'
import { actionPath, scopedPath } from '../utils/routes.js'
const SEVERITY_LABELS = { const SEVERITY_LABELS = {
critical: 'Kritisch', critical: 'Kritisch',
@ -15,11 +16,11 @@ const SEVERITY_LABELS = {
} }
function attentionLink(item) { function attentionLink(item) {
if (item.initiative_id && item.action_id) { if (item.action_id) {
return `/initiatives/${item.initiative_id}` return actionPath(item.action_id)
} }
if (item.initiative_id) { if (item.initiative_id) {
return `/initiatives/${item.initiative_id}` return scopedPath('/control/status', { initiativeId: item.initiative_id })
} }
return null return null
} }

View File

@ -5,6 +5,7 @@ import { ActionList } from '../components/ActionList.jsx'
import { EmptyState } from '../components/EmptyState.jsx' import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
import { actionPath } from '../utils/routes.js'
import { WidgetCard } from '../components/WidgetCard.jsx' import { WidgetCard } from '../components/WidgetCard.jsx'
export function BlockedActionsWidget() { export function BlockedActionsWidget() {
@ -46,13 +47,13 @@ export function BlockedActionsWidget() {
<ActionList <ActionList
actions={actions} actions={actions}
showInitiative showInitiative
getManageLink={(a) => `/initiatives/${a.initiative_id}`} getManageLink={(a) => actionPath(a.id)}
empty={<EmptyState message="Keine blockierten Maßnahmen." />} empty={<EmptyState message="Keine blockierten Maßnahmen." />}
/> />
)} )}
{!loading && !error && actions.length > 0 && ( {!loading && !error && actions.length > 0 && (
<p className="muted mt-sm"> <p className="muted mt-sm">
<Link to="/my-actions" className="link-inline"> <Link to="/work/mine" className="link-inline">
In Meine Maßnahmen bearbeiten In Meine Maßnahmen bearbeiten
</Link> </Link>
</p> </p>

View File

@ -6,6 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { EmptyState } from '../components/EmptyState.jsx' import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
import { scopedPath } from '../utils/routes.js'
import { WidgetCard } from '../components/WidgetCard.jsx' import { WidgetCard } from '../components/WidgetCard.jsx'
export function InitiativePortfolioWidget() { export function InitiativePortfolioWidget() {
@ -51,7 +52,7 @@ export function InitiativePortfolioWidget() {
{initiatives.map((item) => ( {initiatives.map((item) => (
<Link <Link
key={item.id} key={item.id}
to={`/initiatives/${item.id}`} to={scopedPath('/control/status', { initiativeId: item.id })}
className="initiative-portfolio-card card" className="initiative-portfolio-card card"
> >
<h3 className="initiative-portfolio-card-title">{item.title}</h3> <h3 className="initiative-portfolio-card-title">{item.title}</h3>

View File

@ -6,6 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { EmptyState } from '../components/EmptyState.jsx' import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
import { scopedPath } from '../utils/routes.js'
import { WidgetCard } from '../components/WidgetCard.jsx' import { WidgetCard } from '../components/WidgetCard.jsx'
export function InitiativesWidget() { export function InitiativesWidget() {
@ -48,7 +49,7 @@ export function InitiativesWidget() {
<ul className="item-list"> <ul className="item-list">
{initiatives.map((item) => ( {initiatives.map((item) => (
<li key={item.id} className="list-item card-list-item"> <li key={item.id} className="list-item card-list-item">
<Link to={`/initiatives/${item.id}`} className="list-item-link"> <Link to={scopedPath('/control/status', { initiativeId: item.id })} className="list-item-link">
<strong>{item.title}</strong> <strong>{item.title}</strong>
</Link> </Link>
<div className="list-item-meta"> <div className="list-item-meta">

View File

@ -5,6 +5,7 @@ import { ActionList } from '../components/ActionList.jsx'
import { EmptyState } from '../components/EmptyState.jsx' import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
import { actionPath } from '../utils/routes.js'
import { WidgetCard } from '../components/WidgetCard.jsx' import { WidgetCard } from '../components/WidgetCard.jsx'
export function MyOpenActionsWidget() { export function MyOpenActionsWidget() {
@ -40,7 +41,7 @@ export function MyOpenActionsWidget() {
} }
footer={ footer={
actions.length > 0 ? ( actions.length > 0 ? (
<Link to="/my-actions" className="link-inline"> <Link to="/work/mine" className="link-inline">
Alle meine Maßnahmen Alle meine Maßnahmen
</Link> </Link>
) : null ) : null
@ -52,7 +53,7 @@ export function MyOpenActionsWidget() {
<ActionList <ActionList
actions={actions} actions={actions}
showInitiative showInitiative
getManageLink={(a) => `/initiatives/${a.initiative_id}`} getManageLink={(a) => actionPath(a.id)}
empty={ empty={
<EmptyState message="Keine offenen Maßnahmen. Du kannst eine neue Maßnahme in einem Vorhaben anlegen." /> <EmptyState message="Keine offenen Maßnahmen. Du kannst eine neue Maßnahme in einem Vorhaben anlegen." />
} }

View File

@ -5,7 +5,7 @@ import { NEXT_ACTION_KIND_LABELS } from '../constants/operating.js'
import { EmptyState } from '../components/EmptyState.jsx' import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
import { WidgetCard } from '../components/WidgetCard.jsx' import { actionPath, scopedPath } from '../utils/routes.js'
function NextActionList({ items, initiativeId, showInitiativeLink = true }) { function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
if (items.length === 0) { if (items.length === 0) {
@ -33,16 +33,16 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
)} )}
</div> </div>
<div className="list-item-meta"> <div className="list-item-meta">
{item.action_id && initiativeId && ( {item.action_id && (
<Link <Link to={actionPath(item.action_id)} className="btn btn-primary btn-sm">
to={`/initiatives/${initiativeId}/actions/${item.action_id}`}
className="btn btn-primary btn-sm"
>
Arbeitspaket Arbeitspaket
</Link> </Link>
)} )}
{showInitiativeLink && item.initiative_id && !initiativeId && ( {showInitiativeLink && item.initiative_id && !initiativeId && (
<Link to={`/initiatives/${item.initiative_id}`} className="btn btn-primary btn-sm"> <Link
to={scopedPath('/control/status', { initiativeId: item.initiative_id })}
className="btn btn-primary btn-sm"
>
Zum Vorhaben Zum Vorhaben
</Link> </Link>
)} )}

View File

@ -6,7 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { EmptyState } from '../components/EmptyState.jsx' import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
import { WidgetCard } from '../components/WidgetCard.jsx' import { actionPath, scopedPath } from '../utils/routes.js'
function formatDue(iso) { function formatDue(iso) {
if (!iso) return null if (!iso) return null
@ -55,7 +55,7 @@ export function WorkspaceTodayWidget() {
} }
footer={ footer={
actions.length > 0 ? ( actions.length > 0 ? (
<Link to="/my-actions" className="link-inline"> <Link to="/work/mine" className="link-inline">
Alle meine Maßnahmen Alle meine Maßnahmen
</Link> </Link>
) : null ) : null
@ -85,7 +85,7 @@ export function WorkspaceTodayWidget() {
<div className="list-item-meta"> <div className="list-item-meta">
<StatusBadge status={action.status} /> <StatusBadge status={action.status} />
<PriorityBadge priority={action.priority} /> <PriorityBadge priority={action.priority} />
<Link to={`/initiatives/${action.initiative_id}`} className="btn btn-secondary btn-sm"> <Link to={actionPath(action.id)} className="btn btn-secondary btn-sm">
Öffnen Öffnen
</Link> </Link>
</div> </div>