AP1.14: Plan-Snapshot und Plan/Ist-Overlay in Kontrolle.
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Failing after 2m18s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped

Revisionen mit Audit, getrennte Soll/Ist/Diff-Ansicht unter Kontrolle/Plan-Ist; API plan-ist-view und plan-snapshots.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-11 08:10:15 +02:00
parent 1fa61b2dac
commit 064779e187
14 changed files with 987 additions and 0 deletions

View File

@ -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);

View File

@ -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,

View File

@ -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

View File

@ -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,
)

View File

@ -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)

View File

@ -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

View File

@ -79,6 +79,7 @@ function AppRoutes() {
<Route path="/control" element={<ControlLayout />}>
<Route index element={<ControlIndexRedirect />} />
<Route path="status" element={<MODE_ROUTE_COMPONENTS.controlStatus />} />
<Route path="plan-ist" element={<MODE_ROUTE_COMPONENTS.controlPlanIst />} />
<Route path="journey" element={<MODE_ROUTE_COMPONENTS.controlJourney />} />
</Route>

View File

@ -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',

View File

@ -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 <LoadingState message="Plan/Ist wird geladen…" />
if (error && !view) return <ErrorState message={error} onRetry={load} />
if (!view) return null
const planItems = view.plan?.items || []
const istItems = view.ist?.items || []
const diffs = view.diff || []
return (
<section className="card plan-ist-panel">
<div className="section-header">
<div>
<h2>Plan vs. Ist</h2>
<p className="section-lead muted">
Soll-Graph und Ist-Overlay parallel Abweichungen sichtbar, Plan-Revision mit Audit.
</p>
{view.latest_snapshot ? (
<p className="muted plan-ist-panel__snapshot-meta">
Baseline: Revision {view.latest_snapshot.revision_number}
{view.latest_snapshot.reason ? `${view.latest_snapshot.reason}` : ''}
</p>
) : (
<p className="muted plan-ist-panel__snapshot-meta">
Noch keine Plan-Revision aktueller Plan = live. Revision festhalten vor größeren
Änderungen.
</p>
)}
</div>
{canManage && (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => setShowRevision(true)}
disabled={busy}
>
Plan-Revision
</button>
)}
</div>
{error && <p className="error">{error}</p>}
<div className="plan-ist-panel__tabs" role="tablist" aria-label="Plan Ist Diff">
{['plan', 'ist', 'diff'].map((key) => (
<button
key={key}
type="button"
role="tab"
aria-selected={tab === key}
className={
'plan-ist-panel__tab' + (tab === key ? ' plan-ist-panel__tab--active' : '')
}
onClick={() => setTab(key)}
>
{key === 'plan' ? 'Plan (Soll)' : key === 'ist' ? 'Ist' : `Diff (${diffs.length})`}
</button>
))}
</div>
{tab === 'plan' && (
<div className="plan-ist-panel__pane">
{planItems.length === 0 && (
<EmptyState message="Kein Plan — Zielzustände unter Planen anlegen." />
)}
<ul className="item-list">
{planItems.map((item) => (
<li key={item.id} className="list-item card-list-item">
<div className="list-item-main">
<strong>
<Link to={gatePath(item.id)}>{item.title}</Link>
</strong>
<p className="muted list-item-sub">
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
{item.target_date ? ` · Ziel ${item.target_date}` : ''}
</p>
{item.goal_description && (
<p className="list-item-desc">{item.goal_description}</p>
)}
</div>
</li>
))}
</ul>
{view.plan_source === 'snapshot' && view.current_plan && (
<p className="muted plan-ist-panel__hint">
Live-Plan weicht von Revision ab siehe Diff.
</p>
)}
</div>
)}
{tab === 'ist' && (
<div className="plan-ist-panel__pane">
{view.ist?.graph_state?.initiative_fulfillment_ratio != null && (
<p className="plan-ist-panel__fulfillment muted">
Erfüllungsgrad:{' '}
{formatPct(view.ist.graph_state.initiative_fulfillment_ratio)}
</p>
)}
<ul className="item-list">
{istItems.map((item) => (
<li key={item.id} className="list-item card-list-item">
<div className="list-item-main">
<strong>
<Link to={gatePath(item.id)}>{item.title}</Link>
</strong>
<p className="muted list-item-sub">
{MILESTONE_STATUS_LABELS[item.status] || item.status}
{item.fulfillment_ratio != null
? ` · ${formatPct(item.fulfillment_ratio)} Kriterien`
: ''}
</p>
{item.criteria?.length > 0 && (
<ul className="plan-ist-panel__criteria muted">
{item.criteria.map((crit) => (
<li key={crit.id}>
{crit.title}:{' '}
{CRITERION_STATUS_LABELS[crit.status] || crit.status}
</li>
))}
</ul>
)}
</div>
</li>
))}
</ul>
</div>
)}
{tab === 'diff' && (
<div className="plan-ist-panel__pane">
{diffs.length === 0 && (
<EmptyState message="Keine Abweichungen — Plan und Ist sind konsistent." />
)}
<ul className="item-list">
{diffs.map((entry, index) => (
<li key={`${entry.kind}-${entry.item_id}-${index}`} className="list-item card-list-item">
<div className="list-item-main">
<span className="status-pill plan-ist-panel__diff-kind">
{DIFF_KIND_LABELS[entry.kind] || entry.kind}
</span>
<strong>{entry.title || entry.message}</strong>
<p className="muted list-item-desc">{entry.message}</p>
</div>
{entry.item_id && (
<div className="list-item-meta">
<Link to={gatePath(entry.item_id)} className="btn btn-secondary btn-sm">
Gate
</Link>
</div>
)}
</li>
))}
</ul>
</div>
)}
<Modal open={showRevision} title="Plan-Revision festhalten" onClose={() => setShowRevision(false)}>
<form className="form workspace-form" onSubmit={handleCreateRevision}>
<p className="muted form-hint">
Speichert Topologie, Kanten und Kriterien-Definitionen als Soll-Baseline Ist-Status
bleibt getrennt.
</p>
<label>
Begründung (optional)
<textarea
value={revisionReason}
onChange={(e) => setRevisionReason(e.target.value)}
rows={3}
maxLength={2000}
placeholder="z. B. Scope-Freeze vor Release 1"
/>
</label>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichern …' : 'Revision anlegen'}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setShowRevision(false)}
disabled={busy}
>
Abbrechen
</button>
</div>
</form>
</Modal>
</section>
)
}

