diff --git a/backend/entity_archetypes/ui_profiles.py b/backend/entity_archetypes/ui_profiles.py index ec9c9dc..b89dcbf 100644 --- a/backend/entity_archetypes/ui_profiles.py +++ b/backend/entity_archetypes/ui_profiles.py @@ -1,4 +1,4 @@ -"""UI-Profile für Initiative-Archetypen — migriert aus methodUiDefaults.js (AP2.3a).""" +"""UI-Profile für Initiative-Archetypen — AP2.3a / AP2.3f.""" from __future__ import annotations @@ -37,6 +37,17 @@ _SUPPORT_QUEUE_PROCESS = ( {"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"}, ) +_CONTENT_PROCESS = ( + {"key": "gates", "to": "/plan/gates", "label": "Kapitel", "mode": "plan"}, + {"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"}, + {"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"}, +) + +_DISPUTE_PROCESS = ( + {"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"}, + {"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"}, +) + _COMMON_OM_SLICES = ( "actions", "blockers", @@ -53,15 +64,25 @@ GENERIC_UI_PROFILE: dict[str, Any] = { "controlDefaultRoute": "/control/status", "planOutlineKeys": None, "workNavKeys": None, - "dataSlices": ["actions", "backlog", "roadmap", "projects", "blockers", *_COMMON_OM_SLICES[1:]], + "dataSlices": [ + "actions", + "backlog", + "roadmap", + "projects", + "blockers", + *_COMMON_OM_SLICES[1:], + ], "enabledModes": ["plan", "work", "control"], + "uiFeatures": {}, } INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { + "initiative.generic": dict(GENERIC_UI_PROFILE), "initiative.product": { "processSteps": list(_PRODUCT_PROCESS), "planDefaultRoute": "/plan/inbox", "workDefaultRoute": "/work/sprint", + "workDefaultRouteWithoutActiveWorkCycle": "/work/today", "controlDefaultRoute": "/control/status", "planOutlineKeys": ["profile", "inbox", "sprint", "gates"], "workNavKeys": ["sprint", "today", "mine"], @@ -78,6 +99,10 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], + "uiFeatures": { + "continuousProductWorkMode": True, + "steeringSnapshotOnWorkSprint": True, + }, }, "initiative.linear_project": { "processSteps": list(_LINEAR_PROCESS), @@ -98,6 +123,9 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], + "uiFeatures": { + "criticalPathControl": True, + }, }, "initiative.maturity_journey": { "processSteps": list(_MATURITY_PROCESS), @@ -117,6 +145,7 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], + "uiFeatures": {}, }, "initiative.program": { "processSteps": list(_PROGRAM_PROCESS), @@ -138,6 +167,7 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], + "uiFeatures": {}, }, "initiative.support_queue": { "processSteps": list(_SUPPORT_QUEUE_PROCESS), @@ -153,6 +183,7 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], + "uiFeatures": {}, }, "initiative.recurring_program": { "processSteps": list(_MATURITY_PROCESS), @@ -169,6 +200,44 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], + "uiFeatures": {}, + }, + "initiative.content_project": { + "processSteps": list(_CONTENT_PROCESS), + "planDefaultRoute": "/plan/gates", + "workDefaultRoute": "/work/today", + "controlDefaultRoute": "/control/status", + "planOutlineKeys": ["profile", "gates", "inbox", "work"], + "workNavKeys": ["today", "mine"], + "dataSlices": [ + "actions", + "roadmap", + "blockers", + "evidence", + "decisions", + "reviews", + "steering_methods", + ], + "enabledModes": ["plan", "work", "control"], + "uiFeatures": {}, + }, + "initiative.dispute_case": { + "processSteps": list(_DISPUTE_PROCESS), + "planDefaultRoute": "/plan/profile", + "workDefaultRoute": "/work/today", + "controlDefaultRoute": "/control/status", + "planOutlineKeys": ["profile", "work"], + "workNavKeys": ["today", "mine"], + "dataSlices": [ + "actions", + "blockers", + "evidence", + "decisions", + "reviews", + "steering_methods", + ], + "enabledModes": ["plan", "work", "control"], + "uiFeatures": {}, }, } diff --git a/backend/steering/methods/registrations/ap20_method_stubs.py b/backend/steering/methods/registrations/ap20_method_stubs.py index 63f6d4b..1632449 100644 --- a/backend/steering/methods/registrations/ap20_method_stubs.py +++ b/backend/steering/methods/registrations/ap20_method_stubs.py @@ -58,6 +58,16 @@ def register() -> None: label="Verfahren / Konflikt", description="Reaktive Steuerung, Fristen, Entscheidungen", next_action_strategy_key="default", + data_slices=frozenset( + { + "actions", + "blockers", + "evidence", + "decisions", + "reviews", + "steering_methods", + } + ), compatible_archetype_keys=frozenset({"initiative.dispute_case"}), ) register_stub_method( @@ -65,5 +75,16 @@ def register() -> None: label="Kapitel-Entwicklung", description="Inhaltliche Progression, Reviews", next_action_strategy_key="default", + data_slices=frozenset( + { + "actions", + "roadmap", + "blockers", + "evidence", + "decisions", + "reviews", + "steering_methods", + } + ), compatible_archetype_keys=frozenset({"initiative.content_project"}), ) diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index b4c40ee..71bb5f8 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -84,6 +84,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Situativer Steuerungskontext (Next Action) | ✗ | 📄 Vision §7.6; AP1.8b deferred | | AttentionItem | ◐ | | | Initiative Steering Snapshot | ✓ | Actions + linked; Archetyp/Guidance AP2.0c | +| Operating Context API | ✓ | AP2.3a: `GET …/operating-context`, `ui_profile_json` Migration 027 | +| Archetyp-/Methoden-Plugin-Architektur | ◐ | AP2.3 A–E ✓; AP2.3f Restschuld (Resolver, Route-Gating, uiFeatures, slice reload) ◐ | | operating_phase | ✗ | entfernt AP1.2 | | signals (Snapshot) | ✓ | `snapshot_signals.py` | | Graph Read Models (blocked/ready) | ◐ | AP1.4d/e; Join/OR AP1.15d deferred | @@ -117,6 +119,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Plan-Outline | ◐ | AP1.12a–d: Baum, Modal, Reorder, Actions; AP-Kanten AP1.16c offen | | Execution-Plan (AP-Graph) | ◐ | AP1.16c: Outline + Arbeit-Liste + Action-Detail | | Profil-Modal (Archetyp/EFS) | ◐ | AP1.10c | +| Operating Profile Resolver (FE) | ◐ | AP2.3b/f: API-first, `methodUiDefaults` Fallback | +| Route Gating (Modus-Routen) | ◐ | AP2.3d/f: `OperatingRouteGate`, `MODE_ROUTE_GROUPS` | | Modal-Bearbeitung (Gates/Profil) | ◐ | viele Sektionen noch Inline-CRUD | | Admin-UI | ✗ | | | Team-Modus | ✗ | AP1.9f | diff --git a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md index fb30047..9cfd5a2 100644 --- a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md +++ b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md @@ -4,7 +4,8 @@ **Status:** PO-Kurskorrektur — **führend für Implementierung ab 2026-07-12** **Stand:** 2026-07-12 **Ersetzt als Priorisierung:** Dogfooding-Seed als Implementierungs-Hebel; Status-Review §5 (Dogfooding-first) -**Bezug:** `Kairo_MVP_Definition_v0.3.md` §5–9, `ADP_Archetype_and_Method_Catalog_v0.2.md` §3, `Kairo_Corrected_MVP_Roadmap_v0.2.md` (AP-Historie) +**Bezug:** `Kairo_MVP_Definition_v0.3.md` §5–9, `ADP_Archetype_and_Method_Catalog_v0.2.md` §3, `Kairo_Corrected_MVP_Roadmap_v0.2.md` (AP-Historie) +**Spezifikationsphase (ab 2026-07-24):** `Kairo_Archetype_Specification_Program_v0.1.md` + `docs/product/archetypes/` — Spec vor neuem Archetyp-Code (Welle 2+); ersetzt diesen Plan nicht --- diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 82da70b..2a7896c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,9 +15,8 @@ import { PlanIndexRedirect, ControlLayout, ControlIndexRedirect, - MODE_ROUTE_COMPONENTS, } from './registry/viewRegistry.js' -import { OperatingRouteGate } from './registry/OperatingRouteGate.jsx' +import { renderModeRoutes } from './registry/renderModeRoutes.jsx' import { WorkspaceRedirect, MyActionsRedirect, @@ -65,90 +64,17 @@ function AppRoutes() { }> } /> - - - - } - /> - - - - } - /> - - - - } - /> + {renderModeRoutes('work')} }> } /> - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> + {renderModeRoutes('plan')} }> } /> - } /> - - - - } - /> - - - - } - /> + {renderModeRoutes('control')} } /> diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx index ecb754d..a71409c 100644 --- a/frontend/src/context/InitiativeOperationsContext.jsx +++ b/frontend/src/context/InitiativeOperationsContext.jsx @@ -91,6 +91,7 @@ import { applySliceResults, loadOperatingSlices, } from '../registry/operatingSliceLoaders.js' +import { resolveOperatingProfileFromInput } from '../registry/resolveOperatingProfile.js' const InitiativeOperationsContext = createContext(null) @@ -158,6 +159,22 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ [id, sliceSetters], ) + const refreshOm = useCallback( + async (...sliceKeys) => { + if (!id || !sliceKeys.length) return + const unique = [...new Set(sliceKeys.flat())] + await reloadSlices(unique) + }, + [id, reloadSlices], + ) + + const refreshOperatingContext = useCallback(async () => { + if (!id) return null + const opCtx = await getInitiativeOperatingContext(id).catch(() => null) + setOperatingContext(opCtx) + return opCtx + }, [id]) + const load = useCallback(async ({ silent = false, sliceKeys = null } = {}) => { if (sessionLoading || !id) return if (!silent) setLoading(true) @@ -319,7 +336,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setMethodBusy(true) try { await updateInitiativeSteeringMethod(id, methodKey) - setSteeringSnapshot(await getInitiativeSteeringSnapshot(id)) + await refreshOperatingContext() + await refreshOm(['steering_snapshot', 'steering_methods']) } catch (err) { setSteeringSnapshotError(err.message) } finally { @@ -331,7 +349,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setMethodBusy(true) try { await updateInitiativeMethodProfile(id, methodProfileKey || null) - setSteeringSnapshot(await getInitiativeSteeringSnapshot(id)) + await refreshOperatingContext() + await refreshOm(['steering_snapshot', 'steering_methods']) } catch (err) { setSteeringSnapshotError(err.message) } finally { @@ -354,7 +373,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ assigned_actor_ids: assigned, }) setShowActionForm(false) - await load() + await refreshOm(['actions']) } catch (err) { setError(err.message) } finally { @@ -383,7 +402,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ await setActionAssignments(actionId, payload.assigned_actor_ids || []) } setEditingActionId(null) - await load() + await refreshOm(['actions']) } catch (err) { setError(err.message) } finally { @@ -410,7 +429,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ if (!capabilities.has('kairo.action.manage')) return try { await updateAction(action.id, { status }) - await load() + await refreshOm(['actions']) } catch (err) { setError(err.message) } @@ -420,7 +439,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setFormBusy(true) try { await createInitiativeBlocker(id, body) - await load() + await refreshOm(['blockers', 'actions']) } catch (err) { setError(err.message) } finally { @@ -437,7 +456,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ action_id: actionId, set_action_blocked: true, }) - await load() + await refreshOm(['blockers', 'actions']) } catch (err) { setError(err.message) } finally { @@ -448,7 +467,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleBlockerStatus(blockerId, status) { try { await updateBlocker(blockerId, { status }) - await load() + await refreshOm(['blockers', 'actions']) } catch (err) { setError(err.message) } @@ -457,7 +476,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleDeleteBlocker(blockerId) { try { await deleteBlocker(blockerId) - await load() + await refreshOm(['blockers', 'actions']) } catch (err) { setError(err.message) } @@ -469,7 +488,11 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ try { await updateInitiative(id, core) await saveInitiativeDynamicFields(id, dynamicFields) - await load() + await refreshOm(['initiative']) + if (core.archetype_key) { + await refreshOperatingContext() + await load({ silent: true }) + } return true } catch (err) { setError(err.message) @@ -483,7 +506,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setFormBusy(true) try { await createInitiativeBacklogItem(id, body) - await load() + await refreshOm(['backlog']) } catch (err) { setError(err.message) } finally { @@ -495,7 +518,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setFormBusy(true) try { await updateBacklogItem(itemId, body) - await load() + await refreshOm(['backlog']) return true } catch (err) { setError(err.message) @@ -511,7 +534,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ roadmap_item_id: roadmapItemId || undefined, clear_roadmap_item: !roadmapItemId, }) - await load() + await refreshOm(['backlog']) } catch (err) { setError(err.message) } @@ -520,7 +543,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleBacklogStatus(itemId, status) { try { await updateBacklogItem(itemId, { status }) - await load() + await refreshOm(['backlog']) } catch (err) { setError(err.message) } @@ -587,7 +610,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setFormBusy(true) try { const created = await createWorkCycle(id, body) - await load({ silent: true }) + await refreshOm(['work_cycles', 'actions']) if (created?.id) setSelectedWorkCycleId(created.id) } catch (err) { setError(err.message) @@ -600,7 +623,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setFormBusy(true) try { await activateWorkCycle(id, workCycleId) - await load({ silent: true }) + await refreshOm(['work_cycles', 'actions']) setSelectedWorkCycleId(workCycleId) } catch (err) { setError(err.message) @@ -613,7 +636,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setFormBusy(true) try { await completeWorkCycle(id, workCycleId) - await load({ silent: true }) + await refreshOm(['work_cycles', 'actions']) } catch (err) { setError(err.message) } finally { @@ -635,7 +658,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ } } await completeWorkCycle(id, workCycleId) - await load({ silent: true }) + await refreshOm(['work_cycles', 'actions']) } catch (err) { setError(err.message) throw err @@ -648,7 +671,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setFormBusy(true) try { await reactivateWorkCycle(id, workCycleId) - await load({ silent: true }) + await refreshOm(['work_cycles', 'actions']) setSelectedWorkCycleId(workCycleId) } catch (err) { setError(err.message) @@ -660,7 +683,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleDeleteBacklog(itemId) { try { await deleteBacklogItem(itemId) - await load() + await refreshOm(['backlog']) } catch (err) { setError(err.message) } @@ -670,7 +693,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setFormBusy(true) try { const created = await createInitiativeRoadmapItem(id, body) - await load() + await refreshOm(['roadmap']) return created } catch (err) { setError(err.message) @@ -683,7 +706,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleRoadmapItemStatus(itemId, status) { try { await updateRoadmapItem(itemId, { status }) - await load() + await refreshOm(['roadmap']) } catch (err) { setError(err.message) } @@ -692,7 +715,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleUpdateRoadmapItem(itemId, body) { try { await updateRoadmapItem(itemId, body) - await load() + await refreshOm(['roadmap']) } catch (err) { setError(err.message) } @@ -708,7 +731,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ status: 'accepted', }) await verifyRoadmapItemReached(itemId) - await load() + await refreshOm(['roadmap', 'steering_snapshot']) } catch (err) { setError(err.message) } finally { @@ -720,7 +743,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setFormBusy(true) try { await verifyRoadmapItemReached(itemId) - await load() + await refreshOm(['roadmap', 'steering_snapshot']) } catch (err) { setError(err.message) } finally { @@ -731,7 +754,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleDeleteRoadmapItem(itemId) { try { await deleteRoadmapItem(itemId) - await load() + await refreshOm(['roadmap']) } catch (err) { setError(err.message) } @@ -812,7 +835,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ await Promise.all( patches.map((patch) => updateBacklogItem(patch.id, { sort_order: patch.sort_order })), ) - await load() + await refreshOm(['backlog']) return true } catch (err) { setError(err.message) @@ -830,7 +853,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ await Promise.all( patches.map((patch) => updateRoadmapItem(patch.id, { sort_order: patch.sort_order })), ) - await load() + await refreshOm(['roadmap']) return true } catch (err) { setError(err.message) @@ -849,7 +872,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setFormBusy(true) try { await createInitiativeEvidence(id, body) - await load() + await refreshOm(['evidence']) } catch (err) { setError(err.message) } finally { @@ -860,7 +883,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleEvidenceStatus(evidenceId, status) { try { await updateEvidence(evidenceId, { status }) - await load() + await refreshOm(['evidence']) } catch (err) { setError(err.message) } @@ -869,7 +892,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleDeleteEvidence(evidenceId) { try { await deleteEvidence(evidenceId) - await load() + await refreshOm(['evidence']) } catch (err) { setError(err.message) } @@ -879,7 +902,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setFormBusy(true) try { await createInitiativeDecision(id, body) - await load() + await refreshOm(['decisions']) } catch (err) { setError(err.message) } finally { @@ -890,7 +913,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleDecisionStatus(decisionId, status) { try { await updateDecision(decisionId, { status }) - await load() + await refreshOm(['decisions']) } catch (err) { setError(err.message) } @@ -899,7 +922,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleDeleteDecision(decisionId) { try { await deleteDecision(decisionId) - await load() + await refreshOm(['decisions']) } catch (err) { setError(err.message) } @@ -909,7 +932,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setFormBusy(true) try { await createInitiativeReview(id, body) - await load() + await refreshOm(['reviews']) } catch (err) { setError(err.message) } finally { @@ -920,7 +943,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleReviewStatus(reviewId, status) { try { await updateReview(reviewId, { status }) - await load() + await refreshOm(['reviews']) } catch (err) { setError(err.message) } @@ -929,7 +952,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleDeleteReview(reviewId) { try { await deleteReview(reviewId) - await load() + await refreshOm(['reviews']) } catch (err) { setError(err.message) } @@ -939,7 +962,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setFormBusy(true) try { await createInitiativeRecurring(id, body) - await load() + await refreshOm(['recurring']) } catch (err) { setError(err.message) } finally { @@ -950,7 +973,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleRecurringStatus(recurringId, status) { try { await updateRecurring(recurringId, { status }) - await load() + await refreshOm(['recurring']) } catch (err) { setError(err.message) } @@ -959,12 +982,23 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleDeleteRecurring(recurringId) { try { await deleteRecurring(recurringId) - await load() + await refreshOm(['recurring']) } catch (err) { setError(err.message) } } + const operatingProfile = useMemo( + () => + resolveOperatingProfileFromInput({ + operatingContext, + hasActiveSprint: Boolean(activeWorkCycle?.id), + }), + [operatingContext, activeWorkCycle?.id], + ) + + const uiFeatures = operatingProfile.uiFeatures || {} + const value = { initiativeId: id, initiative, @@ -996,6 +1030,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ steeringMethods, operatingContext, dataSlices: operatingContext?.data_slices || [], + uiFeatures, + operatingProfile, methodBusy, loading, error, @@ -1015,6 +1051,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ reloadActors: actorsState.reload, reload: load, reloadSlices, + refreshOm, + refreshOperatingContext, handleMethodChange, handleMethodProfileChange, handleCreateAction, diff --git a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx index 6f517c0..7ffe4bb 100644 --- a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx +++ b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx @@ -5,14 +5,6 @@ import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.js import { CriticalPathPanel } from '../../components/CriticalPathPanel.jsx' import { NextActionWidget } from '../../widgets/NextActionWidget.jsx' -function usesCriticalPathControl(initiative, steeringSnapshot) { - const archetypeKey = initiative?.archetype_key - const methodKey = steeringSnapshot?.method_key - return ( - archetypeKey === 'initiative.linear_project' || methodKey === 'sequential_dependency' - ) -} - export function InitiativeOverviewPage() { const { initiativeId, @@ -26,10 +18,11 @@ export function InitiativeOverviewPage() { methodBusy, capabilities, handleMethodChange, + uiFeatures, } = useInitiativeOperations() const counts = steeringSnapshot?.counts || {} - const showCriticalPath = usesCriticalPathControl(initiative, steeringSnapshot) + const showCriticalPath = Boolean(uiFeatures.criticalPathControl) return ( <> diff --git a/frontend/src/pages/modes/ControlLayout.jsx b/frontend/src/pages/modes/ControlLayout.jsx index 0e14b32..ecc8853 100644 --- a/frontend/src/pages/modes/ControlLayout.jsx +++ b/frontend/src/pages/modes/ControlLayout.jsx @@ -6,6 +6,8 @@ import { CONTROL_NAV_ITEMS, resolveControlNavActiveKey, } from '../../config/controlNav.js' +import { useProgramScope } from '../../context/ProgramScopeContext.jsx' +import { useMethodUiContext } from '../../hooks/useMethodUiContext.js' export function ControlLayout() { return ( @@ -29,5 +31,13 @@ export function ControlLayout() { } export function ControlIndexRedirect() { - return + const { initiativeId, hrefWithScope } = useProgramScope() + const { controlDefaultRoute, loading, operatingContext } = useMethodUiContext() + + if (initiativeId && loading && !operatingContext) { + return null + } + + const target = initiativeId ? hrefWithScope(controlDefaultRoute) : '/control/status' + return } diff --git a/frontend/src/pages/modes/PlanIndexRedirect.jsx b/frontend/src/pages/modes/PlanIndexRedirect.jsx index 9223095..9d3f555 100644 --- a/frontend/src/pages/modes/PlanIndexRedirect.jsx +++ b/frontend/src/pages/modes/PlanIndexRedirect.jsx @@ -1,48 +1,19 @@ -import { useEffect, useState } from 'react' import { Navigate } from 'react-router-dom' import { useProgramScope } from '../../context/ProgramScopeContext.jsx' -import { getInitiativeOperatingContext } from '../../api/initiatives.js' -import { getActiveWorkCycle } from '../../api/workCycles.js' -import { resolveOperatingProfileFromInput } from '../../registry/resolveOperatingProfile.js' +import { useMethodUiContext } from '../../hooks/useMethodUiContext.js' -/** Portfolio ohne Scope → Übersicht; mit Vorhaben → methoden-Default (AP1.9d). */ +/** Portfolio ohne Scope → Übersicht; mit Vorhaben → methoden-Default aus Operating Context. */ export function PlanIndexRedirect() { const { initiativeId, hrefWithScope } = useProgramScope() - const [target, setTarget] = useState(null) - - useEffect(() => { - if (!initiativeId) { - setTarget('/plan/portfolio') - return undefined - } - let cancelled = false - Promise.all([ - getInitiativeOperatingContext(initiativeId), - getActiveWorkCycle(initiativeId).catch(() => null), - ]) - .then(([context, activeCycle]) => { - if (cancelled) return - const defaults = resolveOperatingProfileFromInput({ - operatingContext: context, - hasActiveSprint: Boolean(activeCycle?.id), - }) - setTarget(hrefWithScope(defaults.planDefaultRoute)) - }) - .catch(() => { - if (!cancelled) setTarget(hrefWithScope('/plan/profile')) - }) - return () => { - cancelled = true - } - }, [initiativeId, hrefWithScope]) + const { planDefaultRoute, loading, operatingContext } = useMethodUiContext() if (!initiativeId) { return } - if (!target) { + if (loading && !operatingContext) { return null } - return + return } diff --git a/frontend/src/pages/modes/WorkLayout.jsx b/frontend/src/pages/modes/WorkLayout.jsx index 4a005c2..2efa609 100644 --- a/frontend/src/pages/modes/WorkLayout.jsx +++ b/frontend/src/pages/modes/WorkLayout.jsx @@ -1,45 +1,22 @@ import { Navigate, Outlet } from 'react-router-dom' -import { useEffect, useState } from 'react' +import { useMemo } from 'react' import { ModeAreaShell } from '../../components/ModeAreaShell.jsx' import { ModeAreaNav } from '../../components/ModeAreaNav.jsx' import { ModeShell } from '../../components/ModeShell.jsx' import { WORK_NAV_ITEMS, resolveWorkNavActiveKey } from '../../config/workNav.js' -import { resolveWorkNavItems } from '../../config/methodUiDefaults.js' import { useProgramScope } from '../../context/ProgramScopeContext.jsx' -import { getInitiative } from '../../api/initiatives.js' -import { getActiveWorkCycle } from '../../api/workCycles.js' -import { resolveMethodUiDefaults } from '../../config/methodUiDefaults.js' +import { useMethodUiContext } from '../../hooks/useMethodUiContext.js' +import { resolveWorkNavItemsFromProfile } from '../../registry/resolveOperatingProfile.js' function WorkAreaNav() { const { initiativeId } = useProgramScope() - const [navItems, setNavItems] = useState(WORK_NAV_ITEMS) + const { operatingContext, hasActiveSprint, loading } = useMethodUiContext() - useEffect(() => { - if (!initiativeId) { - setNavItems(WORK_NAV_ITEMS) - return undefined - } - let cancelled = false - Promise.all([ - getInitiative(initiativeId), - getActiveWorkCycle(initiativeId).catch(() => null), - ]) - .then(([initiative, activeCycle]) => { - if (cancelled) return - setNavItems( - resolveWorkNavItems({ - archetypeKey: initiative?.archetype_key, - hasActiveSprint: Boolean(activeCycle?.id), - }), - ) - }) - .catch(() => { - if (!cancelled) setNavItems(WORK_NAV_ITEMS) - }) - return () => { - cancelled = true - } - }, [initiativeId]) + const navItems = useMemo(() => { + if (!initiativeId) return WORK_NAV_ITEMS + if (loading && !operatingContext) return WORK_NAV_ITEMS + return resolveWorkNavItemsFromProfile({ operatingContext, hasActiveSprint }) + }, [initiativeId, loading, operatingContext, hasActiveSprint]) return ( { - if (!initiativeId) { - setTarget('/work/today') - return undefined - } - let cancelled = false - Promise.all([ - getInitiative(initiativeId), - getActiveWorkCycle(initiativeId).catch(() => null), - ]) - .then(([initiative, activeCycle]) => { - if (cancelled) return - const defaults = resolveMethodUiDefaults({ - archetypeKey: initiative?.archetype_key, - hasActiveSprint: Boolean(activeCycle?.id), - }) - setTarget(hrefWithScope(defaults.workDefaultRoute)) - }) - .catch(() => { - if (!cancelled) setTarget(hrefWithScope('/work/today')) - }) - return () => { - cancelled = true - } - }, [initiativeId, hrefWithScope]) + if (!initiativeId) { + return + } - return + if (loading && !operatingContext) { + return null + } + + return } diff --git a/frontend/src/pages/modes/WorkSprintPage.jsx b/frontend/src/pages/modes/WorkSprintPage.jsx index 1014c85..b732945 100644 --- a/frontend/src/pages/modes/WorkSprintPage.jsx +++ b/frontend/src/pages/modes/WorkSprintPage.jsx @@ -1,7 +1,6 @@ import { useEffect, useMemo } from 'react' import { Link } from 'react-router-dom' import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' -import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx' import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx' import { NextActionWidget } from '../../widgets/NextActionWidget.jsx' import { LoadingState } from '../../components/LoadingState.jsx' @@ -43,6 +42,7 @@ function WorkSprintInner() { initiative, operatingContext, dataSlices, + uiFeatures, steeringSnapshot, steeringSnapshotLoading, visibleActions, @@ -50,13 +50,20 @@ function WorkSprintInner() { } = ops const supportsWorkCycles = dataSlices.includes('work_cycles') - const isContinuousProduct = operatingContext?.method_key === 'continuous_product' + const isContinuousProductMode = uiFeatures.continuousProductWorkMode && supportsWorkCycles + const loadSteeringSnapshot = uiFeatures.steeringSnapshotOnWorkSprint useEffect(() => { - if (initiativeId && !steeringSnapshot && !steeringSnapshotLoading) { + if (initiativeId && loadSteeringSnapshot && !steeringSnapshot && !steeringSnapshotLoading) { reloadSlices(['steering_snapshot']) } - }, [initiativeId, steeringSnapshot, steeringSnapshotLoading, reloadSlices]) + }, [ + initiativeId, + loadSteeringSnapshot, + steeringSnapshot, + steeringSnapshotLoading, + reloadSlices, + ]) const defaultWorkCycleId = activeWorkCycle?.id || '' @@ -74,7 +81,7 @@ function WorkSprintInner() { return } - const isProductArchetype = isContinuousProduct && supportsWorkCycles + const isProductArchetype = isContinuousProductMode return ( <> @@ -86,7 +93,7 @@ function WorkSprintInner() {

Product-Ist (ohne aktiven Sprint)

Kein aktiver Sprint — Kairo steuert nach{' '} - continuous_product (wirkungsvollster Schritt). Sprint-Planung + {operatingContext?.method_key || 'continuous_product'} (wirkungsvollster Schritt). Sprint-Planung unter{' '} Plan → Sprint @@ -208,10 +215,8 @@ function WorkSprintInner() { export function WorkSprintPage() { return ( - - - - + + ) } diff --git a/frontend/src/registry/modeRouteRegistry.js b/frontend/src/registry/modeRouteRegistry.js new file mode 100644 index 0000000..8f848f6 --- /dev/null +++ b/frontend/src/registry/modeRouteRegistry.js @@ -0,0 +1,66 @@ +/** + * AP2.3f — Modus-Routen-Matching für Route Gating. + */ +import { MODE_ROUTE_GROUPS } from './viewRegistry.js' + +/** + * @param {string} pathname + * @returns {{ layout: string, route: import('./viewRegistry.js').ModeRouteDefinition, fullPath: string } | null} + */ +export function matchModeRoute(pathname) { + const normalized = (pathname || '').replace(/\/$/, '') || pathname + + for (const [layout, routes] of Object.entries(MODE_ROUTE_GROUPS)) { + for (const route of routes) { + const fullPath = route.routePath || `/${layout}/${route.path}` + if (normalized === fullPath || normalized.startsWith(`${fullPath}/`)) { + return { layout, route, fullPath } + } + } + } + return null +} + +/** + * @param {string} pathname + * @param {{ operatingContext?: object | null, dataSlices?: string[] }} input + */ +export function isRouteAllowedForMode(pathname, input = {}) { + const match = matchModeRoute(pathname) + if (!match) return true + + const { route } = match + const context = input.operatingContext + const dataSlices = input.dataSlices || context?.data_slices || [] + const archetypeKey = context?.archetype_key || null + + if (route.allowedArchetypes?.length && archetypeKey) { + if (!route.allowedArchetypes.includes(archetypeKey)) { + return false + } + } + + if (route.requiredSlices?.length) { + return route.requiredSlices.every((slice) => dataSlices.includes(slice)) + } + + return true +} + +/** + * @param {string} pathname + * @param {{ operatingContext?: object | null }} input + */ +export function resolveRedirectForModeRoute(pathname, input = {}) { + const match = matchModeRoute(pathname) + const layout = match?.layout || 'plan' + const profile = input.operatingContext?.ui_profile || {} + + if (layout === 'work') { + return profile.workDefaultRoute || '/work/today' + } + if (layout === 'control') { + return profile.controlDefaultRoute || '/control/status' + } + return profile.planDefaultRoute || '/plan/profile' +} diff --git a/frontend/src/registry/renderModeRoutes.jsx b/frontend/src/registry/renderModeRoutes.jsx new file mode 100644 index 0000000..12dbdc3 --- /dev/null +++ b/frontend/src/registry/renderModeRoutes.jsx @@ -0,0 +1,22 @@ +import { Route } from 'react-router-dom' +import { OperatingRouteGate } from './OperatingRouteGate.jsx' +import { MODE_ROUTE_COMPONENTS, MODE_ROUTE_GROUPS } from './viewRegistry.js' + +/** + * AP2.3f — Modus-Subrouten aus Registry (kein App.jsx-Fork pro Archetyp). + * @param {'work'|'plan'|'control'} layout + */ +export function renderModeRoutes(layout) { + const routes = MODE_ROUTE_GROUPS[layout] || [] + return routes.map((route) => { + const Component = MODE_ROUTE_COMPONENTS[route.componentKey] + const element = route.gated ? ( + + + + ) : ( + + ) + return + }) +} diff --git a/frontend/src/registry/resolveOperatingProfile.js b/frontend/src/registry/resolveOperatingProfile.js index 6269eb8..d174529 100644 --- a/frontend/src/registry/resolveOperatingProfile.js +++ b/frontend/src/registry/resolveOperatingProfile.js @@ -1,5 +1,5 @@ /** - * AP2.3b — Operating Profile Resolver (API-first, methodUiDefaults als Fallback). + * AP2.3b/f — Operating Profile Resolver (API-first, methodUiDefaults nur als Fallback). */ import { PLAN_OUTLINE_NODES } from '../plan/planOutlineNodes.js' import { WORK_NAV_ITEMS } from '../config/workNav.js' @@ -7,6 +7,10 @@ import { resolveMethodUiDefaults, resolveActiveProcessStepKey as resolveActiveProcessStepKeyFallback, } from '../config/methodUiDefaults.js' +import { + isRouteAllowedForMode, + resolveRedirectForModeRoute, +} from './modeRouteRegistry.js' /** * @typedef {import('../config/methodUiDefaults.js').ProcessStep} ProcessStep @@ -16,7 +20,7 @@ import { * archetype_key?: string | null, * method_key?: string | null, * method_profile_key?: string | null, - * ui_profile?: Partial | null, + * ui_profile?: Partial, workDefaultRouteWithoutActiveWorkCycle?: string }> | null, * data_slices?: string[], * method_capabilities?: object, * }} OperatingContextResponse @@ -35,32 +39,49 @@ const EMPTY_PROFILE = { controlDefaultRoute: '/control/status', planOutlineKeys: null, workNavKeys: null, + uiFeatures: {}, +} + +/** Methoden-Features ergänzen Archetyp-Profil (bis MethodDefinition ui_features trägt). */ +const METHOD_UI_FEATURES = { + sequential_dependency: { criticalPathControl: true }, + continuous_product: { + continuousProductWorkMode: true, + steeringSnapshotOnWorkSprint: true, + }, +} + +function mergeUiFeatures(profileFeatures, methodKey) { + const methodFeatures = METHOD_UI_FEATURES[methodKey] || {} + return { ...methodFeatures, ...(profileFeatures || {}) } +} + +function resolveWorkDefaultRoute(profile, options = {}) { + const base = profile.workDefaultRoute || EMPTY_PROFILE.workDefaultRoute + if (!options.hasActiveSprint && profile.workDefaultRouteWithoutActiveWorkCycle) { + return profile.workDefaultRouteWithoutActiveWorkCycle + } + return base } /** * @param {OperatingContextResponse | null | undefined} context * @param {{ hasActiveSprint?: boolean }} [options] - * @returns {MethodUiDefaults & { dataSlices: string[] }} + * @returns {MethodUiDefaults & { dataSlices: string[], uiFeatures: Record }} */ export function resolveOperatingProfile(context, options = {}) { if (context?.ui_profile) { const profile = context.ui_profile - let workDefaultRoute = profile.workDefaultRoute || EMPTY_PROFILE.workDefaultRoute - if ( - context.archetype_key === 'initiative.product' && - !options.hasActiveSprint - ) { - workDefaultRoute = '/work/today' - } return { processSteps: profile.processSteps || [], planDefaultRoute: profile.planDefaultRoute || EMPTY_PROFILE.planDefaultRoute, - workDefaultRoute, + workDefaultRoute: resolveWorkDefaultRoute(profile, options), controlDefaultRoute: profile.controlDefaultRoute || EMPTY_PROFILE.controlDefaultRoute, planOutlineKeys: profile.planOutlineKeys ?? null, workNavKeys: profile.workNavKeys ?? null, dataSlices: context.data_slices || profile.dataSlices || [], + uiFeatures: mergeUiFeatures(profile.uiFeatures, context?.method_key), } } @@ -72,12 +93,13 @@ export function resolveOperatingProfile(context, options = {}) { return { ...fallback, dataSlices: context?.data_slices || [], + uiFeatures: mergeUiFeatures({}, context?.method_key), } } /** * @param {ResolveOperatingProfileInput} input - * @returns {MethodUiDefaults & { dataSlices: string[] }} + * @returns {MethodUiDefaults & { dataSlices: string[], uiFeatures: Record }} */ export function resolveOperatingProfileFromInput(input = {}) { if (input.operatingContext) { @@ -90,7 +112,7 @@ export function resolveOperatingProfileFromInput(input = {}) { methodKey: input.methodKey || null, hasActiveSprint: input.hasActiveSprint, }) - return { ...fallback, dataSlices: [] } + return { ...fallback, dataSlices: [], uiFeatures: {} } } /** @@ -132,23 +154,10 @@ export function resolveActiveProcessStepKey(pathname, steps) { */ export function isRouteAllowedForProfile(routePath, input = {}) { const profile = resolveOperatingProfileFromInput(input) - const normalized = (routePath || '').replace(/\/$/, '') - - if (normalized.startsWith('/plan/sprint') || normalized.startsWith('/work/sprint')) { - return profile.dataSlices.includes('work_cycles') - } - if (normalized.startsWith('/plan/structure')) { - return profile.dataSlices.includes('projects') - } - if (normalized.startsWith('/plan/inbox')) { - return profile.dataSlices.includes('backlog') - } - if (normalized.startsWith('/control/journey')) { - const steps = profile.processSteps || [] - return steps.some((s) => s.key === 'journey') - } - - return true + return isRouteAllowedForMode(routePath, { + operatingContext: input.operatingContext, + dataSlices: profile.dataSlices, + }) } /** @@ -157,14 +166,12 @@ export function isRouteAllowedForProfile(routePath, input = {}) { * @returns {string} */ export function resolveRedirectForDisallowedRoute(routePath, input = {}) { + if (input.operatingContext) { + return resolveRedirectForModeRoute(routePath, input) + } const profile = resolveOperatingProfileFromInput(input) const normalized = (routePath || '').replace(/\/$/, '') - - if (normalized.startsWith('/work/')) { - return profile.workDefaultRoute - } - if (normalized.startsWith('/control/')) { - return profile.controlDefaultRoute - } + if (normalized.startsWith('/work/')) return profile.workDefaultRoute + if (normalized.startsWith('/control/')) return profile.controlDefaultRoute return profile.planDefaultRoute } diff --git a/frontend/src/registry/resolveOperatingProfile.test.js b/frontend/src/registry/resolveOperatingProfile.test.js index 902ff44..6640c78 100644 --- a/frontend/src/registry/resolveOperatingProfile.test.js +++ b/frontend/src/registry/resolveOperatingProfile.test.js @@ -17,6 +17,7 @@ const PRODUCT_CONTEXT = { ], planDefaultRoute: '/plan/inbox', workDefaultRoute: '/work/sprint', + workDefaultRouteWithoutActiveWorkCycle: '/work/today', controlDefaultRoute: '/control/status', planOutlineKeys: ['profile', 'inbox', 'sprint', 'gates'], workNavKeys: ['sprint', 'today', 'mine'], @@ -73,4 +74,25 @@ describe('resolveOperatingProfile', () => { const input = { operatingContext: PRODUCT_CONTEXT } expect(isRouteAllowedForProfile('/plan/sprint', input)).toBe(true) }) + + it('liefert uiFeatures aus Archetyp-Profil', () => { + const profile = resolveOperatingProfile({ + ...PRODUCT_CONTEXT, + ui_profile: { + ...PRODUCT_CONTEXT.ui_profile, + uiFeatures: { continuousProductWorkMode: true }, + }, + }) + expect(profile.uiFeatures.continuousProductWorkMode).toBe(true) + }) + + it('sequential_dependency ergänzt criticalPathControl', () => { + const profile = resolveOperatingProfile({ + archetype_key: 'initiative.generic', + method_key: 'sequential_dependency', + ui_profile: { uiFeatures: {} }, + data_slices: ['actions'], + }) + expect(profile.uiFeatures.criticalPathControl).toBe(true) + }) }) diff --git a/frontend/src/registry/viewRegistry.js b/frontend/src/registry/viewRegistry.js index a298333..8d45d3f 100644 --- a/frontend/src/registry/viewRegistry.js +++ b/frontend/src/registry/viewRegistry.js @@ -144,36 +144,54 @@ export const MODE_ROUTE_COMPONENTS = { controlJourney: ControlJourneyPage, } -/** Modus-Routen mit Slice-Gating (AP2.3d). */ +/** Modus-Routen mit Slice-Gating (AP2.3d/f). */ +/** @typedef {{ path: string, componentKey: keyof typeof MODE_ROUTE_COMPONENTS, requiredSlices?: string[], allowedArchetypes?: string[], routePath?: string, gated?: boolean }} ModeRouteDefinition */ + +export const MODE_ROUTE_GROUPS = { + work: [ + { path: 'today', componentKey: 'workToday', requiredSlices: ['actions'], gated: true }, + { path: 'mine', componentKey: 'workMine', requiredSlices: ['actions'], gated: true }, + { + path: 'sprint', + componentKey: 'workSprint', + requiredSlices: ['work_cycles'], + routePath: '/work/sprint', + gated: true, + }, + ], + plan: [ + { path: 'portfolio', componentKey: 'planPortfolio', gated: false }, + { path: 'profile', componentKey: 'planProfile', gated: false }, + { path: 'structure', componentKey: 'planStructure', requiredSlices: ['projects'], gated: true }, + { path: 'gates', componentKey: 'planGates', requiredSlices: ['roadmap'], gated: true }, + { path: 'inbox', componentKey: 'planInbox', requiredSlices: ['backlog'], gated: true }, + { + path: 'sprint', + componentKey: 'planSprint', + requiredSlices: ['work_cycles'], + routePath: '/plan/sprint', + gated: true, + }, + { path: 'work', componentKey: 'planWork', requiredSlices: ['actions'], gated: false }, + ], + control: [ + { path: 'status', componentKey: 'controlStatus', gated: false }, + { path: 'plan-ist', componentKey: 'controlPlanIst', requiredSlices: ['roadmap'], gated: true }, + { + path: 'journey', + componentKey: 'controlJourney', + requiredSlices: ['roadmap'], + routePath: '/control/journey', + gated: true, + }, + ], +} + +/** @deprecated Use MODE_ROUTE_GROUPS */ export const MODE_ROUTES = [ - { path: 'today', componentKey: 'workToday', requiredSlices: ['actions'] }, - { path: 'mine', componentKey: 'workMine', requiredSlices: ['actions'] }, - { - path: 'sprint', - componentKey: 'workSprint', - requiredSlices: ['work_cycles'], - routePath: '/work/sprint', - }, - { path: 'portfolio', componentKey: 'planPortfolio', requiredSlices: [] }, - { path: 'profile', componentKey: 'planProfile', requiredSlices: [] }, - { path: 'structure', componentKey: 'planStructure', requiredSlices: ['projects'] }, - { path: 'gates', componentKey: 'planGates', requiredSlices: ['roadmap'] }, - { path: 'inbox', componentKey: 'planInbox', requiredSlices: ['backlog'] }, - { - path: 'sprint', - componentKey: 'planSprint', - requiredSlices: ['work_cycles'], - routePath: '/plan/sprint', - }, - { path: 'work', componentKey: 'planWork', requiredSlices: ['actions'] }, - { path: 'status', componentKey: 'controlStatus', requiredSlices: [] }, - { path: 'plan-ist', componentKey: 'controlPlanIst', requiredSlices: ['roadmap'] }, - { - path: 'journey', - componentKey: 'controlJourney', - requiredSlices: ['roadmap'], - routePath: '/control/journey', - }, + ...MODE_ROUTE_GROUPS.work, + ...MODE_ROUTE_GROUPS.plan, + ...MODE_ROUTE_GROUPS.control, ] export {