From 00567bb0978730f979e753789b1676273d0d6981 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 12 Jul 2026 19:26:08 +0200 Subject: [PATCH] =?UTF-8?q?AP2.2b:=20Kritischer=20Pfad=20in=20Kontrolle=20?= =?UTF-8?q?f=C3=BCr=20A2=20Linear.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CriticalPathPanel aus Execution Graph, Next-Action-Begruendung und Integrationstest fuer sequential_dependency. Co-authored-by: Cursor --- .../tests/test_ap22b_linear_critical_path.py | 78 ++++++++++ frontend/src/components/CriticalPathPanel.jsx | 134 ++++++++++++++++++ frontend/src/constants/operating.js | 7 + .../initiative/InitiativeOverviewPage.jsx | 26 ++++ frontend/src/styles/program-chrome.css | 53 +++++++ frontend/src/utils/executionGraph.js | 123 ++++++++++++++++ frontend/src/utils/executionGraph.test.js | 39 +++++ frontend/src/widgets/NextActionWidget.jsx | 10 +- 8 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_ap22b_linear_critical_path.py create mode 100644 frontend/src/components/CriticalPathPanel.jsx diff --git a/backend/tests/test_ap22b_linear_critical_path.py b/backend/tests/test_ap22b_linear_critical_path.py new file mode 100644 index 0000000..f66e66d --- /dev/null +++ b/backend/tests/test_ap22b_linear_critical_path.py @@ -0,0 +1,78 @@ +"""AP2.2b — A2 linear project: critical path in execution graph + Next Action.""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def test_linear_critical_path_and_next_action(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Neue Küche", + archetype_key="initiative.linear_project", + ) + initiative_id = created.json()["id"] + + a = client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": "Rohbau", "status": "open", "sort_order": 0}, + headers=_auth(token), + ) + b = client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": "Elektrik", "status": "open", "sort_order": 1}, + headers=_auth(token), + ) + c = client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": "Endabnahme", "status": "open", "sort_order": 2}, + headers=_auth(token), + ) + assert a.status_code == 201 and b.status_code == 201 and c.status_code == 201 + a_id, b_id, c_id = a.json()["id"], b.json()["id"], c.json()["id"] + + dep1 = client.post( + f"/api/initiatives/{initiative_id}/execution/dependencies", + json={ + "predecessor_action_id": a_id, + "successor_action_id": b_id, + "dependency_kind": "requires", + }, + headers=_auth(token), + ) + dep2 = client.post( + f"/api/initiatives/{initiative_id}/execution/dependencies", + json={ + "predecessor_action_id": b_id, + "successor_action_id": c_id, + "dependency_kind": "requires", + }, + headers=_auth(token), + ) + assert dep1.status_code == 201 and dep2.status_code == 201 + + graph = client.get( + f"/api/initiatives/{initiative_id}/execution/graph-state", + headers=_auth(token), + ) + assert graph.status_code == 200 + body = graph.json() + assert body["critical_path"] == [a_id, b_id, c_id] + assert a_id in body["ready_actions"] + assert b_id in body["blocked_actions"] + + snap = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ) + assert snap.status_code == 200 + next_actions = snap.json().get("next_actions") or [] + assert next_actions + top = next_actions[0] + assert top.get("action_id") == a_id + assert top.get("reason_code") in ("execution_ready", "execution_critical_path") diff --git a/frontend/src/components/CriticalPathPanel.jsx b/frontend/src/components/CriticalPathPanel.jsx new file mode 100644 index 0000000..bd06c93 --- /dev/null +++ b/frontend/src/components/CriticalPathPanel.jsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { getInitiativeExecutionGraphState } from '../api/executionPlan.js' +import { LoadingState } from './LoadingState.jsx' +import { ErrorState } from './ErrorState.jsx' +import { EmptyState } from './EmptyState.jsx' +import { StatusBadge } from './StatusBadge.jsx' +import { ExecutionFlowBadge } from './ExecutionFlowBadge.jsx' +import { + buildCriticalPathSteps, + findNextReadyOnCriticalPath, + summarizeCriticalPath, +} from '../utils/executionGraph.js' +import { actionPath, scopedPath } from '../utils/routes.js' + +export function CriticalPathPanel({ + initiativeId, + actions = [], + embedded = true, +}) { + const [graphState, setGraphState] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!initiativeId) { + setGraphState(null) + setLoading(false) + return undefined + } + let cancelled = false + setLoading(true) + setError(null) + getInitiativeExecutionGraphState(initiativeId) + .then((data) => { + if (!cancelled) setGraphState(data) + }) + .catch((err) => { + if (!cancelled) setError(err.message) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [initiativeId]) + + const steps = buildCriticalPathSteps(graphState, actions) + const summary = summarizeCriticalPath(graphState, actions) + const nextReady = findNextReadyOnCriticalPath(graphState, actions) + + const body = ( + <> + {loading && } + {!loading && error && ( + window.location.reload()} /> + )} + {!loading && !error && steps.length === 0 && ( + + )} + {!loading && !error && steps.length > 0 && ( + <> +

{summary.message}

+ {nextReady && ( +
+ Nächster Schritt + {nextReady.action.title} +

+ Kritischer Pfad — ausführungsbereit, keine blockierenden Vorgänger. +

+ + Arbeitspaket öffnen + +
+ )} +
    + {steps.map((step) => ( +
  1. + {step.index} +
    + + {step.title} + +

    {step.reason}

    +
    +
    + + +
    +
  2. + ))} +
+

+ Graph unter{' '} + + Plan → Zielzustände + + {' '}pflegen. +

+ + )} + + ) + + if (embedded) { + return ( +
+
+
+

Kritischer Pfad

+

+ Längster Abhängigkeitspfad aus dem Execution Graph — Steuerungsantwort für + lineare Vorhaben (A2). +

+
+
+ {body} +
+ ) + } + + return body +} diff --git a/frontend/src/constants/operating.js b/frontend/src/constants/operating.js index 66410d4..c3ded6d 100644 --- a/frontend/src/constants/operating.js +++ b/frontend/src/constants/operating.js @@ -60,6 +60,13 @@ export const SIGNAL_LABELS = { work_in_progress: 'Arbeit in Umsetzung', } +export const NEXT_ACTION_REASON_LABELS = { + execution_ready: 'Ausführungsbereit', + execution_critical_path: 'Kritischer Pfad', + execution_waiting: 'Wartet (Reihenfolge)', + planning_debt: 'Durchführungsplan fehlt', +} + export const NEXT_ACTION_KIND_LABELS = { action: 'Arbeitspaket', resolve_blocker: 'Blocker klären', diff --git a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx index 3659c2a..6f517c0 100644 --- a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx +++ b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx @@ -2,11 +2,22 @@ import { Link } from 'react-router-dom' import { scopedPath } from '../../utils/routes.js' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx' +import { CriticalPathPanel } from '../../components/CriticalPathPanel.jsx' import { NextActionWidget } from '../../widgets/NextActionWidget.jsx' +function usesCriticalPathControl(initiative, steeringSnapshot) { + const archetypeKey = initiative?.archetype_key + const methodKey = steeringSnapshot?.method_key + return ( + archetypeKey === 'initiative.linear_project' || methodKey === 'sequential_dependency' + ) +} + export function InitiativeOverviewPage() { const { initiativeId, + initiative, + actions, error, steeringSnapshot, steeringSnapshotLoading, @@ -18,6 +29,7 @@ export function InitiativeOverviewPage() { } = useInitiativeOperations() const counts = steeringSnapshot?.counts || {} + const showCriticalPath = usesCriticalPathControl(initiative, steeringSnapshot) return ( <> @@ -35,6 +47,10 @@ export function InitiativeOverviewPage() { /> )} + {showCriticalPath && capabilities.has('kairo.action.read') && ( + + )} + {capabilities.has('kairo.workspace.read') && ( )} diff --git a/frontend/src/styles/program-chrome.css b/frontend/src/styles/program-chrome.css index e1866bf..b230ddd 100644 --- a/frontend/src/styles/program-chrome.css +++ b/frontend/src/styles/program-chrome.css @@ -704,3 +704,56 @@ width: 16px; height: 16px; } + +/* AP2.2b — kritischer Pfad (Kontrolle / A2) */ +.critical-path-panel .critical-path-summary { + margin: 0 0 12px; + font-size: 14px; +} + +.critical-path-next { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + margin-bottom: 16px; + padding: 12px; + border-left: 3px solid var(--jk-primary); + background: var(--jk-surface-muted, rgba(0, 0, 0, 0.03)); + border-radius: 6px; +} + +.critical-path-steps { + margin: 0; + padding: 0; +} + +.critical-path-step { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.critical-path-step--next { + border-color: var(--jk-primary); + box-shadow: inset 3px 0 0 var(--jk-primary); +} + +.critical-path-step--done { + opacity: 0.75; +} + +.critical-path-step__index { + min-width: 1.5rem; + font-weight: 600; + font-size: 13px; +} + +.critical-path-hint { + margin: 12px 0 0; + font-size: 13px; +} + +.next-action-reason { + margin-left: 4px; +} diff --git a/frontend/src/utils/executionGraph.js b/frontend/src/utils/executionGraph.js index 246e1b2..86cd9e8 100644 --- a/frontend/src/utils/executionGraph.js +++ b/frontend/src/utils/executionGraph.js @@ -44,3 +44,126 @@ export function dependenciesForAction(dependencies, actionId) { successors: list.filter((dep) => dep.predecessor_action_id === actionId), } } + +/** + * @param {Array<{ id: string }>} actions + */ +export function actionByIdMap(actions) { + return Object.fromEntries((actions || []).map((a) => [a.id, a])) +} + +/** + * First ready action on critical_path (AP2.2b). + * @param {Record|null|undefined} graphState + * @param {Array<{ id: string, title?: string, status?: string }>} actions + */ +export function findNextReadyOnCriticalPath(graphState, actions) { + const path = graphState?.critical_path + if (!Array.isArray(path) || path.length === 0) return null + + const byId = actionByIdMap(actions) + for (const rawId of path) { + const actionId = String(rawId) + const meta = getActionExecutionMeta(graphState, actionId) + if (!meta?.ready) continue + const action = byId[actionId] + if (!action || action.status === 'done' || action.status === 'discarded') continue + return { actionId, action, meta } + } + return null +} + +/** + * @param {Record|null|undefined} graphState + * @param {string} actionId + * @param {Array<{ id: string, title?: string }>} actions + */ +export function buildCriticalPathStepReason(graphState, actionId, actions) { + const meta = getActionExecutionMeta(graphState, actionId) + if (!meta) return 'Keine Graph-Daten' + + if (meta.ready) { + return 'Bereit — keine offenen Vorgänger auf dem kritischen Pfad.' + } + if (meta.blocked && meta.blocked_by?.length) { + const waitingOn = formatBlockedByTitles(actions, meta.blocked_by) + return waitingOn + ? `Wartet auf: ${waitingOn}` + : 'Wartet auf Vorgänger-Arbeitspakete.' + } + return 'Noch nicht ausführungsbereit.' +} + +/** + * @param {Record|null|undefined} graphState + * @param {Array<{ id: string, title?: string, status?: string }>} actions + */ +export function buildCriticalPathSteps(graphState, actions) { + const path = graphState?.critical_path + if (!Array.isArray(path) || path.length === 0) return [] + + const byId = actionByIdMap(actions) + const nextReady = findNextReadyOnCriticalPath(graphState, actions) + + return path.map((rawId, index) => { + const actionId = String(rawId) + const action = byId[actionId] + const meta = getActionExecutionMeta(graphState, actionId) + return { + index: index + 1, + actionId, + title: action?.title || actionId.slice(0, 8), + status: action?.status || meta?.status || 'open', + meta, + isNextReady: nextReady?.actionId === actionId, + reason: buildCriticalPathStepReason(graphState, actionId, actions), + } + }) +} + +/** + * @param {Record|null|undefined} graphState + * @param {Array<{ id: string, title?: string, status?: string }>} actions + */ +export function summarizeCriticalPath(graphState, actions) { + const steps = buildCriticalPathSteps(graphState, actions) + if (steps.length === 0) { + return { + hasPath: false, + message: 'Noch kein kritischer Pfad — lege Arbeitspakete mit Abhängigkeiten an.', + } + } + + const nextReady = findNextReadyOnCriticalPath(graphState, actions) + if (nextReady) { + return { + hasPath: true, + message: `Nächster Schritt am kritischen Pfad: „${nextReady.action.title || 'Arbeitspaket'}".`, + nextActionId: nextReady.actionId, + } + } + + const openOnPath = steps.filter( + (step) => step.status !== 'done' && step.status !== 'discarded', + ) + if (openOnPath.length === 0) { + return { + hasPath: true, + message: 'Kritischer Pfad abgeschlossen — alle Schritte erledigt.', + } + } + + const waiting = openOnPath.find((step) => step.meta?.blocked) + if (waiting) { + return { + hasPath: true, + message: `Kritischer Pfad wartet bei „${waiting.title}" — ${waiting.reason}`, + blockedActionId: waiting.actionId, + } + } + + return { + hasPath: true, + message: 'Kritischer Pfad vorhanden — kein bereiter Schritt.', + } +} diff --git a/frontend/src/utils/executionGraph.test.js b/frontend/src/utils/executionGraph.test.js index db4cfd2..17cbd7e 100644 --- a/frontend/src/utils/executionGraph.test.js +++ b/frontend/src/utils/executionGraph.test.js @@ -4,6 +4,9 @@ import { formatBlockedByTitles, getActionExecutionMeta, sortActionsForExecutionPlan, + findNextReadyOnCriticalPath, + summarizeCriticalPath, + buildCriticalPathSteps, } from './executionGraph.js' describe('executionGraph utils', () => { @@ -42,4 +45,40 @@ describe('executionGraph utils', () => { expect(dependenciesForAction(deps, 's1').predecessors).toHaveLength(1) expect(dependenciesForAction(deps, 'p1').successors).toHaveLength(1) }) + + it('finds next ready step on critical path', () => { + const graphState = { + critical_path: ['a', 'b', 'c'], + items: { + a: { ready: false, blocked: true, blocked_by: ['x'] }, + b: { ready: true, blocked: false, blocked_by: [] }, + c: { ready: false, blocked: true, blocked_by: ['b'] }, + }, + } + const actions = [ + { id: 'a', title: 'Schritt A', status: 'open' }, + { id: 'b', title: 'Schritt B', status: 'ready' }, + { id: 'c', title: 'Schritt C', status: 'open' }, + ] + const next = findNextReadyOnCriticalPath(graphState, actions) + expect(next?.actionId).toBe('b') + expect(summarizeCriticalPath(graphState, actions).message).toContain('Schritt B') + }) + + it('builds critical path steps with reasons', () => { + const graphState = { + critical_path: ['first', 'second'], + items: { + first: { ready: true, blocked: false, blocked_by: [] }, + second: { ready: false, blocked: true, blocked_by: ['first'] }, + }, + } + const actions = [ + { id: 'first', title: 'Erstes AP', status: 'done' }, + { id: 'second', title: 'Zweites AP', status: 'open' }, + ] + const steps = buildCriticalPathSteps(graphState, actions) + expect(steps).toHaveLength(2) + expect(steps[1].reason).toContain('Erstes AP') + }) }) diff --git a/frontend/src/widgets/NextActionWidget.jsx b/frontend/src/widgets/NextActionWidget.jsx index 29210eb..3f582e7 100644 --- a/frontend/src/widgets/NextActionWidget.jsx +++ b/frontend/src/widgets/NextActionWidget.jsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { getNextActionCandidates } from '../api/attention.js' -import { NEXT_ACTION_KIND_LABELS } from '../constants/operating.js' +import { NEXT_ACTION_KIND_LABELS, NEXT_ACTION_REASON_LABELS } from '../constants/operating.js' import { EmptyState } from '../components/EmptyState.jsx' import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' @@ -52,6 +52,14 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) { {NEXT_ACTION_KIND_LABELS[item.kind] || item.kind} + {item.reason_code && NEXT_ACTION_REASON_LABELS[item.reason_code] && ( + + {NEXT_ACTION_REASON_LABELS[item.reason_code]} + + )} {item.title} {item.summary &&

{item.summary}

} {item.recommended_action && (