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
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:
parent
1fa61b2dac
commit
064779e187
17
backend/migrations/021_roadmap_plan_snapshots.sql
Normal file
17
backend/migrations/021_roadmap_plan_snapshots.sql
Normal 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);
|
||||||
|
|
@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from services import roadmap as roadmap_service
|
from services import roadmap as roadmap_service
|
||||||
from services import roadmap_criteria as criteria_service
|
from services import roadmap_criteria as criteria_service
|
||||||
|
from services import roadmap_plan_snapshot as snapshot_service
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
router = APIRouter(tags=["roadmap"])
|
router = APIRouter(tags=["roadmap"])
|
||||||
|
|
@ -83,6 +84,10 @@ class ReopenRequest(BaseModel):
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PlanSnapshotCreateRequest(BaseModel):
|
||||||
|
reason: str = Field(default="", max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
|
initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
|
||||||
items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"])
|
items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"])
|
||||||
deps_router = APIRouter(prefix="/api/roadmap-dependencies", 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
|
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)
|
@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201)
|
||||||
def create_initiative_roadmap_item(
|
def create_initiative_roadmap_item(
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
|
|
|
||||||
203
backend/services/roadmap_plan_snapshot.py
Normal file
203
backend/services/roadmap_plan_snapshot.py
Normal 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
|
||||||
283
backend/steering/graph/plan_ist.py
Normal file
283
backend/steering/graph/plan_ist.py
Normal 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,
|
||||||
|
)
|
||||||
34
backend/tests/test_ap14_plan_ist.py
Normal file
34
backend/tests/test_ap14_plan_ist.py
Normal 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)
|
||||||
|
|
@ -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"]
|
i["kind"] == "gate_graph_blocked" and i["milestone_id"] == gate_a["id"]
|
||||||
for i in attention.json()
|
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
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ function AppRoutes() {
|
||||||
<Route path="/control" element={<ControlLayout />}>
|
<Route path="/control" element={<ControlLayout />}>
|
||||||
<Route index element={<ControlIndexRedirect />} />
|
<Route index element={<ControlIndexRedirect />} />
|
||||||
<Route path="status" element={<MODE_ROUTE_COMPONENTS.controlStatus />} />
|
<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 path="journey" element={<MODE_ROUTE_COMPONENTS.controlJourney />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,21 @@ export function listInitiativeRoadmapGraphState(initiativeId) {
|
||||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/graph-state`)
|
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) {
|
export function createInitiativeRoadmapItem(initiativeId, body) {
|
||||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
|
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
272
frontend/src/components/PlanIstPanel.jsx
Normal file
272
frontend/src/components/PlanIstPanel.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
/** @type {ModeNavItem[]} */
|
/** @type {ModeNavItem[]} */
|
||||||
export const CONTROL_NAV_ITEMS = [
|
export const CONTROL_NAV_ITEMS = [
|
||||||
{ key: 'status', to: '/control/status', label: 'Steuerung' },
|
{ key: 'status', to: '/control/status', label: 'Steuerung' },
|
||||||
|
{ key: 'plan-ist', to: '/control/plan-ist', label: 'Plan / Ist' },
|
||||||
{ key: 'journey', to: '/control/journey', label: 'Nachvollziehbarkeit' },
|
{ key: 'journey', to: '/control/journey', label: 'Nachvollziehbarkeit' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { resolveWorkNavActiveKey } from '../config/workNav.js'
|
||||||
describe('controlNav', () => {
|
describe('controlNav', () => {
|
||||||
it('resolves active control sub-route', () => {
|
it('resolves active control sub-route', () => {
|
||||||
expect(resolveControlNavActiveKey('/control/status')).toBe('status')
|
expect(resolveControlNavActiveKey('/control/status')).toBe('status')
|
||||||
|
expect(resolveControlNavActiveKey('/control/plan-ist')).toBe('plan-ist')
|
||||||
expect(resolveControlNavActiveKey('/control/journey')).toBe('journey')
|
expect(resolveControlNavActiveKey('/control/journey')).toBe('journey')
|
||||||
expect(resolveControlNavActiveKey('/control')).toBe(null)
|
expect(resolveControlNavActiveKey('/control')).toBe(null)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
24
frontend/src/pages/modes/ControlPlanIstPage.jsx
Normal file
24
frontend/src/pages/modes/ControlPlanIstPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import { PlanProfilePage } from '../pages/modes/PlanProfilePage.jsx'
|
||||||
import { PlanPortfolioPage } from '../pages/modes/PlanPortfolioPage.jsx'
|
import { PlanPortfolioPage } from '../pages/modes/PlanPortfolioPage.jsx'
|
||||||
import { ControlLayout, ControlIndexRedirect } from '../pages/modes/ControlLayout.jsx'
|
import { ControlLayout, ControlIndexRedirect } from '../pages/modes/ControlLayout.jsx'
|
||||||
import { ControlStatusPage } from '../pages/modes/ControlStatusPage.jsx'
|
import { ControlStatusPage } from '../pages/modes/ControlStatusPage.jsx'
|
||||||
|
import { ControlPlanIstPage } from '../pages/modes/ControlPlanIstPage.jsx'
|
||||||
import { ControlJourneyPage } from '../pages/modes/ControlJourneyPage.jsx'
|
import { ControlJourneyPage } from '../pages/modes/ControlJourneyPage.jsx'
|
||||||
import { TeamPage } from '../pages/modes/TeamPage.jsx'
|
import { TeamPage } from '../pages/modes/TeamPage.jsx'
|
||||||
import { InitiativesPage } from '../pages/InitiativesPage.jsx'
|
import { InitiativesPage } from '../pages/InitiativesPage.jsx'
|
||||||
|
|
@ -135,6 +136,7 @@ export const MODE_ROUTE_COMPONENTS = {
|
||||||
planInbox: PlanInboxPage,
|
planInbox: PlanInboxPage,
|
||||||
planWork: PlanWorkPage,
|
planWork: PlanWorkPage,
|
||||||
controlStatus: ControlStatusPage,
|
controlStatus: ControlStatusPage,
|
||||||
|
controlPlanIst: ControlPlanIstPage,
|
||||||
controlJourney: ControlJourneyPage,
|
controlJourney: ControlJourneyPage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1813,6 +1813,44 @@
|
||||||
font-size: 0.875rem;
|
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 {
|
.gate-map-view__node-title {
|
||||||
fill: var(--jk-text, #1a1a1a);
|
fill: var(--jk-text, #1a1a1a);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user