AP1.16c: Durchführungsplan in Plan-Outline und Action-Detail.
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m19s
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 19s
Test Suite / playwright-smoke (push) Successful in 12s

Ready/blocked-Badges, Planning Debt, Vorgänger-Verwaltung — getrennt vom Gate-Designer.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-12 11:25:55 +02:00
parent 1166a0c7f1
commit 8546c7021b
14 changed files with 583 additions and 20 deletions

View File

@ -128,7 +128,7 @@ Phase H2 PM Work Modes AP1.9 ✓ 9a / → 9b9e
Phase H3 Plan Outline AP1.12 + AP1.10 ◐ 12ad, 10c Phase H3 Plan Outline AP1.12 + AP1.10 ◐ 12ad, 10c
Phase I Gate-Graph AP1.13 + AP1.15 ◐ 13a/b, 15ac Phase I Gate-Graph AP1.13 + AP1.15 ◐ 13a/b, 15ac
Phase I2 Plan/Ist Snapshots AP1.14 ✓ Phase I2 Plan/Ist Snapshots AP1.14 ✓
Phase I3 Execution-Plan AP1.16 ◐ 16ab Code, 16cd offen Phase I3 Execution-Plan AP1.16 ◐ 16ab ✓ / 16c UI / 16d offen
Phase J Portfolio AP1.8 ◐ 8a ✓ / 8b deferred Phase J Portfolio AP1.8 ◐ 8a ✓ / 8b deferred
Phase K Archetyp-Steuerung AP2.0 ◐ 2.0ac ✓ / → 2.0df Phase K Archetyp-Steuerung AP2.0 ◐ 2.0ac ✓ / → 2.0df
Phase L Agent Interface AP1.7 ○ Phase L Agent Interface AP1.7 ○

View File

@ -115,7 +115,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Gate Dependencies pflegen | ◐ | AP1.13b Detail | | Gate Dependencies pflegen | ◐ | AP1.13b Detail |
| Zielzustands-Designer | ◐ | AP1.15ac; OR deferred | | Zielzustands-Designer | ◐ | AP1.15ac; OR deferred |
| Plan-Outline | ◐ | AP1.12ad: Baum, Modal, Reorder, Actions; AP-Kanten AP1.16c offen | | Plan-Outline | ◐ | AP1.12ad: 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 | | Profil-Modal (Archetyp/EFS) | ◐ | AP1.10c |
| Modal-Bearbeitung (Gates/Profil) | ◐ | viele Sektionen noch Inline-CRUD | | Modal-Bearbeitung (Gates/Profil) | ◐ | viele Sektionen noch Inline-CRUD |
| Admin-UI | ✗ | | | Admin-UI | ✗ | |

View File

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

View File

