From 064779e18721f4f4c024ea633e8d5f4747455308 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Jul 2026 08:10:15 +0200 Subject: [PATCH] AP1.14: Plan-Snapshot und Plan/Ist-Overlay in Kontrolle. Revisionen mit Audit, getrennte Soll/Ist/Diff-Ansicht unter Kontrolle/Plan-Ist; API plan-ist-view und plan-snapshots. Co-authored-by: Cursor --- .../migrations/021_roadmap_plan_snapshots.sql | 17 ++ backend/routers/roadmap.py | 48 +++ backend/services/roadmap_plan_snapshot.py | 203 +++++++++++++ backend/steering/graph/plan_ist.py | 283 ++++++++++++++++++ backend/tests/test_ap14_plan_ist.py | 34 +++ backend/tests/test_ap14_roadmap.py | 48 +++ frontend/src/App.jsx | 1 + frontend/src/api/roadmap.js | 15 + frontend/src/components/PlanIstPanel.jsx | 272 +++++++++++++++++ frontend/src/config/controlNav.js | 1 + frontend/src/config/modeNav.test.js | 1 + .../src/pages/modes/ControlPlanIstPage.jsx | 24 ++ frontend/src/registry/viewRegistry.js | 2 + frontend/src/styles/components.css | 38 +++ 14 files changed, 987 insertions(+) create mode 100644 backend/migrations/021_roadmap_plan_snapshots.sql create mode 100644 backend/services/roadmap_plan_snapshot.py create mode 100644 backend/steering/graph/plan_ist.py create mode 100644 backend/tests/test_ap14_plan_ist.py create mode 100644 frontend/src/components/PlanIstPanel.jsx create mode 100644 frontend/src/pages/modes/ControlPlanIstPage.jsx diff --git a/backend/migrations/021_roadmap_plan_snapshots.sql b/backend/migrations/021_roadmap_plan_snapshots.sql new file mode 100644 index 0000000..91fff53 --- /dev/null +++ b/backend/migrations/021_roadmap_plan_snapshots.sql @@ -0,0 +1,17 @@ +-- AP1.14: Plan-Snapshots (Plan-Revision) — Soll-Zustand getrennt von Ist + +CREATE TABLE roadmap_plan_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + roadmap_id UUID NOT NULL REFERENCES roadmaps(id) ON DELETE CASCADE, + revision_number INT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + plan_payload JSONB NOT NULL, + created_by_user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, initiative_id, revision_number) +); + +CREATE INDEX idx_roadmap_plan_snapshots_initiative + ON roadmap_plan_snapshots(tenant_id, initiative_id, created_at DESC); diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py index d9d249f..83e7020 100644 --- a/backend/routers/roadmap.py +++ b/backend/routers/roadmap.py @@ -10,6 +10,7 @@ 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 services import roadmap_plan_snapshot as snapshot_service from tenant_context import TenantContext router = APIRouter(tags=["roadmap"]) @@ -83,6 +84,10 @@ class ReopenRequest(BaseModel): reason: str = "" +class PlanSnapshotCreateRequest(BaseModel): + reason: str = Field(default="", max_length=2000) + + initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"]) items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"]) deps_router = APIRouter(prefix="/api/roadmap-dependencies", tags=["roadmap"]) @@ -161,6 +166,49 @@ def get_initiative_roadmap_graph_state( raise HTTPException(status_code=400, detail=str(exc)) from exc +@initiative_router.get("/{initiative_id}/roadmap/plan-ist-view") +def get_initiative_plan_ist_view( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + try: + from steering.graph.plan_ist import load_plan_ist_view + + return load_plan_ist_view(tenant_id=ctx.tenant_id, initiative_id=initiative_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@initiative_router.get("/{initiative_id}/roadmap/plan-snapshots") +def list_initiative_plan_snapshots( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + try: + return snapshot_service.list_plan_snapshots( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@initiative_router.post("/{initiative_id}/roadmap/plan-snapshots", status_code=201) +def create_initiative_plan_snapshot( + initiative_id: str, + body: PlanSnapshotCreateRequest, + ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), +): + try: + return snapshot_service.create_plan_snapshot( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + reason=body.reason, + user_id=ctx.user_id, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @initiative_router.post("/{initiative_id}/roadmap/items", status_code=201) def create_initiative_roadmap_item( initiative_id: str, diff --git a/backend/services/roadmap_plan_snapshot.py b/backend/services/roadmap_plan_snapshot.py new file mode 100644 index 0000000..cdae252 --- /dev/null +++ b/backend/services/roadmap_plan_snapshot.py @@ -0,0 +1,203 @@ +"""Plan snapshot capture & persistence (AP1.14).""" + +from __future__ import annotations + +import json +from typing import Any, Optional + +from psycopg2.extras import RealDictCursor, Json + +from db import get_connection +from services.audit import log_audit +from services.initiatives import get_initiative +from services import roadmap as roadmap_service +from services import roadmap_criteria as criteria_service + + +def _serialize_snapshot(row: dict[str, Any]) -> dict[str, Any]: + result = dict(row) + for key in ("id", "tenant_id", "initiative_id", "roadmap_id", "created_by_user_id"): + if result.get(key): + result[key] = str(result[key]) + if result.get("created_at"): + result["created_at"] = result["created_at"].isoformat() + payload = result.get("plan_payload") + if isinstance(payload, str): + result["plan_payload"] = json.loads(payload) + return result + + +def capture_plan_payload( + *, tenant_id: str, initiative_id: str +) -> dict[str, Any]: + """Soll-Struktur: Topologie + Kriterien-Definitionen ohne Ist-Status.""" + items = roadmap_service.list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + dependencies = roadmap_service.list_dependencies_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + + plan_items = [] + for item in items: + plan_items.append( + { + "id": item["id"], + "title": item["title"], + "item_type": item["item_type"], + "goal_description": item.get("goal_description") or "", + "sort_order": item.get("sort_order", 0), + "target_date": item.get("target_date"), + "sequencing_mode": item.get("sequencing_mode", "sequential"), + } + ) + + plan_deps = [] + for dep in dependencies: + plan_deps.append( + { + "from_item_id": dep["from_item_id"], + "to_item_id": dep["to_item_id"], + "dependency_type": dep["dependency_type"], + "group_key": dep.get("group_key"), + } + ) + + plan_criteria = [] + for item in items: + for crit in criteria_service.list_criteria_for_item( + tenant_id=tenant_id, item_id=item["id"] + ): + plan_criteria.append( + { + "id": crit["id"], + "roadmap_item_id": crit["roadmap_item_id"], + "title": crit["title"], + "description": crit.get("description") or "", + "criterion_kind": crit["criterion_kind"], + "sort_order": crit.get("sort_order", 0), + } + ) + + return { + "items": plan_items, + "dependencies": plan_deps, + "criteria": plan_criteria, + } + + +def list_plan_snapshots( + *, tenant_id: str, initiative_id: str, limit: int = 20 +) -> list[dict[str, Any]]: + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, initiative_id, roadmap_id, revision_number, + reason, plan_payload, created_by_user_id, created_at + FROM roadmap_plan_snapshots + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY revision_number DESC + LIMIT %s + """, + (tenant_id, initiative_id, limit), + ) + return [_serialize_snapshot(dict(r)) for r in cur.fetchall()] + finally: + conn.close() + + +def get_plan_snapshot( + *, tenant_id: str, snapshot_id: str +) -> Optional[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, initiative_id, roadmap_id, revision_number, + reason, plan_payload, created_by_user_id, created_at + FROM roadmap_plan_snapshots + WHERE id = %s AND tenant_id = %s + """, + (snapshot_id, tenant_id), + ) + row = cur.fetchone() + return _serialize_snapshot(dict(row)) if row else None + finally: + conn.close() + + +def create_plan_snapshot( + *, + tenant_id: str, + initiative_id: str, + reason: str = "", + user_id: Optional[str] = None, +) -> dict[str, Any]: + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + roadmap = roadmap_service.get_roadmap_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + if not roadmap: + raise ValueError("Roadmap nicht gefunden") + + plan_payload = capture_plan_payload(tenant_id=tenant_id, initiative_id=initiative_id) + reason = reason.strip() + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT COALESCE(MAX(revision_number), 0) + 1 AS next_rev + FROM roadmap_plan_snapshots + WHERE tenant_id = %s AND initiative_id = %s + """, + (tenant_id, initiative_id), + ) + revision_number = int(cur.fetchone()["next_rev"]) + + cur.execute( + """ + INSERT INTO roadmap_plan_snapshots ( + tenant_id, initiative_id, roadmap_id, revision_number, + reason, plan_payload, created_by_user_id + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id, tenant_id, initiative_id, roadmap_id, revision_number, + reason, plan_payload, created_by_user_id, created_at + """, + ( + tenant_id, + initiative_id, + roadmap["id"], + revision_number, + reason, + Json(plan_payload), + user_id, + ), + ) + row = _serialize_snapshot(dict(cur.fetchone())) + conn.commit() + finally: + conn.close() + + log_audit( + "roadmap_plan_snapshot.created", + user_id=user_id, + tenant_id=tenant_id, + details={ + "snapshot_id": row["id"], + "initiative_id": initiative_id, + "revision_number": revision_number, + "reason": reason or None, + }, + ) + return row diff --git a/backend/steering/graph/plan_ist.py b/backend/steering/graph/plan_ist.py new file mode 100644 index 0000000..dceb497 --- /dev/null +++ b/backend/steering/graph/plan_ist.py @@ -0,0 +1,283 @@ +"""Plan vs. Ist overlay and diff read models (AP1.14).""" + +from __future__ import annotations + +from typing import Any, Optional + +TERMINAL_IST_STATUSES = frozenset({"reached", "moved", "discarded"}) +IST_DEVIATION_CRITERION_STATUSES = frozenset({"waived", "deferred", "failed"}) + + +def _item_def_key(item: dict[str, Any]) -> str: + return str(item["id"]) + + +def _dep_key(dep: dict[str, Any]) -> str: + return ( + f"{dep['from_item_id']}:{dep['to_item_id']}:" + f"{dep.get('dependency_type', 'requires')}" + ) + + +def _crit_def_key(crit: dict[str, Any]) -> str: + return str(crit["id"]) + + +def _structural_diff( + baseline: dict[str, Any], current: dict[str, Any] +) -> list[dict[str, Any]]: + diffs: list[dict[str, Any]] = [] + + baseline_items = {_item_def_key(i): i for i in baseline.get("items", [])} + current_items = {_item_def_key(i): i for i in current.get("items", [])} + + for item_id, item in current_items.items(): + if item_id not in baseline_items: + diffs.append( + { + "kind": "gate_added", + "item_id": item_id, + "title": item.get("title"), + "message": "Gate im Plan neu seit letzter Revision", + } + ) + else: + base = baseline_items[item_id] + if ( + base.get("title") != item.get("title") + or base.get("goal_description") != item.get("goal_description") + or base.get("sort_order") != item.get("sort_order") + ): + diffs.append( + { + "kind": "gate_modified", + "item_id": item_id, + "title": item.get("title"), + "message": "Gate-Definition geändert seit Revision", + } + ) + + for item_id, item in baseline_items.items(): + if item_id not in current_items: + diffs.append( + { + "kind": "gate_removed", + "item_id": item_id, + "title": item.get("title"), + "message": "Gate aus Plan entfernt seit Revision", + } + ) + + baseline_deps = {_dep_key(d): d for d in baseline.get("dependencies", [])} + current_deps = {_dep_key(d): d for d in current.get("dependencies", [])} + for key, dep in current_deps.items(): + if key not in baseline_deps: + diffs.append( + { + "kind": "edge_added", + "item_id": dep["from_item_id"], + "related_item_id": dep["to_item_id"], + "message": "Neue Kante seit Revision", + } + ) + for key, dep in baseline_deps.items(): + if key not in current_deps: + diffs.append( + { + "kind": "edge_removed", + "item_id": dep["from_item_id"], + "related_item_id": dep["to_item_id"], + "message": "Kante entfernt seit Revision", + } + ) + + baseline_crit = {_crit_def_key(c): c for c in baseline.get("criteria", [])} + current_crit = {_crit_def_key(c): c for c in current.get("criteria", [])} + for crit_id, crit in current_crit.items(): + if crit_id not in baseline_crit: + diffs.append( + { + "kind": "criterion_added", + "item_id": crit["roadmap_item_id"], + "criterion_id": crit_id, + "title": crit.get("title"), + "message": "Kriterium neu seit Revision", + } + ) + elif baseline_crit[crit_id].get("title") != crit.get("title"): + diffs.append( + { + "kind": "criterion_modified", + "item_id": crit["roadmap_item_id"], + "criterion_id": crit_id, + "title": crit.get("title"), + "message": "Kriterium geändert seit Revision", + } + ) + for crit_id, crit in baseline_crit.items(): + if crit_id not in current_crit: + diffs.append( + { + "kind": "criterion_removed", + "item_id": crit["roadmap_item_id"], + "criterion_id": crit_id, + "title": crit.get("title"), + "message": "Kriterium entfernt seit Revision", + } + ) + + return diffs + + +def _ist_deviation_diff( + *, + items: list[dict[str, Any]], + criteria_by_item: dict[str, list[dict[str, Any]]], +) -> list[dict[str, Any]]: + diffs: list[dict[str, Any]] = [] + + for item in items: + item_id = str(item["id"]) + status = item.get("status", "planned") + if status in TERMINAL_IST_STATUSES: + diffs.append( + { + "kind": "ist_gate_status", + "item_id": item_id, + "title": item.get("title"), + "ist_status": status, + "message": f"Ist-Status: {status}", + } + ) + elif status == "at_risk": + diffs.append( + { + "kind": "ist_gate_at_risk", + "item_id": item_id, + "title": item.get("title"), + "ist_status": status, + "message": "Gate als gefährdet markiert", + } + ) + + for crit in criteria_by_item.get(item_id, []): + crit_status = crit.get("status", "open") + if crit_status in IST_DEVIATION_CRITERION_STATUSES: + diffs.append( + { + "kind": f"ist_criterion_{crit_status}", + "item_id": item_id, + "criterion_id": crit["id"], + "title": crit.get("title"), + "ist_status": crit_status, + "message": f"Kriterium {crit_status}: {crit.get('title')}", + } + ) + + return diffs + + +def build_plan_ist_view( + *, + tenant_id: str, + initiative_id: str, + snapshot: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + from services import roadmap as roadmap_service + from services import roadmap_criteria as criteria_service + from services.roadmap_plan_snapshot import capture_plan_payload + from steering.graph.roadmap_engine import load_initiative_graph_state + + live_items = roadmap_service.list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + current_plan = capture_plan_payload(tenant_id=tenant_id, initiative_id=initiative_id) + graph_state = load_initiative_graph_state( + tenant_id=tenant_id, initiative_id=initiative_id + ) + + baseline_plan = snapshot["plan_payload"] if snapshot else current_plan + plan_source = "snapshot" if snapshot else "live" + + criteria_by_item: dict[str, list[dict[str, Any]]] = {} + ist_items = [] + for item in live_items: + item_id = str(item["id"]) + criteria = criteria_service.list_criteria_for_item( + tenant_id=tenant_id, item_id=item_id + ) + criteria_by_item[item_id] = criteria + graph_item = graph_state.get("items", {}).get(item_id, {}) + ist_items.append( + { + "id": item_id, + "title": item["title"], + "status": item["status"], + "fulfillment_ratio": graph_item.get("fulfillment_ratio"), + "blocked": graph_item.get("blocked"), + "ready": graph_item.get("ready"), + "criteria": [ + { + "id": c["id"], + "title": c["title"], + "status": c["status"], + "criterion_kind": c["criterion_kind"], + } + for c in criteria + ], + } + ) + + diff: list[dict[str, Any]] = [] + if snapshot: + diff.extend(_structural_diff(baseline_plan, current_plan)) + diff.extend( + _ist_deviation_diff(items=live_items, criteria_by_item=criteria_by_item) + ) + + latest_meta = None + if snapshot: + latest_meta = { + "id": snapshot["id"], + "revision_number": snapshot["revision_number"], + "reason": snapshot.get("reason") or "", + "created_at": snapshot.get("created_at"), + } + + return { + "initiative_id": initiative_id, + "plan_source": plan_source, + "latest_snapshot": latest_meta, + "plan": baseline_plan, + "current_plan": current_plan if snapshot else None, + "ist": { + "items": ist_items, + "graph_state": { + "method_key": graph_state.get("method_key"), + "graph_profile": graph_state.get("graph_profile"), + "initiative_fulfillment_ratio": graph_state.get( + "initiative_fulfillment_ratio" + ), + }, + }, + "diff": diff, + "diff_count": len(diff), + } + + +def load_plan_ist_view(*, tenant_id: str, initiative_id: str) -> dict[str, Any]: + from services.initiatives import get_initiative + from services.roadmap_plan_snapshot import list_plan_snapshots + + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + snapshots = list_plan_snapshots( + tenant_id=tenant_id, initiative_id=initiative_id, limit=1 + ) + snapshot = snapshots[0] if snapshots else None + return build_plan_ist_view( + tenant_id=tenant_id, + initiative_id=initiative_id, + snapshot=snapshot, + ) diff --git a/backend/tests/test_ap14_plan_ist.py b/backend/tests/test_ap14_plan_ist.py new file mode 100644 index 0000000..734145d --- /dev/null +++ b/backend/tests/test_ap14_plan_ist.py @@ -0,0 +1,34 @@ +"""Unit tests for plan/ist overlay (AP1.14).""" + +from steering.graph.plan_ist import _ist_deviation_diff, _structural_diff + + +def test_structural_diff_detects_added_gate(): + baseline = {"items": [{"id": "a", "title": "A", "goal_description": "", "sort_order": 0}], "dependencies": [], "criteria": []} + current = { + "items": [ + {"id": "a", "title": "A", "goal_description": "", "sort_order": 0}, + {"id": "b", "title": "B", "goal_description": "", "sort_order": 1}, + ], + "dependencies": [], + "criteria": [], + } + diffs = _structural_diff(baseline, current) + assert any(d["kind"] == "gate_added" and d["item_id"] == "b" for d in diffs) + + +def test_ist_deviation_detects_reached_gate(): + items = [{"id": "g1", "title": "Gate", "status": "reached"}] + diffs = _ist_deviation_diff(items=items, criteria_by_item={"g1": []}) + assert any(d["kind"] == "ist_gate_status" for d in diffs) + + +def test_ist_deviation_detects_waived_criterion(): + items = [{"id": "g1", "title": "Gate", "status": "active"}] + criteria = { + "g1": [ + {"id": "c1", "title": "Kriterium", "status": "waived", "criterion_kind": "manual"} + ] + } + diffs = _ist_deviation_diff(items=items, criteria_by_item=criteria) + assert any(d["kind"] == "ist_criterion_waived" for d in diffs) diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py index 59d0cd8..b43f046 100644 --- a/backend/tests/test_ap14_roadmap.py +++ b/backend/tests/test_ap14_roadmap.py @@ -269,3 +269,51 @@ def test_graph_blocked_attention_for_gate_enforcing_method(client): i["kind"] == "gate_graph_blocked" and i["milestone_id"] == gate_a["id"] for i in attention.json() ) + + +def test_plan_ist_view_and_snapshot(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + gate = _create_roadmap_item(client, token, initiative_id, title="Gate Plan").json() + + view = client.get( + f"/api/initiatives/{initiative_id}/roadmap/plan-ist-view", + headers=_auth(token), + ) + assert view.status_code == 200 + body = view.json() + assert body["plan_source"] == "live" + assert len(body["plan"]["items"]) >= 1 + assert body["latest_snapshot"] is None + + snap = client.post( + f"/api/initiatives/{initiative_id}/roadmap/plan-snapshots", + json={"reason": "Baseline vor Umsetzung"}, + headers=_auth(token), + ) + assert snap.status_code == 201 + assert snap.json()["revision_number"] == 1 + + view2 = client.get( + f"/api/initiatives/{initiative_id}/roadmap/plan-ist-view", + headers=_auth(token), + ) + assert view2.status_code == 200 + body2 = view2.json() + assert body2["plan_source"] == "snapshot" + assert body2["latest_snapshot"]["revision_number"] == 1 + + patched = client.patch( + f"/api/roadmap-items/{gate['id']}", + json={"title": "Gate umbenannt"}, + headers=_auth(token), + ) + assert patched.status_code == 200 + + view3 = client.get( + f"/api/initiatives/{initiative_id}/roadmap/plan-ist-view", + headers=_auth(token), + ) + diff_kinds = {d["kind"] for d in view3.json()["diff"]} + assert "gate_modified" in diff_kinds diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2fc76f9..0c9eec1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -79,6 +79,7 @@ function AppRoutes() { }> } /> } /> + } /> } /> diff --git a/frontend/src/api/roadmap.js b/frontend/src/api/roadmap.js index 31d7d09..5c39ad9 100644 --- a/frontend/src/api/roadmap.js +++ b/frontend/src/api/roadmap.js @@ -16,6 +16,21 @@ export function listInitiativeRoadmapGraphState(initiativeId) { return apiFetch(`/api/initiatives/${initiativeId}/roadmap/graph-state`) } +export function getInitiativePlanIstView(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/roadmap/plan-ist-view`) +} + +export function listInitiativePlanSnapshots(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/roadmap/plan-snapshots`) +} + +export function createInitiativePlanSnapshot(initiativeId, body) { + return apiFetch(`/api/initiatives/${initiativeId}/roadmap/plan-snapshots`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + export function createInitiativeRoadmapItem(initiativeId, body) { return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, { method: 'POST', diff --git a/frontend/src/components/PlanIstPanel.jsx b/frontend/src/components/PlanIstPanel.jsx new file mode 100644 index 0000000..6cfc238 --- /dev/null +++ b/frontend/src/components/PlanIstPanel.jsx @@ -0,0 +1,272 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { + getInitiativePlanIstView, + createInitiativePlanSnapshot, +} from '../api/roadmap.js' +import { gatePath } from '../utils/routes.js' +import { + CRITERION_STATUS_LABELS, + MILESTONE_STATUS_LABELS, + ROADMAP_ITEM_TYPE_LABELS, +} from '../constants/status.js' +import { LoadingState } from './LoadingState.jsx' +import { ErrorState } from './ErrorState.jsx' +import { Modal } from './Modal.jsx' +import { EmptyState } from './EmptyState.jsx' + +const DIFF_KIND_LABELS = { + gate_added: 'Gate neu', + gate_removed: 'Gate entfernt', + gate_modified: 'Gate geändert', + edge_added: 'Kante neu', + edge_removed: 'Kante entfernt', + criterion_added: 'Kriterium neu', + criterion_removed: 'Kriterium entfernt', + criterion_modified: 'Kriterium geändert', + ist_gate_status: 'Ist-Status Gate', + ist_gate_at_risk: 'Gate gefährdet', + ist_criterion_waived: 'Kriterium ausgelassen', + ist_criterion_deferred: 'Kriterium verschoben', + ist_criterion_failed: 'Kriterium fehlgeschlagen', +} + +function formatPct(ratio) { + if (ratio == null) return null + return `${Math.round(ratio * 100)}%` +} + +export function PlanIstPanel({ initiativeId, canManage = false }) { + const [view, setView] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [tab, setTab] = useState('plan') + const [busy, setBusy] = useState(false) + const [showRevision, setShowRevision] = useState(false) + const [revisionReason, setRevisionReason] = useState('') + + const load = useCallback(async () => { + if (!initiativeId) return + setLoading(true) + setError(null) + try { + setView(await getInitiativePlanIstView(initiativeId)) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + }, [initiativeId]) + + useEffect(() => { + load() + }, [load]) + + async function handleCreateRevision(e) { + e.preventDefault() + setBusy(true) + setError(null) + try { + await createInitiativePlanSnapshot(initiativeId, { + reason: revisionReason.trim(), + }) + setRevisionReason('') + setShowRevision(false) + await load() + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + if (loading) return + if (error && !view) return + if (!view) return null + + const planItems = view.plan?.items || [] + const istItems = view.ist?.items || [] + const diffs = view.diff || [] + + return ( +
+
+
+

Plan vs. Ist

+

+ Soll-Graph und Ist-Overlay parallel — Abweichungen sichtbar, Plan-Revision mit Audit. +

+ {view.latest_snapshot ? ( +

+ Baseline: Revision {view.latest_snapshot.revision_number} + {view.latest_snapshot.reason ? ` — ${view.latest_snapshot.reason}` : ''} +

+ ) : ( +

+ Noch keine Plan-Revision — aktueller Plan = live. Revision festhalten vor größeren + Änderungen. +

+ )} +
+ {canManage && ( + + )} +
+ + {error &&

{error}

} + +
+ {['plan', 'ist', 'diff'].map((key) => ( + + ))} +
+ + {tab === 'plan' && ( +
+ {planItems.length === 0 && ( + + )} +
    + {planItems.map((item) => ( +
  • +
    + + {item.title} + +

    + {ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type} + {item.target_date ? ` · Ziel ${item.target_date}` : ''} +

    + {item.goal_description && ( +

    {item.goal_description}

    + )} +
    +
  • + ))} +
+ {view.plan_source === 'snapshot' && view.current_plan && ( +

+ Live-Plan weicht von Revision ab — siehe Diff. +

+ )} +
+ )} + + {tab === 'ist' && ( +
+ {view.ist?.graph_state?.initiative_fulfillment_ratio != null && ( +

+ Erfüllungsgrad:{' '} + {formatPct(view.ist.graph_state.initiative_fulfillment_ratio)} +

+ )} +
    + {istItems.map((item) => ( +
  • +
    + + {item.title} + +

    + {MILESTONE_STATUS_LABELS[item.status] || item.status} + {item.fulfillment_ratio != null + ? ` · ${formatPct(item.fulfillment_ratio)} Kriterien` + : ''} +

    + {item.criteria?.length > 0 && ( +
      + {item.criteria.map((crit) => ( +
    • + {crit.title}:{' '} + {CRITERION_STATUS_LABELS[crit.status] || crit.status} +
    • + ))} +
    + )} +
    +
  • + ))} +
+
+ )} + + {tab === 'diff' && ( +
+ {diffs.length === 0 && ( + + )} +
    + {diffs.map((entry, index) => ( +
  • +
    + + {DIFF_KIND_LABELS[entry.kind] || entry.kind} + + {entry.title || entry.message} +

    {entry.message}

    +
    + {entry.item_id && ( +
    + + Gate + +
    + )} +
  • + ))} +
+
+ )} + + setShowRevision(false)}> +
+

+ Speichert Topologie, Kanten und Kriterien-Definitionen als Soll-Baseline — Ist-Status + bleibt getrennt. +

+