AP1.4d: Graph Engine mit blocked/ready Read Models.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m17s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m17s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s
Migration 020 fuer Kantenarten und Parallelgruppe; steering/graph berechnet Fortschritt; UI zeigt Bereit/Blockiert in Liste und Graph. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bd64340ca1
commit
4f8725e234
17
backend/migrations/020_roadmap_graph_edges.sql
Normal file
17
backend/migrations/020_roadmap_graph_edges.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- AP1.4d: Graph Engine — erweiterte Kantenarten + Parallelgruppe
|
||||
|
||||
ALTER TABLE roadmap_item_dependencies
|
||||
DROP CONSTRAINT IF EXISTS roadmap_item_dependencies_dependency_type_check;
|
||||
|
||||
ALTER TABLE roadmap_item_dependencies
|
||||
ADD CONSTRAINT roadmap_item_dependencies_dependency_type_check
|
||||
CHECK (dependency_type IN (
|
||||
'requires', 'blocks', 'related', 'parallel_group', 'optional_branch'
|
||||
));
|
||||
|
||||
ALTER TABLE roadmap_item_dependencies
|
||||
ADD COLUMN IF NOT EXISTS group_key VARCHAR(128) NULL;
|
||||
|
||||
CREATE INDEX idx_roadmap_deps_group_key
|
||||
ON roadmap_item_dependencies(tenant_id, group_key)
|
||||
WHERE group_key IS NOT NULL;
|
||||
|
|
@ -45,7 +45,10 @@ class RoadmapItemUpdateRequest(BaseModel):
|
|||
|
||||
class DependencyCreateRequest(BaseModel):
|
||||
to_item_id: str
|
||||
dependency_type: Literal["requires", "blocks", "related"] = "requires"
|
||||
dependency_type: Literal[
|
||||
"requires", "blocks", "related", "parallel_group", "optional_branch"
|
||||
] = "requires"
|
||||
group_key: Optional[str] = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class CriterionCreateRequest(BaseModel):
|
||||
|
|
@ -143,6 +146,21 @@ def list_initiative_roadmap_criteria_progress(
|
|||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap/graph-state")
|
||||
def get_initiative_roadmap_graph_state(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
from steering.graph.roadmap_engine import load_initiative_graph_state
|
||||
|
||||
return load_initiative_graph_state(
|
||||
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/items", status_code=201)
|
||||
def create_initiative_roadmap_item(
|
||||
initiative_id: str,
|
||||
|
|
@ -419,6 +437,7 @@ def add_roadmap_item_dependency(
|
|||
from_item_id=item_id,
|
||||
to_item_id=body.to_item_id,
|
||||
dependency_type=body.dependency_type,
|
||||
group_key=body.group_key,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
|
|
|
|||
|
|
@ -18,14 +18,14 @@ RoadmapItemStatus = Literal[
|
|||
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
||||
]
|
||||
SequencingMode = Literal["sequential", "parallel", "optional"]
|
||||
DependencyType = Literal["requires", "blocks", "related"]
|
||||
DependencyType = Literal["requires", "blocks", "related", "parallel_group", "optional_branch"]
|
||||
|
||||
ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage"})
|
||||
ROADMAP_ITEM_STATUSES = frozenset(
|
||||
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
|
||||
)
|
||||
SEQUENCING_MODES = frozenset({"sequential", "parallel", "optional"})
|
||||
DEPENDENCY_TYPES = frozenset({"requires", "blocks", "related"})
|
||||
DEPENDENCY_TYPES = frozenset({"requires", "blocks", "related", "parallel_group", "optional_branch"})
|
||||
|
||||
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
|
||||
|
||||
|
|
@ -457,6 +457,18 @@ def delete_roadmap_item(
|
|||
return deleted
|
||||
|
||||
|
||||
def _serialize_dependency(row: dict[str, Any]) -> dict[str, Any]:
|
||||
dep = dict(row)
|
||||
for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
|
||||
if dep.get(key):
|
||||
dep[key] = str(dep[key])
|
||||
if dep.get("created_at"):
|
||||
dep["created_at"] = dep["created_at"].isoformat()
|
||||
if dep.get("group_key") is None:
|
||||
dep.pop("group_key", None)
|
||||
return dep
|
||||
|
||||
|
||||
def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
|
||||
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
|
||||
if not item:
|
||||
|
|
@ -467,22 +479,15 @@ def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
|
|||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
|
||||
SELECT id, tenant_id, from_item_id, to_item_id, dependency_type,
|
||||
group_key, created_at
|
||||
FROM roadmap_item_dependencies
|
||||
WHERE tenant_id = %s AND (from_item_id = %s OR to_item_id = %s)
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
(tenant_id, item_id, item_id),
|
||||
)
|
||||
items = []
|
||||
for row in cur.fetchall():
|
||||
dep = dict(row)
|
||||
for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
|
||||
dep[key] = str(dep[key])
|
||||
if dep.get("created_at"):
|
||||
dep["created_at"] = dep["created_at"].isoformat()
|
||||
items.append(dep)
|
||||
return items
|
||||
return [_serialize_dependency(dict(row)) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -499,7 +504,7 @@ def list_dependencies_for_initiative(
|
|||
cur.execute(
|
||||
"""
|
||||
SELECT d.id, d.tenant_id, d.from_item_id, d.to_item_id,
|
||||
d.dependency_type, d.created_at
|
||||
d.dependency_type, d.group_key, d.created_at
|
||||
FROM roadmap_item_dependencies d
|
||||
INNER JOIN roadmap_items fi
|
||||
ON fi.id = d.from_item_id AND fi.tenant_id = d.tenant_id
|
||||
|
|
@ -510,15 +515,7 @@ def list_dependencies_for_initiative(
|
|||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
items = []
|
||||
for row in cur.fetchall():
|
||||
dep = dict(row)
|
||||
for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
|
||||
dep[key] = str(dep[key])
|
||||
if dep.get("created_at"):
|
||||
dep["created_at"] = dep["created_at"].isoformat()
|
||||
items.append(dep)
|
||||
return items
|
||||
return [_serialize_dependency(dict(row)) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -529,6 +526,7 @@ def add_dependency(
|
|||
from_item_id: str,
|
||||
to_item_id: str,
|
||||
dependency_type: DependencyType = "requires",
|
||||
group_key: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
_validate_dependency_type(dependency_type)
|
||||
|
|
@ -542,6 +540,12 @@ def add_dependency(
|
|||
if from_item["initiative_id"] != to_item["initiative_id"]:
|
||||
raise ValueError("Abhängigkeiten nur innerhalb eines Vorhabens")
|
||||
|
||||
normalized_group_key: Optional[str] = None
|
||||
if group_key is not None:
|
||||
normalized_group_key = group_key.strip() or None
|
||||
if dependency_type == "parallel_group" and not normalized_group_key:
|
||||
raise ValueError("group_key ist für parallel_group erforderlich")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
|
|
@ -559,17 +563,15 @@ def add_dependency(
|
|||
cur.execute(
|
||||
"""
|
||||
INSERT INTO roadmap_item_dependencies (
|
||||
tenant_id, from_item_id, to_item_id, dependency_type
|
||||
tenant_id, from_item_id, to_item_id, dependency_type, group_key
|
||||
)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type,
|
||||
group_key, created_at
|
||||
""",
|
||||
(tenant_id, from_item_id, to_item_id, dependency_type),
|
||||
(tenant_id, from_item_id, to_item_id, dependency_type, normalized_group_key),
|
||||
)
|
||||
row = dict(cur.fetchone())
|
||||
for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
|
||||
row[key] = str(row[key])
|
||||
row["created_at"] = row["created_at"].isoformat()
|
||||
row = _serialize_dependency(dict(cur.fetchone()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
@ -582,6 +584,7 @@ def add_dependency(
|
|||
"from_item_id": from_item_id,
|
||||
"to_item_id": to_item_id,
|
||||
"dependency_type": dependency_type,
|
||||
"group_key": normalized_group_key,
|
||||
},
|
||||
)
|
||||
return row
|
||||
|
|
|
|||
5
backend/steering/graph/__init__.py
Normal file
5
backend/steering/graph/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Roadmap graph read models (AP1.4d)."""
|
||||
|
||||
from steering.graph.roadmap_engine import compute_initiative_graph_state
|
||||
|
||||
__all__ = ["compute_initiative_graph_state"]
|
||||
154
backend/steering/graph/roadmap_engine.py
Normal file
154
backend/steering/graph/roadmap_engine.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"""Roadmap gate graph read models — Plan-Zielzustand, kein Workflow (AP1.4d)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
|
||||
ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"})
|
||||
BLOCKING_STATUSES = frozenset({"planned", "active", "at_risk"})
|
||||
|
||||
|
||||
def _is_prerequisite_satisfied(status: str) -> bool:
|
||||
return status == "reached"
|
||||
|
||||
|
||||
def _fulfillment_ratio(progress: Optional[dict[str, int]]) -> Optional[float]:
|
||||
if not progress:
|
||||
return None
|
||||
total = progress.get("total", 0)
|
||||
if total <= 0:
|
||||
return None
|
||||
closed = progress.get("closed", 0)
|
||||
return round(closed / total, 4)
|
||||
|
||||
|
||||
def _sort_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return sorted(
|
||||
items,
|
||||
key=lambda item: (
|
||||
item.get("sort_order", 0),
|
||||
item.get("title") or "",
|
||||
str(item.get("id", "")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _sequential_order_blocked_by(
|
||||
item: dict[str, Any],
|
||||
sorted_items: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
"""Fallback wenn keine Kanten: sequenzielle Items nach sort_order."""
|
||||
blocked_by: list[str] = []
|
||||
item_id = str(item["id"])
|
||||
for prev in sorted_items:
|
||||
if str(prev["id"]) == item_id:
|
||||
break
|
||||
if prev.get("sequencing_mode", "sequential") != "sequential":
|
||||
continue
|
||||
if not _is_prerequisite_satisfied(prev.get("status", "planned")):
|
||||
blocked_by.append(str(prev["id"]))
|
||||
return blocked_by
|
||||
|
||||
|
||||
def compute_initiative_graph_state(
|
||||
*,
|
||||
items: list[dict[str, Any]],
|
||||
dependencies: list[dict[str, Any]],
|
||||
criteria_progress: Optional[dict[str, dict[str, int]]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Read Model pro Initiative: blocked / ready / fulfillment_ratio.
|
||||
|
||||
Kanten-Semantik (from → to):
|
||||
- requires: from benötigt to (Voraussetzung) als reached
|
||||
- optional_branch: wie requires, blockiert aber nicht (nur Metadaten)
|
||||
- blocks: from blockiert to solange from noch aktiv ist
|
||||
- parallel_group / related: keine Blockade
|
||||
"""
|
||||
criteria_progress = criteria_progress or {}
|
||||
sorted_items = _sort_items(items)
|
||||
item_by_id = {str(item["id"]): item for item in items}
|
||||
has_any_deps = len(dependencies) > 0
|
||||
|
||||
blocked_by_map: dict[str, list[str]] = {item_id: [] for item_id in item_by_id}
|
||||
|
||||
for dep in dependencies:
|
||||
dep_type = dep.get("dependency_type") or dep.get("edge_kind") or "requires"
|
||||
from_id = str(dep["from_item_id"])
|
||||
to_id = str(dep["to_item_id"])
|
||||
if from_id not in item_by_id or to_id not in item_by_id:
|
||||
continue
|
||||
|
||||
if dep_type == "requires":
|
||||
prereq = item_by_id[to_id]
|
||||
if not _is_prerequisite_satisfied(prereq.get("status", "planned")):
|
||||
if to_id not in blocked_by_map[from_id]:
|
||||
blocked_by_map[from_id].append(to_id)
|
||||
elif dep_type == "blocks":
|
||||
blocker = item_by_id[from_id]
|
||||
if blocker.get("status", "planned") in BLOCKING_STATUSES:
|
||||
if from_id not in blocked_by_map[to_id]:
|
||||
blocked_by_map[to_id].append(from_id)
|
||||
|
||||
if not has_any_deps:
|
||||
for item in sorted_items:
|
||||
item_id = str(item["id"])
|
||||
if item.get("sequencing_mode", "sequential") == "sequential":
|
||||
for prereq_id in _sequential_order_blocked_by(item, sorted_items):
|
||||
if prereq_id not in blocked_by_map[item_id]:
|
||||
blocked_by_map[item_id].append(prereq_id)
|
||||
|
||||
item_states: dict[str, dict[str, Any]] = {}
|
||||
blocked_items: list[str] = []
|
||||
ready_items: list[str] = []
|
||||
|
||||
for item_id, item in item_by_id.items():
|
||||
status = item.get("status", "planned")
|
||||
blocked_by = blocked_by_map.get(item_id, [])
|
||||
blocked = len(blocked_by) > 0
|
||||
ready = (
|
||||
status in ACTIVE_STATUSES
|
||||
and not blocked
|
||||
)
|
||||
|
||||
ratio = _fulfillment_ratio(criteria_progress.get(item_id))
|
||||
item_states[item_id] = {
|
||||
"blocked": blocked,
|
||||
"ready": ready,
|
||||
"blocked_by": blocked_by,
|
||||
"fulfillment_ratio": ratio,
|
||||
}
|
||||
if blocked and status in ACTIVE_STATUSES:
|
||||
blocked_items.append(item_id)
|
||||
if ready:
|
||||
ready_items.append(item_id)
|
||||
|
||||
return {
|
||||
"items": item_states,
|
||||
"blocked_items": blocked_items,
|
||||
"ready_items": ready_items,
|
||||
}
|
||||
|
||||
|
||||
def load_initiative_graph_state(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Lädt Items, Kanten und Kriterien-Fortschritt und berechnet Graph-State."""
|
||||
from services import roadmap as roadmap_service
|
||||
from services import roadmap_criteria as criteria_service
|
||||
|
||||
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
|
||||
)
|
||||
criteria_progress = criteria_service.criteria_progress_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
return compute_initiative_graph_state(
|
||||
items=items,
|
||||
dependencies=dependencies,
|
||||
criteria_progress=criteria_progress,
|
||||
)
|
||||
|
|
@ -218,3 +218,27 @@ def test_initiative_roadmap_criteria_progress(client):
|
|||
body = progress.json()
|
||||
assert body[item["id"]]["total"] >= 1
|
||||
assert body[item["id"]]["closed"] == 0
|
||||
|
||||
|
||||
def test_initiative_roadmap_graph_state(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
gate_a = _create_roadmap_item(client, token, initiative_id, title="Gate A", sort_order=0).json()
|
||||
gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B", sort_order=1).json()
|
||||
|
||||
client.post(
|
||||
f"/api/roadmap-items/{gate_a['id']}/dependencies",
|
||||
json={"to_item_id": gate_b["id"], "dependency_type": "requires"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
state = client.get(
|
||||
f"/api/initiatives/{initiative_id}/roadmap/graph-state",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert state.status_code == 200
|
||||
body = state.json()
|
||||
assert body["items"][gate_a["id"]]["blocked"] is True
|
||||
assert gate_b["id"] in body["items"][gate_a["id"]]["blocked_by"]
|
||||
assert gate_a["id"] not in body["ready_items"]
|
||||
|
|
|
|||
85
backend/tests/test_ap14d_roadmap_graph.py
Normal file
85
backend/tests/test_ap14d_roadmap_graph.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Unit tests for roadmap graph engine (AP1.4d)."""
|
||||
|
||||
from steering.graph.roadmap_engine import compute_initiative_graph_state
|
||||
|
||||
|
||||
def _item(item_id: str, status: str = "planned", **kwargs):
|
||||
return {
|
||||
"id": item_id,
|
||||
"status": status,
|
||||
"sort_order": kwargs.get("sort_order", 0),
|
||||
"sequencing_mode": kwargs.get("sequencing_mode", "sequential"),
|
||||
"title": kwargs.get("title", item_id),
|
||||
}
|
||||
|
||||
|
||||
def test_requires_blocks_until_prerequisite_reached():
|
||||
items = [_item("a", "planned"), _item("b", "reached")]
|
||||
deps = [
|
||||
{
|
||||
"from_item_id": "a",
|
||||
"to_item_id": "b",
|
||||
"dependency_type": "requires",
|
||||
}
|
||||
]
|
||||
state = compute_initiative_graph_state(items=items, dependencies=deps)
|
||||
assert state["items"]["a"]["blocked"] is False
|
||||
assert state["items"]["a"]["ready"] is True
|
||||
assert "a" in state["ready_items"]
|
||||
|
||||
items[1]["status"] = "planned"
|
||||
state = compute_initiative_graph_state(items=items, dependencies=deps)
|
||||
assert state["items"]["a"]["blocked"] is True
|
||||
assert state["items"]["a"]["blocked_by"] == ["b"]
|
||||
assert "a" in state["blocked_items"]
|
||||
|
||||
|
||||
def test_sequential_fallback_without_edges():
|
||||
items = [
|
||||
_item("first", "planned", sort_order=0),
|
||||
_item("second", "planned", sort_order=1),
|
||||
]
|
||||
state = compute_initiative_graph_state(items=items, dependencies=[])
|
||||
assert state["items"]["first"]["ready"] is True
|
||||
assert state["items"]["second"]["blocked"] is True
|
||||
assert state["items"]["second"]["blocked_by"] == ["first"]
|
||||
|
||||
items[0]["status"] = "reached"
|
||||
state = compute_initiative_graph_state(items=items, dependencies=[])
|
||||
assert state["items"]["second"]["ready"] is True
|
||||
|
||||
|
||||
def test_parallel_sequencing_mode_skips_order_block():
|
||||
items = [
|
||||
_item("first", "planned", sort_order=0),
|
||||
_item("second", "planned", sort_order=1, sequencing_mode="parallel"),
|
||||
]
|
||||
state = compute_initiative_graph_state(items=items, dependencies=[])
|
||||
assert state["items"]["second"]["ready"] is True
|
||||
|
||||
|
||||
def test_blocks_edge():
|
||||
items = [_item("blocker", "active"), _item("blocked", "planned")]
|
||||
deps = [
|
||||
{
|
||||
"from_item_id": "blocker",
|
||||
"to_item_id": "blocked",
|
||||
"dependency_type": "blocks",
|
||||
}
|
||||
]
|
||||
state = compute_initiative_graph_state(items=items, dependencies=deps)
|
||||
assert state["items"]["blocked"]["blocked"] is True
|
||||
assert state["items"]["blocked"]["blocked_by"] == ["blocker"]
|
||||
|
||||
items[0]["status"] = "reached"
|
||||
state = compute_initiative_graph_state(items=items, dependencies=deps)
|
||||
assert state["items"]["blocked"]["ready"] is True
|
||||
|
||||
|
||||
def test_fulfillment_ratio_from_criteria_progress():
|
||||
items = [_item("g", "active")]
|
||||
progress = {"g": {"total": 4, "closed": 2, "open": 2}}
|
||||
state = compute_initiative_graph_state(
|
||||
items=items, dependencies=[], criteria_progress=progress
|
||||
)
|
||||
assert state["items"]["g"]["fulfillment_ratio"] == 0.5
|
||||
|
|
@ -12,6 +12,10 @@ export function listInitiativeRoadmapCriteriaProgress(initiativeId) {
|
|||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/criteria-progress`)
|
||||
}
|
||||
|
||||
export function listInitiativeRoadmapGraphState(initiativeId) {
|
||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/graph-state`)
|
||||
}
|
||||
|
||||
export function createInitiativeRoadmapItem(initiativeId, body) {
|
||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ const DEPENDENCY_TYPE_LABELS = {
|
|||
requires: 'Voraussetzung',
|
||||
blocks: 'Blockiert',
|
||||
related: 'Bezug',
|
||||
parallel_group: 'Parallelgruppe',
|
||||
optional_branch: 'Optional',
|
||||
}
|
||||
|
||||
const OUTGOING_PHRASES = {
|
||||
|
|
@ -56,6 +58,7 @@ function DependencyRow({ dep, itemId, siblingItems, direction, canManage, busy,
|
|||
</p>
|
||||
<p className="muted list-item-sub">
|
||||
{DEPENDENCY_TYPE_LABELS[dep.dependency_type] || dep.dependency_type}
|
||||
{dep.group_key ? ` · Gruppe ${dep.group_key}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
|
|
@ -92,11 +95,17 @@ export function GateDependenciesSection({
|
|||
const form = event.target
|
||||
const toItemId = form.to_item_id.value
|
||||
const dependencyType = form.dependency_type.value
|
||||
const groupKey = form.group_key?.value?.trim()
|
||||
if (!toItemId) return
|
||||
await onAdd({
|
||||
const body = {
|
||||
to_item_id: toItemId,
|
||||
dependency_type: dependencyType,
|
||||
})
|
||||
}
|
||||
if (dependencyType === 'parallel_group') {
|
||||
if (!groupKey) return
|
||||
body.group_key = groupKey
|
||||
}
|
||||
await onAdd(body)
|
||||
form.reset()
|
||||
}
|
||||
|
||||
|
|
@ -169,9 +178,20 @@ export function GateDependenciesSection({
|
|||
<select name="dependency_type" defaultValue="requires">
|
||||
<option value="requires">benötigt (Voraussetzung)</option>
|
||||
<option value="blocks">blockiert</option>
|
||||
<option value="parallel_group">Parallelgruppe</option>
|
||||
<option value="optional_branch">optionaler Zweig</option>
|
||||
<option value="related">steht in Bezug zu</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="gate-dependency-form__group-key">
|
||||
Parallelgruppe (Schlüssel)
|
||||
<input
|
||||
name="group_key"
|
||||
maxLength={128}
|
||||
placeholder="z. B. phase-1"
|
||||
/>
|
||||
<span className="muted form-hint">Nur bei Kantenart „Parallelgruppe“.</span>
|
||||
</label>
|
||||
<label>
|
||||
Anderes Gate
|
||||
<select name="to_item_id" required defaultValue="">
|
||||
|
|
|
|||
27
frontend/src/components/GateGraphStateBadge.jsx
Normal file
27
frontend/src/components/GateGraphStateBadge.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { gateTitleById } from './GateSelect.jsx'
|
||||
|
||||
export function GateGraphStateBadge({ graphState, itemId, siblingItems = [] }) {
|
||||
if (!graphState?.items?.[itemId]) return null
|
||||
|
||||
const { blocked, ready, blocked_by: blockedBy = [] } = graphState.items[itemId]
|
||||
|
||||
if (ready) {
|
||||
return <span className="status-pill status-pill--ready">Bereit</span>
|
||||
}
|
||||
|
||||
if (blocked) {
|
||||
const blockerTitle =
|
||||
blockedBy.length === 1 ? gateTitleById(siblingItems, blockedBy[0]) : null
|
||||
const title =
|
||||
blockerTitle != null
|
||||
? `Blockiert durch ${blockerTitle}`
|
||||
: `Blockiert (${blockedBy.length} Voraussetzungen)`
|
||||
return (
|
||||
<span className="status-pill status-pill--blocked" title={title}>
|
||||
Blockiert
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { listInitiativeRoadmapDependencies } from '../api/roadmap.js'
|
||||
import { listInitiativeRoadmapDependencies, listInitiativeRoadmapGraphState } from '../api/roadmap.js'
|
||||
import { ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js'
|
||||
import { gatePath } from '../utils/routes.js'
|
||||
import { computeGateGraphLayout } from '../plan/gateGraphLayout.js'
|
||||
|
|
@ -21,27 +21,38 @@ function truncateTitle(title, max = 28) {
|
|||
export function GateMapView({ initiativeId, items }) {
|
||||
const navigate = useNavigate()
|
||||
const [dependencies, setDependencies] = useState([])
|
||||
const [graphState, setGraphState] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!initiativeId) {
|
||||
setDependencies([])
|
||||
setGraphState(null)
|
||||
setLoading(false)
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
listInitiativeRoadmapDependencies(initiativeId)
|
||||
.then((data) => {
|
||||
if (!cancelled) setDependencies(Array.isArray(data) ? data : [])
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message || 'Abhängigkeiten konnten nicht geladen werden.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
Promise.allSettled([
|
||||
listInitiativeRoadmapDependencies(initiativeId),
|
||||
listInitiativeRoadmapGraphState(initiativeId),
|
||||
]).then(([depsResult, graphResult]) => {
|
||||
if (cancelled) return
|
||||
if (depsResult.status === 'fulfilled') {
|
||||
setDependencies(Array.isArray(depsResult.value) ? depsResult.value : [])
|
||||
} else {
|
||||
setError(
|
||||
depsResult.reason?.message || 'Abhängigkeiten konnten nicht geladen werden.',
|
||||
)
|
||||
}
|
||||
if (graphResult.status === 'fulfilled') {
|
||||
setGraphState(graphResult.value || null)
|
||||
} else {
|
||||
setGraphState(null)
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
|
@ -140,10 +151,16 @@ export function GateMapView({ initiativeId, items }) {
|
|||
|
||||
{layout.nodes.map((node) => {
|
||||
const item = node.item
|
||||
const nodeGraph = graphState?.items?.[node.id]
|
||||
const nodeClass =
|
||||
'gate-map-view__node gate-map-view__node--' +
|
||||
(item.status || 'planned') +
|
||||
(nodeGraph?.blocked ? ' gate-map-view__node--blocked' : '') +
|
||||
(nodeGraph?.ready ? ' gate-map-view__node--ready' : '')
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
className={`gate-map-view__node gate-map-view__node--${item.status || 'planned'}`}
|
||||
className={nodeClass}
|
||||
transform={`translate(${node.x}, ${node.y})`}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@ import {
|
|||
MILESTONE_STATUSES,
|
||||
MILESTONE_STATUS_LABELS,
|
||||
ROADMAP_ITEM_TYPE_LABELS,
|
||||
SEQUENCING_MODE_LABELS,
|
||||
} from '../constants/status.js'
|
||||
|
||||
const ITEM_TYPES = ['milestone', 'review_gate', 'maturity_stage']
|
||||
const SEQUENCING_MODES = ['sequential', 'parallel', 'optional']
|
||||
const EDITABLE_STATUSES = MILESTONE_STATUSES.filter(
|
||||
(s) => !['reached', 'moved', 'discarded'].includes(s),
|
||||
)
|
||||
|
|
@ -27,7 +25,7 @@ export function RoadmapItemForm({
|
|||
goal_description: form.goal_description.value.trim(),
|
||||
target_date: form.target_date.value || undefined,
|
||||
item_type: form.item_type.value,
|
||||
sequencing_mode: form.sequencing_mode.value,
|
||||
sequencing_mode: initial.sequencing_mode || 'sequential',
|
||||
}
|
||||
if (mode === 'create') {
|
||||
const initialCriterion = form.initial_criterion_title?.value?.trim()
|
||||
|
|
@ -94,21 +92,6 @@ export function RoadmapItemForm({
|
|||
</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="form-row form-row--2">
|
||||
<label>
|
||||
Abfolge
|
||||
<select
|
||||
name="sequencing_mode"
|
||||
defaultValue={initial.sequencing_mode || 'sequential'}
|
||||
disabled={statusLocked}
|
||||
>
|
||||
{SEQUENCING_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{SEQUENCING_MODE_LABELS[m] || m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Zieltermin
|
||||
<input
|
||||
|
|
@ -118,7 +101,10 @@ export function RoadmapItemForm({
|
|||
disabled={statusLocked}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted form-hint">
|
||||
Reihenfolge und Parallelität steuerst du über Kanten auf der Gate-Detailseite — nicht mehr
|
||||
über ein Abfolge-Feld.
|
||||
</p>
|
||||
{mode === 'edit' && (
|
||||
<label>
|
||||
Status
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { gatePath } from '../utils/routes.js'
|
||||
import { listInitiativeRoadmapCriteriaProgress } from '../api/roadmap.js'
|
||||
import { listInitiativeRoadmapCriteriaProgress, listInitiativeRoadmapGraphState } from '../api/roadmap.js'
|
||||
import {
|
||||
ROADMAP_ITEM_TYPE_LABELS,
|
||||
SEQUENCING_MODE_LABELS,
|
||||
} from '../constants/status.js'
|
||||
import { StatusBadge } from './StatusBadge.jsx'
|
||||
import { GateGraphStateBadge } from './GateGraphStateBadge.jsx'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
import { Modal } from './Modal.jsx'
|
||||
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
|
||||
|
|
@ -38,21 +38,32 @@ export function RoadmapPlanSection({
|
|||
const [dragItemId, setDragItemId] = useState('')
|
||||
const [dropTargetId, setDropTargetId] = useState('')
|
||||
const [criteriaProgress, setCriteriaProgress] = useState({})
|
||||
const [graphState, setGraphState] = useState(null)
|
||||
const isDesktop = useMinWidth(1024)
|
||||
const canReorder = canManage && typeof onReorder === 'function'
|
||||
|
||||
useEffect(() => {
|
||||
if (!initiativeId) {
|
||||
setCriteriaProgress({})
|
||||
setGraphState(null)
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
listInitiativeRoadmapCriteriaProgress(initiativeId)
|
||||
.then((data) => {
|
||||
if (!cancelled) setCriteriaProgress(data || {})
|
||||
Promise.all([
|
||||
listInitiativeRoadmapCriteriaProgress(initiativeId),
|
||||
listInitiativeRoadmapGraphState(initiativeId),
|
||||
])
|
||||
.then(([progressData, graphData]) => {
|
||||
if (!cancelled) {
|
||||
setCriteriaProgress(progressData || {})
|
||||
setGraphState(graphData || null)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCriteriaProgress({})
|
||||
if (!cancelled) {
|
||||
setCriteriaProgress({})
|
||||
setGraphState(null)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
|
@ -173,8 +184,6 @@ export function RoadmapPlanSection({
|
|||
</strong>
|
||||
<p className="muted list-item-sub">
|
||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
||||
{' · '}
|
||||
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
||||
</p>
|
||||
{item.goal_description && (
|
||||
<p className="list-item-desc">{item.goal_description}</p>
|
||||
|
|
@ -190,6 +199,11 @@ export function RoadmapPlanSection({
|
|||
</div>
|
||||
<div className="list-item-meta action-controls">
|
||||
<StatusBadge kind="milestone" status={item.status} />
|
||||
<GateGraphStateBadge
|
||||
graphState={graphState}
|
||||
itemId={item.id}
|
||||
siblingItems={sortedItems}
|
||||
/>
|
||||
{canReorder && !isDesktop && (
|
||||
<ReorderControls
|
||||
itemId={item.id}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import { StatusBadge } from '../../components/StatusBadge.jsx'
|
|||
import {
|
||||
MILESTONE_STATUS_LABELS,
|
||||
ROADMAP_ITEM_TYPE_LABELS,
|
||||
SEQUENCING_MODE_LABELS,
|
||||
} from '../../constants/status.js'
|
||||
import { useCapabilities } from '../../hooks/useCapabilities.js'
|
||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||
|
|
@ -161,8 +160,6 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
||||
{' · '}
|
||||
{MILESTONE_STATUS_LABELS[item.status] || item.status}
|
||||
{' · '}
|
||||
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
||||
</p>
|
||||
{item.goal_description && (
|
||||
<p className="roadmap-item-detail__goal-context">{item.goal_description}</p>
|
||||
|
|
|
|||
|
|
@ -1765,6 +1765,38 @@
|
|||
fill: #f8fafc;
|
||||
}
|
||||
|
||||
.gate-map-view__node--ready .gate-map-view__node-box {
|
||||
stroke: #059669;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
|
||||
.gate-map-view__node--blocked .gate-map-view__node-box {
|
||||
stroke: #dc2626;
|
||||
fill: #fef2f2;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
background: var(--jk-surface-muted, #eef2f6);
|
||||
color: var(--jk-text-secondary, #445);
|
||||
}
|
||||
|
||||
.status-pill--ready {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.status-pill--blocked {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.gate-map-view__node-title {
|
||||
fill: var(--jk-text, #1a1a1a);
|
||||
font-size: 14px;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user