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
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:
parent
075cff00a2
commit
43d30944aa
|
|
@ -5,13 +5,14 @@ import { listInitiativeActions } from '../api/initiatives.js'
|
||||||
import { listInitiativeBacklog } from '../api/backlog.js'
|
import { listInitiativeBacklog } from '../api/backlog.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, actionPath } from '../utils/routes.js'
|
import { projectPath } from '../utils/routes.js'
|
||||||
import {
|
import {
|
||||||
PLAN_OUTLINE_NODES,
|
PLAN_OUTLINE_NODES,
|
||||||
resolvePlanOutlineActiveKey,
|
resolvePlanOutlineActiveKey,
|
||||||
resolvePlanOutlineProjectId,
|
resolvePlanOutlineProjectId,
|
||||||
resolvePlanOutlineActionId,
|
resolvePlanOutlineActionId,
|
||||||
} from '../plan/planOutlineNodes.js'
|
} from '../plan/planOutlineNodes.js'
|
||||||
|
import { PlanOutlineWorkTree } from './PlanOutlineWorkTree.jsx'
|
||||||
|
|
||||||
function PlanOutlineProjectTree({ projects, parentId, activeProjectId, depth = 0 }) {
|
function PlanOutlineProjectTree({ projects, parentId, activeProjectId, depth = 0 }) {
|
||||||
const byParent = useMemo(() => buildProjectsByParent(projects), [projects])
|
const byParent = useMemo(() => buildProjectsByParent(projects), [projects])
|
||||||
|
|
@ -217,23 +218,11 @@ export function PlanOutlineNav() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasActionList && workOpen && (
|
{hasActionList && workOpen && (
|
||||||
<ul className="analysis-split__nav-sublist">
|
<PlanOutlineWorkTree
|
||||||
{openActions.map((action) => (
|
openActions={openActions}
|
||||||
<li key={action.id}>
|
activeActionId={activeActionId}
|
||||||
<NavLink
|
enabled={workOpen}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
188
frontend/src/components/PlanOutlineWorkTree.jsx
Normal file
188
frontend/src/components/PlanOutlineWorkTree.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -95,7 +95,7 @@ export function TasksSection({ actionId, canManage, roadmapItems = [] }) {
|
||||||
if (loading) return <p className="muted">Tasks werden geladen…</p>
|
if (loading) return <p className="muted">Tasks werden geladen…</p>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card tasks-section">
|
<section className="card tasks-section" id="tasks">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>Aufgaben</h3>
|
<h3>Aufgaben</h3>
|
||||||
|
|
|
||||||
|
|
@ -348,6 +348,17 @@
|
||||||
font-weight: 500;
|
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) {
|
@media (max-width: 900px) {
|
||||||
.analysis-split {
|
.analysis-split {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user