Kairo-Jinkendo/frontend/src/components/PlanOutlineWorkTree.jsx
Lars 8546c7021b
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
AP1.16c: Durchführungsplan in Plan-Outline und Action-Detail.
Ready/blocked-Badges, Planning Debt, Vorgänger-Verwaltung — getrennt vom Gate-Designer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 11:25:55 +02:00

195 lines
6.0 KiB
JavaScript

import { useEffect, useMemo, useState } from 'react'
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`
}
function PlanOutlineTaskTree({
tasksByParent,
parentId,
actionId,
depth,
expandedTaskIds,
onToggleTask,
activeActionId,
}) {
const nodes = (tasksByParent.get(parentId || '') || []).filter(
(task) => task.status !== 'done' && task.status !== 'discarded',
)
if (nodes.length === 0 || depth > MAX_TASK_UI_DEPTH) {
return null
}
return (
<ul className="analysis-split__nav-sublist">
{nodes.map((task) => {
const childNodes = tasksByParent.get(task.id) || []
const hasChildren = childNodes.some(
(child) => child.status !== 'done' && child.status !== 'discarded',
)
const canExpand = hasChildren && depth < MAX_TASK_UI_DEPTH
const isExpanded = expandedTaskIds.has(task.id)
return (
<li key={task.id}>
<div className="analysis-split__nav-row">
{canExpand && (
<button
type="button"
className="analysis-split__nav-toggle"
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Aufgaben einklappen' : 'Aufgaben ausklappen'}
onClick={() => onToggleTask(task.id)}
>
{isExpanded ? '▾' : '▸'}
</button>
)}
<NavLink
to={actionTasksHref(actionId)}
className={({ isActive }) =>
'analysis-split__nav-link analysis-split__nav-link--nested' +
(depth >= 2 ? ' analysis-split__nav-link--depth-2' : '') +
(isActive || activeActionId === actionId
? ' analysis-split__nav-link--active'
: '')
}
>
{task.title}
</NavLink>
</div>
{canExpand && isExpanded && (
<PlanOutlineTaskTree
tasksByParent={tasksByParent}
parentId={task.id}
actionId={actionId}
depth={depth + 1}
expandedTaskIds={expandedTaskIds}
onToggleTask={onToggleTask}
activeActionId={activeActionId}
/>
)}
</li>
)
})}
</ul>
)
}
function PlanOutlineActionNode({ action, tasks, activeActionId, defaultExpanded, executionGraph }) {
const [actionOpen, setActionOpen] = useState(defaultExpanded)
const [expandedTaskIds, setExpandedTaskIds] = useState(() => new Set())
const tasksByParent = useMemo(() => buildTasksByParent(tasks || []), [tasks])
const openTaskCount = useMemo(() => countOpenTasks(tasks || []), [tasks])
const hasOpenTasks = openTaskCount > 0
useEffect(() => {
setActionOpen(defaultExpanded)
}, [defaultExpanded])
function toggleTask(taskId) {
setExpandedTaskIds((prev) => {
const next = new Set(prev)
if (next.has(taskId)) next.delete(taskId)
else next.add(taskId)
return next
})
}
const execMeta = getActionExecutionMeta(executionGraph, action.id)
return (
<li>
<div className="analysis-split__nav-row">
{hasOpenTasks && (
<button
type="button"
className="analysis-split__nav-toggle"
aria-expanded={actionOpen}
aria-label={actionOpen ? 'Aufgaben einklappen' : 'Aufgaben ausklappen'}
onClick={() => setActionOpen((open) => !open)}
>
{actionOpen ? '▾' : '▸'}
</button>
)}
<NavLink
to={actionPath(action.id)}
className={({ isActive }) =>
'analysis-split__nav-link analysis-split__nav-link--nested' +
(isActive || activeActionId === action.id
? ' analysis-split__nav-link--active'
: '')
}
>
{action.title}
{openTaskCount > 0 && (
<span className="analysis-split__nav-meta"> ({openTaskCount})</span>
)}
</NavLink>
<ExecutionFlowBadge meta={execMeta} actionStatus={action.status} />
</div>
{hasOpenTasks && actionOpen && (
<PlanOutlineTaskTree
tasksByParent={tasksByParent}
parentId=""
actionId={action.id}
depth={1}
expandedTaskIds={expandedTaskIds}
onToggleTask={toggleTask}
activeActionId={activeActionId}
/>
)}
</li>
)
}
export function PlanOutlineWorkTree({ openActions, activeActionId, enabled, executionGraph = null }) {
const [tasksByAction, setTasksByAction] = useState({})
const actionIdsKey = useMemo(
() => openActions.map((action) => action.id).join(','),
[openActions],
)
useEffect(() => {
if (!enabled || openActions.length === 0) {
setTasksByAction({})
return undefined
}
let cancelled = false
Promise.all(
openActions.map((action) =>
listActionTasks(action.id)
.then((tasks) => [action.id, Array.isArray(tasks) ? tasks : []])
.catch(() => [action.id, []]),
),
).then((entries) => {
if (!cancelled) setTasksByAction(Object.fromEntries(entries))
})
return () => {
cancelled = true
}
}, [enabled, actionIdsKey, openActions])
return (
<ul className="analysis-split__nav-sublist">
{openActions.map((action) => (
<PlanOutlineActionNode
key={action.id}
action={action}
tasks={tasksByAction[action.id] || []}
activeActionId={activeActionId}
defaultExpanded={activeActionId === action.id}
executionGraph={executionGraph}
/>
))}
</ul>
)
}