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
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:
parent
e70e3196ec
commit
9bf6ccee36
|
|
@ -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)
|
||||||
|
|
|
||||||
76
backend/migrations/012_roadmap_item_criteria.sql
Normal file
76
backend/migrations/012_roadmap_item_criteria.sql
Normal 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)
|
||||||
|
);
|
||||||
|
|
@ -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"),),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,51 +551,35 @@ 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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT 1 FROM reviews
|
SELECT 1 FROM decisions
|
||||||
WHERE tenant_id = %s AND initiative_id = %s
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
AND status = 'completed'
|
AND status = 'decided'
|
||||||
AND (roadmap_item_id = %s OR milestone_id = %s)
|
AND title ILIKE %s
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(tenant_id, initiative_id, item_id, item_id),
|
(tenant_id, initiative_id, f"gate_override:{item_id}%"),
|
||||||
)
|
)
|
||||||
if cur.fetchone():
|
if cur.fetchone():
|
||||||
verify_reason = "review_completed"
|
verify_reason = "gate_override_decision"
|
||||||
|
criteria_ok = True
|
||||||
else:
|
else:
|
||||||
cur.execute(
|
raise ValueError(
|
||||||
"""
|
f"Verify fehlgeschlagen — {criteria_reason}. "
|
||||||
SELECT 1 FROM decisions
|
"Kriterien in der Checkliste prüfen oder Nachweis am Plan-Element einreichen."
|
||||||
WHERE tenant_id = %s AND initiative_id = %s
|
|
||||||
AND status = 'decided'
|
|
||||||
AND title ILIKE %s
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(tenant_id, initiative_id, f"gate_override:{item_id}%"),
|
|
||||||
)
|
)
|
||||||
if cur.fetchone():
|
elif criteria_reason != "no_criteria":
|
||||||
verify_reason = "gate_override_decision"
|
verify_reason = criteria_reason
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
"Verify fehlgeschlagen — am Plan-Element fehlt ein akzeptierter "
|
|
||||||
"Nachweis (Evidence), ein abgeschlossenes Review oder eine "
|
|
||||||
"gate_override-Decision. Nachweis unter Plan einreichen oder auf "
|
|
||||||
"der Journey-Seite mit Plan-Element verknüpfen und auf Akzeptiert setzen."
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
652
backend/services/roadmap_criteria.py
Normal file
652
backend/services/roadmap_criteria.py
Normal 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"
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
150
backend/tests/test_ap14b_gate_checklist.py
Normal file
150
backend/tests/test_ap14b_gate_checklist.py
Normal 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)"
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
{' · '}
|
{' · '}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
337
frontend/src/pages/initiative/RoadmapItemDetailPage.jsx
Normal file
337
frontend/src/pages/initiative/RoadmapItemDetailPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user