@ -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 (
<section className="card action-dependencies-section">
<div className="section-header">
<div>
<h3>Durchführungsplan</h3>
<p className="section-lead muted">
Vorgänger und Nachfolger getrennt vom Zielzustands-Graph.
</p>
</div>
<ExecutionFlowBadge meta={executionMeta} actionStatus={action.status} />
</div>
{error && <p className="error">{error}</p>}
{loading && <p className="muted">Lade Abhängigkeiten </p>}
{!loading && (
<>
<div className="action-dependencies-section__block">
<h4 className="action-dependencies-section__subtitle">Vorgänger</h4>
{predecessors.length === 0 ? (
<EmptyState message="Keine Vorgänger — Arbeitspaket ist unabhängig oder nutzt sort_order." />
) : (
<ul className="item-list">
{predecessors.map((dep) => {
const pred = actionById[dep.predecessor_action_id]
return (
<li key={dep.id} className="list-item card-list-item">
<div className="list-item-main">
<Link to={actionPath(dep.predecessor_action_id)}>
<strong>{pred?.title || dep.predecessor_action_id.slice(0, 8)}</strong>
</Link>
<p className="list-item-sub muted">
<DependencyKindLabel kind={dep.dependency_kind} />
</p>
</div>
{canManage && (
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => handleRemove(dep.id)}
>
Entfernen
</button>
)}
</li>
)
})}
</ul>
)}
</div>
<div className="action-dependencies-section__block">
<h4 className="action-dependencies-section__subtitle">Nachfolger</h4>
{successors.length === 0 ? (
<p className="muted">Keine Nachfolger.</p>
) : (
<ul className="item-list">
{successors.map((dep) => {
const succ = actionById[dep.successor_action_id]
return (
<li key={dep.id} className="list-item card-list-item">
<Link to={actionPath(dep.successor_action_id)}>
<strong>{succ?.title || dep.successor_action_id.slice(0, 8)}</strong>
</Link>
<span className="muted"> </span>
<DependencyKindLabel kind={dep.dependency_kind} />
</li>
)
})}
</ul>
)}
</div>
{canManage && candidatePredecessors.length > 0 && (
<form className="form action-dependencies-section__form" onSubmit={handleAdd}>
<h4 className="action-dependencies-section__subtitle">Vorgänger hinzufügen</h4>
<label>
Arbeitspaket
<select
value={predecessorId}
onChange={(e) => setPredecessorId(e.target.value)}
required
>
<option value=""> wählen </option>
{candidatePredecessors.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
</label>
<label>
Art
<select
value={dependencyKind}
onChange={(e) => setDependencyKind(e.target.value)}
>
{DEP_KIND_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy || !predecessorId}>
Hinzufügen
</button>
</div>
</form>
)}
</>
)}
</section>
)
}

View File

@ -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 (
<span className="badge execution-flow-badge execution-flow-badge--blocked" title="Vorgänger offen">
Wartet
</span>
)
}
if (meta.ready) {
return (
<span className="badge execution-flow-badge execution-flow-badge--ready" title="Keine offenen Vorgänger">
Bereit
</span>
)
}
return null
}
export function DependencyKindLabel({ kind }) {
return <span className="muted">{KIND_LABELS[kind] || kind}</span>
}

View File

