AP1.12a: Plan-Outline-Shell ersetzt Untertabs im Planen-Modus.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m4s
Test Suite / lint-backend (push) Successful in 2s
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 13s

Linke Outline mit Profil, Struktur, Zielzuständen und Eingang; Portfolio-Einstieg ohne Scope; Vorhabens-Profil als Read-only-Vorschau.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-09 11:16:42 +02:00
parent aa7e1626ea
commit bbadf22d39
12 changed files with 389 additions and 16 deletions

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.16.4-ap1.9b"
APP_VERSION = "0.16.5-ap1.12a"
DB_SCHEMA_VERSION = "014"
APP_NAME = "jinkendo-kairo"

View File

@ -68,6 +68,8 @@ function AppRoutes() {
<Route path="/plan" element={<PlanLayout />}>
<Route index element={<PlanIndexRedirect />} />
<Route path="portfolio" element={<MODE_ROUTE_COMPONENTS.planPortfolio />} />
<Route path="profile" element={<MODE_ROUTE_COMPONENTS.planProfile />} />
<Route path="structure" element={<MODE_ROUTE_COMPONENTS.planStructure />} />
<Route path="gates" element={<MODE_ROUTE_COMPONENTS.planGates />} />
<Route path="inbox" element={<MODE_ROUTE_COMPONENTS.planInbox />} />

View File

@ -0,0 +1,68 @@
import { NavLink, useLocation } from 'react-router-dom'
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
import {
PLAN_OUTLINE_NODES,
resolvePlanOutlineActiveKey,
} from '../plan/planOutlineNodes.js'
export function PlanOutlineNav() {
const location = useLocation()
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
return (
<nav className="plan-outline-nav" aria-label="Plan-Outline">
<div className="plan-outline-nav__header">
<span className="plan-outline-nav__eyebrow">Planen</span>
{initiativeId ? (
<p className="plan-outline-nav__root" title={initiativeTitle || initiativeId}>
{initiativeTitle || 'Vorhaben'}
</p>
) : (
<p className="plan-outline-nav__root plan-outline-nav__root--muted">
Portfolio Vorhaben wählen
</p>
)}
</div>
<ul className="plan-outline-nav__list">
{PLAN_OUTLINE_NODES.map((node) => {
const needsInitiative = node.requiresInitiative && !initiativeId
const isDisabled = node.disabled || needsInitiative
if (isDisabled) {
return (
<li key={node.key}>
<span
className={
'plan-outline-nav__link plan-outline-nav__link--disabled' +
(activeKey === node.key ? ' plan-outline-nav__link--active' : '')
}
aria-disabled="true"
title={needsInitiative ? 'Vorhaben im Scope wählen' : node.hint}
>
{node.label}
</span>
</li>
)
}
return (
<li key={node.key}>
<NavLink
to={hrefWithScope(node.to)}
end
className={({ isActive }) =>
'plan-outline-nav__link' +
(isActive ? ' plan-outline-nav__link--active' : '')
}
>
{node.label}
</NavLink>
</li>
)
})}
</ul>
</nav>
)
}

View File

@ -0,0 +1,13 @@
import { Navigate } from 'react-router-dom'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
/** Portfolio ohne Scope → Übersicht; mit Vorhaben → Profil als Einstieg. */
export function PlanIndexRedirect() {
const { initiativeId, hrefWithScope } = useProgramScope()
if (initiativeId) {
return <Navigate to={hrefWithScope('/plan/profile')} replace />
}
return <Navigate to="/plan/portfolio" replace />
}

View File

@ -1,23 +1,19 @@
import { Navigate, Outlet } from 'react-router-dom'
import { Outlet } from 'react-router-dom'
import { ModeShell } from '../../components/ModeShell.jsx'
import { ModeSubNav } from '../../components/ModeSubNav.jsx'
const PLAN_NAV = [
{ to: '/plan/structure', label: 'Struktur', end: true },
{ to: '/plan/gates', label: 'Zielzustände', end: true },
{ to: '/plan/inbox', label: 'Eingang', end: true },
]
import { PlanOutlineNav } from '../../components/PlanOutlineNav.jsx'
export function PlanLayout() {
return (
<div className="app-page">
<div className="app-page plan-outline-shell">
<ModeShell modeKey="plan" />
<ModeSubNav items={PLAN_NAV} />
<Outlet />
<div className="plan-outline-shell__body">
<PlanOutlineNav />
<div className="plan-outline-shell__content">
<Outlet />
</div>
</div>
</div>
)
}
export function PlanIndexRedirect() {
return <Navigate to="/plan/structure" replace />
}
export { PlanIndexRedirect } from './PlanIndexRedirect.jsx'

View File

@ -0,0 +1,64 @@
import { Link } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { listInitiatives } from '../../api/initiatives.js'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { scopedPath } from '../../utils/routes.js'
export function PlanPortfolioPage() {
const { applyFromInitiative } = useProgramScope()
const [initiatives, setInitiatives] = useState(null)
useEffect(() => {
let cancelled = false
listInitiatives()
.then((data) => {
if (!cancelled) setInitiatives(data)
})
.catch(() => {
if (!cancelled) setInitiatives([])
})
return () => {
cancelled = true
}
}, [])
if (initiatives === null) {
return <LoadingState message="Lade Vorhaben …" />
}
return (
<section className="card plan-portfolio">
<h2 className="card-title">Programm planen</h2>
<p className="page-lead">
Wähle ein Vorhaben im Scope oben oder in der Liste dann erscheint die Plan-Outline
mit Profil, Struktur, Zielzuständen und Eingang.
</p>
{initiatives.length === 0 ? (
<p className="muted">
Noch keine Vorhaben angelegt.{' '}
<Link to="/initiatives">Zum Vorhaben-Katalog</Link>
</p>
) : (
<>
<p className="muted plan-portfolio__hint">
Schnellstart Vorhaben öffnen:
</p>
<ul className="scope-picker__list">
{initiatives.map((item) => (
<li key={item.id}>
<Link
to={scopedPath('/plan/profile', { initiativeId: item.id })}
className="scope-picker__link"
onClick={() => applyFromInitiative(item.id)}
>
{item.title}
</Link>
</li>
))}
</ul>
</>
)}
</section>
)
}

View File

@ -0,0 +1,51 @@
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { StatusBadge } from '../../components/StatusBadge.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
function PlanProfileInner() {
const ops = useInitiativeOperations()
const { initiative, loading, error } = ops
if (loading) {
return <LoadingState message="Lade Profil …" />
}
if (!initiative) {
return <p className="muted">Vorhaben nicht gefunden.</p>
}
return (
<section className="card plan-profile">
<header className="plan-profile__header">
<h2 className="card-title">{initiative.title}</h2>
<StatusBadge kind="initiative" status={initiative.status} />
</header>
{error && <p className="error">{error}</p>}
{initiative.goal ? (
<div className="plan-profile__field">
<h3 className="plan-profile__label">Ziel</h3>
<p>{initiative.goal}</p>
</div>
) : (
<p className="muted">
Noch kein Ziel hinterlegt vollständiges Profil folgt mit Archetypen (AP1.10).
</p>
)}
<p className="muted plan-profile__note">
Bearbeitung per Modal kommt in AP1.10 (Entity Field System).
</p>
</section>
)
}
export function PlanProfilePage() {
return (
<RequireInitiativeScope lead="Das Vorhabens-Profil gehört zu einem gewählten Scope.">
<ScopedInitiativeProvider>
<PlanProfileInner />
</ScopedInitiativeProvider>
</RequireInitiativeScope>
)
}

View File

@ -0,0 +1,34 @@
/**
* Top-level Plan-Outline-Knoten (AP1.12a).
* Unterknoten (Projekt-Baum, Gate-Graph) folgen in AP1.12b+.
*
* @typedef {{ key: string, to: string, label: string, requiresInitiative?: boolean, disabled?: boolean, hint?: string }} PlanOutlineNode
*/
/** @type {PlanOutlineNode[]} */
export const PLAN_OUTLINE_NODES = [
{ key: 'profile', to: '/plan/profile', label: 'Profil', requiresInitiative: true },
{ key: 'structure', to: '/plan/structure', label: 'Struktur', requiresInitiative: true },
{ key: 'gates', to: '/plan/gates', label: 'Zielzustände', requiresInitiative: true },
{ key: 'inbox', to: '/plan/inbox', label: 'Eingang', requiresInitiative: true },
{
key: 'work',
to: '/plan/work',
label: 'Arbeit',
requiresInitiative: true,
disabled: true,
hint: 'AP1.12 — Actions im Plan-Kontext',
},
]
/**
* @param {string} pathname
* @returns {string | null}
*/
export function resolvePlanOutlineActiveKey(pathname) {
const path = pathname.replace(/\/$/, '') || pathname
const match = PLAN_OUTLINE_NODES.find(
(node) => path === node.to || path.startsWith(`${node.to}/`),
)
return match?.key ?? null
}

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { PLAN_OUTLINE_NODES, resolvePlanOutlineActiveKey } from './planOutlineNodes.js'
describe('planOutlineNodes', () => {
it('lists plan sections in product order', () => {
expect(PLAN_OUTLINE_NODES.map((n) => n.key)).toEqual([
'profile',
'structure',
'gates',
'inbox',
'work',
])
})
it('resolves active outline key from pathname', () => {
expect(resolvePlanOutlineActiveKey('/plan/profile')).toBe('profile')
expect(resolvePlanOutlineActiveKey('/plan/structure')).toBe('structure')
expect(resolvePlanOutlineActiveKey('/plan/gates')).toBe('gates')
expect(resolvePlanOutlineActiveKey('/plan/inbox')).toBe('inbox')
expect(resolvePlanOutlineActiveKey('/plan/portfolio')).toBe(null)
})
})

View File

@ -6,6 +6,8 @@ import { PlanLayout, PlanIndexRedirect } from '../pages/modes/PlanLayout.jsx'
import { PlanStructurePage } from '../pages/modes/PlanStructurePage.jsx'
import { PlanGatesPage } from '../pages/modes/PlanGatesPage.jsx'
import { PlanInboxPage } from '../pages/modes/PlanInboxPage.jsx'
import { PlanProfilePage } from '../pages/modes/PlanProfilePage.jsx'
import { PlanPortfolioPage } from '../pages/modes/PlanPortfolioPage.jsx'
import { ControlLayout, ControlIndexRedirect } from '../pages/modes/ControlLayout.jsx'
import { ControlStatusPage } from '../pages/modes/ControlStatusPage.jsx'
import { ControlJourneyPage } from '../pages/modes/ControlJourneyPage.jsx'
@ -125,6 +127,8 @@ export function getViewByKey(key) {
export const MODE_ROUTE_COMPONENTS = {
workToday: WorkTodayPage,
workMine: WorkMinePage,
planPortfolio: PlanPortfolioPage,
planProfile: PlanProfilePage,
planStructure: PlanStructurePage,
planGates: PlanGatesPage,
planInbox: PlanInboxPage,

View File

@ -291,3 +291,122 @@
.section-header--compact {
margin-bottom: 12px;
}
.plan-outline-shell__body {
display: grid;
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr);
gap: 20px;
align-items: start;
}
@media (max-width: 900px) {
.plan-outline-shell__body {
grid-template-columns: 1fr;
}
}
.plan-outline-nav {
position: sticky;
top: 12px;
padding: 14px;
border-radius: 12px;
border: 1px solid var(--jk-border);
background: var(--jk-surface);
}
.plan-outline-nav__header {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--jk-border);
}
.plan-outline-nav__eyebrow {
display: block;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--jk-text-muted);
}
.plan-outline-nav__root {
margin: 6px 0 0;
font-size: 15px;
font-weight: 700;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
}
.plan-outline-nav__root--muted {
font-weight: 600;
color: var(--jk-text-muted);
}
.plan-outline-nav__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.plan-outline-nav__link {
display: block;
padding: 8px 10px;
border-radius: 8px;
text-decoration: none;
font-size: 13px;
font-weight: 600;
color: var(--jk-text-muted);
}
.plan-outline-nav__link:hover {
background: var(--jk-primary-soft);
color: var(--jk-text);
}
.plan-outline-nav__link--active {
color: var(--jk-primary);
background: var(--jk-primary-soft);
}
.plan-outline-nav__link--disabled {
opacity: 0.55;
cursor: not-allowed;
}
.plan-outline-shell__content {
min-width: 0;
}
.plan-profile__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.plan-profile__label {
margin: 0 0 4px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--jk-text-muted);
}
.plan-profile__field {
margin-top: 12px;
}
.plan-profile__note {
margin-top: 16px;
font-size: 13px;
}
.plan-portfolio__hint {
margin-top: 8px;
}

View File

@ -6,7 +6,7 @@
const MODE_DEFAULTS = {
cockpit: { portfolioHub: '/cockpit', initiativeHub: '/cockpit' },
work: { portfolioHub: '/work/today', initiativeHub: '/work/today' },
plan: { portfolioHub: '/plan/structure', initiativeHub: '/plan/structure' },
plan: { portfolioHub: '/plan/portfolio', initiativeHub: '/plan/profile' },
control: { portfolioHub: '/control/status', initiativeHub: '/control/status' },
team: { portfolioHub: '/team', initiativeHub: '/team' },
}