From e42edd86fa53e3aeaf519103e605828e835ae3b2 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 8 Jul 2026 12:55:37 +0200 Subject: [PATCH] AP1.9a: Arbeitsmodi-Shell mit Scope, Objekt-Routen und Legacy-Redirects. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/version.py | 2 +- ...DP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md | 146 ++++++ .../Kairo_Corrected_MVP_Roadmap_v0.2.md | 50 +- .../Kairo_PM_Frontend_UI_Concept_v0.1.md | 444 ++++++++++++++++++ frontend/src/App.jsx | 122 +++-- frontend/src/app.css | 1 + .../src/components/InitiativeNavCards.jsx | 21 +- frontend/src/components/ModeShell.jsx | 16 + frontend/src/components/ModeSubNav.jsx | 26 + frontend/src/components/ProgramScopeBar.jsx | 62 +++ frontend/src/components/ProjectsSection.jsx | 4 +- frontend/src/components/QuickJump.jsx | 179 +++++++ frontend/src/components/RecentlyOpened.jsx | 33 ++ .../src/components/RequireInitiativeScope.jsx | 62 +++ .../src/components/RoadmapPlanSection.jsx | 3 +- frontend/src/config/appNav.js | 31 +- .../context/InitiativeOperationsContext.jsx | 7 +- frontend/src/context/ProgramScopeContext.jsx | 164 +++++++ frontend/src/layout/AppLayout.jsx | 11 + .../src/layout/ScopedInitiativeProvider.jsx | 13 + frontend/src/pages/ActionDetailPage.jsx | 24 +- frontend/src/pages/InitiativesPage.jsx | 3 +- frontend/src/pages/LegacyRedirects.jsx | 61 +++ frontend/src/pages/MyActionsPage.jsx | 72 ++- .../initiative/InitiativeOverviewPage.jsx | 3 +- .../pages/initiative/InitiativePlanPage.jsx | 5 +- .../pages/initiative/ProjectDetailPage.jsx | 21 +- .../initiative/RoadmapItemDetailPage.jsx | 11 +- frontend/src/pages/modes/CockpitPage.jsx | 84 ++++ .../src/pages/modes/ControlJourneyPage.jsx | 13 + frontend/src/pages/modes/ControlLayout.jsx | 22 + .../src/pages/modes/ControlStatusPage.jsx | 13 + frontend/src/pages/modes/PlanGatesPage.jsx | 13 + frontend/src/pages/modes/PlanInboxPage.jsx | 13 + frontend/src/pages/modes/PlanLayout.jsx | 23 + .../src/pages/modes/PlanStructurePage.jsx | 40 ++ frontend/src/pages/modes/TeamPage.jsx | 65 +++ frontend/src/pages/modes/WorkLayout.jsx | 22 + frontend/src/pages/modes/WorkMinePage.jsx | 5 + frontend/src/pages/modes/WorkTodayPage.jsx | 9 + frontend/src/pages/objects/GateObjectPage.jsx | 70 +++ .../src/pages/objects/ProjectObjectPage.jsx | 89 ++++ frontend/src/registry/registry.test.js | 27 +- frontend/src/registry/viewRegistry.js | 107 ++++- frontend/src/styles/program-chrome.css | 293 ++++++++++++ frontend/src/utils/recentObjects.js | 34 ++ frontend/src/utils/routes.js | 57 +++ frontend/src/widgets/AttentionWidget.jsx | 7 +- frontend/src/widgets/BlockedActionsWidget.jsx | 5 +- .../src/widgets/InitiativePortfolioWidget.jsx | 3 +- frontend/src/widgets/InitiativesWidget.jsx | 3 +- frontend/src/widgets/MyOpenActionsWidget.jsx | 5 +- frontend/src/widgets/NextActionWidget.jsx | 14 +- frontend/src/widgets/WorkspaceTodayWidget.jsx | 6 +- 54 files changed, 2489 insertions(+), 150 deletions(-) create mode 100644 docs/architecture/ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md create mode 100644 docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md create mode 100644 frontend/src/components/ModeShell.jsx create mode 100644 frontend/src/components/ModeSubNav.jsx create mode 100644 frontend/src/components/ProgramScopeBar.jsx create mode 100644 frontend/src/components/QuickJump.jsx create mode 100644 frontend/src/components/RecentlyOpened.jsx create mode 100644 frontend/src/components/RequireInitiativeScope.jsx create mode 100644 frontend/src/context/ProgramScopeContext.jsx create mode 100644 frontend/src/layout/ScopedInitiativeProvider.jsx create mode 100644 frontend/src/pages/LegacyRedirects.jsx create mode 100644 frontend/src/pages/modes/CockpitPage.jsx create mode 100644 frontend/src/pages/modes/ControlJourneyPage.jsx create mode 100644 frontend/src/pages/modes/ControlLayout.jsx create mode 100644 frontend/src/pages/modes/ControlStatusPage.jsx create mode 100644 frontend/src/pages/modes/PlanGatesPage.jsx create mode 100644 frontend/src/pages/modes/PlanInboxPage.jsx create mode 100644 frontend/src/pages/modes/PlanLayout.jsx create mode 100644 frontend/src/pages/modes/PlanStructurePage.jsx create mode 100644 frontend/src/pages/modes/TeamPage.jsx create mode 100644 frontend/src/pages/modes/WorkLayout.jsx create mode 100644 frontend/src/pages/modes/WorkMinePage.jsx create mode 100644 frontend/src/pages/modes/WorkTodayPage.jsx create mode 100644 frontend/src/pages/objects/GateObjectPage.jsx create mode 100644 frontend/src/pages/objects/ProjectObjectPage.jsx create mode 100644 frontend/src/styles/program-chrome.css create mode 100644 frontend/src/utils/recentObjects.js create mode 100644 frontend/src/utils/routes.js diff --git a/backend/version.py b/backend/version.py index 15e34a5..856b6ae 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.15.1-ap1.5e" +APP_VERSION = "0.16.0-ap1.9a" DB_SCHEMA_VERSION = "014" APP_NAME = "jinkendo-kairo" diff --git a/docs/architecture/ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md b/docs/architecture/ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md new file mode 100644 index 0000000..ac85442 --- /dev/null +++ b/docs/architecture/ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md @@ -0,0 +1,146 @@ +# ADP — AP1.9 PM Work Modes & Frontend IA Reset v0.1 + +**Status:** vorgeschlagen (PO-Freigabe ausstehend) +**Stand:** 2026-07-08 +**Autor:** Product/Architektur (Cursor-Session, PM-Feedback) +**UI-Konzept:** `docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md` +**Auslöser:** Frontend strukturiert nach Objekten (Vorhaben-Tabs), nicht nach Programmmanager-Arbeitslogik + +--- + +## Problem + +Nach AP1.2c–AP1.6 existiert ein IA-Skeleton (Workspace, Initiative-Übersicht, Unterseiten), aber: + +1. **Alle Arbeitsflächen hängen unter `/initiatives/:id/...`** — Querschnitt (alle Tasks, alle Blocker, Next Step portfolio-weit) fehlt als eigene Fläche. +2. Tab **Ausführung** vermischt Struktur (Projects), Ist (Actions), Blocker — widerspricht Plan/Ist-Trennung und PM-Use-Cases. +3. **Workspace** ist Widget-Sammlung ohne klaren Modus „Cockpit“. +4. Weitere UI-Patches auf Unterseiten **verstärken** das Anti-Pattern (Vision §7, Canonical OM §8.1). + +Ohne Entscheidung wird jede weitere Feature-Arbeit (Tasks, KI, Portfolio-Prio) erneut „irgendwo unter Vorhaben“ landen. + +--- + +## Betroffene Regeln + +| Dokument | Regel | +|----------|--------| +| Vision v0.2 §7 | Drei Ebenen: Workspace → Initiative-Übersicht → Unterseiten | +| Vision v0.2 §7.1–7.2 | Workspace = Querschnitt; Initiative-Übersicht = Steuerung im Alltag | +| Canonical OM §8.1–8.4 | Übersicht entscheidet; getrennte Hauptflächen Plan / Ausführung / Eingang / Journey | +| ADP PO Product Layering | Gates ≠ operativer Plan; Ausführung = Ist-Kern | +| Principle Gate / Sprint0 | Kein weiteres InitiativeDetail-Omnibus | + +**Erweiterung (dieses ADP):** Vision-Ebenen bleiben fachlich; **Präsentation** wird um **App-weite Arbeitsmodi** ergänzt. Unterseiten-Inhalte **migrieren in Modi + Scope**, nicht in tiefere Verschachtelung. + +--- + +## Optionen + +| Option | Kurzbeschreibung | Pro | Contra | +|--------|------------------|-----|--------| +| **A** | Status quo + weiter patchen | Kein Migrationsaufwand | PM-Use-Cases bleiben unbedienbar; KI ohne Einstieg | +| **B** | Nur Widgets auf Workspace erweitern | Schnell | Kein Plan/Kontrolle-Modus; Vorhaben-Tabs bleiben | +| **C** | **Fünf App-Modi + Scope-Selector** (UI Concept v0.1) | Entspricht PM-Logik; Querschnitt; Agent-ready | Einmaliger IA-Umbau; Redirects nötig | + +--- + +## Entscheidung (Empfehlung: Option C) + +### 1. App-Hauptnavigation = Arbeitsmodi + +| Modus | Route | Ersetzt / absorbiert | +|-------|-------|----------------------| +| Cockpit | `/cockpit` | `/workspace`, Portfolio-Widgets | +| Ausführen | `/work/*` | `/my-actions`, Tab Ausführung (Ist-Teil) | +| Planen | `/plan/*` | Tabs Plan, Eingang, Project-Struktur | +| Kontrolle | `/control/*` | Initiative-Steuerung, Journey, Plan-Ist-Aggregate | +| Team | `/team` | Workload (neu) | + +**Objekt-Routen (first-class, parallel zur Modus-Nav):** `/actions/:id`, `/projects/:id`, `/gates/:id`, `/tasks/:id` (AP1.5d). Beim Öffnen: Scope und aktiver Modus aus Objektdaten ableiten; Redirects von legacy nested URLs. + +Vorhaben-Katalog: `/initiatives` — **Scope setzen**, nicht primärer Arbeitscontainer. + +### 2. Scope-Selector (global) + +- Portfolio | Initiative | optional Project +- URL: `?initiative=&project=` (Query) — **Scope Lock** bis ADP v0.2 +- React Context: `ProgramScopeProvider` in `AppLayout` + +### 3. InitiativeSubNav — deprecate + +- Tabs `Steuerung | Zielzustände | Ausführung | Eingang | Nachvollziehbarkeit` werden **entfernt** (nach AP1.9a Redirects) +- Inhalt wandert in Modi (siehe UI Concept §11) + +### 4. Harte Guardrails (Scope Lock) + +| Verboten | Erlaubt | +|----------|---------| +| Neue Sektionen auf `InitiativeExecutionPage` | Hotfix bis AP1.9a | +| Inline-CRUD auf Modus-Übersichten | Detail-Route / Modal | +| Dritte parallele Navigationshierarchie | Scope + Modus nur | +| Project-Lifecycle / Steering pro Project | ADP Recursive Containers | + +### 5. Implementierungspakete + +```text +AP1.9a Shell, Routen, Scope, Redirects, Platzhalter-Screens +AP1.9b Cockpit + Work/Heute (AP-Querschnitt) +AP1.9c Control (Snapshot, Journey, Plan-Ist) +AP1.9d Plan (Structure, Gates, Inbox) +AP1.9e Team (Workload) +``` + +AP1.5d (Tasks) **füllt** `/work` — ändert IA nicht. +AP1.8 (Portfolio-Prio) **erweitert** `/cockpit`. +AP1.7 (Agent API) **mappt** auf Modus-Endpunkte. + +--- + +## Risiko + +| Risiko | Mitigation | +|--------|------------| +| Große Migration / gebrochene Bookmarks | Redirect-Tabelle, 6 Monate | +| Doppelpflege alt/neu | Freeze auf alten Tabs ab AP1.9a Start | +| Scope in URL vs. State | Ein Provider, Tests für Deep-Links | +| Überengineering Mobile | 9a nur Desktop+Mobile Nav-Parität; Detail polish later | + +--- + +## Rückbaubarkeit + +- Modi-Routen isoliert unter `frontend/src/modes/` (neu) +- Alte Initiative-Routen als Redirect-Layer bis Entfernung +- Scope-Provider optional abschaltbar für Tests +- ADP v0.2 kann Modus-Namen anpassen ohne OM-Änderung + +--- + +## Auswirkung auf Roadmap + +| Paket | Änderung | +|-------|----------| +| AP1.5d | Bleibt; liefert Task-Daten für `/work` | +| AP1.4c Gate-UI | Gehört in `/plan/gates`, nicht Initiative-Tab | +| AP1.8 | Cockpit-Feature | +| AP1.7 | Nach AP1.9b+c (Leseschnittstellen stabil) | + +**Nächstes Code-Paket nach PO-Freigabe:** AP1.9a (Shell) — **nicht** weiter AP1.5e-UI. + +--- + +## PO-Freigabe + +- [ ] Option C angenommen +- [ ] UI Concept v0.1 bestätigt +- [ ] AP1.9a–e Reihenfolge OK +- [ ] InitiativeSubNav-Abschaffung OK + +--- + +## Referenzen + +- `docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md` +- `docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md` (Abschnitt AP1.9) +- `docs/product/Kairo_Implementation_Truth_Table_v0.1.md` diff --git a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md index c052a5c..6fe4c5a 100644 --- a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md +++ b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md @@ -52,7 +52,24 @@ Siehe `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`. Siehe `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`. -**Reihenfolge:** AP1.5c → AP1.6 → AP1.5d +**Reihenfolge:** AP1.5c → AP1.6 → **AP1.9 (PM Work Modes IA)** → AP1.5d + +--- + +## 1.3 PO-Entscheidung 2026-07-08 — PM Work Modes (Frontend IA) + +**Auslöser:** Programmmanager-Feedback — Vorhaben-Unterseiten decken Arbeitslogik nicht ab; Querschnitt fehlt. + +| Entscheidung | | +|--------------|--| +| App-Navigation = **Arbeitsmodi** (Cockpit, Ausführen, Planen, Kontrolle, Team) | vorgeschlagen | +| Scope = Filter (Portfolio / Vorhaben / Projekt), nicht Navigationstiefe | vorgeschlagen | +| Initiative-Tabs (`InitiativeSubNav`) abschaffen | vorgeschlagen, mit Redirects | +| Kein weiteres UI-Patching auf Ausführungs-Tab | Scope Lock ab AP1.9a | + +Siehe `Kairo_PM_Frontend_UI_Concept_v0.1.md`, `ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md`. + +**Reihenfolge Code:** AP1.9a (Shell) → 9b–9e (Modus-Inhalte); AP1.5d parallel nach 9b wo sinnvoll. --- @@ -69,8 +86,9 @@ Phase F Gate-Backend AP1.4 + AP1.4b ✓ (Backend); UI minimal Phase G Ist-Hierarchie AP1.5 ✓ Phase G2 Rekursive Container AP1.5c–5d ✓ 5c / → 5d nach AP1.6 Phase H Plan/Ist-Verknüpfung AP1.6 ✓ -Phase H2 Rekursive Tasks AP1.5d ← **NÄCHSTES CODE** -Phase I Gate-Graph AP1.4d–4e nach AP1.6 +Phase H2 PM Work Modes IA AP1.9 ← **NÄCHSTES CODE** (PO 2026-07-08) +Phase H3 Rekursive Tasks AP1.5d nach AP1.9b +Phase I Gate-Graph AP1.4d–4e nach AP1.9d Phase J Portfolio & Kontext AP1.8 geplant Phase K Agent Interface AP1.7 geplant (unverändert) Phase L Validation AP0.10d (laufend PO) parallel @@ -94,6 +112,8 @@ Phase L Validation AP0.10d (laufend PO) parallel | AP1.4b | 0.13.1-ap1.4b | Gate-Checkliste Backend + Detail-Route | | AP1.5c | 0.14.1-ap1.5c | Rekursive Projects | | AP1.6 | 0.15.0-ap1.6 | Plan/Ist, Journey, Gate-Beiträge | +| AP1.5e | 0.15.1-ap1.5e | Projekt-Detail, Lifecycle-Chip (Übergang — ersetzt durch AP1.9) | +| DOC PM UI | 2026-07-08 | PM Frontend UI Concept v0.1, ADP AP1.9 | | DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset | | DOC Container | 2026-07-06 | ADP Recursive Containers & Execution Granularity | @@ -156,6 +176,26 @@ Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline. --- +### AP1.9 — PM Work Modes & Frontend IA Reset ← **NÄCHSTES CODE** + +**Ziel:** App-weite Arbeitsmodi statt Vorhaben-Tab-Omnibus; Querschnitt für PM-Use-Cases. + +| Teil | Scope | +|------|--------| +| **9a** | App-Shell, Routen `/cockpit`, `/work`, `/plan`, `/control`, `/team`, Scope-Provider, Redirects | +| **9b** | Cockpit + Ausführen/Heute (AP-Querschnitt) | +| **9c** | Kontrolle: Snapshot, Journey, Plan-Ist-Aggregate | +| **9d** | Planen: Struktur, Gates, Inbox (Migration von Initiative-Tabs) | +| **9e** | Team: Workload, Zuweisungsübersicht | + +**ADP:** `ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md` +**UI Concept:** `Kairo_PM_Frontend_UI_Concept_v0.1.md` +**Version-Ziel:** `0.16.0-ap1.9a` (Shell), dann inkrementell + +**Explizit nicht:** Weitere Features unter `InitiativeExecutionPage` / `InitiativeSubNav`. + +--- + ### AP1.5d — Rekursive Tasks & Roll-up **Ziel:** Task-Baum (`parent_task_id`); Mensch UI max. ~3 Ebenen; Agent tiefer. @@ -169,8 +209,8 @@ Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline. | **Kein** nested Action (Default) | **ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md` -**Nach AP1.6** -**Version:** `0.15.1-ap1.5d` +**Nach AP1.9b** (Tasks füllen `/work`, IA bleibt) +**Version:** `0.16.x-ap1.5d` --- diff --git a/docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md b/docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md new file mode 100644 index 0000000..0298b2c --- /dev/null +++ b/docs/product/Kairo_PM_Frontend_UI_Concept_v0.1.md @@ -0,0 +1,444 @@ +# Kairo — PM Frontend UI Concept v0.1 + +**Status:** PO-Arbeitsentwurf (Entscheidung ausstehend) +**Stand:** 2026-07-08 +**Auslöser:** Programmmanager-Feedback — Objekt-Hierarchie ohne Arbeitslogik; Vorhaben-Unterseiten als Sackgasse +**Bezug:** Vision v0.2 §7, Canonical OM v0.2 §8, `ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md` +**Ersetzt als UI-Leitbild:** Ad-hoc-Layout unter `InitiativeExecutionPage` / Tab-Omnibus + +--- + +## 1. Problem in einem Satz + +Kairo hat **Objekte** (Vorhaben, Projekt, Arbeitspaket) und **Tabs**, aber keine **Arbeitsmodi** — deshalb findet ein Programmmanager weder Querschnitt noch Planung noch Kontrolle, und KI/Agenten haben keinen kanonischen Einstieg. + +--- + +## 2. Leitprinzipien (verbindlich für UI) + +| # | Prinzip | Konsequenz | +|---|---------|------------| +| P1 | **Modi für Überblick, Objekte direkt** | Modi beantworten Querschnittsfragen; **bekannte** APs/Projekte/Gates sind per **Deep Link** sofort erreichbar — ohne vorher Modus wählen. | +| P2 | **Scope ist Filter, kein Gefängnis** | Portfolio, ein Vorhaben oder ein Projekt sind **Filter** auf dieselben Modi — nicht separate Welten. | +| P3 | **Querschnitt auf Top-Level** | „Alle meine Tasks“, „Was klemmt?“, „Nächster Schritt?“ leben **global**, nicht unter `/initiatives/:id/...`. | +| P4 | **Übersicht entscheidet, Detail pflegt** | Modi zeigen Steuerungsantworten; Anlage/Bearbeitung nur auf **Objekt-Detail-Routen** oder Modal — nie als dauerhafte Inline-CRUD-Wand. | +| P5 | **Plan ≠ Ausführung ≠ Kontrolle** | Drei kognitive Tätigkeiten — drei Flächen. Nicht in einem Tab stapeln. | +| P6 | **Methodenneutral** | Gates optional; flache Vorhaben ohne Roadmap müssen in jedem Modus sinnvoll leer oder reduziert sein (`generic_operating`). | +| P7 | **Gleiche Logik für Mensch und Agent** | Jeder Modus mappt auf lesbare/schriftbare API-Oberflächen (`steering/`, Workspace Data Layer). | +| P8 | **Stabile Objekt-URLs** | `/actions/:id`, `/projects/:id` usw. sind bookmarkbar; Scope und Modus werden aus dem Objekt **abgeleitet**, nicht manuell vorher gesetzt. | + +--- + +## 3. Navigationsmodell — drei Ebenen + +```text + ┌─────────────────────────────────────────────────────────────┐ + │ EBENE 1 — MODUS (Sidebar): Querschnitt & Arbeitsintention │ + │ Cockpit │ Ausführen │ Planen │ Kontrolle │ Team │ + └─────────────────────────────────────────────────────────────┘ + + + ┌─────────────────────────────────────────────────────────────┐ + │ EBENE 2 — SCOPE (Header): Filter auf Modus-Inhalte │ + │ Portfolio > Vorhaben X > [ optional Projekt Y ] │ + └─────────────────────────────────────────────────────────────┘ + + + ┌─────────────────────────────────────────────────────────────┐ + │ EBENE 3 — OBJEKTFokus (Route): Konkretes Ding bearbeiten │ + │ /actions/:id /projects/:id /gates/:id … │ + └─────────────────────────────────────────────────────────────┘ +``` + +**Modus allein reicht nicht** — du springst oft **direkt** auf ein Arbeitspaket oder Projekt (Link, Lesezeichen, Next-Action-Klick, Agent). +**Modus allein reicht auch nicht ohne Querschnitt** — „Was ist heute dran über alles?“ braucht `/work/heute` ohne vorher ein Vorhaben zu öffnen. + +| Ebene | Wann | Beispiel | +|-------|------|----------| +| **Modus** | Ich weiß noch nicht *welches* Objekt, aber *welche Aufgabe* | „Heute“, „Was klemmt?“, „Planen“ | +| **Scope** | Ich will alles in einem Vorhaben/Projekt sehen | Filter auf Queue, Kontrolle, Plan | +| **Objekt** | Ich kenne die ID / den Namen | AP „API-Auth“, Projekt „Phase 2“ | + +**Regel:** Objekt-Route hat **Vorrang**. Beim Öffnen von `/actions/:id` setzt die App automatisch Scope (Initiative, ggf. Project) und markiert den passenden Modus in der Sidebar — du musst nicht „erst Ausführen klicken“. + +Wechsel des Modus **behält** Scope, solange sinnvoll. Wechsel des Objekts **aktualisiert** Scope aus den Objektdaten. + +--- + +## 4. Die fünf Arbeitsmodi (Product Language) + +### 4.1 Cockpit — *Wo stehe ich im Portfolio?* + +**Leitfrage:** Welches Vorhaben braucht Aufmerksamkeit — und was ist programmweit als Nächstes dran? + +| Inhalt | Quelle (heute / geplant) | +|--------|---------------------------| +| Portfolio-Kacheln mit Lifecycle, Signal, Prio | `InitiativePortfolioWidget`, AP1.8 Prio | +| Attention / Showstopper quer | `AttentionWidget`, Snapshot-Signale | +| Next Action (Portfolio-Scope) | `NextActionWidget` scope=portfolio | +| Kurz-KPIs (offen, blockiert, Gates at risk) | `WorkspaceSummaryWidget` | + +**Nicht hier:** Gate-CRUD, Projektbaum, Sprint-Planung im Detail. + +**Route:** `/cockpit` (Alias `/workspace` → Redirect) + +--- + +### 4.2 Ausführen — *Was mache ich jetzt konkret?* + +**Leitfrage:** Welche committete Arbeit (Arbeitspaket / Task) ist dran — **über alle Vorhaben und Projekte**? + +| Inhalt | Beschreibung | +|--------|--------------| +| **Heute** | Mir zugewiesen, überfällig zuerst, Next-Action-Ranking | +| **Meine Queue** | Alle offenen Tasks/APs (filterbar) | +| **Nach Vorhaben / Projekt** | Gruppierung optional — kein Navigationszwang | +| Detail-Drill | Link zu AP-/Task-Detail | + +**Nicht hier:** Programmstruktur anlegen, Gates definieren, Portfolio-Priorität setzen. + +**Route:** `/work` (+ Unterrouten `/work/today`, `/work/mine`, `/work/queue`) + +**Abhängigkeit:** Task-Querschnitt braucht AP1.5d; MVP von 4.2 startet mit **Arbeitspaketen**, Tasks folgen ohne IA-Wechsel. + +--- + +### 4.3 Planen — *Wie soll das Programm aufgebaut sein?* + +**Leitfrage:** Struktur, Gates, Eingang — was ist geplant, noch nicht committet? + +| Zone | Inhalt | +|------|--------| +| **Struktur** | Project-Baum (Container), optional flach | +| **Zielzustände** | Gates / RoadmapItems, Kriterien, Abhängigkeiten | +| **Eingang** | Backlog, Triage, Convert-to-Action | + +Planen ist **scope-pflichtig auf mindestens ein Vorhaben** (Struktur hängt an Initiative). Portfolio-Scope zeigt **Planungs-Inbox**: „Vorhaben ohne Struktur“, „Gates ohne Kriterien“ — Einstieg, nicht Vollplanung. + +**Nicht hier:** Tagesausführung, Blocker-Status aller APs, Team-Workload. + +**Route:** `/plan` (+ `/plan/structure`, `/plan/gates`, `/plan/inbox`) + +**Migration:** Heutige Tabs `plan`, `inbox` und Projektbaum aus `execution` **wandern hierher**. + +--- + +### 4.4 Kontrolle — *Erreichen wir die Ziele? Was blockiert den Fortschritt?* + +**Leitfrage:** Plan vs. Ist, Zielerreichung, Risiken, kritischer Pfad, Showstopper. + +| Zone | Inhalt | +|------|--------| +| **Programm-Status** | Lifecycle, Snapshot, Signale (pro Scope) | +| **Plan vs. Ist** | Gate-Beiträge, offene Kriterien, Journey | +| **Roadblocker** | Blockierte APs, offene Blocker, Gates at risk | +| **Kritischer Pfad** | Gate-Graph + Ist-Blocker (AP1.4d+, progressive) | + +**Nicht hier:** Neue APs anlegen, Backlog triagieren. + +**Route:** `/control` (+ `/control/status`, `/control/plan-ist`, `/control/journey`) + +**Migration:** Teile von Initiative-Übersicht + Journey + Gate-Detail-Contributions. + +--- + +### 4.5 Team — *Wer arbeitet an was?* + +**Leitfrage:** Zuweisungen, Kapazität, Delegation. + +| Inhalt | Quelle | +|--------|--------| +| Actor-Workload | `GET /api/workspace/actors/workload` | +| Offene Zuweisungen ohne Fortschritt | Snapshot / Assignments | +| Delegation (später) | Bulk-Zuweisung, Umhängen | + +**Route:** `/team` + +--- + +## 5. Vorhaben-Katalog — nicht der Arbeitsort + +`/initiatives` wird **Katalog & Scope-Auswahl**, kein Omnibus: + +- Liste / Suche aller Vorhaben +- Anlegen neues Vorhaben +- Klick setzt **Scope-Filter** und springt in sinnvollen Default-Modus (Cockpit scoped oder Kontrolle scoped) — **nicht** in verschachtelte Tab-Welt + +**Abschaffen (Ziel):** Vorhaben als „App-in-App“ mit fünf Tabs, in denen alles passiert. + +**Legacy-Redirects:** Alte URLs `/initiatives/:id/plan` → `/plan/gates?initiative=:id` etc. (siehe ADP AP1.9). + +--- + +## 6. Direkte Objektnavigation (Deep Links) + +### 6.1 Stabile Objekt-Routen (first-class) + +Diese URLs funktionieren **ohne** vorherigen Modus- oder Scope-Schritt. Sie sind sharebar, bookmarkbar, Agent-Ziele. + +| Objekt | Route (Ziel) | Modus (Sidebar) | Scope (auto) | +|--------|--------------|-----------------|--------------| +| **Arbeitspaket** | `/actions/:actionId` | Ausführen | Initiative + Project aus AP | +| **Task** (AP1.5d) | `/tasks/:taskId` | Ausführen | über Action → Initiative | +| **Projekt** | `/projects/:projectId` | Planen | Initiative + Project | +| **Gate** | `/gates/:gateId` | Planen (Detail) / Kontrolle (Ist) | Initiative | +| **Vorhaben** | `/initiatives/:id` | Kontrolle (Status-Home scoped) | Initiative | +| **Backlog-Item** | `/backlog/:id` | Planen → Inbox | Initiative | + +**Bestehende Route** `/initiatives/:id/actions/:actionId` → Redirect auf `/actions/:actionId` (kanonische URL). + +API-IDs sind global im Tenant — Pfad **ohne** `/initiatives/:id/...`-Präfix ist gewollt (kürzer, agentenfreundlich). + +### 6.2 Verhalten beim Direktsprung + +```text +Nutzer öffnet /actions/abc-123 + │ + ▼ + GET /api/actions/abc-123 + │ + ├── Scope: initiative_id, project_id gesetzt + ├── Sidebar: „Ausführen“ aktiv + ├── Breadcrumb: Ausführen › {Vorhaben} › {AP-Titel} + └── Inhalt: AP-Detail (wie heute ActionDetailPage) +``` + +**Zurück-Navigation:** „Zur Queue“ → `/work/mine?initiative=` (Scope bleibt), nicht in Tab-Hölle. + +### 6.3 Schnellzugriff (App-Chrome) + +Neben Modus-Nav braucht es **Sprung-Hilfen** — unabhängig vom Modus: + +| Element | Funktion | Phase | +|---------|----------|-------| +| **Springen zu…** (Suche) | Titel/ID: AP, Projekt, Gate, Vorhaben | AP1.9a minimal (Liste), 9f Volltext | +| **Zuletzt geöffnet** | 5–10 Objekte | AP1.9a | +| **Angepinnt** (optional) | PM pinnt laufende APs/Projekte | AP1.9b+ | +| Links aus Widgets | Next Action, Attention, Queue → Objekt-Route | AP1.9b | + +```text +┌──────────┬──────────────────────────────────────────────────┐ +│ … │ [ 🔍 Springen zu… ] Scope: Portfolio ▼ │ +│ Cockpit ├──────────────────────────────────────────────────┤ +``` + +Command Palette (⌘K) ist **optional später** — MVP: Suchfeld mit Typ-Ahead über offene APs/Projekte/Vorhaben. + +### 6.4 Zwei Wege zum gleichen AP (beide gültig) + +| Weg | Nutzen | +|-----|--------| +| **Querschnitt** | `/work/heute` → Liste → Klick | „Was ist überall dran?“ | +| **Direkt** | `/actions/:id` oder Suche | „Ich weiß schon, welches AP“ | + +Das ist kein Widerspruch zu Modus-Nav — **Modi listen auf, Objekt-Routen fokussieren**. + +--- + +## 7. Scope-Selector (globale Komponente) + +Im **App-Chrome** (Sidebar-Header / Mobile-Header), in allen Modi sichtbar: + +```text +┌─────────────────────────────────────────────┐ +│ Scope: [ Portfolio ▼ ] > [ optional ] │ +└─────────────────────────────────────────────┘ +``` + +| Scope | Filterwirkung | +|-------|----------------| +| **Portfolio** | Alle Initiativen im Tenant | +| **Vorhaben** | Eine Initiative (+ implizit deren Projekte/APs) | +| **Projekt** | Subtree eines Blatt-/Container-Projects | + +Scope wird in URL codiert: Query `?initiative=&project=` auf Modus-Listen; **Objekt-Routen** brauchen keinen Query (Scope aus Objekt). Bei Wechsel Scope → Modus-Liste aktualisieren; bei Objekt-Route Scope read-only anzeigen (änderbar über Scope-Dropdown → navigiert zur scoped Modus-Liste). + +--- + +## 8. Routen-Zielbild (v0.1) + +```text +── Modus-Listen (Querschnitt / scoped) ── +/cockpit Portfolio-Cockpit +/cockpit?initiative=:id Programm-Cockpit (scoped) + +/work Ausführen — Default: Heute +/work/mine Meine Queue (APs → später Tasks) +/work/today Heute (überfällig, Next Action) +/work?initiative=:id Queue scoped + +/plan/structure?initiative=:id Project-Baum +/plan/gates?initiative=:id Zielzustände +/plan/inbox?initiative=:id Backlog / Triage + +/control/status?initiative=:id Lifecycle, Snapshot +/control/plan-ist?initiative=:id Gate-Beiträge +/control/journey?initiative=:id Timeline + +/team Workload & Zuweisungen + +/initiatives Katalog +/initiatives/new Anlage + +── Objekt-Routen (Direktsprung, Scope auto) ── +/actions/:actionId Arbeitspaket-Detail ★ +/tasks/:taskId Task-Detail (AP1.5d) +/projects/:projectId Projekt-Detail ★ +/gates/:gateId Gate-Detail +/backlog/:backlogId Backlog-Detail +/initiatives/:id Vorhaben-Status (Kontrolle scoped) + +── Legacy-Redirects ── +/initiatives/:id/actions/:aid → /actions/:aid +/initiatives/:id/execution/projects/:pid → /projects/:pid +/initiatives/:id/plan/items/:gid → /gates/:gid +``` + +★ = häufigste Direktsprünge für PM im Alltag. + +**Objekt-Details** sind **keine Unterseiten eines Vorhabens** — sie sind tenant-globale Routen mit automatischem Kontext. + +--- + +## 9. Screen-Matrix (PM Use Cases → Modus) + +| PM Use Case | Primärer Modus | Sekundär | +|-------------|----------------|----------| +| Programm/Vorhaben strukturieren | **Planen** → Struktur | Planen → Gates | +| Meilensteine / Gates planen | **Planen** → Gates | Kontrolle (Ist-Beitrag lesen) | +| Roadmap / Abhängigkeiten | **Planen** → Gates | AP1.4d Graph | +| Sprint / nächste APs planen | **Planen** → Inbox + Struktur | Ausführen (Queue) | +| Konkrete nächste Schritte tun | **Ausführen** | Cockpit (Next Action) | +| Status innerhalb AP | **Ausführen** → Detail | — | +| Gesamtfortschritt / Zielerreichung | **Kontrolle** | Cockpit | +| Risiken / Showstopper | **Kontrolle** + Cockpit Attention | — | +| Kritischer Pfad | **Kontrolle** (später Graph) | — | +| Wer arbeitet an was | **Team** | Ausführen (Filter Actor) | +| Nächsten besten Schritt | **Cockpit** + **Ausführen/Heute** | Steering API | +| Beauftragung / Delegation | **Team** | AP-Detail | +| Direkt zu AP / Projekt springen | **Objekt-Route** `/actions/:id`, `/projects/:id` | Suche, Zuletzt | +| Alle Tasks quer (alle Projekte) | **Ausführen** → Queue | AP1.5d Tasks | + +--- + +## 10. Layout-Anatomie (Wireframe) + +### 10.1 App-Shell (Desktop) + +```text +┌──────────┬──────────────────────────────────────────────────┐ +│ Kairo │ [ Springen zu… ] Scope: Portfolio ▼ │ +│ ├──────────────────────────────────────────────────┤ +│ Cockpit │ MODUS- oder OBJEKT-INHALT │ +│ Ausführen│ (Liste/Übersicht ODER AP/Projekt-Detail) │ +│ Planen │ │ +│ Kontrolle│ Breadcrumb: Ausführen › Vorhaben › AP-Titel │ +│ Team │ │ +│──────────│ Zuletzt: AP Auth · Projekt Phase 2 · … │ +│ Vorhaben │ │ +│ User │ │ +└──────────┴──────────────────────────────────────────────────┘ +``` + +### 10.2 Mobile + +- Bottom-Nav: **Cockpit | Ausführen | Planen | Mehr** (Kontrolle + Team unter „Mehr“) +- Scope-Selector im Header + +### 10.3 Anti-Pattern (ab AP1.9 verboten) + +```text +✗ InitiativeExecutionPage = Projektbaum + AP-Hub + Blocker + Inline-Forms +✗ Tab „Ausführung“ als Müllhalde für Planung und Ist +✗ Querschnitt nur als kleines Widget ohne eigene Fläche +✗ AP-Detail nur unter /initiatives/:id/actions/:id erreichbar (kein /actions/:id) +``` + +--- + +## 11. Daten & API — Modus-Map (keine Parallel-Logik) + +| Modus | Read Models / APIs | +|-------|---------------------| +| Cockpit | `workspace/*`, `attention`, `next-actions`, Initiative-Liste | +| Ausführen | `workspace/actions/*`, `actions/me/open`, Tasks (AP1.5d) | +| Planen | `projects`, `roadmap/*`, `backlog/*` | +| Kontrolle | `steering-snapshot`, `journey`, `plan-ist` contributions | +| Team | `actors`, `workspace/actors/workload`, Assignments | + +Alles weiter über `TenantContext` + Capabilities — Modus ist **pure IA**, keine neue Domäne. + +--- + +## 12. Migrationsplan — vom Ist zum Zielbild + +| Heute | Ziel | Phase | +|-------|------|-------| +| `/workspace` Widget-Grid | `/cockpit` strukturierte Flächen | 9b | +| `/my-actions` | `/work/mine` | 9b | +| `/initiatives/:id` Steuerung | `/control/status?initiative=` | 9c | +| Tab Plan | `/plan/gates?initiative=` | 9d | +| Tab Eingang | `/plan/inbox?initiative=` | 9d | +| Tab Ausführung (Stack) | `/work?initiative=` + AP-Detail | 9c | +| Tab Journey | `/control/journey?initiative=` | 9c | +| Projekt-Detail unter execution | `/projects/:projectId` | 9d | +| AP-Detail nested unter initiative | `/actions/:actionId` (kanonisch) | 9a | +| `InitiativeSubNav` Tabs | **Entfernen** — Modus-Nav global | 9a | + +Redirects mindestens **6 Monate** oder bis PO schließt. + +--- + +## 13. Umsetzungspakete (Roadmap) + +| Paket | Ergebnis | Nutzen für PM | +|-------|----------|----------------| +| **AP1.9a** | App-Shell, Modi-Routen, **Objekt-Routen**, Scope-Provider, Springen-zu (minimal), Redirects | Navigation + Direktsprung | +| **AP1.9b** | Cockpit + Ausführen/Heute (AP-Querschnitt) | Täglicher Einstieg | +| **AP1.9c** | Kontrolle (Snapshot, Journey, Plan-Ist) | Status & Zielerreichung | +| **AP1.9d** | Planen (Struktur, Gates, Inbox) | Planung ausgelagert | +| **AP1.9e** | Team + Delegation-Grundlagen | Wer macht was | +| **AP1.5d** | Tasks in Ausführen-Queue | Feingranulare Querschnittsliste | +| **AP1.8** | Portfolio-Priorität im Cockpit | Welches Vorhaben zuerst | + +**Kein weiteres AP1.5x-UI-Patch** auf `InitiativeExecutionPage` außer Hotfixes bis AP1.9a live ist. + +--- + +## 14. MVP-Nutzbarkeit (neu formuliert) + +Kairos Frontend ist **für Programmmanager nutzbar**, wenn: + +1. **Ausführen/Heute** in ≤30s die nächste Arbeit **portfolio-weit** zeigt +2. **Cockpit** in ≤30s zeigt, welches Vorhaben Aufmerksamkeit braucht +3. **Planen** und **Ausführen** ohne gemeinsame Seite erreichbar sind +4. **Kontrolle** Plan vs. Ist und Blocker **ohne CRUD-Listen** aggregiert +5. Scope-Wechsel von Portfolio → Vorhaben **ohne Modus-Wechsel** funktioniert +6. Agent-API kann dieselben aggregierten Read Models ansprechen (Vorbereitung AP1.7) +7. **Direktsprung** auf AP/Projekt per URL oder Suche — Scope korrekt gesetzt + +--- + +## 15. Offene PO-Entscheide (vor AP1.9a Code) + +| # | Frage | Empfehlung | +|---|-------|------------| +| 1 | URL-Scope: Query vs. Pfad | Query `?initiative=` + Context | +| 2 | Default nach Vorhaben-Klick | Cockpit scoped | +| 3 | `/workspace` Redirect | Ja → `/cockpit` | +| 4 | Tab-InitiativeSubNav entfernen | Ja, mit Redirects | +| 5 | Planen ohne Scope | Inbox leer + CTA „Vorhaben wählen“ | +| 6 | Kanonische AP-URL | `/actions/:id` (Redirect von nested) | +| 7 | Kanonische Projekt-URL | `/projects/:id` | + +--- + +## 16. Referenzen + +- `Kairo_Vision_and_Product_Direction_v0.2.md` §7 +- `Kairo_Canonical_Operating_Model_v0.2.md` §8 +- `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md` +- `ADP_AP1_9_PM_Work_Modes_Frontend_IA_v0.1.md` (Scope Lock) +- `Kairo_Implementation_Truth_Table_v0.1.md` (wird mit AP1.9 aktualisiert) + +--- + +*Dieses Dokument ist das führende UI-Zielbild ab 2026-07-08 bis PO-Freigabe oder v0.2. Implementierung ohne Alignment hier → Principle Gate.* diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0137e1f..8be2470 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,24 +1,35 @@ -import { Navigate, Route, Routes, useParams } from 'react-router-dom' +import { Navigate, Route, Routes } from 'react-router-dom' import { SessionProvider, useSession } from './context/SessionContext.jsx' +import { ProgramScopeProvider } from './context/ProgramScopeContext.jsx' import { AppLayout } from './layout/AppLayout.jsx' -import { InitiativeLayout } from './layout/InitiativeLayout.jsx' import { AuthPanel } from './pages/AuthPanel.jsx' import { ActionDetailPage } from './pages/ActionDetailPage.jsx' -import { InitiativeOverviewPage } from './pages/initiative/InitiativeOverviewPage.jsx' -import { InitiativePlanPage } from './pages/initiative/InitiativePlanPage.jsx' -import { InitiativeExecutionPage } from './pages/initiative/InitiativeExecutionPage.jsx' -import { ProjectDetailPage } from './pages/initiative/ProjectDetailPage.jsx' -import { InitiativeInboxPage } from './pages/initiative/InitiativeInboxPage.jsx' -import { InitiativeJourneyPage } from './pages/initiative/InitiativeJourneyPage.jsx' -import { RoadmapItemDetailPage } from './pages/initiative/RoadmapItemDetailPage.jsx' -import { VIEWS } from './registry/viewRegistry.js' +import { ProjectObjectPage } from './pages/objects/ProjectObjectPage.jsx' +import { GateObjectPage } from './pages/objects/GateObjectPage.jsx' +import { + VIEWS, + WorkLayout, + WorkIndexRedirect, + PlanLayout, + PlanIndexRedirect, + ControlLayout, + ControlIndexRedirect, + MODE_ROUTE_COMPONENTS, +} from './registry/viewRegistry.js' +import { + WorkspaceRedirect, + MyActionsRedirect, + InitiativeRootRedirect, + InitiativePlanRedirect, + InitiativeExecutionRedirect, + InitiativeInboxRedirect, + InitiativeJourneyRedirect, + InitiativeGateRedirect, + InitiativeProjectRedirect, + InitiativeActionRedirect, +} from './pages/LegacyRedirects.jsx' import './app.css' -function InitiativeLegacyDetailRedirect() { - const { id } = useParams() - return -} - function AppRoutes() { const { isAuthenticated, loading } = useSession() @@ -30,30 +41,69 @@ function AppRoutes() { return } - const topLevelViews = VIEWS.filter((v) => v.key !== 'kairo.initiative_detail') + const topLevelViews = VIEWS.filter( + (v) => + ![ + 'kairo.workspace', + 'kairo.my_actions', + 'kairo.work', + 'kairo.plan', + 'kairo.control', + ].includes(v.key), + ) return ( - - }> - {topLevelViews.map((view) => ( - } /> - ))} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + }> + {topLevelViews.map((view) => ( + } /> + ))} + + }> + } /> + } /> + } /> + + + }> + } /> + } /> + } /> + } /> + + + }> + } /> + } /> + } /> + + + } /> + } /> + } /> + + } /> + } /> + + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + + } /> + } /> - } /> - } /> - } /> - } /> - - + + ) } diff --git a/frontend/src/app.css b/frontend/src/app.css index 285e696..ad93503 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2,3 +2,4 @@ @import './styles/shell.css'; @import './styles/components.css'; @import './styles/pages.css'; +@import './styles/program-chrome.css'; diff --git a/frontend/src/components/InitiativeNavCards.jsx b/frontend/src/components/InitiativeNavCards.jsx index cdac7da..e737b8e 100644 --- a/frontend/src/components/InitiativeNavCards.jsx +++ b/frontend/src/components/InitiativeNavCards.jsx @@ -1,25 +1,30 @@ import { Link } from 'react-router-dom' +import { scopedPath } from '../utils/routes.js' const AREAS = [ { - to: 'plan', + to: '/plan/gates', title: 'Plan', description: 'Roadmap, Gates und Plan-Ist — methodenneutral pflegen.', + countKey: 'roadmapItems', }, { - to: 'execution', + to: '/work/today', title: 'Ausführung', description: 'Arbeitspakete, Zuweisungen, Blocker am Objekt.', + countKey: 'actionsOpen', }, { - to: 'inbox', + to: '/plan/inbox', title: 'Eingang', description: 'Backlog triagieren und in Arbeitspakete überführen.', + countKey: 'backlog', }, { - to: 'journey', + to: '/control/journey', title: 'Nachvollziehbarkeit', description: 'Entscheidungen, Reviews, Nachweise über die Zeit.', + countKey: null, }, ] @@ -30,17 +35,17 @@ export function InitiativeNavCards({ initiativeId, counts = {} }) {
{AREAS.map((area) => { const hint = - area.to === 'inbox' && counts.backlog + area.countKey === 'backlog' && counts.backlog ? `${counts.backlog} Backlog` - : area.to === 'execution' && counts.actionsOpen + : area.countKey === 'actionsOpen' && counts.actionsOpen ? `${counts.actionsOpen} offen` - : area.to === 'plan' && counts.roadmapItems + : area.countKey === 'roadmapItems' && counts.roadmapItems ? `${counts.roadmapItems} Plan-Elemente` : null return (

{area.title}

diff --git a/frontend/src/components/ModeShell.jsx b/frontend/src/components/ModeShell.jsx new file mode 100644 index 0000000..cb3ea0e --- /dev/null +++ b/frontend/src/components/ModeShell.jsx @@ -0,0 +1,16 @@ +import { MODE_META } from '../utils/routes.js' + +export function ModeShell({ modeKey, actions }) { + const meta = MODE_META[modeKey] + if (!meta) return null + + return ( +
+
+

{meta.title}

+

{meta.lead}

+
+ {actions ?
{actions}
: null} +
+ ) +} diff --git a/frontend/src/components/ModeSubNav.jsx b/frontend/src/components/ModeSubNav.jsx new file mode 100644 index 0000000..c0702b1 --- /dev/null +++ b/frontend/src/components/ModeSubNav.jsx @@ -0,0 +1,26 @@ +import { NavLink } from 'react-router-dom' +import { useProgramScope } from '../context/ProgramScopeContext.jsx' + +/** + * @param {{ items: { to: string, label: string, end?: boolean }[] }} props + */ +export function ModeSubNav({ items }) { + const { hrefWithScope } = useProgramScope() + + return ( + + ) +} diff --git a/frontend/src/components/ProgramScopeBar.jsx b/frontend/src/components/ProgramScopeBar.jsx new file mode 100644 index 0000000..688af45 --- /dev/null +++ b/frontend/src/components/ProgramScopeBar.jsx @@ -0,0 +1,62 @@ +import { Link } from 'react-router-dom' +import { useProgramScope } from '../context/ProgramScopeContext.jsx' +import { initiativeCatalogPath, scopedPath } from '../utils/routes.js' + +export function ProgramScopeBar() { + const { + initiativeId, + projectId, + initiativeTitle, + projectTitle, + clearScope, + setScope, + } = useProgramScope() + + return ( +
+ Scope + + + Vorhaben-Katalog + +
+ ) +} diff --git a/frontend/src/components/ProjectsSection.jsx b/frontend/src/components/ProjectsSection.jsx index 6f7a239..1e287ab 100644 --- a/frontend/src/components/ProjectsSection.jsx +++ b/frontend/src/components/ProjectsSection.jsx @@ -33,7 +33,7 @@ function ProjectTreeNodes({ >
{project.title} @@ -117,7 +117,7 @@ export function ProjectsSection({ {canManage && ( Neues Projekt diff --git a/frontend/src/components/QuickJump.jsx b/frontend/src/components/QuickJump.jsx new file mode 100644 index 0000000..523450f --- /dev/null +++ b/frontend/src/components/QuickJump.jsx @@ -0,0 +1,179 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { listInitiatives } from '../api/initiatives.js' +import { listWorkspaceOpenActions } from '../api/workspace.js' +import { useProgramScope } from '../context/ProgramScopeContext.jsx' +import { loadRecentObjects } from '../utils/recentObjects.js' +import { + actionPath, + gatePath, + initiativeCatalogPath, + projectPath, + scopedPath, +} from '../utils/routes.js' + +const TYPE_LABELS = { + action: 'Arbeitspaket', + project: 'Projekt', + gate: 'Gate', + initiative: 'Vorhaben', +} + +function matchQuery(text, query) { + return (text || '').toLowerCase().includes(query.toLowerCase()) +} + +export function QuickJump() { + const navigate = useNavigate() + const { applyFromInitiative } = useProgramScope() + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [initiatives, setInitiatives] = useState([]) + const [actions, setActions] = useState([]) + const [recent, setRecent] = useState([]) + const rootRef = useRef(null) + + useEffect(() => { + let cancelled = false + Promise.all([ + listInitiatives().catch(() => []), + listWorkspaceOpenActions().catch(() => []), + ]).then(([initData, actionData]) => { + if (cancelled) return + setInitiatives(initData) + setActions(actionData) + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + setRecent(loadRecentObjects()) + }, [open]) + + useEffect(() => { + function onDocClick(e) { + if (rootRef.current && !rootRef.current.contains(e.target)) { + setOpen(false) + } + } + document.addEventListener('mousedown', onDocClick) + return () => document.removeEventListener('mousedown', onDocClick) + }, []) + + const results = useMemo(() => { + const q = query.trim() + if (!q) return [] + + const hits = [] + + for (const item of initiatives) { + if (matchQuery(item.title, q)) { + hits.push({ + key: `initiative-${item.id}`, + label: item.title, + meta: 'Vorhaben', + href: scopedPath('/control/status', { initiativeId: item.id }), + onSelect: () => applyFromInitiative(item.id), + }) + } + } + + for (const action of actions) { + if (matchQuery(action.title, q)) { + hits.push({ + key: `action-${action.id}`, + label: action.title, + meta: 'Arbeitspaket', + href: actionPath(action.id), + }) + } + } + + return hits.slice(0, 8) + }, [query, initiatives, actions, applyFromInitiative]) + + const go = useCallback( + (href, onSelect) => { + if (onSelect) onSelect() + navigate(href) + setOpen(false) + setQuery('') + }, + [navigate], + ) + + return ( +
+ + {open && ( +
+ setQuery(e.target.value)} + autoFocus + /> +
    + {query.trim() === '' && + recent.map((item) => ( +
  • + +
  • + ))} + {query.trim() !== '' && + results.map((item) => ( +
  • + +
  • + ))} + {query.trim() !== '' && results.length === 0 && ( +
  • Keine Treffer
  • + )} + {query.trim() === '' && recent.length === 0 && ( +
  • Tippe zum Suchen — oder öffne ein Objekt.
  • + )} +
+
+ +
+
+ )} +
+ ) +} + +export { actionPath, projectPath, gatePath } diff --git a/frontend/src/components/RecentlyOpened.jsx b/frontend/src/components/RecentlyOpened.jsx new file mode 100644 index 0000000..479f005 --- /dev/null +++ b/frontend/src/components/RecentlyOpened.jsx @@ -0,0 +1,33 @@ +import { Link } from 'react-router-dom' +import { loadRecentObjects } from '../utils/recentObjects.js' + +const TYPE_LABELS = { + action: 'AP', + project: 'Projekt', + gate: 'Gate', + initiative: 'Vorhaben', +} + +export function RecentlyOpened() { + const items = loadRecentObjects().slice(0, 5) + if (items.length === 0) return null + + return ( +
+ Zuletzt +
+ {items.map((item) => ( + + {TYPE_LABELS[item.type] || item.type} + {item.title} + + ))} +
+
+ ) +} diff --git a/frontend/src/components/RequireInitiativeScope.jsx b/frontend/src/components/RequireInitiativeScope.jsx new file mode 100644 index 0000000..b4c3e0e --- /dev/null +++ b/frontend/src/components/RequireInitiativeScope.jsx @@ -0,0 +1,62 @@ +import { Link, useLocation } from 'react-router-dom' +import { useEffect, useState } from 'react' +import { listInitiatives } from '../api/initiatives.js' +import { useProgramScope } from '../context/ProgramScopeContext.jsx' +import { LoadingState } from './LoadingState.jsx' +import { scopedPath } from '../utils/routes.js' + +export function RequireInitiativeScope({ children, lead }) { + const location = useLocation() + const { initiativeId, applyFromInitiative } = useProgramScope() + const [initiatives, setInitiatives] = useState(null) + const basePath = location.pathname.replace(/\/$/, '') || location.pathname + + useEffect(() => { + if (initiativeId) return + let cancelled = false + listInitiatives() + .then((data) => { + if (!cancelled) setInitiatives(data) + }) + .catch(() => { + if (!cancelled) setInitiatives([]) + }) + return () => { + cancelled = true + } + }, [initiativeId]) + + if (initiativeId) { + return children + } + + if (initiatives === null) { + return + } + + return ( +
+

Vorhaben wählen

+

+ {lead || 'Dieser Modus braucht ein Vorhaben als Scope-Filter.'} +

+ {initiatives.length === 0 ? ( +

Noch keine Vorhaben angelegt.

+ ) : ( +
    + {initiatives.map((item) => ( +
  • + applyFromInitiative(item.id)} + > + {item.title} + +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/RoadmapPlanSection.jsx b/frontend/src/components/RoadmapPlanSection.jsx index 313542b..b3c33b5 100644 --- a/frontend/src/components/RoadmapPlanSection.jsx +++ b/frontend/src/components/RoadmapPlanSection.jsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { Link } from 'react-router-dom' +import { gatePath } from '../utils/routes.js' import { MILESTONE_STATUSES, MILESTONE_STATUS_LABELS, @@ -152,7 +153,7 @@ export function RoadmapPlanSection({
{initiativeId ? ( - + {item.title} ) : ( diff --git a/frontend/src/config/appNav.js b/frontend/src/config/appNav.js index 4012151..304110c 100644 --- a/frontend/src/config/appNav.js +++ b/frontend/src/config/appNav.js @@ -1,12 +1,22 @@ -import { LayoutDashboard, FolderKanban, ListChecks } from 'lucide-react' +import { + Gauge, + LayoutDashboard, + Map, + Play, + Users, + FolderKanban, +} from 'lucide-react' import { getNavViews } from '../registry/viewRegistry.js' -/** @typedef {{ key: string, to: string, label: string, shortLabel: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent, matchPrefix?: string }} AppNavItem */ +/** @typedef {{ key: string, to: string, label: string, shortLabel: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent, matchPrefix?: string, objectPrefixes?: string[] }} AppNavItem */ const ICON_BY_KEY = { - 'kairo.workspace': LayoutDashboard, + 'kairo.cockpit': LayoutDashboard, + 'kairo.work': Play, + 'kairo.plan': Map, + 'kairo.control': Gauge, + 'kairo.team': Users, 'kairo.initiatives': FolderKanban, - 'kairo.my_actions': ListChecks, } /** @@ -19,9 +29,10 @@ export function getAppNavItems(capabilities) { to: view.path, label: view.navLabel || view.title, shortLabel: view.shortLabel || view.title, - end: view.path !== '/initiatives', + end: view.path === '/cockpit' || view.path === '/team', Icon: ICON_BY_KEY[view.key] || LayoutDashboard, matchPrefix: view.matchPrefix, + objectPrefixes: view.objectPrefixes, })) } @@ -31,8 +42,14 @@ export function getAppNavItems(capabilities) { * @param {boolean} routerIsActive */ export function isNavItemActive(pathname, item, routerIsActive) { - if (item.matchPrefix) { - return pathname.startsWith(item.matchPrefix) + if (item.matchPrefix && pathname.startsWith(item.matchPrefix)) { + return true + } + if (item.objectPrefixes?.some((prefix) => pathname.startsWith(prefix))) { + return true + } + if (item.key === 'kairo.initiatives' && pathname.startsWith('/initiatives')) { + return true } return routerIsActive } diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx index 6c7060d..ec73b18 100644 --- a/frontend/src/context/InitiativeOperationsContext.jsx +++ b/frontend/src/context/InitiativeOperationsContext.jsx @@ -68,12 +68,15 @@ import { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/stee import { useCapabilities } from '../hooks/useCapabilities.js' import { useActors } from '../hooks/useActors.js' import { useSession } from './SessionContext.jsx' +import { useOptionalProgramScope } from './ProgramScopeContext.jsx' import { collectProjectSubtreeIds } from '../utils/projectTree.js' const InitiativeOperationsContext = createContext(null) -export function InitiativeOperationsProvider({ children }) { - const { id } = useParams() +export function InitiativeOperationsProvider({ children, initiativeId: initiativeIdProp }) { + const { id: routeId } = useParams() + const scope = useOptionalProgramScope() + const id = initiativeIdProp || routeId || scope?.initiativeId || null const { context, loading: sessionLoading } = useSession() const { capabilities } = useCapabilities() const actorsState = useActors() diff --git a/frontend/src/context/ProgramScopeContext.jsx b/frontend/src/context/ProgramScopeContext.jsx new file mode 100644 index 0000000..12d6516 --- /dev/null +++ b/frontend/src/context/ProgramScopeContext.jsx @@ -0,0 +1,164 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import { useSearchParams } from 'react-router-dom' +import { getInitiative } from '../api/initiatives.js' +import { getProject } from '../api/projects.js' +import { scopedPath } from '../utils/routes.js' + +const ProgramScopeContext = createContext(null) + +export function ProgramScopeProvider({ children }) { + const [searchParams, setSearchParams] = useSearchParams() + const initiativeId = searchParams.get('initiative') || '' + const projectId = searchParams.get('project') || '' + + const [initiativeTitle, setInitiativeTitle] = useState('') + const [projectTitle, setProjectTitle] = useState('') + + useEffect(() => { + if (!initiativeId) { + setInitiativeTitle('') + return + } + let cancelled = false + getInitiative(initiativeId) + .then((data) => { + if (!cancelled) setInitiativeTitle(data.title || '') + }) + .catch(() => { + if (!cancelled) setInitiativeTitle('') + }) + return () => { + cancelled = true + } + }, [initiativeId]) + + useEffect(() => { + if (!projectId) { + setProjectTitle('') + return + } + let cancelled = false + getProject(projectId) + .then((data) => { + if (!cancelled) setProjectTitle(data.title || '') + }) + .catch(() => { + if (!cancelled) setProjectTitle('') + }) + return () => { + cancelled = true + } + }, [projectId]) + + const setScope = useCallback( + (next) => { + const params = new URLSearchParams(searchParams) + if ('initiativeId' in next) { + if (next.initiativeId) params.set('initiative', next.initiativeId) + else params.delete('initiative') + } + if ('projectId' in next) { + if (next.projectId) params.set('project', next.projectId) + else params.delete('project') + } + if (next.clearProject) params.delete('project') + setSearchParams(params, { replace: true }) + }, + [searchParams, setSearchParams], + ) + + const clearScope = useCallback(() => { + setSearchParams({}, { replace: true }) + }, [setSearchParams]) + + const applyFromInitiative = useCallback( + (id, { keepProject = false } = {}) => { + if (!id) return + setScope({ + initiativeId: id, + projectId: keepProject ? projectId : '', + clearProject: !keepProject, + }) + }, + [projectId, setScope], + ) + + const applyFromProject = useCallback( + (project) => { + if (!project?.initiative_id) return + setScope({ + initiativeId: project.initiative_id, + projectId: project.id, + }) + }, + [setScope], + ) + + const applyFromAction = useCallback( + (action) => { + if (!action?.initiative_id) return + setScope({ + initiativeId: action.initiative_id, + projectId: action.project_id || '', + clearProject: !action.project_id, + }) + }, + [setScope], + ) + + const hrefWithScope = useCallback( + (path) => scopedPath(path, { initiativeId, projectId }), + [initiativeId, projectId], + ) + + const value = useMemo( + () => ({ + initiativeId, + projectId, + initiativeTitle, + projectTitle, + setScope, + clearScope, + applyFromInitiative, + applyFromProject, + applyFromAction, + hrefWithScope, + hasScope: Boolean(initiativeId), + }), + [ + initiativeId, + projectId, + initiativeTitle, + projectTitle, + setScope, + clearScope, + applyFromInitiative, + applyFromProject, + applyFromAction, + hrefWithScope, + ], + ) + + return ( + {children} + ) +} + +export function useProgramScope() { + const ctx = useContext(ProgramScopeContext) + if (!ctx) { + throw new Error('useProgramScope must be used within ProgramScopeProvider') + } + return ctx +} + +export function useOptionalProgramScope() { + return useContext(ProgramScopeContext) +} diff --git a/frontend/src/layout/AppLayout.jsx b/frontend/src/layout/AppLayout.jsx index 89c7f79..00c04ee 100644 --- a/frontend/src/layout/AppLayout.jsx +++ b/frontend/src/layout/AppLayout.jsx @@ -5,6 +5,9 @@ import { LoadingState } from '../components/LoadingState.jsx' import { DesktopSidebar } from '../components/DesktopSidebar.jsx' import { BottomNav } from '../components/BottomNav.jsx' import { ContextIndicator } from '../components/ContextIndicator.jsx' +import { ProgramScopeBar } from '../components/ProgramScopeBar.jsx' +import { QuickJump } from '../components/QuickJump.jsx' +import { RecentlyOpened } from '../components/RecentlyOpened.jsx' export function AppLayout() { const { me, signOut, loading } = useSession() @@ -36,9 +39,17 @@ export function AppLayout() { Kairo Jinkendo Program Director
+
+ + +
+ + + +
diff --git a/frontend/src/layout/ScopedInitiativeProvider.jsx b/frontend/src/layout/ScopedInitiativeProvider.jsx new file mode 100644 index 0000000..b4dca62 --- /dev/null +++ b/frontend/src/layout/ScopedInitiativeProvider.jsx @@ -0,0 +1,13 @@ +import { InitiativeOperationsProvider } from '../context/InitiativeOperationsContext.jsx' +import { useProgramScope } from '../context/ProgramScopeContext.jsx' + +export function ScopedInitiativeProvider({ initiativeId: initiativeIdProp, children }) { + const { initiativeId: scopeInitiativeId } = useProgramScope() + const initiativeId = initiativeIdProp || scopeInitiativeId + + return ( + + {children} + + ) +} diff --git a/frontend/src/pages/ActionDetailPage.jsx b/frontend/src/pages/ActionDetailPage.jsx index 1793005..d6d3fb5 100644 --- a/frontend/src/pages/ActionDetailPage.jsx +++ b/frontend/src/pages/ActionDetailPage.jsx @@ -12,8 +12,11 @@ import { TasksSection } from '../components/TasksSection.jsx' import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx' +import { useProgramScope } from '../context/ProgramScopeContext.jsx' import { useCapabilities } from '../hooks/useCapabilities.js' import { useActors } from '../hooks/useActors.js' +import { recordRecentObject } from '../utils/recentObjects.js' +import { actionPath, scopedPath } from '../utils/routes.js' function ActionDetailBody({ action, @@ -81,6 +84,7 @@ function ActionDetailBody({ function ActionDetailStandalone() { const { actionId } = useParams() + const { applyFromAction } = useProgramScope() const { capabilities } = useCapabilities() const actorsState = useActors() const [action, setAction] = useState(null) @@ -98,6 +102,14 @@ function ActionDetailStandalone() { try { const actionData = await getAction(actionId) setAction(actionData) + applyFromAction(actionData) + recordRecentObject({ + type: 'action', + id: actionData.id, + title: actionData.title, + href: actionPath(actionData.id), + initiativeId: actionData.initiative_id, + }) const [snap, projectData, roadmapData] = await Promise.all([ getInitiativeSteeringSnapshot(actionData.initiative_id), listInitiativeProjects(actionData.initiative_id).catch(() => []), @@ -111,7 +123,7 @@ function ActionDetailStandalone() { } finally { setLoading(false) } - }, [actionId]) + }, [actionId, applyFromAction]) useEffect(() => { load() @@ -190,11 +202,15 @@ function ActionDetailStandalone() { return (
diff --git a/frontend/src/pages/InitiativesPage.jsx b/frontend/src/pages/InitiativesPage.jsx index 3003941..fb72511 100644 --- a/frontend/src/pages/InitiativesPage.jsx +++ b/frontend/src/pages/InitiativesPage.jsx @@ -10,6 +10,7 @@ import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' import { useCapabilities } from '../hooks/useCapabilities.js' import { useSession } from '../context/SessionContext.jsx' +import { scopedPath } from '../utils/routes.js' export function InitiativesPage() { const { context } = useSession() @@ -105,7 +106,7 @@ export function InitiativesPage() { {initiatives.map((item) => (
  • - + {item.title} {item.goal &&

    {item.goal}

    } diff --git a/frontend/src/pages/LegacyRedirects.jsx b/frontend/src/pages/LegacyRedirects.jsx new file mode 100644 index 0000000..8a954a1 --- /dev/null +++ b/frontend/src/pages/LegacyRedirects.jsx @@ -0,0 +1,61 @@ +import { Navigate, useParams, useSearchParams } from 'react-router-dom' +import { actionPath, gatePath, projectPath, scopedPath } from '../utils/routes.js' + +export function WorkspaceRedirect() { + return +} + +export function MyActionsRedirect() { + return +} + +export function InitiativeRootRedirect() { + const { id } = useParams() + return +} + +export function InitiativePlanRedirect() { + const { id } = useParams() + return +} + +export function InitiativeExecutionRedirect() { + const { id } = useParams() + return +} + +export function InitiativeInboxRedirect() { + const { id } = useParams() + return +} + +export function InitiativeJourneyRedirect() { + const { id } = useParams() + return +} + +export function InitiativeGateRedirect() { + const { itemId } = useParams() + return +} + +export function InitiativeProjectRedirect() { + const { projectId } = useParams() + return +} + +export function InitiativeActionRedirect() { + const { actionId } = useParams() + return +} + +export function ProjectNewRedirect() { + const [searchParams] = useSearchParams() + const initiativeId = searchParams.get('initiative') || '' + const parent = searchParams.get('parent') || '' + const params = new URLSearchParams() + if (initiativeId) params.set('initiative', initiativeId) + if (parent) params.set('parent', parent) + const qs = params.toString() + return +} diff --git a/frontend/src/pages/MyActionsPage.jsx b/frontend/src/pages/MyActionsPage.jsx index f4019a5..7e3f320 100644 --- a/frontend/src/pages/MyActionsPage.jsx +++ b/frontend/src/pages/MyActionsPage.jsx @@ -7,7 +7,7 @@ import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' import { WidgetCard } from '../components/WidgetCard.jsx' -export function MyActionsPage() { +export function MyActionsPage({ embedded = false }) { const [actions, setActions] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -34,25 +34,41 @@ export function MyActionsPage() { ? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded') : actions - return ( -
    -
    -
    -

    Meine Maßnahmen

    -

    Dir zugewiesene offene und blockierte Maßnahmen.

    + const content = ( + <> + {!embedded && ( +
    +
    +

    Meine Maßnahmen

    +

    Dir zugewiesene offene und blockierte Maßnahmen.

    +
    + +
    + )} + + {embedded && ( +
    +

    Meine Queue

    +
    - -
    + )} Aktualisieren @@ -65,7 +81,7 @@ export function MyActionsPage() { `/initiatives/${a.initiative_id}`} + getManageLink={(a) => `/actions/${a.id}`} empty={ } @@ -73,11 +89,19 @@ export function MyActionsPage() { )} -

    - - Zu den Vorhaben → - -

    -
    + {!embedded && ( +

    + + Zum Vorhaben-Katalog → + +

    + )} + ) + + if (embedded) { + return content + } + + return
    {content}
    } diff --git a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx index 8bb8387..dba68c5 100644 --- a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx +++ b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx @@ -1,4 +1,5 @@ import { Link } from 'react-router-dom' +import { scopedPath } from '../../utils/routes.js' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx' import { NextActionWidget } from '../../widgets/NextActionWidget.jsx' @@ -60,7 +61,7 @@ export function InitiativeOverviewPage() { {counts.blockers_open} offene Blocker )}

    - + Zur Ausführung diff --git a/frontend/src/pages/initiative/InitiativePlanPage.jsx b/frontend/src/pages/initiative/InitiativePlanPage.jsx index d0a177b..458266e 100644 --- a/frontend/src/pages/initiative/InitiativePlanPage.jsx +++ b/frontend/src/pages/initiative/InitiativePlanPage.jsx @@ -1,10 +1,9 @@ -import { useParams } from 'react-router-dom' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx' export function InitiativePlanPage() { - const { id } = useParams() const { + initiativeId, roadmapItems, capabilities, formBusy, @@ -24,7 +23,7 @@ export function InitiativePlanPage() { <> {error &&

    {error}

    } Gate
    {gateTitle ? ( - + {gateTitle} ) : ( @@ -96,7 +97,7 @@ function ProjectDetailView({ {index === path.length - 1 ? ( {node.title} ) : ( - + {node.title} )} @@ -112,7 +113,7 @@ function ProjectDetailView({
      {childProjects.map((child) => (
    • - + {child.title} @@ -136,7 +137,7 @@ function ProjectDetailView({
        {linkedActions.map((action) => (
      • - + {action.title} @@ -158,7 +159,7 @@ function ProjectDetailView({ {canManage && ( Unterprojekt @@ -212,7 +213,7 @@ export function ProjectDetailPage() { setLocalError(null) const created = await ops.handleCreateProject(payload) if (created?.id) { - navigate(`/initiatives/${ops.initiativeId}/execution/projects/${created.id}`, { replace: true }) + navigate(projectPath(created.id), { replace: true }) } else if (ops.error) { setLocalError(ops.error) } @@ -233,7 +234,7 @@ export function ProjectDetailPage() { setLocalError(null) const ok = await ops.handleDeleteProject(project.id) if (ok) { - navigate(`/initiatives/${ops.initiativeId}/execution`) + navigate(scopedPath('/plan/structure', { initiativeId: ops.initiativeId })) } else if (ops.error) { setLocalError(ops.error) } @@ -241,7 +242,7 @@ export function ProjectDetailPage() { function handleFilter() { if (project) ops.setSelectedProjectId(project.id) - navigate(`/initiatives/${ops.initiativeId}/execution`) + navigate(scopedPath('/plan/structure', { initiativeId: ops.initiativeId })) } const title = isNew ? 'Neues Projekt' : project?.title || 'Projekt' @@ -249,7 +250,7 @@ export function ProjectDetailPage() { return (
        @@ -298,7 +299,7 @@ export function ProjectDetailPage() { onSubmit={isNew ? handleCreate : handleUpdate} onCancel={ isNew - ? () => navigate(`/initiatives/${ops.initiativeId}/execution`) + ? () => navigate(scopedPath('/plan/structure', { initiativeId: ops.initiativeId })) : () => setEditing(false) } busy={ops.formBusy} diff --git a/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx b/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx index 0bfce73..80e89a7 100644 --- a/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx +++ b/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx @@ -25,6 +25,8 @@ import { ROADMAP_ITEM_TYPE_LABELS, } from '../../constants/status.js' import { useCapabilities } from '../../hooks/useCapabilities.js' +import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' +import { scopedPath } from '../../utils/routes.js' import { listGateContributions } from '../../api/journey.js' import { GateContributionsSection } from '../../components/GateContributionsSection.jsx' @@ -71,8 +73,11 @@ function CriterionDecisionForm({ label, onSubmit, busy }) { ) } -export function RoadmapItemDetailPage() { - const { id: initiativeId, itemId } = useParams() +export function RoadmapItemDetailPage({ overrideItemId }) { + const { id: routeInitiativeId, itemId: routeItemId, gateId } = useParams() + const ops = useInitiativeOperations() + const initiativeId = routeInitiativeId || ops?.initiativeId + const itemId = overrideItemId || routeItemId || gateId const { capabilities } = useCapabilities() const canManage = capabilities.has('kairo.milestone.manage') const canReopen = capabilities.has('kairo.roadmap.reopen') @@ -152,7 +157,7 @@ export function RoadmapItemDetailPage() { return (

        - Zielzustände + Zielzustände {' · '} {item.title}

        diff --git a/frontend/src/pages/modes/CockpitPage.jsx b/frontend/src/pages/modes/CockpitPage.jsx new file mode 100644 index 0000000..b6d7eb9 --- /dev/null +++ b/frontend/src/pages/modes/CockpitPage.jsx @@ -0,0 +1,84 @@ +import { useNavigate } from 'react-router-dom' +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { useCapabilities } from '../../hooks/useCapabilities.js' +import { getWidgetsForArea } from '../../registry/widgetRegistry.js' +import { useSession } from '../../context/SessionContext.jsx' +import { InitiativeForm } from '../../components/InitiativeForm.jsx' +import { createInitiative } from '../../api/initiatives.js' +import { ModeShell } from '../../components/ModeShell.jsx' +import { initiativeCatalogPath } from '../../utils/routes.js' + +export function CockpitPage() { + const navigate = useNavigate() + const { context } = useSession() + const { capabilities, hasCapability } = useCapabilities() + const widgets = getWidgetsForArea('workspace', capabilities) + const [showForm, setShowForm] = useState(false) + const [formBusy, setFormBusy] = useState(false) + const [formError, setFormError] = useState(null) + + async function handleCreateInitiative(payload) { + setFormBusy(true) + setFormError(null) + try { + await createInitiative({ + ...payload, + owner_actor_id: context?.actor?.id, + }) + setShowForm(false) + navigate(initiativeCatalogPath()) + } catch (err) { + setFormError(err.message) + } finally { + setFormBusy(false) + } + } + + return ( +
        + setShowForm((v) => !v)} + > + {showForm ? 'Abbrechen' : 'Vorhaben anlegen'} + + ) : null + } + /> + + {showForm && ( +
        +

        Neues Vorhaben

        + {formError &&

        {formError}

        } + setShowForm(false)} + busy={formBusy} + submitLabel="Vorhaben anlegen" + /> +
        + )} + +
        + {widgets.map((widget) => { + const Component = widget.component + return + })} +
        + + {hasCapability('kairo.initiative.read') && ( +

        + + Zum Vorhaben-Katalog → + +

        + )} +
        + ) +} diff --git a/frontend/src/pages/modes/ControlJourneyPage.jsx b/frontend/src/pages/modes/ControlJourneyPage.jsx new file mode 100644 index 0000000..cd439dd --- /dev/null +++ b/frontend/src/pages/modes/ControlJourneyPage.jsx @@ -0,0 +1,13 @@ +import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' +import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx' +import { InitiativeJourneyPage } from '../initiative/InitiativeJourneyPage.jsx' + +export function ControlJourneyPage() { + return ( + + + + + + ) +} diff --git a/frontend/src/pages/modes/ControlLayout.jsx b/frontend/src/pages/modes/ControlLayout.jsx new file mode 100644 index 0000000..d6c011d --- /dev/null +++ b/frontend/src/pages/modes/ControlLayout.jsx @@ -0,0 +1,22 @@ +import { Navigate, Outlet } from 'react-router-dom' +import { ModeShell } from '../../components/ModeShell.jsx' +import { ModeSubNav } from '../../components/ModeSubNav.jsx' + +const CONTROL_NAV = [ + { to: '/control/status', label: 'Steuerung', end: true }, + { to: '/control/journey', label: 'Journey', end: true }, +] + +export function ControlLayout() { + return ( +
        + + + +
        + ) +} + +export function ControlIndexRedirect() { + return +} diff --git a/frontend/src/pages/modes/ControlStatusPage.jsx b/frontend/src/pages/modes/ControlStatusPage.jsx new file mode 100644 index 0000000..779b874 --- /dev/null +++ b/frontend/src/pages/modes/ControlStatusPage.jsx @@ -0,0 +1,13 @@ +import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' +import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx' +import { InitiativeOverviewPage } from '../initiative/InitiativeOverviewPage.jsx' + +export function ControlStatusPage() { + return ( + + + + + + ) +} diff --git a/frontend/src/pages/modes/PlanGatesPage.jsx b/frontend/src/pages/modes/PlanGatesPage.jsx new file mode 100644 index 0000000..97e33bb --- /dev/null +++ b/frontend/src/pages/modes/PlanGatesPage.jsx @@ -0,0 +1,13 @@ +import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' +import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx' +import { InitiativePlanPage } from '../initiative/InitiativePlanPage.jsx' + +export function PlanGatesPage() { + return ( + + + + + + ) +} diff --git a/frontend/src/pages/modes/PlanInboxPage.jsx b/frontend/src/pages/modes/PlanInboxPage.jsx new file mode 100644 index 0000000..329b336 --- /dev/null +++ b/frontend/src/pages/modes/PlanInboxPage.jsx @@ -0,0 +1,13 @@ +import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' +import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx' +import { InitiativeInboxPage } from '../initiative/InitiativeInboxPage.jsx' + +export function PlanInboxPage() { + return ( + + + + + + ) +} diff --git a/frontend/src/pages/modes/PlanLayout.jsx b/frontend/src/pages/modes/PlanLayout.jsx new file mode 100644 index 0000000..024afa4 --- /dev/null +++ b/frontend/src/pages/modes/PlanLayout.jsx @@ -0,0 +1,23 @@ +import { Navigate, Outlet } from 'react-router-dom' +import { ModeShell } from '../../components/ModeShell.jsx' +import { ModeSubNav } from '../../components/ModeSubNav.jsx' + +const PLAN_NAV = [ + { to: '/plan/structure', label: 'Struktur', end: true }, + { to: '/plan/gates', label: 'Zielzustände', end: true }, + { to: '/plan/inbox', label: 'Eingang', end: true }, +] + +export function PlanLayout() { + return ( +
        + + + +
        + ) +} + +export function PlanIndexRedirect() { + return +} diff --git a/frontend/src/pages/modes/PlanStructurePage.jsx b/frontend/src/pages/modes/PlanStructurePage.jsx new file mode 100644 index 0000000..71ec31f --- /dev/null +++ b/frontend/src/pages/modes/PlanStructurePage.jsx @@ -0,0 +1,40 @@ +import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' +import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx' +import { ProjectsSection } from '../../components/ProjectsSection.jsx' +import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' + +function PlanStructureInner() { + const { + initiativeId, + projects, + roadmapItems, + selectedProjectId, + setSelectedProjectId, + capabilities, + error, + } = useInitiativeOperations() + + return ( + <> + {error &&

        {error}

        } + + + ) +} + +export function PlanStructurePage() { + return ( + + + + + + ) +} diff --git a/frontend/src/pages/modes/TeamPage.jsx b/frontend/src/pages/modes/TeamPage.jsx new file mode 100644 index 0000000..2d98027 --- /dev/null +++ b/frontend/src/pages/modes/TeamPage.jsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { getActorWorkload } from '../../api/workspace.js' +import { ModeShell } from '../../components/ModeShell.jsx' +import { LoadingState } from '../../components/LoadingState.jsx' +import { ErrorState } from '../../components/ErrorState.jsx' +import { scopedPath } from '../../utils/routes.js' + +export function TeamPage() { + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + getActorWorkload() + .then((data) => { + if (!cancelled) setRows(data) + }) + .catch((err) => { + if (!cancelled) setError(err.message) + }) + return () => { + cancelled = true + } + }, []) + + return ( +
        + + {rows === null && !error && } + {error && } + {rows && ( +
        +

        Auslastung nach Actor

        + {rows.length === 0 ? ( +

        Keine Zuweisungen im Portfolio.

        + ) : ( +
          + {rows.map((row) => ( +
        • +
          + {row.actor_name || row.actor_id} + + {' '} + · {row.open_actions ?? 0} offen + {(row.blocked_actions ?? 0) > 0 + ? ` · ${row.blocked_actions} blockiert` + : ''} + +
          + + Queue ansehen + +
        • + ))} +
        + )} +

        + AP1.9b erweitert Team um Filter nach Vorhaben und direkte Zuweisungslinks. +

        +
        + )} +
        + ) +} diff --git a/frontend/src/pages/modes/WorkLayout.jsx b/frontend/src/pages/modes/WorkLayout.jsx new file mode 100644 index 0000000..3d60ad3 --- /dev/null +++ b/frontend/src/pages/modes/WorkLayout.jsx @@ -0,0 +1,22 @@ +import { Navigate, Outlet } from 'react-router-dom' +import { ModeShell } from '../../components/ModeShell.jsx' +import { ModeSubNav } from '../../components/ModeSubNav.jsx' + +const WORK_NAV = [ + { to: '/work/today', label: 'Heute', end: true }, + { to: '/work/mine', label: 'Meine Queue', end: true }, +] + +export function WorkLayout() { + return ( +
        + + + +
        + ) +} + +export function WorkIndexRedirect() { + return +} diff --git a/frontend/src/pages/modes/WorkMinePage.jsx b/frontend/src/pages/modes/WorkMinePage.jsx new file mode 100644 index 0000000..d766e8c --- /dev/null +++ b/frontend/src/pages/modes/WorkMinePage.jsx @@ -0,0 +1,5 @@ +import { MyActionsPage } from '../MyActionsPage.jsx' + +export function WorkMinePage() { + return +} diff --git a/frontend/src/pages/modes/WorkTodayPage.jsx b/frontend/src/pages/modes/WorkTodayPage.jsx new file mode 100644 index 0000000..d88467a --- /dev/null +++ b/frontend/src/pages/modes/WorkTodayPage.jsx @@ -0,0 +1,9 @@ +import { WorkspaceTodayWidget } from '../../widgets/WorkspaceTodayWidget.jsx' + +export function WorkTodayPage() { + return ( +
        + +
        + ) +} diff --git a/frontend/src/pages/objects/GateObjectPage.jsx b/frontend/src/pages/objects/GateObjectPage.jsx new file mode 100644 index 0000000..91c7546 --- /dev/null +++ b/frontend/src/pages/objects/GateObjectPage.jsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +import { getRoadmapItem } from '../../api/roadmap.js' +import { LoadingState } from '../../components/LoadingState.jsx' +import { ErrorState } from '../../components/ErrorState.jsx' +import { useProgramScope } from '../../context/ProgramScopeContext.jsx' +import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx' +import { RoadmapItemDetailPage } from '../initiative/RoadmapItemDetailPage.jsx' +import { recordRecentObject } from '../../utils/recentObjects.js' +import { gatePath } from '../../utils/routes.js' + +export function GateObjectPage() { + const { gateId } = useParams() + const { applyFromInitiative } = useProgramScope() + const [gate, setGate] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + setLoading(true) + setError(null) + getRoadmapItem(gateId) + .then((data) => { + if (cancelled) return + setGate(data) + applyFromInitiative(data.initiative_id) + recordRecentObject({ + type: 'gate', + id: data.id, + title: data.title, + href: gatePath(data.id), + initiativeId: data.initiative_id, + }) + }) + .catch((err) => { + if (!cancelled) setError(err.message) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [gateId, applyFromInitiative]) + + if (loading) { + return ( +
        + +
        + ) + } + + if (error || !gate) { + return ( +
        + +
        + ) + } + + return ( +
        + + + +
        + ) +} diff --git a/frontend/src/pages/objects/ProjectObjectPage.jsx b/frontend/src/pages/objects/ProjectObjectPage.jsx new file mode 100644 index 0000000..8dde00a --- /dev/null +++ b/frontend/src/pages/objects/ProjectObjectPage.jsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from 'react' +import { useParams, useSearchParams } from 'react-router-dom' +import { getProject } from '../../api/projects.js' +import { LoadingState } from '../../components/LoadingState.jsx' +import { ErrorState } from '../../components/ErrorState.jsx' +import { useProgramScope } from '../../context/ProgramScopeContext.jsx' +import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx' +import { ProjectDetailPage } from '../initiative/ProjectDetailPage.jsx' +import { recordRecentObject } from '../../utils/recentObjects.js' +import { projectPath } from '../../utils/routes.js' + +export function ProjectObjectPage() { + const { projectId } = useParams() + const [searchParams] = useSearchParams() + const { initiativeId: scopeInitiativeId, applyFromInitiative, applyFromProject } = + useProgramScope() + const initiativeFromQuery = searchParams.get('initiative') || scopeInitiativeId + const [project, setProject] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (projectId === 'new') { + if (initiativeFromQuery) applyFromInitiative(initiativeFromQuery) + setProject(null) + setLoading(false) + return + } + let cancelled = false + setLoading(true) + setError(null) + getProject(projectId) + .then((data) => { + if (cancelled) return + setProject(data) + applyFromProject(data) + recordRecentObject({ + type: 'project', + id: data.id, + title: data.title, + href: projectPath(data.id), + initiativeId: data.initiative_id, + }) + }) + .catch((err) => { + if (!cancelled) setError(err.message) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [projectId, initiativeFromQuery, applyFromInitiative, applyFromProject]) + + if (loading) { + return ( +
        + +
        + ) + } + + if (error) { + return ( +
        + +
        + ) + } + + const initiativeId = project?.initiative_id || initiativeFromQuery + + if (projectId === 'new' && !initiativeId) { + return ( +
        + +
        + ) + } + + return ( +
        + + + +
        + ) +} diff --git a/frontend/src/registry/registry.test.js b/frontend/src/registry/registry.test.js index 692f071..970d3cc 100644 --- a/frontend/src/registry/registry.test.js +++ b/frontend/src/registry/registry.test.js @@ -32,16 +32,19 @@ describe('widgetRegistry', () => { }) describe('viewRegistry', () => { - it('contains workspace, initiatives and my actions views', () => { + it('contains cockpit, work modes and initiatives catalog', () => { const keys = VIEWS.map((v) => v.key) - expect(keys).toContain('kairo.workspace') + expect(keys).toContain('kairo.cockpit') + expect(keys).toContain('kairo.work') + expect(keys).toContain('kairo.plan') + expect(keys).toContain('kairo.control') + expect(keys).toContain('kairo.team') expect(keys).toContain('kairo.initiatives') - expect(keys).toContain('kairo.my_actions') }) it('filters nav views by capability', () => { const nav = getNavViews(['kairo.initiative.read']) - expect(nav.some((v) => v.key === 'kairo.workspace')).toBe(false) + expect(nav.some((v) => v.key === 'kairo.cockpit')).toBe(false) expect(nav.some((v) => v.key === 'kairo.initiatives')).toBe(true) }) }) @@ -52,15 +55,19 @@ describe('appNav', () => { 'kairo.action.read', 'kairo.initiative.read', ]) - expect(items.length).toBe(3) + expect(items.length).toBeGreaterThanOrEqual(5) expect(items[0].Icon).toBeTruthy() }) - it('highlights initiatives for detail route', () => { + it('highlights plan mode for project object route', () => { const items = getAppNavItems(['kairo.action.read', 'kairo.initiative.read']) - const initiatives = items.find((i) => i.key === 'kairo.initiatives') - expect( - isNavItemActive('/initiatives/abc-123', initiatives, false), - ).toBe(true) + const plan = items.find((i) => i.key === 'kairo.plan') + expect(isNavItemActive('/projects/abc-123', plan, false)).toBe(true) + }) + + it('highlights work mode for action object route', () => { + const items = getAppNavItems(['kairo.action.read', 'kairo.initiative.read']) + const work = items.find((i) => i.key === 'kairo.work') + expect(isNavItemActive('/actions/abc-123', work, false)).toBe(true) }) }) diff --git a/frontend/src/registry/viewRegistry.js b/frontend/src/registry/viewRegistry.js index 564bdd7..c4c4467 100644 --- a/frontend/src/registry/viewRegistry.js +++ b/frontend/src/registry/viewRegistry.js @@ -1,17 +1,74 @@ -import { WorkspacePage } from '../pages/WorkspacePage.jsx' +import { CockpitPage } from '../pages/modes/CockpitPage.jsx' +import { WorkLayout, WorkIndexRedirect } from '../pages/modes/WorkLayout.jsx' +import { WorkTodayPage } from '../pages/modes/WorkTodayPage.jsx' +import { WorkMinePage } from '../pages/modes/WorkMinePage.jsx' +import { PlanLayout, PlanIndexRedirect } from '../pages/modes/PlanLayout.jsx' +import { PlanStructurePage } from '../pages/modes/PlanStructurePage.jsx' +import { PlanGatesPage } from '../pages/modes/PlanGatesPage.jsx' +import { PlanInboxPage } from '../pages/modes/PlanInboxPage.jsx' +import { ControlLayout, ControlIndexRedirect } from '../pages/modes/ControlLayout.jsx' +import { ControlStatusPage } from '../pages/modes/ControlStatusPage.jsx' +import { ControlJourneyPage } from '../pages/modes/ControlJourneyPage.jsx' +import { TeamPage } from '../pages/modes/TeamPage.jsx' import { InitiativesPage } from '../pages/InitiativesPage.jsx' -import { MyActionsPage } from '../pages/MyActionsPage.jsx' /** @type {import('./viewRegistry.js').ViewDefinition[]} */ export const VIEWS = [ { - key: 'kairo.workspace', - path: '/workspace', - title: 'Workspace', - navLabel: 'Workspace', - shortLabel: 'Start', + key: 'kairo.cockpit', + path: '/cockpit', + title: 'Cockpit', + navLabel: 'Cockpit', + shortLabel: 'Cockpit', + matchPrefix: '/cockpit', requiredCapability: 'kairo.action.read', - component: WorkspacePage, + component: CockpitPage, + showInNav: true, + }, + { + key: 'kairo.work', + path: '/work', + title: 'Ausführen', + navLabel: 'Ausführen', + shortLabel: 'Arbeit', + matchPrefix: '/work', + objectPrefixes: ['/actions'], + requiredCapability: 'kairo.action.read', + component: WorkLayout, + showInNav: true, + }, + { + key: 'kairo.plan', + path: '/plan', + title: 'Planen', + navLabel: 'Planen', + shortLabel: 'Plan', + matchPrefix: '/plan', + objectPrefixes: ['/projects', '/gates'], + requiredCapability: 'kairo.initiative.read', + component: PlanLayout, + showInNav: true, + }, + { + key: 'kairo.control', + path: '/control', + title: 'Kontrolle', + navLabel: 'Kontrolle', + shortLabel: 'Kontrolle', + matchPrefix: '/control', + requiredCapability: 'kairo.initiative.read', + component: ControlLayout, + showInNav: true, + }, + { + key: 'kairo.team', + path: '/team', + title: 'Team', + navLabel: 'Team', + shortLabel: 'Team', + matchPrefix: '/team', + requiredCapability: 'kairo.action.read', + component: TeamPage, showInNav: true, }, { @@ -25,15 +82,19 @@ export const VIEWS = [ component: InitiativesPage, showInNav: true, }, + { + key: 'kairo.workspace', + path: '/workspace', + title: 'Workspace', + component: CockpitPage, + showInNav: false, + }, { key: 'kairo.my_actions', path: '/my-actions', title: 'Meine Maßnahmen', - navLabel: 'Meine Maßnahmen', - shortLabel: 'Maßnahmen', - requiredCapability: 'kairo.action.read', - component: MyActionsPage, - showInNav: true, + component: WorkMinePage, + showInNav: false, }, ] @@ -45,6 +106,7 @@ export const VIEWS = [ * @property {string} [navLabel] * @property {string} [shortLabel] * @property {string} [matchPrefix] + * @property {string[]} [objectPrefixes] * @property {string} [requiredCapability] * @property {import('react').ComponentType} component * @property {boolean} [showInNav] @@ -59,3 +121,22 @@ export function getNavViews(capabilities) { export function getViewByKey(key) { return VIEWS.find((v) => v.key === key) } + +export const MODE_ROUTE_COMPONENTS = { + workToday: WorkTodayPage, + workMine: WorkMinePage, + planStructure: PlanStructurePage, + planGates: PlanGatesPage, + planInbox: PlanInboxPage, + controlStatus: ControlStatusPage, + controlJourney: ControlJourneyPage, +} + +export { + WorkLayout, + WorkIndexRedirect, + PlanLayout, + PlanIndexRedirect, + ControlLayout, + ControlIndexRedirect, +} diff --git a/frontend/src/styles/program-chrome.css b/frontend/src/styles/program-chrome.css new file mode 100644 index 0000000..7239c28 --- /dev/null +++ b/frontend/src/styles/program-chrome.css @@ -0,0 +1,293 @@ +/* AP1.9a — Program scope bar, quick jump, mode sub-nav */ + +.app-chrome { + display: none; +} + +.app-chrome--desktop { + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 12px 32px; + background: var(--jk-surface); + border-bottom: 1px solid var(--jk-border); +} + +@media (min-width: 1024px) { + .app-chrome--desktop { + display: flex; + } +} + +.program-scope-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 12px; + width: 100%; + padding: 0 16px 8px; + font-size: 13px; +} + +@media (min-width: 1024px) { + .program-scope-bar { + width: auto; + flex: 1; + padding: 0; + } +} + +.program-scope-bar__label { + font-weight: 600; + color: var(--jk-text-subtle); + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.06em; +} + +.program-scope-bar__trail { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} + +.program-scope-bar__crumb { + color: var(--jk-text-muted); + text-decoration: none; + font-weight: 500; +} + +.program-scope-bar__crumb:hover { + color: var(--jk-primary); +} + +.program-scope-bar__crumb--active { + color: var(--jk-text); + font-weight: 600; +} + +.program-scope-bar__sep { + color: var(--jk-text-subtle); +} + +.program-scope-bar__catalog { + margin-left: auto; + font-size: 12px; +} + +.quick-jump { + position: relative; + flex-shrink: 0; +} + +.quick-jump__trigger { + border: 1px solid var(--jk-border); + background: var(--jk-surface-raised); + color: var(--jk-text); + border-radius: 8px; + padding: 8px 12px; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.quick-jump__trigger:hover { + border-color: var(--jk-primary); + color: var(--jk-primary); +} + +.quick-jump__panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + width: min(360px, calc(100vw - 32px)); + background: var(--jk-surface); + border: 1px solid var(--jk-border); + border-radius: 12px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12); + z-index: 40; + padding: 10px; +} + +.quick-jump__input { + width: 100%; + margin-bottom: 8px; +} + +.quick-jump__list { + list-style: none; + margin: 0; + padding: 0; + max-height: 280px; + overflow-y: auto; +} + +.quick-jump__item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + width: 100%; + text-align: left; + border: none; + background: transparent; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; +} + +.quick-jump__item:hover { + background: var(--jk-primary-soft); +} + +.quick-jump__item-label { + font-weight: 600; + font-size: 14px; +} + +.quick-jump__item-meta { + font-size: 12px; + color: var(--jk-text-subtle); +} + +.quick-jump__empty { + padding: 12px; + color: var(--jk-text-subtle); + font-size: 13px; +} + +.quick-jump__footer { + border-top: 1px solid var(--jk-border); + margin-top: 8px; + padding-top: 8px; +} + +.recently-opened { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + width: 100%; + padding: 0 16px 8px; +} + +@media (min-width: 1024px) { + .recently-opened { + width: auto; + padding: 0; + } +} + +.recently-opened__label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--jk-text-subtle); +} + +.recently-opened__items { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.recently-opened__chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 180px; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--jk-border); + background: var(--jk-surface-raised); + text-decoration: none; + color: var(--jk-text); + font-size: 12px; +} + +.recently-opened__chip:hover { + border-color: var(--jk-primary); +} + +.recently-opened__type { + font-weight: 700; + color: var(--jk-primary); + font-size: 10px; +} + +.recently-opened__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mode-subnav { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.mode-subnav__link { + padding: 8px 14px; + border-radius: 999px; + text-decoration: none; + font-size: 13px; + font-weight: 600; + color: var(--jk-text-muted); + border: 1px solid var(--jk-border); + background: var(--jk-surface); +} + +.mode-subnav__link--active { + color: var(--jk-primary); + border-color: var(--jk-primary); + background: var(--jk-primary-soft); +} + +.scope-picker__list { + list-style: none; + margin: 12px 0 0; + padding: 0; + display: grid; + gap: 8px; +} + +.scope-picker__link { + display: block; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--jk-border); + text-decoration: none; + font-weight: 600; + color: var(--jk-text); +} + +.scope-picker__link:hover { + border-color: var(--jk-primary); + background: var(--jk-primary-soft); +} + +.team-workload-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 10px; +} + +.team-workload-list__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding: 10px 0; + border-bottom: 1px solid var(--jk-border); +} + +.section-header--compact { + margin-bottom: 12px; +} diff --git a/frontend/src/utils/recentObjects.js b/frontend/src/utils/recentObjects.js new file mode 100644 index 0000000..3bfcc9e --- /dev/null +++ b/frontend/src/utils/recentObjects.js @@ -0,0 +1,34 @@ +const STORAGE_KEY = 'kairo.recentObjects' +const MAX_ITEMS = 12 + +/** + * @typedef {{ type: string, id: string, title: string, href: string, initiativeId?: string }} RecentObject + */ + +/** @returns {RecentObject[]} */ +export function loadRecentObjects() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +/** @param {RecentObject} entry */ +export function recordRecentObject(entry) { + if (!entry?.id || !entry?.href) return + const items = loadRecentObjects().filter( + (item) => !(item.type === entry.type && item.id === entry.id), + ) + items.unshift({ + type: entry.type, + id: entry.id, + title: entry.title || entry.id, + href: entry.href, + initiativeId: entry.initiativeId || '', + }) + localStorage.setItem(STORAGE_KEY, JSON.stringify(items.slice(0, MAX_ITEMS))) +} diff --git a/frontend/src/utils/routes.js b/frontend/src/utils/routes.js new file mode 100644 index 0000000..b931aa0 --- /dev/null +++ b/frontend/src/utils/routes.js @@ -0,0 +1,57 @@ +/** @typedef {{ initiativeId?: string, projectId?: string }} ProgramScope */ + +export const MODE_META = { + cockpit: { + key: 'cockpit', + title: 'Cockpit', + lead: 'Portfolio-Überblick — welches Vorhaben braucht Aufmerksamkeit?', + }, + work: { + key: 'work', + title: 'Ausführen', + lead: 'Committete Arbeit — was ist jetzt dran?', + }, + plan: { + key: 'plan', + title: 'Planen', + lead: 'Struktur, Gates und Eingang — was ist geplant?', + }, + control: { + key: 'control', + title: 'Kontrolle', + lead: 'Steuerung und Lagebild — wo steht das Vorhaben?', + }, + team: { + key: 'team', + title: 'Team', + lead: 'Auslastung und Zuweisungen über alle Vorhaben.', + }, +} + +/** + * @param {string} path + * @param {ProgramScope} [scope] + */ +export function scopedPath(path, scope = {}) { + const params = new URLSearchParams() + if (scope.initiativeId) params.set('initiative', scope.initiativeId) + if (scope.projectId) params.set('project', scope.projectId) + const qs = params.toString() + return qs ? `${path}?${qs}` : path +} + +export function actionPath(actionId) { + return `/actions/${actionId}` +} + +export function projectPath(projectId) { + return `/projects/${projectId}` +} + +export function gatePath(gateId) { + return `/gates/${gateId}` +} + +export function initiativeCatalogPath() { + return '/initiatives' +} diff --git a/frontend/src/widgets/AttentionWidget.jsx b/frontend/src/widgets/AttentionWidget.jsx index bcf3e9a..073f7e9 100644 --- a/frontend/src/widgets/AttentionWidget.jsx +++ b/frontend/src/widgets/AttentionWidget.jsx @@ -7,6 +7,7 @@ import { WidgetCard } from '../components/WidgetCard.jsx' import { EmptyState } from '../components/EmptyState.jsx' import { ATTENTION_KIND_LABELS } from '../constants/operating.js' +import { actionPath, scopedPath } from '../utils/routes.js' const SEVERITY_LABELS = { critical: 'Kritisch', @@ -15,11 +16,11 @@ const SEVERITY_LABELS = { } function attentionLink(item) { - if (item.initiative_id && item.action_id) { - return `/initiatives/${item.initiative_id}` + if (item.action_id) { + return actionPath(item.action_id) } if (item.initiative_id) { - return `/initiatives/${item.initiative_id}` + return scopedPath('/control/status', { initiativeId: item.initiative_id }) } return null } diff --git a/frontend/src/widgets/BlockedActionsWidget.jsx b/frontend/src/widgets/BlockedActionsWidget.jsx index 06da749..c0627cf 100644 --- a/frontend/src/widgets/BlockedActionsWidget.jsx +++ b/frontend/src/widgets/BlockedActionsWidget.jsx @@ -5,6 +5,7 @@ import { ActionList } from '../components/ActionList.jsx' import { EmptyState } from '../components/EmptyState.jsx' import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' +import { actionPath } from '../utils/routes.js' import { WidgetCard } from '../components/WidgetCard.jsx' export function BlockedActionsWidget() { @@ -46,13 +47,13 @@ export function BlockedActionsWidget() { `/initiatives/${a.initiative_id}`} + getManageLink={(a) => actionPath(a.id)} empty={} /> )} {!loading && !error && actions.length > 0 && (

        - + In „Meine Maßnahmen“ bearbeiten

        diff --git a/frontend/src/widgets/InitiativePortfolioWidget.jsx b/frontend/src/widgets/InitiativePortfolioWidget.jsx index 8f6ad6d..b323aa1 100644 --- a/frontend/src/widgets/InitiativePortfolioWidget.jsx +++ b/frontend/src/widgets/InitiativePortfolioWidget.jsx @@ -6,6 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx' import { EmptyState } from '../components/EmptyState.jsx' import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' +import { scopedPath } from '../utils/routes.js' import { WidgetCard } from '../components/WidgetCard.jsx' export function InitiativePortfolioWidget() { @@ -51,7 +52,7 @@ export function InitiativePortfolioWidget() { {initiatives.map((item) => (

        {item.title}

        diff --git a/frontend/src/widgets/InitiativesWidget.jsx b/frontend/src/widgets/InitiativesWidget.jsx index 68e4925..c9a5396 100644 --- a/frontend/src/widgets/InitiativesWidget.jsx +++ b/frontend/src/widgets/InitiativesWidget.jsx @@ -6,6 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx' import { EmptyState } from '../components/EmptyState.jsx' import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' +import { scopedPath } from '../utils/routes.js' import { WidgetCard } from '../components/WidgetCard.jsx' export function InitiativesWidget() { @@ -48,7 +49,7 @@ export function InitiativesWidget() {
          {initiatives.map((item) => (
        • - + {item.title}
          diff --git a/frontend/src/widgets/MyOpenActionsWidget.jsx b/frontend/src/widgets/MyOpenActionsWidget.jsx index 3270ebc..de2d9c5 100644 --- a/frontend/src/widgets/MyOpenActionsWidget.jsx +++ b/frontend/src/widgets/MyOpenActionsWidget.jsx @@ -5,6 +5,7 @@ import { ActionList } from '../components/ActionList.jsx' import { EmptyState } from '../components/EmptyState.jsx' import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' +import { actionPath } from '../utils/routes.js' import { WidgetCard } from '../components/WidgetCard.jsx' export function MyOpenActionsWidget() { @@ -40,7 +41,7 @@ export function MyOpenActionsWidget() { } footer={ actions.length > 0 ? ( - + Alle meine Maßnahmen → ) : null @@ -52,7 +53,7 @@ export function MyOpenActionsWidget() { `/initiatives/${a.initiative_id}`} + getManageLink={(a) => actionPath(a.id)} empty={ } diff --git a/frontend/src/widgets/NextActionWidget.jsx b/frontend/src/widgets/NextActionWidget.jsx index b548962..e38a479 100644 --- a/frontend/src/widgets/NextActionWidget.jsx +++ b/frontend/src/widgets/NextActionWidget.jsx @@ -5,7 +5,7 @@ import { NEXT_ACTION_KIND_LABELS } from '../constants/operating.js' import { EmptyState } from '../components/EmptyState.jsx' import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' -import { WidgetCard } from '../components/WidgetCard.jsx' +import { actionPath, scopedPath } from '../utils/routes.js' function NextActionList({ items, initiativeId, showInitiativeLink = true }) { if (items.length === 0) { @@ -33,16 +33,16 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) { )}
          - {item.action_id && initiativeId && ( - + {item.action_id && ( + Arbeitspaket )} {showInitiativeLink && item.initiative_id && !initiativeId && ( - + Zum Vorhaben )} diff --git a/frontend/src/widgets/WorkspaceTodayWidget.jsx b/frontend/src/widgets/WorkspaceTodayWidget.jsx index 4fca643..3835056 100644 --- a/frontend/src/widgets/WorkspaceTodayWidget.jsx +++ b/frontend/src/widgets/WorkspaceTodayWidget.jsx @@ -6,7 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx' import { EmptyState } from '../components/EmptyState.jsx' import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' -import { WidgetCard } from '../components/WidgetCard.jsx' +import { actionPath, scopedPath } from '../utils/routes.js' function formatDue(iso) { if (!iso) return null @@ -55,7 +55,7 @@ export function WorkspaceTodayWidget() { } footer={ actions.length > 0 ? ( - + Alle meine Maßnahmen → ) : null @@ -85,7 +85,7 @@ export function WorkspaceTodayWidget() {
          - + Öffnen