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
|
|
@ -12,6 +12,7 @@ from psycopg2.extras import RealDictCursor
|
||||||
from db import get_connection
|
from db import get_connection
|
||||||
from data_layer.attention import get_next_action_candidates_for_initiative
|
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.context import get_steering_context_dto
|
||||||
from steering.signals.snapshot_signals import derive_initiative_signals
|
from steering.signals.snapshot_signals import derive_initiative_signals
|
||||||
from services.initiatives import get_initiative
|
from services.initiatives import get_initiative
|
||||||
|
|
@ -301,6 +302,11 @@ def get_initiative_steering_snapshot(
|
||||||
if isinstance(metadata, str):
|
if isinstance(metadata, str):
|
||||||
metadata = {}
|
metadata = {}
|
||||||
method_profile_key = metadata.get("method_profile_key")
|
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"
|
initiative_archetype = initiative.get("archetype_key") or "initiative.generic"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -312,6 +318,7 @@ def get_initiative_steering_snapshot(
|
||||||
entity_type="initiative", archetype_key=initiative_archetype
|
entity_type="initiative", archetype_key=initiative_archetype
|
||||||
),
|
),
|
||||||
"method_profile_key": method_profile_key,
|
"method_profile_key": method_profile_key,
|
||||||
|
"method_profile_label": method_profile_label,
|
||||||
"lifecycle_state": steering["lifecycle_state"],
|
"lifecycle_state": steering["lifecycle_state"],
|
||||||
"lifecycle_label": steering["lifecycle_label"],
|
"lifecycle_label": steering["lifecycle_label"],
|
||||||
"method_key": steering["method_key"],
|
"method_key": steering["method_key"],
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class InitiativeCreateRequest(BaseModel):
|
||||||
status: Literal["active", "paused", "completed", "archived"] = "active"
|
status: Literal["active", "paused", "completed", "archived"] = "active"
|
||||||
priority: Literal["low", "normal", "high"] = "normal"
|
priority: Literal["low", "normal", "high"] = "normal"
|
||||||
owner_actor_id: Optional[str] = None
|
owner_actor_id: Optional[str] = None
|
||||||
|
method_profile_key: Optional[str] = Field(default=None, min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class InitiativeUpdateRequest(BaseModel):
|
class InitiativeUpdateRequest(BaseModel):
|
||||||
|
|
@ -160,6 +161,7 @@ def create_initiative(
|
||||||
priority=body.priority,
|
priority=body.priority,
|
||||||
owner_actor_id=owner_actor_id,
|
owner_actor_id=owner_actor_id,
|
||||||
user_id=ctx.user_id,
|
user_id=ctx.user_id,
|
||||||
|
method_profile_key=body.method_profile_key,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from 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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from capabilities import require_capability
|
from capabilities import require_capability
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from method_profiles.registry import list_method_profiles
|
||||||
from services import initiatives as initiative_service
|
from services import initiatives as initiative_service
|
||||||
from services import steering_context as sc_service
|
from services import steering_context as sc_service
|
||||||
from steering.context import get_steering_context_dto
|
from steering.context import get_steering_context_dto
|
||||||
|
|
@ -17,13 +18,16 @@ router = APIRouter(prefix="/api/steering", tags=["steering"])
|
||||||
|
|
||||||
|
|
||||||
class SteeringContextUpdateRequest(BaseModel):
|
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")
|
@router.get("/methods")
|
||||||
def list_steering_methods(
|
def list_steering_methods(
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||||
):
|
):
|
||||||
|
_ = ctx
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"key": m.key,
|
"key": m.key,
|
||||||
|
|
@ -36,19 +40,58 @@ 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")
|
@router.patch("/initiatives/{initiative_id}/context")
|
||||||
def patch_initiative_steering_context(
|
def patch_initiative_steering_context(
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
body: SteeringContextUpdateRequest,
|
body: SteeringContextUpdateRequest,
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
|
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
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
):
|
)
|
||||||
|
if not initiative:
|
||||||
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||||
if not body.method_key:
|
|
||||||
raise HTTPException(status_code=400, detail="method_key erforderlich")
|
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:
|
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(
|
sc_service.update_method_key(
|
||||||
tenant_id=ctx.tenant_id,
|
tenant_id=ctx.tenant_id,
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
|
|
@ -57,4 +100,5 @@ def patch_initiative_steering_context(
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
return get_steering_context_dto(ctx, initiative_id=initiative_id)
|
return get_steering_context_dto(ctx, initiative_id=initiative_id)
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ def create_initiative(
|
||||||
status: InitiativeStatus = "active",
|
status: InitiativeStatus = "active",
|
||||||
priority: Priority = "normal",
|
priority: Priority = "normal",
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
|
method_profile_key: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
title = title.strip()
|
title = title.strip()
|
||||||
if not title:
|
if not title:
|
||||||
|
|
@ -125,6 +126,7 @@ def create_initiative(
|
||||||
initiative_id=row["id"],
|
initiative_id=row["id"],
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
archetype_key=archetype_key,
|
archetype_key=archetype_key,
|
||||||
|
method_profile_key=method_profile_key,
|
||||||
)
|
)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,89 @@ def update_method_key(
|
||||||
return result
|
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(
|
def create_context_for_new_initiative(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|
@ -275,6 +358,13 @@ def create_context_for_new_initiative(
|
||||||
method_key = resolve_default_method_key(archetype_key)
|
method_key = resolve_default_method_key(archetype_key)
|
||||||
lifecycle_metadata: dict[str, Any] = {}
|
lifecycle_metadata: dict[str, Any] = {}
|
||||||
if method_profile_key:
|
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)
|
profile_method = resolve_method_for_profile(method_profile_key)
|
||||||
if profile_method:
|
if profile_method:
|
||||||
method_key = profile_method
|
method_key = profile_method
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,23 @@ def get_steering_context_dto(
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
)
|
)
|
||||||
method = get_method(row["method_key"])
|
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 {
|
return {
|
||||||
**row,
|
**row,
|
||||||
"lifecycle_label": lifecycle_label(row["lifecycle_state"]),
|
"lifecycle_label": lifecycle_label(row["lifecycle_state"]),
|
||||||
"method_label": method.label if method else row["method_key"],
|
"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,
|
'blocked_action' AS kind,
|
||||||
'critical' AS severity,
|
'critical' AS severity,
|
||||||
a.title AS title,
|
a.title AS title,
|
||||||
'Maßnahme ist blockiert' AS summary,
|
'Maßnahme ist blockiert' AS summary,
|
||||||
'action' AS scope_type,
|
'action' AS scope_type,
|
||||||
a.id AS scope_id,
|
a.id AS scope_id,
|
||||||
a.initiative_id,
|
a.initiative_id,
|
||||||
|
|
@ -136,7 +136,7 @@ def _high_priority_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
'high_priority_action' AS kind,
|
'high_priority_action' AS kind,
|
||||||
'warning' AS severity,
|
'warning' AS severity,
|
||||||
a.title AS title,
|
a.title AS title,
|
||||||
'High-Priority Maßnahme offen' AS summary,
|
'High-Priority Maßnahme offen' AS summary,
|
||||||
'action' AS scope_type,
|
'action' AS scope_type,
|
||||||
a.id AS scope_id,
|
a.id AS scope_id,
|
||||||
a.initiative_id,
|
a.initiative_id,
|
||||||
|
|
@ -165,7 +165,7 @@ def _unassigned_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
'unassigned_action' AS kind,
|
'unassigned_action' AS kind,
|
||||||
'warning' AS severity,
|
'warning' AS severity,
|
||||||
a.title AS title,
|
a.title AS title,
|
||||||
'Maßnahme ohne Zuweisung' AS summary,
|
'Maßnahme ohne Zuweisung' AS summary,
|
||||||
'action' AS scope_type,
|
'action' AS scope_type,
|
||||||
a.id AS scope_id,
|
a.id AS scope_id,
|
||||||
a.initiative_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,
|
'initiative_without_next_action' AS kind,
|
||||||
'info' AS severity,
|
'info' AS severity,
|
||||||
i.title AS title,
|
i.title AS title,
|
||||||
'Keine offene nächste Maßnahme' AS summary,
|
'Keine offene nächste Maßnahme' AS summary,
|
||||||
'initiative' AS scope_type,
|
'initiative' AS scope_type,
|
||||||
i.id AS scope_id,
|
i.id AS scope_id,
|
||||||
i.id AS initiative_id,
|
i.id AS initiative_id,
|
||||||
|
|
@ -229,7 +229,7 @@ def _stale_initiatives(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
'stale_initiative' AS kind,
|
'stale_initiative' AS kind,
|
||||||
'info' AS severity,
|
'info' AS severity,
|
||||||
i.title AS title,
|
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,
|
'initiative' AS scope_type,
|
||||||
i.id AS scope_id,
|
i.id AS scope_id,
|
||||||
i.id AS initiative_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,
|
'action_review_required' AS kind,
|
||||||
'warning' AS severity,
|
'warning' AS severity,
|
||||||
a.title AS title,
|
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,
|
'action' AS scope_type,
|
||||||
a.id AS scope_id,
|
a.id AS scope_id,
|
||||||
a.initiative_id,
|
a.initiative_id,
|
||||||
|
|
@ -319,7 +319,7 @@ def _overdue_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
'overdue_action' AS kind,
|
'overdue_action' AS kind,
|
||||||
'warning' AS severity,
|
'warning' AS severity,
|
||||||
a.title AS title,
|
a.title AS title,
|
||||||
'Maßnahme überfällig' AS summary,
|
'Maßnahme überfällig' AS summary,
|
||||||
'action' AS scope_type,
|
'action' AS scope_type,
|
||||||
a.id AS scope_id,
|
a.id AS scope_id,
|
||||||
a.initiative_id,
|
a.initiative_id,
|
||||||
|
|
@ -350,7 +350,7 @@ def _reviews_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
'review_due' AS kind,
|
'review_due' AS kind,
|
||||||
'warning' AS severity,
|
'warning' AS severity,
|
||||||
r.title AS title,
|
r.title AS title,
|
||||||
'Review fällig' AS summary,
|
'Review fällig' AS summary,
|
||||||
'review' AS scope_type,
|
'review' AS scope_type,
|
||||||
r.id AS scope_id,
|
r.id AS scope_id,
|
||||||
r.initiative_id,
|
r.initiative_id,
|
||||||
|
|
@ -388,7 +388,7 @@ def _recurring_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
'recurring_due' AS kind,
|
'recurring_due' AS kind,
|
||||||
'info' AS severity,
|
'info' AS severity,
|
||||||
re.title AS title,
|
re.title AS title,
|
||||||
'Wiederkehrendes Element fällig' AS summary,
|
'Wiederkehrendes Element fällig' AS summary,
|
||||||
'recurring_element' AS scope_type,
|
'recurring_element' AS scope_type,
|
||||||
re.id AS scope_id,
|
re.id AS scope_id,
|
||||||
re.initiative_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]]:
|
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()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
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(
|
def get_next_action_candidates(
|
||||||
ctx: TenantContext, *, limit: int = 10
|
ctx: TenantContext, *, limit: int = 10
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Regelbasierte NextActionCandidates ÔÇö limitiert, tenant-scoped."""
|
"""Regelbasierte NextActionCandidates — limitiert, tenant-scoped."""
|
||||||
if limit < 1:
|
if limit < 1:
|
||||||
limit = 1
|
limit = 1
|
||||||
if limit > 50:
|
if limit > 50:
|
||||||
|
|
@ -454,7 +454,7 @@ def get_next_action_candidates(
|
||||||
SELECT
|
SELECT
|
||||||
'resolve_blocker' AS kind,
|
'resolve_blocker' AS kind,
|
||||||
b.title AS title,
|
b.title AS title,
|
||||||
'Blocker klären oder Status aktualisieren' AS summary,
|
'Blocker klären oder Status aktualisieren' AS summary,
|
||||||
b.initiative_id,
|
b.initiative_id,
|
||||||
b.action_id,
|
b.action_id,
|
||||||
NULL::uuid AS backlog_item_id,
|
NULL::uuid AS backlog_item_id,
|
||||||
|
|
@ -486,7 +486,7 @@ def get_next_action_candidates(
|
||||||
a.id AS action_id,
|
a.id AS action_id,
|
||||||
NULL::uuid AS backlog_item_id,
|
NULL::uuid AS backlog_item_id,
|
||||||
'action_unassigned' AS reason_code,
|
'action_unassigned' AS reason_code,
|
||||||
'Maßnahme zuweisen' AS recommended_action
|
'Maßnahme zuweisen' AS recommended_action
|
||||||
FROM actions a
|
FROM actions a
|
||||||
WHERE a.tenant_id = %s
|
WHERE a.tenant_id = %s
|
||||||
AND a.status IN ('open', 'ready', 'in_progress')
|
AND a.status IN ('open', 'ready', 'in_progress')
|
||||||
|
|
@ -512,12 +512,12 @@ def get_next_action_candidates(
|
||||||
SELECT
|
SELECT
|
||||||
'convert_backlog' AS kind,
|
'convert_backlog' AS kind,
|
||||||
bi.title AS title,
|
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,
|
bi.initiative_id,
|
||||||
NULL::uuid AS action_id,
|
NULL::uuid AS action_id,
|
||||||
bi.id AS backlog_item_id,
|
bi.id AS backlog_item_id,
|
||||||
'backlog_accepted_not_converted' AS reason_code,
|
'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
|
FROM backlog_items bi
|
||||||
WHERE bi.tenant_id = %s
|
WHERE bi.tenant_id = %s
|
||||||
AND bi.status = 'accepted'
|
AND bi.status = 'accepted'
|
||||||
|
|
@ -540,12 +540,12 @@ def get_next_action_candidates(
|
||||||
SELECT
|
SELECT
|
||||||
'create_action' AS kind,
|
'create_action' AS kind,
|
||||||
i.title AS title,
|
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,
|
i.id AS initiative_id,
|
||||||
NULL::uuid AS action_id,
|
NULL::uuid AS action_id,
|
||||||
NULL::uuid AS backlog_item_id,
|
NULL::uuid AS backlog_item_id,
|
||||||
'initiative_no_open_action' AS reason_code,
|
'initiative_no_open_action' AS reason_code,
|
||||||
'Maßnahme anlegen' AS recommended_action
|
'Maßnahme anlegen' AS recommended_action
|
||||||
FROM initiatives i
|
FROM initiatives i
|
||||||
WHERE i.tenant_id = %s
|
WHERE i.tenant_id = %s
|
||||||
AND i.status IN ('active', 'paused')
|
AND i.status IN ('active', 'paused')
|
||||||
|
|
@ -573,7 +573,7 @@ def get_next_action_candidates(
|
||||||
def get_next_action_candidates_for_initiative(
|
def get_next_action_candidates_for_initiative(
|
||||||
ctx: TenantContext, *, initiative_id: str, limit: int = 5
|
ctx: TenantContext, *, initiative_id: str, limit: int = 5
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""NextActionCandidates f├╝r ein Vorhaben ÔÇö tenant-scoped."""
|
"""NextActionCandidates für ein Vorhaben — tenant-scoped."""
|
||||||
if limit < 1:
|
if limit < 1:
|
||||||
limit = 1
|
limit = 1
|
||||||
if limit > 20:
|
if limit > 20:
|
||||||
|
|
@ -588,7 +588,7 @@ def get_next_action_candidates_for_initiative(
|
||||||
SELECT
|
SELECT
|
||||||
'resolve_blocker' AS kind,
|
'resolve_blocker' AS kind,
|
||||||
b.title AS title,
|
b.title AS title,
|
||||||
'Blocker klären oder Status aktualisieren' AS summary,
|
'Blocker klären oder Status aktualisieren' AS summary,
|
||||||
b.initiative_id,
|
b.initiative_id,
|
||||||
b.action_id,
|
b.action_id,
|
||||||
NULL::uuid AS backlog_item_id,
|
NULL::uuid AS backlog_item_id,
|
||||||
|
|
@ -621,7 +621,7 @@ def get_next_action_candidates_for_initiative(
|
||||||
a.id AS action_id,
|
a.id AS action_id,
|
||||||
NULL::uuid AS backlog_item_id,
|
NULL::uuid AS backlog_item_id,
|
||||||
'action_unassigned' AS reason_code,
|
'action_unassigned' AS reason_code,
|
||||||
'Maßnahme zuweisen' AS recommended_action
|
'Maßnahme zuweisen' AS recommended_action
|
||||||
FROM actions a
|
FROM actions a
|
||||||
WHERE a.tenant_id = %s AND a.initiative_id = %s
|
WHERE a.tenant_id = %s AND a.initiative_id = %s
|
||||||
AND a.status IN ('open', 'ready', 'in_progress')
|
AND a.status IN ('open', 'ready', 'in_progress')
|
||||||
|
|
@ -647,12 +647,12 @@ def get_next_action_candidates_for_initiative(
|
||||||
SELECT
|
SELECT
|
||||||
'convert_backlog' AS kind,
|
'convert_backlog' AS kind,
|
||||||
bi.title AS title,
|
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,
|
bi.initiative_id,
|
||||||
NULL::uuid AS action_id,
|
NULL::uuid AS action_id,
|
||||||
bi.id AS backlog_item_id,
|
bi.id AS backlog_item_id,
|
||||||
'backlog_accepted_not_converted' AS reason_code,
|
'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
|
FROM backlog_items bi
|
||||||
WHERE bi.tenant_id = %s AND bi.initiative_id = %s
|
WHERE bi.tenant_id = %s AND bi.initiative_id = %s
|
||||||
AND bi.status = 'accepted'
|
AND bi.status = 'accepted'
|
||||||
|
|
@ -693,12 +693,12 @@ def get_next_action_candidates_for_initiative(
|
||||||
{
|
{
|
||||||
"kind": "create_action",
|
"kind": "create_action",
|
||||||
"title": init_row["title"],
|
"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,
|
"initiative_id": initiative_id,
|
||||||
"action_id": None,
|
"action_id": None,
|
||||||
"backlog_item_id": None,
|
"backlog_item_id": None,
|
||||||
"reason_code": "initiative_no_open_action",
|
"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 }),
|
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,
|
getInitiativeFields,
|
||||||
listArchetypeFieldDefinitions,
|
listArchetypeFieldDefinitions,
|
||||||
} from '../api/entityFields.js'
|
} from '../api/entityFields.js'
|
||||||
|
import { listSteeringMethods } from '../api/steering.js'
|
||||||
|
import { listMethodProfiles } from '../api/methodProfiles.js'
|
||||||
import { FieldRenderer } from './FieldRenderer.jsx'
|
import { FieldRenderer } from './FieldRenderer.jsx'
|
||||||
|
import { InitiativeSteeringMeta } from './InitiativeSteeringMeta.jsx'
|
||||||
|
|
||||||
export function InitiativeForm({
|
export function InitiativeForm({
|
||||||
initial = {},
|
initial = {},
|
||||||
|
|
@ -23,6 +26,42 @@ export function InitiativeForm({
|
||||||
const [fieldDefinitions, setFieldDefinitions] = useState([])
|
const [fieldDefinitions, setFieldDefinitions] = useState([])
|
||||||
const [dynamicValues, setDynamicValues] = useState({})
|
const [dynamicValues, setDynamicValues] = useState({})
|
||||||
const [fieldsLoading, setFieldsLoading] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -91,6 +130,7 @@ export function InitiativeForm({
|
||||||
status: form.status.value,
|
status: form.status.value,
|
||||||
priority: form.priority.value,
|
priority: form.priority.value,
|
||||||
dynamicFields: dynamicValues,
|
dynamicFields: dynamicValues,
|
||||||
|
...(methodProfileKey ? { method_profile_key: methodProfileKey } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,6 +161,30 @@ export function InitiativeForm({
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<label>
|
||||||
Kurzbeschreibung (Ziel)
|
Kurzbeschreibung (Ziel)
|
||||||
<textarea name="goal" rows={2} defaultValue={initial.goal || ''} />
|
<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 { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { useEffect, useState } from 'react'
|
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 { listInitiativeBacklog } from '../api/backlog.js'
|
||||||
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||||
|
import { useEntityArchetypes } from '../hooks/useEntityArchetypes.js'
|
||||||
|
import { InitiativeSteeringMeta } from './InitiativeSteeringMeta.jsx'
|
||||||
import {
|
import {
|
||||||
PLAN_OUTLINE_NODES,
|
PLAN_OUTLINE_NODES,
|
||||||
resolvePlanOutlineActiveKey,
|
resolvePlanOutlineActiveKey,
|
||||||
|
|
@ -11,8 +13,28 @@ import {
|
||||||
export function PlanOutlineNav() {
|
export function PlanOutlineNav() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
|
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
|
||||||
|
const { initiativeArchetypes } = useEntityArchetypes()
|
||||||
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
|
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
|
||||||
const [counts, setCounts] = useState({ inbox: null, work: null })
|
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(() => {
|
useEffect(() => {
|
||||||
if (!initiativeId) {
|
if (!initiativeId) {
|
||||||
|
|
@ -62,6 +84,14 @@ export function PlanOutlineNav() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{initiativeId && initiativeArchetypeKey && (
|
||||||
|
<InitiativeSteeringMeta
|
||||||
|
archetypeKey={initiativeArchetypeKey}
|
||||||
|
initiativeArchetypes={initiativeArchetypes}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ul className="plan-outline-nav__list">
|
<ul className="plan-outline-nav__list">
|
||||||
{PLAN_OUTLINE_NODES.map((node) => {
|
{PLAN_OUTLINE_NODES.map((node) => {
|
||||||
const needsInitiative = node.requiresInitiative && !initiativeId
|
const needsInitiative = node.requiresInitiative && !initiativeId
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { INITIATIVE_STATUSES, INITIATIVE_STATUS_LABELS } from '../constants/status.js'
|
import { INITIATIVE_STATUSES, INITIATIVE_STATUS_LABELS } from '../constants/status.js'
|
||||||
import { GateSelect } from './GateSelect.jsx'
|
import { GateSelect } from './GateSelect.jsx'
|
||||||
import { flattenProjectOptions } from '../utils/projectTree.js'
|
import { flattenProjectOptions } from '../utils/projectTree.js'
|
||||||
|
import { projectArchetypeLabel } from '../utils/archetypes.js'
|
||||||
|
|
||||||
const CONTAINER_KINDS = [
|
const CONTAINER_KINDS = [
|
||||||
{ value: '', label: '— Standard —' },
|
{ value: '', label: '— Standard —' },
|
||||||
|
|
@ -19,6 +20,9 @@ export function ProjectForm({
|
||||||
onCancel,
|
onCancel,
|
||||||
busy = false,
|
busy = false,
|
||||||
submitLabel = 'Speichern',
|
submitLabel = 'Speichern',
|
||||||
|
initiativeArchetypeKey = '',
|
||||||
|
initiativeArchetypes = [],
|
||||||
|
projectArchetypes = [],
|
||||||
}) {
|
}) {
|
||||||
const parentOptions = flattenProjectOptions(
|
const parentOptions = flattenProjectOptions(
|
||||||
projects.filter((p) => p.id !== excludeProjectId),
|
projects.filter((p) => p.id !== excludeProjectId),
|
||||||
|
|
@ -51,6 +55,19 @@ export function ProjectForm({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="form workspace-form project-form" onSubmit={handleSubmit}>
|
<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>
|
<label>
|
||||||
Titel
|
Titel
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,9 @@ export function ProjectsSection({
|
||||||
onDelete,
|
onDelete,
|
||||||
onReorder,
|
onReorder,
|
||||||
busy,
|
busy,
|
||||||
|
initiativeArchetypeKey = '',
|
||||||
|
initiativeArchetypes = [],
|
||||||
|
projectArchetypes = [],
|
||||||
}) {
|
}) {
|
||||||
const [modalMode, setModalMode] = useState(null)
|
const [modalMode, setModalMode] = useState(null)
|
||||||
const [dragProjectId, setDragProjectId] = useState('')
|
const [dragProjectId, setDragProjectId] = useState('')
|
||||||
|
|
@ -315,6 +318,9 @@ export function ProjectsSection({
|
||||||
onCancel={closeModal}
|
onCancel={closeModal}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
submitLabel="Anlegen"
|
submitLabel="Anlegen"
|
||||||
|
initiativeArchetypeKey={initiativeArchetypeKey}
|
||||||
|
initiativeArchetypes={initiativeArchetypes}
|
||||||
|
projectArchetypes={projectArchetypes}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{modalMode?.kind === 'edit' && modalMode.project && (
|
{modalMode?.kind === 'edit' && modalMode.project && (
|
||||||
|
|
@ -326,6 +332,9 @@ export function ProjectsSection({
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
onCancel={closeModal}
|
onCancel={closeModal}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
initiativeArchetypeKey={initiativeArchetypeKey}
|
||||||
|
initiativeArchetypes={initiativeArchetypes}
|
||||||
|
projectArchetypes={projectArchetypes}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import {
|
||||||
NEXT_ACTION_KIND_LABELS,
|
NEXT_ACTION_KIND_LABELS,
|
||||||
} from '../constants/operating.js'
|
} from '../constants/operating.js'
|
||||||
import { MILESTONE_STATUS_LABELS } from '../constants/status.js'
|
import { MILESTONE_STATUS_LABELS } from '../constants/status.js'
|
||||||
|
import { methodDescriptionForKey } from '../utils/archetypes.js'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
function formatDate(iso) {
|
function formatDate(iso) {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
|
|
@ -52,6 +55,7 @@ export function SteeringSnapshotPanel({
|
||||||
archetype_key,
|
archetype_key,
|
||||||
archetype_label,
|
archetype_label,
|
||||||
method_profile_key,
|
method_profile_key,
|
||||||
|
method_profile_label,
|
||||||
steering_guidance,
|
steering_guidance,
|
||||||
attention_items = [],
|
attention_items = [],
|
||||||
signals = [],
|
signals = [],
|
||||||
|
|
@ -71,6 +75,27 @@ export function SteeringSnapshotPanel({
|
||||||
{ label: 'Blocker', value: counts.blockers_open, warn: counts.blockers_open > 0 },
|
{ 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 (
|
return (
|
||||||
<section className="card steering-snapshot steering-snapshot--hero">
|
<section className="card steering-snapshot steering-snapshot--hero">
|
||||||
<header className="steering-hero-header">
|
<header className="steering-hero-header">
|
||||||
|
|
@ -92,7 +117,13 @@ export function SteeringSnapshotPanel({
|
||||||
{(archetype_label || archetype_key) && (
|
{(archetype_label || archetype_key) && (
|
||||||
<p className="steering-archetype-row muted">
|
<p className="steering-archetype-row muted">
|
||||||
Archetyp: <strong>{archetype_label || archetype_key}</strong>
|
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>
|
</p>
|
||||||
)}
|
)}
|
||||||
{(method_label || canManageMethod) && (
|
{(method_label || canManageMethod) && (
|
||||||
|
|
@ -115,6 +146,9 @@ export function SteeringSnapshotPanel({
|
||||||
) : (
|
) : (
|
||||||
<strong>{method_label || method_key}</strong>
|
<strong>{method_label || method_key}</strong>
|
||||||
)}
|
)}
|
||||||
|
{methodDescription && (
|
||||||
|
<span className="steering-method-description muted">{methodDescription}</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -133,7 +167,7 @@ export function SteeringSnapshotPanel({
|
||||||
|
|
||||||
{(attention_items?.length > 0 || signals?.length > 0) && (
|
{(attention_items?.length > 0 || signals?.length > 0) && (
|
||||||
<p className="muted snapshot-signals snapshot-attention">
|
<p className="muted snapshot-signals snapshot-attention">
|
||||||
Attention:{' '}
|
Aufmerksamkeit:{' '}
|
||||||
{(attention_items.length > 0
|
{(attention_items.length > 0
|
||||||
? attention_items.map((a) => a.label)
|
? attention_items.map((a) => a.label)
|
||||||
: signals.map((s) => SIGNAL_LABELS[s] || s)
|
: signals.map((s) => SIGNAL_LABELS[s] || s)
|
||||||
|
|
@ -146,19 +180,25 @@ export function SteeringSnapshotPanel({
|
||||||
<div className="steering-next-actions">
|
<div className="steering-next-actions">
|
||||||
<h3>Nächste Schritte</h3>
|
<h3>Nächste Schritte</h3>
|
||||||
<ol className="item-list compact-list">
|
<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">
|
<li key={`${item.kind}-${i}`} className="list-item card-list-item compact">
|
||||||
<div className="list-item-main">
|
<div className="list-item-main">
|
||||||
<span className="next-action-rank">{i + 1}</span>
|
<span className="next-action-rank">{i + 1}</span>
|
||||||
<strong>{item.title}</strong>
|
<strong>{item.title}</strong>
|
||||||
<span className="muted list-item-sub">
|
<span className="muted list-item-sub">
|
||||||
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
|
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
|
||||||
{item.reason_code ? ` · ${item.reason_code}` : ''}
|
|
||||||
{item.recommended_action ? ` — ${item.recommended_action}` : ''}
|
{item.recommended_action ? ` — ${item.recommended_action}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{link && (
|
||||||
|
<Link to={link.to} className="btn btn-primary btn-sm">
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
)})}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</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 = {
|
export const INITIATIVE_ARCHETYPE_LABELS = {
|
||||||
'initiative.generic': 'Allgemeines Vorhaben',
|
'initiative.generic': 'Allgemeines Vorhaben',
|
||||||
'initiative.program': 'Programm / Mega-Vorhaben',
|
'initiative.maturity_journey': 'Reifegrad-Entwicklung',
|
||||||
'initiative.product': 'Produkt / Release',
|
'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) {
|
export function initiativeArchetypeLabel(key, archetypes = []) {
|
||||||
return INITIATIVE_ARCHETYPE_LABELS[key] || key || 'Allgemeines Vorhaben'
|
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,
|
updateRecurring,
|
||||||
deleteRecurring,
|
deleteRecurring,
|
||||||
} from '../api/recurring.js'
|
} 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 { useCapabilities } from '../hooks/useCapabilities.js'
|
||||||
import { useActors } from '../hooks/useActors.js'
|
import { useActors } from '../hooks/useActors.js'
|
||||||
import { useSession } from './SessionContext.jsx'
|
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) {
|
async function handleCreateAction(payload) {
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -714,6 +730,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
reloadActors: actorsState.reload,
|
reloadActors: actorsState.reload,
|
||||||
reload: load,
|
reload: load,
|
||||||
handleMethodChange,
|
handleMethodChange,
|
||||||
|
handleMethodProfileChange,
|
||||||
handleCreateAction,
|
handleCreateAction,
|
||||||
handleUpdateAction,
|
handleUpdateAction,
|
||||||
handleQuickStatus,
|
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 { 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 { listInitiatives, listInitiativeActions, createInitiative } from '../api/initiatives.js'
|
||||||
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
|
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
|
||||||
import { countOpenActions } from '../api/actions.js'
|
import { countOpenActions } from '../api/actions.js'
|
||||||
|
import { ArchetypeBadge } from '../components/ArchetypeBadge.jsx'
|
||||||
import { StatusBadge } from '../components/StatusBadge.jsx'
|
import { StatusBadge } from '../components/StatusBadge.jsx'
|
||||||
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
import { InitiativeForm } from '../components/InitiativeForm.jsx'
|
import { InitiativeForm } from '../components/InitiativeForm.jsx'
|
||||||
|
|
@ -10,12 +11,15 @@ import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { useCapabilities } from '../hooks/useCapabilities.js'
|
import { useCapabilities } from '../hooks/useCapabilities.js'
|
||||||
|
import { useEntityArchetypes } from '../hooks/useEntityArchetypes.js'
|
||||||
import { useSession } from '../context/SessionContext.jsx'
|
import { useSession } from '../context/SessionContext.jsx'
|
||||||
import { scopedPath } from '../utils/routes.js'
|
import { scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
export function InitiativesPage() {
|
export function InitiativesPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const { context } = useSession()
|
const { context } = useSession()
|
||||||
const { hasCapability } = useCapabilities()
|
const { hasCapability } = useCapabilities()
|
||||||
|
const { initiativeArchetypes } = useEntityArchetypes()
|
||||||
const [initiatives, setInitiatives] = useState([])
|
const [initiatives, setInitiatives] = useState([])
|
||||||
const [openCounts, setOpenCounts] = useState({})
|
const [openCounts, setOpenCounts] = useState({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -62,6 +66,7 @@ export function InitiativesPage() {
|
||||||
await saveInitiativeDynamicFields(created.id, dynamicFields)
|
await saveInitiativeDynamicFields(created.id, dynamicFields)
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
await load()
|
await load()
|
||||||
|
navigate(scopedPath('/plan/profile', { initiativeId: created.id }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -74,7 +79,9 @@ export function InitiativesPage() {
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Vorhaben</h1>
|
<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>
|
</div>
|
||||||
{hasCapability('kairo.initiative.manage') && (
|
{hasCapability('kairo.initiative.manage') && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -115,6 +122,10 @@ export function InitiativesPage() {
|
||||||
{item.goal && <p className="list-item-desc">{item.goal}</p>}
|
{item.goal && <p className="list-item-desc">{item.goal}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="list-item-meta">
|
<div className="list-item-meta">
|
||||||
|
<ArchetypeBadge
|
||||||
|
archetypeKey={item.archetype_key}
|
||||||
|
archetypes={initiativeArchetypes}
|
||||||
|
/>
|
||||||
<StatusBadge kind="initiative" status={item.status} />
|
<StatusBadge kind="initiative" status={item.status} />
|
||||||
<PriorityBadge priority={item.priority} />
|
<PriorityBadge priority={item.priority} />
|
||||||
{openCounts[item.id] != null && (
|
{openCounts[item.id] != null && (
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useSession } from '../context/SessionContext.jsx'
|
||||||
import { InitiativeForm } from '../components/InitiativeForm.jsx'
|
import { InitiativeForm } from '../components/InitiativeForm.jsx'
|
||||||
import { createInitiative } from '../api/initiatives.js'
|
import { createInitiative } from '../api/initiatives.js'
|
||||||
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
|
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
|
||||||
|
import { scopedPath } from '../utils/routes.js'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
export function WorkspacePage() {
|
export function WorkspacePage() {
|
||||||
|
|
@ -28,7 +29,7 @@ export function WorkspacePage() {
|
||||||
})
|
})
|
||||||
await saveInitiativeDynamicFields(created.id, dynamicFields)
|
await saveInitiativeDynamicFields(created.id, dynamicFields)
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
navigate('/initiatives')
|
navigate(scopedPath('/plan/profile', { initiativeId: created.id }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFormError(err.message)
|
setFormError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { EmptyState } from '../../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../../components/ErrorState.jsx'
|
import { ErrorState } from '../../components/ErrorState.jsx'
|
||||||
import { gateTitleById } from '../../components/GateSelect.jsx'
|
import { gateTitleById } from '../../components/GateSelect.jsx'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.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 { gatePath, projectPath, actionPath, scopedPath } from '../../utils/routes.js'
|
||||||
import {
|
import {
|
||||||
buildProjectPath,
|
buildProjectPath,
|
||||||
|
|
@ -35,6 +37,8 @@ function ProjectDetailView({
|
||||||
onDelete,
|
onDelete,
|
||||||
onFilter,
|
onFilter,
|
||||||
busy,
|
busy,
|
||||||
|
projectArchetypes,
|
||||||
|
initiativeArchetypes,
|
||||||
}) {
|
}) {
|
||||||
const path = buildProjectPath(projects, project.id)
|
const path = buildProjectPath(projects, project.id)
|
||||||
const gateTitle = gateTitleById(roadmapItems, project.roadmap_item_id)
|
const gateTitle = gateTitleById(roadmapItems, project.roadmap_item_id)
|
||||||
|
|
@ -52,6 +56,20 @@ function ProjectDetailView({
|
||||||
<dt>Art</dt>
|
<dt>Art</dt>
|
||||||
<dd>{containerKindLabel(project.container_kind) || 'Standard'}</dd>
|
<dd>{containerKindLabel(project.container_kind) || 'Standard'}</dd>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<dt>Ebene</dt>
|
<dt>Ebene</dt>
|
||||||
<dd>
|
<dd>
|
||||||
|
|
@ -178,6 +196,7 @@ export function ProjectDetailPage() {
|
||||||
const { projectId } = useParams()
|
const { projectId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const ops = useInitiativeOperations()
|
const ops = useInitiativeOperations()
|
||||||
|
const { initiativeArchetypes, projectArchetypes } = useEntityArchetypes()
|
||||||
const isNew = projectId === 'new'
|
const isNew = projectId === 'new'
|
||||||
const [editing, setEditing] = useState(isNew)
|
const [editing, setEditing] = useState(isNew)
|
||||||
const [localError, setLocalError] = useState(null)
|
const [localError, setLocalError] = useState(null)
|
||||||
|
|
@ -304,6 +323,9 @@ export function ProjectDetailPage() {
|
||||||
}
|
}
|
||||||
busy={ops.formBusy}
|
busy={ops.formBusy}
|
||||||
submitLabel={isNew ? 'Anlegen' : 'Speichern'}
|
submitLabel={isNew ? 'Anlegen' : 'Speichern'}
|
||||||
|
initiativeArchetypeKey={ops.initiative?.archetype_key}
|
||||||
|
initiativeArchetypes={initiativeArchetypes}
|
||||||
|
projectArchetypes={projectArchetypes}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
project && (
|
project && (
|
||||||
|
|
@ -319,6 +341,8 @@ export function ProjectDetailPage() {
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onFilter={handleFilter}
|
onFilter={handleFilter}
|
||||||
busy={ops.formBusy}
|
busy={ops.formBusy}
|
||||||
|
projectArchetypes={projectArchetypes}
|
||||||
|
initiativeArchetypes={initiativeArchetypes}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { InitiativeForm } from '../../components/InitiativeForm.jsx'
|
||||||
import { createInitiative } from '../../api/initiatives.js'
|
import { createInitiative } from '../../api/initiatives.js'
|
||||||
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../../api/entityFields.js'
|
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../../api/entityFields.js'
|
||||||
import { ModeShell } from '../../components/ModeShell.jsx'
|
import { ModeShell } from '../../components/ModeShell.jsx'
|
||||||
import { initiativeCatalogPath } from '../../utils/routes.js'
|
import { initiativeCatalogPath, scopedPath } from '../../utils/routes.js'
|
||||||
|
|
||||||
export function CockpitPage() {
|
export function CockpitPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -30,7 +30,7 @@ export function CockpitPage() {
|
||||||
})
|
})
|
||||||
await saveInitiativeDynamicFields(created.id, dynamicFields)
|
await saveInitiativeDynamicFields(created.id, dynamicFields)
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
navigate(initiativeCatalogPath())
|
navigate(scopedPath('/plan/profile', { initiativeId: created.id }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFormError(err.message)
|
setFormError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
||||||
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
||||||
import { StatusBadge } from '../../components/StatusBadge.jsx'
|
import { StatusBadge } from '../../components/StatusBadge.jsx'
|
||||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
import { Modal } from '../../components/Modal.jsx'
|
import { Modal } from '../../components/Modal.jsx'
|
||||||
import { InitiativeForm } from '../../components/InitiativeForm.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 { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
import { useProgramScope } from '../../context/ProgramScopeContext.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 {
|
import {
|
||||||
getInitiativeFieldDefinitions,
|
getInitiativeFieldDefinitions,
|
||||||
getInitiativeFields,
|
getInitiativeFields,
|
||||||
|
|
@ -74,9 +79,21 @@ function formatFieldValue(definition, value) {
|
||||||
function PlanProfileInner() {
|
function PlanProfileInner() {
|
||||||
const ops = useInitiativeOperations()
|
const ops = useInitiativeOperations()
|
||||||
const { refreshInitiativeMeta } = useProgramScope()
|
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 [editing, setEditing] = useState(false)
|
||||||
|
|
||||||
const canManage = capabilities.has('kairo.initiative.manage')
|
const canManage = capabilities.has('kairo.initiative.manage')
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -87,6 +104,11 @@ function PlanProfileInner() {
|
||||||
return <p className="muted">Vorhaben nicht gefunden.</p>
|
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) {
|
async function handleSave(payload) {
|
||||||
const ok = await ops.handleUpdateInitiative(payload)
|
const ok = await ops.handleUpdateInitiative(payload)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
|
@ -115,9 +137,40 @@ function PlanProfileInner() {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{error && <p className="error">{error}</p>}
|
{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">
|
<div className="plan-profile__field">
|
||||||
<h3 className="plan-profile__label">Archetyp</h3>
|
<h3 className="plan-profile__label">Verantwortlich (Owner)</h3>
|
||||||
<p>{initiativeArchetypeLabel(initiative.archetype_key)}</p>
|
<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>
|
</div>
|
||||||
{initiative.goal ? (
|
{initiative.goal ? (
|
||||||
<div className="plan-profile__field">
|
<div className="plan-profile__field">
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@ import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.
|
||||||
import { ProjectsSection } from '../../components/ProjectsSection.jsx'
|
import { ProjectsSection } from '../../components/ProjectsSection.jsx'
|
||||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
|
import { useEntityArchetypes } from '../../hooks/useEntityArchetypes.js'
|
||||||
|
|
||||||
function PlanStructureInner() {
|
function PlanStructureInner() {
|
||||||
const ops = useInitiativeOperations()
|
const ops = useInitiativeOperations()
|
||||||
|
const { initiativeArchetypes, projectArchetypes } = useEntityArchetypes()
|
||||||
const {
|
const {
|
||||||
|
initiative,
|
||||||
initiativeId,
|
initiativeId,
|
||||||
projects,
|
projects,
|
||||||
roadmapItems,
|
roadmapItems,
|
||||||
|
|
@ -35,6 +38,9 @@ function PlanStructureInner() {
|
||||||
onDelete={ops.handleDeleteProject}
|
onDelete={ops.handleDeleteProject}
|
||||||
onReorder={ops.handleReorderProjects}
|
onReorder={ops.handleReorderProjects}
|
||||||
busy={ops.formBusy}
|
busy={ops.formBusy}
|
||||||
|
initiativeArchetypeKey={initiative?.archetype_key}
|
||||||
|
initiativeArchetypes={initiativeArchetypes}
|
||||||
|
projectArchetypes={projectArchetypes}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1528,3 +1528,86 @@
|
||||||
opacity: 0.75;
|
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 { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { scopedPath } from '../utils/routes.js'
|
import { scopedPath } from '../utils/routes.js'
|
||||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
import { ArchetypeBadge } from '../components/ArchetypeBadge.jsx'
|
||||||
|
import { useEntityArchetypes } from '../hooks/useEntityArchetypes.js'
|
||||||
|
|
||||||
export function InitiativePortfolioWidget() {
|
export function InitiativePortfolioWidget() {
|
||||||
|
const { initiativeArchetypes } = useEntityArchetypes()
|
||||||
const [initiatives, setInitiatives] = useState([])
|
const [initiatives, setInitiatives] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
@ -60,6 +63,10 @@ export function InitiativePortfolioWidget() {
|
||||||
<p className="initiative-portfolio-card-goal muted">{item.goal}</p>
|
<p className="initiative-portfolio-card-goal muted">{item.goal}</p>
|
||||||
)}
|
)}
|
||||||
<div className="initiative-portfolio-card-badges">
|
<div className="initiative-portfolio-card-badges">
|
||||||
|
<ArchetypeBadge
|
||||||
|
archetypeKey={item.archetype_key}
|
||||||
|
archetypes={initiativeArchetypes}
|
||||||
|
/>
|
||||||
<StatusBadge kind="initiative" status={item.status} />
|
<StatusBadge kind="initiative" status={item.status} />
|
||||||
<PriorityBadge priority={item.priority} />
|
<PriorityBadge priority={item.priority} />
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<ol className="item-list next-action-list">
|
<ol className="item-list next-action-list">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
|
|
@ -34,11 +53,17 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="list-item-meta">
|
<div className="list-item-meta">
|
||||||
{item.action_id && (
|
{(() => {
|
||||||
<Link to={actionPath(item.action_id)} className="btn btn-primary btn-sm">
|
const link = actionLinkForItem(item)
|
||||||
Arbeitspaket
|
if (link) {
|
||||||
|
return (
|
||||||
|
<Link to={link.to} className="btn btn-primary btn-sm">
|
||||||
|
{link.label}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})()}
|
||||||
{showInitiativeLink && item.initiative_id && !initiativeId && (
|
{showInitiativeLink && item.initiative_id && !initiativeId && (
|
||||||
<Link
|
<Link
|
||||||
to={scopedPath('/control/status', { initiativeId: item.initiative_id })}
|
to={scopedPath('/control/status', { initiativeId: item.initiative_id })}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user