Kairo-Jinkendo/frontend/src/components/PlanOutlineNav.jsx
Lars 1b55d8558c
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
AP2.3c: Slice-bewusstes Laden und hoisted InitiativeOperationsProvider.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 10:26:56 +02:00

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>
)
}