diff --git a/backend/version.py b/backend/version.py
index 15e34a5..856b6ae 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,3 +1,3 @@
-APP_VERSION = "0.15.1-ap1.5e"
+APP_VERSION = "0.16.0-ap1.9a"
DB_SCHEMA_VERSION = "014"
APP_NAME = "jinkendo-kairo"
diff --git a/docs/architecture/ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md b/docs/architecture/ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md
new file mode 100644
index 0000000..ac85442
--- /dev/null
+++ b/docs/architecture/ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md
@@ -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.2c–AP1.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.1–7.2 | Workspace = Querschnitt; Initiative-Übersicht = Steuerung im Alltag |
+| Canonical OM §8.1–8.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.9a–e 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`
diff --git a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md
index c052a5c..6fe4c5a 100644
--- a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md
+++ b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md
@@ -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`.
-**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) → 9b–9e (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 G2 Rekursive Container AP1.5c–5d ✓ 5c / → 5d nach AP1.6
Phase H Plan/Ist-Verknüpfung AP1.6 ✓
-Phase H2 Rekursive Tasks AP1.5d ← **NÄCHSTES CODE**
-Phase I Gate-Graph AP1.4d–4e nach AP1.6
+Phase H2 PM Work Modes IA AP1.9 ← **NÄCHSTES CODE** (PO 2026-07-08)
+Phase H3 Rekursive Tasks AP1.5d nach AP1.9b
+Phase I Gate-Graph AP1.4d–4e nach AP1.9d
Phase J Portfolio & Kontext AP1.8 geplant
Phase K Agent Interface AP1.7 geplant (unverändert)
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.5c | 0.14.1-ap1.5c | Rekursive Projects |
| 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 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
**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) |
**ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`
-**Nach AP1.6**
-**Version:** `0.15.1-ap1.5d`
+**Nach AP1.9b** (Tasks füllen `/work`, IA bleibt)
+**Version:** `0.16.x-ap1.5d`
---
diff --git a/docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md b/docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md
new file mode 100644
index 0000000..0298b2c
--- /dev/null
+++ b/docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md
@@ -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** | 5–10 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.*
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 0137e1f..8be2470 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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 { ProgramScopeProvider } from './context/ProgramScopeContext.jsx'
import { AppLayout } from './layout/AppLayout.jsx'
-import { InitiativeLayout } from './layout/InitiativeLayout.jsx'
import { AuthPanel } from './pages/AuthPanel.jsx'
import { ActionDetailPage } from './pages/ActionDetailPage.jsx'
-import { InitiativeOverviewPage } from './pages/initiative/InitiativeOverviewPage.jsx'
-import { InitiativePlanPage } from './pages/initiative/InitiativePlanPage.jsx'
-import { InitiativeExecutionPage } from './pages/initiative/InitiativeExecutionPage.jsx'
-import { ProjectDetailPage } from './pages/initiative/ProjectDetailPage.jsx'
-import { InitiativeInboxPage } from './pages/initiative/InitiativeInboxPage.jsx'
-import { InitiativeJourneyPage } from './pages/initiative/InitiativeJourneyPage.jsx'
-import { RoadmapItemDetailPage } from './pages/initiative/RoadmapItemDetailPage.jsx'
-import { VIEWS } from './registry/viewRegistry.js'
+import { ProjectObjectPage } from './pages/objects/ProjectObjectPage.jsx'
+import { GateObjectPage } from './pages/objects/GateObjectPage.jsx'
+import {
+ VIEWS,
+ WorkLayout,
+ WorkIndexRedirect,
+ PlanLayout,
+ 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'
-function InitiativeLegacyDetailRedirect() {
- const { id } = useParams()
- return
-}
-
function AppRoutes() {
const { isAuthenticated, loading } = useSession()
@@ -30,30 +41,69 @@ function AppRoutes() {
return
}
- 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 (
-
- }>
- {topLevelViews.map((view) => (
- } />
- ))}
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
+
+
+ }>
+ {topLevelViews.map((view) => (
+ } />
+ ))}
+
+ }>
+ } />
+ } />
+ } />
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+
+
+ }>
+ } />
+ } />
+ } />
+
+
+ } />
+ } />
+ } />
+
+ } />
+ } />
+
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+
+ } />
+ } />
- } />
- } />
- } />
- } />
-
-
+
+
)
}
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 285e696..ad93503 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -2,3 +2,4 @@
@import './styles/shell.css';
@import './styles/components.css';
@import './styles/pages.css';
+@import './styles/program-chrome.css';
diff --git a/frontend/src/components/InitiativeNavCards.jsx b/frontend/src/components/InitiativeNavCards.jsx
index cdac7da..e737b8e 100644
--- a/frontend/src/components/InitiativeNavCards.jsx
+++ b/frontend/src/components/InitiativeNavCards.jsx
@@ -1,25 +1,30 @@
import { Link } from 'react-router-dom'
+import { scopedPath } from '../utils/routes.js'
const AREAS = [
{
- to: 'plan',
+ to: '/plan/gates',
title: 'Plan',
description: 'Roadmap, Gates und Plan-Ist — methodenneutral pflegen.',
+ countKey: 'roadmapItems',
},
{
- to: 'execution',
+ to: '/work/today',
title: 'Ausführung',
description: 'Arbeitspakete, Zuweisungen, Blocker am Objekt.',
+ countKey: 'actionsOpen',
},
{
- to: 'inbox',
+ to: '/plan/inbox',
title: 'Eingang',
description: 'Backlog triagieren und in Arbeitspakete überführen.',
+ countKey: 'backlog',
},
{
- to: 'journey',
+ to: '/control/journey',
title: 'Nachvollziehbarkeit',
description: 'Entscheidungen, Reviews, Nachweise über die Zeit.',
+ countKey: null,
},
]
@@ -30,17 +35,17 @@ export function InitiativeNavCards({ initiativeId, counts = {} }) {
{AREAS.map((area) => {
const hint =
- area.to === 'inbox' && counts.backlog
+ area.countKey === 'backlog' && counts.backlog
? `${counts.backlog} Backlog`
- : area.to === 'execution' && counts.actionsOpen
+ : area.countKey === 'actionsOpen' && counts.actionsOpen
? `${counts.actionsOpen} offen`
- : area.to === 'plan' && counts.roadmapItems
+ : area.countKey === 'roadmapItems' && counts.roadmapItems
? `${counts.roadmapItems} Plan-Elemente`
: null
return (
{area.title}
diff --git a/frontend/src/components/ModeShell.jsx b/frontend/src/components/ModeShell.jsx
new file mode 100644
index 0000000..cb3ea0e
--- /dev/null
+++ b/frontend/src/components/ModeShell.jsx
@@ -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 (
+
+
+
{meta.title}
+
{meta.lead}
+
+ {actions ? {actions}
: null}
+
+ )
+}
diff --git a/frontend/src/components/ModeSubNav.jsx b/frontend/src/components/ModeSubNav.jsx
new file mode 100644
index 0000000..c0702b1
--- /dev/null
+++ b/frontend/src/components/ModeSubNav.jsx
@@ -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 (
+
+ )
+}
diff --git a/frontend/src/components/ProgramScopeBar.jsx b/frontend/src/components/ProgramScopeBar.jsx
new file mode 100644
index 0000000..688af45
--- /dev/null
+++ b/frontend/src/components/ProgramScopeBar.jsx
@@ -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 (
+
+ Scope
+
+
+ Vorhaben-Katalog
+
+
+ )
+}
diff --git a/frontend/src/components/ProjectsSection.jsx b/frontend/src/components/ProjectsSection.jsx
index 6f7a239..1e287ab 100644
--- a/frontend/src/components/ProjectsSection.jsx
+++ b/frontend/src/components/ProjectsSection.jsx
@@ -33,7 +33,7 @@ function ProjectTreeNodes({
>
{project.title}
@@ -117,7 +117,7 @@ export function ProjectsSection({
{canManage && (
Neues Projekt
diff --git a/frontend/src/components/QuickJump.jsx b/frontend/src/components/QuickJump.jsx
new file mode 100644
index 0000000..523450f
--- /dev/null
+++ b/frontend/src/components/QuickJump.jsx
@@ -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 (
+
+
+ {open && (
+
+
setQuery(e.target.value)}
+ autoFocus
+ />
+
+ {query.trim() === '' &&
+ recent.map((item) => (
+ -
+
+
+ ))}
+ {query.trim() !== '' &&
+ results.map((item) => (
+ -
+
+
+ ))}
+ {query.trim() !== '' && results.length === 0 && (
+ - Keine Treffer
+ )}
+ {query.trim() === '' && recent.length === 0 && (
+ - Tippe zum Suchen — oder öffne ein Objekt.
+ )}
+
+
+
+
+
+ )}
+
+ )
+}
+
+export { actionPath, projectPath, gatePath }
diff --git a/frontend/src/components/RecentlyOpened.jsx b/frontend/src/components/RecentlyOpened.jsx
new file mode 100644
index 0000000..479f005
--- /dev/null
+++ b/frontend/src/components/RecentlyOpened.jsx
@@ -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 (
+
+
Zuletzt
+
+ {items.map((item) => (
+
+ {TYPE_LABELS[item.type] || item.type}
+ {item.title}
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/components/RequireInitiativeScope.jsx b/frontend/src/components/RequireInitiativeScope.jsx
new file mode 100644
index 0000000..b4c3e0e
--- /dev/null
+++ b/frontend/src/components/RequireInitiativeScope.jsx
@@ -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
+ }
+
+ return (
+
+ Vorhaben wählen
+
+ {lead || 'Dieser Modus braucht ein Vorhaben als Scope-Filter.'}
+
+ {initiatives.length === 0 ? (
+ Noch keine Vorhaben angelegt.
+ ) : (
+
+ {initiatives.map((item) => (
+ -
+ applyFromInitiative(item.id)}
+ >
+ {item.title}
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/RoadmapPlanSection.jsx b/frontend/src/components/RoadmapPlanSection.jsx
index 313542b..b3c33b5 100644
--- a/frontend/src/components/RoadmapPlanSection.jsx
+++ b/frontend/src/components/RoadmapPlanSection.jsx
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
+import { gatePath } from '../utils/routes.js'
import {
MILESTONE_STATUSES,
MILESTONE_STATUS_LABELS,
@@ -152,7 +153,7 @@ export function RoadmapPlanSection({
{initiativeId ? (
-
+
{item.title}
) : (
diff --git a/frontend/src/config/appNav.js b/frontend/src/config/appNav.js
index 4012151..304110c 100644
--- a/frontend/src/config/appNav.js
+++ b/frontend/src/config/appNav.js
@@ -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'
-/** @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 = {
- 'kairo.workspace': LayoutDashboard,
+ 'kairo.cockpit': LayoutDashboard,
+ 'kairo.work': Play,
+ 'kairo.plan': Map,
+ 'kairo.control': Gauge,
+ 'kairo.team': Users,
'kairo.initiatives': FolderKanban,
- 'kairo.my_actions': ListChecks,
}
/**
@@ -19,9 +29,10 @@ export function getAppNavItems(capabilities) {
to: view.path,
label: view.navLabel || 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,
matchPrefix: view.matchPrefix,
+ objectPrefixes: view.objectPrefixes,
}))
}
@@ -31,8 +42,14 @@ export function getAppNavItems(capabilities) {
* @param {boolean} routerIsActive
*/
export function isNavItemActive(pathname, item, routerIsActive) {
- if (item.matchPrefix) {
- return pathname.startsWith(item.matchPrefix)
+ if (item.matchPrefix && 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
}
diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx
index 6c7060d..ec73b18 100644
--- a/frontend/src/context/InitiativeOperationsContext.jsx
+++ b/frontend/src/context/InitiativeOperationsContext.jsx
@@ -68,12 +68,15 @@ import { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/stee
import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js'
import { useSession } from './SessionContext.jsx'
+import { useOptionalProgramScope } from './ProgramScopeContext.jsx'
import { collectProjectSubtreeIds } from '../utils/projectTree.js'
const InitiativeOperationsContext = createContext(null)
-export function InitiativeOperationsProvider({ children }) {
- const { id } = useParams()
+export function InitiativeOperationsProvider({ children, initiativeId: initiativeIdProp }) {
+ const { id: routeId } = useParams()
+ const scope = useOptionalProgramScope()
+ const id = initiativeIdProp || routeId || scope?.initiativeId || null
const { context, loading: sessionLoading } = useSession()
const { capabilities } = useCapabilities()
const actorsState = useActors()
diff --git a/frontend/src/context/ProgramScopeContext.jsx b/frontend/src/context/ProgramScopeContext.jsx
new file mode 100644
index 0000000..12d6516
--- /dev/null
+++ b/frontend/src/context/ProgramScopeContext.jsx
@@ -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 (
+ {children}
+ )
+}
+
+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)
+}
diff --git a/frontend/src/layout/AppLayout.jsx b/frontend/src/layout/AppLayout.jsx
index 89c7f79..00c04ee 100644
--- a/frontend/src/layout/AppLayout.jsx
+++ b/frontend/src/layout/AppLayout.jsx
@@ -5,6 +5,9 @@ import { LoadingState } from '../components/LoadingState.jsx'
import { DesktopSidebar } from '../components/DesktopSidebar.jsx'
import { BottomNav } from '../components/BottomNav.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() {
const { me, signOut, loading } = useSession()
@@ -36,9 +39,17 @@ export function AppLayout() {
Kairo
Jinkendo Program Director
+
+
+
+
diff --git a/frontend/src/layout/ScopedInitiativeProvider.jsx b/frontend/src/layout/ScopedInitiativeProvider.jsx
new file mode 100644
index 0000000..b4dca62
--- /dev/null
+++ b/frontend/src/layout/ScopedInitiativeProvider.jsx
@@ -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 (
+
+ {children}
+
+ )
+}
diff --git a/frontend/src/pages/ActionDetailPage.jsx b/frontend/src/pages/ActionDetailPage.jsx
index 1793005..d6d3fb5 100644
--- a/frontend/src/pages/ActionDetailPage.jsx
+++ b/frontend/src/pages/ActionDetailPage.jsx
@@ -12,8 +12,11 @@ import { TasksSection } from '../components/TasksSection.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
+import { useProgramScope } from '../context/ProgramScopeContext.jsx'
import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js'
+import { recordRecentObject } from '../utils/recentObjects.js'
+import { actionPath, scopedPath } from '../utils/routes.js'
function ActionDetailBody({
action,
@@ -81,6 +84,7 @@ function ActionDetailBody({
function ActionDetailStandalone() {
const { actionId } = useParams()
+ const { applyFromAction } = useProgramScope()
const { capabilities } = useCapabilities()
const actorsState = useActors()
const [action, setAction] = useState(null)
@@ -98,6 +102,14 @@ function ActionDetailStandalone() {
try {
const actionData = await getAction(actionId)
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([
getInitiativeSteeringSnapshot(actionData.initiative_id),
listInitiativeProjects(actionData.initiative_id).catch(() => []),
@@ -111,7 +123,7 @@ function ActionDetailStandalone() {
} finally {
setLoading(false)
}
- }, [actionId])
+ }, [actionId, applyFromAction])
useEffect(() => {
load()
@@ -190,11 +202,15 @@ function ActionDetailStandalone() {
return (
diff --git a/frontend/src/pages/InitiativesPage.jsx b/frontend/src/pages/InitiativesPage.jsx
index 3003941..fb72511 100644
--- a/frontend/src/pages/InitiativesPage.jsx
+++ b/frontend/src/pages/InitiativesPage.jsx
@@ -10,6 +10,7 @@ import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { useCapabilities } from '../hooks/useCapabilities.js'
import { useSession } from '../context/SessionContext.jsx'
+import { scopedPath } from '../utils/routes.js'
export function InitiativesPage() {
const { context } = useSession()
@@ -105,7 +106,7 @@ export function InitiativesPage() {
{initiatives.map((item) => (
-
+
{item.title}
{item.goal &&
{item.goal}
}
diff --git a/frontend/src/pages/LegacyRedirects.jsx b/frontend/src/pages/LegacyRedirects.jsx
new file mode 100644
index 0000000..8a954a1
--- /dev/null
+++ b/frontend/src/pages/LegacyRedirects.jsx
@@ -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
+}
+
+export function MyActionsRedirect() {
+ return
+}
+
+export function InitiativeRootRedirect() {
+ const { id } = useParams()
+ return
+}
+
+export function InitiativePlanRedirect() {
+ const { id } = useParams()
+ return
+}
+
+export function InitiativeExecutionRedirect() {
+ const { id } = useParams()
+ return
+}
+
+export function InitiativeInboxRedirect() {
+ const { id } = useParams()
+ return
+}
+
+export function InitiativeJourneyRedirect() {
+ const { id } = useParams()
+ return
+}
+
+export function InitiativeGateRedirect() {
+ const { itemId } = useParams()
+ return
+}
+
+export function InitiativeProjectRedirect() {
+ const { projectId } = useParams()
+ return
+}
+
+export function InitiativeActionRedirect() {
+ const { actionId } = useParams()
+ return
+}
+
+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
+}
diff --git a/frontend/src/pages/MyActionsPage.jsx b/frontend/src/pages/MyActionsPage.jsx
index f4019a5..7e3f320 100644
--- a/frontend/src/pages/MyActionsPage.jsx
+++ b/frontend/src/pages/MyActionsPage.jsx
@@ -7,7 +7,7 @@ import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { WidgetCard } from '../components/WidgetCard.jsx'
-export function MyActionsPage() {
+export function MyActionsPage({ embedded = false }) {
const [actions, setActions] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -34,25 +34,41 @@ export function MyActionsPage() {
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
: actions
- return (
-
-
-
-
Meine Maßnahmen
-
Dir zugewiesene offene und blockierte Maßnahmen.
+ const content = (
+ <>
+ {!embedded && (
+
+ )}
+
+ {embedded && (
+
+
Meine Queue
+
-
-
+ )}
Aktualisieren
@@ -65,7 +81,7 @@ export function MyActionsPage() {
`/initiatives/${a.initiative_id}`}
+ getManageLink={(a) => `/actions/${a.id}`}
empty={
}
@@ -73,11 +89,19 @@ export function MyActionsPage() {
)}
-
-
- Zu den Vorhaben →
-
-
-
+ {!embedded && (
+
+
+ Zum Vorhaben-Katalog →
+
+
+ )}
+ >
)
+
+ if (embedded) {
+ return content
+ }
+
+ return {content}
}
diff --git a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx
index 8bb8387..dba68c5 100644
--- a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx
+++ b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx
@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom'
+import { scopedPath } from '../../utils/routes.js'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx'
import { NextActionWidget } from '../../widgets/NextActionWidget.jsx'
@@ -60,7 +61,7 @@ export function InitiativeOverviewPage() {
{counts.blockers_open} offene Blocker
)}
-
+
Zur Ausführung
diff --git a/frontend/src/pages/initiative/InitiativePlanPage.jsx b/frontend/src/pages/initiative/InitiativePlanPage.jsx
index d0a177b..458266e 100644
--- a/frontend/src/pages/initiative/InitiativePlanPage.jsx
+++ b/frontend/src/pages/initiative/InitiativePlanPage.jsx
@@ -1,10 +1,9 @@
-import { useParams } from 'react-router-dom'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx'
export function InitiativePlanPage() {
- const { id } = useParams()
const {
+ initiativeId,
roadmapItems,
capabilities,
formBusy,
@@ -24,7 +23,7 @@ export function InitiativePlanPage() {
<>
{error && {error}
}
Gate
{gateTitle ? (
-
+
{gateTitle}
) : (
@@ -96,7 +97,7 @@ function ProjectDetailView({
{index === path.length - 1 ? (
{node.title}
) : (
-
+
{node.title}
)}
@@ -112,7 +113,7 @@ function ProjectDetailView({
{childProjects.map((child) => (
-
-
+
{child.title}
@@ -136,7 +137,7 @@ function ProjectDetailView({
{linkedActions.map((action) => (
-
-
+
{action.title}
@@ -158,7 +159,7 @@ function ProjectDetailView({
{canManage && (
Unterprojekt
@@ -212,7 +213,7 @@ export function ProjectDetailPage() {
setLocalError(null)
const created = await ops.handleCreateProject(payload)
if (created?.id) {
- navigate(`/initiatives/${ops.initiativeId}/execution/projects/${created.id}`, { replace: true })
+ navigate(projectPath(created.id), { replace: true })
} else if (ops.error) {
setLocalError(ops.error)
}
@@ -233,7 +234,7 @@ export function ProjectDetailPage() {
setLocalError(null)
const ok = await ops.handleDeleteProject(project.id)
if (ok) {
- navigate(`/initiatives/${ops.initiativeId}/execution`)
+ navigate(scopedPath('/plan/structure', { initiativeId: ops.initiativeId }))
} else if (ops.error) {
setLocalError(ops.error)
}
@@ -241,7 +242,7 @@ export function ProjectDetailPage() {
function handleFilter() {
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'
@@ -249,7 +250,7 @@ export function ProjectDetailPage() {
return (
@@ -298,7 +299,7 @@ export function ProjectDetailPage() {
onSubmit={isNew ? handleCreate : handleUpdate}
onCancel={
isNew
- ? () => navigate(`/initiatives/${ops.initiativeId}/execution`)
+ ? () => navigate(scopedPath('/plan/structure', { initiativeId: ops.initiativeId }))
: () => setEditing(false)
}
busy={ops.formBusy}
diff --git a/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx b/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx
index 0bfce73..80e89a7 100644
--- a/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx
+++ b/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx
@@ -25,6 +25,8 @@ import {
ROADMAP_ITEM_TYPE_LABELS,
} from '../../constants/status.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 { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
@@ -71,8 +73,11 @@ function CriterionDecisionForm({ label, onSubmit, busy }) {
)
}
-export function RoadmapItemDetailPage() {
- const { id: initiativeId, itemId } = useParams()
+export function RoadmapItemDetailPage({ overrideItemId }) {
+ const { id: routeInitiativeId, itemId: routeItemId, gateId } = useParams()
+ const ops = useInitiativeOperations()
+ const initiativeId = routeInitiativeId || ops?.initiativeId
+ const itemId = overrideItemId || routeItemId || gateId
const { capabilities } = useCapabilities()
const canManage = capabilities.has('kairo.milestone.manage')
const canReopen = capabilities.has('kairo.roadmap.reopen')
@@ -152,7 +157,7 @@ export function RoadmapItemDetailPage() {
return (
- Zielzustände
+ Zielzustände
{' · '}
{item.title}
diff --git a/frontend/src/pages/modes/CockpitPage.jsx b/frontend/src/pages/modes/CockpitPage.jsx
new file mode 100644
index 0000000..b6d7eb9
--- /dev/null
+++ b/frontend/src/pages/modes/CockpitPage.jsx
@@ -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 (
+
+
setShowForm((v) => !v)}
+ >
+ {showForm ? 'Abbrechen' : 'Vorhaben anlegen'}
+
+ ) : null
+ }
+ />
+
+ {showForm && (
+
+ Neues Vorhaben
+ {formError && {formError}
}
+ setShowForm(false)}
+ busy={formBusy}
+ submitLabel="Vorhaben anlegen"
+ />
+
+ )}
+
+
+ {widgets.map((widget) => {
+ const Component = widget.component
+ return
+ })}
+
+
+ {hasCapability('kairo.initiative.read') && (
+
+
+ Zum Vorhaben-Katalog →
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/modes/ControlJourneyPage.jsx b/frontend/src/pages/modes/ControlJourneyPage.jsx
new file mode 100644
index 0000000..cd439dd
--- /dev/null
+++ b/frontend/src/pages/modes/ControlJourneyPage.jsx
@@ -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 (
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/modes/ControlLayout.jsx b/frontend/src/pages/modes/ControlLayout.jsx
new file mode 100644
index 0000000..d6c011d
--- /dev/null
+++ b/frontend/src/pages/modes/ControlLayout.jsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
+export function ControlIndexRedirect() {
+ return
+}
diff --git a/frontend/src/pages/modes/ControlStatusPage.jsx b/frontend/src/pages/modes/ControlStatusPage.jsx
new file mode 100644
index 0000000..779b874
--- /dev/null
+++ b/frontend/src/pages/modes/ControlStatusPage.jsx
@@ -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 (
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/modes/PlanGatesPage.jsx b/frontend/src/pages/modes/PlanGatesPage.jsx
new file mode 100644
index 0000000..97e33bb
--- /dev/null
+++ b/frontend/src/pages/modes/PlanGatesPage.jsx
@@ -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 (
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/modes/PlanInboxPage.jsx b/frontend/src/pages/modes/PlanInboxPage.jsx
new file mode 100644
index 0000000..329b336
--- /dev/null
+++ b/frontend/src/pages/modes/PlanInboxPage.jsx
@@ -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 (
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/modes/PlanLayout.jsx b/frontend/src/pages/modes/PlanLayout.jsx
new file mode 100644
index 0000000..024afa4
--- /dev/null
+++ b/frontend/src/pages/modes/PlanLayout.jsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
+export function PlanIndexRedirect() {
+ return
+}
diff --git a/frontend/src/pages/modes/PlanStructurePage.jsx b/frontend/src/pages/modes/PlanStructurePage.jsx
new file mode 100644
index 0000000..71ec31f
--- /dev/null
+++ b/frontend/src/pages/modes/PlanStructurePage.jsx
@@ -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 && {error}
}
+
+ >
+ )
+}
+
+export function PlanStructurePage() {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/modes/TeamPage.jsx b/frontend/src/pages/modes/TeamPage.jsx
new file mode 100644
index 0000000..2d98027
--- /dev/null
+++ b/frontend/src/pages/modes/TeamPage.jsx
@@ -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 (
+
+
+ {rows === null && !error &&
}
+ {error &&
}
+ {rows && (
+
+ Auslastung nach Actor
+ {rows.length === 0 ? (
+ Keine Zuweisungen im Portfolio.
+ ) : (
+
+ )}
+
+ AP1.9b erweitert Team um Filter nach Vorhaben und direkte Zuweisungslinks.
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/modes/WorkLayout.jsx b/frontend/src/pages/modes/WorkLayout.jsx
new file mode 100644
index 0000000..3d60ad3
--- /dev/null
+++ b/frontend/src/pages/modes/WorkLayout.jsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
+export function WorkIndexRedirect() {
+ return
+}
diff --git a/frontend/src/pages/modes/WorkMinePage.jsx b/frontend/src/pages/modes/WorkMinePage.jsx
new file mode 100644
index 0000000..d766e8c
--- /dev/null
+++ b/frontend/src/pages/modes/WorkMinePage.jsx
@@ -0,0 +1,5 @@
+import { MyActionsPage } from '../MyActionsPage.jsx'
+
+export function WorkMinePage() {
+ return
+}
diff --git a/frontend/src/pages/modes/WorkTodayPage.jsx b/frontend/src/pages/modes/WorkTodayPage.jsx
new file mode 100644
index 0000000..d88467a
--- /dev/null
+++ b/frontend/src/pages/modes/WorkTodayPage.jsx
@@ -0,0 +1,9 @@
+import { WorkspaceTodayWidget } from '../../widgets/WorkspaceTodayWidget.jsx'
+
+export function WorkTodayPage() {
+ return (
+
+ )
+}
diff --git a/frontend/src/pages/objects/GateObjectPage.jsx b/frontend/src/pages/objects/GateObjectPage.jsx
new file mode 100644
index 0000000..91c7546
--- /dev/null
+++ b/frontend/src/pages/objects/GateObjectPage.jsx
@@ -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 (
+
+
+
+ )
+ }
+
+ if (error || !gate) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/objects/ProjectObjectPage.jsx b/frontend/src/pages/objects/ProjectObjectPage.jsx
new file mode 100644
index 0000000..8dde00a
--- /dev/null
+++ b/frontend/src/pages/objects/ProjectObjectPage.jsx
@@ -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 (
+
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
+ )
+ }
+
+ const initiativeId = project?.initiative_id || initiativeFromQuery
+
+ if (projectId === 'new' && !initiativeId) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/registry/registry.test.js b/frontend/src/registry/registry.test.js
index 692f071..970d3cc 100644
--- a/frontend/src/registry/registry.test.js
+++ b/frontend/src/registry/registry.test.js
@@ -32,16 +32,19 @@ describe('widgetRegistry', () => {
})
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)
- 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.my_actions')
})
it('filters nav views by capability', () => {
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)
})
})
@@ -52,15 +55,19 @@ describe('appNav', () => {
'kairo.action.read',
'kairo.initiative.read',
])
- expect(items.length).toBe(3)
+ expect(items.length).toBeGreaterThanOrEqual(5)
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 initiatives = items.find((i) => i.key === 'kairo.initiatives')
- expect(
- isNavItemActive('/initiatives/abc-123', initiatives, false),
- ).toBe(true)
+ const plan = items.find((i) => i.key === 'kairo.plan')
+ expect(isNavItemActive('/projects/abc-123', plan, 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)
})
})
diff --git a/frontend/src/registry/viewRegistry.js b/frontend/src/registry/viewRegistry.js
index 564bdd7..c4c4467 100644
--- a/frontend/src/registry/viewRegistry.js
+++ b/frontend/src/registry/viewRegistry.js
@@ -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 { MyActionsPage } from '../pages/MyActionsPage.jsx'
/** @type {import('./viewRegistry.js').ViewDefinition[]} */
export const VIEWS = [
{
- key: 'kairo.workspace',
- path: '/workspace',
- title: 'Workspace',
- navLabel: 'Workspace',
- shortLabel: 'Start',
+ key: 'kairo.cockpit',
+ path: '/cockpit',
+ title: 'Cockpit',
+ navLabel: 'Cockpit',
+ shortLabel: 'Cockpit',
+ matchPrefix: '/cockpit',
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,
},
{
@@ -25,15 +82,19 @@ export const VIEWS = [
component: InitiativesPage,
showInNav: true,
},
+ {
+ key: 'kairo.workspace',
+ path: '/workspace',
+ title: 'Workspace',
+ component: CockpitPage,
+ showInNav: false,
+ },
{
key: 'kairo.my_actions',
path: '/my-actions',
title: 'Meine Maßnahmen',
- navLabel: 'Meine Maßnahmen',
- shortLabel: 'Maßnahmen',
- requiredCapability: 'kairo.action.read',
- component: MyActionsPage,
- showInNav: true,
+ component: WorkMinePage,
+ showInNav: false,
},
]
@@ -45,6 +106,7 @@ export const VIEWS = [
* @property {string} [navLabel]
* @property {string} [shortLabel]
* @property {string} [matchPrefix]
+ * @property {string[]} [objectPrefixes]
* @property {string} [requiredCapability]
* @property {import('react').ComponentType} component
* @property {boolean} [showInNav]
@@ -59,3 +121,22 @@ export function getNavViews(capabilities) {
export function getViewByKey(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,
+}
diff --git a/frontend/src/styles/program-chrome.css b/frontend/src/styles/program-chrome.css
new file mode 100644
index 0000000..7239c28
--- /dev/null
+++ b/frontend/src/styles/program-chrome.css
@@ -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;
+}
diff --git a/frontend/src/utils/recentObjects.js b/frontend/src/utils/recentObjects.js
new file mode 100644
index 0000000..3bfcc9e
--- /dev/null
+++ b/frontend/src/utils/recentObjects.js
@@ -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)))
+}
diff --git a/frontend/src/utils/routes.js b/frontend/src/utils/routes.js
new file mode 100644
index 0000000..b931aa0
--- /dev/null
+++ b/frontend/src/utils/routes.js
@@ -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'
+}
diff --git a/frontend/src/widgets/AttentionWidget.jsx b/frontend/src/widgets/AttentionWidget.jsx
index bcf3e9a..073f7e9 100644
--- a/frontend/src/widgets/AttentionWidget.jsx
+++ b/frontend/src/widgets/AttentionWidget.jsx
@@ -7,6 +7,7 @@ import { WidgetCard } from '../components/WidgetCard.jsx'
import { EmptyState } from '../components/EmptyState.jsx'
import { ATTENTION_KIND_LABELS } from '../constants/operating.js'
+import { actionPath, scopedPath } from '../utils/routes.js'
const SEVERITY_LABELS = {
critical: 'Kritisch',
@@ -15,11 +16,11 @@ const SEVERITY_LABELS = {
}
function attentionLink(item) {
- if (item.initiative_id && item.action_id) {
- return `/initiatives/${item.initiative_id}`
+ if (item.action_id) {
+ return actionPath(item.action_id)
}
if (item.initiative_id) {
- return `/initiatives/${item.initiative_id}`
+ return scopedPath('/control/status', { initiativeId: item.initiative_id })
}
return null
}
diff --git a/frontend/src/widgets/BlockedActionsWidget.jsx b/frontend/src/widgets/BlockedActionsWidget.jsx
index 06da749..c0627cf 100644
--- a/frontend/src/widgets/BlockedActionsWidget.jsx
+++ b/frontend/src/widgets/BlockedActionsWidget.jsx
@@ -5,6 +5,7 @@ import { ActionList } from '../components/ActionList.jsx'
import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
+import { actionPath } from '../utils/routes.js'
import { WidgetCard } from '../components/WidgetCard.jsx'
export function BlockedActionsWidget() {
@@ -46,13 +47,13 @@ export function BlockedActionsWidget() {
`/initiatives/${a.initiative_id}`}
+ getManageLink={(a) => actionPath(a.id)}
empty={}
/>
)}
{!loading && !error && actions.length > 0 && (
-
+
In „Meine Maßnahmen“ bearbeiten
diff --git a/frontend/src/widgets/InitiativePortfolioWidget.jsx b/frontend/src/widgets/InitiativePortfolioWidget.jsx
index 8f6ad6d..b323aa1 100644
--- a/frontend/src/widgets/InitiativePortfolioWidget.jsx
+++ b/frontend/src/widgets/InitiativePortfolioWidget.jsx
@@ -6,6 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
+import { scopedPath } from '../utils/routes.js'
import { WidgetCard } from '../components/WidgetCard.jsx'
export function InitiativePortfolioWidget() {
@@ -51,7 +52,7 @@ export function InitiativePortfolioWidget() {
{initiatives.map((item) => (
{item.title}
diff --git a/frontend/src/widgets/InitiativesWidget.jsx b/frontend/src/widgets/InitiativesWidget.jsx
index 68e4925..c9a5396 100644
--- a/frontend/src/widgets/InitiativesWidget.jsx
+++ b/frontend/src/widgets/InitiativesWidget.jsx
@@ -6,6 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
+import { scopedPath } from '../utils/routes.js'
import { WidgetCard } from '../components/WidgetCard.jsx'
export function InitiativesWidget() {
@@ -48,7 +49,7 @@ export function InitiativesWidget() {
{initiatives.map((item) => (
-
-
+
{item.title}
diff --git a/frontend/src/widgets/MyOpenActionsWidget.jsx b/frontend/src/widgets/MyOpenActionsWidget.jsx
index 3270ebc..de2d9c5 100644
--- a/frontend/src/widgets/MyOpenActionsWidget.jsx
+++ b/frontend/src/widgets/MyOpenActionsWidget.jsx
@@ -5,6 +5,7 @@ import { ActionList } from '../components/ActionList.jsx'
import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
+import { actionPath } from '../utils/routes.js'
import { WidgetCard } from '../components/WidgetCard.jsx'
export function MyOpenActionsWidget() {
@@ -40,7 +41,7 @@ export function MyOpenActionsWidget() {
}
footer={
actions.length > 0 ? (
-
+
Alle meine Maßnahmen →
) : null
@@ -52,7 +53,7 @@ export function MyOpenActionsWidget() {
`/initiatives/${a.initiative_id}`}
+ getManageLink={(a) => actionPath(a.id)}
empty={
}
diff --git a/frontend/src/widgets/NextActionWidget.jsx b/frontend/src/widgets/NextActionWidget.jsx
index b548962..e38a479 100644
--- a/frontend/src/widgets/NextActionWidget.jsx
+++ b/frontend/src/widgets/NextActionWidget.jsx
@@ -5,7 +5,7 @@ import { NEXT_ACTION_KIND_LABELS } from '../constants/operating.js'
import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.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 }) {
if (items.length === 0) {
@@ -33,16 +33,16 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
)}
- {item.action_id && initiativeId && (
-
+ {item.action_id && (
+
Arbeitspaket
)}
{showInitiativeLink && item.initiative_id && !initiativeId && (
-
+
Zum Vorhaben
)}
diff --git a/frontend/src/widgets/WorkspaceTodayWidget.jsx b/frontend/src/widgets/WorkspaceTodayWidget.jsx
index 4fca643..3835056 100644
--- a/frontend/src/widgets/WorkspaceTodayWidget.jsx
+++ b/frontend/src/widgets/WorkspaceTodayWidget.jsx
@@ -6,7 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
-import { WidgetCard } from '../components/WidgetCard.jsx'
+import { actionPath, scopedPath } from '../utils/routes.js'
function formatDue(iso) {
if (!iso) return null
@@ -55,7 +55,7 @@ export function WorkspaceTodayWidget() {
}
footer={
actions.length > 0 ? (
-
+
Alle meine Maßnahmen →
) : null
@@ -85,7 +85,7 @@ export function WorkspaceTodayWidget() {