diff --git a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md
index 1f379b8..e02eb97 100644
--- a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md
+++ b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md
@@ -128,7 +128,7 @@ Phase H2 PM Work Modes AP1.9 ✓ 9a / → 9b–9e
Phase H3 Plan Outline AP1.12 + AP1.10 ◐ 12a–d, 10c
Phase I Gate-Graph AP1.13 + AP1.15 ◐ 13a/b, 15a–c
Phase I2 Plan/Ist Snapshots AP1.14 ✓
-Phase I3 Execution-Plan AP1.16 ◐ 16a–b Code, 16c–d offen
+Phase I3 Execution-Plan AP1.16 ◐ 16a–b ✓ / 16c UI / 16d offen
Phase J Portfolio AP1.8 ◐ 8a ✓ / 8b deferred
Phase K Archetyp-Steuerung AP2.0 ◐ 2.0a–c ✓ / → 2.0d–f
Phase L Agent Interface AP1.7 ○
diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
index 149c16a..3eb417b 100644
--- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
+++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
@@ -115,7 +115,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Gate Dependencies pflegen | ◐ | AP1.13b Detail |
| Zielzustands-Designer | ◐ | AP1.15a–c; OR deferred |
| Plan-Outline | ◐ | AP1.12a–d: Baum, Modal, Reorder, Actions; AP-Kanten AP1.16c offen |
-| Execution-Plan (AP-Graph) | ✗ | 📄 AP1.16; Outline „Arbeit“, nicht Gate-Designer |
+| Execution-Plan (AP-Graph) | ◐ | AP1.16c: Outline + Arbeit-Liste + Action-Detail |
| Profil-Modal (Archetyp/EFS) | ◐ | AP1.10c |
| Modal-Bearbeitung (Gates/Profil) | ◐ | viele Sektionen noch Inline-CRUD |
| Admin-UI | ✗ | |
diff --git a/frontend/src/api/executionPlan.js b/frontend/src/api/executionPlan.js
new file mode 100644
index 0000000..d0f387e
--- /dev/null
+++ b/frontend/src/api/executionPlan.js
@@ -0,0 +1,29 @@
+import { apiFetch } from './client.js'
+
+export function getInitiativeExecutionGraphState(initiativeId, { scopeRoadmapItemId } = {}) {
+ const params = new URLSearchParams()
+ if (scopeRoadmapItemId) {
+ params.set('scope_roadmap_item_id', scopeRoadmapItemId)
+ }
+ const query = params.toString()
+ const suffix = query ? `?${query}` : ''
+ return apiFetch(`/api/initiatives/${initiativeId}/execution/graph-state${suffix}`)
+}
+
+export function listInitiativeActionDependencies(initiativeId) {
+ return apiFetch(`/api/initiatives/${initiativeId}/execution/dependencies`)
+}
+
+export function createInitiativeActionDependency(initiativeId, payload) {
+ return apiFetch(`/api/initiatives/${initiativeId}/execution/dependencies`, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ })
+}
+
+export function deleteInitiativeActionDependency(initiativeId, dependencyId) {
+ return apiFetch(
+ `/api/initiatives/${initiativeId}/execution/dependencies/${dependencyId}`,
+ { method: 'DELETE' },
+ )
+}
diff --git a/frontend/src/components/ActionDependenciesSection.jsx b/frontend/src/components/ActionDependenciesSection.jsx
new file mode 100644
index 0000000..8829fed
--- /dev/null
+++ b/frontend/src/components/ActionDependenciesSection.jsx
@@ -0,0 +1,225 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import {
+ createInitiativeActionDependency,
+ deleteInitiativeActionDependency,
+ listInitiativeActionDependencies,
+} from '../api/executionPlan.js'
+import { actionPath } from '../utils/routes.js'
+import { dependenciesForAction } from '../utils/executionGraph.js'
+import { DependencyKindLabel, ExecutionFlowBadge } from './ExecutionFlowBadge.jsx'
+import { EmptyState } from './EmptyState.jsx'
+
+const DEP_KIND_OPTIONS = [
+ { value: 'requires', label: 'Benötigt (Vorgänger done)' },
+ { value: 'blocks', label: 'Blockiert solange offen' },
+ { value: 'relates', label: 'Bezug (kein Block)' },
+]
+
+export function ActionDependenciesSection({
+ initiativeId,
+ action,
+ actions = [],
+ graphState = null,
+ canManage = false,
+}) {
+ const [dependencies, setDependencies] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [predecessorId, setPredecessorId] = useState('')
+ const [dependencyKind, setDependencyKind] = useState('requires')
+
+ const actionById = useMemo(
+ () => Object.fromEntries(actions.map((item) => [item.id, item])),
+ [actions],
+ )
+
+ const load = useCallback(async () => {
+ if (!initiativeId) return
+ setLoading(true)
+ setError(null)
+ try {
+ const rows = await listInitiativeActionDependencies(initiativeId)
+ setDependencies(Array.isArray(rows) ? rows : [])
+ } catch (err) {
+ setError(err.message)
+ setDependencies([])
+ } finally {
+ setLoading(false)
+ }
+ }, [initiativeId])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ const { predecessors, successors } = useMemo(
+ () => dependenciesForAction(dependencies, action.id),
+ [dependencies, action.id],
+ )
+
+ const candidatePredecessors = useMemo(() => {
+ const existingPreds = new Set(predecessors.map((dep) => dep.predecessor_action_id))
+ return actions.filter(
+ (item) =>
+ item.id !== action.id &&
+ !existingPreds.has(item.id) &&
+ item.status !== 'discarded',
+ )
+ }, [actions, action.id, predecessors])
+
+ const executionMeta = graphState?.items?.[action.id] || null
+
+ async function handleAdd(e) {
+ e.preventDefault()
+ if (!predecessorId || !canManage) return
+ setBusy(true)
+ setError(null)
+ try {
+ await createInitiativeActionDependency(initiativeId, {
+ predecessor_action_id: predecessorId,
+ successor_action_id: action.id,
+ dependency_kind: dependencyKind,
+ })
+ setPredecessorId('')
+ setDependencyKind('requires')
+ await load()
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ async function handleRemove(dependencyId) {
+ if (!canManage) return
+ setBusy(true)
+ setError(null)
+ try {
+ await deleteInitiativeActionDependency(initiativeId, dependencyId)
+ await load()
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
+
Durchführungsplan
+
+ Vorgänger und Nachfolger — getrennt vom Zielzustands-Graph.
+
+
+
+
+
+ {error && {error}
}
+ {loading && Lade Abhängigkeiten …
}
+
+ {!loading && (
+ <>
+
+
Vorgänger
+ {predecessors.length === 0 ? (
+
+ ) : (
+
+ {predecessors.map((dep) => {
+ const pred = actionById[dep.predecessor_action_id]
+ return (
+ -
+
+
+
{pred?.title || dep.predecessor_action_id.slice(0, 8)}
+
+
+
+
+
+ {canManage && (
+
+ )}
+
+ )
+ })}
+
+ )}
+
+
+
+
Nachfolger
+ {successors.length === 0 ? (
+
Keine Nachfolger.
+ ) : (
+
+ {successors.map((dep) => {
+ const succ = actionById[dep.successor_action_id]
+ return (
+ -
+
+ {succ?.title || dep.successor_action_id.slice(0, 8)}
+
+ —
+
+
+ )
+ })}
+
+ )}
+
+
+ {canManage && candidatePredecessors.length > 0 && (
+
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ExecutionFlowBadge.jsx b/frontend/src/components/ExecutionFlowBadge.jsx
new file mode 100644
index 0000000..264a5d4
--- /dev/null
+++ b/frontend/src/components/ExecutionFlowBadge.jsx
@@ -0,0 +1,31 @@
+const KIND_LABELS = {
+ requires: 'benötigt',
+ blocks: 'blockiert',
+ relates: 'bezogen',
+}
+
+export function ExecutionFlowBadge({ meta, actionStatus }) {
+ if (!meta) return null
+ if (actionStatus === 'done' || actionStatus === 'discarded') {
+ return null
+ }
+ if (meta.blocked) {
+ return (
+
+ Wartet
+
+ )
+ }
+ if (meta.ready) {
+ return (
+
+ Bereit
+
+ )
+ }
+ return null
+}
+
+export function DependencyKindLabel({ kind }) {
+ return {KIND_LABELS[kind] || kind}
+}
diff --git a/frontend/src/components/PlanActionsSection.jsx b/frontend/src/components/PlanActionsSection.jsx
index 6379b8f..92d7d56 100644
--- a/frontend/src/components/PlanActionsSection.jsx
+++ b/frontend/src/components/PlanActionsSection.jsx
@@ -9,6 +9,12 @@ import { ActionTaskPanel } from './ActionTaskPanel.jsx'
import { gateTitleById } from './GateSelect.jsx'
import { buildProjectPath, collectProjectSubtreeIds } from '../utils/projectTree.js'
import { actionPath } from '../utils/routes.js'
+import {
+ formatBlockedByTitles,
+ getActionExecutionMeta,
+ sortActionsForExecutionPlan,
+} from '../utils/executionGraph.js'
+import { ExecutionFlowBadge } from './ExecutionFlowBadge.jsx'
function formatProjectContext(projects, projectId) {
if (!projectId) return 'Direkt am Vorhaben'
@@ -31,6 +37,7 @@ export function PlanActionsSection({
actorsUsedFallback,
onReloadActors,
busy,
+ executionGraph = null,
}) {
const [showCreate, setShowCreate] = useState(false)
const [expandedActions, setExpandedActions] = useState(() => new Set())
@@ -45,16 +52,7 @@ export function PlanActionsSection({
list = list.filter((a) => a.project_id && subtreeIds.has(a.project_id))
}
- return list.sort((a, b) => {
- const projectA = a.project_id || ''
- const projectB = b.project_id || ''
- if (projectA !== projectB) {
- if (!projectA) return -1
- if (!projectB) return 1
- return projectA.localeCompare(projectB)
- }
- return (a.title || '').localeCompare(b.title || '')
- })
+ return sortActionsForExecutionPlan(list)
}, [actions, hideDone, scopeProjectId, projects])
async function handleCreate(payload) {
@@ -112,13 +110,25 @@ export function PlanActionsSection({
)}
- {visibleActions.map((action) => (
+ {visibleActions.map((action) => {
+ const execMeta = getActionExecutionMeta(executionGraph, action.id)
+ const blockedHint =
+ execMeta?.blocked_by?.length > 0
+ ? formatBlockedByTitles(actions, execMeta.blocked_by)
+ : null
+
+ return (
-
{action.title}
+ {action.action_kind === 'planning' && (
+
+ Planung
+
+ )}
{formatProjectContext(projects, action.project_id)}
@@ -127,11 +137,15 @@ export function PlanActionsSection({
Gate: {gateTitleById(roadmapItems, action.roadmap_item_id)}
)}
+ {blockedHint && (
+
Wartet auf: {blockedHint}
+ )}
{action.description && (
{action.description}
)}
+
@@ -147,7 +161,8 @@ export function PlanActionsSection({
onToggle={() => toggleActionTasks(action.id)}
/>
- ))}
+ )
+ })}
setShowCreate(false)} size="lg">
diff --git a/frontend/src/components/PlanOutlineNav.jsx b/frontend/src/components/PlanOutlineNav.jsx
index 404841c..fe24c54 100644
--- a/frontend/src/components/PlanOutlineNav.jsx
+++ b/frontend/src/components/PlanOutlineNav.jsx
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
import { listInitiativeProjects } from '../api/projects.js'
import { listInitiativeActions } from '../api/initiatives.js'
import { listInitiativeBacklog } from '../api/backlog.js'
+import { getInitiativeExecutionGraphState } from '../api/executionPlan.js'
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
import { buildProjectsByParent } from '../utils/projectTree.js'
import { projectPath } from '../utils/routes.js'
@@ -58,6 +59,7 @@ export function PlanOutlineNav() {
const [counts, setCounts] = useState({ inbox: null, work: null })
const [projects, setProjects] = useState([])
const [openActions, setOpenActions] = useState([])
+ const [executionGraph, setExecutionGraph] = useState(null)
const [structureOpen, setStructureOpen] = useState(
activeKey === 'structure' || Boolean(activeProjectId),
)
@@ -100,12 +102,14 @@ export function PlanOutlineNav() {
Promise.all([
listInitiativeBacklog(initiativeId).catch(() => []),
listInitiativeActions(initiativeId).catch(() => []),
- ]).then(([backlog, actions]) => {
+ getInitiativeExecutionGraphState(initiativeId).catch(() => null),
+ ]).then(([backlog, actions, graphState]) => {
if (cancelled) return
const open = actions.filter(
(action) => action.status !== 'done' && action.status !== 'discarded',
)
setOpenActions(open)
+ setExecutionGraph(graphState)
setCounts({
inbox: backlog.filter((item) => item.status !== 'converted').length,
work: open.length,
@@ -222,6 +226,7 @@ export function PlanOutlineNav() {
openActions={openActions}
activeActionId={activeActionId}
enabled={workOpen}
+ executionGraph={executionGraph}
/>
)}
diff --git a/frontend/src/components/PlanOutlineWorkTree.jsx b/frontend/src/components/PlanOutlineWorkTree.jsx
index be085c1..3aee374 100644
--- a/frontend/src/components/PlanOutlineWorkTree.jsx
+++ b/frontend/src/components/PlanOutlineWorkTree.jsx
@@ -3,6 +3,8 @@ import { NavLink } from 'react-router-dom'
import { listActionTasks } from '../api/tasks.js'
import { actionPath } from '../utils/routes.js'
import { buildTasksByParent, countOpenTasks, MAX_TASK_UI_DEPTH } from '../utils/taskTree.js'
+import { getActionExecutionMeta } from '../utils/executionGraph.js'
+import { ExecutionFlowBadge } from './ExecutionFlowBadge.jsx'
function actionTasksHref(actionId) {
return `${actionPath(actionId)}#tasks`
@@ -80,7 +82,7 @@ function PlanOutlineTaskTree({
)
}
-function PlanOutlineActionNode({ action, tasks, activeActionId, defaultExpanded }) {
+function PlanOutlineActionNode({ action, tasks, activeActionId, defaultExpanded, executionGraph }) {
const [actionOpen, setActionOpen] = useState(defaultExpanded)
const [expandedTaskIds, setExpandedTaskIds] = useState(() => new Set())
@@ -101,6 +103,8 @@ function PlanOutlineActionNode({ action, tasks, activeActionId, defaultExpanded
})
}
+ const execMeta = getActionExecutionMeta(executionGraph, action.id)
+
return (
-
@@ -129,6 +133,7 @@ function PlanOutlineActionNode({ action, tasks, activeActionId, defaultExpanded
({openTaskCount})
)}
+
{hasOpenTasks && actionOpen && (
openActions.map((action) => action.id).join(','),
@@ -181,6 +186,7 @@ export function PlanOutlineWorkTree({ openActions, activeActionId, enabled }) {
tasks={tasksByAction[action.id] || []}
activeActionId={activeActionId}
defaultExpanded={activeActionId === action.id}
+ executionGraph={executionGraph}
/>
))}
diff --git a/frontend/src/components/PlanningDebtBanner.jsx b/frontend/src/components/PlanningDebtBanner.jsx
new file mode 100644
index 0000000..16b7c08
--- /dev/null
+++ b/frontend/src/components/PlanningDebtBanner.jsx
@@ -0,0 +1,27 @@
+import { Link } from 'react-router-dom'
+
+export function PlanningDebtBanner({ planningDebt, hrefWithScope }) {
+ if (!planningDebt?.length || !hrefWithScope) {
+ return null
+ }
+
+ return (
+
+
Planung ausstehend
+
+ {planningDebt.map((item) => (
+ -
+ {item.title || 'Zielzustand'}
+ — {item.message || 'Durchführungsplan fehlt'}
+
+ ))}
+
+
+ Lege Arbeitspakete unter dem aktiven Gate an —{' '}
+ Zielzustände
+ {' · '}
+ Arbeit
+
+
+ )
+}
diff --git a/frontend/src/pages/ActionDetailPage.jsx b/frontend/src/pages/ActionDetailPage.jsx
index d6d3fb5..037938f 100644
--- a/frontend/src/pages/ActionDetailPage.jsx
+++ b/frontend/src/pages/ActionDetailPage.jsx
@@ -1,12 +1,17 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { getAction } from '../api/actions.js'
-import { getInitiativeSteeringSnapshot } from '../api/initiatives.js'
+import {
+ getInitiativeSteeringSnapshot,
+ listInitiativeActions,
+} from '../api/initiatives.js'
+import { getInitiativeExecutionGraphState } from '../api/executionPlan.js'
import { listInitiativeProjects } from '../api/projects.js'
import { listInitiativeRoadmapItems } from '../api/roadmap.js'
import { updateAction, setActionAssignments } from '../api/actions.js'
import { createInitiativeBlocker } from '../api/blockers.js'
import { ActionHubCard } from '../components/ActionHubCard.jsx'
+import { ActionDependenciesSection } from '../components/ActionDependenciesSection.jsx'
import { ActionForm } from '../components/ActionForm.jsx'
import { TasksSection } from '../components/TasksSection.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
@@ -32,6 +37,8 @@ function ActionDetailBody({
actorsState,
projects = [],
roadmapItems = [],
+ allActions = [],
+ executionGraph = null,
}) {
if (editing) {
return (
@@ -78,6 +85,13 @@ function ActionDetailBody({
canManage={capabilities.has('kairo.action.manage')}
roadmapItems={roadmapItems}
/>
+
>
)
}
@@ -90,6 +104,8 @@ function ActionDetailStandalone() {
const [action, setAction] = useState(null)
const [projects, setProjects] = useState([])
const [roadmapItems, setRoadmapItems] = useState([])
+ const [allActions, setAllActions] = useState([])
+ const [executionGraph, setExecutionGraph] = useState(null)
const [context, setContext] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -110,13 +126,17 @@ function ActionDetailStandalone() {
href: actionPath(actionData.id),
initiativeId: actionData.initiative_id,
})
- const [snap, projectData, roadmapData] = await Promise.all([
+ const [snap, projectData, roadmapData, actionList, graphState] = await Promise.all([
getInitiativeSteeringSnapshot(actionData.initiative_id),
listInitiativeProjects(actionData.initiative_id).catch(() => []),
listInitiativeRoadmapItems(actionData.initiative_id).catch(() => []),
+ listInitiativeActions(actionData.initiative_id).catch(() => []),
+ getInitiativeExecutionGraphState(actionData.initiative_id).catch(() => null),
])
setProjects(projectData)
setRoadmapItems(roadmapData)
+ setAllActions(Array.isArray(actionList) ? actionList : [])
+ setExecutionGraph(graphState)
setContext((snap.actions || []).find((a) => a.id === actionId) || null)
} catch (err) {
setError(err.message)
@@ -232,6 +252,8 @@ function ActionDetailStandalone() {
actorsState={actorsState}
projects={projects}
roadmapItems={roadmapItems}
+ allActions={allActions}
+ executionGraph={executionGraph}
/>
@@ -244,6 +266,25 @@ function ActionDetailNested() {
const action = ops.actions.find((a) => a.id === actionId)
const context = ops.actionContextById[actionId]
const [editing, setEditing] = useState(false)
+ const [executionGraph, setExecutionGraph] = useState(null)
+
+ useEffect(() => {
+ if (!ops.initiativeId) {
+ setExecutionGraph(null)
+ return undefined
+ }
+ let cancelled = false
+ getInitiativeExecutionGraphState(ops.initiativeId)
+ .then((state) => {
+ if (!cancelled) setExecutionGraph(state)
+ })
+ .catch(() => {
+ if (!cancelled) setExecutionGraph(null)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [ops.initiativeId, ops.actions.length])
if (!action) {
return
@@ -274,6 +315,8 @@ function ActionDetailNested() {
}}
projects={ops.projects}
roadmapItems={ops.roadmapItems}
+ allActions={ops.actions}
+ executionGraph={executionGraph}
/>
)
diff --git a/frontend/src/pages/modes/PlanWorkPage.jsx b/frontend/src/pages/modes/PlanWorkPage.jsx
index abd88f6..a6dc21c 100644
--- a/frontend/src/pages/modes/PlanWorkPage.jsx
+++ b/frontend/src/pages/modes/PlanWorkPage.jsx
@@ -1,13 +1,17 @@
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { PlanActionsSection } from '../../components/PlanActionsSection.jsx'
+import { PlanningDebtBanner } from '../../components/PlanningDebtBanner.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
+import { useEffect, useState } from 'react'
+import { getInitiativeExecutionGraphState } from '../../api/executionPlan.js'
function PlanWorkInner() {
const ops = useInitiativeOperations()
- const { projectId: scopeProjectId } = useProgramScope()
+ const { initiativeId, projectId: scopeProjectId, hrefWithScope } = useProgramScope()
+ const [executionGraph, setExecutionGraph] = useState(null)
const {
actions,
projects,
@@ -26,6 +30,24 @@ function PlanWorkInner() {
handleCreateAction,
} = ops
+ useEffect(() => {
+ if (!initiativeId) {
+ setExecutionGraph(null)
+ return undefined
+ }
+ let cancelled = false
+ getInitiativeExecutionGraphState(initiativeId)
+ .then((state) => {
+ if (!cancelled) setExecutionGraph(state)
+ })
+ .catch(() => {
+ if (!cancelled) setExecutionGraph(null)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [initiativeId, actions.length])
+
if (loading) {
return
}
@@ -33,6 +55,10 @@ function PlanWorkInner() {
return (
<>
{error &&
{error}
}
+
>
)
diff --git a/frontend/src/styles/program-chrome.css b/frontend/src/styles/program-chrome.css
index 02d426b..d1bd38f 100644
--- a/frontend/src/styles/program-chrome.css
+++ b/frontend/src/styles/program-chrome.css
@@ -486,6 +486,70 @@
gap: 12px;
}
+.execution-flow-badge {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+.execution-flow-badge--ready {
+ background: color-mix(in srgb, var(--jk-success, #059669) 15%, transparent);
+ color: var(--jk-success, #059669);
+}
+
+.execution-flow-badge--blocked {
+ background: color-mix(in srgb, var(--jk-warning, #d97706) 15%, transparent);
+ color: var(--jk-warning, #d97706);
+}
+
+.execution-flow-badge--planning {
+ background: color-mix(in srgb, var(--jk-primary, #2563eb) 12%, transparent);
+ color: var(--jk-primary, #2563eb);
+}
+
+.analysis-split__nav-row .execution-flow-badge {
+ margin-left: 4px;
+ flex-shrink: 0;
+}
+
+.planning-debt-banner {
+ margin-bottom: 16px;
+ padding: 12px 14px;
+ border: 1px solid color-mix(in srgb, var(--jk-warning, #d97706) 35%, transparent);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--jk-warning, #d97706) 8%, transparent);
+}
+
+.planning-debt-banner__title {
+ margin: 0 0 8px;
+ font-weight: 600;
+}
+
+.planning-debt-banner__list {
+ margin: 0 0 8px;
+ padding-left: 18px;
+}
+
+.action-dependencies-section {
+ margin-top: 16px;
+}
+
+.action-dependencies-section__block + .action-dependencies-section__block {
+ margin-top: 16px;
+}
+
+.action-dependencies-section__subtitle {
+ margin: 0 0 8px;
+ font-size: 14px;
+}
+
+.action-dependencies-section__form {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid var(--jk-border-subtle, #e5e7eb);
+}
+
.action-task-panel {
margin-top: 12px;
padding-top: 12px;
diff --git a/frontend/src/utils/executionGraph.js b/frontend/src/utils/executionGraph.js
new file mode 100644
index 0000000..246e1b2
--- /dev/null
+++ b/frontend/src/utils/executionGraph.js
@@ -0,0 +1,46 @@
+/** @typedef {import('../api/executionPlan.js').*} ExecutionPlanApi */
+
+/**
+ * @param {Record
|null|undefined} graphState
+ * @param {string} actionId
+ */
+export function getActionExecutionMeta(graphState, actionId) {
+ if (!graphState?.items || !actionId) return null
+ return graphState.items[actionId] || null
+}
+
+/**
+ * @param {Array<{ id: string, title?: string }>} actions
+ * @param {string[]} blockedByIds
+ */
+export function formatBlockedByTitles(actions, blockedByIds) {
+ if (!blockedByIds?.length) return ''
+ const byId = Object.fromEntries(actions.map((a) => [a.id, a]))
+ return blockedByIds
+ .map((id) => byId[id]?.title || id.slice(0, 8))
+ .join(', ')
+}
+
+/**
+ * Sort actions for Durchführungsplan display (sort_order, then title).
+ * @param {Array<{ sort_order?: number, title?: string }>} actions
+ */
+export function sortActionsForExecutionPlan(actions) {
+ return [...actions].sort((a, b) => {
+ const orderDiff = (a.sort_order ?? 0) - (b.sort_order ?? 0)
+ if (orderDiff !== 0) return orderDiff
+ return (a.title || '').localeCompare(b.title || '', 'de')
+ })
+}
+
+/**
+ * @param {Array<{ successor_action_id: string, predecessor_action_id: string, dependency_kind?: string, id: string }>} dependencies
+ * @param {string} actionId
+ */
+export function dependenciesForAction(dependencies, actionId) {
+ const list = Array.isArray(dependencies) ? dependencies : []
+ return {
+ predecessors: list.filter((dep) => dep.successor_action_id === actionId),
+ successors: list.filter((dep) => dep.predecessor_action_id === actionId),
+ }
+}
diff --git a/frontend/src/utils/executionGraph.test.js b/frontend/src/utils/executionGraph.test.js
new file mode 100644
index 0000000..db4cfd2
--- /dev/null
+++ b/frontend/src/utils/executionGraph.test.js
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest'
+import {
+ dependenciesForAction,
+ formatBlockedByTitles,
+ getActionExecutionMeta,
+ sortActionsForExecutionPlan,
+} from './executionGraph.js'
+
+describe('executionGraph utils', () => {
+ it('reads action meta from graph state', () => {
+ const state = {
+ items: {
+ a1: { ready: true, blocked: false, blocked_by: [] },
+ },
+ }
+ expect(getActionExecutionMeta(state, 'a1')?.ready).toBe(true)
+ expect(getActionExecutionMeta(state, 'missing')).toBeNull()
+ })
+
+ it('formats blocked-by titles', () => {
+ const actions = [{ id: 'x', title: 'Foundation' }]
+ expect(formatBlockedByTitles(actions, ['x'])).toBe('Foundation')
+ })
+
+ it('sorts by sort_order', () => {
+ const sorted = sortActionsForExecutionPlan([
+ { id: '2', title: 'B', sort_order: 2 },
+ { id: '1', title: 'A', sort_order: 1 },
+ ])
+ expect(sorted.map((a) => a.id)).toEqual(['1', '2'])
+ })
+
+ it('splits predecessors and successors', () => {
+ const deps = [
+ {
+ id: 'd1',
+ predecessor_action_id: 'p1',
+ successor_action_id: 's1',
+ dependency_kind: 'requires',
+ },
+ ]
+ expect(dependenciesForAction(deps, 's1').predecessors).toHaveLength(1)
+ expect(dependenciesForAction(deps, 'p1').successors).toHaveLength(1)
+ })
+})