Erfassen und Transparent: Archetyp, Methode und Auspraegung in der UI sichtbar.
Some checks failed
Test Suite / pytest-backend (push) Failing after 2s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Deploy Development / deploy (push) Failing after 50s

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 <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-10 12:36:12 +02:00
parent 95b743d6fd
commit efb45a3ee6
31 changed files with 965 additions and 65 deletions

View File

@ -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"],

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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,
}

View File

@ -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",
}
)

View File

@ -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"

View File

@ -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}` : ''}`)
}

View File

@ -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),
})
}

View File

@ -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 (
<span className="badge archetype-badge" title={title || archetypeKey}>
{label}
</span>
)
}

View File

@ -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({
))}
</select>
</label>
{methodProfiles.length > 0 && (
<label>
Ausprägung (optional)
<select
name="method_profile_key"
value={methodProfileKey}
onChange={(e) => setMethodProfileKey(e.target.value)}
>
<option value=""> Standard (nur Archetyp) </option>
{methodProfiles.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</select>
</label>
)}
<InitiativeSteeringMeta
archetypeKey={archetypeKey}
initiativeArchetypes={archetypes || []}
steeringMethods={steeringMethods}
methodProfiles={methodProfiles}
selectedMethodProfileKey={methodProfileKey}
/>
<label>
Kurzbeschreibung (Ziel)
<textarea name="goal" rows={2} defaultValue={initial.goal || ''} />

View File

@ -0,0 +1,98 @@
import {
archetypeLabel,
findArchetype,
methodDescriptionForKey,
methodLabelForKey,
methodProfileDescription,
methodProfileLabel,
} from '../utils/archetypes.js'
/**
* Zeigt Archetyp Methode Ausprägung transparent (Capture & Lagebild).
*/
export function InitiativeSteeringMeta({
archetypeKey,
initiativeArchetypes = [],
steeringSnapshot = null,
steeringMethods = [],
methodProfiles = [],
selectedMethodProfileKey = '',
compact = false,
}) {
const archetype = findArchetype(archetypeKey, initiativeArchetypes)
const profileKey =
steeringSnapshot?.method_profile_key || selectedMethodProfileKey || null
const profileLabel =
steeringSnapshot?.method_profile_label ||
methodProfileLabel(profileKey, methodProfiles)
const profileDescription = methodProfileDescription(profileKey, methodProfiles)
const selectedProfile = profileKey
? methodProfiles.find((item) => item.key === profileKey)
: null
const methodKey =
steeringSnapshot?.method_key ||
selectedProfile?.method_key ||
archetype?.default_method_key ||
null
const methodLabel =
steeringSnapshot?.method_label || methodLabelForKey(methodKey, steeringMethods)
const methodDescription = methodDescriptionForKey(methodKey, steeringMethods)
if (!archetypeKey && !methodKey) {
return null
}
const className = compact
? 'initiative-steering-meta initiative-steering-meta--compact'
: 'initiative-steering-meta'
return (
<div className={className}>
{archetypeKey && (
<div className="initiative-steering-meta__row">
<span className="initiative-steering-meta__label">Archetyp</span>
<span className="initiative-steering-meta__value">
<strong>{archetypeLabel(archetypeKey, initiativeArchetypes)}</strong>
{!compact && archetype?.description && (
<span className="initiative-steering-meta__hint muted">
{archetype.description}
</span>
)}
</span>
</div>
)}
{profileKey && (
<div className="initiative-steering-meta__row">
<span className="initiative-steering-meta__label">Ausprägung</span>
<span className="initiative-steering-meta__value">
<strong>{profileLabel}</strong>
{!compact && profileDescription && (
<span className="initiative-steering-meta__hint muted">
{profileDescription}
</span>
)}
</span>
</div>
)}
{methodKey && (
<div className="initiative-steering-meta__row">
<span className="initiative-steering-meta__label">Steuerungsmethode</span>
<span className="initiative-steering-meta__value">
<strong>{methodLabel}</strong>
{!compact && methodDescription && (
<span className="initiative-steering-meta__hint muted">
{methodDescription}
</span>
)}
{!compact && !steeringSnapshot && archetype?.default_method_key && !profileKey && (
<span className="initiative-steering-meta__hint muted">
Wird beim Anlegen automatisch vom Archetyp abgeleitet.
</span>
)}
</span>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
import { listMethodProfiles } from '../api/methodProfiles.js'
export function MethodProfileSelect({
archetypeKey,
value = '',
onChange,
disabled = false,
}) {
const [profiles, setProfiles] = useState([])
useEffect(() => {
if (!archetypeKey) {
setProfiles([])
return undefined
}
let cancelled = false
listMethodProfiles(archetypeKey)
.then((items) => {
if (!cancelled) setProfiles(Array.isArray(items) ? items : [])
})
.catch(() => {
if (!cancelled) setProfiles([])
})
return () => {
cancelled = true
}
}, [archetypeKey])
if (profiles.length === 0) {
return (
<p className="muted method-profile-select__empty">
Keine Referenz-Ausprägung für diesen Archetyp hinterlegt.
</p>
)
}
return (
<label className="method-profile-select">
Ausprägung
<select
value={value || ''}
disabled={disabled}
onChange={(e) => onChange?.(e.target.value)}
aria-label="Methoden-Ausprägung"
>
<option value=""> Standard (nur Archetyp) </option>
{profiles.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</select>
</label>
)
}

View File

@ -1,8 +1,10 @@
import { NavLink, useLocation } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { listInitiativeActions } from '../api/initiatives.js'
import { getInitiative, listInitiativeActions } from '../api/initiatives.js'
import { listInitiativeBacklog } from '../api/backlog.js'
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
import { useEntityArchetypes } from '../hooks/useEntityArchetypes.js'
import { InitiativeSteeringMeta } from './InitiativeSteeringMeta.jsx'
import {
PLAN_OUTLINE_NODES,
resolvePlanOutlineActiveKey,
@ -11,8 +13,28 @@ import {
export function PlanOutlineNav() {
const location = useLocation()
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
const { initiativeArchetypes } = useEntityArchetypes()
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
const [counts, setCounts] = useState({ inbox: null, work: null })
const [initiativeArchetypeKey, setInitiativeArchetypeKey] = useState('')
useEffect(() => {
if (!initiativeId) {
setInitiativeArchetypeKey('')
return undefined
}
let cancelled = false
getInitiative(initiativeId)
.then((data) => {
if (!cancelled) setInitiativeArchetypeKey(data.archetype_key || '')
})
.catch(() => {
if (!cancelled) setInitiativeArchetypeKey('')
})
return () => {
cancelled = true
}
}, [initiativeId])
useEffect(() => {
if (!initiativeId) {
@ -62,6 +84,14 @@ export function PlanOutlineNav() {
)}
</div>
{initiativeId && initiativeArchetypeKey && (
<InitiativeSteeringMeta
archetypeKey={initiativeArchetypeKey}
initiativeArchetypes={initiativeArchetypes}
compact
/>
)}
<ul className="plan-outline-nav__list">
{PLAN_OUTLINE_NODES.map((node) => {
const needsInitiative = node.requiresInitiative && !initiativeId

View File

@ -1,6 +1,7 @@
import { INITIATIVE_STATUSES, INITIATIVE_STATUS_LABELS } from '../constants/status.js'
import { GateSelect } from './GateSelect.jsx'
import { flattenProjectOptions } from '../utils/projectTree.js'
import { projectArchetypeLabel } from '../utils/archetypes.js'
const CONTAINER_KINDS = [
{ value: '', label: '— Standard —' },
@ -19,6 +20,9 @@ export function ProjectForm({
onCancel,
busy = false,
submitLabel = 'Speichern',
initiativeArchetypeKey = '',
initiativeArchetypes = [],
projectArchetypes = [],
}) {
const parentOptions = flattenProjectOptions(
projects.filter((p) => p.id !== excludeProjectId),
@ -51,6 +55,19 @@ export function ProjectForm({
return (
<form className="form workspace-form project-form" onSubmit={handleSubmit}>
{initiativeArchetypeKey && (
<p className="form-hint muted project-form-archetype-hint">
Archetyp wird vom Vorhaben übernommen:{' '}
<strong>
{projectArchetypeLabel(
`project.${initiativeArchetypeKey.replace(/^initiative\./, '')}`,
projectArchetypes,
initiativeArchetypes,
)}
</strong>
. Art unten ist nur die Struktur-Rolle (Projekt, Stream, Phase ).
</p>
)}
<label>
Titel
<input

View File

@ -170,6 +170,9 @@ export function ProjectsSection({
onDelete,
onReorder,
busy,
initiativeArchetypeKey = '',
initiativeArchetypes = [],
projectArchetypes = [],
}) {
const [modalMode, setModalMode] = useState(null)
const [dragProjectId, setDragProjectId] = useState('')
@ -315,6 +318,9 @@ export function ProjectsSection({
onCancel={closeModal}
busy={busy}
submitLabel="Anlegen"
initiativeArchetypeKey={initiativeArchetypeKey}
initiativeArchetypes={initiativeArchetypes}
projectArchetypes={projectArchetypes}
/>
)}
{modalMode?.kind === 'edit' && modalMode.project && (
@ -326,6 +332,9 @@ export function ProjectsSection({
onSubmit={handleEditSubmit}
onCancel={closeModal}
busy={busy}
initiativeArchetypeKey={initiativeArchetypeKey}
initiativeArchetypes={initiativeArchetypes}
projectArchetypes={projectArchetypes}
/>
)}
</Modal>

View File

@ -4,6 +4,9 @@ import {
NEXT_ACTION_KIND_LABELS,
} from '../constants/operating.js'
import { MILESTONE_STATUS_LABELS } from '../constants/status.js'
import { methodDescriptionForKey } from '../utils/archetypes.js'
import { Link } from 'react-router-dom'
import { scopedPath } from '../utils/routes.js'
function formatDate(iso) {
if (!iso) return '—'
@ -52,6 +55,7 @@ export function SteeringSnapshotPanel({
archetype_key,
archetype_label,
method_profile_key,
method_profile_label,
steering_guidance,
attention_items = [],
signals = [],
@ -71,6 +75,27 @@ export function SteeringSnapshotPanel({
{ label: 'Blocker', value: counts.blockers_open, warn: counts.blockers_open > 0 },
]
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 (
<section className="card steering-snapshot steering-snapshot--hero">
<header className="steering-hero-header">
@ -92,7 +117,13 @@ export function SteeringSnapshotPanel({
{(archetype_label || archetype_key) && (
<p className="steering-archetype-row muted">
Archetyp: <strong>{archetype_label || archetype_key}</strong>
{method_profile_key ? ` · Ausprägung: ${method_profile_key}` : ''}
{method_profile_label || method_profile_key ? (
<>
{' '}
· Ausprägung:{' '}
<strong>{method_profile_label || method_profile_key}</strong>
</>
) : null}
</p>
)}
{(method_label || canManageMethod) && (
@ -115,6 +146,9 @@ export function SteeringSnapshotPanel({
) : (
<strong>{method_label || method_key}</strong>
)}
{methodDescription && (
<span className="steering-method-description muted">{methodDescription}</span>
)}
</p>
)}
@ -133,7 +167,7 @@ export function SteeringSnapshotPanel({
{(attention_items?.length > 0 || signals?.length > 0) && (
<p className="muted snapshot-signals snapshot-attention">
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({
<div className="steering-next-actions">
<h3>Nächste Schritte</h3>
<ol className="item-list compact-list">
{next_actions.map((item, i) => (
{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.reason_code ? ` · ${item.reason_code}` : ''}
{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>
)}

View File

@ -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
}

View File

@ -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,

View File

@ -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 }
}

View File

@ -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() {
<header className="page-header">
<div>
<h1>Vorhaben</h1>
<p className="page-lead">Aktive und pausierte Vorhaben im Tenant.</p>
<p className="page-lead">
Alle aktiven Vorhaben Archetyp und Steuerung im Profil und unter Kontrolle einsehen.
</p>
</div>
{hasCapability('kairo.initiative.manage') && (
<button
@ -115,6 +122,10 @@ export function InitiativesPage() {
{item.goal && <p className="list-item-desc">{item.goal}</p>}
</div>
<div className="list-item-meta">
<ArchetypeBadge
archetypeKey={item.archetype_key}
archetypes={initiativeArchetypes}
/>
<StatusBadge kind="initiative" status={item.status} />
<PriorityBadge priority={item.priority} />
{openCounts[item.id] != null && (

View File

@ -6,6 +6,7 @@ import { useSession } from '../context/SessionContext.jsx'
import { InitiativeForm } from '../components/InitiativeForm.jsx'
import { createInitiative } from '../api/initiatives.js'
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
import { scopedPath } from '../utils/routes.js'
import { Link } from 'react-router-dom'
export function WorkspacePage() {
@ -28,7 +29,7 @@ export function WorkspacePage() {
})
await saveInitiativeDynamicFields(created.id, dynamicFields)
setShowForm(false)
navigate('/initiatives')
navigate(scopedPath('/plan/profile', { initiativeId: created.id }))
} catch (err) {
setFormError(err.message)
} finally {

View File

@ -6,6 +6,8 @@ import { EmptyState } from '../../components/EmptyState.jsx'
import { ErrorState } from '../../components/ErrorState.jsx'
import { gateTitleById } from '../../components/GateSelect.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { useEntityArchetypes } from '../../hooks/useEntityArchetypes.js'
import { projectArchetypeLabel } from '../../utils/archetypes.js'
import { gatePath, projectPath, actionPath, scopedPath } from '../../utils/routes.js'
import {
buildProjectPath,
@ -35,6 +37,8 @@ function ProjectDetailView({
onDelete,
onFilter,
busy,
projectArchetypes,
initiativeArchetypes,
}) {
const path = buildProjectPath(projects, project.id)
const gateTitle = gateTitleById(roadmapItems, project.roadmap_item_id)
@ -52,6 +56,20 @@ function ProjectDetailView({
<dt>Art</dt>
<dd>{containerKindLabel(project.container_kind) || 'Standard'}</dd>
</div>
<div>
<dt>Archetyp</dt>
<dd>
{projectArchetypeLabel(
project.archetype_key,
projectArchetypes,
initiativeArchetypes,
)}
<span className="muted project-detail-archetype-hint">
{' '}
(vom Vorhaben gespiegelt)
</span>
</dd>
</div>
<div>
<dt>Ebene</dt>
<dd>
@ -178,6 +196,7 @@ export function ProjectDetailPage() {
const { projectId } = useParams()
const navigate = useNavigate()
const ops = useInitiativeOperations()
const { initiativeArchetypes, projectArchetypes } = useEntityArchetypes()
const isNew = projectId === 'new'
const [editing, setEditing] = useState(isNew)
const [localError, setLocalError] = useState(null)
@ -304,6 +323,9 @@ export function ProjectDetailPage() {
}
busy={ops.formBusy}
submitLabel={isNew ? 'Anlegen' : 'Speichern'}
initiativeArchetypeKey={ops.initiative?.archetype_key}
initiativeArchetypes={initiativeArchetypes}
projectArchetypes={projectArchetypes}
/>
) : (
project && (
@ -319,6 +341,8 @@ export function ProjectDetailPage() {
onDelete={handleDelete}
onFilter={handleFilter}
busy={ops.formBusy}
projectArchetypes={projectArchetypes}
initiativeArchetypes={initiativeArchetypes}
/>
)
)}

View File

@ -8,7 +8,7 @@ import { InitiativeForm } from '../../components/InitiativeForm.jsx'
import { createInitiative } from '../../api/initiatives.js'
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../../api/entityFields.js'
import { ModeShell } from '../../components/ModeShell.jsx'
import { initiativeCatalogPath } from '../../utils/routes.js'
import { initiativeCatalogPath, scopedPath } from '../../utils/routes.js'
export function CockpitPage() {
const navigate = useNavigate()
@ -30,7 +30,7 @@ export function CockpitPage() {
})
await saveInitiativeDynamicFields(created.id, dynamicFields)
setShowForm(false)
navigate(initiativeCatalogPath())
navigate(scopedPath('/plan/profile', { initiativeId: created.id }))
} catch (err) {
setFormError(err.message)
} finally {

View File

@ -1,13 +1,18 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { StatusBadge } from '../../components/StatusBadge.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { Modal } from '../../components/Modal.jsx'
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
import { InitiativeSteeringMeta } from '../../components/InitiativeSteeringMeta.jsx'
import { MethodProfileSelect } from '../../components/MethodProfileSelect.jsx'
import { PriorityBadge } from '../../components/PriorityBadge.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
import { initiativeArchetypeLabel } from '../../constants/initiativeArchetypes.js'
import { useEntityArchetypes } from '../../hooks/useEntityArchetypes.js'
import { scopedPath } from '../../utils/routes.js'
import {
getInitiativeFieldDefinitions,
getInitiativeFields,
@ -74,9 +79,21 @@ function formatFieldValue(definition, value) {
function PlanProfileInner() {
const ops = useInitiativeOperations()
const { refreshInitiativeMeta } = useProgramScope()
const { initiative, loading, error, capabilities, formBusy } = ops
const { initiativeArchetypes } = useEntityArchetypes()
const {
initiative,
loading,
error,
capabilities,
formBusy,
steeringSnapshot,
steeringMethods,
methodBusy,
handleMethodProfileChange,
actors,
projects,
} = ops
const [editing, setEditing] = useState(false)
const canManage = capabilities.has('kairo.initiative.manage')
if (loading) {
@ -87,6 +104,11 @@ function PlanProfileInner() {
return <p className="muted">Vorhaben nicht gefunden.</p>
}
const ownerName =
actors.find((actor) => actor.id === initiative.owner_actor_id)?.name ||
initiative.owner_actor_id ||
'—'
async function handleSave(payload) {
const ok = await ops.handleUpdateInitiative(payload)
if (ok) {
@ -115,9 +137,40 @@ function PlanProfileInner() {
</div>
</header>
{error && <p className="error">{error}</p>}
<InitiativeSteeringMeta
archetypeKey={initiative.archetype_key}
initiativeArchetypes={initiativeArchetypes}
steeringSnapshot={steeringSnapshot}
steeringMethods={steeringMethods}
/>
{canManage && (
<div className="plan-profile__steering-edit">
<MethodProfileSelect
archetypeKey={initiative.archetype_key}
value={steeringSnapshot?.method_profile_key || ''}
disabled={methodBusy}
onChange={handleMethodProfileChange}
/>
</div>
)}
<div className="plan-profile__field">
<h3 className="plan-profile__label">Archetyp</h3>
<p>{initiativeArchetypeLabel(initiative.archetype_key)}</p>
<h3 className="plan-profile__label">Verantwortlich (Owner)</h3>
<p>{ownerName}</p>
</div>
<div className="plan-profile__field">
<h3 className="plan-profile__label">Struktur</h3>
<p>
{projects.length} Projekt{projects.length === 1 ? '' : 'e'} ·{' '}
<Link to={scopedPath('/plan/structure', { initiativeId: initiative.id })}>
Struktur pflegen
</Link>
</p>
</div>
<div className="plan-profile__field">
<h3 className="plan-profile__label">Priorität</h3>
<p>
<PriorityBadge priority={initiative.priority} />
</p>
</div>
{initiative.goal ? (
<div className="plan-profile__field">

View File

@ -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}
/>
</>
)

View File

@ -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;
}

View File

@ -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 || ''
}

View File

@ -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() {
<p className="initiative-portfolio-card-goal muted">{item.goal}</p>
)}
<div className="initiative-portfolio-card-badges">
<ArchetypeBadge
archetypeKey={item.archetype_key}
archetypes={initiativeArchetypes}
/>
<StatusBadge kind="initiative" status={item.status} />
<PriorityBadge priority={item.priority} />
</div>

View File

@ -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 (
<ol className="item-list next-action-list">
{items.map((item, index) => (
@ -34,11 +53,17 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
)}
</div>
<div className="list-item-meta">
{item.action_id && (
<Link to={actionPath(item.action_id)} className="btn btn-primary btn-sm">
Arbeitspaket
</Link>
)}
{(() => {
const link = actionLinkForItem(item)
if (link) {
return (
<Link to={link.to} className="btn btn-primary btn-sm">
{link.label}
</Link>
)
}
return null
})()}
{showInitiativeLink && item.initiative_id && !initiativeId && (
<Link
to={scopedPath('/control/status', { initiativeId: item.initiative_id })}