2026-07-09 11:16:42 +02:00
|
|
|
import { NavLink, useLocation } from 'react-router-dom'
|
2026-07-10 14:27:28 +02:00
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
|
import { listInitiativeProjects } from '../api/projects.js'
|
2026-07-10 14:26:14 +02:00
|
|
|
import { listInitiativeActions } from '../api/initiatives.js'
|
2026-07-09 11:38:17 +02:00
|
|
|
import { listInitiativeBacklog } from '../api/backlog.js'
|
2026-07-09 11:16:42 +02:00
|
|
|
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
2026-07-10 14:27:28 +02:00
|
|
|
import { buildProjectsByParent } from '../utils/projectTree.js'
|
|
|
|
|
import { projectPath } from '../utils/routes.js'
|
2026-07-09 11:16:42 +02:00
|
|
|
import {
|
|
|
|
|
PLAN_OUTLINE_NODES,
|
|
|
|
|
resolvePlanOutlineActiveKey,
|
2026-07-10 14:27:28 +02:00
|
|
|
resolvePlanOutlineProjectId,
|
2026-07-09 11:16:42 +02:00
|
|
|
} from '../plan/planOutlineNodes.js'
|
|
|
|
|
|
2026-07-10 14:27:28 +02:00
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 11:16:42 +02:00
|
|
|
export function PlanOutlineNav() {
|
|
|
|
|
const location = useLocation()
|
|
|
|
|
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
|
|
|
|
|
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
|
2026-07-10 14:27:28 +02:00
|
|
|
const activeProjectId = resolvePlanOutlineProjectId(location.pathname)
|
2026-07-09 11:38:17 +02:00
|
|
|
const [counts, setCounts] = useState({ inbox: null, work: null })
|
2026-07-10 14:27:28 +02:00
|
|
|
const [projects, setProjects] = useState([])
|
|
|
|
|
const [structureOpen, setStructureOpen] = useState(
|
|
|
|
|
activeKey === 'structure' || Boolean(activeProjectId),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setStructureOpen(activeKey === 'structure' || Boolean(activeProjectId))
|
|
|
|
|
}, [activeKey, activeProjectId])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!initiativeId) {
|
|
|
|
|
setProjects([])
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
let cancelled = false
|
|
|
|
|
listInitiativeProjects(initiativeId)
|
|
|
|
|
.then((items) => {
|
|
|
|
|
if (!cancelled) setProjects(Array.isArray(items) ? items : [])
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
if (!cancelled) setProjects([])
|
|
|
|
|
})
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true
|
|
|
|
|
}
|
|
|
|
|
}, [initiativeId])
|
2026-07-09 11:38:17 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!initiativeId) {
|
|
|
|
|
setCounts({ inbox: null, work: null })
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
let cancelled = false
|
|
|
|
|
Promise.all([
|
|
|
|
|
listInitiativeBacklog(initiativeId).catch(() => []),
|
|
|
|
|
listInitiativeActions(initiativeId).catch(() => []),
|
|
|
|
|
]).then(([backlog, actions]) => {
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
setCounts({
|
|
|
|
|
inbox: backlog.filter((item) => item.status !== 'converted').length,
|
|
|
|
|
work: actions.filter(
|
|
|
|
|
(action) => action.status !== 'done' && action.status !== 'discarded',
|
|
|
|
|
).length,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
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})`
|
|
|
|
|
}
|
2026-07-10 14:27:28 +02:00
|
|
|
if (node.key === 'structure' && projects.length > 0) {
|
|
|
|
|
return `${node.label} (${projects.length})`
|
|
|
|
|
}
|
2026-07-09 11:38:17 +02:00
|
|
|
return node.label
|
|
|
|
|
}
|
2026-07-09 11:16:42 +02:00
|
|
|
|
|
|
|
|
return (
|
2026-07-10 14:26:14 +02:00
|
|
|
<nav className="analysis-split__nav" aria-label="Plan-Navigation">
|
|
|
|
|
<div className="analysis-split__nav-header">
|
|
|
|
|
<span className="analysis-split__nav-eyebrow">Planen</span>
|
2026-07-09 11:16:42 +02:00
|
|
|
{initiativeId ? (
|
2026-07-10 14:26:14 +02:00
|
|
|
<p className="analysis-split__nav-scope" title={initiativeTitle || initiativeId}>
|
2026-07-09 11:16:42 +02:00
|
|
|
{initiativeTitle || 'Vorhaben'}
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
2026-07-10 14:26:14 +02:00
|
|
|
<p className="analysis-split__nav-scope analysis-split__nav-scope--muted">
|
2026-07-09 11:16:42 +02:00
|
|
|
Portfolio — Vorhaben wählen
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-07-10 14:26:14 +02:00
|
|
|
<ul className="analysis-split__nav-list">
|
2026-07-09 11:16:42 +02:00
|
|
|
{PLAN_OUTLINE_NODES.map((node) => {
|
|
|
|
|
const needsInitiative = node.requiresInitiative && !initiativeId
|
|
|
|
|
const isDisabled = node.disabled || needsInitiative
|
2026-07-10 14:27:28 +02:00
|
|
|
const isStructure = node.key === 'structure'
|
|
|
|
|
const hasProjectTree = isStructure && projects.length > 0 && initiativeId
|
2026-07-09 11:16:42 +02:00
|
|
|
|
|
|
|
|
if (isDisabled) {
|
|
|
|
|
return (
|
|
|
|
|
<li key={node.key}>
|
|
|
|
|
<span
|
|
|
|
|
className={
|
2026-07-10 14:26:14 +02:00
|
|
|
'analysis-split__nav-link analysis-split__nav-link--disabled' +
|
|
|
|
|
(activeKey === node.key ? ' analysis-split__nav-link--active' : '')
|
2026-07-09 11:16:42 +02:00
|
|
|
}
|
|
|
|
|
aria-disabled="true"
|
|
|
|
|
title={needsInitiative ? 'Vorhaben im Scope wählen' : node.hint}
|
|
|
|
|
>
|
2026-07-09 11:38:17 +02:00
|
|
|
{nodeLabel(node)}
|
2026-07-09 11:16:42 +02:00
|
|
|
</span>
|
|
|
|
|
</li>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<li key={node.key}>
|
2026-07-10 14:27:28 +02:00
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
<NavLink
|
|
|
|
|
to={hrefWithScope(node.to)}
|
|
|
|
|
end
|
|
|
|
|
className={({ isActive }) =>
|
|
|
|
|
'analysis-split__nav-link' +
|
|
|
|
|
(isActive || (isStructure && activeProjectId)
|
|
|
|
|
? ' analysis-split__nav-link--active'
|
|
|
|
|
: '')
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{nodeLabel(node)}
|
|
|
|
|
</NavLink>
|
|
|
|
|
</div>
|
|
|
|
|
{hasProjectTree && structureOpen && (
|
|
|
|
|
<PlanOutlineProjectTree
|
|
|
|
|
projects={projects}
|
|
|
|
|
parentId=""
|
|
|
|
|
activeProjectId={activeProjectId}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-07-09 11:16:42 +02:00
|
|
|
</li>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</ul>
|
|
|
|
|
</nav>
|
|
|
|
|
)
|
|
|
|
|
}
|