diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py
index 8065d51..a974275 100644
--- a/backend/routers/roadmap.py
+++ b/backend/routers/roadmap.py
@@ -115,6 +115,19 @@ def list_initiative_roadmap_items(
raise HTTPException(status_code=400, detail=str(exc)) from exc
+@initiative_router.get("/{initiative_id}/roadmap/dependencies")
+def list_initiative_roadmap_dependencies(
+ initiative_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
+):
+ try:
+ return roadmap_service.list_dependencies_for_initiative(
+ 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,
diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py
index a74b1d8..036d157 100644
--- a/backend/services/roadmap.py
+++ b/backend/services/roadmap.py
@@ -484,6 +484,42 @@ def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
conn.close()
+def list_dependencies_for_initiative(
+ *, tenant_id: str, initiative_id: str
+) -> list[dict[str, Any]]:
+ if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
+ raise ValueError("Initiative nicht gefunden")
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT d.id, d.tenant_id, d.from_item_id, d.to_item_id,
+ d.dependency_type, 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
+ INNER JOIN roadmaps r
+ ON r.id = fi.roadmap_id AND r.tenant_id = fi.tenant_id
+ WHERE d.tenant_id = %s AND r.initiative_id = %s
+ ORDER BY d.created_at ASC
+ """,
+ (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
+ finally:
+ conn.close()
+
+
def add_dependency(
*,
tenant_id: str,
diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py
index 8f17d92..9ed63fa 100644
--- a/backend/tests/test_ap14_roadmap.py
+++ b/backend/tests/test_ap14_roadmap.py
@@ -120,3 +120,29 @@ def test_steering_snapshot_reads_roadmap_items(client):
assert snap.get("roadmap_items")
assert snap.get("upcoming_milestones")
assert len(snap["upcoming_roadmap_items"]) >= 1
+
+
+def test_initiative_roadmap_dependencies_list(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").json()
+ gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B").json()
+
+ created = client.post(
+ f"/api/roadmap-items/{gate_a['id']}/dependencies",
+ json={"to_item_id": gate_b["id"], "dependency_type": "requires"},
+ headers=_auth(token),
+ )
+ assert created.status_code == 201
+
+ listed = client.get(
+ f"/api/initiatives/{initiative_id}/roadmap/dependencies",
+ headers=_auth(token),
+ )
+ assert listed.status_code == 200
+ deps = listed.json()
+ assert len(deps) == 1
+ assert deps[0]["from_item_id"] == gate_a["id"]
+ assert deps[0]["to_item_id"] == gate_b["id"]
+ assert deps[0]["dependency_type"] == "requires"
diff --git a/frontend/src/api/roadmap.js b/frontend/src/api/roadmap.js
index 40cabf3..4013a12 100644
--- a/frontend/src/api/roadmap.js
+++ b/frontend/src/api/roadmap.js
@@ -4,6 +4,10 @@ export function listInitiativeRoadmapItems(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`)
}
+export function listInitiativeRoadmapDependencies(initiativeId) {
+ return apiFetch(`/api/initiatives/${initiativeId}/roadmap/dependencies`)
+}
+
export function createInitiativeRoadmapItem(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
method: 'POST',
diff --git a/frontend/src/components/GateMapView.jsx b/frontend/src/components/GateMapView.jsx
new file mode 100644
index 0000000..76bf46d
--- /dev/null
+++ b/frontend/src/components/GateMapView.jsx
@@ -0,0 +1,186 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { listInitiativeRoadmapDependencies } 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'
+import { LoadingState } from './LoadingState.jsx'
+import { ErrorState } from './ErrorState.jsx'
+import { EmptyState } from './EmptyState.jsx'
+
+function edgePath(edge) {
+ const midY = (edge.y1 + edge.y2) / 2
+ return `M ${edge.x1} ${edge.y1} C ${edge.x1} ${midY}, ${edge.x2} ${midY}, ${edge.x2} ${edge.y2}`
+}
+
+function truncateTitle(title, max = 28) {
+ if (!title || title.length <= max) return title || 'Gate'
+ return `${title.slice(0, max - 1)}…`
+}
+
+export function GateMapView({ initiativeId, items }) {
+ const navigate = useNavigate()
+ const [dependencies, setDependencies] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!initiativeId) {
+ setDependencies([])
+ 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)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [initiativeId])
+
+ const layout = useMemo(
+ () => computeGateGraphLayout(items, dependencies),
+ [items, dependencies],
+ )
+
+ if (!items.length) {
+ return (
+
+ )
+ }
+
+ if (loading) {
+ return
+ }
+
+ if (error) {
+ return
+ }
+
+ return (
+
+
+
+
Zielzustände (Graph)
+
+ Read-only Übersicht der Gate-Abhängigkeiten — Bearbeitung auf der Gate-Detailseite
+ (AP1.13b).
+
+
+
+
+
+
+
+ Voraussetzung
+
+
+
+ Blockiert
+
+
+
+ Bezug
+
+
+
+
+
+
+
+ {dependencies.length === 0 && (
+
+ Noch keine Kanten — Gates sind nach Reihenfolge angeordnet. Abhängigkeiten können auf den
+ Gate-Detailseiten gepflegt werden (AP1.13b).
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/GatesPlanPanel.jsx b/frontend/src/components/GatesPlanPanel.jsx
new file mode 100644
index 0000000..611e47f
--- /dev/null
+++ b/frontend/src/components/GatesPlanPanel.jsx
@@ -0,0 +1,68 @@
+import { useEffect, useState } from 'react'
+import { RoadmapPlanSection } from './RoadmapPlanSection.jsx'
+import { GateMapView } from './GateMapView.jsx'
+import { resolveGatesViewMode, storeGatesViewMode } from '../plan/gateGraphLayout.js'
+
+export function GatesPlanPanel({
+ initiativeId,
+ items,
+ canManage,
+ onCreate,
+ onReorder,
+ onDelete,
+ busy,
+}) {
+ const [viewMode, setViewMode] = useState(() => resolveGatesViewMode(initiativeId, items.length))
+
+ useEffect(() => {
+ setViewMode(resolveGatesViewMode(initiativeId, items.length))
+ }, [initiativeId, items.length])
+
+ function handleViewChange(mode) {
+ setViewMode(mode)
+ storeGatesViewMode(initiativeId, mode)
+ }
+
+ return (
+
+
+
+
+
+
+ {viewMode === 'list' ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/initiative/InitiativePlanPage.jsx b/frontend/src/pages/initiative/InitiativePlanPage.jsx
index 04fc506..38ea136 100644
--- a/frontend/src/pages/initiative/InitiativePlanPage.jsx
+++ b/frontend/src/pages/initiative/InitiativePlanPage.jsx
@@ -1,5 +1,5 @@
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
-import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx'
+import { GatesPlanPanel } from '../../components/GatesPlanPanel.jsx'
export function InitiativePlanPage() {
const {
@@ -20,7 +20,7 @@ export function InitiativePlanPage() {
return (
<>
{error && {error}
}
-
+ (a.sort_order ?? 0) - (b.sort_order ?? 0) ||
+ (a.title || '').localeCompare(b.title || '') ||
+ a.id.localeCompare(b.id),
+ )
+
+ const layoutEdges = buildLayoutEdges(dependencies)
+ const layerById = new Map()
+
+ for (const item of sortedItems) {
+ layerById.set(item.id, 0)
+ }
+
+ const maxIterations = sortedItems.length + 1
+ for (let i = 0; i < maxIterations; i += 1) {
+ let changed = false
+ for (const edge of layoutEdges) {
+ const fromLayer = layerById.get(edge.from) ?? 0
+ const toLayer = layerById.get(edge.to) ?? 0
+ const nextLayer = fromLayer + 1
+ if (nextLayer > toLayer) {
+ layerById.set(edge.to, nextLayer)
+ changed = true
+ }
+ }
+ if (!changed) break
+ }
+
+ const layers = new Map()
+ for (const item of sortedItems) {
+ const layer = layerById.get(item.id) ?? 0
+ if (!layers.has(layer)) layers.set(layer, [])
+ layers.get(layer).push(item)
+ }
+
+ const layerIndices = [...layers.keys()].sort((a, b) => a - b)
+ const maxLayerWidth = Math.max(
+ ...layerIndices.map((layer) => layers.get(layer).length),
+ 1,
+ )
+
+ const nodes = []
+ for (const layer of layerIndices) {
+ const row = layers.get(layer)
+ const rowWidth =
+ row.length * GATE_NODE_WIDTH + Math.max(0, row.length - 1) * GATE_NODE_GAP_X
+ const canvasWidth =
+ maxLayerWidth * GATE_NODE_WIDTH +
+ Math.max(0, maxLayerWidth - 1) * GATE_NODE_GAP_X +
+ GATE_GRAPH_PADDING * 2
+ const startX = GATE_GRAPH_PADDING + Math.max(0, (canvasWidth - rowWidth) / 2 - GATE_GRAPH_PADDING)
+
+ row.forEach((item, index) => {
+ nodes.push({
+ id: item.id,
+ item,
+ width: GATE_NODE_WIDTH,
+ height: GATE_NODE_HEIGHT,
+ x: startX + index * (GATE_NODE_WIDTH + GATE_NODE_GAP_X),
+ y: GATE_GRAPH_PADDING + layer * (GATE_NODE_HEIGHT + GATE_LAYER_GAP_Y),
+ })
+ })
+ }
+
+ const nodeById = new Map(nodes.map((node) => [node.id, node]))
+ const edges = dependencies
+ .map((dep) => {
+ const sourceNode =
+ dep.dependency_type === 'requires'
+ ? nodeById.get(dep.to_item_id)
+ : nodeById.get(dep.from_item_id)
+ const targetNode =
+ dep.dependency_type === 'requires'
+ ? nodeById.get(dep.from_item_id)
+ : nodeById.get(dep.to_item_id)
+ if (!sourceNode || !targetNode) return null
+
+ const center = (node) => ({
+ x: node.x + node.width / 2,
+ y: node.y + node.height / 2,
+ })
+
+ if (dep.dependency_type === 'related') {
+ const from = center(sourceNode)
+ const to = center(targetNode)
+ return {
+ id: dep.id || `${dep.from_item_id}-${dep.to_item_id}-${dep.dependency_type}`,
+ fromId: dep.from_item_id,
+ toId: dep.to_item_id,
+ dependency_type: dep.dependency_type,
+ x1: from.x,
+ y1: from.y,
+ x2: to.x,
+ y2: to.y,
+ }
+ }
+
+ return {
+ id: dep.id || `${dep.from_item_id}-${dep.to_item_id}-${dep.dependency_type}`,
+ fromId: dep.from_item_id,
+ toId: dep.to_item_id,
+ dependency_type: dep.dependency_type,
+ x1: sourceNode.x + sourceNode.width / 2,
+ y1: sourceNode.y + sourceNode.height,
+ x2: targetNode.x + targetNode.width / 2,
+ y2: targetNode.y,
+ }
+ })
+ .filter(Boolean)
+
+ const width =
+ Math.max(...nodes.map((node) => node.x + node.width), GATE_NODE_WIDTH) + GATE_GRAPH_PADDING
+ const height =
+ Math.max(...nodes.map((node) => node.y + node.height), GATE_NODE_HEIGHT) + GATE_GRAPH_PADDING
+
+ return { nodes, edges, width, height }
+}
+
+/**
+ * @param {string} initiativeId
+ * @param {number} itemCount
+ * @returns {'list' | 'graph'}
+ */
+export function resolveGatesViewMode(initiativeId, itemCount) {
+ if (typeof window !== 'undefined' && initiativeId) {
+ const stored = window.localStorage.getItem(`kairo.plan.gates.view:${initiativeId}`)
+ if (stored === 'list' || stored === 'graph') {
+ return stored
+ }
+ }
+ return itemCount <= 5 ? 'list' : 'graph'
+}
+
+/**
+ * @param {string} initiativeId
+ * @param {'list' | 'graph'} mode
+ */
+export function storeGatesViewMode(initiativeId, mode) {
+ if (typeof window !== 'undefined' && initiativeId) {
+ window.localStorage.setItem(`kairo.plan.gates.view:${initiativeId}`, mode)
+ }
+}
diff --git a/frontend/src/plan/gateGraphLayout.test.js b/frontend/src/plan/gateGraphLayout.test.js
new file mode 100644
index 0000000..3004785
--- /dev/null
+++ b/frontend/src/plan/gateGraphLayout.test.js
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import { buildLayoutEdges, computeGateGraphLayout } from './gateGraphLayout.js'
+
+describe('gateGraphLayout', () => {
+ it('layers requires dependencies with prerequisite above dependent', () => {
+ const items = [
+ { id: 'a', title: 'A', sort_order: 0 },
+ { id: 'b', title: 'B', sort_order: 1 },
+ ]
+ const dependencies = [
+ { id: 'd1', from_item_id: 'a', to_item_id: 'b', dependency_type: 'requires' },
+ ]
+
+ expect(buildLayoutEdges(dependencies)).toEqual([
+ { from: 'b', to: 'a', dependency_type: 'requires' },
+ ])
+
+ const layout = computeGateGraphLayout(items, dependencies)
+ const nodeA = layout.nodes.find((node) => node.id === 'a')
+ const nodeB = layout.nodes.find((node) => node.id === 'b')
+ expect(nodeB.y).toBeLessThan(nodeA.y)
+ })
+
+ it('places items without dependencies on one row by sort_order', () => {
+ const items = [
+ { id: 'b', title: 'B', sort_order: 10 },
+ { id: 'a', title: 'A', sort_order: 0 },
+ ]
+ const layout = computeGateGraphLayout(items, [])
+ expect(layout.nodes.map((node) => node.id)).toEqual(['a', 'b'])
+ expect(layout.nodes[0].y).toBe(layout.nodes[1].y)
+ expect(layout.nodes[0].x).toBeLessThan(layout.nodes[1].x)
+ })
+})
diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css
index 8ff612c..9684f89 100644
--- a/frontend/src/styles/components.css
+++ b/frontend/src/styles/components.css
@@ -1611,3 +1611,152 @@
font-size: 0.875rem;
}
+.gates-plan-panel__toolbar {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.gates-plan-panel__tab {
+ border: 1px solid var(--jk-border, #dde3ea);
+ background: var(--jk-surface, #fff);
+ color: var(--jk-text-secondary, #445);
+ border-radius: var(--jk-radius-md, 8px);
+ padding: 0.4rem 0.85rem;
+ font-size: 0.875rem;
+ cursor: pointer;
+}
+
+.gates-plan-panel__tab--active {
+ background: var(--jk-surface-muted, #eef2f6);
+ color: var(--jk-text, #1a1a1a);
+ border-color: var(--jk-border-strong, #c5ced8);
+ font-weight: 600;
+}
+
+.gate-map-view__legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem 1.5rem;
+ margin-bottom: 0.75rem;
+ font-size: 0.8125rem;
+}
+
+.gate-map-view__legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.gate-map-view__legend-line {
+ display: inline-block;
+ width: 1.75rem;
+ height: 0;
+ border-top-width: 2px;
+ border-top-style: solid;
+}
+
+.gate-map-view__legend-line--requires {
+ border-top-color: #2563eb;
+}
+
+.gate-map-view__legend-line--blocks {
+ border-top-color: #dc2626;
+}
+
+.gate-map-view__legend-line--related {
+ border-top-color: #64748b;
+ border-top-style: dashed;
+}
+
+.gate-map-view__canvas-wrap {
+ overflow: auto;
+ border: 1px solid var(--jk-border, #dde3ea);
+ border-radius: var(--jk-radius-md, 8px);
+ background: var(--jk-surface-subtle, #f8fafc);
+}
+
+.gate-map-view__canvas {
+ display: block;
+ min-width: 100%;
+}
+
+.gate-map-view__edge {
+ fill: none;
+ stroke-width: 2;
+}
+
+.gate-map-view__edge--requires {
+ stroke: #2563eb;
+}
+
+.gate-map-view__edge--blocks {
+ stroke: #dc2626;
+}
+
+.gate-map-view__edge--related {
+ stroke: #64748b;
+ stroke-dasharray: 6 4;
+}
+
+.gate-map-view__arrow--requires {
+ fill: #2563eb;
+}
+
+.gate-map-view__arrow--blocks {
+ fill: #dc2626;
+}
+
+.gate-map-view__node {
+ cursor: pointer;
+}
+
+.gate-map-view__node-box {
+ fill: #fff;
+ stroke: var(--jk-border-strong, #c5ced8);
+ stroke-width: 1.5;
+}
+
+.gate-map-view__node--active .gate-map-view__node-box,
+.gate-map-view__node--planned .gate-map-view__node-box {
+ stroke: #2563eb;
+}
+
+.gate-map-view__node--at_risk .gate-map-view__node-box {
+ stroke: #d97706;
+}
+
+.gate-map-view__node--reached .gate-map-view__node-box {
+ stroke: #16a34a;
+ fill: #f0fdf4;
+}
+
+.gate-map-view__node--discarded .gate-map-view__node-box,
+.gate-map-view__node--moved .gate-map-view__node-box {
+ stroke: #94a3b8;
+ fill: #f8fafc;
+}
+
+.gate-map-view__node-title {
+ fill: var(--jk-text, #1a1a1a);
+ font-size: 14px;
+ font-weight: 600;
+ pointer-events: none;
+}
+
+.gate-map-view__node-meta {
+ fill: var(--jk-text-secondary, #445);
+ font-size: 12px;
+ pointer-events: none;
+}
+
+.gate-map-view__node:focus-visible .gate-map-view__node-box {
+ stroke: #2563eb;
+ stroke-width: 2.5;
+}
+
+.gate-map-view__hint {
+ margin-top: 0.75rem;
+ font-size: 0.875rem;
+}
+