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
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:
parent
1166a0c7f1
commit
8546c7021b
|
|
@ -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 ○
|
||||
|
|
|
|||
|
|
@ -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 | ✗ | |
|
||||
|
|
|
|||
29
frontend/src/api/executionPlan.js
Normal file
29
frontend/src/api/executionPlan.js
Normal 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' },
|
||||
)
|
||||
}
|
||||
225
frontend/src/components/ActionDependenciesSection.jsx
Normal file
225
frontend/src/components/ActionDependenciesSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
frontend/src/components/ExecutionFlowBadge.jsx
Normal file
31
frontend/src/components/ExecutionFlowBadge.jsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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({
|
|||
)}
|
||||
|
||||
<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">
|
||||
<div className="plan-actions-list__header">
|
||||
<div className="list-item-main">
|
||||
<Link to={actionPath(action.id)} className="plan-actions-list__title">
|
||||
<strong>{action.title}</strong>
|
||||
</Link>
|
||||
{action.action_kind === 'planning' && (
|
||||
<span className="badge execution-flow-badge execution-flow-badge--planning">
|
||||
Planung
|
||||
</span>
|
||||
)}
|
||||
<p className="list-item-sub muted">
|
||||
{formatProjectContext(projects, action.project_id)}
|
||||
</p>
|
||||
|
|
@ -127,11 +137,15 @@ export function PlanActionsSection({
|
|||
Gate: {gateTitleById(roadmapItems, action.roadmap_item_id)}
|
||||
</p>
|
||||
)}
|
||||
{blockedHint && (
|
||||
<p className="list-item-sub muted">Wartet auf: {blockedHint}</p>
|
||||
)}
|
||||
{action.description && (
|
||||
<p className="list-item-desc">{action.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-item-meta action-controls">
|
||||
<ExecutionFlowBadge meta={execMeta} actionStatus={action.status} />
|
||||
<StatusBadge kind="action" status={action.status} />
|
||||
<PriorityBadge priority={action.priority} />
|
||||
<Link to={actionPath(action.id)} className="btn btn-secondary btn-sm">
|
||||
|
|
@ -147,7 +161,8 @@ export function PlanActionsSection({
|
|||
onToggle={() => toggleActionTasks(action.id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<Modal open={showCreate} title="Arbeitspaket anlegen" onClose={() => setShowCreate(false)} size="lg">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<li>
|
||||
<div className="analysis-split__nav-row">
|
||||
|
|
@ -129,6 +133,7 @@ function PlanOutlineActionNode({ action, tasks, activeActionId, defaultExpanded
|
|||
<span className="analysis-split__nav-meta"> ({openTaskCount})</span>
|
||||
)}
|
||||
</NavLink>
|
||||
<ExecutionFlowBadge meta={execMeta} actionStatus={action.status} />
|
||||
</div>
|
||||
{hasOpenTasks && actionOpen && (
|
||||
<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 actionIdsKey = useMemo(
|
||||
() => 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}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
27
frontend/src/components/PlanningDebtBanner.jsx
Normal file
27
frontend/src/components/PlanningDebtBanner.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
<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 [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}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -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 <ErrorState message="Arbeitspaket nicht in diesem Vorhaben." onRetry={ops.reload} />
|
||||
|
|
@ -274,6 +315,8 @@ function ActionDetailNested() {
|
|||
}}
|
||||
projects={ops.projects}
|
||||
roadmapItems={ops.roadmapItems}
|
||||
allActions={ops.actions}
|
||||
executionGraph={executionGraph}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 <LoadingState message="Lade Arbeitspakete …" />
|
||||
}
|
||||
|
|
@ -33,6 +55,10 @@ function PlanWorkInner() {
|
|||
return (
|
||||
<>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<PlanningDebtBanner
|
||||
planningDebt={executionGraph?.planning_debt}
|
||||
hrefWithScope={hrefWithScope}
|
||||
/>
|
||||
<PlanActionsSection
|
||||
actions={actions}
|
||||
projects={projects}
|
||||
|
|
@ -48,6 +74,7 @@ function PlanWorkInner() {
|
|||
actorsUsedFallback={actorsUsedFallback}
|
||||
onReloadActors={reloadActors}
|
||||
busy={formBusy}
|
||||
executionGraph={executionGraph}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
46
frontend/src/utils/executionGraph.js
Normal file
46
frontend/src/utils/executionGraph.js
Normal 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),
|
||||
}
|
||||
}
|
||||
45
frontend/src/utils/executionGraph.test.js
Normal file
45
frontend/src/utils/executionGraph.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user