From efb45a3ee61bc62e1e319c70855d52133a0c6d02 Mon Sep 17 00:00:00 2001
From: Lars
Date: Fri, 10 Jul 2026 12:36:12 +0200
Subject: [PATCH] Erfassen und Transparent: Archetyp, Methode und Auspraegung
in der UI sichtbar.
Method-Profiles-API und Auswahl beim Anlegen/Profil; Steuerungs-Meta in Listen und Plan; Projekt-Archetyp-Spiegel, Next-Action-Links und Umlaut-Fix fuer testbare Guidance.
Co-authored-by: Cursor
---
backend/data_layer/initiative_snapshot.py | 9 +-
backend/routers/initiatives.py | 2 +
backend/routers/steering.py | 70 ++++++++++---
backend/services/initiatives.py | 2 +
backend/services/steering_context.py | 90 +++++++++++++++++
backend/steering/context.py | 13 +++
backend/steering/signals/default_rules.py | 48 ++++-----
backend/tests/test_ap20_method_profiles.py | 73 ++++++++++++++
frontend/src/api/methodProfiles.js | 10 ++
frontend/src/api/steering.js | 10 ++
frontend/src/components/ArchetypeBadge.jsx | 11 +++
frontend/src/components/InitiativeForm.jsx | 64 ++++++++++++
.../src/components/InitiativeSteeringMeta.jsx | 98 +++++++++++++++++++
.../src/components/MethodProfileSelect.jsx | 56 +++++++++++
frontend/src/components/PlanOutlineNav.jsx | 32 +++++-
frontend/src/components/ProjectForm.jsx | 17 ++++
frontend/src/components/ProjectsSection.jsx | 9 ++
.../src/components/SteeringSnapshotPanel.jsx | 50 +++++++++-
.../src/constants/initiativeArchetypes.js | 19 +++-
.../context/InitiativeOperationsContext.jsx | 19 +++-
frontend/src/hooks/useEntityArchetypes.js | 39 ++++++++
frontend/src/pages/InitiativesPage.jsx | 15 ++-
frontend/src/pages/WorkspacePage.jsx | 3 +-
.../pages/initiative/ProjectDetailPage.jsx | 24 +++++
frontend/src/pages/modes/CockpitPage.jsx | 4 +-
frontend/src/pages/modes/PlanProfilePage.jsx | 63 +++++++++++-
.../src/pages/modes/PlanStructurePage.jsx | 6 ++
frontend/src/styles/components.css | 83 ++++++++++++++++
frontend/src/utils/archetypes.js | 49 ++++++++++
.../src/widgets/InitiativePortfolioWidget.jsx | 7 ++
frontend/src/widgets/NextActionWidget.jsx | 35 ++++++-
31 files changed, 965 insertions(+), 65 deletions(-)
create mode 100644 backend/tests/test_ap20_method_profiles.py
create mode 100644 frontend/src/api/methodProfiles.js
create mode 100644 frontend/src/components/ArchetypeBadge.jsx
create mode 100644 frontend/src/components/InitiativeSteeringMeta.jsx
create mode 100644 frontend/src/components/MethodProfileSelect.jsx
create mode 100644 frontend/src/hooks/useEntityArchetypes.js
create mode 100644 frontend/src/utils/archetypes.js
diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py
index 44ae0b1..7dccada 100644
--- a/backend/data_layer/initiative_snapshot.py
+++ b/backend/data_layer/initiative_snapshot.py
@@ -11,7 +11,8 @@ from psycopg2.extras import RealDictCursor
from db import get_connection
from data_layer.attention import get_next_action_candidates_for_initiative
-from entity_archetypes.registry import archetype_label
+ from entity_archetypes.registry import archetype_label
+ from method_profiles.registry import get_method_profile
from steering.context import get_steering_context_dto
from steering.signals.snapshot_signals import derive_initiative_signals
from services.initiatives import get_initiative
@@ -301,6 +302,11 @@ def get_initiative_steering_snapshot(
if isinstance(metadata, str):
metadata = {}
method_profile_key = metadata.get("method_profile_key")
+ method_profile_label = None
+ if method_profile_key:
+ profile = get_method_profile(method_profile_key)
+ if profile:
+ method_profile_label = profile.get("label")
initiative_archetype = initiative.get("archetype_key") or "initiative.generic"
return {
@@ -312,6 +318,7 @@ def get_initiative_steering_snapshot(
entity_type="initiative", archetype_key=initiative_archetype
),
"method_profile_key": method_profile_key,
+ "method_profile_label": method_profile_label,
"lifecycle_state": steering["lifecycle_state"],
"lifecycle_label": steering["lifecycle_label"],
"method_key": steering["method_key"],
diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py
index 46ab5b4..30a1585 100644
--- a/backend/routers/initiatives.py
+++ b/backend/routers/initiatives.py
@@ -38,6 +38,7 @@ class InitiativeCreateRequest(BaseModel):
status: Literal["active", "paused", "completed", "archived"] = "active"
priority: Literal["low", "normal", "high"] = "normal"
owner_actor_id: Optional[str] = None
+ method_profile_key: Optional[str] = Field(default=None, min_length=1)
class InitiativeUpdateRequest(BaseModel):
@@ -160,6 +161,7 @@ def create_initiative(
priority=body.priority,
owner_actor_id=owner_actor_id,
user_id=ctx.user_id,
+ method_profile_key=body.method_profile_key,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
diff --git a/backend/routers/steering.py b/backend/routers/steering.py
index 5442e21..128b73c 100644
--- a/backend/routers/steering.py
+++ b/backend/routers/steering.py
@@ -1,12 +1,13 @@
-"""Steering API — methods and context (AP1.1)."""
+"""Steering API — methods, profiles and context (AP1.1 / AP2.0)."""
from __future__ import annotations
from typing import Optional
from capabilities import require_capability
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
+from method_profiles.registry import list_method_profiles
from services import initiatives as initiative_service
from services import steering_context as sc_service
from steering.context import get_steering_context_dto
@@ -17,13 +18,16 @@ router = APIRouter(prefix="/api/steering", tags=["steering"])
class SteeringContextUpdateRequest(BaseModel):
- method_key: str = Field(..., min_length=1)
+ method_key: Optional[str] = Field(default=None, min_length=1)
+ method_profile_key: Optional[str] = Field(default=None, min_length=1)
+ clear_method_profile: bool = False
@router.get("/methods")
def list_steering_methods(
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
+ _ = ctx
return [
{
"key": m.key,
@@ -36,25 +40,65 @@ def list_steering_methods(
]
+@router.get("/method-profiles")
+def list_steering_method_profiles(
+ initiative_archetype_key: Optional[str] = Query(default=None),
+ ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
+):
+ _ = ctx
+ return [
+ {
+ "key": p["key"],
+ "label": p["label"],
+ "description": p["description"],
+ "initiative_archetype_key": p["initiative_archetype_key"],
+ "method_key": p["method_key"],
+ }
+ for p in list_method_profiles(initiative_archetype_key=initiative_archetype_key)
+ ]
+
+
@router.patch("/initiatives/{initiative_id}/context")
def patch_initiative_steering_context(
initiative_id: str,
body: SteeringContextUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
):
- if not initiative_service.get_initiative(
+ initiative = initiative_service.get_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
- ):
+ )
+ if not initiative:
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
- if not body.method_key:
- raise HTTPException(status_code=400, detail="method_key erforderlich")
- try:
- sc_service.update_method_key(
- tenant_id=ctx.tenant_id,
- initiative_id=initiative_id,
- method_key=body.method_key,
- user_id=ctx.user_id,
+
+ if not body.method_key and not body.method_profile_key and not body.clear_method_profile:
+ raise HTTPException(
+ status_code=400,
+ detail="method_key, method_profile_key oder clear_method_profile erforderlich",
)
+
+ try:
+ if body.clear_method_profile:
+ sc_service.update_method_profile_key(
+ tenant_id=ctx.tenant_id,
+ initiative_id=initiative_id,
+ method_profile_key=None,
+ user_id=ctx.user_id,
+ )
+ elif body.method_profile_key:
+ sc_service.update_method_profile_key(
+ tenant_id=ctx.tenant_id,
+ initiative_id=initiative_id,
+ method_profile_key=body.method_profile_key,
+ user_id=ctx.user_id,
+ )
+ elif body.method_key:
+ sc_service.update_method_key(
+ tenant_id=ctx.tenant_id,
+ initiative_id=initiative_id,
+ method_key=body.method_key,
+ user_id=ctx.user_id,
+ )
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+
return get_steering_context_dto(ctx, initiative_id=initiative_id)
diff --git a/backend/services/initiatives.py b/backend/services/initiatives.py
index 0cc4c25..e18e314 100644
--- a/backend/services/initiatives.py
+++ b/backend/services/initiatives.py
@@ -73,6 +73,7 @@ def create_initiative(
status: InitiativeStatus = "active",
priority: Priority = "normal",
user_id: Optional[str] = None,
+ method_profile_key: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
@@ -125,6 +126,7 @@ def create_initiative(
initiative_id=row["id"],
user_id=user_id,
archetype_key=archetype_key,
+ method_profile_key=method_profile_key,
)
return row
diff --git a/backend/services/steering_context.py b/backend/services/steering_context.py
index 02852aa..3f3d14f 100644
--- a/backend/services/steering_context.py
+++ b/backend/services/steering_context.py
@@ -261,6 +261,89 @@ def update_method_key(
return result
+def update_method_profile_key(
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ method_profile_key: Optional[str],
+ user_id: Optional[str] = None,
+) -> dict[str, Any]:
+ """Setzt oder entfernt method_profile_key; passt method_key aus Profile/Archetyp an."""
+ from entity_archetypes.registry import resolve_default_method_key
+ from method_profiles.registry import get_method_profile
+ from steering.methods.registry import get_method
+
+ initiative = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
+ if not initiative:
+ raise ValueError("Vorhaben nicht gefunden")
+
+ existing = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id)
+ if not existing:
+ raise ValueError("SteeringContext nicht gefunden")
+
+ metadata = dict(existing.get("lifecycle_metadata") or {})
+ previous_profile = metadata.get("method_profile_key")
+
+ if method_profile_key:
+ profile = get_method_profile(method_profile_key)
+ if not profile:
+ raise ValueError(f"Unbekannte Ausprägung: {method_profile_key}")
+ if profile["initiative_archetype_key"] != initiative["archetype_key"]:
+ raise ValueError("Ausprägung passt nicht zum Archetyp des Vorhabens")
+ metadata["method_profile_key"] = method_profile_key
+ resolved_method = profile["method_key"]
+ else:
+ metadata.pop("method_profile_key", None)
+ resolved_method = resolve_default_method_key(initiative["archetype_key"])
+
+ method = get_method(resolved_method)
+ if not method:
+ raise ValueError(f"Unbekannte Methode: {resolved_method}")
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ UPDATE steering_contexts
+ SET method_key = %s,
+ method_version = %s,
+ lifecycle_metadata = %s,
+ updated_at = NOW()
+ WHERE tenant_id = %s AND initiative_id = %s
+ RETURNING id, tenant_id, initiative_id, method_key, method_version,
+ lifecycle_state, lifecycle_metadata, created_at, updated_at
+ """,
+ (
+ method.key,
+ method.version,
+ Json(metadata),
+ tenant_id,
+ initiative_id,
+ ),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise ValueError("SteeringContext nicht gefunden")
+ result = _serialize_row(dict(row))
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "steering_context.method_profile_changed",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={
+ "initiative_id": initiative_id,
+ "from_profile": previous_profile,
+ "to_profile": method_profile_key,
+ "method_key": method.key,
+ },
+ )
+ return result
+
+
def create_context_for_new_initiative(
*,
tenant_id: str,
@@ -275,6 +358,13 @@ def create_context_for_new_initiative(
method_key = resolve_default_method_key(archetype_key)
lifecycle_metadata: dict[str, Any] = {}
if method_profile_key:
+ from method_profiles.registry import get_method_profile
+
+ profile = get_method_profile(method_profile_key)
+ if not profile:
+ raise ValueError(f"Unbekannte Ausprägung: {method_profile_key}")
+ if profile["initiative_archetype_key"] != archetype_key:
+ raise ValueError("Ausprägung passt nicht zum Archetyp des Vorhabens")
profile_method = resolve_method_for_profile(method_profile_key)
if profile_method:
method_key = profile_method
diff --git a/backend/steering/context.py b/backend/steering/context.py
index 23d10a2..693b0f6 100644
--- a/backend/steering/context.py
+++ b/backend/steering/context.py
@@ -22,10 +22,23 @@ def get_steering_context_dto(
initiative_id=initiative_id,
)
method = get_method(row["method_key"])
+ metadata = row.get("lifecycle_metadata") or {}
+ if isinstance(metadata, str):
+ metadata = {}
+ method_profile_key = metadata.get("method_profile_key")
+ method_profile_label = None
+ if method_profile_key:
+ from method_profiles.registry import get_method_profile
+
+ profile = get_method_profile(method_profile_key)
+ if profile:
+ method_profile_label = profile.get("label")
return {
**row,
"lifecycle_label": lifecycle_label(row["lifecycle_state"]),
"method_label": method.label if method else row["method_key"],
+ "method_profile_key": method_profile_key,
+ "method_profile_label": method_profile_label,
}
diff --git a/backend/steering/signals/default_rules.py b/backend/steering/signals/default_rules.py
index a3d8b12..9809f33 100644
--- a/backend/steering/signals/default_rules.py
+++ b/backend/steering/signals/default_rules.py
@@ -63,7 +63,7 @@ def _blocked_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
'blocked_action' AS kind,
'critical' AS severity,
a.title AS title,
- 'Maßnahme ist blockiert' AS summary,
+ 'Maßnahme ist blockiert' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
@@ -136,7 +136,7 @@ def _high_priority_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
'high_priority_action' AS kind,
'warning' AS severity,
a.title AS title,
- 'High-Priority Maßnahme offen' AS summary,
+ 'High-Priority Maßnahme offen' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
@@ -165,7 +165,7 @@ def _unassigned_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
'unassigned_action' AS kind,
'warning' AS severity,
a.title AS title,
- 'Maßnahme ohne Zuweisung' AS summary,
+ 'Maßnahme ohne Zuweisung' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
@@ -196,7 +196,7 @@ def _initiatives_without_next_action(cur, ctx: TenantContext) -> list[dict[str,
'initiative_without_next_action' AS kind,
'info' AS severity,
i.title AS title,
- 'Keine offene nächste Maßnahme' AS summary,
+ 'Keine offene nächste Maßnahme' AS summary,
'initiative' AS scope_type,
i.id AS scope_id,
i.id AS initiative_id,
@@ -229,7 +229,7 @@ def _stale_initiatives(cur, ctx: TenantContext) -> list[dict[str, Any]]:
'stale_initiative' AS kind,
'info' AS severity,
i.title AS title,
- 'Vorhaben seit über 14 Tagen unverändert' AS summary,
+ 'Vorhaben seit über 14 Tagen unverändert' AS summary,
'initiative' AS scope_type,
i.id AS scope_id,
i.id AS initiative_id,
@@ -284,7 +284,7 @@ def _actions_review_required(cur, ctx: TenantContext) -> list[dict[str, Any]]:
'action_review_required' AS kind,
'warning' AS severity,
a.title AS title,
- 'Ma├ƒnahme wartet auf Review ÔÇö kein geplantes Review verkn├╝pft' AS summary,
+ 'Maßnahme wartet auf Review — kein geplantes Review verknüpft' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
@@ -319,7 +319,7 @@ def _overdue_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
'overdue_action' AS kind,
'warning' AS severity,
a.title AS title,
- 'Maßnahme überfällig' AS summary,
+ 'Maßnahme überfällig' AS summary,
'action' AS scope_type,
a.id AS scope_id,
a.initiative_id,
@@ -350,7 +350,7 @@ def _reviews_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
'review_due' AS kind,
'warning' AS severity,
r.title AS title,
- 'Review fällig' AS summary,
+ 'Review fällig' AS summary,
'review' AS scope_type,
r.id AS scope_id,
r.initiative_id,
@@ -388,7 +388,7 @@ def _recurring_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
'recurring_due' AS kind,
'info' AS severity,
re.title AS title,
- 'Wiederkehrendes Element fällig' AS summary,
+ 'Wiederkehrendes Element fällig' AS summary,
'recurring_element' AS scope_type,
re.id AS scope_id,
re.initiative_id,
@@ -413,7 +413,7 @@ def _recurring_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
- """Regelbasierte Attention Items ÔÇö tenant-scoped, erkl├ñrbar."""
+ """Regelbasierte Attention Items — tenant-scoped, erklärbar."""
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
@@ -439,7 +439,7 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
def get_next_action_candidates(
ctx: TenantContext, *, limit: int = 10
) -> list[dict[str, Any]]:
- """Regelbasierte NextActionCandidates ÔÇö limitiert, tenant-scoped."""
+ """Regelbasierte NextActionCandidates — limitiert, tenant-scoped."""
if limit < 1:
limit = 1
if limit > 50:
@@ -454,7 +454,7 @@ def get_next_action_candidates(
SELECT
'resolve_blocker' AS kind,
b.title AS title,
- 'Blocker klären oder Status aktualisieren' AS summary,
+ 'Blocker klären oder Status aktualisieren' AS summary,
b.initiative_id,
b.action_id,
NULL::uuid AS backlog_item_id,
@@ -486,7 +486,7 @@ def get_next_action_candidates(
a.id AS action_id,
NULL::uuid AS backlog_item_id,
'action_unassigned' AS reason_code,
- 'Maßnahme zuweisen' AS recommended_action
+ 'Maßnahme zuweisen' AS recommended_action
FROM actions a
WHERE a.tenant_id = %s
AND a.status IN ('open', 'ready', 'in_progress')
@@ -512,12 +512,12 @@ def get_next_action_candidates(
SELECT
'convert_backlog' AS kind,
bi.title AS title,
- 'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
+ 'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
bi.initiative_id,
NULL::uuid AS action_id,
bi.id AS backlog_item_id,
'backlog_accepted_not_converted' AS reason_code,
- 'In Maßnahme umwandeln' AS recommended_action
+ 'In Maßnahme umwandeln' AS recommended_action
FROM backlog_items bi
WHERE bi.tenant_id = %s
AND bi.status = 'accepted'
@@ -540,12 +540,12 @@ def get_next_action_candidates(
SELECT
'create_action' AS kind,
i.title AS title,
- 'Nächste Maßnahme für Vorhaben anlegen' AS summary,
+ 'Nächste Maßnahme für Vorhaben anlegen' AS summary,
i.id AS initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS backlog_item_id,
'initiative_no_open_action' AS reason_code,
- 'Maßnahme anlegen' AS recommended_action
+ 'Maßnahme anlegen' AS recommended_action
FROM initiatives i
WHERE i.tenant_id = %s
AND i.status IN ('active', 'paused')
@@ -573,7 +573,7 @@ def get_next_action_candidates(
def get_next_action_candidates_for_initiative(
ctx: TenantContext, *, initiative_id: str, limit: int = 5
) -> list[dict[str, Any]]:
- """NextActionCandidates f├╝r ein Vorhaben ÔÇö tenant-scoped."""
+ """NextActionCandidates für ein Vorhaben — tenant-scoped."""
if limit < 1:
limit = 1
if limit > 20:
@@ -588,7 +588,7 @@ def get_next_action_candidates_for_initiative(
SELECT
'resolve_blocker' AS kind,
b.title AS title,
- 'Blocker klären oder Status aktualisieren' AS summary,
+ 'Blocker klären oder Status aktualisieren' AS summary,
b.initiative_id,
b.action_id,
NULL::uuid AS backlog_item_id,
@@ -621,7 +621,7 @@ def get_next_action_candidates_for_initiative(
a.id AS action_id,
NULL::uuid AS backlog_item_id,
'action_unassigned' AS reason_code,
- 'Maßnahme zuweisen' AS recommended_action
+ 'Maßnahme zuweisen' AS recommended_action
FROM actions a
WHERE a.tenant_id = %s AND a.initiative_id = %s
AND a.status IN ('open', 'ready', 'in_progress')
@@ -647,12 +647,12 @@ def get_next_action_candidates_for_initiative(
SELECT
'convert_backlog' AS kind,
bi.title AS title,
- 'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
+ 'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
bi.initiative_id,
NULL::uuid AS action_id,
bi.id AS backlog_item_id,
'backlog_accepted_not_converted' AS reason_code,
- 'In Maßnahme umwandeln' AS recommended_action
+ 'In Maßnahme umwandeln' AS recommended_action
FROM backlog_items bi
WHERE bi.tenant_id = %s AND bi.initiative_id = %s
AND bi.status = 'accepted'
@@ -693,12 +693,12 @@ def get_next_action_candidates_for_initiative(
{
"kind": "create_action",
"title": init_row["title"],
- "summary": "Nächste Maßnahme für Vorhaben anlegen",
+ "summary": "Nächste Maßnahme für Vorhaben anlegen",
"initiative_id": initiative_id,
"action_id": None,
"backlog_item_id": None,
"reason_code": "initiative_no_open_action",
- "recommended_action": "Maßnahme anlegen",
+ "recommended_action": "Maßnahme anlegen",
}
)
diff --git a/backend/tests/test_ap20_method_profiles.py b/backend/tests/test_ap20_method_profiles.py
new file mode 100644
index 0000000..a648bb2
--- /dev/null
+++ b/backend/tests/test_ap20_method_profiles.py
@@ -0,0 +1,73 @@
+"""Tests for method profile API — AP2.0 capture slice."""
+
+from __future__ import annotations
+
+from tests.factories import provision_user_in_tenant
+from tests.test_initiatives_actions import _auth, _create_initiative, _login
+
+
+def test_method_profiles_list_for_product_archetype(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+
+ res = client.get(
+ "/api/steering/method-profiles?initiative_archetype_key=initiative.product",
+ headers=_auth(token),
+ )
+ assert res.status_code == 200
+ keys = {item["key"] for item in res.json()}
+ assert "product.kairo_dev" in keys
+
+
+def test_create_initiative_with_method_profile(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+
+ created = client.post(
+ "/api/initiatives",
+ json={
+ "title": "Kairo Dev",
+ "archetype_key": "initiative.product",
+ "method_profile_key": "product.kairo_dev",
+ },
+ headers=_auth(token),
+ )
+ assert created.status_code == 201
+ initiative_id = created.json()["id"]
+
+ snap = client.get(
+ f"/api/initiatives/{initiative_id}/steering-snapshot",
+ headers=_auth(token),
+ )
+ assert snap.status_code == 200
+ body = snap.json()
+ assert body["method_profile_key"] == "product.kairo_dev"
+ assert body.get("method_profile_label") == "Kairo Entwicklung"
+ assert body["method_key"] == "continuous_product"
+
+
+def test_patch_method_profile_on_initiative(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+
+ created = _create_initiative(
+ client,
+ token,
+ title="Product X",
+ archetype_key="initiative.product",
+ )
+ initiative_id = created.json()["id"]
+
+ patched = client.patch(
+ f"/api/steering/initiatives/{initiative_id}/context",
+ json={"method_profile_key": "product.kairo_dev"},
+ headers=_auth(token),
+ )
+ assert patched.status_code == 200
+ assert patched.json()["method_key"] == "continuous_product"
+
+ snap = client.get(
+ f"/api/initiatives/{initiative_id}/steering-snapshot",
+ headers=_auth(token),
+ )
+ assert snap.json()["method_profile_label"] == "Kairo Entwicklung"
diff --git a/frontend/src/api/methodProfiles.js b/frontend/src/api/methodProfiles.js
new file mode 100644
index 0000000..8f6674e
--- /dev/null
+++ b/frontend/src/api/methodProfiles.js
@@ -0,0 +1,10 @@
+import { apiFetch } from './client.js'
+
+export function listMethodProfiles(initiativeArchetypeKey) {
+ const params = new URLSearchParams()
+ if (initiativeArchetypeKey) {
+ params.set('initiative_archetype_key', initiativeArchetypeKey)
+ }
+ const qs = params.toString()
+ return apiFetch(`/api/steering/method-profiles${qs ? `?${qs}` : ''}`)
+}
diff --git a/frontend/src/api/steering.js b/frontend/src/api/steering.js
index f505012..65fece5 100644
--- a/frontend/src/api/steering.js
+++ b/frontend/src/api/steering.js
@@ -10,3 +10,13 @@ export function updateInitiativeSteeringMethod(initiativeId, methodKey) {
body: JSON.stringify({ method_key: methodKey }),
})
}
+
+export function updateInitiativeMethodProfile(initiativeId, methodProfileKey) {
+ const body = methodProfileKey
+ ? { method_profile_key: methodProfileKey }
+ : { clear_method_profile: true }
+ return apiFetch(`/api/steering/initiatives/${initiativeId}/context`, {
+ method: 'PATCH',
+ body: JSON.stringify(body),
+ })
+}
diff --git a/frontend/src/components/ArchetypeBadge.jsx b/frontend/src/components/ArchetypeBadge.jsx
new file mode 100644
index 0000000..b479ce1
--- /dev/null
+++ b/frontend/src/components/ArchetypeBadge.jsx
@@ -0,0 +1,11 @@
+import { archetypeLabel } from '../utils/archetypes.js'
+
+export function ArchetypeBadge({ archetypeKey, archetypes = [], title }) {
+ if (!archetypeKey) return null
+ const label = archetypeLabel(archetypeKey, archetypes)
+ return (
+
+ {label}
+
+ )
+}
diff --git a/frontend/src/components/InitiativeForm.jsx b/frontend/src/components/InitiativeForm.jsx
index 40cc3af..515dc4d 100644
--- a/frontend/src/components/InitiativeForm.jsx
+++ b/frontend/src/components/InitiativeForm.jsx
@@ -8,7 +8,10 @@ import {
getInitiativeFields,
listArchetypeFieldDefinitions,
} from '../api/entityFields.js'
+import { listSteeringMethods } from '../api/steering.js'
+import { listMethodProfiles } from '../api/methodProfiles.js'
import { FieldRenderer } from './FieldRenderer.jsx'
+import { InitiativeSteeringMeta } from './InitiativeSteeringMeta.jsx'
export function InitiativeForm({
initial = {},
@@ -23,6 +26,42 @@ export function InitiativeForm({
const [fieldDefinitions, setFieldDefinitions] = useState([])
const [dynamicValues, setDynamicValues] = useState({})
const [fieldsLoading, setFieldsLoading] = useState(false)
+ const [steeringMethods, setSteeringMethods] = useState([])
+ const [methodProfiles, setMethodProfiles] = useState([])
+ const [methodProfileKey, setMethodProfileKey] = useState('')
+
+ useEffect(() => {
+ let cancelled = false
+ listSteeringMethods()
+ .then((items) => {
+ if (!cancelled) setSteeringMethods(Array.isArray(items) ? items : [])
+ })
+ .catch(() => {
+ if (!cancelled) setSteeringMethods([])
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ useEffect(() => {
+ let cancelled = false
+ setMethodProfileKey('')
+ if (!archetypeKey) {
+ setMethodProfiles([])
+ return undefined
+ }
+ listMethodProfiles(archetypeKey)
+ .then((items) => {
+ if (!cancelled) setMethodProfiles(Array.isArray(items) ? items : [])
+ })
+ .catch(() => {
+ if (!cancelled) setMethodProfiles([])
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [archetypeKey])
useEffect(() => {
let cancelled = false
@@ -91,6 +130,7 @@ export function InitiativeForm({
status: form.status.value,
priority: form.priority.value,
dynamicFields: dynamicValues,
+ ...(methodProfileKey ? { method_profile_key: methodProfileKey } : {}),
})
}
@@ -121,6 +161,30 @@ export function InitiativeForm({
))}
+ {methodProfiles.length > 0 && (
+
+ )}
+
)}
@@ -133,7 +167,7 @@ export function SteeringSnapshotPanel({
{(attention_items?.length > 0 || signals?.length > 0) && (
- Attention:{' '}
+ Aufmerksamkeit:{' '}
{(attention_items.length > 0
? attention_items.map((a) => a.label)
: signals.map((s) => SIGNAL_LABELS[s] || s)
@@ -146,19 +180,25 @@ export function SteeringSnapshotPanel({
Nächste Schritte
- {next_actions.map((item, i) => (
+ {next_actions.map((item, i) => {
+ const link = nextActionLink(item)
+ return (
-
{i + 1}
{item.title}
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
- {item.reason_code ? ` · ${item.reason_code}` : ''}
{item.recommended_action ? ` — ${item.recommended_action}` : ''}
+ {link && (
+
+ {link.label}
+
+ )}
- ))}
+ )})}
)}
diff --git a/frontend/src/constants/initiativeArchetypes.js b/frontend/src/constants/initiativeArchetypes.js
index 4e8a0a7..8ff6912 100644
--- a/frontend/src/constants/initiativeArchetypes.js
+++ b/frontend/src/constants/initiativeArchetypes.js
@@ -1,10 +1,19 @@
-/** Fallback-Labels bis API geladen (AP1.10a). */
+/** Fallback-Labels bis API geladen (AP1.10a / AP2.0). */
export const INITIATIVE_ARCHETYPE_LABELS = {
'initiative.generic': 'Allgemeines Vorhaben',
- 'initiative.program': 'Programm / Mega-Vorhaben',
- 'initiative.product': 'Produkt / Release',
+ 'initiative.maturity_journey': 'Reifegrad-Entwicklung',
+ 'initiative.linear_project': 'Lineares Vorhaben',
+ 'initiative.recurring_program': 'Dauerprogramm / Rhythmus',
+ 'initiative.support_queue': 'Inbox / Queue',
+ 'initiative.program': 'Programm (begrenzt)',
+ 'initiative.product': 'Produkt (kontinuierlich)',
+ 'initiative.dispute_case': 'Verfahren / Konflikt',
+ 'initiative.content_project': 'Inhalt / Kapitel',
}
-export function initiativeArchetypeLabel(key) {
- return INITIATIVE_ARCHETYPE_LABELS[key] || key || 'Allgemeines Vorhaben'
+export function initiativeArchetypeLabel(key, archetypes = []) {
+ if (!key) return 'Allgemeines Vorhaben'
+ const fromApi = archetypes.find((item) => item.key === key)
+ if (fromApi?.label) return fromApi.label
+ return INITIATIVE_ARCHETYPE_LABELS[key] || key
}
diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx
index 8b0f835..8d667e5 100644
--- a/frontend/src/context/InitiativeOperationsContext.jsx
+++ b/frontend/src/context/InitiativeOperationsContext.jsx
@@ -66,7 +66,11 @@ import {
updateRecurring,
deleteRecurring,
} from '../api/recurring.js'
-import { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/steering.js'
+import {
+ listSteeringMethods,
+ updateInitiativeSteeringMethod,
+ updateInitiativeMethodProfile,
+} from '../api/steering.js'
import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js'
import { useSession } from './SessionContext.jsx'
@@ -204,6 +208,18 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
}
}
+ async function handleMethodProfileChange(methodProfileKey) {
+ setMethodBusy(true)
+ try {
+ await updateInitiativeMethodProfile(id, methodProfileKey || null)
+ setSteeringSnapshot(await getInitiativeSteeringSnapshot(id))
+ } catch (err) {
+ setSteeringSnapshotError(err.message)
+ } finally {
+ setMethodBusy(false)
+ }
+ }
+
async function handleCreateAction(payload) {
setFormBusy(true)
try {
@@ -714,6 +730,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
reloadActors: actorsState.reload,
reload: load,
handleMethodChange,
+ handleMethodProfileChange,
handleCreateAction,
handleUpdateAction,
handleQuickStatus,
diff --git a/frontend/src/hooks/useEntityArchetypes.js b/frontend/src/hooks/useEntityArchetypes.js
new file mode 100644
index 0000000..011fb4d
--- /dev/null
+++ b/frontend/src/hooks/useEntityArchetypes.js
@@ -0,0 +1,39 @@
+import { useEffect, useState } from 'react'
+import { listEntityArchetypes } from '../api/entityArchetypes.js'
+
+/**
+ * Lädt Initiative- und Project-Archetypen (AP1.10 / AP2.0).
+ * @param {{ enabled?: boolean }} [options]
+ */
+export function useEntityArchetypes(options = {}) {
+ const { enabled = true } = options
+ const [initiativeArchetypes, setInitiativeArchetypes] = useState([])
+ const [projectArchetypes, setProjectArchetypes] = useState([])
+ const [loading, setLoading] = useState(enabled)
+
+ useEffect(() => {
+ if (!enabled) {
+ setLoading(false)
+ return undefined
+ }
+ let cancelled = false
+ setLoading(true)
+ Promise.all([
+ listEntityArchetypes('initiative').catch(() => []),
+ listEntityArchetypes('project').catch(() => []),
+ ])
+ .then(([initiative, project]) => {
+ if (cancelled) return
+ setInitiativeArchetypes(Array.isArray(initiative) ? initiative : [])
+ setProjectArchetypes(Array.isArray(project) ? project : [])
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [enabled])
+
+ return { initiativeArchetypes, projectArchetypes, loading }
+}
diff --git a/frontend/src/pages/InitiativesPage.jsx b/frontend/src/pages/InitiativesPage.jsx
index e13219d..d9b4fc8 100644
--- a/frontend/src/pages/InitiativesPage.jsx
+++ b/frontend/src/pages/InitiativesPage.jsx
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
+import { Link, useNavigate } from 'react-router-dom'
import { listInitiatives, listInitiativeActions, createInitiative } from '../api/initiatives.js'
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
import { countOpenActions } from '../api/actions.js'
+import { ArchetypeBadge } from '../components/ArchetypeBadge.jsx'
import { StatusBadge } from '../components/StatusBadge.jsx'
import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { InitiativeForm } from '../components/InitiativeForm.jsx'
@@ -10,12 +11,15 @@ import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { useCapabilities } from '../hooks/useCapabilities.js'
+import { useEntityArchetypes } from '../hooks/useEntityArchetypes.js'
import { useSession } from '../context/SessionContext.jsx'
import { scopedPath } from '../utils/routes.js'
export function InitiativesPage() {
+ const navigate = useNavigate()
const { context } = useSession()
const { hasCapability } = useCapabilities()
+ const { initiativeArchetypes } = useEntityArchetypes()
const [initiatives, setInitiatives] = useState([])
const [openCounts, setOpenCounts] = useState({})
const [loading, setLoading] = useState(true)
@@ -62,6 +66,7 @@ export function InitiativesPage() {
await saveInitiativeDynamicFields(created.id, dynamicFields)
setShowForm(false)
await load()
+ navigate(scopedPath('/plan/profile', { initiativeId: created.id }))
} catch (err) {
setError(err.message)
} finally {
@@ -74,7 +79,9 @@ export function InitiativesPage() {
Vorhaben
-
Aktive und pausierte Vorhaben im Tenant.
+
+ Alle aktiven Vorhaben — Archetyp und Steuerung im Profil und unter Kontrolle einsehen.
+
{hasCapability('kairo.initiative.manage') && (
{error && {error}
}
+
+ {canManage && (
+
+
+
+ )}
-
Archetyp
-
{initiativeArchetypeLabel(initiative.archetype_key)}
+
Verantwortlich (Owner)
+
{ownerName}
+
+
+
Struktur
+
+ {projects.length} Projekt{projects.length === 1 ? '' : 'e'} ·{' '}
+
+ Struktur pflegen
+
+
+
+
{initiative.goal ? (
diff --git a/frontend/src/pages/modes/PlanStructurePage.jsx b/frontend/src/pages/modes/PlanStructurePage.jsx
index 7ea7037..60986d9 100644
--- a/frontend/src/pages/modes/PlanStructurePage.jsx
+++ b/frontend/src/pages/modes/PlanStructurePage.jsx
@@ -3,10 +3,13 @@ import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.
import { ProjectsSection } from '../../components/ProjectsSection.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
+import { useEntityArchetypes } from '../../hooks/useEntityArchetypes.js'
function PlanStructureInner() {
const ops = useInitiativeOperations()
+ const { initiativeArchetypes, projectArchetypes } = useEntityArchetypes()
const {
+ initiative,
initiativeId,
projects,
roadmapItems,
@@ -35,6 +38,9 @@ function PlanStructureInner() {
onDelete={ops.handleDeleteProject}
onReorder={ops.handleReorderProjects}
busy={ops.formBusy}
+ initiativeArchetypeKey={initiative?.archetype_key}
+ initiativeArchetypes={initiativeArchetypes}
+ projectArchetypes={projectArchetypes}
/>
>
)
diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css
index e1d3da7..8ff612c 100644
--- a/frontend/src/styles/components.css
+++ b/frontend/src/styles/components.css
@@ -1528,3 +1528,86 @@
opacity: 0.75;
}
+.archetype-badge {
+ background: var(--jk-surface-muted, #eef2f6);
+ color: var(--jk-text-secondary, #445);
+ font-size: 0.8125rem;
+ font-weight: 500;
+}
+
+.initiative-steering-meta {
+ margin: 0.75rem 0 1rem;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--jk-border, #dde3ea);
+ border-radius: var(--jk-radius-md, 8px);
+ background: var(--jk-surface-subtle, #f8fafc);
+}
+
+.initiative-steering-meta--compact {
+ margin: 0.5rem 0 0.75rem;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.875rem;
+}
+
+.initiative-steering-meta__row {
+ display: grid;
+ grid-template-columns: minmax(7rem, 9rem) 1fr;
+ gap: 0.5rem 1rem;
+ align-items: start;
+}
+
+.initiative-steering-meta__row + .initiative-steering-meta__row {
+ margin-top: 0.5rem;
+}
+
+.initiative-steering-meta__label {
+ color: var(--jk-text-muted);
+ font-size: 0.8125rem;
+}
+
+.initiative-steering-meta__value {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.initiative-steering-meta__hint {
+ font-size: 0.8125rem;
+ line-height: 1.4;
+}
+
+.steering-method-description {
+ display: block;
+ margin-top: 0.25rem;
+ font-size: 0.8125rem;
+ line-height: 1.4;
+}
+
+.project-form-archetype-hint,
+.project-detail-archetype-hint {
+ font-size: 0.875rem;
+}
+
+.form-hint {
+ margin: 0 0 1rem;
+ padding: 0.625rem 0.75rem;
+ border-radius: var(--jk-radius-md, 8px);
+ background: var(--jk-surface-subtle, #f8fafc);
+}
+
+.plan-profile__steering-edit {
+ margin: 0 0 1rem;
+ max-width: 28rem;
+}
+
+.method-profile-select {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.method-profile-select__empty {
+ margin: 0 0 1rem;
+ font-size: 0.875rem;
+}
+
diff --git a/frontend/src/utils/archetypes.js b/frontend/src/utils/archetypes.js
new file mode 100644
index 0000000..a4e8f50
--- /dev/null
+++ b/frontend/src/utils/archetypes.js
@@ -0,0 +1,49 @@
+import { INITIATIVE_ARCHETYPE_LABELS } from '../constants/initiativeArchetypes.js'
+
+/** @typedef {{ key: string, label?: string, description?: string, default_method_key?: string }} ArchetypeDto */
+/** @typedef {{ key: string, label?: string, description?: string }} MethodDto */
+
+export function findArchetype(key, archetypes = []) {
+ if (!key) return null
+ return archetypes.find((item) => item.key === key) || null
+}
+
+export function archetypeLabel(key, archetypes = []) {
+ if (!key) return '—'
+ const match = findArchetype(key, archetypes)
+ if (match?.label) return match.label
+ return INITIATIVE_ARCHETYPE_LABELS[key] || key
+}
+
+export function projectArchetypeLabel(key, projectArchetypes = [], initiativeArchetypes = []) {
+ const fromProject = findArchetype(key, projectArchetypes)
+ if (fromProject?.label) return fromProject.label
+ if (key?.startsWith('project.')) {
+ return archetypeLabel(`initiative.${key.slice('project.'.length)}`, initiativeArchetypes)
+ }
+ return key || '—'
+}
+
+export function methodLabelForKey(methodKey, methods = []) {
+ if (!methodKey) return '—'
+ const match = methods.find((item) => item.key === methodKey)
+ return match?.label || methodKey
+}
+
+export function methodDescriptionForKey(methodKey, methods = []) {
+ if (!methodKey) return ''
+ const match = methods.find((item) => item.key === methodKey)
+ return match?.description || ''
+}
+
+export function methodProfileLabel(key, profiles = []) {
+ if (!key) return '—'
+ const match = profiles.find((item) => item.key === key)
+ return match?.label || key
+}
+
+export function methodProfileDescription(key, profiles = []) {
+ if (!key) return ''
+ const match = profiles.find((item) => item.key === key)
+ return match?.description || ''
+}
diff --git a/frontend/src/widgets/InitiativePortfolioWidget.jsx b/frontend/src/widgets/InitiativePortfolioWidget.jsx
index b323aa1..eb75083 100644
--- a/frontend/src/widgets/InitiativePortfolioWidget.jsx
+++ b/frontend/src/widgets/InitiativePortfolioWidget.jsx
@@ -8,8 +8,11 @@ import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { scopedPath } from '../utils/routes.js'
import { WidgetCard } from '../components/WidgetCard.jsx'
+import { ArchetypeBadge } from '../components/ArchetypeBadge.jsx'
+import { useEntityArchetypes } from '../hooks/useEntityArchetypes.js'
export function InitiativePortfolioWidget() {
+ const { initiativeArchetypes } = useEntityArchetypes()
const [initiatives, setInitiatives] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -60,6 +63,10 @@ export function InitiativePortfolioWidget() {
{item.goal}
)}
diff --git a/frontend/src/widgets/NextActionWidget.jsx b/frontend/src/widgets/NextActionWidget.jsx
index 99c636a..e8c8ed0 100644
--- a/frontend/src/widgets/NextActionWidget.jsx
+++ b/frontend/src/widgets/NextActionWidget.jsx
@@ -15,6 +15,25 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
)
}
+ function actionLinkForItem(item) {
+ if (item.action_id) {
+ return { to: actionPath(item.action_id), label: 'Arbeitspaket' }
+ }
+ 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 (
{items.map((item, index) => (
@@ -34,11 +53,17 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
)}
- {item.action_id && (
-
- Arbeitspaket
-
- )}
+ {(() => {
+ const link = actionLinkForItem(item)
+ if (link) {
+ return (
+
+ {link.label}
+
+ )
+ }
+ return null
+ })()}
{showInitiativeLink && item.initiative_id && !initiativeId && (