View File

@ -8,6 +8,7 @@
/** @type {ModeNavItem[]} */
export const CONTROL_NAV_ITEMS = [
{ key: 'status', to: '/control/status', label: 'Steuerung' },
{ key: 'plan-ist', to: '/control/plan-ist', label: 'Plan / Ist' },
{ key: 'journey', to: '/control/journey', label: 'Nachvollziehbarkeit' },
]

View File

@ -5,6 +5,7 @@ import { resolveWorkNavActiveKey } from '../config/workNav.js'
describe('controlNav', () => {
it('resolves active control sub-route', () => {
expect(resolveControlNavActiveKey('/control/status')).toBe('status')
expect(resolveControlNavActiveKey('/control/plan-ist')).toBe('plan-ist')
expect(resolveControlNavActiveKey('/control/journey')).toBe('journey')
expect(resolveControlNavActiveKey('/control')).toBe(null)
})

View File

@ -0,0 +1,24 @@
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { PlanIstPanel } from '../../components/PlanIstPanel.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
function PlanIstContent() {
const { initiativeId, capabilities } = useInitiativeOperations()
return (
<PlanIstPanel
initiativeId={initiativeId}
canManage={capabilities.has('kairo.milestone.manage')}
/>
)
}
export function ControlPlanIstPage() {
return (
<RequireInitiativeScope lead="Plan/Ist-Vergleich braucht ein Vorhaben im Scope.">
<ScopedInitiativeProvider>
<PlanIstContent />
</ScopedInitiativeProvider>
</RequireInitiativeScope>
)
}

View File

@ -11,6 +11,7 @@ import { PlanProfilePage } from '../pages/modes/PlanProfilePage.jsx'
import { PlanPortfolioPage } from '../pages/modes/PlanPortfolioPage.jsx'
import { ControlLayout, ControlIndexRedirect } from '../pages/modes/ControlLayout.jsx'
import { ControlStatusPage } from '../pages/modes/ControlStatusPage.jsx'
import { ControlPlanIstPage } from '../pages/modes/ControlPlanIstPage.jsx'
import { ControlJourneyPage } from '../pages/modes/ControlJourneyPage.jsx'
import { TeamPage } from '../pages/modes/TeamPage.jsx'
import { InitiativesPage } from '../pages/InitiativesPage.jsx'
@ -135,6 +136,7 @@ export const MODE_ROUTE_COMPONENTS = {
planInbox: PlanInboxPage,
planWork: PlanWorkPage,
controlStatus: ControlStatusPage,
controlPlanIst: ControlPlanIstPage,
controlJourney: ControlJourneyPage,
}

View File

@ -1813,6 +1813,44 @@
font-size: 0.875rem;
}
.plan-ist-panel__tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.plan-ist-panel__tab {
padding: 0.35rem 0.75rem;
border: 1px solid var(--jk-border-strong, #c5ced8);
border-radius: 999px;
background: transparent;
cursor: pointer;
font-size: 0.875rem;
}
.plan-ist-panel__tab--active {
background: var(--jk-surface-muted, #eef2f6);
font-weight: 600;
}
.plan-ist-panel__snapshot-meta,
.plan-ist-panel__hint,
.plan-ist-panel__fulfillment {
font-size: 0.875rem;
margin-top: 0.35rem;
}
.plan-ist-panel__criteria {
margin: 0.35rem 0 0;
padding-left: 1.1rem;
font-size: 0.8125rem;
}
.plan-ist-panel__diff-kind {
margin-right: 0.5rem;
}
.gate-map-view__node-title {
fill: var(--jk-text, #1a1a1a);
font-size: 14px;