AP1.4b: Gate-Checkliste, Verify, Reopen und Plan-Detail-UI
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 1m36s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s

Kriterien mit waive/defer per Decision; Verify gegen Checkliste; Reopen active mit Audit; Schema 012.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-06 06:53:49 +02:00
parent e70e3196ec
commit 9bf6ccee36
17 changed files with 1607 additions and 54 deletions

View File

@ -88,6 +88,7 @@ app.include_router(backlog.router)
app.include_router(milestones.router) app.include_router(milestones.router)
app.include_router(roadmap.initiative_router) app.include_router(roadmap.initiative_router)
app.include_router(roadmap.items_router) app.include_router(roadmap.items_router)
app.include_router(roadmap.criteria_router)
app.include_router(evidence.router) app.include_router(evidence.router)
app.include_router(decisions.router) app.include_router(decisions.router)
app.include_router(reviews.router) app.include_router(reviews.router)

View File

@ -0,0 +1,76 @@
-- AP1.4b: Gate-Checkliste (Prüfplan pro RoadmapItem)
CREATE TABLE roadmap_item_criteria (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
roadmap_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
sort_order INT NOT NULL DEFAULT 0,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
criterion_kind VARCHAR(32) NOT NULL DEFAULT 'evidence_required'
CHECK (criterion_kind IN ('manual', 'evidence_required', 'review_required', 'metric')),
verification_hint TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'satisfied', 'waived', 'deferred', 'failed')),
waived_or_deferred_decision_id UUID NULL REFERENCES decisions(id) ON DELETE SET NULL,
satisfied_by_evidence_id UUID NULL REFERENCES evidence(id) ON DELETE SET NULL,
satisfied_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_roadmap_criteria_item ON roadmap_item_criteria(tenant_id, roadmap_item_id);
CREATE INDEX idx_roadmap_criteria_status ON roadmap_item_criteria(tenant_id, status);
-- Compat: Default-Kriterium pro bestehendem Plan-Element
INSERT INTO roadmap_item_criteria (
tenant_id, roadmap_item_id, sort_order, title, description, criterion_kind, status
)
SELECT
ri.tenant_id,
ri.id,
0,
'Gate allgemein',
COALESCE(NULLIF(ri.goal_description, ''), 'Akzeptierter Nachweis oder abgeschlossenes Review am Plan-Element.'),
'evidence_required',
'open'
FROM roadmap_items ri;
-- Bereits vorhandene accepted Evidence → Default-Kriterium satisfied
UPDATE roadmap_item_criteria ric
SET
status = 'satisfied',
satisfied_at = NOW(),
satisfied_by_evidence_id = sub.evidence_id
FROM (
SELECT DISTINCT ON (ric2.id)
ric2.id AS criterion_id,
e.id AS evidence_id
FROM roadmap_item_criteria ric2
JOIN evidence e
ON e.tenant_id = ric2.tenant_id
AND e.roadmap_item_id = ric2.roadmap_item_id
AND e.status = 'accepted'
WHERE ric2.title = 'Gate allgemein'
ORDER BY ric2.id, e.updated_at DESC
) sub
WHERE ric.id = sub.criterion_id;
-- Bereits reached Gates ohne Evidence: Review completed
UPDATE roadmap_item_criteria ric
SET status = 'satisfied', satisfied_at = NOW()
FROM roadmap_items ri
WHERE ric.roadmap_item_id = ri.id
AND ric.tenant_id = ri.tenant_id
AND ric.title = 'Gate allgemein'
AND ric.status = 'open'
AND ri.status = 'reached'
AND EXISTS (
SELECT 1 FROM reviews rev
WHERE rev.tenant_id = ri.tenant_id
AND rev.initiative_id IN (
SELECT r.initiative_id FROM roadmaps r WHERE r.id = ri.roadmap_id
)
AND rev.status = 'completed'
AND (rev.roadmap_item_id = ri.id OR rev.milestone_id = ri.id)
);

View File

@ -153,3 +153,21 @@ register_capability(
default_grants=_MEMBER_MANAGE, default_grants=_MEMBER_MANAGE,
) )
) )
register_capability(
CapabilityRegistration(
key="kairo.roadmap.reopen",
module="roadmap",
description="Erreichte Plan-Elemente wieder öffnen (Reopen)",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.roadmap.reopen.emergency",
module="roadmap",
description="Plan-Reopen als Portal-Admin (Notfall)",
default_grants=(("portal", "admin"),),
)
)

View File

