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 { 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>
|
||||
)
|
||||
|
|
|
|||
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>
|
||||
|
||||
return (
|
||||
<section className="card tasks-section">
|
||||
<section className="card tasks-section" id="tasks">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h3>Aufgaben</h3>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user