@ -9,6 +9,12 @@ import { ActionTaskPanel } from './ActionTaskPanel.jsx'
import { gateTitleById } from './GateSelect.jsx' import { gateTitleById } from './GateSelect.jsx'
import { buildProjectPath, collectProjectSubtreeIds } from '../utils/projectTree.js' import { buildProjectPath, collectProjectSubtreeIds } from '../utils/projectTree.js'
import { actionPath } from '../utils/routes.js' import { actionPath } from '../utils/routes.js'
import {
formatBlockedByTitles,
getActionExecutionMeta,
sortActionsForExecutionPlan,
} from '../utils/executionGraph.js'
import { ExecutionFlowBadge } from './ExecutionFlowBadge.jsx'
function formatProjectContext(projects, projectId) { function formatProjectContext(projects, projectId) {
if (!projectId) return 'Direkt am Vorhaben' if (!projectId) return 'Direkt am Vorhaben'
@ -31,6 +37,7 @@ export function PlanActionsSection({
actorsUsedFallback, actorsUsedFallback,
onReloadActors, onReloadActors,
busy, busy,
executionGraph = null,
}) { }) {
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [expandedActions, setExpandedActions] = useState(() => new Set()) 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)) list = list.filter((a) => a.project_id && subtreeIds.has(a.project_id))
} }
return list.sort((a, b) => { return sortActionsForExecutionPlan(list)
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 || '')
})
}, [actions, hideDone, scopeProjectId, projects]) }, [actions, hideDone, scopeProjectId, projects])
async function handleCreate(payload) { async function handleCreate(payload) {
@ -112,13 +110,25 @@ export function PlanActionsSection({
)} )}
<ul className="item-list plan-actions-list"> <ul className="item-list plan-actions-list">
{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 (
<li key={action.id} className="list-item card-list-item plan-actions-list__item"> <li key={action.id} className="list-item card-list-item plan-actions-list__item">
<div className="plan-actions-list__header"> <div className="plan-actions-list__header">
<div className="list-item-main"> <div className="list-item-main">
<Link to={actionPath(action.id)} className="plan-actions-list__title"> <Link to={actionPath(action.id)} className="plan-actions-list__title">
<strong>{action.title}</strong> <strong>{action.title}</strong>
</Link> </Link>
{action.action_kind === 'planning' && (
<span className="badge execution-flow-badge execution-flow-badge--planning">
Planung
</span>
)}
<p className="list-item-sub muted"> <p className="list-item-sub muted">
{formatProjectContext(projects, action.project_id)} {formatProjectContext(projects, action.project_id)}
</p> </p>
@ -127,11 +137,15 @@ export function PlanActionsSection({
Gate: {gateTitleById(roadmapItems, action.roadmap_item_id)} Gate: {gateTitleById(roadmapItems, action.roadmap_item_id)}
</p> </p>
)} )}
{blockedHint && (
<p className="list-item-sub muted">Wartet auf: {blockedHint}</p>
)}
{action.description && ( {action.description && (
<p className="list-item-desc">{action.description}</p> <p className="list-item-desc">{action.description}</p>
)} )}
</div> </div>
<div className="list-item-meta action-controls"> <div className="list-item-meta action-controls">
<ExecutionFlowBadge meta={execMeta} actionStatus={action.status} />
<StatusBadge kind="action" status={action.status} /> <StatusBadge kind="action" status={action.status} />
<PriorityBadge priority={action.priority} /> <PriorityBadge priority={action.priority} />
<Link to={actionPath(action.id)} className="btn btn-secondary btn-sm"> <Link to={actionPath(action.id)} className="btn btn-secondary btn-sm">
@ -147,7 +161,8 @@ export function PlanActionsSection({
onToggle={() => toggleActionTasks(action.id)} onToggle={() => toggleActionTasks(action.id)}
/> />
</li> </li>
))} )
})}
</ul> </ul>
<Modal open={showCreate} title="Arbeitspaket anlegen" onClose={() => setShowCreate(false)} size="lg"> <Modal open={showCreate} title="Arbeitspaket anlegen" onClose={() => setShowCreate(false)} size="lg">

View File

