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

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:
Lars 2026-07-11 07:59:18 +02:00
parent bd64340ca1
commit 4f8725e234
15 changed files with 488 additions and 84 deletions

View 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;

View File

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

View File

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

View 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"]

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

View File

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

View 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

View File

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

View File

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

View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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