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):
|
class DependencyCreateRequest(BaseModel):
|
||||||
to_item_id: str
|
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):
|
class CriterionCreateRequest(BaseModel):
|
||||||
|
|
@ -143,6 +146,21 @@ def list_initiative_roadmap_criteria_progress(
|
||||||
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/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)
|
@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,
|
||||||
|
|
@ -419,6 +437,7 @@ def add_roadmap_item_dependency(
|
||||||
from_item_id=item_id,
|
from_item_id=item_id,
|
||||||
to_item_id=body.to_item_id,
|
to_item_id=body.to_item_id,
|
||||||
dependency_type=body.dependency_type,
|
dependency_type=body.dependency_type,
|
||||||
|
group_key=body.group_key,
|
||||||
user_id=ctx.user_id,
|
user_id=ctx.user_id,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,14 @@ RoadmapItemStatus = Literal[
|
||||||
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
||||||
]
|
]
|
||||||
SequencingMode = Literal["sequential", "parallel", "optional"]
|
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_TYPES = frozenset({"milestone", "review_gate", "maturity_stage"})
|
||||||
ROADMAP_ITEM_STATUSES = frozenset(
|
ROADMAP_ITEM_STATUSES = frozenset(
|
||||||
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
|
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
|
||||||
)
|
)
|
||||||
SEQUENCING_MODES = frozenset({"sequential", "parallel", "optional"})
|
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"})
|
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
|
||||||
|
|
||||||
|
|
@ -457,6 +457,18 @@ def delete_roadmap_item(
|
||||||
return deleted
|
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]]:
|
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)
|
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
|
||||||
if not item:
|
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:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute(
|
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
|
FROM roadmap_item_dependencies
|
||||||
WHERE tenant_id = %s AND (from_item_id = %s OR to_item_id = %s)
|
WHERE tenant_id = %s AND (from_item_id = %s OR to_item_id = %s)
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
""",
|
""",
|
||||||
(tenant_id, item_id, item_id),
|
(tenant_id, item_id, item_id),
|
||||||
)
|
)
|
||||||
items = []
|
return [_serialize_dependency(dict(row)) for row in cur.fetchall()]
|
||||||
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
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
@ -499,7 +504,7 @@ def list_dependencies_for_initiative(
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT d.id, d.tenant_id, d.from_item_id, d.to_item_id,
|
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
|
FROM roadmap_item_dependencies d
|
||||||
INNER JOIN roadmap_items fi
|
INNER JOIN roadmap_items fi
|
||||||
ON fi.id = d.from_item_id AND fi.tenant_id = d.tenant_id
|
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),
|
(tenant_id, initiative_id),
|
||||||
)
|
)
|
||||||
items = []
|
return [_serialize_dependency(dict(row)) for row in cur.fetchall()]
|
||||||
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
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
@ -529,6 +526,7 @@ def add_dependency(
|
||||||
from_item_id: str,
|
from_item_id: str,
|
||||||
to_item_id: str,
|
to_item_id: str,
|
||||||
dependency_type: DependencyType = "requires",
|
dependency_type: DependencyType = "requires",
|
||||||
|
group_key: Optional[str] = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
_validate_dependency_type(dependency_type)
|
_validate_dependency_type(dependency_type)
|
||||||
|
|
@ -542,6 +540,12 @@ def add_dependency(
|
||||||
if from_item["initiative_id"] != to_item["initiative_id"]:
|
if from_item["initiative_id"] != to_item["initiative_id"]:
|
||||||
raise ValueError("Abhängigkeiten nur innerhalb eines Vorhabens")
|
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()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
|
@ -559,17 +563,15 @@ def add_dependency(
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO roadmap_item_dependencies (
|
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)
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
|
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())
|
row = _serialize_dependency(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()
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -582,6 +584,7 @@ def add_dependency(
|
||||||
"from_item_id": from_item_id,
|
"from_item_id": from_item_id,
|
||||||
"to_item_id": to_item_id,
|
"to_item_id": to_item_id,
|
||||||
"dependency_type": dependency_type,
|
"dependency_type": dependency_type,
|
||||||
|
"group_key": normalized_group_key,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return row
|
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()
|
body = progress.json()
|
||||||
assert body[item["id"]]["total"] >= 1
|
assert body[item["id"]]["total"] >= 1
|
||||||
assert body[item["id"]]["closed"] == 0
|
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`)
|
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) {
|
export function createInitiativeRoadmapItem(initiativeId, body) {
|
||||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
|
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ const DEPENDENCY_TYPE_LABELS = {
|
||||||
requires: 'Voraussetzung',
|
requires: 'Voraussetzung',
|
||||||
blocks: 'Blockiert',
|
blocks: 'Blockiert',
|
||||||
related: 'Bezug',
|
related: 'Bezug',
|
||||||
|
parallel_group: 'Parallelgruppe',
|
||||||
|
optional_branch: 'Optional',
|
||||||
}
|
}
|
||||||
|
|
||||||
const OUTGOING_PHRASES = {
|
const OUTGOING_PHRASES = {
|
||||||
|
|
@ -56,6 +58,7 @@ function DependencyRow({ dep, itemId, siblingItems, direction, canManage, busy,
|
||||||
</p>
|
</p>
|
||||||
<p className="muted list-item-sub">
|
<p className="muted list-item-sub">
|
||||||
{DEPENDENCY_TYPE_LABELS[dep.dependency_type] || dep.dependency_type}
|
{DEPENDENCY_TYPE_LABELS[dep.dependency_type] || dep.dependency_type}
|
||||||
|
{dep.group_key ? ` · Gruppe ${dep.group_key}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
|
|
@ -92,11 +95,17 @@ export function GateDependenciesSection({
|
||||||
const form = event.target
|
const form = event.target
|
||||||
const toItemId = form.to_item_id.value
|
const toItemId = form.to_item_id.value
|
||||||
const dependencyType = form.dependency_type.value
|
const dependencyType = form.dependency_type.value
|
||||||
|
const groupKey = form.group_key?.value?.trim()
|
||||||
if (!toItemId) return
|
if (!toItemId) return
|
||||||
await onAdd({
|
const body = {
|
||||||
to_item_id: toItemId,
|
to_item_id: toItemId,
|
||||||
dependency_type: dependencyType,
|
dependency_type: dependencyType,
|
||||||
})
|
}
|
||||||
|
if (dependencyType === 'parallel_group') {
|
||||||
|
if (!groupKey) return
|
||||||
|
body.group_key = groupKey
|
||||||
|
}
|
||||||
|
await onAdd(body)
|
||||||
form.reset()
|
form.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,9 +178,20 @@ export function GateDependenciesSection({
|
||||||
<select name="dependency_type" defaultValue="requires">
|
<select name="dependency_type" defaultValue="requires">
|
||||||
<option value="requires">benötigt (Voraussetzung)</option>
|
<option value="requires">benötigt (Voraussetzung)</option>
|
||||||
<option value="blocks">blockiert</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>
|
<option value="related">steht in Bezug zu</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<label>
|
||||||
Anderes Gate
|
Anderes Gate
|
||||||
<select name="to_item_id" required defaultValue="">
|
<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 { useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 { ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js'
|
||||||
import { gatePath } from '../utils/routes.js'
|
import { gatePath } from '../utils/routes.js'
|
||||||
import { computeGateGraphLayout } from '../plan/gateGraphLayout.js'
|
import { computeGateGraphLayout } from '../plan/gateGraphLayout.js'
|
||||||
|
|
@ -21,28 +21,39 @@ function truncateTitle(title, max = 28) {
|
||||||
export function GateMapView({ initiativeId, items }) {
|
export function GateMapView({ initiativeId, items }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [dependencies, setDependencies] = useState([])
|
const [dependencies, setDependencies] = useState([])
|
||||||
|
const [graphState, setGraphState] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initiativeId) {
|
if (!initiativeId) {
|
||||||
setDependencies([])
|
setDependencies([])
|
||||||
|
setGraphState(null)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
listInitiativeRoadmapDependencies(initiativeId)
|
Promise.allSettled([
|
||||||
.then((data) => {
|
listInitiativeRoadmapDependencies(initiativeId),
|
||||||
if (!cancelled) setDependencies(Array.isArray(data) ? data : [])
|
listInitiativeRoadmapGraphState(initiativeId),
|
||||||
})
|
]).then(([depsResult, graphResult]) => {
|
||||||
.catch((err) => {
|
if (cancelled) return
|
||||||
if (!cancelled) setError(err.message || 'Abhängigkeiten konnten nicht geladen werden.')
|
if (depsResult.status === 'fulfilled') {
|
||||||
})
|
setDependencies(Array.isArray(depsResult.value) ? depsResult.value : [])
|
||||||
.finally(() => {
|
} else {
|
||||||
if (!cancelled) setLoading(false)
|
setError(
|
||||||
})
|
depsResult.reason?.message || 'Abhängigkeiten konnten nicht geladen werden.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (graphResult.status === 'fulfilled') {
|
||||||
|
setGraphState(graphResult.value || null)
|
||||||
|
} else {
|
||||||
|
setGraphState(null)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
|
|
@ -140,10 +151,16 @@ export function GateMapView({ initiativeId, items }) {
|
||||||
|
|
||||||
{layout.nodes.map((node) => {
|
{layout.nodes.map((node) => {
|
||||||
const item = node.item
|
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 (
|
return (
|
||||||
<g
|
<g
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className={`gate-map-view__node gate-map-view__node--${item.status || 'planned'}`}
|
className={nodeClass}
|
||||||
transform={`translate(${node.x}, ${node.y})`}
|
transform={`translate(${node.x}, ${node.y})`}
|
||||||
role="link"
|
role="link"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,9 @@ import {
|
||||||
MILESTONE_STATUSES,
|
MILESTONE_STATUSES,
|
||||||
MILESTONE_STATUS_LABELS,
|
MILESTONE_STATUS_LABELS,
|
||||||
ROADMAP_ITEM_TYPE_LABELS,
|
ROADMAP_ITEM_TYPE_LABELS,
|
||||||
SEQUENCING_MODE_LABELS,
|
|
||||||
} from '../constants/status.js'
|
} from '../constants/status.js'
|
||||||
|
|
||||||
const ITEM_TYPES = ['milestone', 'review_gate', 'maturity_stage']
|
const ITEM_TYPES = ['milestone', 'review_gate', 'maturity_stage']
|
||||||
const SEQUENCING_MODES = ['sequential', 'parallel', 'optional']
|
|
||||||
const EDITABLE_STATUSES = MILESTONE_STATUSES.filter(
|
const EDITABLE_STATUSES = MILESTONE_STATUSES.filter(
|
||||||
(s) => !['reached', 'moved', 'discarded'].includes(s),
|
(s) => !['reached', 'moved', 'discarded'].includes(s),
|
||||||
)
|
)
|
||||||
|
|
@ -27,7 +25,7 @@ export function RoadmapItemForm({
|
||||||
goal_description: form.goal_description.value.trim(),
|
goal_description: form.goal_description.value.trim(),
|
||||||
target_date: form.target_date.value || undefined,
|
target_date: form.target_date.value || undefined,
|
||||||
item_type: form.item_type.value,
|
item_type: form.item_type.value,
|
||||||
sequencing_mode: form.sequencing_mode.value,
|
sequencing_mode: initial.sequencing_mode || 'sequential',
|
||||||
}
|
}
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
const initialCriterion = form.initial_criterion_title?.value?.trim()
|
const initialCriterion = form.initial_criterion_title?.value?.trim()
|
||||||
|
|
@ -94,31 +92,19 @@ export function RoadmapItemForm({
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<div className="form-row form-row--2">
|
<label>
|
||||||
<label>
|
Zieltermin
|
||||||
Abfolge
|
<input
|
||||||
<select
|
type="date"
|
||||||
name="sequencing_mode"
|
name="target_date"
|
||||||
defaultValue={initial.sequencing_mode || 'sequential'}
|
defaultValue={initial.target_date || ''}
|
||||||
disabled={statusLocked}
|
disabled={statusLocked}
|
||||||
>
|
/>
|
||||||
{SEQUENCING_MODES.map((m) => (
|
</label>
|
||||||
<option key={m} value={m}>
|
<p className="muted form-hint">
|
||||||
{SEQUENCING_MODE_LABELS[m] || m}
|
Reihenfolge und Parallelität steuerst du über Kanten auf der Gate-Detailseite — nicht mehr
|
||||||
</option>
|
über ein Abfolge-Feld.
|
||||||
))}
|
</p>
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Zieltermin
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
name="target_date"
|
|
||||||
defaultValue={initial.target_date || ''}
|
|
||||||
disabled={statusLocked}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{mode === 'edit' && (
|
{mode === 'edit' && (
|
||||||
<label>
|
<label>
|
||||||
Status
|
Status
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { gatePath } from '../utils/routes.js'
|
import { gatePath } from '../utils/routes.js'
|
||||||
import { listInitiativeRoadmapCriteriaProgress } from '../api/roadmap.js'
|
import { listInitiativeRoadmapCriteriaProgress, listInitiativeRoadmapGraphState } from '../api/roadmap.js'
|
||||||
import {
|
import {
|
||||||
ROADMAP_ITEM_TYPE_LABELS,
|
ROADMAP_ITEM_TYPE_LABELS,
|
||||||
SEQUENCING_MODE_LABELS,
|
|
||||||
} from '../constants/status.js'
|
} from '../constants/status.js'
|
||||||
import { StatusBadge } from './StatusBadge.jsx'
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
|
import { GateGraphStateBadge } from './GateGraphStateBadge.jsx'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
import { Modal } from './Modal.jsx'
|
import { Modal } from './Modal.jsx'
|
||||||
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
|
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
|
||||||
|
|
@ -38,21 +38,32 @@ export function RoadmapPlanSection({
|
||||||
const [dragItemId, setDragItemId] = useState('')
|
const [dragItemId, setDragItemId] = useState('')
|
||||||
const [dropTargetId, setDropTargetId] = useState('')
|
const [dropTargetId, setDropTargetId] = useState('')
|
||||||
const [criteriaProgress, setCriteriaProgress] = useState({})
|
const [criteriaProgress, setCriteriaProgress] = useState({})
|
||||||
|
const [graphState, setGraphState] = useState(null)
|
||||||
const isDesktop = useMinWidth(1024)
|
const isDesktop = useMinWidth(1024)
|
||||||
const canReorder = canManage && typeof onReorder === 'function'
|
const canReorder = canManage && typeof onReorder === 'function'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initiativeId) {
|
if (!initiativeId) {
|
||||||
setCriteriaProgress({})
|
setCriteriaProgress({})
|
||||||
|
setGraphState(null)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
listInitiativeRoadmapCriteriaProgress(initiativeId)
|
Promise.all([
|
||||||
.then((data) => {
|
listInitiativeRoadmapCriteriaProgress(initiativeId),
|
||||||
if (!cancelled) setCriteriaProgress(data || {})
|
listInitiativeRoadmapGraphState(initiativeId),
|
||||||
|
])
|
||||||
|
.then(([progressData, graphData]) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCriteriaProgress(progressData || {})
|
||||||
|
setGraphState(graphData || null)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setCriteriaProgress({})
|
if (!cancelled) {
|
||||||
|
setCriteriaProgress({})
|
||||||
|
setGraphState(null)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
|
@ -173,8 +184,6 @@ export function RoadmapPlanSection({
|
||||||
</strong>
|
</strong>
|
||||||
<p className="muted list-item-sub">
|
<p className="muted list-item-sub">
|
||||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
||||||
{' · '}
|
|
||||||
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
|
||||||
</p>
|
</p>
|
||||||
{item.goal_description && (
|
{item.goal_description && (
|
||||||
<p className="list-item-desc">{item.goal_description}</p>
|
<p className="list-item-desc">{item.goal_description}</p>
|
||||||
|
|
@ -190,6 +199,11 @@ export function RoadmapPlanSection({
|
||||||
</div>
|
</div>
|
||||||
<div className="list-item-meta action-controls">
|
<div className="list-item-meta action-controls">
|
||||||
<StatusBadge kind="milestone" status={item.status} />
|
<StatusBadge kind="milestone" status={item.status} />
|
||||||
|
<GateGraphStateBadge
|
||||||
|
graphState={graphState}
|
||||||
|
itemId={item.id}
|
||||||
|
siblingItems={sortedItems}
|
||||||
|
/>
|
||||||
{canReorder && !isDesktop && (
|
{canReorder && !isDesktop && (
|
||||||
<ReorderControls
|
<ReorderControls
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import { StatusBadge } from '../../components/StatusBadge.jsx'
|
||||||
import {
|
import {
|
||||||
MILESTONE_STATUS_LABELS,
|
MILESTONE_STATUS_LABELS,
|
||||||
ROADMAP_ITEM_TYPE_LABELS,
|
ROADMAP_ITEM_TYPE_LABELS,
|
||||||
SEQUENCING_MODE_LABELS,
|
|
||||||
} from '../../constants/status.js'
|
} from '../../constants/status.js'
|
||||||
import { useCapabilities } from '../../hooks/useCapabilities.js'
|
import { useCapabilities } from '../../hooks/useCapabilities.js'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
|
|
@ -161,8 +160,6 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
||||||
{' · '}
|
{' · '}
|
||||||
{MILESTONE_STATUS_LABELS[item.status] || item.status}
|
{MILESTONE_STATUS_LABELS[item.status] || item.status}
|
||||||
{' · '}
|
|
||||||
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
|
||||||
</p>
|
</p>
|
||||||
{item.goal_description && (
|
{item.goal_description && (
|
||||||
<p className="roadmap-item-detail__goal-context">{item.goal_description}</p>
|
<p className="roadmap-item-detail__goal-context">{item.goal_description}</p>
|
||||||
|
|
|
||||||
|
|
@ -1765,6 +1765,38 @@
|
||||||
fill: #f8fafc;
|
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 {
|
.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