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
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:
parent
95b743d6fd
commit
efb45a3ee6
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
73
backend/tests/test_ap20_method_profiles.py
Normal file
73
backend/tests/test_ap20_method_profiles.py
Normal 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"
|
||||
10
frontend/src/api/methodProfiles.js
Normal file
10
frontend/src/api/methodProfiles.js
Normal 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}` : ''}`)
|
||||
}
|
||||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
11
frontend/src/components/ArchetypeBadge.jsx
Normal file
11
frontend/src/components/ArchetypeBadge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 || ''} />
|
||||
|
|
|
|||
98
frontend/src/components/InitiativeSteeringMeta.jsx
Normal file
98
frontend/src/components/InitiativeSteeringMeta.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
frontend/src/components/MethodProfileSelect.jsx
Normal file
56
frontend/src/components/MethodProfileSelect.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
39
frontend/src/hooks/useEntityArchetypes.js
Normal file
39
frontend/src/hooks/useEntityArchetypes.js
Normal 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 }
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
49
frontend/src/utils/archetypes.js
Normal file
49
frontend/src/utils/archetypes.js
Normal 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 || ''
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user