@ -9,6 +9,7 @@ from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from services import roadmap as roadmap_service from services import roadmap as roadmap_service
from services import roadmap_criteria as criteria_service
from tenant_context import TenantContext from tenant_context import TenantContext
router = APIRouter(tags=["roadmap"]) router = APIRouter(tags=["roadmap"])
@ -46,6 +47,38 @@ class DependencyCreateRequest(BaseModel):
dependency_type: Literal["requires", "blocks", "related"] = "requires" dependency_type: Literal["requires", "blocks", "related"] = "requires"
class CriterionCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=500)
description: str = ""
criterion_kind: Literal["manual", "evidence_required", "review_required", "metric"] = "manual"
verification_hint: str = ""
sort_order: int = 0
class CriterionUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=500)
description: Optional[str] = None
criterion_kind: Optional[
Literal["manual", "evidence_required", "review_required", "metric"]
] = None
verification_hint: Optional[str] = None
sort_order: Optional[int] = None
change_reason: str = ""
class CriterionWaiveDeferRequest(BaseModel):
decision_title: str = Field(min_length=1, max_length=255)
decision_description: str = ""
class CriterionSatisfyRequest(BaseModel):
evidence_id: Optional[str] = None
class ReopenRequest(BaseModel):
reason: str = ""
initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"]) initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"]) items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"])
@ -169,6 +202,156 @@ def verify_roadmap_item_reached(
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.post("/{item_id}/reopen")
def reopen_roadmap_item(
item_id: str,
body: ReopenRequest,
ctx: TenantContext = Depends(require_capability("kairo.roadmap.reopen")),
):
try:
return roadmap_service.reopen_roadmap_item(
tenant_id=ctx.tenant_id,
item_id=item_id,
user_id=ctx.user_id,
reason=body.reason,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.get("/{item_id}/criteria")
def list_roadmap_item_criteria(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
progress = criteria_service.criteria_progress(
tenant_id=ctx.tenant_id, item_id=item_id
)
items = criteria_service.list_criteria_for_item(
tenant_id=ctx.tenant_id, item_id=item_id
)
return {"items": items, "progress": progress}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.post("/{item_id}/criteria", status_code=201)
def create_roadmap_item_criterion(
item_id: str,
body: CriterionCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return criteria_service.create_criterion(
tenant_id=ctx.tenant_id,
item_id=item_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
criterion_kind=body.criterion_kind,
verification_hint=body.verification_hint,
sort_order=body.sort_order,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
criteria_router = APIRouter(prefix="/api/roadmap-criteria", tags=["roadmap"])
@criteria_router.patch("/{criterion_id}")
def update_roadmap_criterion(
criterion_id: str,
body: CriterionUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
item = criteria_service.update_criterion(
tenant_id=ctx.tenant_id,
criterion_id=criterion_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
criterion_kind=body.criterion_kind,
verification_hint=body.verification_hint,
sort_order=body.sort_order,
change_reason=body.change_reason,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Kriterium nicht gefunden")
return item
@criteria_router.delete("/{criterion_id}", status_code=204)
def delete_roadmap_criterion(
criterion_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
if not criteria_service.delete_criterion(
tenant_id=ctx.tenant_id, criterion_id=criterion_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Kriterium nicht gefunden")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@criteria_router.post("/{criterion_id}/satisfy")
def satisfy_roadmap_criterion(
criterion_id: str,
body: CriterionSatisfyRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return criteria_service.satisfy_criterion(
tenant_id=ctx.tenant_id,
criterion_id=criterion_id,
user_id=ctx.user_id,
evidence_id=body.evidence_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@criteria_router.post("/{criterion_id}/waive")
def waive_roadmap_criterion(
criterion_id: str,
body: CriterionWaiveDeferRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return criteria_service.waive_criterion(
tenant_id=ctx.tenant_id,
criterion_id=criterion_id,
user_id=ctx.user_id,
decision_title=body.decision_title,
decision_description=body.decision_description,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@criteria_router.post("/{criterion_id}/defer")
def defer_roadmap_criterion(
criterion_id: str,
body: CriterionWaiveDeferRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return criteria_service.defer_criterion(
tenant_id=ctx.tenant_id,
criterion_id=criterion_id,
user_id=ctx.user_id,
decision_title=body.decision_title,
decision_description=body.decision_description,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.get("/{item_id}/dependencies") @items_router.get("/{item_id}/dependencies")
def list_roadmap_item_dependencies( def list_roadmap_item_dependencies(
item_id: str, item_id: str,

View File

@ -11,6 +11,7 @@ from psycopg2.extras import RealDictCursor
from db import get_connection from db import get_connection
from services.audit import log_audit from services.audit import log_audit
from services.initiatives import get_initiative from services.initiatives import get_initiative
from services.roadmap_criteria import create_default_criterion, prepare_criteria_for_verify
RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage"] RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage"]
RoadmapItemStatus = Literal[ RoadmapItemStatus = Literal[
@ -275,6 +276,12 @@ def create_roadmap_item(
) )
row = _serialize_row(dict(cur.fetchone())) row = _serialize_row(dict(cur.fetchone()))
row["initiative_id"] = initiative_id row["initiative_id"] = initiative_id
create_default_criterion(
cur,
tenant_id=tenant_id,
roadmap_item_id=row["id"],
description=goal_description or "",
)
_sync_milestone_compat_row( _sync_milestone_compat_row(
cur, cur,
tenant_id=tenant_id, tenant_id=tenant_id,
@ -533,7 +540,7 @@ def add_dependency(
def verify_reached( def verify_reached(
*, tenant_id: str, item_id: str, user_id: Optional[str] = None *, tenant_id: str, item_id: str, user_id: Optional[str] = None
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Gate-Verify: reached nur mit accepted Evidence, completed Review oder gate_override Decision.""" """Gate-Verify: reached wenn alle Kriterien erfüllt (satisfied/waived/deferred)."""
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id) item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item: if not item:
raise ValueError("RoadmapItem nicht gefunden") raise ValueError("RoadmapItem nicht gefunden")
@ -544,32 +551,15 @@ def verify_reached(
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute( criteria_ok, criteria_reason = prepare_criteria_for_verify(
""" cur,
SELECT 1 FROM evidence tenant_id=tenant_id,
WHERE tenant_id = %s AND initiative_id = %s initiative_id=initiative_id,
AND status = 'accepted' item_id=item_id,
AND (roadmap_item_id = %s OR milestone_id = %s)
LIMIT 1
""",
(tenant_id, initiative_id, item_id, item_id),
) )
if cur.fetchone(): verify_reason = "criteria_ready"
verify_reason = "evidence_accepted"
else: if not criteria_ok:
cur.execute(
"""
SELECT 1 FROM reviews
WHERE tenant_id = %s AND initiative_id = %s
AND status = 'completed'
AND (roadmap_item_id = %s OR milestone_id = %s)
LIMIT 1
""",
(tenant_id, initiative_id, item_id, item_id),
)
if cur.fetchone():
verify_reason = "review_completed"
else:
cur.execute( cur.execute(
""" """
SELECT 1 FROM decisions SELECT 1 FROM decisions
@ -582,13 +572,14 @@ def verify_reached(
) )
if cur.fetchone(): if cur.fetchone():
verify_reason = "gate_override_decision" verify_reason = "gate_override_decision"
criteria_ok = True
else: else:
raise ValueError( raise ValueError(
"Verify fehlgeschlagen — am Plan-Element fehlt ein akzeptierter " f"Verify fehlgeschlagen — {criteria_reason}. "
"Nachweis (Evidence), ein abgeschlossenes Review oder eine " "Kriterien in der Checkliste prüfen oder Nachweis am Plan-Element einreichen."
"gate_override-Decision. Nachweis unter Plan einreichen oder auf "
"der Journey-Seite mit Plan-Element verknüpfen und auf Akzeptiert setzen."
) )
elif criteria_reason != "no_criteria":
verify_reason = criteria_reason
cur.execute( cur.execute(
""" """
@ -621,3 +612,57 @@ def verify_reached(
details={"roadmap_item_id": item_id, "verify_reason": verify_reason}, details={"roadmap_item_id": item_id, "verify_reason": verify_reason},
) )
return row return row
def reopen_roadmap_item(
*,
tenant_id: str,
item_id: str,
user_id: Optional[str] = None,
reason: str = "",
) -> dict[str, Any]:
"""Reopen reached → active; Kriterien-Status bleibt unverändert."""
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item:
raise ValueError("RoadmapItem nicht gefunden")
if item["status"] != "reached":
raise ValueError("Reopen nur für erreichte Plan-Elemente (status=reached)")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
UPDATE roadmap_items
SET status = 'active', updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at
""",
(item_id, tenant_id),
)
row = _serialize_row(dict(cur.fetchone()))
row["initiative_id"] = item["initiative_id"]
_sync_milestone_compat_row(
cur,
tenant_id=tenant_id,
initiative_id=item["initiative_id"],
item=row,
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.reopened",
user_id=user_id,
tenant_id=tenant_id,
details={
"roadmap_item_id": item_id,
"reason": reason.strip() or None,
"from_status": "reached",
"to_status": "active",
},
)
return row

View File

@ -0,0 +1,652 @@
"""RoadmapItem criteria / gate checklist service (AP1.4b)."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.decisions import create_decision
CriterionKind = Literal["manual", "evidence_required", "review_required", "metric"]
CriterionStatus = Literal["open", "satisfied", "waived", "deferred", "failed"]
CRITERION_KINDS = frozenset({"manual", "evidence_required", "review_required", "metric"})
CRITERION_STATUSES = frozenset({"open", "satisfied", "waived", "deferred", "failed"})
CLOSED_CRITERION_STATUSES = frozenset({"satisfied", "waived", "deferred"})
_TERMINAL_ITEM_STATUSES = frozenset({"reached", "moved", "discarded"})
_CRITERION_COLUMNS = """
id, tenant_id, roadmap_item_id, sort_order, title, description,
criterion_kind, verification_hint, status,
waived_or_deferred_decision_id, satisfied_by_evidence_id, satisfied_at,
created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in (
"id",
"tenant_id",
"roadmap_item_id",
"waived_or_deferred_decision_id",
"satisfied_by_evidence_id",
):
if result.get(key):
result[key] = str(result[key])
for ts in ("satisfied_at", "created_at", "updated_at"):
if result.get(ts):
result[ts] = result[ts].isoformat()
return result
def _validate_kind(kind: str) -> None:
if kind not in CRITERION_KINDS:
raise ValueError(f"Ungültiger criterion_kind: {kind}")
def _validate_status(status: str) -> None:
if status not in CRITERION_STATUSES:
raise ValueError(f"Ungültiger Kriterien-Status: {status}")
def _get_item_or_raise(*, tenant_id: str, item_id: str) -> dict[str, Any]:
from services.roadmap import get_roadmap_item
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item:
raise ValueError("RoadmapItem nicht gefunden")
return item
def _reopen_item_if_reached(
cur,
*,
tenant_id: str,
item: dict[str, Any],
user_id: Optional[str],
audit_reason: str,
) -> None:
if item["status"] != "reached":
return
cur.execute(
"""
UPDATE roadmap_items
SET status = 'active', updated_at = NOW()
WHERE id = %s AND tenant_id = %s
""",
(item["id"], tenant_id),
)
log_audit(
"roadmap_item.reopened",
user_id=user_id,
tenant_id=tenant_id,
details={
"roadmap_item_id": item["id"],
"reason": audit_reason,
"from_status": "reached",
"to_status": "active",
},
)
def create_default_criterion(
cur,
*,
tenant_id: str,
roadmap_item_id: str,
title: str = "Gate allgemein",
description: str = "",
sort_order: int = 0,
) -> dict[str, Any]:
cur.execute(
f"""
INSERT INTO roadmap_item_criteria (
tenant_id, roadmap_item_id, sort_order, title, description, criterion_kind
)
VALUES (%s, %s, %s, %s, %s, 'evidence_required')
RETURNING {_CRITERION_COLUMNS}
""",
(tenant_id, roadmap_item_id, sort_order, title, description or title),
)
return _serialize_row(dict(cur.fetchone()))
def list_criteria_for_item(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
_get_item_or_raise(tenant_id=tenant_id, item_id=item_id)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_CRITERION_COLUMNS}
FROM roadmap_item_criteria
WHERE tenant_id = %s AND roadmap_item_id = %s
ORDER BY sort_order ASC, created_at ASC
""",
(tenant_id, item_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_criterion(*, tenant_id: str, criterion_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_CRITERION_COLUMNS}
FROM roadmap_item_criteria
WHERE id = %s AND tenant_id = %s
""",
(criterion_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def create_criterion(
*,
tenant_id: str,
item_id: str,
title: str,
description: str = "",
criterion_kind: CriterionKind = "manual",
verification_hint: str = "",
sort_order: int = 0,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_kind(criterion_kind)
item = _get_item_or_raise(tenant_id=tenant_id, item_id=item_id)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
INSERT INTO roadmap_item_criteria (
tenant_id, roadmap_item_id, sort_order, title, description,
criterion_kind, verification_hint
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING {_CRITERION_COLUMNS}
""",
(
tenant_id,
item_id,
sort_order,
title,
description,
criterion_kind,
verification_hint,
),
)
row = _serialize_row(dict(cur.fetchone()))
if item["status"] == "reached":
_reopen_item_if_reached(
cur,
tenant_id=tenant_id,
item=item,
user_id=user_id,
audit_reason="criterion_created_on_reached_gate",
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item_criterion.created",
user_id=user_id,
tenant_id=tenant_id,
details={"criterion_id": row["id"], "roadmap_item_id": item_id, "title": title},
)
return row
def update_criterion(
*,
tenant_id: str,
criterion_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
criterion_kind: Optional[CriterionKind] = None,
verification_hint: Optional[str] = None,
sort_order: Optional[int] = None,
change_reason: str = "",
) -> Optional[dict[str, Any]]:
existing = get_criterion(tenant_id=tenant_id, criterion_id=criterion_id)
if not existing:
return None
item = _get_item_or_raise(tenant_id=tenant_id, item_id=existing["roadmap_item_id"])
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if criterion_kind is not None:
_validate_kind(criterion_kind)
updates.append("criterion_kind = %s")
params.append(criterion_kind)
if verification_hint is not None:
updates.append("verification_hint = %s")
params.append(verification_hint)
if sort_order is not None:
updates.append("sort_order = %s")
params.append(sort_order)
if not updates:
return existing
if item["status"] == "reached" and not change_reason.strip():
raise ValueError(
"Änderung an Kriterien bei erreichtem Gate erfordert Begründung (change_reason)"
)
updates.append("updated_at = NOW()")
params.extend([criterion_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE roadmap_item_criteria
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING {_CRITERION_COLUMNS}
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
if item["status"] == "reached":
_reopen_item_if_reached(
cur,
tenant_id=tenant_id,
item=item,
user_id=user_id,
audit_reason=change_reason.strip() or "criterion_updated_on_reached_gate",
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item_criterion.updated",
user_id=user_id,
tenant_id=tenant_id,
details={
"criterion_id": criterion_id,
"roadmap_item_id": existing["roadmap_item_id"],
"change_reason": change_reason.strip() or None,
},
)
return result
def delete_criterion(
*, tenant_id: str, criterion_id: str, user_id: Optional[str] = None
) -> bool:
existing = get_criterion(tenant_id=tenant_id, criterion_id=criterion_id)
if not existing:
return False
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) FROM roadmap_item_criteria
WHERE tenant_id = %s AND roadmap_item_id = %s
""",
(tenant_id, existing["roadmap_item_id"]),
)
if cur.fetchone()[0] <= 1:
raise ValueError("Letztes Kriterium eines Gates kann nicht gelöscht werden")
cur.execute(
"""
DELETE FROM roadmap_item_criteria
WHERE id = %s AND tenant_id = %s
RETURNING id
""",
(criterion_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"roadmap_item_criterion.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={
"criterion_id": criterion_id,
"roadmap_item_id": existing["roadmap_item_id"],
},
)
return deleted
def _accepted_evidence_for_item(cur, *, tenant_id: str, initiative_id: str, item_id: str):
cur.execute(
"""
SELECT id FROM evidence
WHERE tenant_id = %s AND initiative_id = %s AND status = 'accepted'
AND (roadmap_item_id = %s OR milestone_id = %s)
ORDER BY updated_at DESC
LIMIT 1
""",
(tenant_id, initiative_id, item_id, item_id),
)
row = cur.fetchone()
return str(row["id"]) if row else None
def _completed_review_for_item(cur, *, tenant_id: str, initiative_id: str, item_id: str) -> bool:
cur.execute(
"""
SELECT 1 FROM reviews
WHERE tenant_id = %s AND initiative_id = %s AND status = 'completed'
AND (roadmap_item_id = %s OR milestone_id = %s)
LIMIT 1
""",
(tenant_id, initiative_id, item_id, item_id),
)
return cur.fetchone() is not None
def satisfy_criterion(
*,
tenant_id: str,
criterion_id: str,
user_id: Optional[str] = None,
evidence_id: Optional[str] = None,
) -> dict[str, Any]:
existing = get_criterion(tenant_id=tenant_id, criterion_id=criterion_id)
if not existing:
raise ValueError("Kriterium nicht gefunden")
item = _get_item_or_raise(tenant_id=tenant_id, item_id=existing["roadmap_item_id"])
if item["status"] in _TERMINAL_ITEM_STATUSES and item["status"] != "reached":
raise ValueError(f"Gate ist terminal: {item['status']}")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
linked_evidence = evidence_id
kind = existing["criterion_kind"]
if kind == "evidence_required":
if linked_evidence:
cur.execute(
"""
SELECT id FROM evidence
WHERE id = %s AND tenant_id = %s AND status = 'accepted'
AND initiative_id = %s
""",
(linked_evidence, tenant_id, item["initiative_id"]),
)
if not cur.fetchone():
raise ValueError("Evidence nicht gefunden oder nicht akzeptiert")
else:
linked_evidence = _accepted_evidence_for_item(
cur,
tenant_id=tenant_id,
initiative_id=item["initiative_id"],
item_id=item["id"],
)
if not linked_evidence:
raise ValueError(
"Evidence-Kriterium: akzeptierter Nachweis am Plan-Element erforderlich"
)
elif kind == "review_required":
if not _completed_review_for_item(
cur,
tenant_id=tenant_id,
initiative_id=item["initiative_id"],
item_id=item["id"],
):
raise ValueError(
"Review-Kriterium: abgeschlossenes Review am Plan-Element erforderlich"
)
cur.execute(
f"""
UPDATE roadmap_item_criteria
SET status = 'satisfied',
satisfied_by_evidence_id = %s,
satisfied_at = NOW(),
waived_or_deferred_decision_id = NULL,
updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING {_CRITERION_COLUMNS}
""",
(linked_evidence, criterion_id, tenant_id),
)
result = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item_criterion.satisfied",
user_id=user_id,
tenant_id=tenant_id,
details={
"criterion_id": criterion_id,
"roadmap_item_id": existing["roadmap_item_id"],
"evidence_id": linked_evidence,
},
)
return result
def _waive_or_defer(
*,
tenant_id: str,
criterion_id: str,
action: Literal["waived", "deferred"],
user_id: Optional[str],
decision_title: str,
decision_description: str,
) -> dict[str, Any]:
existing = get_criterion(tenant_id=tenant_id, criterion_id=criterion_id)
if not existing:
raise ValueError("Kriterium nicht gefunden")
item = _get_item_or_raise(tenant_id=tenant_id, item_id=existing["roadmap_item_id"])
if item["status"] in _TERMINAL_ITEM_STATUSES and item["status"] != "reached":
raise ValueError(f"Gate ist terminal: {item['status']}")
decision_title = decision_title.strip()
if not decision_title:
raise ValueError("Decision-Titel (Begründung) ist erforderlich")
prefix = f"criterion_{action}:"
decision = create_decision(
tenant_id=tenant_id,
initiative_id=item["initiative_id"],
title=f"{prefix}{criterion_id}{decision_title}",
description=decision_description,
status="decided",
user_id=user_id,
)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE roadmap_item_criteria
SET status = %s,
waived_or_deferred_decision_id = %s,
satisfied_by_evidence_id = NULL,
satisfied_at = NULL,
updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING {_CRITERION_COLUMNS}
""",
(action, decision["id"], criterion_id, tenant_id),
)
result = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
f"roadmap_item_criterion.{action}",
user_id=user_id,
tenant_id=tenant_id,
details={
"criterion_id": criterion_id,
"roadmap_item_id": existing["roadmap_item_id"],
"decision_id": decision["id"],
},
)
return result
def waive_criterion(
*,
tenant_id: str,
criterion_id: str,
user_id: Optional[str] = None,
decision_title: str,
decision_description: str = "",
) -> dict[str, Any]:
return _waive_or_defer(
tenant_id=tenant_id,
criterion_id=criterion_id,
action="waived",
user_id=user_id,
decision_title=decision_title,
decision_description=decision_description,
)
def defer_criterion(
*,
tenant_id: str,
criterion_id: str,
user_id: Optional[str] = None,
decision_title: str,
decision_description: str = "",
) -> dict[str, Any]:
return _waive_or_defer(
tenant_id=tenant_id,
criterion_id=criterion_id,
action="deferred",
user_id=user_id,
decision_title=decision_title,
decision_description=decision_description,
)
def criteria_progress(*, tenant_id: str, item_id: str) -> dict[str, int]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (
WHERE status IN ('satisfied', 'waived', 'deferred')
) AS closed
FROM roadmap_item_criteria
WHERE tenant_id = %s AND roadmap_item_id = %s
""",
(tenant_id, item_id),
)
total, closed = cur.fetchone()
return {"total": total, "closed": closed, "open": total - closed}
finally:
conn.close()
def prepare_criteria_for_verify(
cur,
*,
tenant_id: str,
initiative_id: str,
item_id: str,
) -> tuple[bool, str]:
"""Auto-satisfy open evidence/review criteria wenn Nachweis da; prüft Gesamtstatus."""
cur.execute(
f"""
SELECT {_CRITERION_COLUMNS}
FROM roadmap_item_criteria
WHERE tenant_id = %s AND roadmap_item_id = %s
ORDER BY sort_order ASC, created_at ASC
""",
(tenant_id, item_id),
)
criteria = [dict(r) for r in cur.fetchall()]
if not criteria:
return True, "no_criteria"
evidence_id = _accepted_evidence_for_item(
cur, tenant_id=tenant_id, initiative_id=initiative_id, item_id=item_id
)
has_review = _completed_review_for_item(
cur, tenant_id=tenant_id, initiative_id=initiative_id, item_id=item_id
)
for crit in criteria:
status = crit["status"]
if status == "failed":
return False, f"Kriterium fehlgeschlagen: {crit['title']}"
if status in CLOSED_CRITERION_STATUSES:
if status in ("waived", "deferred") and not crit["waived_or_deferred_decision_id"]:
return False, f"Kriterium ohne Decision: {crit['title']}"
continue
if status == "open":
kind = crit["criterion_kind"]
if kind == "evidence_required" and evidence_id:
cur.execute(
"""
UPDATE roadmap_item_criteria
SET status = 'satisfied',
satisfied_by_evidence_id = %s,
satisfied_at = NOW(),
updated_at = NOW()
WHERE id = %s AND tenant_id = %s
""",
(evidence_id, crit["id"], tenant_id),
)
continue
if kind == "review_required" and has_review:
cur.execute(
"""
UPDATE roadmap_item_criteria
SET status = 'satisfied', satisfied_at = NOW(), updated_at = NOW()
WHERE id = %s AND tenant_id = %s
""",
(crit["id"], tenant_id),
)
continue
if kind == "manual":
return False, f"Manuelles Kriterium noch offen: {crit['title']}"
return False, f"Kriterium noch offen: {crit['title']}"
return True, "criteria_ready"

View File

@ -84,7 +84,7 @@ def test_verify_reached_requires_evidence(client):
) )
assert ok.status_code == 200 assert ok.status_code == 200
assert ok.json()["status"] == "reached" assert ok.json()["status"] == "reached"
assert ok.json()["verify_reason"] == "evidence_accepted" assert ok.json()["verify_reason"] in ("criteria_ready", "evidence_accepted")
def test_milestone_compat_api_uses_roadmap(client): def test_milestone_compat_api_uses_roadmap(client):

View File

@ -0,0 +1,150 @@
"""AP1.4b — Gate checklist, waive/defer, reopen tests."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import (
_auth,
_create_initiative,
_login,
)
from tests.test_ap14_roadmap import _create_roadmap_item
def _list_criteria(client, token, item_id):
return client.get(f"/api/roadmap-items/{item_id}/criteria", headers=_auth(token))
def test_default_criterion_on_create(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
item = _create_roadmap_item(client, token, initiative_id).json()
resp = _list_criteria(client, token, item["id"])
assert resp.status_code == 200
body = resp.json()
assert body["progress"]["total"] == 1
assert body["items"][0]["title"] == "Gate allgemein"
assert body["items"][0]["status"] == "open"
def test_verify_with_waived_criterion(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
item = _create_roadmap_item(client, token, initiative_id, status="active").json()
criteria = _list_criteria(client, token, item["id"]).json()["items"]
crit_a = criteria[0]["id"]
added = client.post(
f"/api/roadmap-items/{item['id']}/criteria",
json={"title": "Review-Protokoll", "criterion_kind": "manual"},
headers=_auth(token),
)
assert added.status_code == 201
crit_b = added.json()["id"]
waived = client.post(
f"/api/roadmap-criteria/{crit_b}/waive",
json={"decision_title": "Review entfällt", "decision_description": "PO-Freigabe"},
headers=_auth(token),
)
assert waived.status_code == 200
assert waived.json()["status"] == "waived"
evidence = client.post(
f"/api/initiatives/{initiative_id}/evidence",
json={
"title": "Nachweis",
"roadmap_item_id": item["id"],
"status": "accepted",
},
headers=_auth(token),
)
assert evidence.status_code == 201
ok = client.post(
f"/api/roadmap-items/{item['id']}/verify-reached",
headers=_auth(token),
)
assert ok.status_code == 200
assert ok.json()["status"] == "reached"
progress = _list_criteria(client, token, item["id"]).json()["progress"]
assert progress["closed"] == 2
def test_reopen_keeps_criterion_status(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
item = _create_roadmap_item(client, token, initiative_id, status="active").json()
client.post(
f"/api/initiatives/{initiative_id}/evidence",
json={
"title": "Nachweis",
"roadmap_item_id": item["id"],
"status": "accepted",
},
headers=_auth(token),
)
client.post(
f"/api/roadmap-items/{item['id']}/verify-reached",
headers=_auth(token),
)
criteria_before = _list_criteria(client, token, item["id"]).json()["items"]
assert criteria_before[0]["status"] == "satisfied"
reopened = client.post(
f"/api/roadmap-items/{item['id']}/reopen",
json={"reason": "Versehentlich geschlossen"},
headers=_auth(token),
)
assert reopened.status_code == 200
assert reopened.json()["status"] == "active"
criteria_after = _list_criteria(client, token, item["id"]).json()["items"]
assert criteria_after[0]["status"] == "satisfied"
def test_criterion_change_on_reached_reopens_gate(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
item = _create_roadmap_item(client, token, initiative_id, status="active").json()
client.post(
f"/api/initiatives/{initiative_id}/evidence",
json={
"title": "Nachweis",
"roadmap_item_id": item["id"],
"status": "accepted",
},
headers=_auth(token),
)
client.post(
f"/api/roadmap-items/{item['id']}/verify-reached",
headers=_auth(token),
)
crit_id = _list_criteria(client, token, item["id"]).json()["items"][0]["id"]
patched = client.patch(
f"/api/roadmap-criteria/{crit_id}",
json={
"title": "Gate allgemein (präzisiert)",
"change_reason": "DoD nachgeschärft",
},
headers=_auth(token),
)
assert patched.status_code == 200
item_after = client.get(
f"/api/roadmap-items/{item['id']}",
headers=_auth(token),
).json()
assert item_after["status"] == "active"
assert patched.json()["title"] == "Gate allgemein (präzisiert)"

View File

@ -43,6 +43,8 @@ def test_registry_contains_initial_capabilities():
"kairo.review.manage", "kairo.review.manage",
"kairo.recurring.read", "kairo.recurring.read",
"kairo.recurring.manage", "kairo.recurring.manage",
"kairo.roadmap.reopen",
"kairo.roadmap.reopen.emergency",
} }
@ -54,7 +56,7 @@ def test_sync_is_idempotent():
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM capabilities") cur.execute("SELECT COUNT(*) FROM capabilities")
assert cur.fetchone()[0] == 33 assert cur.fetchone()[0] == 35
cur.execute("SELECT COUNT(*) FROM role_capability_grants") cur.execute("SELECT COUNT(*) FROM role_capability_grants")
assert cur.fetchone()[0] >= 5 assert cur.fetchone()[0] >= 5
finally: finally:

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.13.0-ap1.4" APP_VERSION = "0.13.1-ap1.4b"
DB_SCHEMA_VERSION = "011" DB_SCHEMA_VERSION = "012"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -1,7 +1,7 @@
# Kairo — Implementation Truth Table v0.1 # Kairo — Implementation Truth Table v0.1
**Status:** living document — bei jedem AP aktualisieren **Status:** living document — bei jedem AP aktualisieren
**Stand:** 2026-07-05 (nach AP1.1b) **Stand:** 2026-07-06 (nach AP1.4b)
**Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert** **Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert**
Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
@ -30,7 +30,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Capabilities | ✓ | Keine Objekt-Sichtbarkeit | | Capabilities | ✓ | Keine Objekt-Sichtbarkeit |
| Auth / Session | ✓ | | | Auth / Session | ✓ | |
| Audit (Auth/Admin) | ◐ | Nicht für alle OM-Events | | Audit (Auth/Admin) | ◐ | Nicht für alle OM-Events |
| Migrationen nummeriert | ✓ | Schema 009 | | Migrationen nummeriert | ✓ | Schema 012 |
--- ---
@ -51,9 +51,10 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| RecurringElement | ✓ | | | RecurringElement | ✓ | |
| Task (unter Action) | ✗ | | | Task (unter Action) | ✗ | |
| Roadmap | ◐ | 1 pro Initiative; Migration 010 | | Roadmap | ◐ | 1 pro Initiative; Migration 010 |
| RoadmapItem | ◐ | milestone/review_gate/maturity_stage; Milestone-API Compat | | RoadmapItem | ◐ | Verify über Checkliste AP1.4b |
| RoadmapItem Dependencies | ◐ | requires/blocks/related | | RoadmapItem Dependencies | ◐ | requires/blocks/related |
| Plan-Ist-Verknüpfung | ◐ | backlog/actions.roadmap_item_id; Gate-Verify AP1.4 | | RoadmapItem Criteria | ◐ | Checkliste, waive/defer, Reopen AP1.4b |
| Plan-Ist-Verknüpfung | ◐ | Evidence/Reviews + Kriterien |
--- ---
@ -88,7 +89,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Next-Action-Widget konfigurierbar (Workspace + Initiative) | ✓ | scope portfolio/initiative | | Next-Action-Widget konfigurierbar (Workspace + Initiative) | ✓ | scope portfolio/initiative |
| Action Detail-Route | ✓ | `/initiatives/:id/actions/:actionId` | | Action Detail-Route | ✓ | `/initiatives/:id/actions/:actionId` |
| Action Detail-Seite | ✗ | | | Action Detail-Seite | ✗ | |
| Gate Detail-Seite | ✗ | | | Gate Detail-Seite | ◐ | `/initiatives/:id/plan/items/:itemId` AP1.4b |
| Modal-Bearbeitung | ✗ | Inline-Formulare überall | | Modal-Bearbeitung | ✗ | Inline-Formulare überall |
| Admin-UI | ✗ | | | Admin-UI | ✗ | |
@ -98,13 +99,14 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Gate-Anforderung (Vision) | Ist | | Gate-Anforderung (Vision) | Ist |
|---------------------------|-----| |---------------------------|-----|
| DoD / prüfbare Kriterien | ◐ `goal_description` Freitext, kein Kriterienmodell | | DoD / prüfbare Kriterien | ◐ | `roadmap_item_criteria`; Default „Gate allgemein“ |
| Zieltermin | ◐ `target_date` | | Zieltermin | ◐ | `target_date` |
| Abhängigkeiten | ✗ | | Abhängigkeiten | ◐ | API; Graph Engine AP1.4d |
| parallel vs. sequenziell | ✗ | | parallel vs. sequenziell | ◐ | `sequencing_mode` (deprecated); Graph AP1.4d |
| Verify vor `reached` | ✗ Status-Dropdown | | Verify vor `reached` | ◐ | Kriterienplan + gate_override |
| Decision bei `moved` | ✗ | | Reopen nach `reached` | ◐ | `active`, Kriterien bleiben AP1.4b |
| Quality-Gate-Semantik | ✗ | | Decision bei `moved` | ✗ | |
| Quality-Gate-Semantik | ◐ | Checkliste + Audit |
--- ---
@ -115,7 +117,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Arbeitspaket | Maßnahme | | Arbeitspaket | Maßnahme |
| Quality Gate | Meilenstein (CRUD) | | Quality Gate | Meilenstein (CRUD) |
| Programm | Vorhaben | | Programm | Vorhaben |
| Plan | (nicht sichtbar) | | Plan | ◐ | Plan-Liste + Gate-Detail AP1.4b |
--- ---

View File

@ -9,6 +9,7 @@ import { InitiativePlanPage } from './pages/initiative/InitiativePlanPage.jsx'
import { InitiativeExecutionPage } from './pages/initiative/InitiativeExecutionPage.jsx' import { InitiativeExecutionPage } from './pages/initiative/InitiativeExecutionPage.jsx'
import { InitiativeInboxPage } from './pages/initiative/InitiativeInboxPage.jsx' import { InitiativeInboxPage } from './pages/initiative/InitiativeInboxPage.jsx'
import { InitiativeJourneyPage } from './pages/initiative/InitiativeJourneyPage.jsx' import { InitiativeJourneyPage } from './pages/initiative/InitiativeJourneyPage.jsx'
import { RoadmapItemDetailPage } from './pages/initiative/RoadmapItemDetailPage.jsx'
import { VIEWS } from './registry/viewRegistry.js' import { VIEWS } from './registry/viewRegistry.js'
import './app.css' import './app.css'
@ -39,6 +40,7 @@ function AppRoutes() {
<Route path="/initiatives/:id" element={<InitiativeLayout />}> <Route path="/initiatives/:id" element={<InitiativeLayout />}>
<Route index element={<InitiativeOverviewPage />} /> <Route index element={<InitiativeOverviewPage />} />
<Route path="plan" element={<InitiativePlanPage />} /> <Route path="plan" element={<InitiativePlanPage />} />
<Route path="plan/items/:itemId" element={<RoadmapItemDetailPage />} />
<Route path="execution" element={<InitiativeExecutionPage />} /> <Route path="execution" element={<InitiativeExecutionPage />} />
<Route path="inbox" element={<InitiativeInboxPage />} /> <Route path="inbox" element={<InitiativeInboxPage />} />
<Route path="journey" element={<InitiativeJourneyPage />} /> <Route path="journey" element={<InitiativeJourneyPage />} />

View File

@ -28,6 +28,60 @@ export function verifyRoadmapItemReached(itemId) {
}) })
} }
export function reopenRoadmapItem(itemId, body = {}) {
return apiFetch(`/api/roadmap-items/${itemId}/reopen`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function getRoadmapItem(itemId) {
return apiFetch(`/api/roadmap-items/${itemId}`)
}
export function listRoadmapItemCriteria(itemId) {
return apiFetch(`/api/roadmap-items/${itemId}/criteria`)
}
export function createRoadmapItemCriterion(itemId, body) {
return apiFetch(`/api/roadmap-items/${itemId}/criteria`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function updateRoadmapCriterion(criterionId, body) {
return apiFetch(`/api/roadmap-criteria/${criterionId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
export function deleteRoadmapCriterion(criterionId) {
return apiFetch(`/api/roadmap-criteria/${criterionId}`, { method: 'DELETE' })
}
export function satisfyRoadmapCriterion(criterionId, body = {}) {
return apiFetch(`/api/roadmap-criteria/${criterionId}/satisfy`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function waiveRoadmapCriterion(criterionId, body) {
return apiFetch(`/api/roadmap-criteria/${criterionId}/waive`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function deferRoadmapCriterion(criterionId, body) {
return apiFetch(`/api/roadmap-criteria/${criterionId}/defer`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function listRoadmapItemDependencies(itemId) { export function listRoadmapItemDependencies(itemId) {
return apiFetch(`/api/roadmap-items/${itemId}/dependencies`) return apiFetch(`/api/roadmap-items/${itemId}/dependencies`)
} }

View File

@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom'
import { import {
MILESTONE_STATUSES, MILESTONE_STATUSES,
MILESTONE_STATUS_LABELS, MILESTONE_STATUS_LABELS,
@ -26,6 +27,7 @@ function formatDate(value) {
} }
export function RoadmapPlanSection({ export function RoadmapPlanSection({
initiativeId,
items, items,
canManage, canManage,
onCreate, onCreate,
@ -148,7 +150,15 @@ export function RoadmapPlanSection({
{items.map((item) => ( {items.map((item) => (
<li key={item.id} className="list-item card-list-item milestone-list-item"> <li key={item.id} className="list-item card-list-item milestone-list-item">
<div className="list-item-main"> <div className="list-item-main">
<strong>{item.title}</strong> <strong>
{initiativeId ? (
<Link to={`/initiatives/${initiativeId}/plan/items/${item.id}`}>
{item.title}
</Link>
) : (
item.title
)}
</strong>
<p className="muted list-item-sub"> <p className="muted list-item-sub">
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type} {ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
{' · '} {' · '}

View File

@ -72,6 +72,24 @@ export const SEQUENCING_MODE_LABELS = {
optional: 'Optional', optional: 'Optional',
} }
export const CRITERION_STATUSES = ['open', 'satisfied', 'waived', 'deferred', 'failed']
export const CRITERION_KINDS = ['manual', 'evidence_required', 'review_required', 'metric']
export const CRITERION_STATUS_LABELS = {
open: 'Offen',
satisfied: 'Erfüllt',
waived: 'Ausgelassen',
deferred: 'Verschoben',
failed: 'Fehlgeschlagen',
}
export const CRITERION_KIND_LABELS = {
manual: 'Manuell',
evidence_required: 'Nachweis',
review_required: 'Review',
metric: 'Metrik',
}
export const EVIDENCE_STATUS_LABELS = { export const EVIDENCE_STATUS_LABELS = {
submitted: 'Eingereicht', submitted: 'Eingereicht',
accepted: 'Akzeptiert', accepted: 'Akzeptiert',

View File

@ -1,7 +1,9 @@
import { useParams } from 'react-router-dom'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx' import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx'
export function InitiativePlanPage() { export function InitiativePlanPage() {
const { id } = useParams()
const { const {
roadmapItems, roadmapItems,
capabilities, capabilities,
@ -22,6 +24,7 @@ export function InitiativePlanPage() {
<> <>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<RoadmapPlanSection <RoadmapPlanSection
initiativeId={id}
items={roadmapItems} items={roadmapItems}
canManage={capabilities.has('kairo.milestone.manage')} canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateRoadmapItem} onCreate={handleCreateRoadmapItem}

View File

@ -0,0 +1,337 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import {
createInitiativeEvidence,
} from '../../api/evidence.js'
import {
getRoadmapItem,
listRoadmapItemCriteria,
createRoadmapItemCriterion,
deleteRoadmapCriterion,
satisfyRoadmapCriterion,
waiveRoadmapCriterion,
deferRoadmapCriterion,
verifyRoadmapItemReached,
reopenRoadmapItem,
} from '../../api/roadmap.js'
import { ErrorState } from '../../components/ErrorState.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { StatusBadge } from '../../components/StatusBadge.jsx'
import {
CRITERION_KIND_LABELS,
CRITERION_KINDS,
CRITERION_STATUS_LABELS,
MILESTONE_STATUS_LABELS,
ROADMAP_ITEM_TYPE_LABELS,
} from '../../constants/status.js'
import { useCapabilities } from '../../hooks/useCapabilities.js'
function CriterionDecisionForm({ label, onSubmit, busy }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
return (
<form
className="inline-form-block criterion-decision-form"
onSubmit={async (e) => {
e.preventDefault()
if (!title.trim()) return
await onSubmit({
decision_title: title.trim(),
decision_description: description.trim(),
})
setTitle('')
setDescription('')
}}
>
<label>
{label} Begründung (Decision)
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
required
maxLength={255}
placeholder="Kurzbegründung"
/>
</label>
<label>
Details
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</label>
<button type="submit" className="btn btn-secondary" disabled={busy}>
Speichern
</button>
</form>
)
}
export function RoadmapItemDetailPage() {
const { id: initiativeId, itemId } = useParams()
const { capabilities } = useCapabilities()
const canManage = capabilities.has('kairo.milestone.manage')
const canReopen = capabilities.has('kairo.roadmap.reopen')
const [item, setItem] = useState(null)
const [criteria, setCriteria] = useState([])
const [progress, setProgress] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [newKind, setNewKind] = useState('manual')
const [expandedDecision, setExpandedDecision] = useState(null)
const [reopenReason, setReopenReason] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [itemData, criteriaData] = await Promise.all([
getRoadmapItem(itemId),
listRoadmapItemCriteria(itemId),
])
setItem(itemData)
setCriteria(criteriaData.items || [])
setProgress(criteriaData.progress || null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [itemId])
useEffect(() => {
load()
}, [load])
async function runAction(fn) {
setBusy(true)
setError(null)
try {
await fn()
await load()
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
if (loading) return <LoadingState message="Plan-Element wird geladen…" />
if (error && !item) return <ErrorState message={error} onRetry={load} />
if (!item) return <ErrorState message="Plan-Element nicht gefunden." onRetry={load} />
const closedCount = progress?.closed ?? 0
const totalCount = progress?.total ?? criteria.length
return (
<section className="card roadmap-item-detail">
<p className="breadcrumb muted">
<Link to={`/initiatives/${initiativeId}/plan`}>Plan</Link>
{' · '}
{item.title}
</p>
{error && <p className="error">{error}</p>}
<div className="section-header">
<div>
<h2>{item.title}</h2>
<p className="section-lead muted">
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
{' · '}
{MILESTONE_STATUS_LABELS[item.status] || item.status}
</p>
{item.goal_description && <p>{item.goal_description}</p>}
</div>
<StatusBadge kind="milestone" status={item.status} />
</div>
<p className="muted">
Checkliste: {closedCount}/{totalCount} Kriterien abgeschlossen
</p>
<ul className="item-list criterion-list">
{criteria.map((crit) => (
<li key={crit.id} className="list-item card-list-item">
<div className="list-item-main">
<strong>{crit.title}</strong>
<p className="muted list-item-sub">
{CRITERION_KIND_LABELS[crit.criterion_kind] || crit.criterion_kind}
</p>
{crit.description && <p className="list-item-desc">{crit.description}</p>}
</div>
<div className="list-item-meta action-controls">
<span className="status-pill">
{CRITERION_STATUS_LABELS[crit.status] || crit.status}
</span>
{canManage && crit.status === 'open' && (
<>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => runAction(() => satisfyRoadmapCriterion(crit.id))}
>
Erfüllt
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
setExpandedDecision(
expandedDecision === `${crit.id}:waive` ? null : `${crit.id}:waive`
)
}
>
Auslassen
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
setExpandedDecision(
expandedDecision === `${crit.id}:defer` ? null : `${crit.id}:defer`
)
}
>
Verschieben
</button>
</>
)}
{canManage && criteria.length > 1 && (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => runAction(() => deleteRoadmapCriterion(crit.id))}
>
Entfernen
</button>
)}
</div>
{expandedDecision === `${crit.id}:waive` && (
<CriterionDecisionForm
label="Auslassen"
busy={busy}
onSubmit={(body) =>
runAction(async () => {
await waiveRoadmapCriterion(crit.id, body)
setExpandedDecision(null)
})
}
/>
)}
{expandedDecision === `${crit.id}:defer` && (
<CriterionDecisionForm
label="Verschieben"
busy={busy}
onSubmit={(body) =>
runAction(async () => {
await deferRoadmapCriterion(crit.id, body)
setExpandedDecision(null)
})
}
/>
)}
</li>
))}
</ul>
{canManage && (
<form
className="inline-form-block"
onSubmit={(e) => {
e.preventDefault()
if (!newTitle.trim()) return
runAction(async () => {
await createRoadmapItemCriterion(itemId, {
title: newTitle.trim(),
criterion_kind: newKind,
})
setNewTitle('')
setNewKind('manual')
})
}}
>
<h3>Kriterium hinzufügen</h3>
<label>
Titel
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
maxLength={500}
required
/>
</label>
<label>
Art
<select value={newKind} onChange={(e) => setNewKind(e.target.value)}>
{CRITERION_KINDS.map((k) => (
<option key={k} value={k}>
{CRITERION_KIND_LABELS[k]}
</option>
))}
</select>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Hinzufügen
</button>
</form>
)}
<div className="gate-detail-actions">
{canManage && ['planned', 'active', 'at_risk'].includes(item.status) && (
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() =>
runAction(async () => {
await createInitiativeEvidence(initiativeId, {
title: `Nachweis: ${item.title}`,
roadmap_item_id: itemId,
status: 'accepted',
})
await verifyRoadmapItemReached(itemId)
})
}
>
Gate schließen (Verify)
</button>
)}
{canReopen && item.status === 'reached' && (
<div className="reopen-block">
<label>
Reopen Begründung (optional)
<input
value={reopenReason}
onChange={(e) => setReopenReason(e.target.value)}
placeholder="Versehentlich geschlossen"
/>
</label>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runAction(async () => {
await reopenRoadmapItem(itemId, { reason: reopenReason.trim() })
setReopenReason('')
})
}
>
Gate wieder öffnen
</button>
</div>
)}
</div>
</section>
)
}