From 9bf6ccee361ecf933ba3c976e98d7bb17a3ca940 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 6 Jul 2026 06:53:49 +0200 Subject: [PATCH] AP1.4b: Gate-Checkliste, Verify, Reopen und Plan-Detail-UI Kriterien mit waive/defer per Decision; Verify gegen Checkliste; Reopen active mit Audit; Schema 012. Co-authored-by: Cursor --- backend/main.py | 1 + .../migrations/012_roadmap_item_criteria.sql | 76 ++ .../operating_model_ops.py | 18 + backend/routers/roadmap.py | 183 +++++ backend/services/roadmap.py | 117 +++- backend/services/roadmap_criteria.py | 652 ++++++++++++++++++ backend/tests/test_ap14_roadmap.py | 2 +- backend/tests/test_ap14b_gate_checklist.py | 150 ++++ backend/tests/test_rights_registry.py | 4 +- backend/version.py | 4 +- .../Kairo_Implementation_Truth_Table_v0.1.md | 28 +- frontend/src/App.jsx | 2 + frontend/src/api/roadmap.js | 54 ++ .../src/components/RoadmapPlanSection.jsx | 12 +- frontend/src/constants/status.js | 18 + .../pages/initiative/InitiativePlanPage.jsx | 3 + .../initiative/RoadmapItemDetailPage.jsx | 337 +++++++++ 17 files changed, 1607 insertions(+), 54 deletions(-) create mode 100644 backend/migrations/012_roadmap_item_criteria.sql create mode 100644 backend/services/roadmap_criteria.py create mode 100644 backend/tests/test_ap14b_gate_checklist.py create mode 100644 frontend/src/pages/initiative/RoadmapItemDetailPage.jsx diff --git a/backend/main.py b/backend/main.py index a406bb7..b60b845 100644 --- a/backend/main.py +++ b/backend/main.py @@ -88,6 +88,7 @@ app.include_router(backlog.router) app.include_router(milestones.router) app.include_router(roadmap.initiative_router) app.include_router(roadmap.items_router) +app.include_router(roadmap.criteria_router) app.include_router(evidence.router) app.include_router(decisions.router) app.include_router(reviews.router) diff --git a/backend/migrations/012_roadmap_item_criteria.sql b/backend/migrations/012_roadmap_item_criteria.sql new file mode 100644 index 0000000..ab3edc0 --- /dev/null +++ b/backend/migrations/012_roadmap_item_criteria.sql @@ -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) + ); diff --git a/backend/rights_registrations/operating_model_ops.py b/backend/rights_registrations/operating_model_ops.py index 0fbcc4d..5320c6a 100644 --- a/backend/rights_registrations/operating_model_ops.py +++ b/backend/rights_registrations/operating_model_ops.py @@ -153,3 +153,21 @@ register_capability( 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"),), + ) +) diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py index 015044c..8065d51 100644 --- a/backend/routers/roadmap.py +++ b/backend/routers/roadmap.py @@ -9,6 +9,7 @@ from capabilities import require_capability from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from services import roadmap as roadmap_service +from services import roadmap_criteria as criteria_service from tenant_context import TenantContext router = APIRouter(tags=["roadmap"]) @@ -46,6 +47,38 @@ class DependencyCreateRequest(BaseModel): 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"]) 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 +@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") def list_roadmap_item_dependencies( item_id: str, diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py index 4deb4a1..a74b1d8 100644 --- a/backend/services/roadmap.py +++ b/backend/services/roadmap.py @@ -11,6 +11,7 @@ from psycopg2.extras import RealDictCursor from db import get_connection from services.audit import log_audit 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"] RoadmapItemStatus = Literal[ @@ -275,6 +276,12 @@ def create_roadmap_item( ) row = _serialize_row(dict(cur.fetchone())) 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( cur, tenant_id=tenant_id, @@ -533,7 +540,7 @@ def add_dependency( def verify_reached( *, tenant_id: str, item_id: str, user_id: Optional[str] = None ) -> 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) if not item: raise ValueError("RoadmapItem nicht gefunden") @@ -544,51 +551,35 @@ def verify_reached( conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: - cur.execute( - """ - SELECT 1 FROM evidence - WHERE tenant_id = %s AND initiative_id = %s - AND status = 'accepted' - AND (roadmap_item_id = %s OR milestone_id = %s) - LIMIT 1 - """, - (tenant_id, initiative_id, item_id, item_id), + criteria_ok, criteria_reason = prepare_criteria_for_verify( + cur, + tenant_id=tenant_id, + initiative_id=initiative_id, + item_id=item_id, ) - if cur.fetchone(): - verify_reason = "evidence_accepted" - else: + verify_reason = "criteria_ready" + + if not criteria_ok: cur.execute( """ - SELECT 1 FROM reviews + SELECT 1 FROM decisions WHERE tenant_id = %s AND initiative_id = %s - AND status = 'completed' - AND (roadmap_item_id = %s OR milestone_id = %s) + AND status = 'decided' + AND title ILIKE %s LIMIT 1 """, - (tenant_id, initiative_id, item_id, item_id), + (tenant_id, initiative_id, f"gate_override:{item_id}%"), ) if cur.fetchone(): - verify_reason = "review_completed" + verify_reason = "gate_override_decision" + criteria_ok = True else: - cur.execute( - """ - SELECT 1 FROM decisions - 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}%"), + raise ValueError( + f"Verify fehlgeschlagen — {criteria_reason}. " + "Kriterien in der Checkliste prüfen oder Nachweis am Plan-Element einreichen." ) - if cur.fetchone(): - verify_reason = "gate_override_decision" - 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." - ) + elif criteria_reason != "no_criteria": + verify_reason = criteria_reason cur.execute( """ @@ -621,3 +612,57 @@ def verify_reached( details={"roadmap_item_id": item_id, "verify_reason": verify_reason}, ) 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 diff --git a/backend/services/roadmap_criteria.py b/backend/services/roadmap_criteria.py new file mode 100644 index 0000000..beed977 --- /dev/null +++ b/backend/services/roadmap_criteria.py @@ -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" diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py index 0a5b2c4..8f17d92 100644 --- a/backend/tests/test_ap14_roadmap.py +++ b/backend/tests/test_ap14_roadmap.py @@ -84,7 +84,7 @@ def test_verify_reached_requires_evidence(client): ) assert ok.status_code == 200 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): diff --git a/backend/tests/test_ap14b_gate_checklist.py b/backend/tests/test_ap14b_gate_checklist.py new file mode 100644 index 0000000..9490d5e --- /dev/null +++ b/backend/tests/test_ap14b_gate_checklist.py @@ -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)" diff --git a/backend/tests/test_rights_registry.py b/backend/tests/test_rights_registry.py index b5a8ca7..706929f 100644 --- a/backend/tests/test_rights_registry.py +++ b/backend/tests/test_rights_registry.py @@ -43,6 +43,8 @@ def test_registry_contains_initial_capabilities(): "kairo.review.manage", "kairo.recurring.read", "kairo.recurring.manage", + "kairo.roadmap.reopen", + "kairo.roadmap.reopen.emergency", } @@ -54,7 +56,7 @@ def test_sync_is_idempotent(): try: with conn.cursor() as cur: cur.execute("SELECT COUNT(*) FROM capabilities") - assert cur.fetchone()[0] == 33 + assert cur.fetchone()[0] == 35 cur.execute("SELECT COUNT(*) FROM role_capability_grants") assert cur.fetchone()[0] >= 5 finally: diff --git a/backend/version.py b/backend/version.py index 0c95b54..0f8fd6e 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.13.0-ap1.4" -DB_SCHEMA_VERSION = "011" +APP_VERSION = "0.13.1-ap1.4b" +DB_SCHEMA_VERSION = "012" APP_NAME = "jinkendo-kairo" diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 42db788..a63eb3f 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -1,7 +1,7 @@ # Kairo — Implementation Truth Table v0.1 **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** 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 | | Auth / Session | ✓ | | | 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 | ✓ | | | Task (unter Action) | ✗ | | | 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 | -| 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 | | Action Detail-Route | ✓ | `/initiatives/:id/actions/:actionId` | | Action Detail-Seite | ✗ | | -| Gate Detail-Seite | ✗ | | +| Gate Detail-Seite | ◐ | `/initiatives/:id/plan/items/:itemId` AP1.4b | | Modal-Bearbeitung | ✗ | Inline-Formulare überall | | Admin-UI | ✗ | | @@ -98,13 +99,14 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Gate-Anforderung (Vision) | Ist | |---------------------------|-----| -| DoD / prüfbare Kriterien | ◐ `goal_description` Freitext, kein Kriterienmodell | -| Zieltermin | ◐ `target_date` | -| Abhängigkeiten | ✗ | -| parallel vs. sequenziell | ✗ | -| Verify vor `reached` | ✗ Status-Dropdown | -| Decision bei `moved` | ✗ | -| Quality-Gate-Semantik | ✗ | +| DoD / prüfbare Kriterien | ◐ | `roadmap_item_criteria`; Default „Gate allgemein“ | +| Zieltermin | ◐ | `target_date` | +| Abhängigkeiten | ◐ | API; Graph Engine AP1.4d | +| parallel vs. sequenziell | ◐ | `sequencing_mode` (deprecated); Graph AP1.4d | +| Verify vor `reached` | ◐ | Kriterienplan + gate_override | +| Reopen nach `reached` | ◐ | `active`, Kriterien bleiben AP1.4b | +| Decision bei `moved` | ✗ | | +| Quality-Gate-Semantik | ◐ | Checkliste + Audit | --- @@ -115,7 +117,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Arbeitspaket | Maßnahme | | Quality Gate | Meilenstein (CRUD) | | Programm | Vorhaben | -| Plan | (nicht sichtbar) | +| Plan | ◐ | Plan-Liste + Gate-Detail AP1.4b | --- diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9609575..7eb2bb6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import { InitiativePlanPage } from './pages/initiative/InitiativePlanPage.jsx' import { InitiativeExecutionPage } from './pages/initiative/InitiativeExecutionPage.jsx' import { InitiativeInboxPage } from './pages/initiative/InitiativeInboxPage.jsx' import { InitiativeJourneyPage } from './pages/initiative/InitiativeJourneyPage.jsx' +import { RoadmapItemDetailPage } from './pages/initiative/RoadmapItemDetailPage.jsx' import { VIEWS } from './registry/viewRegistry.js' import './app.css' @@ -39,6 +40,7 @@ function AppRoutes() { }> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/roadmap.js b/frontend/src/api/roadmap.js index 3226c5e..40cabf3 100644 --- a/frontend/src/api/roadmap.js +++ b/frontend/src/api/roadmap.js @@ -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) { return apiFetch(`/api/roadmap-items/${itemId}/dependencies`) } diff --git a/frontend/src/components/RoadmapPlanSection.jsx b/frontend/src/components/RoadmapPlanSection.jsx index 2fe1f7f..9aa9151 100644 --- a/frontend/src/components/RoadmapPlanSection.jsx +++ b/frontend/src/components/RoadmapPlanSection.jsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { Link } from 'react-router-dom' import { MILESTONE_STATUSES, MILESTONE_STATUS_LABELS, @@ -26,6 +27,7 @@ function formatDate(value) { } export function RoadmapPlanSection({ + initiativeId, items, canManage, onCreate, @@ -148,7 +150,15 @@ export function RoadmapPlanSection({ {items.map((item) => (
  • - {item.title} + + {initiativeId ? ( + + {item.title} + + ) : ( + item.title + )} +

    {ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type} {' · '} diff --git a/frontend/src/constants/status.js b/frontend/src/constants/status.js index 98e7d44..a4acbf9 100644 --- a/frontend/src/constants/status.js +++ b/frontend/src/constants/status.js @@ -72,6 +72,24 @@ export const SEQUENCING_MODE_LABELS = { 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 = { submitted: 'Eingereicht', accepted: 'Akzeptiert', diff --git a/frontend/src/pages/initiative/InitiativePlanPage.jsx b/frontend/src/pages/initiative/InitiativePlanPage.jsx index bc58308..d0a177b 100644 --- a/frontend/src/pages/initiative/InitiativePlanPage.jsx +++ b/frontend/src/pages/initiative/InitiativePlanPage.jsx @@ -1,7 +1,9 @@ +import { useParams } from 'react-router-dom' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx' export function InitiativePlanPage() { + const { id } = useParams() const { roadmapItems, capabilities, @@ -22,6 +24,7 @@ export function InitiativePlanPage() { <> {error &&

    {error}

    } { + e.preventDefault() + if (!title.trim()) return + await onSubmit({ + decision_title: title.trim(), + decision_description: description.trim(), + }) + setTitle('') + setDescription('') + }} + > + +