AP1.5d: Aufgaben-Baum in der Plan-Outline unter Arbeit.
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m12s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s

Offene Tasks erscheinen unter dem Arbeitspaket (max. 3 Ebenen, Tiefe 2 eingeklappt); Links fuehren zur Action-Detailseite #tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-11 07:43:33 +02:00
parent 075cff00a2
commit 43d30944aa
4 changed files with 207 additions and 19 deletions

View File

@ -5,13 +5,14 @@ import { listInitiativeActions } from '../api/initiatives.js'
import { listInitiativeBacklog } from '../api/backlog.js'
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
import { buildProjectsByParent } from '../utils/projectTree.js'
import { projectPath, actionPath } from '../utils/routes.js'
import { projectPath } from '../utils/routes.js'
import {
PLAN_OUTLINE_NODES,
resolvePlanOutlineActiveKey,
resolvePlanOutlineProjectId,
resolvePlanOutlineActionId,
} from '../plan/planOutlineNodes.js'
import { PlanOutlineWorkTree } from './PlanOutlineWorkTree.jsx'
function PlanOutlineProjectTree({ projects, parentId, activeProjectId, depth = 0 }) {
const byParent = useMemo(() => buildProjectsByParent(projects), [projects])
@ -217,23 +218,11 @@ export function PlanOutlineNav() {
/>
)}
{hasActionList && workOpen && (
<ul className="analysis-split__nav-sublist">
{openActions.map((action) => (
<li key={action.id}>
<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}
</NavLink>
</li>
))}
</ul>
<PlanOutlineWorkTree
openActions={openActions}
activeActionId={activeActionId}
enabled={workOpen}
/>
)}
</li>
)

View File

@ -0,0 +1,188 @@
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'
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 }) {
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
})
}
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>
</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 }) {
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}
/>
))}
</ul>
)
}

View File

@ -95,7 +95,7 @@ export function TasksSection({ actionId, canManage, roadmapItems = [] }) {
if (loading) return <p className="muted">Tasks werden geladen</p>
return (
<section className="card tasks-section">
<section className="card tasks-section" id="tasks">
<div className="section-header">
<div>
<h3>Aufgaben</h3>

View File

@ -348,6 +348,17 @@
font-weight: 500;
}
.analysis-split__nav-link--depth-2 {
font-size: 11px;
font-weight: 500;
opacity: 0.92;
}
.analysis-split__nav-meta {
font-weight: 400;
opacity: 0.85;
}
@media (max-width: 900px) {
.analysis-split {
grid-template-columns: 1fr;