diff --git a/backend/version.py b/backend/version.py index 35e2d0e..8aaa9ed 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8be2470..93b8a38 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -68,6 +68,8 @@ function AppRoutes() { }> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/PlanOutlineNav.jsx b/frontend/src/components/PlanOutlineNav.jsx new file mode 100644 index 0000000..7789bb9 --- /dev/null +++ b/frontend/src/components/PlanOutlineNav.jsx @@ -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 ( + + ) +} diff --git a/frontend/src/pages/modes/PlanIndexRedirect.jsx b/frontend/src/pages/modes/PlanIndexRedirect.jsx new file mode 100644 index 0000000..3a6d855 --- /dev/null +++ b/frontend/src/pages/modes/PlanIndexRedirect.jsx @@ -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 + } + + return +} diff --git a/frontend/src/pages/modes/PlanLayout.jsx b/frontend/src/pages/modes/PlanLayout.jsx index 024afa4..0b7c9fa 100644 --- a/frontend/src/pages/modes/PlanLayout.jsx +++ b/frontend/src/pages/modes/PlanLayout.jsx @@ -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 ( -
+
- - +
+ +
+ +
+
) } -export function PlanIndexRedirect() { - return -} +export { PlanIndexRedirect } from './PlanIndexRedirect.jsx' diff --git a/frontend/src/pages/modes/PlanPortfolioPage.jsx b/frontend/src/pages/modes/PlanPortfolioPage.jsx new file mode 100644 index 0000000..f554fbe --- /dev/null +++ b/frontend/src/pages/modes/PlanPortfolioPage.jsx @@ -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 + } + + return ( +
+

Programm planen

+

+ Wähle ein Vorhaben im Scope oben oder in der Liste — dann erscheint die Plan-Outline + mit Profil, Struktur, Zielzuständen und Eingang. +

+ {initiatives.length === 0 ? ( +

+ Noch keine Vorhaben angelegt.{' '} + Zum Vorhaben-Katalog +

+ ) : ( + <> +

+ Schnellstart — Vorhaben öffnen: +

+
    + {initiatives.map((item) => ( +
  • + applyFromInitiative(item.id)} + > + {item.title} + +
  • + ))} +
+ + )} +
+ ) +} diff --git a/frontend/src/pages/modes/PlanProfilePage.jsx b/frontend/src/pages/modes/PlanProfilePage.jsx new file mode 100644 index 0000000..81bea34 --- /dev/null +++ b/frontend/src/pages/modes/PlanProfilePage.jsx @@ -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 + } + + if (!initiative) { + return

Vorhaben nicht gefunden.

+ } + + return ( +
+
+

{initiative.title}

+ +
+ {error &&

{error}

} + {initiative.goal ? ( +
+

Ziel

+

{initiative.goal}

+
+ ) : ( +

+ Noch kein Ziel hinterlegt — vollständiges Profil folgt mit Archetypen (AP1.10). +

+ )} +

+ Bearbeitung per Modal kommt in AP1.10 (Entity Field System). +

+
+ ) +} + +export function PlanProfilePage() { + return ( + + + + + + ) +} diff --git a/frontend/src/plan/planOutlineNodes.js b/frontend/src/plan/planOutlineNodes.js new file mode 100644 index 0000000..f7a4c08 --- /dev/null +++ b/frontend/src/plan/planOutlineNodes.js @@ -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 +} diff --git a/frontend/src/plan/planOutlineNodes.test.js b/frontend/src/plan/planOutlineNodes.test.js new file mode 100644 index 0000000..e3dc553 --- /dev/null +++ b/frontend/src/plan/planOutlineNodes.test.js @@ -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) + }) +}) diff --git a/frontend/src/registry/viewRegistry.js b/frontend/src/registry/viewRegistry.js index c4c4467..12d56f2 100644 --- a/frontend/src/registry/viewRegistry.js +++ b/frontend/src/registry/viewRegistry.js @@ -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, diff --git a/frontend/src/styles/program-chrome.css b/frontend/src/styles/program-chrome.css index 7239c28..5c04fa2 100644 --- a/frontend/src/styles/program-chrome.css +++ b/frontend/src/styles/program-chrome.css @@ -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; +} diff --git a/frontend/src/utils/scopeNavigation.js b/frontend/src/utils/scopeNavigation.js index 234280d..e2822a8 100644 --- a/frontend/src/utils/scopeNavigation.js +++ b/frontend/src/utils/scopeNavigation.js @@ -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' }, }