From bcf6f8381309eef70dab3ab01880a4c545800577 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 10 Jul 2026 14:27:28 +0200 Subject: [PATCH] AP1.12a: Plan-Outline mit Projektbaum unter Struktur. Linke Outline zeigt rekursive Projects im Scope; Deep-Links zu /projects/:id markieren Struktur aktiv. Co-authored-by: Cursor --- frontend/src/components/PlanOutlineNav.jsx | 115 +++++++++++++++++++-- frontend/src/plan/planOutlineNodes.js | 12 +++ frontend/src/plan/planOutlineNodes.test.js | 1 + frontend/src/styles/program-chrome.css | 42 ++++++++ 4 files changed, 159 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/PlanOutlineNav.jsx b/frontend/src/components/PlanOutlineNav.jsx index 761d48b..c61bd84 100644 --- a/frontend/src/components/PlanOutlineNav.jsx +++ b/frontend/src/components/PlanOutlineNav.jsx @@ -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 ( + + ) +} + 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 (
  • - - 'analysis-split__nav-link' + - (isActive ? ' analysis-split__nav-link--active' : '') - } - > - {nodeLabel(node)} - +
    + {hasProjectTree && ( + + )} + + 'analysis-split__nav-link' + + (isActive || (isStructure && activeProjectId) + ? ' analysis-split__nav-link--active' + : '') + } + > + {nodeLabel(node)} + +
    + {hasProjectTree && structureOpen && ( + + )}
  • ) })} diff --git a/frontend/src/plan/planOutlineNodes.js b/frontend/src/plan/planOutlineNodes.js index 0ac24a1..c9d1a9b 100644 --- a/frontend/src/plan/planOutlineNodes.js +++ b/frontend/src/plan/planOutlineNodes.js @@ -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 +} diff --git a/frontend/src/plan/planOutlineNodes.test.js b/frontend/src/plan/planOutlineNodes.test.js index c484093..51132fc 100644 --- a/frontend/src/plan/planOutlineNodes.test.js +++ b/frontend/src/plan/planOutlineNodes.test.js @@ -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') }) }) diff --git a/frontend/src/styles/program-chrome.css b/frontend/src/styles/program-chrome.css index b0dfea4..47cdabe 100644 --- a/frontend/src/styles/program-chrome.css +++ b/frontend/src/styles/program-chrome.css @@ -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;