AP1.9a: Arbeitsmodi-Shell mit Scope, Objekt-Routen und Legacy-Redirects.
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Failing after 1m46s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Failing after 1m46s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Ersetzt die Vorhaben-Tab-Navigation durch Cockpit/Ausführen/Planen/Kontrolle/Team, Deep Links für APs/Projekte/Gates und Programm-Scope im Header. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
33831a8c2c
commit
e42edd86fa
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.15.1-ap1.5e"
|
APP_VERSION = "0.16.0-ap1.9a"
|
||||||
DB_SCHEMA_VERSION = "014"
|
DB_SCHEMA_VERSION = "014"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
146
docs/architecture/ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md
Normal file
146
docs/architecture/ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
# ADP — AP1.9 PM Work Modes & Frontend IA Reset v0.1
|
||||||
|
|
||||||
|
**Status:** vorgeschlagen (PO-Freigabe ausstehend)
|
||||||
|
**Stand:** 2026-07-08
|
||||||
|
**Autor:** Product/Architektur (Cursor-Session, PM-Feedback)
|
||||||
|
**UI-Konzept:** `docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md`
|
||||||
|
**Auslöser:** Frontend strukturiert nach Objekten (Vorhaben-Tabs), nicht nach Programmmanager-Arbeitslogik
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Nach AP1.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`
|
||||||
|
|
@ -52,7 +52,24 @@ Siehe `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`.
|
||||||
|
|
||||||
Siehe `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`.
|
Siehe `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`.
|
||||||
|
|
||||||
**Reihenfolge:** AP1.5c → AP1.6 → AP1.5d
|
**Reihenfolge:** AP1.5c → AP1.6 → **AP1.9 (PM Work Modes IA)** → AP1.5d
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.3 PO-Entscheidung 2026-07-08 — PM Work Modes (Frontend IA)
|
||||||
|
|
||||||
|
**Auslöser:** Programmmanager-Feedback — Vorhaben-Unterseiten decken Arbeitslogik nicht ab; Querschnitt fehlt.
|
||||||
|
|
||||||
|
| Entscheidung | |
|
||||||
|
|--------------|--|
|
||||||
|
| App-Navigation = **Arbeitsmodi** (Cockpit, Ausführen, Planen, Kontrolle, Team) | vorgeschlagen |
|
||||||
|
| Scope = Filter (Portfolio / Vorhaben / Projekt), nicht Navigationstiefe | vorgeschlagen |
|
||||||
|
| Initiative-Tabs (`InitiativeSubNav`) abschaffen | vorgeschlagen, mit Redirects |
|
||||||
|
| Kein weiteres UI-Patching auf Ausführungs-Tab | Scope Lock ab AP1.9a |
|
||||||
|
|
||||||
|
Siehe `Kairo_PM_Frontend_UI_Concept_v0.1.md`, `ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md`.
|
||||||
|
|
||||||
|
**Reihenfolge Code:** AP1.9a (Shell) → 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 G Ist-Hierarchie AP1.5 ✓
|
||||||
Phase G2 Rekursive Container AP1.5c–5d ✓ 5c / → 5d nach AP1.6
|
Phase G2 Rekursive Container AP1.5c–5d ✓ 5c / → 5d nach AP1.6
|
||||||
Phase H Plan/Ist-Verknüpfung AP1.6 ✓
|
Phase H Plan/Ist-Verknüpfung AP1.6 ✓
|
||||||
Phase H2 Rekursive Tasks AP1.5d ← **NÄCHSTES CODE**
|
Phase H2 PM Work Modes IA AP1.9 ← **NÄCHSTES CODE** (PO 2026-07-08)
|
||||||
Phase I Gate-Graph AP1.4d–4e nach AP1.6
|
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 J Portfolio & Kontext AP1.8 geplant
|
||||||
Phase K Agent Interface AP1.7 geplant (unverändert)
|
Phase K Agent Interface AP1.7 geplant (unverändert)
|
||||||
Phase L Validation AP0.10d (laufend PO) parallel
|
Phase L Validation AP0.10d (laufend PO) parallel
|
||||||
|
|
@ -94,6 +112,8 @@ Phase L Validation AP0.10d (laufend PO) parallel
|
||||||
| AP1.4b | 0.13.1-ap1.4b | Gate-Checkliste Backend + Detail-Route |
|
| AP1.4b | 0.13.1-ap1.4b | Gate-Checkliste Backend + Detail-Route |
|
||||||
| AP1.5c | 0.14.1-ap1.5c | Rekursive Projects |
|
| AP1.5c | 0.14.1-ap1.5c | Rekursive Projects |
|
||||||
| AP1.6 | 0.15.0-ap1.6 | Plan/Ist, Journey, Gate-Beiträge |
|
| AP1.6 | 0.15.0-ap1.6 | Plan/Ist, Journey, Gate-Beiträge |
|
||||||
|
| AP1.5e | 0.15.1-ap1.5e | Projekt-Detail, Lifecycle-Chip (Übergang — ersetzt durch AP1.9) |
|
||||||
|
| DOC PM UI | 2026-07-08 | PM Frontend UI Concept v0.1, ADP AP1.9 |
|
||||||
| DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset |
|
| DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset |
|
||||||
| DOC Container | 2026-07-06 | ADP Recursive Containers & Execution Granularity |
|
| DOC Container | 2026-07-06 | ADP Recursive Containers & Execution Granularity |
|
||||||
|
|
||||||
|
|
@ -156,6 +176,26 @@ Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### AP1.9 — PM Work Modes & Frontend IA Reset ← **NÄCHSTES CODE**
|
||||||
|
|
||||||
|
**Ziel:** App-weite Arbeitsmodi statt Vorhaben-Tab-Omnibus; Querschnitt für PM-Use-Cases.
|
||||||
|
|
||||||
|
| Teil | Scope |
|
||||||
|
|------|--------|
|
||||||
|
| **9a** | App-Shell, Routen `/cockpit`, `/work`, `/plan`, `/control`, `/team`, Scope-Provider, Redirects |
|
||||||
|
| **9b** | Cockpit + Ausführen/Heute (AP-Querschnitt) |
|
||||||
|
| **9c** | Kontrolle: Snapshot, Journey, Plan-Ist-Aggregate |
|
||||||
|
| **9d** | Planen: Struktur, Gates, Inbox (Migration von Initiative-Tabs) |
|
||||||
|
| **9e** | Team: Workload, Zuweisungsübersicht |
|
||||||
|
|
||||||
|
**ADP:** `ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md`
|
||||||
|
**UI Concept:** `Kairo_PM_Frontend_UI_Concept_v0.1.md`
|
||||||
|
**Version-Ziel:** `0.16.0-ap1.9a` (Shell), dann inkrementell
|
||||||
|
|
||||||
|
**Explizit nicht:** Weitere Features unter `InitiativeExecutionPage` / `InitiativeSubNav`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### AP1.5d — Rekursive Tasks & Roll-up
|
### AP1.5d — Rekursive Tasks & Roll-up
|
||||||
|
|
||||||
**Ziel:** Task-Baum (`parent_task_id`); Mensch UI max. ~3 Ebenen; Agent tiefer.
|
**Ziel:** Task-Baum (`parent_task_id`); Mensch UI max. ~3 Ebenen; Agent tiefer.
|
||||||
|
|
@ -169,8 +209,8 @@ Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline.
|
||||||
| **Kein** nested Action (Default) |
|
| **Kein** nested Action (Default) |
|
||||||
|
|
||||||
**ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`
|
**ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`
|
||||||
**Nach AP1.6**
|
**Nach AP1.9b** (Tasks füllen `/work`, IA bleibt)
|
||||||
**Version:** `0.15.1-ap1.5d`
|
**Version:** `0.16.x-ap1.5d`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
444
docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md
Normal file
444
docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md
Normal file
|
|
@ -0,0 +1,444 @@
|
||||||
|
# Kairo — PM Frontend UI Concept v0.1
|
||||||
|
|
||||||
|
**Status:** PO-Arbeitsentwurf (Entscheidung ausstehend)
|
||||||
|
**Stand:** 2026-07-08
|
||||||
|
**Auslöser:** Programmmanager-Feedback — Objekt-Hierarchie ohne Arbeitslogik; Vorhaben-Unterseiten als Sackgasse
|
||||||
|
**Bezug:** Vision v0.2 §7, Canonical OM v0.2 §8, `ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md`
|
||||||
|
**Ersetzt als UI-Leitbild:** Ad-hoc-Layout unter `InitiativeExecutionPage` / Tab-Omnibus
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem in einem Satz
|
||||||
|
|
||||||
|
Kairo hat **Objekte** (Vorhaben, Projekt, Arbeitspaket) und **Tabs**, aber keine **Arbeitsmodi** — deshalb findet ein Programmmanager weder Querschnitt noch Planung noch Kontrolle, und KI/Agenten haben keinen kanonischen Einstieg.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Leitprinzipien (verbindlich für UI)
|
||||||
|
|
||||||
|
| # | Prinzip | Konsequenz |
|
||||||
|
|---|---------|------------|
|
||||||
|
| P1 | **Modi für Überblick, Objekte direkt** | Modi beantworten Querschnittsfragen; **bekannte** APs/Projekte/Gates sind per **Deep Link** sofort erreichbar — ohne vorher Modus wählen. |
|
||||||
|
| P2 | **Scope ist Filter, kein Gefängnis** | Portfolio, ein Vorhaben oder ein Projekt sind **Filter** auf dieselben Modi — nicht separate Welten. |
|
||||||
|
| P3 | **Querschnitt auf Top-Level** | „Alle meine Tasks“, „Was klemmt?“, „Nächster Schritt?“ leben **global**, nicht unter `/initiatives/:id/...`. |
|
||||||
|
| P4 | **Übersicht entscheidet, Detail pflegt** | Modi zeigen Steuerungsantworten; Anlage/Bearbeitung nur auf **Objekt-Detail-Routen** oder Modal — nie als dauerhafte Inline-CRUD-Wand. |
|
||||||
|
| P5 | **Plan ≠ Ausführung ≠ Kontrolle** | Drei kognitive Tätigkeiten — drei Flächen. Nicht in einem Tab stapeln. |
|
||||||
|
| P6 | **Methodenneutral** | Gates optional; flache Vorhaben ohne Roadmap müssen in jedem Modus sinnvoll leer oder reduziert sein (`generic_operating`). |
|
||||||
|
| P7 | **Gleiche Logik für Mensch und Agent** | Jeder Modus mappt auf lesbare/schriftbare API-Oberflächen (`steering/`, Workspace Data Layer). |
|
||||||
|
| P8 | **Stabile Objekt-URLs** | `/actions/:id`, `/projects/:id` usw. sind bookmarkbar; Scope und Modus werden aus dem Objekt **abgeleitet**, nicht manuell vorher gesetzt. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Navigationsmodell — drei Ebenen
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ EBENE 1 — MODUS (Sidebar): Querschnitt & Arbeitsintention │
|
||||||
|
│ Cockpit │ Ausführen │ Planen │ Kontrolle │ Team │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
+
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ EBENE 2 — SCOPE (Header): Filter auf Modus-Inhalte │
|
||||||
|
│ Portfolio > Vorhaben X > [ optional Projekt Y ] │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
+
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ EBENE 3 — OBJEKTFokus (Route): Konkretes Ding bearbeiten │
|
||||||
|
│ /actions/:id /projects/:id /gates/:id … │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modus allein reicht nicht** — du springst oft **direkt** auf ein Arbeitspaket oder Projekt (Link, Lesezeichen, Next-Action-Klick, Agent).
|
||||||
|
**Modus allein reicht auch nicht ohne Querschnitt** — „Was ist heute dran über alles?“ braucht `/work/heute` ohne vorher ein Vorhaben zu öffnen.
|
||||||
|
|
||||||
|
| Ebene | Wann | Beispiel |
|
||||||
|
|-------|------|----------|
|
||||||
|
| **Modus** | Ich weiß noch nicht *welches* Objekt, aber *welche Aufgabe* | „Heute“, „Was klemmt?“, „Planen“ |
|
||||||
|
| **Scope** | Ich will alles in einem Vorhaben/Projekt sehen | Filter auf Queue, Kontrolle, Plan |
|
||||||
|
| **Objekt** | Ich kenne die ID / den Namen | AP „API-Auth“, Projekt „Phase 2“ |
|
||||||
|
|
||||||
|
**Regel:** Objekt-Route hat **Vorrang**. Beim Öffnen von `/actions/:id` setzt die App automatisch Scope (Initiative, ggf. Project) und markiert den passenden Modus in der Sidebar — du musst nicht „erst Ausführen klicken“.
|
||||||
|
|
||||||
|
Wechsel des Modus **behält** Scope, solange sinnvoll. Wechsel des Objekts **aktualisiert** Scope aus den Objektdaten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Die fünf Arbeitsmodi (Product Language)
|
||||||
|
|
||||||
|
### 4.1 Cockpit — *Wo stehe ich im Portfolio?*
|
||||||
|
|
||||||
|
**Leitfrage:** Welches Vorhaben braucht Aufmerksamkeit — und was ist programmweit als Nächstes dran?
|
||||||
|
|
||||||
|
| Inhalt | Quelle (heute / geplant) |
|
||||||
|
|--------|---------------------------|
|
||||||
|
| Portfolio-Kacheln mit Lifecycle, Signal, Prio | `InitiativePortfolioWidget`, AP1.8 Prio |
|
||||||
|
| Attention / Showstopper quer | `AttentionWidget`, Snapshot-Signale |
|
||||||
|
| Next Action (Portfolio-Scope) | `NextActionWidget` scope=portfolio |
|
||||||
|
| Kurz-KPIs (offen, blockiert, Gates at risk) | `WorkspaceSummaryWidget` |
|
||||||
|
|
||||||
|
**Nicht hier:** Gate-CRUD, Projektbaum, Sprint-Planung im Detail.
|
||||||
|
|
||||||
|
**Route:** `/cockpit` (Alias `/workspace` → Redirect)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Ausführen — *Was mache ich jetzt konkret?*
|
||||||
|
|
||||||
|
**Leitfrage:** Welche committete Arbeit (Arbeitspaket / Task) ist dran — **über alle Vorhaben und Projekte**?
|
||||||
|
|
||||||
|
| Inhalt | Beschreibung |
|
||||||
|
|--------|--------------|
|
||||||
|
| **Heute** | Mir zugewiesen, überfällig zuerst, Next-Action-Ranking |
|
||||||
|
| **Meine Queue** | Alle offenen Tasks/APs (filterbar) |
|
||||||
|
| **Nach Vorhaben / Projekt** | Gruppierung optional — kein Navigationszwang |
|
||||||
|
| Detail-Drill | Link zu AP-/Task-Detail |
|
||||||
|
|
||||||
|
**Nicht hier:** Programmstruktur anlegen, Gates definieren, Portfolio-Priorität setzen.
|
||||||
|
|
||||||
|
**Route:** `/work` (+ Unterrouten `/work/today`, `/work/mine`, `/work/queue`)
|
||||||
|
|
||||||
|
**Abhängigkeit:** Task-Querschnitt braucht AP1.5d; MVP von 4.2 startet mit **Arbeitspaketen**, Tasks folgen ohne IA-Wechsel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Planen — *Wie soll das Programm aufgebaut sein?*
|
||||||
|
|
||||||
|
**Leitfrage:** Struktur, Gates, Eingang — was ist geplant, noch nicht committet?
|
||||||
|
|
||||||
|
| Zone | Inhalt |
|
||||||
|
|------|--------|
|
||||||
|
| **Struktur** | Project-Baum (Container), optional flach |
|
||||||
|
| **Zielzustände** | Gates / RoadmapItems, Kriterien, Abhängigkeiten |
|
||||||
|
| **Eingang** | Backlog, Triage, Convert-to-Action |
|
||||||
|
|
||||||
|
Planen ist **scope-pflichtig auf mindestens ein Vorhaben** (Struktur hängt an Initiative). Portfolio-Scope zeigt **Planungs-Inbox**: „Vorhaben ohne Struktur“, „Gates ohne Kriterien“ — Einstieg, nicht Vollplanung.
|
||||||
|
|
||||||
|
**Nicht hier:** Tagesausführung, Blocker-Status aller APs, Team-Workload.
|
||||||
|
|
||||||
|
**Route:** `/plan` (+ `/plan/structure`, `/plan/gates`, `/plan/inbox`)
|
||||||
|
|
||||||
|
**Migration:** Heutige Tabs `plan`, `inbox` und Projektbaum aus `execution` **wandern hierher**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 Kontrolle — *Erreichen wir die Ziele? Was blockiert den Fortschritt?*
|
||||||
|
|
||||||
|
**Leitfrage:** Plan vs. Ist, Zielerreichung, Risiken, kritischer Pfad, Showstopper.
|
||||||
|
|
||||||
|
| Zone | Inhalt |
|
||||||
|
|------|--------|
|
||||||
|
| **Programm-Status** | Lifecycle, Snapshot, Signale (pro Scope) |
|
||||||
|
| **Plan vs. Ist** | Gate-Beiträge, offene Kriterien, Journey |
|
||||||
|
| **Roadblocker** | Blockierte APs, offene Blocker, Gates at risk |
|
||||||
|
| **Kritischer Pfad** | Gate-Graph + Ist-Blocker (AP1.4d+, progressive) |
|
||||||
|
|
||||||
|
**Nicht hier:** Neue APs anlegen, Backlog triagieren.
|
||||||
|
|
||||||
|
**Route:** `/control` (+ `/control/status`, `/control/plan-ist`, `/control/journey`)
|
||||||
|
|
||||||
|
**Migration:** Teile von Initiative-Übersicht + Journey + Gate-Detail-Contributions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.5 Team — *Wer arbeitet an was?*
|
||||||
|
|
||||||
|
**Leitfrage:** Zuweisungen, Kapazität, Delegation.
|
||||||
|
|
||||||
|
| Inhalt | Quelle |
|
||||||
|
|--------|--------|
|
||||||
|
| Actor-Workload | `GET /api/workspace/actors/workload` |
|
||||||
|
| Offene Zuweisungen ohne Fortschritt | Snapshot / Assignments |
|
||||||
|
| Delegation (später) | Bulk-Zuweisung, Umhängen |
|
||||||
|
|
||||||
|
**Route:** `/team`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Vorhaben-Katalog — nicht der Arbeitsort
|
||||||
|
|
||||||
|
`/initiatives` wird **Katalog & Scope-Auswahl**, kein Omnibus:
|
||||||
|
|
||||||
|
- Liste / Suche aller Vorhaben
|
||||||
|
- Anlegen neues Vorhaben
|
||||||
|
- Klick setzt **Scope-Filter** und springt in sinnvollen Default-Modus (Cockpit scoped oder Kontrolle scoped) — **nicht** in verschachtelte Tab-Welt
|
||||||
|
|
||||||
|
**Abschaffen (Ziel):** Vorhaben als „App-in-App“ mit fünf Tabs, in denen alles passiert.
|
||||||
|
|
||||||
|
**Legacy-Redirects:** Alte URLs `/initiatives/:id/plan` → `/plan/gates?initiative=:id` etc. (siehe ADP AP1.9).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Direkte Objektnavigation (Deep Links)
|
||||||
|
|
||||||
|
### 6.1 Stabile Objekt-Routen (first-class)
|
||||||
|
|
||||||
|
Diese URLs funktionieren **ohne** vorherigen Modus- oder Scope-Schritt. Sie sind sharebar, bookmarkbar, Agent-Ziele.
|
||||||
|
|
||||||
|
| Objekt | Route (Ziel) | Modus (Sidebar) | Scope (auto) |
|
||||||
|
|--------|--------------|-----------------|--------------|
|
||||||
|
| **Arbeitspaket** | `/actions/:actionId` | Ausführen | Initiative + Project aus AP |
|
||||||
|
| **Task** (AP1.5d) | `/tasks/:taskId` | Ausführen | über Action → Initiative |
|
||||||
|
| **Projekt** | `/projects/:projectId` | Planen | Initiative + Project |
|
||||||
|
| **Gate** | `/gates/:gateId` | Planen (Detail) / Kontrolle (Ist) | Initiative |
|
||||||
|
| **Vorhaben** | `/initiatives/:id` | Kontrolle (Status-Home scoped) | Initiative |
|
||||||
|
| **Backlog-Item** | `/backlog/:id` | Planen → Inbox | Initiative |
|
||||||
|
|
||||||
|
**Bestehende Route** `/initiatives/:id/actions/:actionId` → Redirect auf `/actions/:actionId` (kanonische URL).
|
||||||
|
|
||||||
|
API-IDs sind global im Tenant — Pfad **ohne** `/initiatives/:id/...`-Präfix ist gewollt (kürzer, agentenfreundlich).
|
||||||
|
|
||||||
|
### 6.2 Verhalten beim Direktsprung
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nutzer öffnet /actions/abc-123
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
GET /api/actions/abc-123
|
||||||
|
│
|
||||||
|
├── Scope: initiative_id, project_id gesetzt
|
||||||
|
├── Sidebar: „Ausführen“ aktiv
|
||||||
|
├── Breadcrumb: Ausführen › {Vorhaben} › {AP-Titel}
|
||||||
|
└── Inhalt: AP-Detail (wie heute ActionDetailPage)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zurück-Navigation:** „Zur Queue“ → `/work/mine?initiative=` (Scope bleibt), nicht in Tab-Hölle.
|
||||||
|
|
||||||
|
### 6.3 Schnellzugriff (App-Chrome)
|
||||||
|
|
||||||
|
Neben Modus-Nav braucht es **Sprung-Hilfen** — unabhängig vom Modus:
|
||||||
|
|
||||||
|
| Element | Funktion | Phase |
|
||||||
|
|---------|----------|-------|
|
||||||
|
| **Springen zu…** (Suche) | Titel/ID: AP, Projekt, Gate, Vorhaben | AP1.9a minimal (Liste), 9f Volltext |
|
||||||
|
| **Zuletzt geöffnet** | 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.*
|
||||||
|
|
@ -1,24 +1,35 @@
|
||||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { SessionProvider, useSession } from './context/SessionContext.jsx'
|
import { SessionProvider, useSession } from './context/SessionContext.jsx'
|
||||||
|
import { ProgramScopeProvider } from './context/ProgramScopeContext.jsx'
|
||||||
import { AppLayout } from './layout/AppLayout.jsx'
|
import { AppLayout } from './layout/AppLayout.jsx'
|
||||||
import { InitiativeLayout } from './layout/InitiativeLayout.jsx'
|
|
||||||
import { AuthPanel } from './pages/AuthPanel.jsx'
|
import { AuthPanel } from './pages/AuthPanel.jsx'
|
||||||
import { ActionDetailPage } from './pages/ActionDetailPage.jsx'
|
import { ActionDetailPage } from './pages/ActionDetailPage.jsx'
|
||||||
import { InitiativeOverviewPage } from './pages/initiative/InitiativeOverviewPage.jsx'
|
import { ProjectObjectPage } from './pages/objects/ProjectObjectPage.jsx'
|
||||||
import { InitiativePlanPage } from './pages/initiative/InitiativePlanPage.jsx'
|
import { GateObjectPage } from './pages/objects/GateObjectPage.jsx'
|
||||||
import { InitiativeExecutionPage } from './pages/initiative/InitiativeExecutionPage.jsx'
|
import {
|
||||||
import { ProjectDetailPage } from './pages/initiative/ProjectDetailPage.jsx'
|
VIEWS,
|
||||||
import { InitiativeInboxPage } from './pages/initiative/InitiativeInboxPage.jsx'
|
WorkLayout,
|
||||||
import { InitiativeJourneyPage } from './pages/initiative/InitiativeJourneyPage.jsx'
|
WorkIndexRedirect,
|
||||||
import { RoadmapItemDetailPage } from './pages/initiative/RoadmapItemDetailPage.jsx'
|
PlanLayout,
|
||||||
import { VIEWS } from './registry/viewRegistry.js'
|
PlanIndexRedirect,
|
||||||
|
ControlLayout,
|
||||||
|
ControlIndexRedirect,
|
||||||
|
MODE_ROUTE_COMPONENTS,
|
||||||
|
} from './registry/viewRegistry.js'
|
||||||
|
import {
|
||||||
|
WorkspaceRedirect,
|
||||||
|
MyActionsRedirect,
|
||||||
|
InitiativeRootRedirect,
|
||||||
|
InitiativePlanRedirect,
|
||||||
|
InitiativeExecutionRedirect,
|
||||||
|
InitiativeInboxRedirect,
|
||||||
|
InitiativeJourneyRedirect,
|
||||||
|
InitiativeGateRedirect,
|
||||||
|
InitiativeProjectRedirect,
|
||||||
|
InitiativeActionRedirect,
|
||||||
|
} from './pages/LegacyRedirects.jsx'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
|
|
||||||
function InitiativeLegacyDetailRedirect() {
|
|
||||||
const { id } = useParams()
|
|
||||||
return <Navigate to={`/initiatives/${id}`} replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { isAuthenticated, loading } = useSession()
|
const { isAuthenticated, loading } = useSession()
|
||||||
|
|
||||||
|
|
@ -30,30 +41,69 @@ function AppRoutes() {
|
||||||
return <AuthPanel />
|
return <AuthPanel />
|
||||||
}
|
}
|
||||||
|
|
||||||
const topLevelViews = VIEWS.filter((v) => v.key !== 'kairo.initiative_detail')
|
const topLevelViews = VIEWS.filter(
|
||||||
|
(v) =>
|
||||||
|
![
|
||||||
|
'kairo.workspace',
|
||||||
|
'kairo.my_actions',
|
||||||
|
'kairo.work',
|
||||||
|
'kairo.plan',
|
||||||
|
'kairo.control',
|
||||||
|
].includes(v.key),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ProgramScopeProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
{topLevelViews.map((view) => (
|
{topLevelViews.map((view) => (
|
||||||
<Route key={view.key} path={view.path} element={<view.component />} />
|
<Route key={view.key} path={view.path} element={<view.component />} />
|
||||||
))}
|
))}
|
||||||
<Route path="/initiatives/:id" element={<InitiativeLayout />}>
|
|
||||||
<Route index element={<InitiativeOverviewPage />} />
|
<Route path="/work" element={<WorkLayout />}>
|
||||||
<Route path="plan" element={<InitiativePlanPage />} />
|
<Route index element={<WorkIndexRedirect />} />
|
||||||
<Route path="plan/items/:itemId" element={<RoadmapItemDetailPage />} />
|
<Route path="today" element={<MODE_ROUTE_COMPONENTS.workToday />} />
|
||||||
<Route path="execution" element={<InitiativeExecutionPage />} />
|
<Route path="mine" element={<MODE_ROUTE_COMPONENTS.workMine />} />
|
||||||
<Route path="execution/projects/:projectId" element={<ProjectDetailPage />} />
|
|
||||||
<Route path="inbox" element={<InitiativeInboxPage />} />
|
|
||||||
<Route path="journey" element={<InitiativeJourneyPage />} />
|
|
||||||
<Route path="actions/:actionId" element={<ActionDetailPage nested />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/initiatives/:id/detail" element={<InitiativeLegacyDetailRedirect />} />
|
|
||||||
|
<Route path="/plan" element={<PlanLayout />}>
|
||||||
|
<Route index element={<PlanIndexRedirect />} />
|
||||||
|
<Route path="structure" element={<MODE_ROUTE_COMPONENTS.planStructure />} />
|
||||||
|
<Route path="gates" element={<MODE_ROUTE_COMPONENTS.planGates />} />
|
||||||
|
<Route path="inbox" element={<MODE_ROUTE_COMPONENTS.planInbox />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/control" element={<ControlLayout />}>
|
||||||
|
<Route index element={<ControlIndexRedirect />} />
|
||||||
|
<Route path="status" element={<MODE_ROUTE_COMPONENTS.controlStatus />} />
|
||||||
|
<Route path="journey" element={<MODE_ROUTE_COMPONENTS.controlJourney />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/projects/:projectId" element={<ProjectObjectPage />} />
|
||||||
|
<Route path="/gates/:gateId" element={<GateObjectPage />} />
|
||||||
<Route path="/actions/:actionId" element={<ActionDetailPage />} />
|
<Route path="/actions/:actionId" element={<ActionDetailPage />} />
|
||||||
<Route path="/" element={<Navigate to="/workspace" replace />} />
|
|
||||||
<Route path="*" element={<Navigate to="/workspace" replace />} />
|
<Route path="/workspace" element={<WorkspaceRedirect />} />
|
||||||
|
<Route path="/my-actions" element={<MyActionsRedirect />} />
|
||||||
|
|
||||||
|
<Route path="/initiatives/:id" element={<InitiativeRootRedirect />} />
|
||||||
|
<Route path="/initiatives/:id/plan" element={<InitiativePlanRedirect />} />
|
||||||
|
<Route path="/initiatives/:id/plan/items/:itemId" element={<InitiativeGateRedirect />} />
|
||||||
|
<Route path="/initiatives/:id/execution" element={<InitiativeExecutionRedirect />} />
|
||||||
|
<Route
|
||||||
|
path="/initiatives/:id/execution/projects/:projectId"
|
||||||
|
element={<InitiativeProjectRedirect />}
|
||||||
|
/>
|
||||||
|
<Route path="/initiatives/:id/inbox" element={<InitiativeInboxRedirect />} />
|
||||||
|
<Route path="/initiatives/:id/journey" element={<InitiativeJourneyRedirect />} />
|
||||||
|
<Route path="/initiatives/:id/actions/:actionId" element={<InitiativeActionRedirect />} />
|
||||||
|
<Route path="/initiatives/:id/detail" element={<InitiativeRootRedirect />} />
|
||||||
|
|
||||||
|
<Route path="/" element={<Navigate to="/cockpit" replace />} />
|
||||||
|
<Route path="*" element={<Navigate to="/cockpit" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</ProgramScopeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@
|
||||||
@import './styles/shell.css';
|
@import './styles/shell.css';
|
||||||
@import './styles/components.css';
|
@import './styles/components.css';
|
||||||
@import './styles/pages.css';
|
@import './styles/pages.css';
|
||||||
|
@import './styles/program-chrome.css';
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,30 @@
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
const AREAS = [
|
const AREAS = [
|
||||||
{
|
{
|
||||||
to: 'plan',
|
to: '/plan/gates',
|
||||||
title: 'Plan',
|
title: 'Plan',
|
||||||
description: 'Roadmap, Gates und Plan-Ist — methodenneutral pflegen.',
|
description: 'Roadmap, Gates und Plan-Ist — methodenneutral pflegen.',
|
||||||
|
countKey: 'roadmapItems',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: 'execution',
|
to: '/work/today',
|
||||||
title: 'Ausführung',
|
title: 'Ausführung',
|
||||||
description: 'Arbeitspakete, Zuweisungen, Blocker am Objekt.',
|
description: 'Arbeitspakete, Zuweisungen, Blocker am Objekt.',
|
||||||
|
countKey: 'actionsOpen',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: 'inbox',
|
to: '/plan/inbox',
|
||||||
title: 'Eingang',
|
title: 'Eingang',
|
||||||
description: 'Backlog triagieren und in Arbeitspakete überführen.',
|
description: 'Backlog triagieren und in Arbeitspakete überführen.',
|
||||||
|
countKey: 'backlog',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: 'journey',
|
to: '/control/journey',
|
||||||
title: 'Nachvollziehbarkeit',
|
title: 'Nachvollziehbarkeit',
|
||||||
description: 'Entscheidungen, Reviews, Nachweise über die Zeit.',
|
description: 'Entscheidungen, Reviews, Nachweise über die Zeit.',
|
||||||
|
countKey: null,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -30,17 +35,17 @@ export function InitiativeNavCards({ initiativeId, counts = {} }) {
|
||||||
<div className="initiative-nav-cards-grid">
|
<div className="initiative-nav-cards-grid">
|
||||||
{AREAS.map((area) => {
|
{AREAS.map((area) => {
|
||||||
const hint =
|
const hint =
|
||||||
area.to === 'inbox' && counts.backlog
|
area.countKey === 'backlog' && counts.backlog
|
||||||
? `${counts.backlog} Backlog`
|
? `${counts.backlog} Backlog`
|
||||||
: area.to === 'execution' && counts.actionsOpen
|
: area.countKey === 'actionsOpen' && counts.actionsOpen
|
||||||
? `${counts.actionsOpen} offen`
|
? `${counts.actionsOpen} offen`
|
||||||
: area.to === 'plan' && counts.roadmapItems
|
: area.countKey === 'roadmapItems' && counts.roadmapItems
|
||||||
? `${counts.roadmapItems} Plan-Elemente`
|
? `${counts.roadmapItems} Plan-Elemente`
|
||||||
: null
|
: null
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={area.to}
|
key={area.to}
|
||||||
to={`/initiatives/${initiativeId}/${area.to}`}
|
to={scopedPath(area.to, { initiativeId })}
|
||||||
className="initiative-nav-card card"
|
className="initiative-nav-card card"
|
||||||
>
|
>
|
||||||
<h3>{area.title}</h3>
|
<h3>{area.title}</h3>
|
||||||
|
|
|
||||||
16
frontend/src/components/ModeShell.jsx
Normal file
16
frontend/src/components/ModeShell.jsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { MODE_META } from '../utils/routes.js'
|
||||||
|
|
||||||
|
export function ModeShell({ modeKey, actions }) {
|
||||||
|
const meta = MODE_META[modeKey]
|
||||||
|
if (!meta) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="page-header mode-shell">
|
||||||
|
<div>
|
||||||
|
<h1>{meta.title}</h1>
|
||||||
|
<p className="page-lead">{meta.lead}</p>
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="page-header__actions">{actions}</div> : null}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
frontend/src/components/ModeSubNav.jsx
Normal file
26
frontend/src/components/ModeSubNav.jsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ items: { to: string, label: string, end?: boolean }[] }} props
|
||||||
|
*/
|
||||||
|
export function ModeSubNav({ items }) {
|
||||||
|
const { hrefWithScope } = useProgramScope()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="mode-subnav" aria-label="Modus-Unternavigation">
|
||||||
|
{items.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={hrefWithScope(item.to)}
|
||||||
|
end={item.end !== false}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
'mode-subnav__link' + (isActive ? ' mode-subnav__link--active' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
frontend/src/components/ProgramScopeBar.jsx
Normal file
62
frontend/src/components/ProgramScopeBar.jsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||||
|
import { initiativeCatalogPath, scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
|
export function ProgramScopeBar() {
|
||||||
|
const {
|
||||||
|
initiativeId,
|
||||||
|
projectId,
|
||||||
|
initiativeTitle,
|
||||||
|
projectTitle,
|
||||||
|
clearScope,
|
||||||
|
setScope,
|
||||||
|
} = useProgramScope()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="program-scope-bar" aria-label="Programm-Scope">
|
||||||
|
<span className="program-scope-bar__label">Scope</span>
|
||||||
|
<nav className="program-scope-bar__trail">
|
||||||
|
<Link
|
||||||
|
to={scopedPath('/cockpit')}
|
||||||
|
className={
|
||||||
|
'program-scope-bar__crumb' +
|
||||||
|
(!initiativeId ? ' program-scope-bar__crumb--active' : '')
|
||||||
|
}
|
||||||
|
onClick={() => clearScope()}
|
||||||
|
>
|
||||||
|
Portfolio
|
||||||
|
</Link>
|
||||||
|
{initiativeId && (
|
||||||
|
<>
|
||||||
|
<span className="program-scope-bar__sep" aria-hidden="true">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
to={scopedPath('/control/status', { initiativeId })}
|
||||||
|
className={
|
||||||
|
'program-scope-bar__crumb' +
|
||||||
|
(!projectId ? ' program-scope-bar__crumb--active' : '')
|
||||||
|
}
|
||||||
|
onClick={() => setScope({ projectId: '', clearProject: true })}
|
||||||
|
>
|
||||||
|
{initiativeTitle || 'Vorhaben'}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{projectId && (
|
||||||
|
<>
|
||||||
|
<span className="program-scope-bar__sep" aria-hidden="true">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
<span className="program-scope-bar__crumb program-scope-bar__crumb--active">
|
||||||
|
{projectTitle || 'Projekt'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
<Link to={initiativeCatalogPath()} className="program-scope-bar__catalog link-inline">
|
||||||
|
Vorhaben-Katalog
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -33,7 +33,7 @@ function ProjectTreeNodes({
|
||||||
>
|
>
|
||||||
<div className="list-item-main project-tree-item-main">
|
<div className="list-item-main project-tree-item-main">
|
||||||
<Link
|
<Link
|
||||||
to={`/initiatives/${initiativeId}/execution/projects/${project.id}`}
|
to={projectPath(project.id)}
|
||||||
className="project-tree-title-link"
|
className="project-tree-title-link"
|
||||||
>
|
>
|
||||||
<strong>{project.title}</strong>
|
<strong>{project.title}</strong>
|
||||||
|
|
@ -117,7 +117,7 @@ export function ProjectsSection({
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Link
|
<Link
|
||||||
className="btn btn-primary btn-block-mobile"
|
className="btn btn-primary btn-block-mobile"
|
||||||
to={`/initiatives/${initiativeId}/execution/projects/new`}
|
to={`${projectPath('new')}?initiative=${initiativeId}`}
|
||||||
>
|
>
|
||||||
Neues Projekt
|
Neues Projekt
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
179
frontend/src/components/QuickJump.jsx
Normal file
179
frontend/src/components/QuickJump.jsx
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { listInitiatives } from '../api/initiatives.js'
|
||||||
|
import { listWorkspaceOpenActions } from '../api/workspace.js'
|
||||||
|
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||||
|
import { loadRecentObjects } from '../utils/recentObjects.js'
|
||||||
|
import {
|
||||||
|
actionPath,
|
||||||
|
gatePath,
|
||||||
|
initiativeCatalogPath,
|
||||||
|
projectPath,
|
||||||
|
scopedPath,
|
||||||
|
} from '../utils/routes.js'
|
||||||
|
|
||||||
|
const TYPE_LABELS = {
|
||||||
|
action: 'Arbeitspaket',
|
||||||
|
project: 'Projekt',
|
||||||
|
gate: 'Gate',
|
||||||
|
initiative: 'Vorhaben',
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchQuery(text, query) {
|
||||||
|
return (text || '').toLowerCase().includes(query.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickJump() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { applyFromInitiative } = useProgramScope()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [initiatives, setInitiatives] = useState([])
|
||||||
|
const [actions, setActions] = useState([])
|
||||||
|
const [recent, setRecent] = useState([])
|
||||||
|
const rootRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
Promise.all([
|
||||||
|
listInitiatives().catch(() => []),
|
||||||
|
listWorkspaceOpenActions().catch(() => []),
|
||||||
|
]).then(([initData, actionData]) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setInitiatives(initData)
|
||||||
|
setActions(actionData)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRecent(loadRecentObjects())
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onDocClick(e) {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(e.target)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDocClick)
|
||||||
|
return () => document.removeEventListener('mousedown', onDocClick)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
const q = query.trim()
|
||||||
|
if (!q) return []
|
||||||
|
|
||||||
|
const hits = []
|
||||||
|
|
||||||
|
for (const item of initiatives) {
|
||||||
|
if (matchQuery(item.title, q)) {
|
||||||
|
hits.push({
|
||||||
|
key: `initiative-${item.id}`,
|
||||||
|
label: item.title,
|
||||||
|
meta: 'Vorhaben',
|
||||||
|
href: scopedPath('/control/status', { initiativeId: item.id }),
|
||||||
|
onSelect: () => applyFromInitiative(item.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const action of actions) {
|
||||||
|
if (matchQuery(action.title, q)) {
|
||||||
|
hits.push({
|
||||||
|
key: `action-${action.id}`,
|
||||||
|
label: action.title,
|
||||||
|
meta: 'Arbeitspaket',
|
||||||
|
href: actionPath(action.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits.slice(0, 8)
|
||||||
|
}, [query, initiatives, actions, applyFromInitiative])
|
||||||
|
|
||||||
|
const go = useCallback(
|
||||||
|
(href, onSelect) => {
|
||||||
|
if (onSelect) onSelect()
|
||||||
|
navigate(href)
|
||||||
|
setOpen(false)
|
||||||
|
setQuery('')
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="quick-jump" ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="quick-jump__trigger"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
>
|
||||||
|
Springen zu…
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="quick-jump__panel">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="quick-jump__input"
|
||||||
|
placeholder="Vorhaben, Arbeitspaket …"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<ul className="quick-jump__list" role="listbox">
|
||||||
|
{query.trim() === '' &&
|
||||||
|
recent.map((item) => (
|
||||||
|
<li key={`recent-${item.type}-${item.id}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="quick-jump__item"
|
||||||
|
onClick={() => go(item.href)}
|
||||||
|
>
|
||||||
|
<span className="quick-jump__item-label">{item.title}</span>
|
||||||
|
<span className="quick-jump__item-meta">
|
||||||
|
{TYPE_LABELS[item.type] || item.type} · zuletzt
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{query.trim() !== '' &&
|
||||||
|
results.map((item) => (
|
||||||
|
<li key={item.key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="quick-jump__item"
|
||||||
|
onClick={() => go(item.href, item.onSelect)}
|
||||||
|
>
|
||||||
|
<span className="quick-jump__item-label">{item.label}</span>
|
||||||
|
<span className="quick-jump__item-meta">{item.meta}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{query.trim() !== '' && results.length === 0 && (
|
||||||
|
<li className="quick-jump__empty">Keine Treffer</li>
|
||||||
|
)}
|
||||||
|
{query.trim() === '' && recent.length === 0 && (
|
||||||
|
<li className="quick-jump__empty">Tippe zum Suchen — oder öffne ein Objekt.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<div className="quick-jump__footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-inline"
|
||||||
|
onClick={() => go(initiativeCatalogPath())}
|
||||||
|
>
|
||||||
|
Vorhaben-Katalog
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { actionPath, projectPath, gatePath }
|
||||||
33
frontend/src/components/RecentlyOpened.jsx
Normal file
33
frontend/src/components/RecentlyOpened.jsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { loadRecentObjects } from '../utils/recentObjects.js'
|
||||||
|
|
||||||
|
const TYPE_LABELS = {
|
||||||
|
action: 'AP',
|
||||||
|
project: 'Projekt',
|
||||||
|
gate: 'Gate',
|
||||||
|
initiative: 'Vorhaben',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentlyOpened() {
|
||||||
|
const items = loadRecentObjects().slice(0, 5)
|
||||||
|
if (items.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="recently-opened" aria-label="Zuletzt geöffnet">
|
||||||
|
<span className="recently-opened__label">Zuletzt</span>
|
||||||
|
<div className="recently-opened__items">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={`${item.type}-${item.id}`}
|
||||||
|
to={item.href}
|
||||||
|
className="recently-opened__chip"
|
||||||
|
title={item.title}
|
||||||
|
>
|
||||||
|
<span className="recently-opened__type">{TYPE_LABELS[item.type] || item.type}</span>
|
||||||
|
<span className="recently-opened__title">{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
frontend/src/components/RequireInitiativeScope.jsx
Normal file
62
frontend/src/components/RequireInitiativeScope.jsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { listInitiatives } from '../api/initiatives.js'
|
||||||
|
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||||
|
import { LoadingState } from './LoadingState.jsx'
|
||||||
|
import { scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
|
export function RequireInitiativeScope({ children, lead }) {
|
||||||
|
const location = useLocation()
|
||||||
|
const { initiativeId, applyFromInitiative } = useProgramScope()
|
||||||
|
const [initiatives, setInitiatives] = useState(null)
|
||||||
|
const basePath = location.pathname.replace(/\/$/, '') || location.pathname
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initiativeId) return
|
||||||
|
let cancelled = false
|
||||||
|
listInitiatives()
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setInitiatives(data)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setInitiatives([])
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [initiativeId])
|
||||||
|
|
||||||
|
if (initiativeId) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initiatives === null) {
|
||||||
|
return <LoadingState message="Lade Vorhaben …" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card scope-picker">
|
||||||
|
<h2 className="card-title">Vorhaben wählen</h2>
|
||||||
|
<p className="page-lead">
|
||||||
|
{lead || 'Dieser Modus braucht ein Vorhaben als Scope-Filter.'}
|
||||||
|
</p>
|
||||||
|
{initiatives.length === 0 ? (
|
||||||
|
<p className="muted">Noch keine Vorhaben angelegt.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="scope-picker__list">
|
||||||
|
{initiatives.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<Link
|
||||||
|
to={scopedPath(basePath, { initiativeId: item.id })}
|
||||||
|
className="scope-picker__link"
|
||||||
|
onClick={() => applyFromInitiative(item.id)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { gatePath } from '../utils/routes.js'
|
||||||
import {
|
import {
|
||||||
MILESTONE_STATUSES,
|
MILESTONE_STATUSES,
|
||||||
MILESTONE_STATUS_LABELS,
|
MILESTONE_STATUS_LABELS,
|
||||||
|
|
@ -152,7 +153,7 @@ export function RoadmapPlanSection({
|
||||||
<div className="list-item-main">
|
<div className="list-item-main">
|
||||||
<strong>
|
<strong>
|
||||||
{initiativeId ? (
|
{initiativeId ? (
|
||||||
<Link to={`/initiatives/${initiativeId}/plan/items/${item.id}`}>
|
<Link to={gatePath(item.id)}>
|
||||||
{item.title}
|
{item.title}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
import { LayoutDashboard, FolderKanban, ListChecks } from 'lucide-react'
|
import {
|
||||||
|
Gauge,
|
||||||
|
LayoutDashboard,
|
||||||
|
Map,
|
||||||
|
Play,
|
||||||
|
Users,
|
||||||
|
FolderKanban,
|
||||||
|
} from 'lucide-react'
|
||||||
import { getNavViews } from '../registry/viewRegistry.js'
|
import { getNavViews } from '../registry/viewRegistry.js'
|
||||||
|
|
||||||
/** @typedef {{ key: string, to: string, label: string, shortLabel: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent, matchPrefix?: string }} AppNavItem */
|
/** @typedef {{ key: string, to: string, label: string, shortLabel: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent, matchPrefix?: string, objectPrefixes?: string[] }} AppNavItem */
|
||||||
|
|
||||||
const ICON_BY_KEY = {
|
const ICON_BY_KEY = {
|
||||||
'kairo.workspace': LayoutDashboard,
|
'kairo.cockpit': LayoutDashboard,
|
||||||
|
'kairo.work': Play,
|
||||||
|
'kairo.plan': Map,
|
||||||
|
'kairo.control': Gauge,
|
||||||
|
'kairo.team': Users,
|
||||||
'kairo.initiatives': FolderKanban,
|
'kairo.initiatives': FolderKanban,
|
||||||
'kairo.my_actions': ListChecks,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -19,9 +29,10 @@ export function getAppNavItems(capabilities) {
|
||||||
to: view.path,
|
to: view.path,
|
||||||
label: view.navLabel || view.title,
|
label: view.navLabel || view.title,
|
||||||
shortLabel: view.shortLabel || view.title,
|
shortLabel: view.shortLabel || view.title,
|
||||||
end: view.path !== '/initiatives',
|
end: view.path === '/cockpit' || view.path === '/team',
|
||||||
Icon: ICON_BY_KEY[view.key] || LayoutDashboard,
|
Icon: ICON_BY_KEY[view.key] || LayoutDashboard,
|
||||||
matchPrefix: view.matchPrefix,
|
matchPrefix: view.matchPrefix,
|
||||||
|
objectPrefixes: view.objectPrefixes,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,8 +42,14 @@ export function getAppNavItems(capabilities) {
|
||||||
* @param {boolean} routerIsActive
|
* @param {boolean} routerIsActive
|
||||||
*/
|
*/
|
||||||
export function isNavItemActive(pathname, item, routerIsActive) {
|
export function isNavItemActive(pathname, item, routerIsActive) {
|
||||||
if (item.matchPrefix) {
|
if (item.matchPrefix && pathname.startsWith(item.matchPrefix)) {
|
||||||
return pathname.startsWith(item.matchPrefix)
|
return true
|
||||||
|
}
|
||||||
|
if (item.objectPrefixes?.some((prefix) => pathname.startsWith(prefix))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (item.key === 'kairo.initiatives' && pathname.startsWith('/initiatives')) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
return routerIsActive
|
return routerIsActive
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,12 +68,15 @@ import { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/stee
|
||||||
import { useCapabilities } from '../hooks/useCapabilities.js'
|
import { useCapabilities } from '../hooks/useCapabilities.js'
|
||||||
import { useActors } from '../hooks/useActors.js'
|
import { useActors } from '../hooks/useActors.js'
|
||||||
import { useSession } from './SessionContext.jsx'
|
import { useSession } from './SessionContext.jsx'
|
||||||
|
import { useOptionalProgramScope } from './ProgramScopeContext.jsx'
|
||||||
import { collectProjectSubtreeIds } from '../utils/projectTree.js'
|
import { collectProjectSubtreeIds } from '../utils/projectTree.js'
|
||||||
|
|
||||||
const InitiativeOperationsContext = createContext(null)
|
const InitiativeOperationsContext = createContext(null)
|
||||||
|
|
||||||
export function InitiativeOperationsProvider({ children }) {
|
export function InitiativeOperationsProvider({ children, initiativeId: initiativeIdProp }) {
|
||||||
const { id } = useParams()
|
const { id: routeId } = useParams()
|
||||||
|
const scope = useOptionalProgramScope()
|
||||||
|
const id = initiativeIdProp || routeId || scope?.initiativeId || null
|
||||||
const { context, loading: sessionLoading } = useSession()
|
const { context, loading: sessionLoading } = useSession()
|
||||||
const { capabilities } = useCapabilities()
|
const { capabilities } = useCapabilities()
|
||||||
const actorsState = useActors()
|
const actorsState = useActors()
|
||||||
|
|
|
||||||
164
frontend/src/context/ProgramScopeContext.jsx
Normal file
164
frontend/src/context/ProgramScopeContext.jsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { getInitiative } from '../api/initiatives.js'
|
||||||
|
import { getProject } from '../api/projects.js'
|
||||||
|
import { scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
|
const ProgramScopeContext = createContext(null)
|
||||||
|
|
||||||
|
export function ProgramScopeProvider({ children }) {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const initiativeId = searchParams.get('initiative') || ''
|
||||||
|
const projectId = searchParams.get('project') || ''
|
||||||
|
|
||||||
|
const [initiativeTitle, setInitiativeTitle] = useState('')
|
||||||
|
const [projectTitle, setProjectTitle] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initiativeId) {
|
||||||
|
setInitiativeTitle('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
getInitiative(initiativeId)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setInitiativeTitle(data.title || '')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setInitiativeTitle('')
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [initiativeId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) {
|
||||||
|
setProjectTitle('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
getProject(projectId)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setProjectTitle(data.title || '')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setProjectTitle('')
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
const setScope = useCallback(
|
||||||
|
(next) => {
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
if ('initiativeId' in next) {
|
||||||
|
if (next.initiativeId) params.set('initiative', next.initiativeId)
|
||||||
|
else params.delete('initiative')
|
||||||
|
}
|
||||||
|
if ('projectId' in next) {
|
||||||
|
if (next.projectId) params.set('project', next.projectId)
|
||||||
|
else params.delete('project')
|
||||||
|
}
|
||||||
|
if (next.clearProject) params.delete('project')
|
||||||
|
setSearchParams(params, { replace: true })
|
||||||
|
},
|
||||||
|
[searchParams, setSearchParams],
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearScope = useCallback(() => {
|
||||||
|
setSearchParams({}, { replace: true })
|
||||||
|
}, [setSearchParams])
|
||||||
|
|
||||||
|
const applyFromInitiative = useCallback(
|
||||||
|
(id, { keepProject = false } = {}) => {
|
||||||
|
if (!id) return
|
||||||
|
setScope({
|
||||||
|
initiativeId: id,
|
||||||
|
projectId: keepProject ? projectId : '',
|
||||||
|
clearProject: !keepProject,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[projectId, setScope],
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyFromProject = useCallback(
|
||||||
|
(project) => {
|
||||||
|
if (!project?.initiative_id) return
|
||||||
|
setScope({
|
||||||
|
initiativeId: project.initiative_id,
|
||||||
|
projectId: project.id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[setScope],
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyFromAction = useCallback(
|
||||||
|
(action) => {
|
||||||
|
if (!action?.initiative_id) return
|
||||||
|
setScope({
|
||||||
|
initiativeId: action.initiative_id,
|
||||||
|
projectId: action.project_id || '',
|
||||||
|
clearProject: !action.project_id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[setScope],
|
||||||
|
)
|
||||||
|
|
||||||
|
const hrefWithScope = useCallback(
|
||||||
|
(path) => scopedPath(path, { initiativeId, projectId }),
|
||||||
|
[initiativeId, projectId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
initiativeId,
|
||||||
|
projectId,
|
||||||
|
initiativeTitle,
|
||||||
|
projectTitle,
|
||||||
|
setScope,
|
||||||
|
clearScope,
|
||||||
|
applyFromInitiative,
|
||||||
|
applyFromProject,
|
||||||
|
applyFromAction,
|
||||||
|
hrefWithScope,
|
||||||
|
hasScope: Boolean(initiativeId),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
initiativeId,
|
||||||
|
projectId,
|
||||||
|
initiativeTitle,
|
||||||
|
projectTitle,
|
||||||
|
setScope,
|
||||||
|
clearScope,
|
||||||
|
applyFromInitiative,
|
||||||
|
applyFromProject,
|
||||||
|
applyFromAction,
|
||||||
|
hrefWithScope,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProgramScopeContext.Provider value={value}>{children}</ProgramScopeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProgramScope() {
|
||||||
|
const ctx = useContext(ProgramScopeContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useProgramScope must be used within ProgramScopeProvider')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOptionalProgramScope() {
|
||||||
|
return useContext(ProgramScopeContext)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,9 @@ import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { DesktopSidebar } from '../components/DesktopSidebar.jsx'
|
import { DesktopSidebar } from '../components/DesktopSidebar.jsx'
|
||||||
import { BottomNav } from '../components/BottomNav.jsx'
|
import { BottomNav } from '../components/BottomNav.jsx'
|
||||||
import { ContextIndicator } from '../components/ContextIndicator.jsx'
|
import { ContextIndicator } from '../components/ContextIndicator.jsx'
|
||||||
|
import { ProgramScopeBar } from '../components/ProgramScopeBar.jsx'
|
||||||
|
import { QuickJump } from '../components/QuickJump.jsx'
|
||||||
|
import { RecentlyOpened } from '../components/RecentlyOpened.jsx'
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const { me, signOut, loading } = useSession()
|
const { me, signOut, loading } = useSession()
|
||||||
|
|
@ -36,9 +39,17 @@ export function AppLayout() {
|
||||||
<span className="app-brand__name">Kairo</span>
|
<span className="app-brand__name">Kairo</span>
|
||||||
<span className="app-brand__family">Jinkendo Program Director</span>
|
<span className="app-brand__family">Jinkendo Program Director</span>
|
||||||
</div>
|
</div>
|
||||||
|
<QuickJump />
|
||||||
</div>
|
</div>
|
||||||
<ContextIndicator variant="mobile" />
|
<ContextIndicator variant="mobile" />
|
||||||
|
<ProgramScopeBar />
|
||||||
|
<RecentlyOpened />
|
||||||
</header>
|
</header>
|
||||||
|
<div className="app-chrome app-chrome--desktop">
|
||||||
|
<ProgramScopeBar />
|
||||||
|
<QuickJump />
|
||||||
|
<RecentlyOpened />
|
||||||
|
</div>
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
13
frontend/src/layout/ScopedInitiativeProvider.jsx
Normal file
13
frontend/src/layout/ScopedInitiativeProvider.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { InitiativeOperationsProvider } from '../context/InitiativeOperationsContext.jsx'
|
||||||
|
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||||
|
|
||||||
|
export function ScopedInitiativeProvider({ initiativeId: initiativeIdProp, children }) {
|
||||||
|
const { initiativeId: scopeInitiativeId } = useProgramScope()
|
||||||
|
const initiativeId = initiativeIdProp || scopeInitiativeId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InitiativeOperationsProvider initiativeId={initiativeId}>
|
||||||
|
{children}
|
||||||
|
</InitiativeOperationsProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -12,8 +12,11 @@ import { TasksSection } from '../components/TasksSection.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
|
||||||
|
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||||
import { useCapabilities } from '../hooks/useCapabilities.js'
|
import { useCapabilities } from '../hooks/useCapabilities.js'
|
||||||
import { useActors } from '../hooks/useActors.js'
|
import { useActors } from '../hooks/useActors.js'
|
||||||
|
import { recordRecentObject } from '../utils/recentObjects.js'
|
||||||
|
import { actionPath, scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
function ActionDetailBody({
|
function ActionDetailBody({
|
||||||
action,
|
action,
|
||||||
|
|
@ -81,6 +84,7 @@ function ActionDetailBody({
|
||||||
|
|
||||||
function ActionDetailStandalone() {
|
function ActionDetailStandalone() {
|
||||||
const { actionId } = useParams()
|
const { actionId } = useParams()
|
||||||
|
const { applyFromAction } = useProgramScope()
|
||||||
const { capabilities } = useCapabilities()
|
const { capabilities } = useCapabilities()
|
||||||
const actorsState = useActors()
|
const actorsState = useActors()
|
||||||
const [action, setAction] = useState(null)
|
const [action, setAction] = useState(null)
|
||||||
|
|
@ -98,6 +102,14 @@ function ActionDetailStandalone() {
|
||||||
try {
|
try {
|
||||||
const actionData = await getAction(actionId)
|
const actionData = await getAction(actionId)
|
||||||
setAction(actionData)
|
setAction(actionData)
|
||||||
|
applyFromAction(actionData)
|
||||||
|
recordRecentObject({
|
||||||
|
type: 'action',
|
||||||
|
id: actionData.id,
|
||||||
|
title: actionData.title,
|
||||||
|
href: actionPath(actionData.id),
|
||||||
|
initiativeId: actionData.initiative_id,
|
||||||
|
})
|
||||||
const [snap, projectData, roadmapData] = await Promise.all([
|
const [snap, projectData, roadmapData] = await Promise.all([
|
||||||
getInitiativeSteeringSnapshot(actionData.initiative_id),
|
getInitiativeSteeringSnapshot(actionData.initiative_id),
|
||||||
listInitiativeProjects(actionData.initiative_id).catch(() => []),
|
listInitiativeProjects(actionData.initiative_id).catch(() => []),
|
||||||
|
|
@ -111,7 +123,7 @@ function ActionDetailStandalone() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [actionId])
|
}, [actionId, applyFromAction])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load()
|
load()
|
||||||
|
|
@ -190,11 +202,15 @@ function ActionDetailStandalone() {
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
<nav className="breadcrumb">
|
<nav className="breadcrumb">
|
||||||
<Link to="/workspace">Workspace</Link>
|
<Link to="/cockpit">Cockpit</Link>
|
||||||
<span aria-hidden="true"> / </span>
|
<span aria-hidden="true"> / </span>
|
||||||
<Link to={`/initiatives/${action.initiative_id}`}>Vorhaben</Link>
|
<Link to={scopedPath('/control/status', { initiativeId: action.initiative_id })}>
|
||||||
|
Vorhaben
|
||||||
|
</Link>
|
||||||
<span aria-hidden="true"> / </span>
|
<span aria-hidden="true"> / </span>
|
||||||
<Link to={`/initiatives/${action.initiative_id}/execution`}>Ausführung</Link>
|
<Link to={scopedPath('/work/today', { initiativeId: action.initiative_id })}>
|
||||||
|
Ausführen
|
||||||
|
</Link>
|
||||||
<span aria-hidden="true"> / </span>
|
<span aria-hidden="true"> / </span>
|
||||||
<span>{action.title}</span>
|
<span>{action.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { useCapabilities } from '../hooks/useCapabilities.js'
|
import { useCapabilities } from '../hooks/useCapabilities.js'
|
||||||
import { useSession } from '../context/SessionContext.jsx'
|
import { useSession } from '../context/SessionContext.jsx'
|
||||||
|
import { scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
export function InitiativesPage() {
|
export function InitiativesPage() {
|
||||||
const { context } = useSession()
|
const { context } = useSession()
|
||||||
|
|
@ -105,7 +106,7 @@ export function InitiativesPage() {
|
||||||
{initiatives.map((item) => (
|
{initiatives.map((item) => (
|
||||||
<li key={item.id} className="list-item card-list-item">
|
<li key={item.id} className="list-item card-list-item">
|
||||||
<div className="list-item-main">
|
<div className="list-item-main">
|
||||||
<Link to={`/initiatives/${item.id}`} className="list-item-link">
|
<Link to={scopedPath('/control/status', { initiativeId: item.id })} className="list-item-link">
|
||||||
<strong>{item.title}</strong>
|
<strong>{item.title}</strong>
|
||||||
</Link>
|
</Link>
|
||||||
{item.goal && <p className="list-item-desc">{item.goal}</p>}
|
{item.goal && <p className="list-item-desc">{item.goal}</p>}
|
||||||
|
|
|
||||||
61
frontend/src/pages/LegacyRedirects.jsx
Normal file
61
frontend/src/pages/LegacyRedirects.jsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { Navigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import { actionPath, gatePath, projectPath, scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
|
export function WorkspaceRedirect() {
|
||||||
|
return <Navigate to="/cockpit" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyActionsRedirect() {
|
||||||
|
return <Navigate to="/work/mine" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InitiativeRootRedirect() {
|
||||||
|
const { id } = useParams()
|
||||||
|
return <Navigate to={scopedPath('/control/status', { initiativeId: id })} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InitiativePlanRedirect() {
|
||||||
|
const { id } = useParams()
|
||||||
|
return <Navigate to={scopedPath('/plan/gates', { initiativeId: id })} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InitiativeExecutionRedirect() {
|
||||||
|
const { id } = useParams()
|
||||||
|
return <Navigate to={scopedPath('/plan/structure', { initiativeId: id })} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InitiativeInboxRedirect() {
|
||||||
|
const { id } = useParams()
|
||||||
|
return <Navigate to={scopedPath('/plan/inbox', { initiativeId: id })} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InitiativeJourneyRedirect() {
|
||||||
|
const { id } = useParams()
|
||||||
|
return <Navigate to={scopedPath('/control/journey', { initiativeId: id })} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InitiativeGateRedirect() {
|
||||||
|
const { itemId } = useParams()
|
||||||
|
return <Navigate to={gatePath(itemId)} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InitiativeProjectRedirect() {
|
||||||
|
const { projectId } = useParams()
|
||||||
|
return <Navigate to={projectPath(projectId)} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InitiativeActionRedirect() {
|
||||||
|
const { actionId } = useParams()
|
||||||
|
return <Navigate to={actionPath(actionId)} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectNewRedirect() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const initiativeId = searchParams.get('initiative') || ''
|
||||||
|
const parent = searchParams.get('parent') || ''
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (initiativeId) params.set('initiative', initiativeId)
|
||||||
|
if (parent) params.set('parent', parent)
|
||||||
|
const qs = params.toString()
|
||||||
|
return <Navigate to={qs ? `/projects/new?${qs}` : '/projects/new'} replace />
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
|
||||||
export function MyActionsPage() {
|
export function MyActionsPage({ embedded = false }) {
|
||||||
const [actions, setActions] = useState([])
|
const [actions, setActions] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
@ -34,8 +34,9 @@ export function MyActionsPage() {
|
||||||
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
||||||
: actions
|
: actions
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<div className="app-page">
|
<>
|
||||||
|
{!embedded && (
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Meine Maßnahmen</h1>
|
<h1>Meine Maßnahmen</h1>
|
||||||
|
|
@ -50,9 +51,24 @@ export function MyActionsPage() {
|
||||||
Erledigte ausblenden
|
Erledigte ausblenden
|
||||||
</label>
|
</label>
|
||||||
</header>
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{embedded && (
|
||||||
|
<div className="section-header section-header--compact">
|
||||||
|
<h2>Meine Queue</h2>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideDone}
|
||||||
|
onChange={(e) => setHideDone(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Erledigte ausblenden
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Zugewiesene Maßnahmen"
|
title={embedded ? undefined : 'Zugewiesene Maßnahmen'}
|
||||||
actions={
|
actions={
|
||||||
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
|
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
|
|
@ -65,7 +81,7 @@ export function MyActionsPage() {
|
||||||
<ActionList
|
<ActionList
|
||||||
actions={visible}
|
actions={visible}
|
||||||
showInitiative
|
showInitiative
|
||||||
getManageLink={(a) => `/initiatives/${a.initiative_id}`}
|
getManageLink={(a) => `/actions/${a.id}`}
|
||||||
empty={
|
empty={
|
||||||
<EmptyState message="Keine offenen Maßnahmen. Du kannst eine neue Maßnahme in einem Vorhaben anlegen." />
|
<EmptyState message="Keine offenen Maßnahmen. Du kannst eine neue Maßnahme in einem Vorhaben anlegen." />
|
||||||
}
|
}
|
||||||
|
|
@ -73,11 +89,19 @@ export function MyActionsPage() {
|
||||||
)}
|
)}
|
||||||
</WidgetCard>
|
</WidgetCard>
|
||||||
|
|
||||||
|
{!embedded && (
|
||||||
<p className="page-footer-link">
|
<p className="page-footer-link">
|
||||||
<Link to="/initiatives" className="link-inline">
|
<Link to="/initiatives" className="link-inline">
|
||||||
Zu den Vorhaben →
|
Zum Vorhaben-Katalog →
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="app-page">{content}</div>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { scopedPath } from '../../utils/routes.js'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx'
|
import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx'
|
||||||
import { NextActionWidget } from '../../widgets/NextActionWidget.jsx'
|
import { NextActionWidget } from '../../widgets/NextActionWidget.jsx'
|
||||||
|
|
@ -60,7 +61,7 @@ export function InitiativeOverviewPage() {
|
||||||
<span>{counts.blockers_open} offene Blocker</span>
|
<span>{counts.blockers_open} offene Blocker</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<Link to={`/initiatives/${initiativeId}/execution`} className="btn btn-secondary btn-sm">
|
<Link to={scopedPath('/work/today', { initiativeId })} className="btn btn-secondary btn-sm">
|
||||||
Zur Ausführung
|
Zur Ausführung
|
||||||
</Link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { useParams } from 'react-router-dom'
|
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx'
|
import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx'
|
||||||
|
|
||||||
export function InitiativePlanPage() {
|
export function InitiativePlanPage() {
|
||||||
const { id } = useParams()
|
|
||||||
const {
|
const {
|
||||||
|
initiativeId,
|
||||||
roadmapItems,
|
roadmapItems,
|
||||||
capabilities,
|
capabilities,
|
||||||
formBusy,
|
formBusy,
|
||||||
|
|
@ -24,7 +23,7 @@ export function InitiativePlanPage() {
|
||||||
<>
|
<>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<RoadmapPlanSection
|
<RoadmapPlanSection
|
||||||
initiativeId={id}
|
initiativeId={initiativeId}
|
||||||
items={roadmapItems}
|
items={roadmapItems}
|
||||||
canManage={capabilities.has('kairo.milestone.manage')}
|
canManage={capabilities.has('kairo.milestone.manage')}
|
||||||
onCreate={handleCreateRoadmapItem}
|
onCreate={handleCreateRoadmapItem}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { EmptyState } from '../../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../../components/ErrorState.jsx'
|
import { ErrorState } from '../../components/ErrorState.jsx'
|
||||||
import { gateTitleById } from '../../components/GateSelect.jsx'
|
import { gateTitleById } from '../../components/GateSelect.jsx'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
|
import { gatePath, projectPath, actionPath, scopedPath } from '../../utils/routes.js'
|
||||||
import {
|
import {
|
||||||
buildProjectPath,
|
buildProjectPath,
|
||||||
buildProjectsByParent,
|
buildProjectsByParent,
|
||||||
|
|
@ -65,7 +66,7 @@ function ProjectDetailView({
|
||||||
<dt>Gate</dt>
|
<dt>Gate</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{gateTitle ? (
|
{gateTitle ? (
|
||||||
<Link to={`/initiatives/${initiativeId}/plan/items/${project.roadmap_item_id}`}>
|
<Link to={gatePath(project.roadmap_item_id)}>
|
||||||
{gateTitle}
|
{gateTitle}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -96,7 +97,7 @@ function ProjectDetailView({
|
||||||
{index === path.length - 1 ? (
|
{index === path.length - 1 ? (
|
||||||
<strong>{node.title}</strong>
|
<strong>{node.title}</strong>
|
||||||
) : (
|
) : (
|
||||||
<Link to={`/initiatives/${initiativeId}/execution/projects/${node.id}`}>
|
<Link to={projectPath(node.id)}>
|
||||||
{node.title}
|
{node.title}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
@ -112,7 +113,7 @@ function ProjectDetailView({
|
||||||
<ul className="item-list">
|
<ul className="item-list">
|
||||||
{childProjects.map((child) => (
|
{childProjects.map((child) => (
|
||||||
<li key={child.id} className="list-item card-list-item">
|
<li key={child.id} className="list-item card-list-item">
|
||||||
<Link to={`/initiatives/${initiativeId}/execution/projects/${child.id}`}>
|
<Link to={projectPath(child.id)}>
|
||||||
<strong>{child.title}</strong>
|
<strong>{child.title}</strong>
|
||||||
</Link>
|
</Link>
|
||||||
<StatusBadge kind="project" status={child.status} />
|
<StatusBadge kind="project" status={child.status} />
|
||||||
|
|
@ -136,7 +137,7 @@ function ProjectDetailView({
|
||||||
<ul className="item-list">
|
<ul className="item-list">
|
||||||
{linkedActions.map((action) => (
|
{linkedActions.map((action) => (
|
||||||
<li key={action.id} className="list-item card-list-item">
|
<li key={action.id} className="list-item card-list-item">
|
||||||
<Link to={`/initiatives/${initiativeId}/actions/${action.id}`}>
|
<Link to={actionPath(action.id)}>
|
||||||
<strong>{action.title}</strong>
|
<strong>{action.title}</strong>
|
||||||
</Link>
|
</Link>
|
||||||
<StatusBadge kind="action" status={action.status} />
|
<StatusBadge kind="action" status={action.status} />
|
||||||
|
|
@ -158,7 +159,7 @@ function ProjectDetailView({
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Link
|
<Link
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
to={`/initiatives/${initiativeId}/execution/projects/new?parent=${project.id}`}
|
to={`${projectPath('new')}?initiative=${initiativeId}&parent=${project.id}`}
|
||||||
>
|
>
|
||||||
Unterprojekt
|
Unterprojekt
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -212,7 +213,7 @@ export function ProjectDetailPage() {
|
||||||
setLocalError(null)
|
setLocalError(null)
|
||||||
const created = await ops.handleCreateProject(payload)
|
const created = await ops.handleCreateProject(payload)
|
||||||
if (created?.id) {
|
if (created?.id) {
|
||||||
navigate(`/initiatives/${ops.initiativeId}/execution/projects/${created.id}`, { replace: true })
|
navigate(projectPath(created.id), { replace: true })
|
||||||
} else if (ops.error) {
|
} else if (ops.error) {
|
||||||
setLocalError(ops.error)
|
setLocalError(ops.error)
|
||||||
}
|
}
|
||||||
|
|
@ -233,7 +234,7 @@ export function ProjectDetailPage() {
|
||||||
setLocalError(null)
|
setLocalError(null)
|
||||||
const ok = await ops.handleDeleteProject(project.id)
|
const ok = await ops.handleDeleteProject(project.id)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
navigate(`/initiatives/${ops.initiativeId}/execution`)
|
navigate(scopedPath('/plan/structure', { initiativeId: ops.initiativeId }))
|
||||||
} else if (ops.error) {
|
} else if (ops.error) {
|
||||||
setLocalError(ops.error)
|
setLocalError(ops.error)
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +242,7 @@ export function ProjectDetailPage() {
|
||||||
|
|
||||||
function handleFilter() {
|
function handleFilter() {
|
||||||
if (project) ops.setSelectedProjectId(project.id)
|
if (project) ops.setSelectedProjectId(project.id)
|
||||||
navigate(`/initiatives/${ops.initiativeId}/execution`)
|
navigate(scopedPath('/plan/structure', { initiativeId: ops.initiativeId }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = isNew ? 'Neues Projekt' : project?.title || 'Projekt'
|
const title = isNew ? 'Neues Projekt' : project?.title || 'Projekt'
|
||||||
|
|
@ -249,7 +250,7 @@ export function ProjectDetailPage() {
|
||||||
return (
|
return (
|
||||||
<section className="card project-detail-page">
|
<section className="card project-detail-page">
|
||||||
<nav className="breadcrumb breadcrumb--inline">
|
<nav className="breadcrumb breadcrumb--inline">
|
||||||
<Link to={`/initiatives/${ops.initiativeId}/execution`}>Ausführung</Link>
|
<Link to={scopedPath('/plan/structure', { initiativeId: ops.initiativeId })}>Struktur</Link>
|
||||||
<span aria-hidden="true"> / </span>
|
<span aria-hidden="true"> / </span>
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -298,7 +299,7 @@ export function ProjectDetailPage() {
|
||||||
onSubmit={isNew ? handleCreate : handleUpdate}
|
onSubmit={isNew ? handleCreate : handleUpdate}
|
||||||
onCancel={
|
onCancel={
|
||||||
isNew
|
isNew
|
||||||
? () => navigate(`/initiatives/${ops.initiativeId}/execution`)
|
? () => navigate(scopedPath('/plan/structure', { initiativeId: ops.initiativeId }))
|
||||||
: () => setEditing(false)
|
: () => setEditing(false)
|
||||||
}
|
}
|
||||||
busy={ops.formBusy}
|
busy={ops.formBusy}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ import {
|
||||||
ROADMAP_ITEM_TYPE_LABELS,
|
ROADMAP_ITEM_TYPE_LABELS,
|
||||||
} from '../../constants/status.js'
|
} from '../../constants/status.js'
|
||||||
import { useCapabilities } from '../../hooks/useCapabilities.js'
|
import { useCapabilities } from '../../hooks/useCapabilities.js'
|
||||||
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
|
import { scopedPath } from '../../utils/routes.js'
|
||||||
import { listGateContributions } from '../../api/journey.js'
|
import { listGateContributions } from '../../api/journey.js'
|
||||||
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
|
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
|
||||||
|
|
||||||
|
|
@ -71,8 +73,11 @@ function CriterionDecisionForm({ label, onSubmit, busy }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoadmapItemDetailPage() {
|
export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
const { id: initiativeId, itemId } = useParams()
|
const { id: routeInitiativeId, itemId: routeItemId, gateId } = useParams()
|
||||||
|
const ops = useInitiativeOperations()
|
||||||
|
const initiativeId = routeInitiativeId || ops?.initiativeId
|
||||||
|
const itemId = overrideItemId || routeItemId || gateId
|
||||||
const { capabilities } = useCapabilities()
|
const { capabilities } = useCapabilities()
|
||||||
const canManage = capabilities.has('kairo.milestone.manage')
|
const canManage = capabilities.has('kairo.milestone.manage')
|
||||||
const canReopen = capabilities.has('kairo.roadmap.reopen')
|
const canReopen = capabilities.has('kairo.roadmap.reopen')
|
||||||
|
|
@ -152,7 +157,7 @@ export function RoadmapItemDetailPage() {
|
||||||
return (
|
return (
|
||||||
<section className="card roadmap-item-detail">
|
<section className="card roadmap-item-detail">
|
||||||
<p className="breadcrumb muted">
|
<p className="breadcrumb muted">
|
||||||
<Link to={`/initiatives/${initiativeId}/plan`}>Zielzustände</Link>
|
<Link to={scopedPath('/plan/gates', { initiativeId })}>Zielzustände</Link>
|
||||||
{' · '}
|
{' · '}
|
||||||
{item.title}
|
{item.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
84
frontend/src/pages/modes/CockpitPage.jsx
Normal file
84
frontend/src/pages/modes/CockpitPage.jsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useCapabilities } from '../../hooks/useCapabilities.js'
|
||||||
|
import { getWidgetsForArea } from '../../registry/widgetRegistry.js'
|
||||||
|
import { useSession } from '../../context/SessionContext.jsx'
|
||||||
|
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
|
||||||
|
import { createInitiative } from '../../api/initiatives.js'
|
||||||
|
import { ModeShell } from '../../components/ModeShell.jsx'
|
||||||
|
import { initiativeCatalogPath } from '../../utils/routes.js'
|
||||||
|
|
||||||
|
export function CockpitPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { context } = useSession()
|
||||||
|
const { capabilities, hasCapability } = useCapabilities()
|
||||||
|
const widgets = getWidgetsForArea('workspace', capabilities)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [formBusy, setFormBusy] = useState(false)
|
||||||
|
const [formError, setFormError] = useState(null)
|
||||||
|
|
||||||
|
async function handleCreateInitiative(payload) {
|
||||||
|
setFormBusy(true)
|
||||||
|
setFormError(null)
|
||||||
|
try {
|
||||||
|
await createInitiative({
|
||||||
|
...payload,
|
||||||
|
owner_actor_id: context?.actor?.id,
|
||||||
|
})
|
||||||
|
setShowForm(false)
|
||||||
|
navigate(initiativeCatalogPath())
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err.message)
|
||||||
|
} finally {
|
||||||
|
setFormBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<ModeShell
|
||||||
|
modeKey="cockpit"
|
||||||
|
actions={
|
||||||
|
hasCapability('kairo.initiative.manage') ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : 'Vorhaben anlegen'}
|
||||||
|
</button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<section className="card inline-form-card">
|
||||||
|
<h2 className="card-title card-title--lg">Neues Vorhaben</h2>
|
||||||
|
{formError && <p className="error">{formError}</p>}
|
||||||
|
<InitiativeForm
|
||||||
|
onSubmit={handleCreateInitiative}
|
||||||
|
onCancel={() => setShowForm(false)}
|
||||||
|
busy={formBusy}
|
||||||
|
submitLabel="Vorhaben anlegen"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="widget-grid">
|
||||||
|
{widgets.map((widget) => {
|
||||||
|
const Component = widget.component
|
||||||
|
return <Component key={widget.key} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasCapability('kairo.initiative.read') && (
|
||||||
|
<p className="page-footer-link">
|
||||||
|
<Link to={initiativeCatalogPath()} className="link-inline">
|
||||||
|
Zum Vorhaben-Katalog →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
frontend/src/pages/modes/ControlJourneyPage.jsx
Normal file
13
frontend/src/pages/modes/ControlJourneyPage.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
||||||
|
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
||||||
|
import { InitiativeJourneyPage } from '../initiative/InitiativeJourneyPage.jsx'
|
||||||
|
|
||||||
|
export function ControlJourneyPage() {
|
||||||
|
return (
|
||||||
|
<RequireInitiativeScope lead="Journey ist vorhabenspezifisch.">
|
||||||
|
<ScopedInitiativeProvider>
|
||||||
|
<InitiativeJourneyPage />
|
||||||
|
</ScopedInitiativeProvider>
|
||||||
|
</RequireInitiativeScope>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
frontend/src/pages/modes/ControlLayout.jsx
Normal file
22
frontend/src/pages/modes/ControlLayout.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom'
|
||||||
|
import { ModeShell } from '../../components/ModeShell.jsx'
|
||||||
|
import { ModeSubNav } from '../../components/ModeSubNav.jsx'
|
||||||
|
|
||||||
|
const CONTROL_NAV = [
|
||||||
|
{ to: '/control/status', label: 'Steuerung', end: true },
|
||||||
|
{ to: '/control/journey', label: 'Journey', end: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ControlLayout() {
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<ModeShell modeKey="control" />
|
||||||
|
<ModeSubNav items={CONTROL_NAV} />
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ControlIndexRedirect() {
|
||||||
|
return <Navigate to="/control/status" replace />
|
||||||
|
}
|
||||||
13
frontend/src/pages/modes/ControlStatusPage.jsx
Normal file
13
frontend/src/pages/modes/ControlStatusPage.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
||||||
|
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
||||||
|
import { InitiativeOverviewPage } from '../initiative/InitiativeOverviewPage.jsx'
|
||||||
|
|
||||||
|
export function ControlStatusPage() {
|
||||||
|
return (
|
||||||
|
<RequireInitiativeScope lead="Steuerung und Lagebild brauchen ein Vorhaben im Scope.">
|
||||||
|
<ScopedInitiativeProvider>
|
||||||
|
<InitiativeOverviewPage />
|
||||||
|
</ScopedInitiativeProvider>
|
||||||
|
</RequireInitiativeScope>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
frontend/src/pages/modes/PlanGatesPage.jsx
Normal file
13
frontend/src/pages/modes/PlanGatesPage.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
||||||
|
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
||||||
|
import { InitiativePlanPage } from '../initiative/InitiativePlanPage.jsx'
|
||||||
|
|
||||||
|
export function PlanGatesPage() {
|
||||||
|
return (
|
||||||
|
<RequireInitiativeScope lead="Gates und Roadmap gehören zu einem Vorhaben.">
|
||||||
|
<ScopedInitiativeProvider>
|
||||||
|
<InitiativePlanPage />
|
||||||
|
</ScopedInitiativeProvider>
|
||||||
|
</RequireInitiativeScope>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
frontend/src/pages/modes/PlanInboxPage.jsx
Normal file
13
frontend/src/pages/modes/PlanInboxPage.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
||||||
|
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
||||||
|
import { InitiativeInboxPage } from '../initiative/InitiativeInboxPage.jsx'
|
||||||
|
|
||||||
|
export function PlanInboxPage() {
|
||||||
|
return (
|
||||||
|
<RequireInitiativeScope lead="Eingang und Triage sind vorhabenspezifisch.">
|
||||||
|
<ScopedInitiativeProvider>
|
||||||
|
<InitiativeInboxPage />
|
||||||
|
</ScopedInitiativeProvider>
|
||||||
|
</RequireInitiativeScope>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
frontend/src/pages/modes/PlanLayout.jsx
Normal file
23
frontend/src/pages/modes/PlanLayout.jsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom'
|
||||||
|
import { ModeShell } from '../../components/ModeShell.jsx'
|
||||||
|
import { ModeSubNav } from '../../components/ModeSubNav.jsx'
|
||||||
|
|
||||||
|
const PLAN_NAV = [
|
||||||
|
{ to: '/plan/structure', label: 'Struktur', end: true },
|
||||||
|
{ to: '/plan/gates', label: 'Zielzustände', end: true },
|
||||||
|
{ to: '/plan/inbox', label: 'Eingang', end: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function PlanLayout() {
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<ModeShell modeKey="plan" />
|
||||||
|
<ModeSubNav items={PLAN_NAV} />
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanIndexRedirect() {
|
||||||
|
return <Navigate to="/plan/structure" replace />
|
||||||
|
}
|
||||||
40
frontend/src/pages/modes/PlanStructurePage.jsx
Normal file
40
frontend/src/pages/modes/PlanStructurePage.jsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
||||||
|
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
||||||
|
import { ProjectsSection } from '../../components/ProjectsSection.jsx'
|
||||||
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
|
|
||||||
|
function PlanStructureInner() {
|
||||||
|
const {
|
||||||
|
initiativeId,
|
||||||
|
projects,
|
||||||
|
roadmapItems,
|
||||||
|
selectedProjectId,
|
||||||
|
setSelectedProjectId,
|
||||||
|
capabilities,
|
||||||
|
error,
|
||||||
|
} = useInitiativeOperations()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<ProjectsSection
|
||||||
|
initiativeId={initiativeId}
|
||||||
|
projects={projects}
|
||||||
|
roadmapItems={roadmapItems}
|
||||||
|
selectedProjectId={selectedProjectId}
|
||||||
|
onSelectProject={setSelectedProjectId}
|
||||||
|
canManage={capabilities.has('kairo.project.manage')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanStructurePage() {
|
||||||
|
return (
|
||||||
|
<RequireInitiativeScope lead="Projektstruktur hängt an einem Vorhaben — wähle den Scope oben oder hier.">
|
||||||
|
<ScopedInitiativeProvider>
|
||||||
|
<PlanStructureInner />
|
||||||
|
</ScopedInitiativeProvider>
|
||||||
|
</RequireInitiativeScope>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
frontend/src/pages/modes/TeamPage.jsx
Normal file
65
frontend/src/pages/modes/TeamPage.jsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { getActorWorkload } from '../../api/workspace.js'
|
||||||
|
import { ModeShell } from '../../components/ModeShell.jsx'
|
||||||
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
|
import { ErrorState } from '../../components/ErrorState.jsx'
|
||||||
|
import { scopedPath } from '../../utils/routes.js'
|
||||||
|
|
||||||
|
export function TeamPage() {
|
||||||
|
const [rows, setRows] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
getActorWorkload()
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setRows(data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err.message)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<ModeShell modeKey="team" />
|
||||||
|
{rows === null && !error && <LoadingState message="Lade Team-Auslastung …" />}
|
||||||
|
{error && <ErrorState message={error} />}
|
||||||
|
{rows && (
|
||||||
|
<section className="card">
|
||||||
|
<h2 className="card-title">Auslastung nach Actor</h2>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="muted">Keine Zuweisungen im Portfolio.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="team-workload-list">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<li key={row.actor_id} className="team-workload-list__item">
|
||||||
|
<div>
|
||||||
|
<strong>{row.actor_name || row.actor_id}</strong>
|
||||||
|
<span className="muted">
|
||||||
|
{' '}
|
||||||
|
· {row.open_actions ?? 0} offen
|
||||||
|
{(row.blocked_actions ?? 0) > 0
|
||||||
|
? ` · ${row.blocked_actions} blockiert`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link to={scopedPath('/work/mine')} className="link-inline">
|
||||||
|
Queue ansehen
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="page-footer-link muted">
|
||||||
|
AP1.9b erweitert Team um Filter nach Vorhaben und direkte Zuweisungslinks.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
frontend/src/pages/modes/WorkLayout.jsx
Normal file
22
frontend/src/pages/modes/WorkLayout.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom'
|
||||||
|
import { ModeShell } from '../../components/ModeShell.jsx'
|
||||||
|
import { ModeSubNav } from '../../components/ModeSubNav.jsx'
|
||||||
|
|
||||||
|
const WORK_NAV = [
|
||||||
|
{ to: '/work/today', label: 'Heute', end: true },
|
||||||
|
{ to: '/work/mine', label: 'Meine Queue', end: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function WorkLayout() {
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<ModeShell modeKey="work" />
|
||||||
|
<ModeSubNav items={WORK_NAV} />
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkIndexRedirect() {
|
||||||
|
return <Navigate to="/work/today" replace />
|
||||||
|
}
|
||||||
5
frontend/src/pages/modes/WorkMinePage.jsx
Normal file
5
frontend/src/pages/modes/WorkMinePage.jsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { MyActionsPage } from '../MyActionsPage.jsx'
|
||||||
|
|
||||||
|
export function WorkMinePage() {
|
||||||
|
return <MyActionsPage embedded />
|
||||||
|
}
|
||||||
9
frontend/src/pages/modes/WorkTodayPage.jsx
Normal file
9
frontend/src/pages/modes/WorkTodayPage.jsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { WorkspaceTodayWidget } from '../../widgets/WorkspaceTodayWidget.jsx'
|
||||||
|
|
||||||
|
export function WorkTodayPage() {
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<WorkspaceTodayWidget />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
frontend/src/pages/objects/GateObjectPage.jsx
Normal file
70
frontend/src/pages/objects/GateObjectPage.jsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { getRoadmapItem } from '../../api/roadmap.js'
|
||||||
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
|
import { ErrorState } from '../../components/ErrorState.jsx'
|
||||||
|
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
|
||||||
|
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
||||||
|
import { RoadmapItemDetailPage } from '../initiative/RoadmapItemDetailPage.jsx'
|
||||||
|
import { recordRecentObject } from '../../utils/recentObjects.js'
|
||||||
|
import { gatePath } from '../../utils/routes.js'
|
||||||
|
|
||||||
|
export function GateObjectPage() {
|
||||||
|
const { gateId } = useParams()
|
||||||
|
const { applyFromInitiative } = useProgramScope()
|
||||||
|
const [gate, setGate] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
getRoadmapItem(gateId)
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setGate(data)
|
||||||
|
applyFromInitiative(data.initiative_id)
|
||||||
|
recordRecentObject({
|
||||||
|
type: 'gate',
|
||||||
|
id: data.id,
|
||||||
|
title: data.title,
|
||||||
|
href: gatePath(data.id),
|
||||||
|
initiativeId: data.initiative_id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err.message)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [gateId, applyFromInitiative])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<LoadingState message="Lade Gate …" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !gate) {
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<ErrorState message={error || 'Gate nicht gefunden.'} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<ScopedInitiativeProvider initiativeId={gate.initiative_id}>
|
||||||
|
<RoadmapItemDetailPage overrideItemId={gateId} />
|
||||||
|
</ScopedInitiativeProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
frontend/src/pages/objects/ProjectObjectPage.jsx
Normal file
89
frontend/src/pages/objects/ProjectObjectPage.jsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import { getProject } from '../../api/projects.js'
|
||||||
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
|
import { ErrorState } from '../../components/ErrorState.jsx'
|
||||||
|
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
|
||||||
|
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
||||||
|
import { ProjectDetailPage } from '../initiative/ProjectDetailPage.jsx'
|
||||||
|
import { recordRecentObject } from '../../utils/recentObjects.js'
|
||||||
|
import { projectPath } from '../../utils/routes.js'
|
||||||
|
|
||||||
|
export function ProjectObjectPage() {
|
||||||
|
const { projectId } = useParams()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const { initiativeId: scopeInitiativeId, applyFromInitiative, applyFromProject } =
|
||||||
|
useProgramScope()
|
||||||
|
const initiativeFromQuery = searchParams.get('initiative') || scopeInitiativeId
|
||||||
|
const [project, setProject] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectId === 'new') {
|
||||||
|
if (initiativeFromQuery) applyFromInitiative(initiativeFromQuery)
|
||||||
|
setProject(null)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
getProject(projectId)
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setProject(data)
|
||||||
|
applyFromProject(data)
|
||||||
|
recordRecentObject({
|
||||||
|
type: 'project',
|
||||||
|
id: data.id,
|
||||||
|
title: data.title,
|
||||||
|
href: projectPath(data.id),
|
||||||
|
initiativeId: data.initiative_id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err.message)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [projectId, initiativeFromQuery, applyFromInitiative, applyFromProject])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<LoadingState message="Lade Projekt …" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<ErrorState message={error} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initiativeId = project?.initiative_id || initiativeFromQuery
|
||||||
|
|
||||||
|
if (projectId === 'new' && !initiativeId) {
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<ErrorState message="Für ein neues Projekt braucht es ein Vorhaben im Scope (?initiative=)." />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<ScopedInitiativeProvider initiativeId={initiativeId}>
|
||||||
|
<ProjectDetailPage />
|
||||||
|
</ScopedInitiativeProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -32,16 +32,19 @@ describe('widgetRegistry', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('viewRegistry', () => {
|
describe('viewRegistry', () => {
|
||||||
it('contains workspace, initiatives and my actions views', () => {
|
it('contains cockpit, work modes and initiatives catalog', () => {
|
||||||
const keys = VIEWS.map((v) => v.key)
|
const keys = VIEWS.map((v) => v.key)
|
||||||
expect(keys).toContain('kairo.workspace')
|
expect(keys).toContain('kairo.cockpit')
|
||||||
|
expect(keys).toContain('kairo.work')
|
||||||
|
expect(keys).toContain('kairo.plan')
|
||||||
|
expect(keys).toContain('kairo.control')
|
||||||
|
expect(keys).toContain('kairo.team')
|
||||||
expect(keys).toContain('kairo.initiatives')
|
expect(keys).toContain('kairo.initiatives')
|
||||||
expect(keys).toContain('kairo.my_actions')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('filters nav views by capability', () => {
|
it('filters nav views by capability', () => {
|
||||||
const nav = getNavViews(['kairo.initiative.read'])
|
const nav = getNavViews(['kairo.initiative.read'])
|
||||||
expect(nav.some((v) => v.key === 'kairo.workspace')).toBe(false)
|
expect(nav.some((v) => v.key === 'kairo.cockpit')).toBe(false)
|
||||||
expect(nav.some((v) => v.key === 'kairo.initiatives')).toBe(true)
|
expect(nav.some((v) => v.key === 'kairo.initiatives')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -52,15 +55,19 @@ describe('appNav', () => {
|
||||||
'kairo.action.read',
|
'kairo.action.read',
|
||||||
'kairo.initiative.read',
|
'kairo.initiative.read',
|
||||||
])
|
])
|
||||||
expect(items.length).toBe(3)
|
expect(items.length).toBeGreaterThanOrEqual(5)
|
||||||
expect(items[0].Icon).toBeTruthy()
|
expect(items[0].Icon).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('highlights initiatives for detail route', () => {
|
it('highlights plan mode for project object route', () => {
|
||||||
const items = getAppNavItems(['kairo.action.read', 'kairo.initiative.read'])
|
const items = getAppNavItems(['kairo.action.read', 'kairo.initiative.read'])
|
||||||
const initiatives = items.find((i) => i.key === 'kairo.initiatives')
|
const plan = items.find((i) => i.key === 'kairo.plan')
|
||||||
expect(
|
expect(isNavItemActive('/projects/abc-123', plan, false)).toBe(true)
|
||||||
isNavItemActive('/initiatives/abc-123', initiatives, false),
|
})
|
||||||
).toBe(true)
|
|
||||||
|
it('highlights work mode for action object route', () => {
|
||||||
|
const items = getAppNavItems(['kairo.action.read', 'kairo.initiative.read'])
|
||||||
|
const work = items.find((i) => i.key === 'kairo.work')
|
||||||
|
expect(isNavItemActive('/actions/abc-123', work, false)).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,74 @@
|
||||||
import { WorkspacePage } from '../pages/WorkspacePage.jsx'
|
import { CockpitPage } from '../pages/modes/CockpitPage.jsx'
|
||||||
|
import { WorkLayout, WorkIndexRedirect } from '../pages/modes/WorkLayout.jsx'
|
||||||
|
import { WorkTodayPage } from '../pages/modes/WorkTodayPage.jsx'
|
||||||
|
import { WorkMinePage } from '../pages/modes/WorkMinePage.jsx'
|
||||||
|
import { PlanLayout, PlanIndexRedirect } from '../pages/modes/PlanLayout.jsx'
|
||||||
|
import { PlanStructurePage } from '../pages/modes/PlanStructurePage.jsx'
|
||||||
|
import { PlanGatesPage } from '../pages/modes/PlanGatesPage.jsx'
|
||||||
|
import { PlanInboxPage } from '../pages/modes/PlanInboxPage.jsx'
|
||||||
|
import { ControlLayout, ControlIndexRedirect } from '../pages/modes/ControlLayout.jsx'
|
||||||
|
import { ControlStatusPage } from '../pages/modes/ControlStatusPage.jsx'
|
||||||
|
import { ControlJourneyPage } from '../pages/modes/ControlJourneyPage.jsx'
|
||||||
|
import { TeamPage } from '../pages/modes/TeamPage.jsx'
|
||||||
import { InitiativesPage } from '../pages/InitiativesPage.jsx'
|
import { InitiativesPage } from '../pages/InitiativesPage.jsx'
|
||||||
import { MyActionsPage } from '../pages/MyActionsPage.jsx'
|
|
||||||
|
|
||||||
/** @type {import('./viewRegistry.js').ViewDefinition[]} */
|
/** @type {import('./viewRegistry.js').ViewDefinition[]} */
|
||||||
export const VIEWS = [
|
export const VIEWS = [
|
||||||
{
|
{
|
||||||
key: 'kairo.workspace',
|
key: 'kairo.cockpit',
|
||||||
path: '/workspace',
|
path: '/cockpit',
|
||||||
title: 'Workspace',
|
title: 'Cockpit',
|
||||||
navLabel: 'Workspace',
|
navLabel: 'Cockpit',
|
||||||
shortLabel: 'Start',
|
shortLabel: 'Cockpit',
|
||||||
|
matchPrefix: '/cockpit',
|
||||||
requiredCapability: 'kairo.action.read',
|
requiredCapability: 'kairo.action.read',
|
||||||
component: WorkspacePage,
|
component: CockpitPage,
|
||||||
|
showInNav: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'kairo.work',
|
||||||
|
path: '/work',
|
||||||
|
title: 'Ausführen',
|
||||||
|
navLabel: 'Ausführen',
|
||||||
|
shortLabel: 'Arbeit',
|
||||||
|
matchPrefix: '/work',
|
||||||
|
objectPrefixes: ['/actions'],
|
||||||
|
requiredCapability: 'kairo.action.read',
|
||||||
|
component: WorkLayout,
|
||||||
|
showInNav: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'kairo.plan',
|
||||||
|
path: '/plan',
|
||||||
|
title: 'Planen',
|
||||||
|
navLabel: 'Planen',
|
||||||
|
shortLabel: 'Plan',
|
||||||
|
matchPrefix: '/plan',
|
||||||
|
objectPrefixes: ['/projects', '/gates'],
|
||||||
|
requiredCapability: 'kairo.initiative.read',
|
||||||
|
component: PlanLayout,
|
||||||
|
showInNav: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'kairo.control',
|
||||||
|
path: '/control',
|
||||||
|
title: 'Kontrolle',
|
||||||
|
navLabel: 'Kontrolle',
|
||||||
|
shortLabel: 'Kontrolle',
|
||||||
|
matchPrefix: '/control',
|
||||||
|
requiredCapability: 'kairo.initiative.read',
|
||||||
|
component: ControlLayout,
|
||||||
|
showInNav: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'kairo.team',
|
||||||
|
path: '/team',
|
||||||
|
title: 'Team',
|
||||||
|
navLabel: 'Team',
|
||||||
|
shortLabel: 'Team',
|
||||||
|
matchPrefix: '/team',
|
||||||
|
requiredCapability: 'kairo.action.read',
|
||||||
|
component: TeamPage,
|
||||||
showInNav: true,
|
showInNav: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -25,15 +82,19 @@ export const VIEWS = [
|
||||||
component: InitiativesPage,
|
component: InitiativesPage,
|
||||||
showInNav: true,
|
showInNav: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'kairo.workspace',
|
||||||
|
path: '/workspace',
|
||||||
|
title: 'Workspace',
|
||||||
|
component: CockpitPage,
|
||||||
|
showInNav: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'kairo.my_actions',
|
key: 'kairo.my_actions',
|
||||||
path: '/my-actions',
|
path: '/my-actions',
|
||||||
title: 'Meine Maßnahmen',
|
title: 'Meine Maßnahmen',
|
||||||
navLabel: 'Meine Maßnahmen',
|
component: WorkMinePage,
|
||||||
shortLabel: 'Maßnahmen',
|
showInNav: false,
|
||||||
requiredCapability: 'kairo.action.read',
|
|
||||||
component: MyActionsPage,
|
|
||||||
showInNav: true,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -45,6 +106,7 @@ export const VIEWS = [
|
||||||
* @property {string} [navLabel]
|
* @property {string} [navLabel]
|
||||||
* @property {string} [shortLabel]
|
* @property {string} [shortLabel]
|
||||||
* @property {string} [matchPrefix]
|
* @property {string} [matchPrefix]
|
||||||
|
* @property {string[]} [objectPrefixes]
|
||||||
* @property {string} [requiredCapability]
|
* @property {string} [requiredCapability]
|
||||||
* @property {import('react').ComponentType<any>} component
|
* @property {import('react').ComponentType<any>} component
|
||||||
* @property {boolean} [showInNav]
|
* @property {boolean} [showInNav]
|
||||||
|
|
@ -59,3 +121,22 @@ export function getNavViews(capabilities) {
|
||||||
export function getViewByKey(key) {
|
export function getViewByKey(key) {
|
||||||
return VIEWS.find((v) => v.key === key)
|
return VIEWS.find((v) => v.key === key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MODE_ROUTE_COMPONENTS = {
|
||||||
|
workToday: WorkTodayPage,
|
||||||
|
workMine: WorkMinePage,
|
||||||
|
planStructure: PlanStructurePage,
|
||||||
|
planGates: PlanGatesPage,
|
||||||
|
planInbox: PlanInboxPage,
|
||||||
|
controlStatus: ControlStatusPage,
|
||||||
|
controlJourney: ControlJourneyPage,
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
WorkLayout,
|
||||||
|
WorkIndexRedirect,
|
||||||
|
PlanLayout,
|
||||||
|
PlanIndexRedirect,
|
||||||
|
ControlLayout,
|
||||||
|
ControlIndexRedirect,
|
||||||
|
}
|
||||||
|
|
|
||||||
293
frontend/src/styles/program-chrome.css
Normal file
293
frontend/src/styles/program-chrome.css
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
/* AP1.9a — Program scope bar, quick jump, mode sub-nav */
|
||||||
|
|
||||||
|
.app-chrome {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chrome--desktop {
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 32px;
|
||||||
|
background: var(--jk-surface);
|
||||||
|
border-bottom: 1px solid var(--jk-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.app-chrome--desktop {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-scope-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.program-scope-bar {
|
||||||
|
width: auto;
|
||||||
|
flex: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-scope-bar__label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--jk-text-subtle);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-scope-bar__trail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-scope-bar__crumb {
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-scope-bar__crumb:hover {
|
||||||
|
color: var(--jk-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-scope-bar__crumb--active {
|
||||||
|
color: var(--jk-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-scope-bar__sep {
|
||||||
|
color: var(--jk-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-scope-bar__catalog {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump__trigger {
|
||||||
|
border: 1px solid var(--jk-border);
|
||||||
|
background: var(--jk-surface-raised);
|
||||||
|
color: var(--jk-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump__trigger:hover {
|
||||||
|
border-color: var(--jk-primary);
|
||||||
|
color: var(--jk-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump__panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
background: var(--jk-surface);
|
||||||
|
border: 1px solid var(--jk-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 40;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump__input {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump__item:hover {
|
||||||
|
background: var(--jk-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump__item-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump__item-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--jk-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump__empty {
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--jk-text-subtle);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump__footer {
|
||||||
|
border-top: 1px solid var(--jk-border);
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recently-opened {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.recently-opened {
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recently-opened__label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--jk-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recently-opened__items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recently-opened__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 180px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--jk-border);
|
||||||
|
background: var(--jk-surface-raised);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--jk-text);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recently-opened__chip:hover {
|
||||||
|
border-color: var(--jk-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recently-opened__type {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--jk-primary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recently-opened__title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-subnav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-subnav__link {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
border: 1px solid var(--jk-border);
|
||||||
|
background: var(--jk-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-subnav__link--active {
|
||||||
|
color: var(--jk-primary);
|
||||||
|
border-color: var(--jk-primary);
|
||||||
|
background: var(--jk-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-picker__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-picker__link {
|
||||||
|
display: block;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--jk-border);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--jk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-picker__link:hover {
|
||||||
|
border-color: var(--jk-primary);
|
||||||
|
background: var(--jk-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-workload-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-workload-list__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--jk-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header--compact {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
34
frontend/src/utils/recentObjects.js
Normal file
34
frontend/src/utils/recentObjects.js
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
const STORAGE_KEY = 'kairo.recentObjects'
|
||||||
|
const MAX_ITEMS = 12
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ type: string, id: string, title: string, href: string, initiativeId?: string }} RecentObject
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @returns {RecentObject[]} */
|
||||||
|
export function loadRecentObjects() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {RecentObject} entry */
|
||||||
|
export function recordRecentObject(entry) {
|
||||||
|
if (!entry?.id || !entry?.href) return
|
||||||
|
const items = loadRecentObjects().filter(
|
||||||
|
(item) => !(item.type === entry.type && item.id === entry.id),
|
||||||
|
)
|
||||||
|
items.unshift({
|
||||||
|
type: entry.type,
|
||||||
|
id: entry.id,
|
||||||
|
title: entry.title || entry.id,
|
||||||
|
href: entry.href,
|
||||||
|
initiativeId: entry.initiativeId || '',
|
||||||
|
})
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(items.slice(0, MAX_ITEMS)))
|
||||||
|
}
|
||||||
57
frontend/src/utils/routes.js
Normal file
57
frontend/src/utils/routes.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
/** @typedef {{ initiativeId?: string, projectId?: string }} ProgramScope */
|
||||||
|
|
||||||
|
export const MODE_META = {
|
||||||
|
cockpit: {
|
||||||
|
key: 'cockpit',
|
||||||
|
title: 'Cockpit',
|
||||||
|
lead: 'Portfolio-Überblick — welches Vorhaben braucht Aufmerksamkeit?',
|
||||||
|
},
|
||||||
|
work: {
|
||||||
|
key: 'work',
|
||||||
|
title: 'Ausführen',
|
||||||
|
lead: 'Committete Arbeit — was ist jetzt dran?',
|
||||||
|
},
|
||||||
|
plan: {
|
||||||
|
key: 'plan',
|
||||||
|
title: 'Planen',
|
||||||
|
lead: 'Struktur, Gates und Eingang — was ist geplant?',
|
||||||
|
},
|
||||||
|
control: {
|
||||||
|
key: 'control',
|
||||||
|
title: 'Kontrolle',
|
||||||
|
lead: 'Steuerung und Lagebild — wo steht das Vorhaben?',
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
key: 'team',
|
||||||
|
title: 'Team',
|
||||||
|
lead: 'Auslastung und Zuweisungen über alle Vorhaben.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} path
|
||||||
|
* @param {ProgramScope} [scope]
|
||||||
|
*/
|
||||||
|
export function scopedPath(path, scope = {}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (scope.initiativeId) params.set('initiative', scope.initiativeId)
|
||||||
|
if (scope.projectId) params.set('project', scope.projectId)
|
||||||
|
const qs = params.toString()
|
||||||
|
return qs ? `${path}?${qs}` : path
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionPath(actionId) {
|
||||||
|
return `/actions/${actionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectPath(projectId) {
|
||||||
|
return `/projects/${projectId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gatePath(gateId) {
|
||||||
|
return `/gates/${gateId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initiativeCatalogPath() {
|
||||||
|
return '/initiatives'
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
|
|
||||||
import { ATTENTION_KIND_LABELS } from '../constants/operating.js'
|
import { ATTENTION_KIND_LABELS } from '../constants/operating.js'
|
||||||
|
import { actionPath, scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
const SEVERITY_LABELS = {
|
const SEVERITY_LABELS = {
|
||||||
critical: 'Kritisch',
|
critical: 'Kritisch',
|
||||||
|
|
@ -15,11 +16,11 @@ const SEVERITY_LABELS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
function attentionLink(item) {
|
function attentionLink(item) {
|
||||||
if (item.initiative_id && item.action_id) {
|
if (item.action_id) {
|
||||||
return `/initiatives/${item.initiative_id}`
|
return actionPath(item.action_id)
|
||||||
}
|
}
|
||||||
if (item.initiative_id) {
|
if (item.initiative_id) {
|
||||||
return `/initiatives/${item.initiative_id}`
|
return scopedPath('/control/status', { initiativeId: item.initiative_id })
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ActionList } from '../components/ActionList.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
import { actionPath } from '../utils/routes.js'
|
||||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
|
||||||
export function BlockedActionsWidget() {
|
export function BlockedActionsWidget() {
|
||||||
|
|
@ -46,13 +47,13 @@ export function BlockedActionsWidget() {
|
||||||
<ActionList
|
<ActionList
|
||||||
actions={actions}
|
actions={actions}
|
||||||
showInitiative
|
showInitiative
|
||||||
getManageLink={(a) => `/initiatives/${a.initiative_id}`}
|
getManageLink={(a) => actionPath(a.id)}
|
||||||
empty={<EmptyState message="Keine blockierten Maßnahmen." />}
|
empty={<EmptyState message="Keine blockierten Maßnahmen." />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && actions.length > 0 && (
|
{!loading && !error && actions.length > 0 && (
|
||||||
<p className="muted mt-sm">
|
<p className="muted mt-sm">
|
||||||
<Link to="/my-actions" className="link-inline">
|
<Link to="/work/mine" className="link-inline">
|
||||||
In „Meine Maßnahmen“ bearbeiten
|
In „Meine Maßnahmen“ bearbeiten
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
import { scopedPath } from '../utils/routes.js'
|
||||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
|
||||||
export function InitiativePortfolioWidget() {
|
export function InitiativePortfolioWidget() {
|
||||||
|
|
@ -51,7 +52,7 @@ export function InitiativePortfolioWidget() {
|
||||||
{initiatives.map((item) => (
|
{initiatives.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
to={`/initiatives/${item.id}`}
|
to={scopedPath('/control/status', { initiativeId: item.id })}
|
||||||
className="initiative-portfolio-card card"
|
className="initiative-portfolio-card card"
|
||||||
>
|
>
|
||||||
<h3 className="initiative-portfolio-card-title">{item.title}</h3>
|
<h3 className="initiative-portfolio-card-title">{item.title}</h3>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
import { scopedPath } from '../utils/routes.js'
|
||||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
|
||||||
export function InitiativesWidget() {
|
export function InitiativesWidget() {
|
||||||
|
|
@ -48,7 +49,7 @@ export function InitiativesWidget() {
|
||||||
<ul className="item-list">
|
<ul className="item-list">
|
||||||
{initiatives.map((item) => (
|
{initiatives.map((item) => (
|
||||||
<li key={item.id} className="list-item card-list-item">
|
<li key={item.id} className="list-item card-list-item">
|
||||||
<Link to={`/initiatives/${item.id}`} className="list-item-link">
|
<Link to={scopedPath('/control/status', { initiativeId: item.id })} className="list-item-link">
|
||||||
<strong>{item.title}</strong>
|
<strong>{item.title}</strong>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="list-item-meta">
|
<div className="list-item-meta">
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ActionList } from '../components/ActionList.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
import { actionPath } from '../utils/routes.js'
|
||||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
|
||||||
export function MyOpenActionsWidget() {
|
export function MyOpenActionsWidget() {
|
||||||
|
|
@ -40,7 +41,7 @@ export function MyOpenActionsWidget() {
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
actions.length > 0 ? (
|
actions.length > 0 ? (
|
||||||
<Link to="/my-actions" className="link-inline">
|
<Link to="/work/mine" className="link-inline">
|
||||||
Alle meine Maßnahmen →
|
Alle meine Maßnahmen →
|
||||||
</Link>
|
</Link>
|
||||||
) : null
|
) : null
|
||||||
|
|
@ -52,7 +53,7 @@ export function MyOpenActionsWidget() {
|
||||||
<ActionList
|
<ActionList
|
||||||
actions={actions}
|
actions={actions}
|
||||||
showInitiative
|
showInitiative
|
||||||
getManageLink={(a) => `/initiatives/${a.initiative_id}`}
|
getManageLink={(a) => actionPath(a.id)}
|
||||||
empty={
|
empty={
|
||||||
<EmptyState message="Keine offenen Maßnahmen. Du kannst eine neue Maßnahme in einem Vorhaben anlegen." />
|
<EmptyState message="Keine offenen Maßnahmen. Du kannst eine neue Maßnahme in einem Vorhaben anlegen." />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { NEXT_ACTION_KIND_LABELS } from '../constants/operating.js'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
import { actionPath, scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
|
function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
|
|
@ -33,16 +33,16 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="list-item-meta">
|
<div className="list-item-meta">
|
||||||
{item.action_id && initiativeId && (
|
{item.action_id && (
|
||||||
<Link
|
<Link to={actionPath(item.action_id)} className="btn btn-primary btn-sm">
|
||||||
to={`/initiatives/${initiativeId}/actions/${item.action_id}`}
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
>
|
|
||||||
Arbeitspaket
|
Arbeitspaket
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{showInitiativeLink && item.initiative_id && !initiativeId && (
|
{showInitiativeLink && item.initiative_id && !initiativeId && (
|
||||||
<Link to={`/initiatives/${item.initiative_id}`} className="btn btn-primary btn-sm">
|
<Link
|
||||||
|
to={scopedPath('/control/status', { initiativeId: item.initiative_id })}
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
Zum Vorhaben
|
Zum Vorhaben
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
import { actionPath, scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
function formatDue(iso) {
|
function formatDue(iso) {
|
||||||
if (!iso) return null
|
if (!iso) return null
|
||||||
|
|
@ -55,7 +55,7 @@ export function WorkspaceTodayWidget() {
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
actions.length > 0 ? (
|
actions.length > 0 ? (
|
||||||
<Link to="/my-actions" className="link-inline">
|
<Link to="/work/mine" className="link-inline">
|
||||||
Alle meine Maßnahmen →
|
Alle meine Maßnahmen →
|
||||||
</Link>
|
</Link>
|
||||||
) : null
|
) : null
|
||||||
|
|
@ -85,7 +85,7 @@ export function WorkspaceTodayWidget() {
|
||||||
<div className="list-item-meta">
|
<div className="list-item-meta">
|
||||||
<StatusBadge status={action.status} />
|
<StatusBadge status={action.status} />
|
||||||
<PriorityBadge priority={action.priority} />
|
<PriorityBadge priority={action.priority} />
|
||||||
<Link to={`/initiatives/${action.initiative_id}`} className="btn btn-secondary btn-sm">
|
<Link to={actionPath(action.id)} className="btn btn-secondary btn-sm">
|
||||||
Öffnen
|
Öffnen
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user