feat(AP2.2b, UX): Linear E2E, Composition-Kernel und Steering-Paritaet
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 4m24s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 4m24s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
AP2.2b: Integrationstests Neue-Kueche-Happy-Path, Truth Table und Execution Plan abgeschlossen. UX Composition Kernel (Control/Plan/Cockpit-Slots), CriticalPathPanel aus Kernel-Read-Model mit Gate-Horizont, Next Action elementgesteuert ohne Snapshot-Doppelung, Product-Continuous-Copy und Plan/Arbeit ueber Kernel planning_debt. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b6434bcc50
commit
43d33d0edb
238
backend/tests/test_ap22b_linear_e2e.py
Normal file
238
backend/tests/test_ap22b_linear_e2e.py
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
"""AP2.2b — A2 linear project End-to-End (Neue Küche Happy Path)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
||||||
|
|
||||||
|
_STARTER_ACTION = "Erster Schritt — Planung konkretisieren"
|
||||||
|
_GATE_TITLES = (
|
||||||
|
"G1 — Planung",
|
||||||
|
"G2 — Vorbereitung",
|
||||||
|
"G3 — Umsetzung",
|
||||||
|
"G4 — Abschluss",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _gate_by_title(items: list[dict], prefix: str) -> dict:
|
||||||
|
return next(i for i in items if i.get("title", "").startswith(prefix))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_kitchen(client, token):
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="Neue Küche",
|
||||||
|
archetype_key="initiative.linear_project",
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
body = created.json()
|
||||||
|
assert body["starter_kit"]["applied"] is True
|
||||||
|
assert body["archetype_key"] == "initiative.linear_project"
|
||||||
|
return body["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a2_starter_kit_kitchen_structure(client):
|
||||||
|
"""Starter-Kit: Gates in Kette, Projects, Guidance, Operating Context."""
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_kitchen(client, token)
|
||||||
|
|
||||||
|
ctx = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/operating-context",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert ctx.status_code == 200
|
||||||
|
body = ctx.json()
|
||||||
|
assert body["method_key"] == "sequential_dependency"
|
||||||
|
assert "critical_path" in body["steering_elements"]
|
||||||
|
assert "gate_fulfillment" in body["steering_elements"]
|
||||||
|
assert body["ui_profile"]["planDefaultRoute"] == "/plan/gates"
|
||||||
|
assert body["ui_profile"]["controlDefaultRoute"] == "/control/status"
|
||||||
|
|
||||||
|
roadmap = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/roadmap/items",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert roadmap.status_code == 200
|
||||||
|
items = roadmap.json()
|
||||||
|
assert len(items) == 4
|
||||||
|
assert {i["title"] for i in items} == set(_GATE_TITLES)
|
||||||
|
assert sum(1 for i in items if i["status"] == "active") == 1
|
||||||
|
assert _gate_by_title(items, "G1")["status"] == "active"
|
||||||
|
|
||||||
|
deps = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/roadmap/dependencies",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert deps.status_code == 200
|
||||||
|
gate_ids = {i["title"]: i["id"] for i in items}
|
||||||
|
dep_pairs = {(d["from_item_id"], d["to_item_id"]) for d in deps.json()}
|
||||||
|
assert (gate_ids["G2 — Vorbereitung"], gate_ids["G1 — Planung"]) in dep_pairs
|
||||||
|
assert (gate_ids["G3 — Umsetzung"], gate_ids["G2 — Vorbereitung"]) in dep_pairs
|
||||||
|
assert (gate_ids["G4 — Abschluss"], gate_ids["G3 — Umsetzung"]) in dep_pairs
|
||||||
|
|
||||||
|
projects = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/projects",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert projects.status_code == 200
|
||||||
|
assert {"Hauptpfad", "Begleitung"} <= {p["title"] for p in projects.json()}
|
||||||
|
|
||||||
|
actions = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/actions",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert actions.status_code == 200
|
||||||
|
assert any(a["title"] == _STARTER_ACTION for a in actions.json())
|
||||||
|
|
||||||
|
snap = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert snap.status_code == 200
|
||||||
|
snap_body = snap.json()
|
||||||
|
assert snap_body["steering_guidance"]
|
||||||
|
assert snap_body["steering_kernel"]["primary_method_key"] == "sequential_dependency"
|
||||||
|
assert snap_body["steering_kernel"]["horizon"]["kind"] == "gate"
|
||||||
|
next_actions = snap_body.get("next_actions") or []
|
||||||
|
assert next_actions
|
||||||
|
starter = next(a for a in actions.json() if a["title"] == _STARTER_ACTION)
|
||||||
|
assert next_actions[0]["action_id"] == starter["id"]
|
||||||
|
assert next_actions[0]["reason_code"] in (
|
||||||
|
"execution_ready",
|
||||||
|
"execution_critical_path",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a2_kitchen_critical_path_complete_advances_next(client):
|
||||||
|
"""Montage-Kette am aktiven Gate: kritischer Pfad, Next mit Begründung, Fortschritt."""
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_kitchen(client, token)
|
||||||
|
|
||||||
|
actions = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/actions",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
starter = next(a for a in actions.json() if a["title"] == _STARTER_ACTION)
|
||||||
|
deleted = client.delete(
|
||||||
|
f"/api/actions/{starter['id']}",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
|
||||||
|
roadmap = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/roadmap/items",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
g1_id = _gate_by_title(roadmap.json(), "G1")["id"]
|
||||||
|
|
||||||
|
titles = ["Geräte geliefert", "Einbau Montage", "Endabnahme Küche"]
|
||||||
|
action_ids: list[str] = []
|
||||||
|
for index, title in enumerate(titles):
|
||||||
|
res = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/actions",
|
||||||
|
json={
|
||||||
|
"title": title,
|
||||||
|
"status": "open",
|
||||||
|
"roadmap_item_id": g1_id,
|
||||||
|
"sort_order": index,
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
action_ids.append(res.json()["id"])
|
||||||
|
|
||||||
|
for pred, succ in zip(action_ids[:-1], action_ids[1:]):
|
||||||
|
dep = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/execution/dependencies",
|
||||||
|
json={
|
||||||
|
"predecessor_action_id": pred,
|
||||||
|
"successor_action_id": succ,
|
||||||
|
"dependency_kind": "requires",
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert dep.status_code == 201
|
||||||
|
|
||||||
|
graph = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/execution/graph-state",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert graph.status_code == 200
|
||||||
|
graph_body = graph.json()
|
||||||
|
assert graph_body["critical_path"] == action_ids
|
||||||
|
assert action_ids[0] in graph_body["ready_actions"]
|
||||||
|
assert action_ids[1] in graph_body["blocked_actions"]
|
||||||
|
|
||||||
|
snap = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert snap.status_code == 200
|
||||||
|
snap_body = snap.json()
|
||||||
|
kernel = snap_body["steering_kernel"]
|
||||||
|
exec_graph = kernel["read_models"].get("execution_graph") or {}
|
||||||
|
assert exec_graph.get("critical_path") == action_ids
|
||||||
|
|
||||||
|
next_actions = snap_body.get("next_actions") or []
|
||||||
|
assert next_actions
|
||||||
|
top = next_actions[0]
|
||||||
|
assert top["action_id"] == action_ids[0]
|
||||||
|
assert top["reason_code"] in ("execution_ready", "execution_critical_path")
|
||||||
|
|
||||||
|
done = client.patch(
|
||||||
|
f"/api/actions/{action_ids[0]}",
|
||||||
|
json={"status": "done"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert done.status_code == 200
|
||||||
|
|
||||||
|
snap_after = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert snap_after.status_code == 200
|
||||||
|
next_after = snap_after.json().get("next_actions") or []
|
||||||
|
assert next_after
|
||||||
|
assert next_after[0]["action_id"] == action_ids[1]
|
||||||
|
assert next_after[0]["reason_code"] in (
|
||||||
|
"execution_ready",
|
||||||
|
"execution_critical_path",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a2_kitchen_planning_debt_surfaces_gate_proposals(client):
|
||||||
|
"""Aktives Gate ohne Actions → Planning Debt + gate_next_actions Proposal."""
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_kitchen(client, token)
|
||||||
|
|
||||||
|
actions = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/actions",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
starter = next(a for a in actions.json() if a["title"] == _STARTER_ACTION)
|
||||||
|
deleted = client.delete(
|
||||||
|
f"/api/actions/{starter['id']}",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
|
||||||
|
snap = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert snap.status_code == 200
|
||||||
|
snap_body = snap.json()
|
||||||
|
planning_debt = snap_body["steering_kernel"]["read_models"].get("planning_debt") or []
|
||||||
|
assert planning_debt
|
||||||
|
assert any(d.get("kind") == "missing_execution_plan" for d in planning_debt)
|
||||||
|
|
||||||
|
gate_proposals = snap_body.get("gate_next_actions_proposals") or []
|
||||||
|
assert gate_proposals
|
||||||
|
assert any(p.get("reason_code") == "planning_debt" for p in gate_proposals)
|
||||||
|
|
||||||
|
attention_codes = {a.get("code") for a in snap_body.get("attention_items") or []}
|
||||||
|
assert "planning_debt" in attention_codes
|
||||||
182
docs/architecture/ADP_UX_Composition_Kernel_v0.1.md
Normal file
182
docs/architecture/ADP_UX_Composition_Kernel_v0.1.md
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
# ADP — UX Composition Kernel v0.1 (AP-UX-0 / AP-UX-1)
|
||||||
|
|
||||||
|
**Status:** PO-Arbeitsentwurf — **MVP geliefert** (2026-07-27)
|
||||||
|
**Stand:** 2026-07-27
|
||||||
|
**Bezug:** `ADP_Steering_Kernel_Extension_Model_v0.1.md`, `ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md`, `Kairo_PM_Frontend_UI_Concept_v0.1.md`, `ARCHETYPE_UI_NAVIGATION_v0.1.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
Das Backend hat mit dem **Steering Kernel v0.4** einen zentralen Steuerungs-Einstieg (`evaluate_steering()`). Provider für Read Models, Proposals und Agent-Slots hängen an **registrierten Keys** und `steering_elements` — nicht an Archetyp-Ifs.
|
||||||
|
|
||||||
|
Das Frontend spiegelt das **noch nicht zentral**:
|
||||||
|
|
||||||
|
| Baustein | Heute | Soll |
|
||||||
|
|----------|-------|------|
|
||||||
|
| Control-Panels | `InitiativeOverviewPage` mit manuellen Ifs | UI-Slots + Provider |
|
||||||
|
| Proposals | `SteeringProposalsPanel` direkt in Plan-Pages | Slot `plan.*.proposals` |
|
||||||
|
| Agent-Slots | Direktimport in Overview | Slot `control.status.agent` |
|
||||||
|
| Cockpit-Widgets | `widgetRegistry` + manuelle Grid-Schleife | Slot `cockpit.main` |
|
||||||
|
| Steuerungselement-Registry | Labels/Hints only | Aktiviert Composition-Provider |
|
||||||
|
|
||||||
|
**Leitfrage:** Wie composen Cockpit, Control und Plan konsistent aus dem gleichen Modell — analog zum Steering Kernel?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Entscheidung — Vier Schichten (Frontend-Spiegel)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Operating Context + Steering Snapshot
|
||||||
|
↓
|
||||||
|
resolveSteeringComposition(mode, routeKey, scope, context)
|
||||||
|
↓
|
||||||
|
UI-Slots (z.B. control.status.steering, plan.inbox.proposals)
|
||||||
|
↓
|
||||||
|
registrierte Provider (steering_element | proposal | agent_slots | widget | core)
|
||||||
|
↓
|
||||||
|
React-Komponenten (generisch, keine Archetyp-Ifs)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Schicht | Registry | Liefert |
|
||||||
|
|---------|----------|---------|
|
||||||
|
| **UI-Slot** | `composition/uiSlotRegistry.js` | Benannte Flächen pro Modus/Route |
|
||||||
|
| **Provider** | `composition/compositionProviders.js` | Match-Regeln + Slot-Zuordnung |
|
||||||
|
| **Resolver** | `composition/resolveSteeringComposition.js` | Aktive Provider pro Slot zur Laufzeit |
|
||||||
|
| **Renderer** | `CompositionSlot.jsx`, `providerComponents.jsx` | Generisches Rendering |
|
||||||
|
|
||||||
|
**Parallele zum Backend:**
|
||||||
|
|
||||||
|
| Backend (Steering Kernel) | Frontend (UX Composition) |
|
||||||
|
|---------------------------|---------------------------|
|
||||||
|
| `steering_elements` | Provider `kind: steering_element` |
|
||||||
|
| `proposals[key]` | Provider `kind: proposal` |
|
||||||
|
| `agent_slots[]` | Provider `kind: agent_slots` |
|
||||||
|
| Portfolio-Aggregation | Provider `kind: widget` (Cockpit) |
|
||||||
|
| Snapshot-Kern | Provider `kind: core` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UI-Slots (MVP)
|
||||||
|
|
||||||
|
Slots sind **stabil benannte Flächen** — unabhängig von React-Page-Struktur.
|
||||||
|
|
||||||
|
| Slot-Key | Modus | Route | Inhalt (MVP) |
|
||||||
|
|----------|-------|-------|--------------|
|
||||||
|
| `cockpit.main` | cockpit | — | Portfolio-Widgets |
|
||||||
|
| `control.status.core` | control | status | Steering-Snapshot |
|
||||||
|
| `control.status.steering` | control | status | Kritischer Pfad, Next Action |
|
||||||
|
| `control.status.agent` | control | status | Agent-Slots |
|
||||||
|
| `control.status.alerts` | control | status | Roadblocker-Strip |
|
||||||
|
| `plan.inbox.proposals` | plan | inbox | Triage-Vorschläge |
|
||||||
|
| `plan.sprint.proposals` | plan | sprint | Sprint-Commit-Vorschläge |
|
||||||
|
| `plan.gates.proposals` | plan | gates | Gate-Vorschläge |
|
||||||
|
|
||||||
|
**Erweiterung (post-MVP):** `control.plan-ist.read_models`, `plan.inbox.read_models`, Work-Modus-Slots.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Provider-Vertrag
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
key: 'steering.critical_path',
|
||||||
|
kind: 'steering_element', // steering_element | proposal | agent_slots | widget | core | conditional
|
||||||
|
steeringElement: 'critical_path',
|
||||||
|
slotKeys: ['control.status.steering'],
|
||||||
|
componentKey: 'CriticalPathPanel',
|
||||||
|
requiresCapability: 'kairo.action.read',
|
||||||
|
scopeTypes: ['initiative'], // portfolio | initiative
|
||||||
|
order: 10,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Match-Regeln
|
||||||
|
|
||||||
|
| kind | Aktiv wenn |
|
||||||
|
|------|------------|
|
||||||
|
| `steering_element` | `steering_elements` enthält Key |
|
||||||
|
| `proposal` | `steering_kernel.proposals[key]` nicht leer (+ optional `filterContext`) |
|
||||||
|
| `agent_slots` | `agent_slots` oder `steering_kernel.agent_slots` nicht leer |
|
||||||
|
| `widget` | Capability erfüllt; Widget aus `widgetRegistry` |
|
||||||
|
| `core` | Immer auf Surface (Scope + Capability) |
|
||||||
|
| `conditional` | Custom predicate (z.B. Roadblocker counts > 0) |
|
||||||
|
|
||||||
|
**Verboten:** `archetype_key === '…'` in Pages — nur Provider-Match über Operating Context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Auflösung zur Laufzeit
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
resolveSteeringComposition({
|
||||||
|
mode: 'control',
|
||||||
|
routeKey: 'status',
|
||||||
|
scope: 'initiative',
|
||||||
|
operatingContext,
|
||||||
|
steeringSnapshot,
|
||||||
|
capabilities,
|
||||||
|
filterContext: {},
|
||||||
|
// Initiative-Ops für Props:
|
||||||
|
opsContext: { initiativeId, actions, ... },
|
||||||
|
})
|
||||||
|
// → { surfaceKey: 'control.status', slots: { 'control.status.core': [ProviderInstance, ...], ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Surfaces sind deklarativ in `COMPOSITION_SURFACES` — eine Page rendert `<CompositionSurface surfaceKey="control.status" />` statt ad-hoc Imports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Frontend-Dateien (MVP)
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/src/composition/
|
||||||
|
uiSlotRegistry.js — Slot- + Surface-Definitionen
|
||||||
|
compositionProviders.js — Provider-Registry
|
||||||
|
resolveSteeringComposition.js — Resolver + buildProviderProps
|
||||||
|
resolveSteeringComposition.test.js
|
||||||
|
providerComponents.jsx — Component-Map + Prop-Adapter
|
||||||
|
CompositionSlot.jsx — Slot-Renderer
|
||||||
|
CompositionSurface.jsx — Surface-Renderer (mehrere Slots)
|
||||||
|
useSteeringComposition.js — Hook (InitiativeOperationsContext)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Migration (AP-UX-1)
|
||||||
|
|
||||||
|
| Page | Vorher | Nachher |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `InitiativeOverviewPage` | Manuelle Panel-Ifs | `CompositionSurface surfaceKey="control.status"` |
|
||||||
|
| `InitiativeInboxPage` | Direkt `SteeringProposalsPanel` | Slot `plan.inbox.proposals` |
|
||||||
|
| `PlanSprintPage` | Direkt `SteeringProposalsPanel` | Slot `plan.sprint.proposals` |
|
||||||
|
| `InitiativePlanPage` | Direkt `SteeringProposalsPanel` | Slot `plan.gates.proposals` |
|
||||||
|
| `CockpitPage` | `getWidgetsForArea` Schleife | Slot `cockpit.main` via Composition |
|
||||||
|
|
||||||
|
**Nicht migriert (post-MVP):** `PlanIstPanel`, `BacklogSection`, `GatesPlanPanel` — Domain-CRUD bleibt außerhalb Composition (P4: Übersicht entscheidet, Detail pflegt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Abnahme (AP-UX-1)
|
||||||
|
|
||||||
|
- [ ] `resolveSteeringComposition` Unit-Tests für alle Provider-Kinds
|
||||||
|
- [ ] Control/Plan-Pages ohne direkte `SteeringProposalsPanel`/`AgentSlotsPanel`-Imports
|
||||||
|
- [ ] Keine neuen Archetyp-Ifs in migrierten Pages
|
||||||
|
- [ ] Vitest grün (`npm test`)
|
||||||
|
- [ ] Dev-Deploy: Cockpit, Control/status, Plan/inbox/sprint/gates funktional
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Referenzen
|
||||||
|
|
||||||
|
| Artefakt | Pfad |
|
||||||
|
|----------|------|
|
||||||
|
| Steering Element Registry | `frontend/src/registry/steeringElementRegistry.js` |
|
||||||
|
| Proposal UI Config | `frontend/src/utils/steeringProposals.js` |
|
||||||
|
| Widget Registry | `frontend/src/registry/widgetRegistry.js` |
|
||||||
|
| Operating Profile | `frontend/src/registry/resolveOperatingProfile.js` |
|
||||||
|
| Steering Kernel Coding Rules | `docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*AP-UX-0 = dieses ADP. AP-UX-1 = MVP-Implementierung unter `frontend/src/composition/`.*
|
||||||
|
|
@ -157,7 +157,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| Archetyp | Anlage+Kit | Struktur pflegen | Kontrolle | Op-API | Paket |
|
| Archetyp | Anlage+Kit | Struktur pflegen | Kontrolle | Op-API | Paket |
|
||||||
|----------|------------|------------------|-----------|--------|-------|
|
|----------|------------|------------------|-----------|--------|-------|
|
||||||
| A1 Reifegrad | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2c + AP2.0e |
|
| A1 Reifegrad | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2c + AP2.0e |
|
||||||
| A2 Linear | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2b |
|
| A2 Linear | ✓ AP2.2a | ✓ | ✓ | ◐ | AP2.2b ✓ |
|
||||||
| B2b Product | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2d ✓ |
|
| B2b Product | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2d ✓ |
|
||||||
| B3 Sprint-Profil | ◐ | ◐ | ◐ | ◐ | AP2.2d ✓ |
|
| B3 Sprint-Profil | ◐ | ◐ | ◐ | ◐ | AP2.2d ✓ |
|
||||||
| B2a Programm | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2e |
|
| B2a Programm | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2e |
|
||||||
|
|
@ -230,7 +230,8 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1
|
||||||
| AP2.2a | Starter-Kits ◐→✓ |
|
| AP2.2a | Starter-Kits ◐→✓ |
|
||||||
| ADP Backlog/Epic | P1–P8 ✓ · K-Ext-1/2/3/4/5 ✓ |
|
| ADP Backlog/Epic | P1–P8 ✓ · K-Ext-1/2/3/4/5 ✓ |
|
||||||
| Steering Kernel Proposals | ✓ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.4) |
|
| Steering Kernel Proposals | ✓ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.4) |
|
||||||
| AP2.2b–e | Referenz-Archetypen End-to-End ◐→✓ |
|
| AP2.2b | A2 Linear E2E ✓ (2026-07-27) |
|
||||||
|
| AP2.2c–e | Referenz-Archetypen End-to-End ◐→✓ |
|
||||||
| AP2.1 | MVP-Abnahfe ✗→✓ |
|
| AP2.1 | MVP-Abnahfe ✗→✓ |
|
||||||
| AP1.7b | Op-API Parität |
|
| AP1.7b | Op-API Parität |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ Phase 0b AP2.3/AP2.4 Plugin-Architektur (Archetyp ↔ Methode) ✓
|
||||||
Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ✓
|
Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ✓
|
||||||
Phase 1b Specs Welle 1 finalisieren (A1,A2,B2a,B2b,B3) ◐
|
Phase 1b Specs Welle 1 finalisieren (A1,A2,B2a,B2b,B3) ◐
|
||||||
Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) ◐ (Hints + Recurring)
|
Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) ◐ (Hints + Recurring)
|
||||||
Phase 3 AP2.2b A2 Linear End-to-End ◐
|
Phase 3 AP2.2b A2 Linear End-to-End ✓
|
||||||
AP2.2c A1 Reifegrad + AP2.0e ◐ Code
|
AP2.2c A1 Reifegrad + AP2.0e ◐ Code
|
||||||
AP2.2d B2b Product + B3 Sprint ✓
|
AP2.2d B2b Product + B3 Sprint ✓
|
||||||
AP2.2e B2a Programm (optional vor AP2.1)
|
AP2.2e B2a Programm (optional vor AP2.1)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { StatusBadge } from './StatusBadge.jsx'
|
||||||
import { ExecutionFlowBadge } from './ExecutionFlowBadge.jsx'
|
import { ExecutionFlowBadge } from './ExecutionFlowBadge.jsx'
|
||||||
import {
|
import {
|
||||||
buildCriticalPathSteps,
|
buildCriticalPathSteps,
|
||||||
findNextReadyOnCriticalPath,
|
|
||||||
summarizeCriticalPath,
|
summarizeCriticalPath,
|
||||||
} from '../utils/executionGraph.js'
|
} from '../utils/executionGraph.js'
|
||||||
import { actionPath, scopedPath } from '../utils/routes.js'
|
import { actionPath, scopedPath } from '../utils/routes.js'
|
||||||
|
|
@ -16,39 +15,47 @@ import { actionPath, scopedPath } from '../utils/routes.js'
|
||||||
export function CriticalPathPanel({
|
export function CriticalPathPanel({
|
||||||
initiativeId,
|
initiativeId,
|
||||||
actions = [],
|
actions = [],
|
||||||
|
graphState: kernelGraphState = null,
|
||||||
|
graphLoading = false,
|
||||||
|
scopeRoadmapItemId = null,
|
||||||
embedded = true,
|
embedded = true,
|
||||||
}) {
|
}) {
|
||||||
const [graphState, setGraphState] = useState(null)
|
const useKernelGraph = kernelGraphState != null || graphLoading
|
||||||
const [loading, setLoading] = useState(true)
|
const [fallbackGraphState, setFallbackGraphState] = useState(null)
|
||||||
|
const [fallbackLoading, setFallbackLoading] = useState(!useKernelGraph)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initiativeId) {
|
if (!initiativeId || useKernelGraph) {
|
||||||
setGraphState(null)
|
setFallbackGraphState(null)
|
||||||
setLoading(false)
|
setFallbackLoading(false)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setLoading(true)
|
setFallbackLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
getInitiativeExecutionGraphState(initiativeId)
|
getInitiativeExecutionGraphState(initiativeId, {
|
||||||
|
scopeRoadmapItemId: scopeRoadmapItemId || undefined,
|
||||||
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) setGraphState(data)
|
if (!cancelled) setFallbackGraphState(data)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!cancelled) setError(err.message)
|
if (!cancelled) setError(err.message)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false)
|
if (!cancelled) setFallbackLoading(false)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [initiativeId])
|
}, [initiativeId, scopeRoadmapItemId, useKernelGraph])
|
||||||
|
|
||||||
|
const graphState = useKernelGraph ? kernelGraphState : fallbackGraphState
|
||||||
|
const loading = useKernelGraph ? graphLoading : fallbackLoading
|
||||||
|
|
||||||
const steps = buildCriticalPathSteps(graphState, actions)
|
const steps = buildCriticalPathSteps(graphState, actions)
|
||||||
const summary = summarizeCriticalPath(graphState, actions)
|
const summary = summarizeCriticalPath(graphState, actions)
|
||||||
const nextReady = findNextReadyOnCriticalPath(graphState, actions)
|
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
<>
|
<>
|
||||||
|
|
@ -57,26 +64,11 @@ export function CriticalPathPanel({
|
||||||
<ErrorState message={error} onRetry={() => window.location.reload()} />
|
<ErrorState message={error} onRetry={() => window.location.reload()} />
|
||||||
)}
|
)}
|
||||||
{!loading && !error && steps.length === 0 && (
|
{!loading && !error && steps.length === 0 && (
|
||||||
<EmptyState message="Noch kein kritischer Pfad — Arbeitspakete mit Abhängigkeiten unter Plan → Zielzustände / Durchführungsplan anlegen." />
|
<EmptyState message="Noch kein kritischer Pfad im aktiven Gate-Horizont — Arbeitspakete mit Abhängigkeiten unter Plan → Zielzustände / Durchführungsplan anlegen." />
|
||||||
)}
|
)}
|
||||||
{!loading && !error && steps.length > 0 && (
|
{!loading && !error && steps.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="critical-path-summary muted">{summary.message}</p>
|
<p className="critical-path-summary muted">{summary.message}</p>
|
||||||
{nextReady && (
|
|
||||||
<div className="critical-path-next card-list-item">
|
|
||||||
<span className="badge status-badge status-active">Nächster Schritt</span>
|
|
||||||
<strong>{nextReady.action.title}</strong>
|
|
||||||
<p className="muted">
|
|
||||||
Kritischer Pfad — ausführungsbereit, keine blockierenden Vorgänger.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
to={actionPath(nextReady.actionId)}
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
>
|
|
||||||
Arbeitspaket öffnen
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ol className="critical-path-steps item-list">
|
<ol className="critical-path-steps item-list">
|
||||||
{steps.map((step) => (
|
{steps.map((step) => (
|
||||||
<li
|
<li
|
||||||
|
|
@ -120,8 +112,8 @@ export function CriticalPathPanel({
|
||||||
<div>
|
<div>
|
||||||
<h2>Kritischer Pfad</h2>
|
<h2>Kritischer Pfad</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Längster Abhängigkeitspfad aus dem Execution Graph — Steuerungsantwort für
|
Abhängigkeitspfad im aktiven Gate-Horizont — Steuerungsantwort für lineare
|
||||||
lineare Vorhaben (A2).
|
Vorhaben (A2).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import {
|
import {
|
||||||
LIFECYCLE_LABELS,
|
LIFECYCLE_LABELS,
|
||||||
SIGNAL_LABELS,
|
SIGNAL_LABELS,
|
||||||
NEXT_ACTION_KIND_LABELS,
|
|
||||||
} from '../constants/operating.js'
|
} from '../constants/operating.js'
|
||||||
import { MILESTONE_STATUS_LABELS } from '../constants/status.js'
|
import { MILESTONE_STATUS_LABELS } from '../constants/status.js'
|
||||||
import { methodDescriptionForKey } from '../utils/archetypes.js'
|
import { methodDescriptionForKey } from '../utils/archetypes.js'
|
||||||
import { ArchetypeSteeringHints } from './ArchetypeSteeringHints.jsx'
|
import { ArchetypeSteeringHints } from './ArchetypeSteeringHints.jsx'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { scopedPath } from '../utils/routes.js'
|
|
||||||
|
|
||||||
function formatDate(iso) {
|
function formatDate(iso) {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
|
|
@ -26,6 +23,7 @@ export function SteeringSnapshotPanel({
|
||||||
canManageMethod = false,
|
canManageMethod = false,
|
||||||
onMethodChange,
|
onMethodChange,
|
||||||
methodBusy = false,
|
methodBusy = false,
|
||||||
|
showGateHorizon = false,
|
||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -60,7 +58,6 @@ export function SteeringSnapshotPanel({
|
||||||
signals = [],
|
signals = [],
|
||||||
counts,
|
counts,
|
||||||
upcoming_milestones = [],
|
upcoming_milestones = [],
|
||||||
next_actions = [],
|
|
||||||
active_work_cycle = null,
|
active_work_cycle = null,
|
||||||
} = snapshot
|
} = snapshot
|
||||||
const displayLabel =
|
const displayLabel =
|
||||||
|
|
@ -77,25 +74,6 @@ export function SteeringSnapshotPanel({
|
||||||
|
|
||||||
const methodDescription = methodDescriptionForKey(method_key, methods)
|
const methodDescription = methodDescriptionForKey(method_key, methods)
|
||||||
|
|
||||||
function nextActionLink(item) {
|
|
||||||
if (item.action_id) {
|
|
||||||
return { to: `/actions/${item.action_id}`, label: 'Arbeitspaket öffnen' }
|
|
||||||
}
|
|
||||||
if (item.backlog_item_id && item.initiative_id) {
|
|
||||||
return {
|
|
||||||
to: scopedPath('/plan/inbox', { initiativeId: item.initiative_id }),
|
|
||||||
label: 'Zum Eingang',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item.kind === 'create_action' && item.initiative_id) {
|
|
||||||
return {
|
|
||||||
to: scopedPath('/plan/work', { initiativeId: item.initiative_id }),
|
|
||||||
label: 'Arbeitspaket anlegen',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card steering-snapshot steering-snapshot--hero">
|
<section className="card steering-snapshot steering-snapshot--hero">
|
||||||
<header className="steering-hero-header">
|
<header className="steering-hero-header">
|
||||||
|
|
@ -182,35 +160,8 @@ export function SteeringSnapshotPanel({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(showGateHorizon && upcoming_milestones.length > 0) && (
|
||||||
<div className="steering-hero-columns">
|
<div className="steering-hero-columns">
|
||||||
{next_actions.length > 0 && (
|
|
||||||
<div className="steering-next-actions">
|
|
||||||
<h3>Nächste Schritte</h3>
|
|
||||||
<ol className="item-list compact-list">
|
|
||||||
{next_actions.map((item, i) => {
|
|
||||||
const link = nextActionLink(item)
|
|
||||||
return (
|
|
||||||
<li key={`${item.kind}-${i}`} className="list-item card-list-item compact">
|
|
||||||
<div className="list-item-main">
|
|
||||||
<span className="next-action-rank">{i + 1}</span>
|
|
||||||
<strong>{item.title}</strong>
|
|
||||||
<span className="muted list-item-sub">
|
|
||||||
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
|
|
||||||
{item.recommended_action ? ` — ${item.recommended_action}` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{link && (
|
|
||||||
<Link to={link.to} className="btn btn-primary btn-sm">
|
|
||||||
{link.label}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
)})}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{upcoming_milestones.length > 0 && (
|
|
||||||
<div className="steering-milestones">
|
<div className="steering-milestones">
|
||||||
<h3>Meilenstein-Horizont</h3>
|
<h3>Meilenstein-Horizont</h3>
|
||||||
<ul className="item-list compact-list">
|
<ul className="item-list compact-list">
|
||||||
|
|
@ -227,8 +178,8 @@ export function SteeringSnapshotPanel({
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(counts.unlinked_blockers > 0 || counts.unlinked_evidence > 0) && (
|
{(counts.unlinked_blockers > 0 || counts.unlinked_evidence > 0) && (
|
||||||
<p className="steering-unlinked-hint muted">
|
<p className="steering-unlinked-hint muted">
|
||||||
|
|
|
||||||
19
frontend/src/composition/CompositionSlot.jsx
Normal file
19
frontend/src/composition/CompositionSlot.jsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { getProviderComponent } from './providerComponents.jsx'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert alle Provider-Instanzen eines UI-Slots.
|
||||||
|
* @param {{ slotKey: string, providers?: import('./resolveSteeringComposition.js').ResolvedProvider[], className?: string }} props
|
||||||
|
*/
|
||||||
|
export function CompositionSlot({ slotKey, providers = [], className }) {
|
||||||
|
if (!providers.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-composition-slot={slotKey}>
|
||||||
|
{providers.map((provider) => {
|
||||||
|
const Component = getProviderComponent(provider.componentKey)
|
||||||
|
if (!Component) return null
|
||||||
|
return <Component key={provider.instanceKey} {...provider.props} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
frontend/src/composition/CompositionSurface.jsx
Normal file
60
frontend/src/composition/CompositionSurface.jsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { CompositionSlot } from './CompositionSlot.jsx'
|
||||||
|
import { resolveSteeringComposition } from './resolveSteeringComposition.js'
|
||||||
|
import { getCompositionSurface } from './uiSlotRegistry.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert alle Slots einer Composition-Surface.
|
||||||
|
* @param {{
|
||||||
|
* surfaceKey: string,
|
||||||
|
* scope?: 'portfolio' | 'initiative',
|
||||||
|
* operatingContext?: object | null,
|
||||||
|
* steeringSnapshot?: object | null,
|
||||||
|
* capabilities?: Set<string> | string[],
|
||||||
|
* filterContext?: Record<string, unknown>,
|
||||||
|
* opsContext?: object | null,
|
||||||
|
* slotClassName?: string,
|
||||||
|
* wrapperClassName?: string,
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
export function CompositionSurface({
|
||||||
|
surfaceKey,
|
||||||
|
scope = 'initiative',
|
||||||
|
operatingContext = null,
|
||||||
|
steeringSnapshot = null,
|
||||||
|
capabilities = new Set(),
|
||||||
|
filterContext = {},
|
||||||
|
opsContext = null,
|
||||||
|
slotClassName,
|
||||||
|
wrapperClassName,
|
||||||
|
}) {
|
||||||
|
const composition = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveSteeringComposition({
|
||||||
|
surfaceKey,
|
||||||
|
scope,
|
||||||
|
operatingContext,
|
||||||
|
steeringSnapshot,
|
||||||
|
capabilities,
|
||||||
|
filterContext,
|
||||||
|
opsContext,
|
||||||
|
}),
|
||||||
|
[surfaceKey, scope, operatingContext, steeringSnapshot, capabilities, filterContext, opsContext],
|
||||||
|
)
|
||||||
|
|
||||||
|
const surface = getCompositionSurface(surfaceKey)
|
||||||
|
if (!surface) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={wrapperClassName} data-composition-surface={surfaceKey}>
|
||||||
|
{surface.slotKeys.map((slotKey) => (
|
||||||
|
<CompositionSlot
|
||||||
|
key={slotKey}
|
||||||
|
slotKey={slotKey}
|
||||||
|
providers={composition.slots[slotKey] || []}
|
||||||
|
className={slotClassName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
frontend/src/composition/InitiativeCompositionSurface.jsx
Normal file
62
frontend/src/composition/InitiativeCompositionSurface.jsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
|
||||||
|
import { CompositionSurface } from './CompositionSurface.jsx'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composition-Surface mit InitiativeOperationsContext — für scoped Plan/Control-Pages.
|
||||||
|
* @param {{
|
||||||
|
* surfaceKey: string,
|
||||||
|
* filterContext?: Record<string, unknown>,
|
||||||
|
* onAcceptBacklogProposal?: (itemId: string, options?: object) => Promise<void> | void,
|
||||||
|
* slotClassName?: string,
|
||||||
|
* wrapperClassName?: string,
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
export function InitiativeCompositionSurface({
|
||||||
|
surfaceKey,
|
||||||
|
filterContext = {},
|
||||||
|
onAcceptBacklogProposal,
|
||||||
|
slotClassName,
|
||||||
|
wrapperClassName,
|
||||||
|
}) {
|
||||||
|
const ops = useInitiativeOperations()
|
||||||
|
|
||||||
|
const opsContext = useMemo(
|
||||||
|
() => ({
|
||||||
|
initiativeId: ops.initiativeId,
|
||||||
|
actions: ops.actions,
|
||||||
|
steeringSnapshotLoading: ops.steeringSnapshotLoading,
|
||||||
|
steeringSnapshotError: ops.steeringSnapshotError,
|
||||||
|
steeringMethods: ops.steeringMethods,
|
||||||
|
handleMethodChange: ops.handleMethodChange,
|
||||||
|
methodBusy: ops.methodBusy,
|
||||||
|
formBusy: ops.formBusy,
|
||||||
|
onAcceptBacklogProposal,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
ops.initiativeId,
|
||||||
|
ops.actions,
|
||||||
|
ops.steeringSnapshotLoading,
|
||||||
|
ops.steeringSnapshotError,
|
||||||
|
ops.steeringMethods,
|
||||||
|
ops.handleMethodChange,
|
||||||
|
ops.methodBusy,
|
||||||
|
ops.formBusy,
|
||||||
|
onAcceptBacklogProposal,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CompositionSurface
|
||||||
|
surfaceKey={surfaceKey}
|
||||||
|
scope="initiative"
|
||||||
|
operatingContext={ops.operatingContext}
|
||||||
|
steeringSnapshot={ops.steeringSnapshot}
|
||||||
|
capabilities={ops.capabilities}
|
||||||
|
filterContext={filterContext}
|
||||||
|
opsContext={opsContext}
|
||||||
|
slotClassName={slotClassName}
|
||||||
|
wrapperClassName={wrapperClassName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
135
frontend/src/composition/compositionProviders.js
Normal file
135
frontend/src/composition/compositionProviders.js
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
/**
|
||||||
|
* AP-UX-1 — Composition-Provider-Registry.
|
||||||
|
* Provider hängen an UI-Slots; Aktivierung über steering_elements, proposals, widgets, …
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WIDGETS } from '../registry/widgetRegistry.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {'steering_element' | 'proposal' | 'agent_slots' | 'widget' | 'core' | 'conditional'} ProviderKind
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} CompositionProviderDefinition
|
||||||
|
* @property {string} key
|
||||||
|
* @property {ProviderKind} kind
|
||||||
|
* @property {string[]} slotKeys
|
||||||
|
* @property {string} componentKey
|
||||||
|
* @property {number} [order]
|
||||||
|
* @property {string} [requiresCapability]
|
||||||
|
* @property {('portfolio' | 'initiative')[]} [scopeTypes]
|
||||||
|
* @property {string} [steeringElement]
|
||||||
|
* @property {string} [proposalKey]
|
||||||
|
* @property {string} [widgetKey]
|
||||||
|
* @property {string} [predicateKey]
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WIDGET_PROVIDERS = WIDGETS.filter((w) => w.area === 'workspace').map((widget) => ({
|
||||||
|
key: `widget.${widget.key}`,
|
||||||
|
kind: /** @type {ProviderKind} */ ('widget'),
|
||||||
|
widgetKey: widget.key,
|
||||||
|
slotKeys: ['cockpit.main'],
|
||||||
|
componentKey: 'WidgetHost',
|
||||||
|
order: widget.defaultOrder,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** @type {CompositionProviderDefinition[]} */
|
||||||
|
export const COMPOSITION_PROVIDERS = [
|
||||||
|
...WIDGET_PROVIDERS,
|
||||||
|
|
||||||
|
// —— Control / Status ——
|
||||||
|
{
|
||||||
|
key: 'core.steering_snapshot',
|
||||||
|
kind: 'core',
|
||||||
|
slotKeys: ['control.status.core'],
|
||||||
|
componentKey: 'SteeringSnapshotPanel',
|
||||||
|
requiresCapability: 'kairo.initiative.read',
|
||||||
|
scopeTypes: ['initiative'],
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'steering.critical_path',
|
||||||
|
kind: 'steering_element',
|
||||||
|
steeringElement: 'critical_path',
|
||||||
|
slotKeys: ['control.status.steering'],
|
||||||
|
componentKey: 'CriticalPathPanel',
|
||||||
|
requiresCapability: 'kairo.action.read',
|
||||||
|
scopeTypes: ['initiative'],
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'steering.next_action',
|
||||||
|
kind: 'steering_element',
|
||||||
|
steeringElement: 'next_action_primary',
|
||||||
|
slotKeys: ['control.status.steering'],
|
||||||
|
componentKey: 'NextActionWidget',
|
||||||
|
requiresCapability: 'kairo.workspace.read',
|
||||||
|
scopeTypes: ['initiative'],
|
||||||
|
order: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'agent.slots',
|
||||||
|
kind: 'agent_slots',
|
||||||
|
slotKeys: ['control.status.agent'],
|
||||||
|
componentKey: 'AgentSlotsPanel',
|
||||||
|
requiresCapability: 'kairo.initiative.read',
|
||||||
|
scopeTypes: ['initiative'],
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'conditional.roadblockers',
|
||||||
|
kind: 'conditional',
|
||||||
|
predicateKey: 'hasRoadblockers',
|
||||||
|
slotKeys: ['control.status.alerts'],
|
||||||
|
componentKey: 'RoadblockersStrip',
|
||||||
|
scopeTypes: ['initiative'],
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// —— Plan / Proposals ——
|
||||||
|
{
|
||||||
|
key: 'proposal.intake_triage',
|
||||||
|
kind: 'proposal',
|
||||||
|
proposalKey: 'intake_triage',
|
||||||
|
slotKeys: ['plan.inbox.proposals'],
|
||||||
|
componentKey: 'SteeringProposalsPanel',
|
||||||
|
requiresCapability: 'kairo.initiative.read',
|
||||||
|
scopeTypes: ['initiative'],
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'proposal.sprint_commit',
|
||||||
|
kind: 'proposal',
|
||||||
|
proposalKey: 'sprint_commit',
|
||||||
|
slotKeys: ['plan.sprint.proposals'],
|
||||||
|
componentKey: 'SteeringProposalsPanel',
|
||||||
|
requiresCapability: 'kairo.initiative.read',
|
||||||
|
scopeTypes: ['initiative'],
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'proposal.gate_next_actions',
|
||||||
|
kind: 'proposal',
|
||||||
|
proposalKey: 'gate_next_actions',
|
||||||
|
slotKeys: ['plan.gates.proposals'],
|
||||||
|
componentKey: 'SteeringProposalsPanel',
|
||||||
|
requiresCapability: 'kairo.initiative.read',
|
||||||
|
scopeTypes: ['initiative'],
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} slotKey
|
||||||
|
*/
|
||||||
|
export function getProvidersForSlot(slotKey) {
|
||||||
|
return COMPOSITION_PROVIDERS.filter((p) => p.slotKeys.includes(slotKey))
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
*/
|
||||||
|
export function getProviderByKey(key) {
|
||||||
|
return COMPOSITION_PROVIDERS.find((p) => p.key === key) || null
|
||||||
|
}
|
||||||
49
frontend/src/composition/providerComponents.jsx
Normal file
49
frontend/src/composition/providerComponents.jsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { scopedPath } from '../utils/routes.js'
|
||||||
|
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
|
||||||
|
import { CriticalPathPanel } from '../components/CriticalPathPanel.jsx'
|
||||||
|
import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx'
|
||||||
|
import { SteeringProposalsPanel } from '../components/SteeringProposalsPanel.jsx'
|
||||||
|
import { NextActionWidget } from '../widgets/NextActionWidget.jsx'
|
||||||
|
|
||||||
|
/** Roadblocker-Strip — extrahiert aus InitiativeOverviewPage für Composition. */
|
||||||
|
export function RoadblockersStrip({ initiativeId, actionsBlocked = 0, blockersOpen = 0 }) {
|
||||||
|
if (actionsBlocked <= 0 && blockersOpen <= 0) return null
|
||||||
|
return (
|
||||||
|
<section className="card initiative-summary-strip">
|
||||||
|
<h2 className="card-title">Roadblocker</h2>
|
||||||
|
<p className="muted">
|
||||||
|
{actionsBlocked > 0 && <span>{actionsBlocked} blockierte Arbeitspakete</span>}
|
||||||
|
{actionsBlocked > 0 && blockersOpen > 0 && ' · '}
|
||||||
|
{blockersOpen > 0 && <span>{blockersOpen} offene Blocker</span>}
|
||||||
|
</p>
|
||||||
|
<Link to={scopedPath('/work/today', { initiativeId })} className="btn btn-secondary btn-sm">
|
||||||
|
Zur Ausführung
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetHost({ widget }) {
|
||||||
|
if (!widget?.component) return null
|
||||||
|
const Component = widget.component
|
||||||
|
return <Component key={widget.key} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, import('react').ComponentType<any>>} */
|
||||||
|
export const PROVIDER_COMPONENTS = {
|
||||||
|
SteeringSnapshotPanel,
|
||||||
|
CriticalPathPanel,
|
||||||
|
NextActionWidget,
|
||||||
|
AgentSlotsPanel,
|
||||||
|
SteeringProposalsPanel,
|
||||||
|
RoadblockersStrip,
|
||||||
|
WidgetHost,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} componentKey
|
||||||
|
*/
|
||||||
|
export function getProviderComponent(componentKey) {
|
||||||
|
return PROVIDER_COMPONENTS[componentKey] || null
|
||||||
|
}
|
||||||
257
frontend/src/composition/resolveSteeringComposition.js
Normal file
257
frontend/src/composition/resolveSteeringComposition.js
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
/**
|
||||||
|
* AP-UX-1 — UX Composition Resolver (Frontend-Spiegel des Steering Kernels).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { hasSteeringElement, steeringElementUi } from '../registry/steeringElementRegistry.js'
|
||||||
|
import { getWidgetByKey } from '../registry/widgetRegistry.js'
|
||||||
|
import { filterSprintProposals } from '../utils/steeringProposals.js'
|
||||||
|
import { COMPOSITION_PROVIDERS, getProvidersForSlot } from './compositionProviders.js'
|
||||||
|
import { COMPOSITION_SURFACES, getCompositionSurface, surfaceKeyFor } from './uiSlotRegistry.js'
|
||||||
|
|
||||||
|
/** @typedef {import('./compositionProviders.js').CompositionProviderDefinition} CompositionProviderDefinition */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} CompositionInput
|
||||||
|
* @property {string} [surfaceKey]
|
||||||
|
* @property {string} [mode]
|
||||||
|
* @property {string | null} [routeKey]
|
||||||
|
* @property {'portfolio' | 'initiative'} [scope]
|
||||||
|
* @property {string[] | null} [steeringElements]
|
||||||
|
* @property {object | null} [operatingContext]
|
||||||
|
* @property {object | null} [steeringSnapshot]
|
||||||
|
* @property {Set<string> | string[]} [capabilities]
|
||||||
|
* @property {Record<string, unknown>} [filterContext]
|
||||||
|
* @property {object | null} [opsContext]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {CompositionProviderDefinition & { instanceKey: string, props: Record<string, unknown> }} ResolvedProvider
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CONDITIONAL_PREDICATES = {
|
||||||
|
hasRoadblockers(ctx) {
|
||||||
|
const counts = ctx.steeringSnapshot?.counts || {}
|
||||||
|
return (counts.actions_blocked || 0) > 0 || (counts.blockers_open || 0) > 0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Set<string> | string[] | undefined} capabilities
|
||||||
|
* @param {string | undefined} required
|
||||||
|
*/
|
||||||
|
function hasCapability(capabilities, required) {
|
||||||
|
if (!required) return true
|
||||||
|
const capSet = capabilities instanceof Set ? capabilities : new Set(capabilities || [])
|
||||||
|
return capSet.has(required)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CompositionInput} input
|
||||||
|
*/
|
||||||
|
function resolveSteeringElements(input) {
|
||||||
|
if (Array.isArray(input.steeringElements)) return input.steeringElements
|
||||||
|
return input.operatingContext?.steering_elements || []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CompositionInput} input
|
||||||
|
*/
|
||||||
|
function resolveKernelProposals(input) {
|
||||||
|
return input.steeringSnapshot?.steering_kernel?.proposals || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CompositionInput} input
|
||||||
|
*/
|
||||||
|
function resolveAgentSlots(input) {
|
||||||
|
return (
|
||||||
|
input.steeringSnapshot?.agent_slots
|
||||||
|
|| input.steeringSnapshot?.steering_kernel?.agent_slots
|
||||||
|
|| []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next-Action-Überschriften: kritischster passender steering_element (Priorität).
|
||||||
|
* @param {string[]} elements
|
||||||
|
* @param {object | null | undefined} steeringSnapshot
|
||||||
|
*/
|
||||||
|
export function resolveNextActionUi(elements, steeringSnapshot) {
|
||||||
|
const priority = ['critical_path', 'work_cycle_scope', 'gate_fulfillment', 'queue_inbox']
|
||||||
|
const hasActiveSprint = Boolean(steeringSnapshot?.active_work_cycle)
|
||||||
|
for (const key of priority) {
|
||||||
|
if (key === 'work_cycle_scope' && !hasActiveSprint) continue
|
||||||
|
const ui = steeringElementUi(elements, key)
|
||||||
|
if (ui?.nextActionTitle) return ui
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CompositionProviderDefinition} provider
|
||||||
|
* @param {CompositionInput} input
|
||||||
|
*/
|
||||||
|
function isProviderActive(provider, input) {
|
||||||
|
const scope = input.scope || 'portfolio'
|
||||||
|
const scopeTypes = provider.scopeTypes || ['portfolio', 'initiative']
|
||||||
|
if (!scopeTypes.includes(scope)) return false
|
||||||
|
if (!hasCapability(input.capabilities, provider.requiresCapability)) return false
|
||||||
|
|
||||||
|
const elements = resolveSteeringElements(input)
|
||||||
|
const proposals = resolveKernelProposals(input)
|
||||||
|
const agentSlots = resolveAgentSlots(input)
|
||||||
|
|
||||||
|
switch (provider.kind) {
|
||||||
|
case 'steering_element':
|
||||||
|
return hasSteeringElement(elements, provider.steeringElement)
|
||||||
|
case 'proposal': {
|
||||||
|
const key = provider.proposalKey
|
||||||
|
if (!key) return false
|
||||||
|
let items = proposals[key] || []
|
||||||
|
if (key === 'sprint_commit' && input.filterContext?.workCycleId) {
|
||||||
|
items = filterSprintProposals(items, input.filterContext.workCycleId)
|
||||||
|
}
|
||||||
|
return items.length > 0
|
||||||
|
}
|
||||||
|
case 'agent_slots':
|
||||||
|
return agentSlots.length > 0
|
||||||
|
case 'widget': {
|
||||||
|
const widget = getWidgetByKey(provider.widgetKey)
|
||||||
|
if (!widget) return false
|
||||||
|
return hasCapability(input.capabilities, widget.requiredCapability)
|
||||||
|
}
|
||||||
|
case 'conditional': {
|
||||||
|
const fn = CONDITIONAL_PREDICATES[provider.predicateKey]
|
||||||
|
return typeof fn === 'function' ? fn(input) : false
|
||||||
|
}
|
||||||
|
case 'core':
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CompositionProviderDefinition} provider
|
||||||
|
* @param {CompositionInput} input
|
||||||
|
*/
|
||||||
|
export function buildProviderProps(provider, input) {
|
||||||
|
const ops = input.opsContext || {}
|
||||||
|
const proposals = resolveKernelProposals(input)
|
||||||
|
const agentSlots = resolveAgentSlots(input)
|
||||||
|
const elements = resolveSteeringElements(input)
|
||||||
|
const capabilities = input.capabilities instanceof Set
|
||||||
|
? input.capabilities
|
||||||
|
: new Set(input.capabilities || [])
|
||||||
|
|
||||||
|
switch (provider.componentKey) {
|
||||||
|
case 'SteeringSnapshotPanel':
|
||||||
|
return {
|
||||||
|
snapshot: input.steeringSnapshot,
|
||||||
|
loading: ops.steeringSnapshotLoading,
|
||||||
|
error: ops.steeringSnapshotError,
|
||||||
|
methods: ops.steeringMethods,
|
||||||
|
canManageMethod: capabilities.has('kairo.initiative.manage'),
|
||||||
|
onMethodChange: ops.handleMethodChange,
|
||||||
|
methodBusy: ops.methodBusy,
|
||||||
|
showGateHorizon: hasSteeringElement(elements, 'gate_fulfillment'),
|
||||||
|
}
|
||||||
|
case 'CriticalPathPanel': {
|
||||||
|
const kernel = input.steeringSnapshot?.steering_kernel
|
||||||
|
return {
|
||||||
|
initiativeId: ops.initiativeId,
|
||||||
|
actions: ops.actions || [],
|
||||||
|
graphState: kernel?.read_models?.execution_graph ?? null,
|
||||||
|
graphLoading: ops.steeringSnapshotLoading,
|
||||||
|
scopeRoadmapItemId: kernel?.horizon?.gate_roadmap_item_id ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'NextActionWidget': {
|
||||||
|
const nextActionUi = resolveNextActionUi(elements, input.steeringSnapshot)
|
||||||
|
return {
|
||||||
|
scope: 'initiative',
|
||||||
|
initiativeId: ops.initiativeId,
|
||||||
|
items: input.steeringSnapshot?.next_actions,
|
||||||
|
loading: ops.steeringSnapshotLoading,
|
||||||
|
embedded: true,
|
||||||
|
title: nextActionUi?.nextActionTitle,
|
||||||
|
subtitle: nextActionUi?.nextActionSubtitle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'AgentSlotsPanel':
|
||||||
|
return {
|
||||||
|
slots: agentSlots,
|
||||||
|
loading: ops.steeringSnapshotLoading,
|
||||||
|
}
|
||||||
|
case 'RoadblockersStrip': {
|
||||||
|
const counts = input.steeringSnapshot?.counts || {}
|
||||||
|
return {
|
||||||
|
initiativeId: ops.initiativeId,
|
||||||
|
actionsBlocked: counts.actions_blocked || 0,
|
||||||
|
blockersOpen: counts.blockers_open || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'SteeringProposalsPanel':
|
||||||
|
return {
|
||||||
|
proposalsByKey: proposals,
|
||||||
|
proposalKeys: provider.proposalKey ? [provider.proposalKey] : undefined,
|
||||||
|
filterContext: input.filterContext || {},
|
||||||
|
backlogVocabulary: input.operatingContext?.backlog_vocabulary,
|
||||||
|
canManage: capabilities.has('kairo.backlog.manage'),
|
||||||
|
onAcceptBacklogProposal: ops.onAcceptBacklogProposal,
|
||||||
|
busy: ops.formBusy,
|
||||||
|
}
|
||||||
|
case 'WidgetHost': {
|
||||||
|
const widget = getWidgetByKey(provider.widgetKey)
|
||||||
|
return { widget }
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CompositionProviderDefinition} provider
|
||||||
|
* @param {CompositionInput} input
|
||||||
|
* @returns {ResolvedProvider | null}
|
||||||
|
*/
|
||||||
|
export function resolveProviderInstance(provider, input) {
|
||||||
|
if (!isProviderActive(provider, input)) return null
|
||||||
|
return {
|
||||||
|
...provider,
|
||||||
|
instanceKey: `${provider.key}:${provider.slotKeys[0]}`,
|
||||||
|
props: buildProviderProps(provider, input),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} slotKey
|
||||||
|
* @param {CompositionInput} input
|
||||||
|
* @returns {ResolvedProvider[]}
|
||||||
|
*/
|
||||||
|
export function resolveSlotComposition(slotKey, input) {
|
||||||
|
return getProvidersForSlot(slotKey)
|
||||||
|
.map((provider) => resolveProviderInstance(provider, input))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CompositionInput} input
|
||||||
|
*/
|
||||||
|
export function resolveSteeringComposition(input) {
|
||||||
|
const surfaceKey = input.surfaceKey
|
||||||
|
|| surfaceKeyFor(/** @type {import('./uiSlotRegistry.js').CompositionMode} */ (input.mode), input.routeKey ?? null)
|
||||||
|
const surface = getCompositionSurface(surfaceKey)
|
||||||
|
if (!surface) {
|
||||||
|
return { surfaceKey, slots: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, ResolvedProvider[]>} */
|
||||||
|
const slots = {}
|
||||||
|
for (const slotKey of surface.slotKeys) {
|
||||||
|
slots[slotKey] = resolveSlotComposition(slotKey, input)
|
||||||
|
}
|
||||||
|
return { surfaceKey, slots }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exported for tests */
|
||||||
|
export { COMPOSITION_PROVIDERS, isProviderActive, CONDITIONAL_PREDICATES }
|
||||||
235
frontend/src/composition/resolveSteeringComposition.test.js
Normal file
235
frontend/src/composition/resolveSteeringComposition.test.js
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { COMPOSITION_PROVIDERS } from './compositionProviders.js'
|
||||||
|
import {
|
||||||
|
resolveSteeringComposition,
|
||||||
|
resolveSlotComposition,
|
||||||
|
buildProviderProps,
|
||||||
|
} from './resolveSteeringComposition.js'
|
||||||
|
import { COMPOSITION_SURFACES, UI_SLOTS } from './uiSlotRegistry.js'
|
||||||
|
|
||||||
|
const CAPS = new Set([
|
||||||
|
'kairo.initiative.read',
|
||||||
|
'kairo.action.read',
|
||||||
|
'kairo.workspace.read',
|
||||||
|
'kairo.backlog.manage',
|
||||||
|
])
|
||||||
|
|
||||||
|
describe('uiSlotRegistry', () => {
|
||||||
|
it('defines MVP surfaces', () => {
|
||||||
|
expect(COMPOSITION_SURFACES['control.status']).toBeTruthy()
|
||||||
|
expect(COMPOSITION_SURFACES['plan.inbox']).toBeTruthy()
|
||||||
|
expect(COMPOSITION_SURFACES.cockpit).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has unique slot keys', () => {
|
||||||
|
const keys = UI_SLOTS.map((s) => s.key)
|
||||||
|
expect(new Set(keys).size).toBe(keys.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('compositionProviders', () => {
|
||||||
|
it('registers widget providers from widgetRegistry', () => {
|
||||||
|
const widgets = COMPOSITION_PROVIDERS.filter((p) => p.kind === 'widget')
|
||||||
|
expect(widgets.length).toBeGreaterThanOrEqual(8)
|
||||||
|
expect(widgets.some((p) => p.widgetKey === 'kairo.initiative_portfolio')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registers proposal providers for plan surfaces', () => {
|
||||||
|
expect(COMPOSITION_PROVIDERS.some((p) => p.proposalKey === 'intake_triage')).toBe(true)
|
||||||
|
expect(COMPOSITION_PROVIDERS.some((p) => p.proposalKey === 'sprint_commit')).toBe(true)
|
||||||
|
expect(COMPOSITION_PROVIDERS.some((p) => p.proposalKey === 'gate_next_actions')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveSteeringComposition', () => {
|
||||||
|
it('resolves cockpit widgets by capability', () => {
|
||||||
|
const result = resolveSteeringComposition({
|
||||||
|
surfaceKey: 'cockpit',
|
||||||
|
scope: 'portfolio',
|
||||||
|
capabilities: CAPS,
|
||||||
|
})
|
||||||
|
const main = result.slots['cockpit.main'] || []
|
||||||
|
expect(main.length).toBeGreaterThan(0)
|
||||||
|
expect(main.some((p) => p.widgetKey === 'kairo.initiative_portfolio')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters cockpit widgets without capability', () => {
|
||||||
|
const result = resolveSteeringComposition({
|
||||||
|
surfaceKey: 'cockpit',
|
||||||
|
scope: 'portfolio',
|
||||||
|
capabilities: ['kairo.initiative.read'],
|
||||||
|
})
|
||||||
|
const main = result.slots['cockpit.main'] || []
|
||||||
|
expect(main.some((p) => p.widgetKey === 'kairo.my_open_actions')).toBe(false)
|
||||||
|
expect(main.some((p) => p.widgetKey === 'kairo.initiative_portfolio')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activates critical_path and next_action_primary on control.status', () => {
|
||||||
|
const result = resolveSteeringComposition({
|
||||||
|
surfaceKey: 'control.status',
|
||||||
|
scope: 'initiative',
|
||||||
|
capabilities: CAPS,
|
||||||
|
steeringElements: ['critical_path', 'next_action_primary'],
|
||||||
|
steeringSnapshot: { counts: {}, next_actions: [] },
|
||||||
|
opsContext: { initiativeId: 'init-1', actions: [] },
|
||||||
|
})
|
||||||
|
const steering = result.slots['control.status.steering'] || []
|
||||||
|
expect(steering.some((p) => p.key === 'steering.critical_path')).toBe(true)
|
||||||
|
expect(steering.some((p) => p.key === 'steering.next_action')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips next_action when next_action_primary inactive', () => {
|
||||||
|
const result = resolveSteeringComposition({
|
||||||
|
surfaceKey: 'control.status',
|
||||||
|
scope: 'initiative',
|
||||||
|
capabilities: CAPS,
|
||||||
|
steeringElements: ['critical_path'],
|
||||||
|
steeringSnapshot: { counts: {}, next_actions: [] },
|
||||||
|
opsContext: { initiativeId: 'init-1', actions: [] },
|
||||||
|
})
|
||||||
|
const steering = result.slots['control.status.steering'] || []
|
||||||
|
expect(steering.some((p) => p.key === 'steering.next_action')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips critical_path when element inactive', () => {
|
||||||
|
const result = resolveSteeringComposition({
|
||||||
|
surfaceKey: 'control.status',
|
||||||
|
scope: 'initiative',
|
||||||
|
capabilities: CAPS,
|
||||||
|
steeringElements: ['next_action_primary'],
|
||||||
|
steeringSnapshot: { counts: {}, next_actions: [] },
|
||||||
|
opsContext: { initiativeId: 'init-1', actions: [] },
|
||||||
|
})
|
||||||
|
const steering = result.slots['control.status.steering'] || []
|
||||||
|
expect(steering.some((p) => p.key === 'steering.critical_path')).toBe(false)
|
||||||
|
expect(steering.some((p) => p.key === 'steering.next_action')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activates proposal provider when kernel has items', () => {
|
||||||
|
const result = resolveSteeringComposition({
|
||||||
|
surfaceKey: 'plan.inbox',
|
||||||
|
scope: 'initiative',
|
||||||
|
capabilities: CAPS,
|
||||||
|
steeringSnapshot: {
|
||||||
|
steering_kernel: {
|
||||||
|
proposals: {
|
||||||
|
intake_triage: [{ scope_type: 'backlog_item', scope_id: 'b1', title: 'Bug', rank: 1 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
opsContext: { formBusy: false },
|
||||||
|
})
|
||||||
|
const proposals = result.slots['plan.inbox.proposals'] || []
|
||||||
|
expect(proposals).toHaveLength(1)
|
||||||
|
expect(proposals[0].proposalKey).toBe('intake_triage')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters sprint proposals by workCycleId', () => {
|
||||||
|
const slot = resolveSlotComposition('plan.sprint.proposals', {
|
||||||
|
scope: 'initiative',
|
||||||
|
capabilities: CAPS,
|
||||||
|
filterContext: { workCycleId: 'wc-1' },
|
||||||
|
steeringSnapshot: {
|
||||||
|
steering_kernel: {
|
||||||
|
proposals: {
|
||||||
|
sprint_commit: [
|
||||||
|
{ scope_type: 'backlog_item', scope_id: 'b1', title: 'A', rank: 1, work_cycle_id: 'wc-1' },
|
||||||
|
{ scope_type: 'backlog_item', scope_id: 'b2', title: 'B', rank: 2, work_cycle_id: 'wc-2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
opsContext: {},
|
||||||
|
})
|
||||||
|
expect(slot).toHaveLength(1)
|
||||||
|
expect(slot[0].props.proposalKeys).toEqual(['sprint_commit'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows roadblockers strip when counts indicate blockers', () => {
|
||||||
|
const result = resolveSteeringComposition({
|
||||||
|
surfaceKey: 'control.status',
|
||||||
|
scope: 'initiative',
|
||||||
|
capabilities: CAPS,
|
||||||
|
steeringElements: [],
|
||||||
|
steeringSnapshot: { counts: { actions_blocked: 2, blockers_open: 1 } },
|
||||||
|
opsContext: { initiativeId: 'init-1' },
|
||||||
|
})
|
||||||
|
const alerts = result.slots['control.status.alerts'] || []
|
||||||
|
expect(alerts.some((p) => p.key === 'conditional.roadblockers')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activates agent slots provider when slots present', () => {
|
||||||
|
const result = resolveSteeringComposition({
|
||||||
|
surfaceKey: 'control.status',
|
||||||
|
scope: 'initiative',
|
||||||
|
capabilities: CAPS,
|
||||||
|
steeringSnapshot: {
|
||||||
|
steering_kernel: {
|
||||||
|
agent_slots: [{ slot_key: 'review.due', title: 'Review fällig', scope_id: 'r1' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
opsContext: {},
|
||||||
|
})
|
||||||
|
const agent = result.slots['control.status.agent'] || []
|
||||||
|
expect(agent.some((p) => p.key === 'agent.slots')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildProviderProps passes critical path UI to NextActionWidget', () => {
|
||||||
|
const provider = COMPOSITION_PROVIDERS.find((p) => p.key === 'steering.next_action')
|
||||||
|
const props = buildProviderProps(provider, {
|
||||||
|
steeringElements: ['critical_path', 'next_action_primary'],
|
||||||
|
steeringSnapshot: { next_actions: [] },
|
||||||
|
opsContext: { initiativeId: 'x', steeringSnapshotLoading: false },
|
||||||
|
capabilities: CAPS,
|
||||||
|
})
|
||||||
|
expect(props.title).toContain('kritischen Pfad')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildProviderProps uses continuous product next-action copy', () => {
|
||||||
|
const provider = COMPOSITION_PROVIDERS.find((p) => p.key === 'steering.next_action')
|
||||||
|
const props = buildProviderProps(provider, {
|
||||||
|
steeringElements: ['next_action_primary', 'gate_fulfillment'],
|
||||||
|
steeringSnapshot: { next_actions: [] },
|
||||||
|
opsContext: { initiativeId: 'x', steeringSnapshotLoading: false },
|
||||||
|
capabilities: CAPS,
|
||||||
|
})
|
||||||
|
expect(props.title).toContain('Nächster sinnvoller Schritt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildProviderProps passes kernel execution graph to CriticalPathPanel', () => {
|
||||||
|
const provider = COMPOSITION_PROVIDERS.find((p) => p.key === 'steering.critical_path')
|
||||||
|
const props = buildProviderProps(provider, {
|
||||||
|
steeringElements: ['critical_path'],
|
||||||
|
steeringSnapshot: {
|
||||||
|
steering_kernel: {
|
||||||
|
horizon: { gate_roadmap_item_id: 'gate-1' },
|
||||||
|
read_models: {
|
||||||
|
execution_graph: { critical_path: ['a1'], ready_actions: ['a1'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
opsContext: { initiativeId: 'init-1', actions: [], steeringSnapshotLoading: false },
|
||||||
|
capabilities: CAPS,
|
||||||
|
})
|
||||||
|
expect(props.graphState?.critical_path).toEqual(['a1'])
|
||||||
|
expect(props.scopeRoadmapItemId).toBe('gate-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildProviderProps gates milestone horizon in snapshot panel', () => {
|
||||||
|
const provider = COMPOSITION_PROVIDERS.find((p) => p.key === 'core.steering_snapshot')
|
||||||
|
const withGate = buildProviderProps(provider, {
|
||||||
|
steeringElements: ['gate_fulfillment', 'next_action_primary'],
|
||||||
|
steeringSnapshot: { counts: {} },
|
||||||
|
opsContext: {},
|
||||||
|
capabilities: CAPS,
|
||||||
|
})
|
||||||
|
const withoutGate = buildProviderProps(provider, {
|
||||||
|
steeringElements: ['next_action_primary'],
|
||||||
|
steeringSnapshot: { counts: {} },
|
||||||
|
opsContext: {},
|
||||||
|
capabilities: CAPS,
|
||||||
|
})
|
||||||
|
expect(withGate.showGateHorizon).toBe(true)
|
||||||
|
expect(withoutGate.showGateHorizon).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
93
frontend/src/composition/uiSlotRegistry.js
Normal file
93
frontend/src/composition/uiSlotRegistry.js
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
/**
|
||||||
|
* AP-UX-0 — UI-Slot-Registry: benannte Flächen pro Modus/Route.
|
||||||
|
* @see docs/architecture/ADP_UX_Composition_Kernel_v0.1.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @typedef {'cockpit' | 'control' | 'plan' | 'work' | 'team'} CompositionMode */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} UiSlotDefinition
|
||||||
|
* @property {string} key
|
||||||
|
* @property {CompositionMode} mode
|
||||||
|
* @property {string | null} routeKey
|
||||||
|
* @property {string} label
|
||||||
|
* @property {number} order
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {UiSlotDefinition[]} */
|
||||||
|
export const UI_SLOTS = [
|
||||||
|
{ key: 'cockpit.main', mode: 'cockpit', routeKey: null, label: 'Cockpit-Hauptfläche', order: 0 },
|
||||||
|
|
||||||
|
{ key: 'control.status.core', mode: 'control', routeKey: 'status', label: 'Steuerungs-Snapshot', order: 0 },
|
||||||
|
{ key: 'control.status.steering', mode: 'control', routeKey: 'status', label: 'Steuerungselemente', order: 10 },
|
||||||
|
{ key: 'control.status.agent', mode: 'control', routeKey: 'status', label: 'Agent-Slots', order: 20 },
|
||||||
|
{ key: 'control.status.alerts', mode: 'control', routeKey: 'status', label: 'Roadblocker', order: 30 },
|
||||||
|
|
||||||
|
{ key: 'plan.inbox.proposals', mode: 'plan', routeKey: 'inbox', label: 'Triage-Vorschläge', order: 0 },
|
||||||
|
{ key: 'plan.sprint.proposals', mode: 'plan', routeKey: 'sprint', label: 'Sprint-Vorschläge', order: 0 },
|
||||||
|
{ key: 'plan.gates.proposals', mode: 'plan', routeKey: 'gates', label: 'Gate-Vorschläge', order: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} CompositionSurfaceDefinition
|
||||||
|
* @property {CompositionMode} mode
|
||||||
|
* @property {string | null} routeKey
|
||||||
|
* @property {string[]} slotKeys
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Surfaces = zusammengehörige Slots für eine Page. */
|
||||||
|
/** @type {Record<string, CompositionSurfaceDefinition>} */
|
||||||
|
export const COMPOSITION_SURFACES = {
|
||||||
|
cockpit: {
|
||||||
|
mode: 'cockpit',
|
||||||
|
routeKey: null,
|
||||||
|
slotKeys: ['cockpit.main'],
|
||||||
|
},
|
||||||
|
'control.status': {
|
||||||
|
mode: 'control',
|
||||||
|
routeKey: 'status',
|
||||||
|
slotKeys: [
|
||||||
|
'control.status.core',
|
||||||
|
'control.status.steering',
|
||||||
|
'control.status.agent',
|
||||||
|
'control.status.alerts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'plan.inbox': {
|
||||||
|
mode: 'plan',
|
||||||
|
routeKey: 'inbox',
|
||||||
|
slotKeys: ['plan.inbox.proposals'],
|
||||||
|
},
|
||||||
|
'plan.sprint': {
|
||||||
|
mode: 'plan',
|
||||||
|
routeKey: 'sprint',
|
||||||
|
slotKeys: ['plan.sprint.proposals'],
|
||||||
|
},
|
||||||
|
'plan.gates': {
|
||||||
|
mode: 'plan',
|
||||||
|
routeKey: 'gates',
|
||||||
|
slotKeys: ['plan.gates.proposals'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} surfaceKey
|
||||||
|
*/
|
||||||
|
export function getCompositionSurface(surfaceKey) {
|
||||||
|
return COMPOSITION_SURFACES[surfaceKey] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} slotKey
|
||||||
|
*/
|
||||||
|
export function getUiSlot(slotKey) {
|
||||||
|
return UI_SLOTS.find((s) => s.key === slotKey) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CompositionMode} mode
|
||||||
|
* @param {string | null} [routeKey]
|
||||||
|
*/
|
||||||
|
export function surfaceKeyFor(mode, routeKey = null) {
|
||||||
|
return routeKey ? `${mode}.${routeKey}` : mode
|
||||||
|
}
|
||||||
65
frontend/src/composition/useSteeringComposition.js
Normal file
65
frontend/src/composition/useSteeringComposition.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
|
||||||
|
import { resolveSteeringComposition } from './resolveSteeringComposition.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook: baut Composition-Input aus InitiativeOperationsContext.
|
||||||
|
* @param {string} surfaceKey
|
||||||
|
* @param {Record<string, unknown>} [options]
|
||||||
|
*/
|
||||||
|
export function useSteeringComposition(surfaceKey, options = {}) {
|
||||||
|
const ops = useInitiativeOperations()
|
||||||
|
const {
|
||||||
|
filterContext = options.filterContext || {},
|
||||||
|
scope = 'initiative',
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const opsContext = useMemo(
|
||||||
|
() => ({
|
||||||
|
initiativeId: ops.initiativeId,
|
||||||
|
actions: ops.actions,
|
||||||
|
steeringSnapshotLoading: ops.steeringSnapshotLoading,
|
||||||
|
steeringSnapshotError: ops.steeringSnapshotError,
|
||||||
|
steeringMethods: ops.steeringMethods,
|
||||||
|
handleMethodChange: ops.handleMethodChange,
|
||||||
|
methodBusy: ops.methodBusy,
|
||||||
|
formBusy: ops.formBusy,
|
||||||
|
onAcceptBacklogProposal: options.onAcceptBacklogProposal,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
ops.initiativeId,
|
||||||
|
ops.actions,
|
||||||
|
ops.steeringSnapshotLoading,
|
||||||
|
ops.steeringSnapshotError,
|
||||||
|
ops.steeringMethods,
|
||||||
|
ops.handleMethodChange,
|
||||||
|
ops.methodBusy,
|
||||||
|
ops.formBusy,
|
||||||
|
options.onAcceptBacklogProposal,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
resolveSteeringComposition({
|
||||||
|
surfaceKey,
|
||||||
|
scope,
|
||||||
|
steeringElements: ops.steeringElements,
|
||||||
|
operatingContext: ops.operatingContext,
|
||||||
|
steeringSnapshot: ops.steeringSnapshot,
|
||||||
|
capabilities: ops.capabilities,
|
||||||
|
filterContext,
|
||||||
|
opsContext,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
surfaceKey,
|
||||||
|
scope,
|
||||||
|
ops.steeringElements,
|
||||||
|
ops.operatingContext,
|
||||||
|
ops.steeringSnapshot,
|
||||||
|
ops.capabilities,
|
||||||
|
filterContext,
|
||||||
|
opsContext,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useCallback } from 'react'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
|
import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx'
|
||||||
import { BacklogSection } from '../../components/BacklogSection.jsx'
|
import { BacklogSection } from '../../components/BacklogSection.jsx'
|
||||||
import { SteeringProposalsPanel } from '../../components/SteeringProposalsPanel.jsx'
|
|
||||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
|
|
||||||
export function InitiativeInboxPage() {
|
export function InitiativeInboxPage() {
|
||||||
|
|
@ -24,17 +24,18 @@ export function InitiativeInboxPage() {
|
||||||
handleBulkConvertBacklog,
|
handleBulkConvertBacklog,
|
||||||
handleDeleteBacklog,
|
handleDeleteBacklog,
|
||||||
reloadSlices,
|
reloadSlices,
|
||||||
steeringSnapshot,
|
|
||||||
operatingContext,
|
|
||||||
} = useInitiativeOperations()
|
} = useInitiativeOperations()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reloadSlices(['steering_snapshot'])
|
reloadSlices(['steering_snapshot'])
|
||||||
}, [reloadSlices])
|
}, [reloadSlices])
|
||||||
|
|
||||||
const kernelProposals = useMemo(
|
const onAcceptBacklogProposal = useCallback(
|
||||||
() => steeringSnapshot?.steering_kernel?.proposals || {},
|
async (itemId) => {
|
||||||
[steeringSnapshot],
|
await handleConvertBacklog(itemId, {})
|
||||||
|
await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
|
||||||
|
},
|
||||||
|
[handleConvertBacklog, reloadSlices],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!capabilities.has('kairo.initiative.read')) {
|
if (!capabilities.has('kairo.initiative.read')) {
|
||||||
|
|
@ -48,16 +49,9 @@ export function InitiativeInboxPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<SteeringProposalsPanel
|
<InitiativeCompositionSurface
|
||||||
proposalsByKey={kernelProposals}
|
surfaceKey="plan.inbox"
|
||||||
proposalKeys={['intake_triage']}
|
onAcceptBacklogProposal={onAcceptBacklogProposal}
|
||||||
backlogVocabulary={operatingContext?.backlog_vocabulary}
|
|
||||||
canManage={capabilities.has('kairo.backlog.manage')}
|
|
||||||
onAcceptBacklogProposal={async (itemId) => {
|
|
||||||
await handleConvertBacklog(itemId, {})
|
|
||||||
await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
|
|
||||||
}}
|
|
||||||
busy={formBusy}
|
|
||||||
/>
|
/>
|
||||||
<BacklogSection
|
<BacklogSection
|
||||||
items={backlogItems}
|
items={backlogItems}
|
||||||
|
|
|
||||||
|
|
@ -1,94 +1,13 @@
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { scopedPath } from '../../utils/routes.js'
|
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx'
|
import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx'
|
||||||
import { AgentSlotsPanel } from '../../components/AgentSlotsPanel.jsx'
|
|
||||||
import { CriticalPathPanel } from '../../components/CriticalPathPanel.jsx'
|
|
||||||
import { NextActionWidget } from '../../widgets/NextActionWidget.jsx'
|
|
||||||
import {
|
|
||||||
hasSteeringElement,
|
|
||||||
steeringElementUi,
|
|
||||||
} from '../../registry/steeringElementRegistry.js'
|
|
||||||
|
|
||||||
export function InitiativeOverviewPage() {
|
export function InitiativeOverviewPage() {
|
||||||
const {
|
const { error } = useInitiativeOperations()
|
||||||
initiativeId,
|
|
||||||
actions,
|
|
||||||
error,
|
|
||||||
steeringSnapshot,
|
|
||||||
steeringSnapshotLoading,
|
|
||||||
steeringSnapshotError,
|
|
||||||
steeringMethods,
|
|
||||||
methodBusy,
|
|
||||||
capabilities,
|
|
||||||
handleMethodChange,
|
|
||||||
steeringElements,
|
|
||||||
} = useInitiativeOperations()
|
|
||||||
|
|
||||||
const counts = steeringSnapshot?.counts || {}
|
|
||||||
const showCriticalPath = hasSteeringElement(steeringElements, 'critical_path')
|
|
||||||
const criticalPathUi = steeringElementUi(steeringElements, 'critical_path')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
|
<InitiativeCompositionSurface surfaceKey="control.status" />
|
||||||
{capabilities.has('kairo.initiative.read') && (
|
|
||||||
<SteeringSnapshotPanel
|
|
||||||
snapshot={steeringSnapshot}
|
|
||||||
loading={steeringSnapshotLoading}
|
|
||||||
error={steeringSnapshotError}
|
|
||||||
methods={steeringMethods}
|
|
||||||
canManageMethod={capabilities.has('kairo.initiative.manage')}
|
|
||||||
onMethodChange={handleMethodChange}
|
|
||||||
methodBusy={methodBusy}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showCriticalPath && capabilities.has('kairo.action.read') && (
|
|
||||||
<CriticalPathPanel initiativeId={initiativeId} actions={actions} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{capabilities.has('kairo.workspace.read') && (
|
|
||||||
<NextActionWidget
|
|
||||||
scope="initiative"
|
|
||||||
initiativeId={initiativeId}
|
|
||||||
items={steeringSnapshot?.next_actions}
|
|
||||||
loading={steeringSnapshotLoading}
|
|
||||||
embedded
|
|
||||||
title={criticalPathUi?.nextActionTitle}
|
|
||||||
subtitle={criticalPathUi?.nextActionSubtitle}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{capabilities.has('kairo.initiative.read') && (
|
|
||||||
<AgentSlotsPanel
|
|
||||||
slots={
|
|
||||||
steeringSnapshot?.agent_slots ||
|
|
||||||
steeringSnapshot?.steering_kernel?.agent_slots ||
|
|
||||||
[]
|
|
||||||
}
|
|
||||||
loading={steeringSnapshotLoading}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(counts.actions_blocked > 0 || counts.blockers_open > 0) && (
|
|
||||||
<section className="card initiative-summary-strip">
|
|
||||||
<h2 className="card-title">Roadblocker</h2>
|
|
||||||
<p className="muted">
|
|
||||||
{counts.actions_blocked > 0 && (
|
|
||||||
<span>{counts.actions_blocked} blockierte Arbeitspakete</span>
|
|
||||||
)}
|
|
||||||
{counts.actions_blocked > 0 && counts.blockers_open > 0 && ' · '}
|
|
||||||
{counts.blockers_open > 0 && (
|
|
||||||
<span>{counts.blockers_open} offene Blocker</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<Link to={scopedPath('/work/today', { initiativeId })} className="btn btn-secondary btn-sm">
|
|
||||||
Zur Ausführung
|
|
||||||
</Link>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useCallback } from 'react'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
|
import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx'
|
||||||
import { GatesPlanPanel } from '../../components/GatesPlanPanel.jsx'
|
import { GatesPlanPanel } from '../../components/GatesPlanPanel.jsx'
|
||||||
import { SteeringProposalsPanel } from '../../components/SteeringProposalsPanel.jsx'
|
|
||||||
|
|
||||||
export function InitiativePlanPage() {
|
export function InitiativePlanPage() {
|
||||||
const {
|
const {
|
||||||
|
|
@ -14,8 +14,6 @@ export function InitiativePlanPage() {
|
||||||
handleDeleteRoadmapItem,
|
handleDeleteRoadmapItem,
|
||||||
handleConvertBacklog,
|
handleConvertBacklog,
|
||||||
reloadSlices,
|
reloadSlices,
|
||||||
steeringSnapshot,
|
|
||||||
operatingContext,
|
|
||||||
error,
|
error,
|
||||||
} = useInitiativeOperations()
|
} = useInitiativeOperations()
|
||||||
|
|
||||||
|
|
@ -23,9 +21,12 @@ export function InitiativePlanPage() {
|
||||||
reloadSlices(['steering_snapshot'])
|
reloadSlices(['steering_snapshot'])
|
||||||
}, [reloadSlices])
|
}, [reloadSlices])
|
||||||
|
|
||||||
const kernelProposals = useMemo(
|
const onAcceptBacklogProposal = useCallback(
|
||||||
() => steeringSnapshot?.steering_kernel?.proposals || {},
|
async (itemId) => {
|
||||||
[steeringSnapshot],
|
await handleConvertBacklog(itemId, {})
|
||||||
|
await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
|
||||||
|
},
|
||||||
|
[handleConvertBacklog, reloadSlices],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!capabilities.has('kairo.initiative.read')) {
|
if (!capabilities.has('kairo.initiative.read')) {
|
||||||
|
|
@ -35,16 +36,9 @@ export function InitiativePlanPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<SteeringProposalsPanel
|
<InitiativeCompositionSurface
|
||||||
proposalsByKey={kernelProposals}
|
surfaceKey="plan.gates"
|
||||||
proposalKeys={['gate_next_actions']}
|
onAcceptBacklogProposal={onAcceptBacklogProposal}
|
||||||
backlogVocabulary={operatingContext?.backlog_vocabulary}
|
|
||||||
canManage={capabilities.has('kairo.backlog.manage')}
|
|
||||||
onAcceptBacklogProposal={async (itemId) => {
|
|
||||||
await handleConvertBacklog(itemId, {})
|
|
||||||
await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
|
|
||||||
}}
|
|
||||||
busy={formBusy}
|
|
||||||
/>
|
/>
|
||||||
<GatesPlanPanel
|
<GatesPlanPanel
|
||||||
initiativeId={initiativeId}
|
initiativeId={initiativeId}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useCapabilities } from '../../hooks/useCapabilities.js'
|
import { useCapabilities } from '../../hooks/useCapabilities.js'
|
||||||
import { getWidgetsForArea } from '../../registry/widgetRegistry.js'
|
|
||||||
import { useSession } from '../../context/SessionContext.jsx'
|
import { useSession } from '../../context/SessionContext.jsx'
|
||||||
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
|
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
|
||||||
import { createInitiative } from '../../api/initiatives.js'
|
import { createInitiative } from '../../api/initiatives.js'
|
||||||
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../../api/entityFields.js'
|
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../../api/entityFields.js'
|
||||||
import { ModeShell } from '../../components/ModeShell.jsx'
|
import { ModeShell } from '../../components/ModeShell.jsx'
|
||||||
import { initiativeCatalogPath, scopedPath } from '../../utils/routes.js'
|
import { initiativeCatalogPath, scopedPath } from '../../utils/routes.js'
|
||||||
|
import { CompositionSurface } from '../../composition/CompositionSurface.jsx'
|
||||||
|
|
||||||
export function CockpitPage() {
|
export function CockpitPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { context } = useSession()
|
const { context } = useSession()
|
||||||
const { capabilities, hasCapability } = useCapabilities()
|
const { capabilities, hasCapability } = useCapabilities()
|
||||||
const widgets = getWidgetsForArea('workspace', capabilities)
|
const cockpitCapabilities = useMemo(() => capabilities, [capabilities])
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [formBusy, setFormBusy] = useState(false)
|
const [formBusy, setFormBusy] = useState(false)
|
||||||
const [formError, setFormError] = useState(null)
|
const [formError, setFormError] = useState(null)
|
||||||
|
|
@ -68,12 +68,12 @@ export function CockpitPage() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="widget-grid">
|
<CompositionSurface
|
||||||
{widgets.map((widget) => {
|
surfaceKey="cockpit"
|
||||||
const Component = widget.component
|
scope="portfolio"
|
||||||
return <Component key={widget.key} />
|
capabilities={cockpitCapabilities}
|
||||||
})}
|
wrapperClassName="widget-grid"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{hasCapability('kairo.initiative.read') && (
|
{hasCapability('kairo.initiative.read') && (
|
||||||
<p className="page-footer-link">
|
<p className="page-footer-link">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo, useCallback } from 'react'
|
||||||
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
||||||
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
||||||
import { WorkCyclesPanel } from '../../components/WorkCyclesPanel.jsx'
|
import { WorkCyclesPanel } from '../../components/WorkCyclesPanel.jsx'
|
||||||
import { SteeringProposalsPanel } from '../../components/SteeringProposalsPanel.jsx'
|
import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx'
|
||||||
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
|
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
|
||||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
|
|
@ -50,17 +50,23 @@ function PlanSprintInner() {
|
||||||
handleUnplanAction,
|
handleUnplanAction,
|
||||||
handleConvertBacklog,
|
handleConvertBacklog,
|
||||||
reloadSlices,
|
reloadSlices,
|
||||||
steeringSnapshot,
|
|
||||||
operatingContext,
|
|
||||||
} = ops
|
} = ops
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reloadSlices(['steering_snapshot'])
|
reloadSlices(['steering_snapshot'])
|
||||||
}, [reloadSlices])
|
}, [reloadSlices])
|
||||||
|
|
||||||
const kernelProposals = useMemo(
|
const onAcceptBacklogProposal = useCallback(
|
||||||
() => steeringSnapshot?.steering_kernel?.proposals || {},
|
async (itemId, options) => {
|
||||||
[steeringSnapshot],
|
await handleConvertBacklog(itemId, options)
|
||||||
|
await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
|
||||||
|
},
|
||||||
|
[handleConvertBacklog, reloadSlices],
|
||||||
|
)
|
||||||
|
|
||||||
|
const proposalFilterContext = useMemo(
|
||||||
|
() => (selectedWorkCycleId ? { workCycleId: selectedWorkCycleId } : {}),
|
||||||
|
[selectedWorkCycleId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const actionCountByCycleId = useMemo(
|
const actionCountByCycleId = useMemo(
|
||||||
|
|
@ -107,17 +113,10 @@ function PlanSprintInner() {
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
/>
|
/>
|
||||||
{selectedWorkCycleId && (
|
{selectedWorkCycleId && (
|
||||||
<SteeringProposalsPanel
|
<InitiativeCompositionSurface
|
||||||
proposalsByKey={kernelProposals}
|
surfaceKey="plan.sprint"
|
||||||
proposalKeys={['sprint_commit']}
|
filterContext={proposalFilterContext}
|
||||||
filterContext={{ workCycleId: selectedWorkCycleId }}
|
onAcceptBacklogProposal={onAcceptBacklogProposal}
|
||||||
backlogVocabulary={operatingContext?.backlog_vocabulary}
|
|
||||||
canManage={capabilities.has('kairo.backlog.manage')}
|
|
||||||
onAcceptBacklogProposal={async (itemId, options) => {
|
|
||||||
await handleConvertBacklog(itemId, options)
|
|
||||||
await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
|
|
||||||
}}
|
|
||||||
busy={formBusy}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{selectedWorkCycleId && selectedWorkCycle && (
|
{selectedWorkCycleId && selectedWorkCycle && (
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,15 @@ import { PlanningDebtBanner } from '../../components/PlanningDebtBanner.jsx'
|
||||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
|
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
|
||||||
import { useEffect, useState } from 'react'
|
import { hasSteeringElement } from '../../registry/steeringElementRegistry.js'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { getInitiativeExecutionGraphState } from '../../api/executionPlan.js'
|
import { getInitiativeExecutionGraphState } from '../../api/executionPlan.js'
|
||||||
|
|
||||||
function PlanWorkInner() {
|
function PlanWorkInner() {
|
||||||
const ops = useInitiativeOperations()
|
const ops = useInitiativeOperations()
|
||||||
const { initiativeId, projectId: scopeProjectId, hrefWithScope } = useProgramScope()
|
const { initiativeId, projectId: scopeProjectId, hrefWithScope } = useProgramScope()
|
||||||
const [executionGraph, setExecutionGraph] = useState(null)
|
const [fallbackExecutionGraph, setFallbackExecutionGraph] = useState(null)
|
||||||
const {
|
const {
|
||||||
initiative,
|
|
||||||
actions,
|
actions,
|
||||||
projects,
|
projects,
|
||||||
roadmapItems,
|
roadmapItems,
|
||||||
|
|
@ -29,32 +29,61 @@ function PlanWorkInner() {
|
||||||
actorsUsedFallback,
|
actorsUsedFallback,
|
||||||
reloadActors,
|
reloadActors,
|
||||||
handleCreateAction,
|
handleCreateAction,
|
||||||
|
steeringSnapshot,
|
||||||
|
steeringElements,
|
||||||
|
uiFeatures,
|
||||||
|
reloadSlices,
|
||||||
} = ops
|
} = ops
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initiativeId) {
|
reloadSlices(['steering_snapshot'])
|
||||||
setExecutionGraph(null)
|
}, [reloadSlices])
|
||||||
|
|
||||||
|
const kernelReadModels = steeringSnapshot?.steering_kernel?.read_models
|
||||||
|
const useKernelGraph = Boolean(
|
||||||
|
kernelReadModels?.execution_graph != null
|
||||||
|
|| kernelReadModels?.planning_debt != null,
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initiativeId || useKernelGraph) {
|
||||||
|
setFallbackExecutionGraph(null)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
getInitiativeExecutionGraphState(initiativeId)
|
getInitiativeExecutionGraphState(initiativeId)
|
||||||
.then((state) => {
|
.then((state) => {
|
||||||
if (!cancelled) setExecutionGraph(state)
|
if (!cancelled) setFallbackExecutionGraph(state)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setExecutionGraph(null)
|
if (!cancelled) setFallbackExecutionGraph(null)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [initiativeId, actions.length])
|
}, [initiativeId, actions.length, useKernelGraph])
|
||||||
|
|
||||||
|
const executionGraph = useMemo(() => {
|
||||||
|
if (kernelReadModels?.execution_graph) {
|
||||||
|
return {
|
||||||
|
...kernelReadModels.execution_graph,
|
||||||
|
planning_debt: kernelReadModels.planning_debt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackExecutionGraph
|
||||||
|
}, [kernelReadModels, fallbackExecutionGraph])
|
||||||
|
|
||||||
|
const planningDebt = useMemo(() => {
|
||||||
|
if (!hasSteeringElement(steeringElements, 'gate_fulfillment')) return null
|
||||||
|
return kernelReadModels?.planning_debt ?? executionGraph?.planning_debt ?? null
|
||||||
|
}, [steeringElements, kernelReadModels, executionGraph])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingState message="Lade Arbeitspakete …" />
|
return <LoadingState message="Lade Arbeitspakete …" />
|
||||||
}
|
}
|
||||||
|
|
||||||
const isProduct = initiative?.archetype_key === 'initiative.product'
|
const isContinuousProduct = Boolean(uiFeatures?.continuousProductWorkMode)
|
||||||
const sectionLead = isProduct
|
const sectionLead = isContinuousProduct
|
||||||
? 'Arbeitspakete direkt anlegen (Pfad B) oder committetes Ist — ohne Eingang-Umweg. Sprint-fokussiert unter Ausführen → Sprint.'
|
? 'Arbeitspakete direkt anlegen (Pfad B) oder committetes Ist — ohne Eingang-Umweg. Sprint-fokussiert unter Ausführen → Sprint.'
|
||||||
: 'Gesamt-Ist am Vorhaben — Sprint-fokussierte Arbeit unter Ausführen → Sprint.'
|
: 'Gesamt-Ist am Vorhaben — Sprint-fokussierte Arbeit unter Ausführen → Sprint.'
|
||||||
|
|
||||||
|
|
@ -62,7 +91,7 @@ function PlanWorkInner() {
|
||||||
<>
|
<>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<PlanningDebtBanner
|
<PlanningDebtBanner
|
||||||
planningDebt={executionGraph?.planning_debt}
|
planningDebt={planningDebt}
|
||||||
hrefWithScope={hrefWithScope}
|
hrefWithScope={hrefWithScope}
|
||||||
/>
|
/>
|
||||||
<PlanActionsSection
|
<PlanActionsSection
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,16 @@ export const STEERING_ELEMENT_UI = {
|
||||||
nextActionSubtitle:
|
nextActionSubtitle:
|
||||||
'Empfehlung aus sequenzieller Abhängigkeit — bereite Arbeitspakete zuerst.',
|
'Empfehlung aus sequenzieller Abhängigkeit — bereite Arbeitspakete zuerst.',
|
||||||
},
|
},
|
||||||
|
work_cycle_scope: {
|
||||||
|
nextActionTitle: 'Nächster Schritt im aktiven Sprint',
|
||||||
|
nextActionSubtitle:
|
||||||
|
'Empfehlung aus dem Sprint-Backlog — Continuous Next außerhalb des Sprints.',
|
||||||
|
},
|
||||||
|
gate_fulfillment: {
|
||||||
|
nextActionTitle: 'Nächster sinnvoller Schritt',
|
||||||
|
nextActionSubtitle:
|
||||||
|
'Continuous Betrieb — committete Arbeitspakete und Eingang triagieren.',
|
||||||
|
},
|
||||||
queue_inbox: {
|
queue_inbox: {
|
||||||
nextActionTitle: 'Nächster Schritt aus der Queue',
|
nextActionTitle: 'Nächster Schritt aus der Queue',
|
||||||
nextActionSubtitle: 'Pull-Empfehlung aus dem Eingang.',
|
nextActionSubtitle: 'Pull-Empfehlung aus dem Eingang.',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user