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 H3 Plan Outline AP1.12 + AP1.10 ◐ 12a–d, 10c
|
||||||
Phase I Gate-Graph AP1.13 + AP1.15 ◐ 13a/b, 15a–c
|
Phase I Gate-Graph AP1.13 + AP1.15 ◐ 13a/b, 15a–c
|
||||||
Phase I2 Plan/Ist Snapshots AP1.14 ✓
|
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 J Portfolio AP1.8 ◐ 8a ✓ / 8b deferred
|
||||||
Phase K Archetyp-Steuerung AP2.0 ◐ 2.0a–c ✓ / → 2.0d–f
|
Phase K Archetyp-Steuerung AP2.0 ◐ 2.0a–c ✓ / → 2.0d–f
|
||||||
Phase L Agent Interface AP1.7 ○
|
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 |
|
| Gate Dependencies pflegen | ◐ | AP1.13b Detail |
|
||||||
| Zielzustands-Designer | ◐ | AP1.15a–c; OR deferred |
|
| Zielzustands-Designer | ◐ | AP1.15a–c; OR deferred |
|
||||||
| Plan-Outline | ◐ | AP1.12a–d: Baum, Modal, Reorder, Actions; AP-Kanten AP1.16c offen |
|
| 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 |
|
| 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 | ✗ | |
|
||||||
|
|
|
||||||
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 { 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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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 { 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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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