@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
import { listInitiativeProjects } from '../api/projects.js' import { listInitiativeProjects } from '../api/projects.js'
import { listInitiativeActions } from '../api/initiatives.js' import { listInitiativeActions } from '../api/initiatives.js'
import { listInitiativeBacklog } from '../api/backlog.js' import { listInitiativeBacklog } from '../api/backlog.js'
import { getInitiativeExecutionGraphState } from '../api/executionPlan.js'
import { useProgramScope } from '../context/ProgramScopeContext.jsx' import { useProgramScope } from '../context/ProgramScopeContext.jsx'
import { buildProjectsByParent } from '../utils/projectTree.js' import { buildProjectsByParent } from '../utils/projectTree.js'
import { projectPath } from '../utils/routes.js' import { projectPath } from '../utils/routes.js'
@ -58,6 +59,7 @@ export function PlanOutlineNav() {
const [counts, setCounts] = useState({ inbox: null, work: null }) const [counts, setCounts] = useState({ inbox: null, work: null })
const [projects, setProjects] = useState([]) const [projects, setProjects] = useState([])
const [openActions, setOpenActions] = useState([]) const [openActions, setOpenActions] = useState([])
const [executionGraph, setExecutionGraph] = useState(null)
const [structureOpen, setStructureOpen] = useState( const [structureOpen, setStructureOpen] = useState(
activeKey === 'structure' || Boolean(activeProjectId), activeKey === 'structure' || Boolean(activeProjectId),
) )
@ -100,12 +102,14 @@ export function PlanOutlineNav() {
Promise.all([ Promise.all([
listInitiativeBacklog(initiativeId).catch(() => []), listInitiativeBacklog(initiativeId).catch(() => []),
listInitiativeActions(initiativeId).catch(() => []), listInitiativeActions(initiativeId).catch(() => []),
]).then(([backlog, actions]) => { getInitiativeExecutionGraphState(initiativeId).catch(() => null),
]).then(([backlog, actions, graphState]) => {
if (cancelled) return if (cancelled) return
const open = actions.filter( const open = actions.filter(
(action) => action.status !== 'done' && action.status !== 'discarded', (action) => action.status !== 'done' && action.status !== 'discarded',
) )
setOpenActions(open) setOpenActions(open)
setExecutionGraph(graphState)
setCounts({ setCounts({
inbox: backlog.filter((item) => item.status !== 'converted').length, inbox: backlog.filter((item) => item.status !== 'converted').length,
work: open.length, work: open.length,
@ -222,6 +226,7 @@ export function PlanOutlineNav() {
openActions={openActions} openActions={openActions}
activeActionId={activeActionId} activeActionId={activeActionId}
enabled={workOpen} enabled={workOpen}
executionGraph={executionGraph}
/> />
)} )}
</li> </li>

View File

@ -3,6 +3,8 @@ import { NavLink } from 'react-router-dom'
import { listActionTasks } from '../api/tasks.js' import { listActionTasks } from '../api/tasks.js'
import { actionPath } from '../utils/routes.js' import { actionPath } from '../utils/routes.js'
import { buildTasksByParent, countOpenTasks, MAX_TASK_UI_DEPTH } from '../utils/taskTree.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) { function actionTasksHref(actionId) {
return `${actionPath(actionId)}#tasks` 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 [actionOpen, setActionOpen] = useState(defaultExpanded)
const [expandedTaskIds, setExpandedTaskIds] = useState(() => new Set()) const [expandedTaskIds, setExpandedTaskIds] = useState(() => new Set())
@ -101,6 +103,8 @@ function PlanOutlineActionNode({ action, tasks, activeActionId, defaultExpanded
}) })
} }
const execMeta = getActionExecutionMeta(executionGraph, action.id)
return ( return (
<li> <li>
<div className="analysis-split__nav-row"> <div className="analysis-split__nav-row">
@ -129,6 +133,7 @@ function PlanOutlineActionNode({ action, tasks, activeActionId, defaultExpanded
<span className="analysis-split__nav-meta"> ({openTaskCount})</span> <span className="analysis-split__nav-meta"> ({openTaskCount})</span>
)} )}
</NavLink> </NavLink>
<ExecutionFlowBadge meta={execMeta} actionStatus={action.status} />
</div> </div>
{hasOpenTasks && actionOpen && ( {hasOpenTasks && actionOpen && (
<PlanOutlineTaskTree <PlanOutlineTaskTree
@ -145,7 +150,7 @@ function PlanOutlineActionNode({ action, tasks, activeActionId, defaultExpanded
) )
} }
export function PlanOutlineWorkTree({ openActions, activeActionId, enabled }) { export function PlanOutlineWorkTree({ openActions, activeActionId, enabled, executionGraph = null }) {
const [tasksByAction, setTasksByAction] = useState({}) const [tasksByAction, setTasksByAction] = useState({})
const actionIdsKey = useMemo( const actionIdsKey = useMemo(
() => openActions.map((action) => action.id).join(','), () => openActions.map((action) => action.id).join(','),
@ -181,6 +186,7 @@ export function PlanOutlineWorkTree({ openActions, activeActionId, enabled }) {
tasks={tasksByAction[action.id] || []} tasks={tasksByAction[action.id] || []}
activeActionId={activeActionId} activeActionId={activeActionId}
defaultExpanded={activeActionId === action.id} defaultExpanded={activeActionId === action.id}
executionGraph={executionGraph}
/> />
))} ))}
</ul> </ul>

View File

@ -0,0 +1,27 @@
import { Link } from 'react-router-dom'
export function PlanningDebtBanner({ planningDebt, hrefWithScope }) {
if (!planningDebt?.length || !hrefWithScope) {
return null
}
return (
<div className="planning-debt-banner" role="status">
<p className="planning-debt-banner__title">Planung ausstehend</p>
<ul className="planning-debt-banner__list">
{planningDebt.map((item) => (
<li key={item.roadmap_item_id}>
<strong>{item.title || 'Zielzustand'}</strong>
<span className="muted"> {item.message || 'Durchführungsplan fehlt'}</span>
</li>
))}
</ul>
<p className="muted planning-debt-banner__hint">
Lege Arbeitspakete unter dem aktiven Gate an {' '}
<Link to={hrefWithScope('/plan/gates')}>Zielzustände</Link>
{' · '}
<Link to={hrefWithScope('/plan/work')}>Arbeit</Link>
</p>
</div>
)
}

View File

@ -1,12 +1,17 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { getAction } from '../api/actions.js' 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 { listInitiativeProjects } from '../api/projects.js'
import { listInitiativeRoadmapItems } from '../api/roadmap.js' import { listInitiativeRoadmapItems } from '../api/roadmap.js'
import { updateAction, setActionAssignments } from '../api/actions.js' import { updateAction, setActionAssignments } from '../api/actions.js'
import { createInitiativeBlocker } from '../api/blockers.js' import { createInitiativeBlocker } from '../api/blockers.js'
import { ActionHubCard } from '../components/ActionHubCard.jsx' import { ActionHubCard } from '../components/ActionHubCard.jsx'
import { ActionDependenciesSection } from '../components/ActionDependenciesSection.jsx'
import { ActionForm } from '../components/ActionForm.jsx' import { ActionForm } from '../components/ActionForm.jsx'
import { TasksSection } from '../components/TasksSection.jsx' import { TasksSection } from '../components/TasksSection.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
@ -32,6 +37,8 @@ function ActionDetailBody({
actorsState, actorsState,
projects = [], projects = [],
roadmapItems = [], roadmapItems = [],
allActions = [],
executionGraph = null,
}) { }) {
if (editing) { if (editing) {
return ( return (
@ -78,6 +85,13 @@ function ActionDetailBody({
canManage={capabilities.has('kairo.action.manage')} canManage={capabilities.has('kairo.action.manage')}
roadmapItems={roadmapItems} roadmapItems={roadmapItems}
/> />
<ActionDependenciesSection
initiativeId={action.initiative_id}
action={action}
actions={allActions}
graphState={executionGraph}
canManage={capabilities.has('kairo.action.manage')}
/>
</> </>
) )
} }
@ -90,6 +104,8 @@ function ActionDetailStandalone() {
const [action, setAction] = useState(null) const [action, setAction] = useState(null)
const [projects, setProjects] = useState([]) const [projects, setProjects] = useState([])
const [roadmapItems, setRoadmapItems] = useState([]) const [roadmapItems, setRoadmapItems] = useState([])
const [allActions, setAllActions] = useState([])
const [executionGraph, setExecutionGraph] = useState(null)
const [context, setContext] = useState(null) const [context, setContext] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@ -110,13 +126,17 @@ function ActionDetailStandalone() {
href: actionPath(actionData.id), href: actionPath(actionData.id),
initiativeId: actionData.initiative_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), getInitiativeSteeringSnapshot(actionData.initiative_id),
listInitiativeProjects(actionData.initiative_id).catch(() => []), listInitiativeProjects(actionData.initiative_id).catch(() => []),
listInitiativeRoadmapItems(actionData.initiative_id).catch(() => []), listInitiativeRoadmapItems(actionData.initiative_id).catch(() => []),
listInitiativeActions(actionData.initiative_id).catch(() => []),
getInitiativeExecutionGraphState(actionData.initiative_id).catch(() => null),
]) ])
setProjects(projectData) setProjects(projectData)
setRoadmapItems(roadmapData) setRoadmapItems(roadmapData)
setAllActions(Array.isArray(actionList) ? actionList : [])
setExecutionGraph(graphState)
setContext((snap.actions || []).find((a) => a.id === actionId) || null) setContext((snap.actions || []).find((a) => a.id === actionId) || null)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -232,6 +252,8 @@ function ActionDetailStandalone() {
actorsState={actorsState} actorsState={actorsState}
projects={projects} projects={projects}
roadmapItems={roadmapItems} roadmapItems={roadmapItems}
allActions={allActions}
executionGraph={executionGraph}
/> />
</section> </section>
</div> </div>
@ -244,6 +266,25 @@ function ActionDetailNested() {
const action = ops.actions.find((a) => a.id === actionId) const action = ops.actions.find((a) => a.id === actionId)
const context = ops.actionContextById[actionId] const context = ops.actionContextById[actionId]
const [editing, setEditing] = useState(false) 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) { if (!action) {
return <ErrorState message="Arbeitspaket nicht in diesem Vorhaben." onRetry={ops.reload} /> return <ErrorState message="Arbeitspaket nicht in diesem Vorhaben." onRetry={ops.reload} />
@ -274,6 +315,8 @@ function ActionDetailNested() {
}} }}
projects={ops.projects} projects={ops.projects}
roadmapItems={ops.roadmapItems} roadmapItems={ops.roadmapItems}
allActions={ops.actions}
executionGraph={executionGraph}
/> />
</section> </section>
) )

View File

@ -1,13 +1,17 @@
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx' import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { PlanActionsSection } from '../../components/PlanActionsSection.jsx' import { PlanActionsSection } from '../../components/PlanActionsSection.jsx'
import { PlanningDebtBanner } from '../../components/PlanningDebtBanner.jsx'
import { LoadingState } from '../../components/LoadingState.jsx' import { LoadingState } from '../../components/LoadingState.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx' import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
import { useEffect, useState } from 'react'
import { getInitiativeExecutionGraphState } from '../../api/executionPlan.js'
function PlanWorkInner() { function PlanWorkInner() {
const ops = useInitiativeOperations() const ops = useInitiativeOperations()
const { projectId: scopeProjectId } = useProgramScope() const { initiativeId, projectId: scopeProjectId, hrefWithScope } = useProgramScope()
const [executionGraph, setExecutionGraph] = useState(null)
const { const {
actions, actions,
projects, projects,
@ -26,6 +30,24 @@ function PlanWorkInner() {
handleCreateAction, handleCreateAction,
} = ops } = 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) { if (loading) {
return <LoadingState message="Lade Arbeitspakete …" /> return <LoadingState message="Lade Arbeitspakete …" />
} }
@ -33,6 +55,10 @@ function PlanWorkInner() {
return ( return (
<> <>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<PlanningDebtBanner
planningDebt={executionGraph?.planning_debt}
hrefWithScope={hrefWithScope}
/>
<PlanActionsSection <PlanActionsSection
actions={actions} actions={actions}
projects={projects} projects={projects}
@ -48,6 +74,7 @@ function PlanWorkInner() {
actorsUsedFallback={actorsUsedFallback} actorsUsedFallback={actorsUsedFallback}
onReloadActors={reloadActors} onReloadActors={reloadActors}
busy={formBusy} busy={formBusy}
executionGraph={executionGraph}
/> />
</> </>
) )

View File

@ -486,6 +486,70 @@
gap: 12px; 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 { .action-task-panel {
margin-top: 12px; margin-top: 12px;
padding-top: 12px; padding-top: 12px;

View File

@ -0,0 +1,46 @@
/** @typedef {import('../api/executionPlan.js').*} ExecutionPlanApi */
/**
* @param {Record<string, unknown>|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),
}
}

View File

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