AP0.10a: OM-Integration — Steering-Snapshot, Transitions, Verknüpfungen
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Failing after 1m10s
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 1s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Failing after 1m10s
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 1s
Test Suite / compose-smoke (push) Has been skipped
Beantwortet die Lücke zwischen parallelen Tabellen und späterem Steering Core: Graph-Read-Model, minimale Flows, sichtbarer Steuerungszustand. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a025542545
commit
e25ff86420
12
README.md
12
README.md
|
|
@ -230,7 +230,17 @@ Operating Model Extension II (AP0.9, Schema `008`):
|
|||
|
||||
Capabilities gesamt nach AP0.9: **33**.
|
||||
|
||||
Zielarchitektur-Einordnung: AP0.9 ist Schritt 7 der Evolutionslinie (Evidence/Review/Decision/Recurring) — siehe `docs/sprints/Sprint0_AP0_9_Assignment_v0.1.md`.
|
||||
Operating Model Integration (AP0.10, Schema weiter `008`):
|
||||
|
||||
| Endpoint | Methode | Capability | Beschreibung |
|
||||
|----------|---------|------------|--------------|
|
||||
| `/api/initiatives/{id}/steering-snapshot` | GET | `kairo.initiative.read` | Verknüpfter Graph + Operating Phase |
|
||||
|
||||
**Operating-Transitions:** Blocker gelöst → Maßnahme entblocken; Review abgeschlossen → Maßnahme `review_required` → `done`
|
||||
|
||||
**Attention Regel 10:** `action_review_required` — Maßnahme ohne geplantes Review
|
||||
|
||||
Version nach AP0.10: **`0.10.0-ap0.10`**. Brücke zum Steering Core: `docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md`
|
||||
|
||||
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ AttentionKind = Literal[
|
|||
"overdue_action",
|
||||
"review_due",
|
||||
"recurring_due",
|
||||
"action_review_required",
|
||||
]
|
||||
|
||||
NextActionKind = Literal[
|
||||
|
|
@ -275,6 +276,41 @@ def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|||
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _actions_review_required(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
'action_review_required' AS kind,
|
||||
'warning' AS severity,
|
||||
a.title AS title,
|
||||
'Maßnahme wartet auf Review — kein geplantes Review verknüpft' AS summary,
|
||||
'action' AS scope_type,
|
||||
a.id AS scope_id,
|
||||
a.initiative_id,
|
||||
a.id AS action_id,
|
||||
NULL::uuid AS blocker_id,
|
||||
NULL::uuid AS milestone_id,
|
||||
NULL::uuid AS review_id,
|
||||
NULL::uuid AS recurring_element_id,
|
||||
'action_review_required_no_review' AS reason_code,
|
||||
'actions' AS data_source
|
||||
FROM actions a
|
||||
WHERE a.tenant_id = %s
|
||||
AND a.status = 'review_required'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM reviews r
|
||||
WHERE r.tenant_id = a.tenant_id
|
||||
AND r.action_id = a.id
|
||||
AND r.status = 'planned'
|
||||
)
|
||||
ORDER BY a.updated_at DESC
|
||||
LIMIT 20
|
||||
""",
|
||||
(ctx.tenant_id,),
|
||||
)
|
||||
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _overdue_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
@ -389,6 +425,7 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
|
|||
items.extend(_stale_initiatives(cur, ctx))
|
||||
items.extend(_milestones_at_risk(cur, ctx))
|
||||
items.extend(_overdue_actions(cur, ctx))
|
||||
items.extend(_actions_review_required(cur, ctx))
|
||||
items.extend(_reviews_due(cur, ctx))
|
||||
items.extend(_recurring_due(cur, ctx))
|
||||
|
||||
|
|
|
|||
349
backend/data_layer/initiative_snapshot.py
Normal file
349
backend/data_layer/initiative_snapshot.py
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
"""Initiative steering snapshot — verknüpftes Read-Model (AP0.10a).
|
||||
|
||||
Baut aus flachen OM-Tabellen einen erklärbaren Graph für UI und spätere Steering Core.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from services.initiatives import get_initiative
|
||||
from tenant_context import TenantContext
|
||||
|
||||
OperatingPhase = Literal[
|
||||
"capture",
|
||||
"triage",
|
||||
"structure",
|
||||
"commit",
|
||||
"execute",
|
||||
"verify",
|
||||
"review",
|
||||
"adapt",
|
||||
"closure",
|
||||
]
|
||||
|
||||
OPEN_BLOCKER = ("open", "in_progress")
|
||||
OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required")
|
||||
ACTIVE_INITIATIVE = ("active", "paused")
|
||||
|
||||
|
||||
def _sid(value: Any) -> Optional[str]:
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _iso(value: Any) -> Optional[str]:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def _derive_operating_phase(
|
||||
*,
|
||||
initiative_status: str,
|
||||
open_actions: int,
|
||||
blocked_actions: int,
|
||||
review_required_actions: int,
|
||||
open_blockers: int,
|
||||
backlog_new: int,
|
||||
planned_reviews_due: int,
|
||||
) -> tuple[OperatingPhase, list[str]]:
|
||||
"""Heuristische, erklärbare Phase — kein Lifecycle-State-Machine-Ersatz."""
|
||||
signals: list[str] = []
|
||||
|
||||
if initiative_status in ("completed", "archived"):
|
||||
return "closure", ["initiative_terminal"]
|
||||
|
||||
if open_actions == 0 and backlog_new > 0:
|
||||
signals.append("backlog_awaiting_commit")
|
||||
return "triage", signals
|
||||
|
||||
if open_actions == 0 and backlog_new == 0:
|
||||
signals.append("no_open_work")
|
||||
return "structure", signals
|
||||
|
||||
if blocked_actions > 0 or open_blockers > 0:
|
||||
if blocked_actions:
|
||||
signals.append("actions_blocked")
|
||||
if open_blockers:
|
||||
signals.append("open_blockers")
|
||||
return "execute", signals
|
||||
|
||||
if review_required_actions > 0 or planned_reviews_due > 0:
|
||||
if review_required_actions:
|
||||
signals.append("actions_need_review")
|
||||
if planned_reviews_due:
|
||||
signals.append("reviews_due")
|
||||
return "review", signals
|
||||
|
||||
if open_actions > 0:
|
||||
signals.append("work_in_progress")
|
||||
return "execute", signals
|
||||
|
||||
return "adapt", signals
|
||||
|
||||
|
||||
def get_initiative_steering_snapshot(
|
||||
ctx: TenantContext, *, initiative_id: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Tenant-scoped Graph-Snapshot eines Vorhabens."""
|
||||
initiative = get_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
|
||||
if not initiative:
|
||||
return None
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, description, status, priority, due_at,
|
||||
created_at, updated_at
|
||||
FROM actions
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
actions_raw = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, action_id, milestone_id, title, status, created_at, updated_at
|
||||
FROM blockers
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
blockers = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, action_id, milestone_id, title, status, created_at, updated_at
|
||||
FROM evidence
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
evidence = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, action_id, milestone_id, title, status, due_at,
|
||||
created_at, updated_at
|
||||
FROM reviews
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
reviews = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, status, priority, converted_action_id, created_at, updated_at
|
||||
FROM backlog_items
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
backlog = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, status, target_date, created_at, updated_at
|
||||
FROM milestones
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
milestones_raw = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, status, outcome, created_at, updated_at
|
||||
FROM decisions
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
decisions = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, status, next_due_at, created_at, updated_at
|
||||
FROM recurring_elements
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
recurring = [dict(r) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
blockers_by_action: dict[str, list[dict]] = {}
|
||||
unlinked_blockers: list[dict] = []
|
||||
for b in blockers:
|
||||
item = {
|
||||
"id": _sid(b["id"]),
|
||||
"title": b["title"],
|
||||
"status": b["status"],
|
||||
"action_id": _sid(b.get("action_id")),
|
||||
"milestone_id": _sid(b.get("milestone_id")),
|
||||
}
|
||||
aid = item["action_id"]
|
||||
if aid:
|
||||
blockers_by_action.setdefault(aid, []).append(item)
|
||||
else:
|
||||
unlinked_blockers.append(item)
|
||||
|
||||
evidence_by_action: dict[str, list[dict]] = {}
|
||||
unlinked_evidence: list[dict] = []
|
||||
for e in evidence:
|
||||
item = {
|
||||
"id": _sid(e["id"]),
|
||||
"title": e["title"],
|
||||
"status": e["status"],
|
||||
"action_id": _sid(e.get("action_id")),
|
||||
"milestone_id": _sid(e.get("milestone_id")),
|
||||
}
|
||||
aid = item["action_id"]
|
||||
if aid:
|
||||
evidence_by_action.setdefault(aid, []).append(item)
|
||||
else:
|
||||
unlinked_evidence.append(item)
|
||||
|
||||
reviews_by_action: dict[str, list[dict]] = {}
|
||||
reviews_by_milestone: dict[str, list[dict]] = {}
|
||||
unlinked_reviews: list[dict] = []
|
||||
planned_reviews_due = 0
|
||||
for r in reviews:
|
||||
item = {
|
||||
"id": _sid(r["id"]),
|
||||
"title": r["title"],
|
||||
"status": r["status"],
|
||||
"due_at": _iso(r.get("due_at")),
|
||||
"action_id": _sid(r.get("action_id")),
|
||||
"milestone_id": _sid(r.get("milestone_id")),
|
||||
}
|
||||
if r["status"] == "planned" and r.get("due_at"):
|
||||
planned_reviews_due += 1
|
||||
aid, mid = item["action_id"], item["milestone_id"]
|
||||
if aid:
|
||||
reviews_by_action.setdefault(aid, []).append(item)
|
||||
elif mid:
|
||||
reviews_by_milestone.setdefault(mid, []).append(item)
|
||||
else:
|
||||
unlinked_reviews.append(item)
|
||||
|
||||
actions: list[dict[str, Any]] = []
|
||||
open_actions = blocked_actions = review_required_actions = 0
|
||||
for a in actions_raw:
|
||||
aid = _sid(a["id"])
|
||||
status = a["status"]
|
||||
if status in OPEN_ACTION:
|
||||
open_actions += 1
|
||||
if status == "blocked":
|
||||
blocked_actions += 1
|
||||
if status == "review_required":
|
||||
review_required_actions += 1
|
||||
action_blockers = blockers_by_action.get(aid, [])
|
||||
open_blocker_count = sum(1 for b in action_blockers if b["status"] in OPEN_BLOCKER)
|
||||
actions.append(
|
||||
{
|
||||
"id": aid,
|
||||
"title": a["title"],
|
||||
"status": status,
|
||||
"priority": a["priority"],
|
||||
"due_at": _iso(a.get("due_at")),
|
||||
"blockers": action_blockers,
|
||||
"evidence": evidence_by_action.get(aid, []),
|
||||
"reviews": reviews_by_action.get(aid, []),
|
||||
"open_blocker_count": open_blocker_count,
|
||||
"has_open_blocker": open_blocker_count > 0,
|
||||
}
|
||||
)
|
||||
|
||||
milestones = []
|
||||
for m in milestones_raw:
|
||||
mid = _sid(m["id"])
|
||||
milestones.append(
|
||||
{
|
||||
"id": mid,
|
||||
"title": m["title"],
|
||||
"status": m["status"],
|
||||
"target_date": m["target_date"].isoformat() if m.get("target_date") else None,
|
||||
"reviews": reviews_by_milestone.get(mid, []),
|
||||
}
|
||||
)
|
||||
|
||||
open_blockers = sum(
|
||||
1 for b in blockers if b["status"] in OPEN_BLOCKER
|
||||
)
|
||||
backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted"))
|
||||
|
||||
phase, phase_signals = _derive_operating_phase(
|
||||
initiative_status=initiative["status"],
|
||||
open_actions=open_actions,
|
||||
blocked_actions=blocked_actions,
|
||||
review_required_actions=review_required_actions,
|
||||
open_blockers=open_blockers,
|
||||
backlog_new=backlog_new,
|
||||
planned_reviews_due=planned_reviews_due,
|
||||
)
|
||||
|
||||
return {
|
||||
"initiative_id": initiative_id,
|
||||
"initiative_title": initiative["title"],
|
||||
"initiative_status": initiative["status"],
|
||||
"operating_phase": phase,
|
||||
"phase_signals": phase_signals,
|
||||
"counts": {
|
||||
"actions_open": open_actions,
|
||||
"actions_blocked": blocked_actions,
|
||||
"actions_review_required": review_required_actions,
|
||||
"blockers_open": open_blockers,
|
||||
"backlog_open": backlog_new,
|
||||
"decisions": len(decisions),
|
||||
"reviews_planned_due": planned_reviews_due,
|
||||
"recurring_active": sum(1 for r in recurring if r["status"] == "active"),
|
||||
"unlinked_blockers": len(unlinked_blockers),
|
||||
"unlinked_evidence": len(unlinked_evidence),
|
||||
"unlinked_reviews": len(unlinked_reviews),
|
||||
},
|
||||
"actions": actions,
|
||||
"milestones": milestones,
|
||||
"initiative_level": {
|
||||
"blockers": unlinked_blockers,
|
||||
"evidence": unlinked_evidence,
|
||||
"reviews": unlinked_reviews,
|
||||
"decisions": [
|
||||
{"id": _sid(d["id"]), "title": d["title"], "status": d["status"]}
|
||||
for d in decisions
|
||||
],
|
||||
"backlog": [
|
||||
{
|
||||
"id": _sid(b["id"]),
|
||||
"title": b["title"],
|
||||
"status": b["status"],
|
||||
"converted_action_id": _sid(b.get("converted_action_id")),
|
||||
}
|
||||
for b in backlog
|
||||
],
|
||||
"recurring": [
|
||||
{
|
||||
"id": _sid(r["id"]),
|
||||
"title": r["title"],
|
||||
"status": r["status"],
|
||||
"next_due_at": _iso(r.get("next_due_at")),
|
||||
}
|
||||
for r in recurring
|
||||
],
|
||||
},
|
||||
"data_source": "initiative_steering_snapshot",
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ from datetime import datetime
|
|||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from data_layer import initiative_snapshot as dl_initiative_snapshot
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from services import actions as action_service
|
||||
|
|
@ -154,6 +155,19 @@ def get_initiative(
|
|||
return item
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/steering-snapshot")
|
||||
def get_initiative_steering_snapshot(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
snapshot = dl_initiative_snapshot.get_initiative_steering_snapshot(
|
||||
ctx, initiative_id=initiative_id
|
||||
)
|
||||
if not snapshot:
|
||||
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||
return snapshot
|
||||
|
||||
|
||||
@router.patch("/{initiative_id}")
|
||||
def update_initiative(
|
||||
initiative_id: str,
|
||||
|
|
|
|||
|
|
@ -264,6 +264,16 @@ def update_blocker(
|
|||
"to_status": status,
|
||||
},
|
||||
)
|
||||
from services.operating_transitions import after_blocker_status_change
|
||||
|
||||
after_blocker_status_change(
|
||||
tenant_id=tenant_id,
|
||||
blocker_id=blocker_id,
|
||||
action_id=result.get("action_id"),
|
||||
old_status=old_status,
|
||||
new_status=status,
|
||||
user_id=user_id,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
|
|
|||
147
backend/services/operating_transitions.py
Normal file
147
backend/services/operating_transitions.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""Minimale Operating-Transitions — Vorstufe zum Steering Orchestrator (AP0.10a).
|
||||
|
||||
Side Effects bei Statusänderungen. Später: Hook Registry + Method Strategies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
|
||||
RESOLVED_BLOCKER_STATUSES = frozenset({"resolved", "accepted_risk", "dismissed"})
|
||||
|
||||
|
||||
def after_blocker_status_change(
|
||||
*,
|
||||
tenant_id: str,
|
||||
blocker_id: str,
|
||||
action_id: Optional[str],
|
||||
old_status: str,
|
||||
new_status: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Blocker geschlossen → verknüpfte blockierte Maßnahme entblocken."""
|
||||
effects: list[dict[str, Any]] = []
|
||||
if new_status == old_status:
|
||||
return effects
|
||||
if new_status not in RESOLVED_BLOCKER_STATUSES or not action_id:
|
||||
return effects
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, status FROM actions
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(action_id, tenant_id),
|
||||
)
|
||||
action = cur.fetchone()
|
||||
if not action or action["status"] != "blocked":
|
||||
return effects
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM blockers
|
||||
WHERE tenant_id = %s AND action_id = %s
|
||||
AND id != %s AND status IN ('open', 'in_progress')
|
||||
""",
|
||||
(tenant_id, action_id, blocker_id),
|
||||
)
|
||||
remaining = cur.fetchone()[0]
|
||||
if remaining > 0:
|
||||
effects.append(
|
||||
{
|
||||
"kind": "action_still_blocked",
|
||||
"action_id": str(action_id),
|
||||
"reason": "other_open_blockers",
|
||||
}
|
||||
)
|
||||
return effects
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE actions SET status = 'open', updated_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s AND status = 'blocked'
|
||||
RETURNING id
|
||||
""",
|
||||
(action_id, tenant_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
effects.append(
|
||||
{
|
||||
"kind": "action_unblocked",
|
||||
"action_id": str(action_id),
|
||||
"trigger": "blocker_resolved",
|
||||
"blocker_id": blocker_id,
|
||||
}
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if effects and user_id:
|
||||
from services.audit import log_audit
|
||||
|
||||
log_audit(
|
||||
"operating_transition.action_unblocked",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"effects": effects, "blocker_id": blocker_id},
|
||||
)
|
||||
return effects
|
||||
|
||||
|
||||
def after_review_status_change(
|
||||
*,
|
||||
tenant_id: str,
|
||||
review_id: str,
|
||||
action_id: Optional[str],
|
||||
old_status: str,
|
||||
new_status: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Review abgeschlossen → Maßnahme aus review_required weiterführen."""
|
||||
effects: list[dict[str, Any]] = []
|
||||
if new_status == old_status or new_status != "completed" or not action_id:
|
||||
return effects
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE actions
|
||||
SET status = 'done', updated_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s AND status = 'review_required'
|
||||
RETURNING id
|
||||
""",
|
||||
(action_id, tenant_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
effects.append(
|
||||
{
|
||||
"kind": "action_completed_after_review",
|
||||
"action_id": str(action_id),
|
||||
"trigger": "review_completed",
|
||||
"review_id": review_id,
|
||||
}
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if effects and user_id:
|
||||
from services.audit import log_audit
|
||||
|
||||
log_audit(
|
||||
"operating_transition.action_completed_after_review",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"effects": effects, "review_id": review_id},
|
||||
)
|
||||
return effects
|
||||
|
|
@ -309,6 +309,16 @@ def update_review(
|
|||
"to_status": status,
|
||||
},
|
||||
)
|
||||
from services.operating_transitions import after_review_status_change
|
||||
|
||||
after_review_status_change(
|
||||
tenant_id=tenant_id,
|
||||
review_id=review_id,
|
||||
action_id=result.get("action_id"),
|
||||
old_status=old_status,
|
||||
new_status=status,
|
||||
user_id=user_id,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
|
|
|||
90
backend/tests/test_ap10_integration.py
Normal file
90
backend/tests/test_ap10_integration.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""AP0.10 — Integration: Snapshot, Transitions, Attention."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.factories import provision_user_in_tenant
|
||||
from tests.test_initiatives_actions import (
|
||||
_auth,
|
||||
_create_action,
|
||||
_create_initiative,
|
||||
_login,
|
||||
)
|
||||
|
||||
|
||||
def test_steering_snapshot_graph(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
action = _create_action(client, token, initiative_id, title="Linked Action").json()
|
||||
|
||||
blocker = client.post(
|
||||
f"/api/initiatives/{initiative_id}/blockers",
|
||||
json={"title": "B", "action_id": action["id"]},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert blocker.status_code == 201
|
||||
|
||||
snap = client.get(
|
||||
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert snap.status_code == 200
|
||||
data = snap.json()
|
||||
assert data["operating_phase"] in (
|
||||
"execute",
|
||||
"triage",
|
||||
"structure",
|
||||
"review",
|
||||
"adapt",
|
||||
"commit",
|
||||
"capture",
|
||||
"verify",
|
||||
"closure",
|
||||
)
|
||||
linked = next(a for a in data["actions"] if a["id"] == action["id"])
|
||||
assert len(linked["blockers"]) == 1
|
||||
assert linked["open_blocker_count"] == 1
|
||||
|
||||
|
||||
def test_blocker_resolve_unblocks_action(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
action = _create_action(client, token, initiative_id).json()["id"]
|
||||
|
||||
created = client.post(
|
||||
f"/api/initiatives/{initiative_id}/blockers",
|
||||
json={
|
||||
"title": "Block",
|
||||
"action_id": action,
|
||||
"set_action_blocked": True,
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
blocker_id = created.json()["id"]
|
||||
blocked = client.get(f"/api/actions/{action}", headers=_auth(token)).json()
|
||||
assert blocked["status"] == "blocked"
|
||||
|
||||
client.patch(
|
||||
f"/api/blockers/{blocker_id}",
|
||||
json={"status": "resolved"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
unblocked = client.get(f"/api/actions/{action}", headers=_auth(token)).json()
|
||||
assert unblocked["status"] == "open"
|
||||
|
||||
|
||||
def test_attention_action_review_required_without_review(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
action = _create_action(client, token, initiative_id).json()
|
||||
client.patch(
|
||||
f"/api/actions/{action['id']}",
|
||||
json={"status": "review_required"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
res = client.get("/api/workspace/attention", headers=_auth(token))
|
||||
assert res.status_code == 200
|
||||
assert any(i["kind"] == "action_review_required" for i in res.json())
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
APP_VERSION = "0.9.0-ap0.9"
|
||||
APP_VERSION = "0.10.0-ap0.10"
|
||||
DB_SCHEMA_VERSION = "008"
|
||||
APP_NAME = "jinkendo-kairo"
|
||||
|
|
|
|||
146
docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md
Normal file
146
docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# Kairo — Operating Model → Steering Bridge
|
||||
|
||||
**Status:** Architektur-Einordnung
|
||||
**Stand:** 2026-07-05
|
||||
**Zweck:** Ehrliche Antwort auf die Frage, ob AP0.8/0.9 auf spätere Steuerungslogik einzahlen — und was noch fehlt.
|
||||
|
||||
---
|
||||
|
||||
## 1. Kurzantwort
|
||||
|
||||
**Ja, aber indirekt.** AP0.8/0.9 haben das **Vokabular und die Persistenzschicht** des kanonischen Operating Models geliefert — nicht den **Steuerungsgraph**, nicht den **Lifecycle-Orchestrator** und nicht die **Method Registry**.
|
||||
|
||||
Das ist kein Versehen, sondern die dokumentierte Reihenfolge:
|
||||
|
||||
```text
|
||||
Foundation (AP0.1–0.7)
|
||||
→ Operating Model Entitäten (AP0.8–0.9) ← wir sind hier
|
||||
→ Integration & Validation (AP0.10)
|
||||
→ Steering Core Foundation (Sprint 1 / AP1.x)
|
||||
→ Method Registry, Hooks, Lifecycle Runtime
|
||||
```
|
||||
|
||||
Ohne die Entitäten hätte Steering nichts zu lesen und zu steuern.
|
||||
Mit nur den Entitäten **ohne Verbindungslogik** wirkt Kairo zu Recht wie eine Sammlung paralleler Tabellen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Was AP0.8/0.9 tatsächlich vorbereitet
|
||||
|
||||
| Baustein | Beitrag für späteres Steering | Noch nicht da |
|
||||
|----------|------------------------------|---------------|
|
||||
| **Entitäten** | Blocker, Backlog, Milestone, Evidence, Decision, Review, Recurring | SteeringContext, Project, Dependency |
|
||||
| **Schwache FKs** | `blocker.action_id`, `evidence.action_id`, `review.action_id` | Pflicht-Struktur, Graph-Traversierung in UI |
|
||||
| **Attention (Regeln 1–9)** | Proto-Signal-Engine, erklärbare DTOs | Methoden-spezifische Signal Rules, Ranking |
|
||||
| **NextActionCandidates** | Regelbasierte Empfehlungen | Hook `on_next_action_requested`, Strategien |
|
||||
| **Audit-Events** | Nachvollziehbarkeit für Agenten/Steering | Lifecycle-Audit-Kette |
|
||||
| **Actor-Assignments** | Wer handelt | Waiting/Reminder/Escalation |
|
||||
| **Status-Enums** | Anknüpfpunkte für Lifecycle-Transitions | State Machine + Guards |
|
||||
|
||||
**Fazit:** AP0.8/0.9 = **System of Record für OM-Objekte** + **regelbasierte Signale**.
|
||||
Das ist Schritt 5–7 der Evolutionslinie (`Kairo_System_Target_State_v0.1.md` §24), **nicht** Schritt 2–4 (Steering Core, Method Registry, Lifecycle).
|
||||
|
||||
---
|
||||
|
||||
## 3. Kanonisches Modell vs. Ist-Implementierung
|
||||
|
||||
### Soll (Canonical Operating Model)
|
||||
|
||||
```text
|
||||
Initiative
|
||||
├── Milestone
|
||||
├── BacklogItem
|
||||
├── Action
|
||||
│ ├── Assignment
|
||||
│ ├── Blocker ← primär an Maßnahme
|
||||
│ └── Evidence ← primär an Maßnahme
|
||||
├── Decision
|
||||
├── Review
|
||||
└── RecurringElement
|
||||
```
|
||||
|
||||
### Ist (AP0.9)
|
||||
|
||||
```text
|
||||
Initiative
|
||||
├── [parallele Sektionen in UI]
|
||||
├── Action (Liste)
|
||||
├── Blocker (Liste, action_id optional)
|
||||
├── Evidence (Liste, action_id optional)
|
||||
├── …
|
||||
```
|
||||
|
||||
**Lücke:** Objekte existieren, aber **Struktur und Flow sind nicht sichtbar**.
|
||||
Der Nutzer sieht Listen, keinen **Steuerungszustand** eines Vorhabens.
|
||||
|
||||
---
|
||||
|
||||
## 4. Was „Steuerungslogik“ später bedeutet
|
||||
|
||||
Aus `Kairo_Target_Architecture_Method_Driven_Adaptive_Steering_Core_v0.1.md`:
|
||||
|
||||
| Komponente | Rolle |
|
||||
|------------|-------|
|
||||
| **SteeringContext** | Bindet Initiative an Methode, Lifecycle, Domain |
|
||||
| **Method Registry** | Liefert Structure Builder, Signal Rules, Strategien |
|
||||
| **Standard Lifecycle** | intake → … → closure mit Guards |
|
||||
| **Hook Registry** | `on_review_due`, `on_blocker_open`, `on_next_action_requested` |
|
||||
| **Signal Engine** | Attention + NextAction aus Methode + Kontext |
|
||||
| **Transition Orchestrator** | Statusänderung → Side Effects (unblock, review anstoßen) |
|
||||
|
||||
AP0.8/0.9 **ersetzen** das nicht — sie liefern die **Daten**, an die Hooks und Regeln andocken.
|
||||
|
||||
---
|
||||
|
||||
## 5. AP0.10 — Integration statt nur Validation
|
||||
|
||||
AP0.10 wird daher **zweigeteilt**:
|
||||
|
||||
### A) Operating Model Integration (technisch)
|
||||
|
||||
- **Steering-Snapshot** Read-Model: Initiative als verknüpfter Graph (Actions mit Blockern/Evidence/Reviews)
|
||||
- **Operating Cycle Phase** (heuristisch, erklärbar): triage / execute / verify / review / adapt
|
||||
- **Transition-Orchestrierung minimal:** z. B. Blocker gelöst → verknüpfte Maßnahme entblocken; Review abgeschlossen → Maßnahme weiterführen
|
||||
- **UI:** Verknüpfungen sichtbar (Maßnahme ↔ Blocker/Evidence), nicht nur parallele Sektionen
|
||||
- **Attention:** Regeln, die **Beziehungen** nutzen (Maßnahme blockiert + offener Blocker auf derselben Maßnahme)
|
||||
|
||||
### B) MVP Validation (fachlich)
|
||||
|
||||
Testvorhaben aus Roadmap (Kairo, Gewaltschutzkurs, …) — Prüffragen: Hilft es bei täglicher Steuerung? Fehlt was **wirklich** als Nächstes?
|
||||
|
||||
---
|
||||
|
||||
## 6. Evolutionspfad (konkret)
|
||||
|
||||
```text
|
||||
AP0.10 Integration + Validation ← nächster Schritt
|
||||
AP1.0 backend/steering/ Foundation (SteeringContext, Lifecycle stub)
|
||||
AP1.1 Method Registry minimal + Hook Registry
|
||||
AP1.2 Signal Engine aus attention.py → steering/signals/
|
||||
AP1.3 Transition Orchestrator (ersetzt operating_transitions.py)
|
||||
Sprint 2+ Dependencies, Waiting, Method Profiles
|
||||
```
|
||||
|
||||
**Regel:** Keine neuen parallelen Tabellen ohne OM-Bezug.
|
||||
Neue Arbeit muss either **verbinden**, **orchestratieren** oder **validieren**.
|
||||
|
||||
---
|
||||
|
||||
## 7. Leitfrage-Check
|
||||
|
||||
> Zahlt jede Entität auf „Welcher nächste Schritt bringt das Vorhaben voran?“ ein?
|
||||
|
||||
| Entität | Heute | Nach AP0.10 | Nach Steering Core |
|
||||
|---------|-------|-------------|-------------------|
|
||||
| Action | ✓ Commit/Execute | ✓ mit Kontext | ✓ NextAction-Ziel |
|
||||
| Blocker | △ Signal | ✓ an Maßnahme | ✓ Hook on_blocker_open |
|
||||
| Backlog | ✓ Triage | ✓ Convert-Flow | ✓ Structure Builder |
|
||||
| Milestone | △ Signal at_risk | ✓ mit Reviews | ✓ Roadmap-Lane |
|
||||
| Evidence | △ passive | ✓ an Maßnahme | ✓ Verify-Phase |
|
||||
| Review | △ due signal | ✓ schließt Execute | ✓ Review Strategy |
|
||||
| Decision | △ passive | △ Adapt-Hinweis | ✓ Adapt-Hook |
|
||||
| Recurring | △ due signal | △ Routine-Hinweis | ✓ routine_control |
|
||||
|
||||
---
|
||||
|
||||
*v0.1 — Referenz für AP0.10 und Sprint-1-Planung*
|
||||
50
docs/sprints/Sprint0_AP0_10_Assignment_v0.1.md
Normal file
50
docs/sprints/Sprint0_AP0_10_Assignment_v0.1.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# AP0.10 — Operating Model Integration & MVP Validation
|
||||
## Implementierungsauftrag v0.1
|
||||
|
||||
**Status:** freigegeben
|
||||
**Stand:** 2026-07-05
|
||||
**Vorgänger:** AP0.9 ✓
|
||||
**Zielversion:** `0.10.0-ap0.10` (keine Schema-Migration)
|
||||
|
||||
---
|
||||
|
||||
## Warum AP0.10 anders ist als AP0.8/0.9
|
||||
|
||||
AP0.8/0.9 lieferten **Entitäten + Signale**.
|
||||
AP0.10 liefert **Verknüpfung + erklärbaren Zustand + minimale Flows** — Brücke zum Steering Core.
|
||||
|
||||
Referenz: `docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md`
|
||||
|
||||
---
|
||||
|
||||
## Teil A — Integration (technisch)
|
||||
|
||||
| Slice | Inhalt |
|
||||
|-------|--------|
|
||||
| 10a | Steering-Snapshot Read-Model + API |
|
||||
| 10b | Operating-Transitions (Blocker→Action, Review→Action) |
|
||||
| 10c | Attention Regel: review_required ohne Review |
|
||||
| 10d | UI: Steuerungszustand + Verknüpfungen an Maßnahmen |
|
||||
|
||||
## Teil B — Validation (fachlich)
|
||||
|
||||
Testvorhaben aus Roadmap durchspielen, Lücken dokumentieren, AP1.0-Prioritäten ableiten.
|
||||
|
||||
## Nicht-Scope
|
||||
|
||||
- SteeringContext, Method Registry, Dependencies, Sprints
|
||||
- UI-Redesign
|
||||
- KI/MCP/Workflow
|
||||
|
||||
---
|
||||
|
||||
## Abnahme Teil A
|
||||
|
||||
- `GET /api/initiatives/{id}/steering-snapshot` liefert Graph + operating_phase
|
||||
- Blocker resolved → verknüpfte Maßnahme entblockt (wenn keine weiteren offenen Blocker)
|
||||
- Review completed → Maßnahme in review_required → done
|
||||
- UI zeigt Phase + Maßnahmen-Kontext (Blocker/Evidence)
|
||||
|
||||
---
|
||||
|
||||
*Freigegeben — Integration vor Validation.*
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "kairo-jinkendo-frontend",
|
||||
"version": "0.9.0-ap0.9",
|
||||
"version": "0.10.0-ap0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ export function getInitiative(id) {
|
|||
return apiFetch(`/api/initiatives/${id}`)
|
||||
}
|
||||
|
||||
export function getInitiativeSteeringSnapshot(id) {
|
||||
return apiFetch(`/api/initiatives/${id}/steering-snapshot`)
|
||||
}
|
||||
|
||||
export function createInitiative(payload) {
|
||||
return apiFetch('/api/initiatives', {
|
||||
method: 'POST',
|
||||
|
|
|
|||
51
frontend/src/components/SteeringSnapshotPanel.jsx
Normal file
51
frontend/src/components/SteeringSnapshotPanel.jsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { PHASE_LABELS } from '../constants/operating.js'
|
||||
|
||||
export function SteeringSnapshotPanel({ snapshot }) {
|
||||
if (!snapshot) return null
|
||||
|
||||
const { operating_phase, phase_signals, counts } = snapshot
|
||||
const phaseLabel = PHASE_LABELS[operating_phase] || operating_phase
|
||||
|
||||
return (
|
||||
<section className="card steering-snapshot">
|
||||
<div className="section-header">
|
||||
<h2>Steuerungszustand</h2>
|
||||
<span className="badge status-badge status-active">{phaseLabel}</span>
|
||||
</div>
|
||||
{phase_signals?.length > 0 && (
|
||||
<p className="muted snapshot-signals">
|
||||
Signale: {phase_signals.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
<dl className="snapshot-counts">
|
||||
<div>
|
||||
<dt>Offene Maßnahmen</dt>
|
||||
<dd>{counts.actions_open}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Blockiert</dt>
|
||||
<dd>{counts.actions_blocked}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Review nötig</dt>
|
||||
<dd>{counts.actions_review_required}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Offene Blocker</dt>
|
||||
<dd>{counts.blockers_open}</dd>
|
||||
</div>
|
||||
{(counts.unlinked_blockers > 0 || counts.unlinked_evidence > 0) && (
|
||||
<div className="snapshot-warn">
|
||||
<dt>Unverknüpft</dt>
|
||||
<dd>
|
||||
{counts.unlinked_blockers} Blocker, {counts.unlinked_evidence} Evidence
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<p className="muted snapshot-hint">
|
||||
Kanonischer Operating Cycle — noch heuristisch, später methodengeführt über Steering Core.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
23
frontend/src/constants/operating.js
Normal file
23
frontend/src/constants/operating.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export const OPERATING_PHASES = [
|
||||
'capture',
|
||||
'triage',
|
||||
'structure',
|
||||
'commit',
|
||||
'execute',
|
||||
'verify',
|
||||
'review',
|
||||
'adapt',
|
||||
'closure',
|
||||
]
|
||||
|
||||
export const PHASE_LABELS = {
|
||||
capture: 'Erfassung',
|
||||
triage: 'Einordnung',
|
||||
structure: 'Struktur',
|
||||
commit: 'Commit',
|
||||
execute: 'Umsetzung',
|
||||
verify: 'Nachweis',
|
||||
review: 'Review',
|
||||
adapt: 'Anpassung',
|
||||
closure: 'Abschluss',
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||
import { Link, useParams } from 'react-router-dom'
|
||||
import {
|
||||
getInitiative,
|
||||
getInitiativeSteeringSnapshot,
|
||||
listInitiativeActions,
|
||||
createInitiativeAction,
|
||||
} from '../api/initiatives.js'
|
||||
|
|
@ -60,6 +61,7 @@ import { EvidenceSection } from '../components/EvidenceSection.jsx'
|
|||
import { DecisionsSection } from '../components/DecisionsSection.jsx'
|
||||
import { ReviewsSection } from '../components/ReviewsSection.jsx'
|
||||
import { RecurringSection } from '../components/RecurringSection.jsx'
|
||||
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
|
||||
import { EmptyState } from '../components/EmptyState.jsx'
|
||||
import { ErrorState } from '../components/ErrorState.jsx'
|
||||
import { LoadingState } from '../components/LoadingState.jsx'
|
||||
|
|
@ -88,6 +90,7 @@ export function InitiativeDetailPage() {
|
|||
const [decisions, setDecisions] = useState([])
|
||||
const [reviews, setReviews] = useState([])
|
||||
const [recurringItems, setRecurringItems] = useState([])
|
||||
const [steeringSnapshot, setSteeringSnapshot] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [showActionForm, setShowActionForm] = useState(false)
|
||||
|
|
@ -119,6 +122,11 @@ export function InitiativeDetailPage() {
|
|||
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
|
||||
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
|
||||
loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([])))
|
||||
loads.push(
|
||||
getInitiativeSteeringSnapshot(id)
|
||||
.then(setSteeringSnapshot)
|
||||
.catch(() => setSteeringSnapshot(null))
|
||||
)
|
||||
}
|
||||
await Promise.all(loads)
|
||||
} catch (err) {
|
||||
|
|
@ -136,6 +144,10 @@ export function InitiativeDetailPage() {
|
|||
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
||||
: actions
|
||||
|
||||
const actionContextById = Object.fromEntries(
|
||||
(steeringSnapshot?.actions || []).map((a) => [a.id, a])
|
||||
)
|
||||
|
||||
async function handleCreateAction(payload) {
|
||||
setFormBusy(true)
|
||||
try {
|
||||
|
|
@ -200,6 +212,23 @@ export function InitiativeDetailPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleCreateBlockerForAction(actionId) {
|
||||
if (!capabilities.has('kairo.blocker.manage')) return
|
||||
setFormBusy(true)
|
||||
try {
|
||||
await createInitiativeBlocker(id, {
|
||||
title: 'Blocker',
|
||||
action_id: actionId,
|
||||
set_action_blocked: true,
|
||||
})
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setFormBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlockerStatus(blockerId, status) {
|
||||
try {
|
||||
await updateBlocker(blockerId, { status })
|
||||
|
|
@ -453,6 +482,10 @@ export function InitiativeDetailPage() {
|
|||
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{capabilities.has('kairo.initiative.read') && (
|
||||
<SteeringSnapshotPanel snapshot={steeringSnapshot} />
|
||||
)}
|
||||
|
||||
<section className="card">
|
||||
<div className="section-header">
|
||||
<h2>Maßnahmen</h2>
|
||||
|
|
@ -498,7 +531,9 @@ export function InitiativeDetailPage() {
|
|||
)}
|
||||
|
||||
<ul className="item-list actions-detail-list">
|
||||
{visibleActions.map((action) => (
|
||||
{visibleActions.map((action) => {
|
||||
const ctx = actionContextById[action.id]
|
||||
return (
|
||||
<li key={action.id} className="list-item card-list-item">
|
||||
{editingAction === action.id ? (
|
||||
<ActionForm
|
||||
|
|
@ -530,6 +565,13 @@ export function InitiativeDetailPage() {
|
|||
Fällig: {new Date(action.due_at).toLocaleString('de-DE')}
|
||||
</p>
|
||||
)}
|
||||
{ctx && (ctx.open_blocker_count > 0 || ctx.evidence?.length > 0 || ctx.reviews?.length > 0) && (
|
||||
<p className="muted list-item-sub action-links">
|
||||
{ctx.open_blocker_count > 0 && `${ctx.open_blocker_count} Blocker · `}
|
||||
{ctx.evidence?.length > 0 && `${ctx.evidence.length} Evidence · `}
|
||||
{ctx.reviews?.length > 0 && `${ctx.reviews.length} Review(s)`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-item-meta action-controls">
|
||||
<StatusBadge status={action.status} />
|
||||
|
|
@ -548,6 +590,16 @@ export function InitiativeDetailPage() {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
{capabilities.has('kairo.blocker.manage') && action.status !== 'done' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handleCreateBlockerForAction(action.id)}
|
||||
disabled={formBusy}
|
||||
>
|
||||
Blocker
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
|
|
@ -564,7 +616,8 @@ export function InitiativeDetailPage() {
|
|||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -597,3 +597,38 @@
|
|||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.steering-snapshot .snapshot-counts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
|
||||
gap: 0.75rem 1rem;
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.steering-snapshot .snapshot-counts div {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.steering-snapshot .snapshot-counts dt {
|
||||
font-size: 0.75rem;
|
||||
color: var(--jk-muted, #666);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.steering-snapshot .snapshot-counts dd {
|
||||
margin: 0.15rem 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.steering-snapshot .snapshot-warn dd {
|
||||
color: var(--jk-warning, #b45309);
|
||||
}
|
||||
|
||||
.steering-snapshot .snapshot-hint {
|
||||
font-size: 0.8rem;
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.action-links {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user