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 (
+
+ {nodes.map((project) => (
+ -
+
+ 'analysis-split__nav-link analysis-split__nav-link--nested' +
+ (isActive || activeProjectId === project.id
+ ? ' analysis-split__nav-link--active'
+ : '')
+ }
+ >
+ {project.title}
+
+
+
+ ))}
+
+ )
+}
+
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;