Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
242 lines
8.2 KiB
JavaScript
242 lines
8.2 KiB
JavaScript
import { NavLink, useLocation } from 'react-router-dom'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { getInitiativeExecutionGraphState } from '../api/executionPlan.js'
|
|
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
|
import { useOptionalInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
|
|
import { useMethodUiContext } from '../hooks/useMethodUiContext.js'
|
|
import { buildProjectsByParent } from '../utils/projectTree.js'
|
|
import { projectPath } from '../utils/routes.js'
|
|
import {
|
|
resolvePlanOutlineActiveKey,
|
|
resolvePlanOutlineProjectId,
|
|
resolvePlanOutlineActionId,
|
|
} from '../plan/planOutlineNodes.js'
|
|
import { resolvePlanOutlineNodesFromProfile } from '../registry/resolveOperatingProfile.js'
|
|
import { PlanOutlineWorkTree } from './PlanOutlineWorkTree.jsx'
|
|
|
|
function PlanOutlineProjectTree({ projects, parentId, activeProjectId, depth = 0 }) {
|
|
const byParent = useMemo(() => buildProjectsByParent(projects), [projects])
|
|
const nodes = byParent.get(parentId || '') || []
|
|
|
|
if (nodes.length === 0) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<ul className="analysis-split__nav-sublist">
|
|
{nodes.map((project) => (
|
|
<li key={project.id}>
|
|
<NavLink
|
|
to={projectPath(project.id)}
|
|
className={({ isActive }) =>
|
|
'analysis-split__nav-link analysis-split__nav-link--nested' +
|
|
(isActive || activeProjectId === project.id
|
|
? ' analysis-split__nav-link--active'
|
|
: '')
|
|
}
|
|
>
|
|
{project.title}
|
|
</NavLink>
|
|
<PlanOutlineProjectTree
|
|
projects={projects}
|
|
parentId={project.id}
|
|
activeProjectId={activeProjectId}
|
|
depth={depth + 1}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
export function PlanOutlineNav() {
|
|
const location = useLocation()
|
|
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
|
|
const { operatingContext, hasActiveSprint } = useMethodUiContext()
|
|
const ops = useOptionalInitiativeOperations()
|
|
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
|
|
const activeProjectId = resolvePlanOutlineProjectId(location.pathname)
|
|
const activeActionId = resolvePlanOutlineActionId(location.pathname)
|
|
const [executionGraph, setExecutionGraph] = useState(null)
|
|
const [structureOpen, setStructureOpen] = useState(
|
|
activeKey === 'structure' || Boolean(activeProjectId),
|
|
)
|
|
const [workOpen, setWorkOpen] = useState(
|
|
activeKey === 'work' || Boolean(activeActionId),
|
|
)
|
|
|
|
const contextReady = Boolean(initiativeId && ops?.initiativeId === initiativeId)
|
|
const projects = contextReady ? ops.projects : []
|
|
const backlogItems = contextReady ? ops.backlogItems : []
|
|
const actions = contextReady ? ops.actions : []
|
|
|
|
const openActions = useMemo(
|
|
() =>
|
|
actions.filter(
|
|
(action) => action.status !== 'done' && action.status !== 'discarded',
|
|
),
|
|
[actions],
|
|
)
|
|
|
|
const counts = useMemo(
|
|
() => ({
|
|
inbox: contextReady
|
|
? backlogItems.filter((item) => item.status !== 'converted').length
|
|
: null,
|
|
work: contextReady ? openActions.length : null,
|
|
}),
|
|
[contextReady, backlogItems, openActions],
|
|
)
|
|
|
|
useEffect(() => {
|
|
setStructureOpen(activeKey === 'structure' || Boolean(activeProjectId))
|
|
}, [activeKey, activeProjectId])
|
|
|
|
useEffect(() => {
|
|
setWorkOpen(activeKey === 'work' || Boolean(activeActionId))
|
|
}, [activeKey, activeActionId])
|
|
|
|
const outlineNodes = useMemo(
|
|
() =>
|
|
resolvePlanOutlineNodesFromProfile({
|
|
operatingContext,
|
|
hasActiveSprint,
|
|
}),
|
|
[operatingContext, hasActiveSprint],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!initiativeId) {
|
|
setExecutionGraph(null)
|
|
return undefined
|
|
}
|
|
let cancelled = false
|
|
getInitiativeExecutionGraphState(initiativeId)
|
|
.then((graphState) => {
|
|
if (!cancelled) setExecutionGraph(graphState)
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setExecutionGraph(null)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [initiativeId, location.pathname])
|
|
|
|
function nodeLabel(node) {
|
|
if (node.key === 'inbox' && counts.inbox != null) {
|
|
return `${node.label} (${counts.inbox})`
|
|
}
|
|
if (node.key === 'work' && counts.work != null) {
|
|
return `${node.label} (${counts.work})`
|
|
}
|
|
if (node.key === 'structure' && projects.length > 0) {
|
|
return `${node.label} (${projects.length})`
|
|
}
|
|
return node.label
|
|
}
|
|
|
|
return (
|
|
<nav className="analysis-split__nav" aria-label="Plan-Navigation">
|
|
<div className="analysis-split__nav-header">
|
|
<span className="analysis-split__nav-eyebrow">Planen</span>
|
|
{initiativeId ? (
|
|
<p className="analysis-split__nav-scope" title={initiativeTitle || initiativeId}>
|
|
{initiativeTitle || 'Vorhaben'}
|
|
</p>
|
|
) : (
|
|
<p className="analysis-split__nav-scope analysis-split__nav-scope--muted">
|
|
Portfolio — Vorhaben wählen
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<ul className="analysis-split__nav-list">
|
|
{outlineNodes.map((node) => {
|
|
const needsInitiative = node.requiresInitiative && !initiativeId
|
|
const isDisabled = node.disabled || needsInitiative
|
|
const isStructure = node.key === 'structure'
|
|
const isWork = node.key === 'work'
|
|
const hasProjectTree = isStructure && projects.length > 0 && initiativeId
|
|
const hasActionList = isWork && openActions.length > 0 && initiativeId
|
|
|
|
if (isDisabled) {
|
|
return (
|
|
<li key={node.key}>
|
|
<span
|
|
className={
|
|
'analysis-split__nav-link analysis-split__nav-link--disabled' +
|
|
(activeKey === node.key ? ' analysis-split__nav-link--active' : '')
|
|
}
|
|
aria-disabled="true"
|
|
title={needsInitiative ? 'Vorhaben im Scope wählen' : node.hint}
|
|
>
|
|
{nodeLabel(node)}
|
|
</span>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<li key={node.key}>
|
|
<div className="analysis-split__nav-row">
|
|
{hasProjectTree && (
|
|
<button
|
|
type="button"
|
|
className="analysis-split__nav-toggle"
|
|
aria-expanded={structureOpen}
|
|
aria-label={structureOpen ? 'Struktur einklappen' : 'Struktur ausklappen'}
|
|
onClick={() => setStructureOpen((open) => !open)}
|
|
>
|
|
{structureOpen ? '▾' : '▸'}
|
|
</button>
|
|
)}
|
|
{hasActionList && (
|
|
<button
|
|
type="button"
|
|
className="analysis-split__nav-toggle"
|
|
aria-expanded={workOpen}
|
|
aria-label={workOpen ? 'Arbeit einklappen' : 'Arbeit ausklappen'}
|
|
onClick={() => setWorkOpen((open) => !open)}
|
|
>
|
|
{workOpen ? '▾' : '▸'}
|
|
</button>
|
|
)}
|
|
<NavLink
|
|
to={hrefWithScope(node.to)}
|
|
end
|
|
className={({ isActive }) =>
|
|
'analysis-split__nav-link' +
|
|
(isActive ||
|
|
(isStructure && activeProjectId) ||
|
|
(isWork && activeActionId)
|
|
? ' analysis-split__nav-link--active'
|
|
: '')
|
|
}
|
|
>
|
|
{nodeLabel(node)}
|
|
</NavLink>
|
|
</div>
|
|
{hasProjectTree && structureOpen && (
|
|
<PlanOutlineProjectTree
|
|
projects={projects}
|
|
parentId=""
|
|
activeProjectId={activeProjectId}
|
|
/>
|
|
)}
|
|
{hasActionList && workOpen && (
|
|
<PlanOutlineWorkTree
|
|
openActions={openActions}
|
|
activeActionId={activeActionId}
|
|
enabled={workOpen}
|
|
executionGraph={executionGraph}
|
|
/>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
)
|
|
}
|