AP1.12a: Plan-Outline mit Projektbaum unter Struktur.
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Test Suite / pytest-backend (push) Successful in 2m7s
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

Linke Outline zeigt rekursive Projects im Scope; Deep-Links zu /projects/:id markieren Struktur aktiv.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-10 14:27:28 +02:00
parent 5e2ae9de31
commit bcf6f83813
4 changed files with 159 additions and 11 deletions

View File

@ -1,18 +1,84 @@
import { NavLink, useLocation } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { listInitiativeProjects } from '../api/projects.js'
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 } from '../utils/routes.js'
import {
PLAN_OUTLINE_NODES,
resolvePlanOutlineActiveKey,
resolvePlanOutlineProjectId,
} from '../plan/planOutlineNodes.js'
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 activeKey = resolvePlanOutlineActiveKey(location.pathname)
const activeProjectId = resolvePlanOutlineProjectId(location.pathname)
const [counts, setCounts] = useState({ inbox: null, work: null })
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])
useEffect(() => {
if (!initiativeId) {
@ -44,6 +110,9 @@ export function PlanOutlineNav() {
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
}
@ -66,6 +135,8 @@ export function PlanOutlineNav() {
{PLAN_OUTLINE_NODES.map((node) => {
const needsInitiative = node.requiresInitiative && !initiativeId
const isDisabled = node.disabled || needsInitiative
const isStructure = node.key === 'structure'
const hasProjectTree = isStructure && projects.length > 0 && initiativeId
if (isDisabled) {
return (
@ -86,16 +157,38 @@ export function PlanOutlineNav() {
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>
)}
<NavLink
to={hrefWithScope(node.to)}
end
className={({ isActive }) =>
'analysis-split__nav-link' +
(isActive ? ' analysis-split__nav-link--active' : '')
(isActive || (isStructure && activeProjectId)
? ' analysis-split__nav-link--active'
: '')
}
>
{nodeLabel(node)}
</NavLink>
</div>
{hasProjectTree && structureOpen && (
<PlanOutlineProjectTree
projects={projects}
parentId=""
activeProjectId={activeProjectId}
/>
)}
</li>
)
})}

View File

@ -20,8 +20,20 @@ export const PLAN_OUTLINE_NODES = [
*/
export function resolvePlanOutlineActiveKey(pathname) {
const path = pathname.replace(/\/$/, '') || pathname
if (path.startsWith('/projects/')) {
return 'structure'
}
const match = PLAN_OUTLINE_NODES.find(
(node) => path === node.to || path.startsWith(`${node.to}/`),
)
return match?.key ?? null
}
/**
* @param {string} pathname
* @returns {string | null}
*/
export function resolvePlanOutlineProjectId(pathname) {
const match = (pathname || '').match(/^\/projects\/([^/?]+)/)
return match?.[1] ?? null
}

View File

@ -19,5 +19,6 @@ describe('planOutlineNodes', () => {
expect(resolvePlanOutlineActiveKey('/plan/inbox')).toBe('inbox')
expect(resolvePlanOutlineActiveKey('/plan/work')).toBe('work')
expect(resolvePlanOutlineActiveKey('/plan/portfolio')).toBe(null)
expect(resolvePlanOutlineActiveKey('/projects/abc-123')).toBe('structure')
})
})

View File

@ -306,6 +306,48 @@
cursor: not-allowed;
}
.analysis-split__nav-row {
display: flex;
align-items: stretch;
gap: 2px;
}
.analysis-split__nav-row .analysis-split__nav-link {
flex: 1;
min-width: 0;
}
.analysis-split__nav-toggle {
flex: 0 0 auto;
width: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--jk-text-muted);
cursor: pointer;
font-size: 11px;
line-height: 1;
}
.analysis-split__nav-toggle:hover {
background: var(--jk-primary-soft);
color: var(--jk-text);
}
.analysis-split__nav-sublist {
list-style: none;
margin: 2px 0 4px;
padding: 0 0 0 12px;
display: grid;
gap: 2px;
border-left: 1px solid var(--jk-border);
}
.analysis-split__nav-link--nested {
font-size: 12px;
font-weight: 500;
}
@media (max-width: 900px) {
.analysis-split {
grid-template-columns: 1fr;