AP2.3f: Restschuld ? uiFeatures, Route-Registry und slice-bewusste Mutationen.
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 3m9s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped

Archetyp-Profile und Seitenlogik auf Operating Context migriert; methodUiDefaults nur noch Fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-25 10:52:12 +02:00
parent 44fda7beb9
commit c79bfa844f
16 changed files with 432 additions and 300 deletions

View File

@ -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 from __future__ import annotations
@ -37,6 +37,17 @@ _SUPPORT_QUEUE_PROCESS = (
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"}, {"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 = ( _COMMON_OM_SLICES = (
"actions", "actions",
"blockers", "blockers",
@ -53,15 +64,25 @@ GENERIC_UI_PROFILE: dict[str, Any] = {
"controlDefaultRoute": "/control/status", "controlDefaultRoute": "/control/status",
"planOutlineKeys": None, "planOutlineKeys": None,
"workNavKeys": 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"], "enabledModes": ["plan", "work", "control"],
"uiFeatures": {},
} }
INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = {
"initiative.generic": dict(GENERIC_UI_PROFILE),
"initiative.product": { "initiative.product": {
"processSteps": list(_PRODUCT_PROCESS), "processSteps": list(_PRODUCT_PROCESS),
"planDefaultRoute": "/plan/inbox", "planDefaultRoute": "/plan/inbox",
"workDefaultRoute": "/work/sprint", "workDefaultRoute": "/work/sprint",
"workDefaultRouteWithoutActiveWorkCycle": "/work/today",
"controlDefaultRoute": "/control/status", "controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "inbox", "sprint", "gates"], "planOutlineKeys": ["profile", "inbox", "sprint", "gates"],
"workNavKeys": ["sprint", "today", "mine"], "workNavKeys": ["sprint", "today", "mine"],
@ -78,6 +99,10 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = {
"steering_methods", "steering_methods",
], ],
"enabledModes": ["plan", "work", "control"], "enabledModes": ["plan", "work", "control"],
"uiFeatures": {
"continuousProductWorkMode": True,
"steeringSnapshotOnWorkSprint": True,
},
}, },
"initiative.linear_project": { "initiative.linear_project": {
"processSteps": list(_LINEAR_PROCESS), "processSteps": list(_LINEAR_PROCESS),
@ -98,6 +123,9 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = {
"steering_methods", "steering_methods",
], ],
"enabledModes": ["plan", "work", "control"], "enabledModes": ["plan", "work", "control"],
"uiFeatures": {
"criticalPathControl": True,
},
}, },
"initiative.maturity_journey": { "initiative.maturity_journey": {
"processSteps": list(_MATURITY_PROCESS), "processSteps": list(_MATURITY_PROCESS),
@ -117,6 +145,7 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = {
"steering_methods", "steering_methods",
], ],
"enabledModes": ["plan", "work", "control"], "enabledModes": ["plan", "work", "control"],
"uiFeatures": {},
}, },
"initiative.program": { "initiative.program": {
"processSteps": list(_PROGRAM_PROCESS), "processSteps": list(_PROGRAM_PROCESS),
@ -138,6 +167,7 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = {
"steering_methods", "steering_methods",
], ],
"enabledModes": ["plan", "work", "control"], "enabledModes": ["plan", "work", "control"],
"uiFeatures": {},
}, },
"initiative.support_queue": { "initiative.support_queue": {
"processSteps": list(_SUPPORT_QUEUE_PROCESS), "processSteps": list(_SUPPORT_QUEUE_PROCESS),
@ -153,6 +183,7 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = {
"steering_methods", "steering_methods",
], ],
"enabledModes": ["plan", "work", "control"], "enabledModes": ["plan", "work", "control"],
"uiFeatures": {},
}, },
"initiative.recurring_program": { "initiative.recurring_program": {
"processSteps": list(_MATURITY_PROCESS), "processSteps": list(_MATURITY_PROCESS),
@ -169,6 +200,44 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = {
"steering_methods", "steering_methods",
], ],
"enabledModes": ["plan", "work", "control"], "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": {},
}, },
} }

View File

@ -58,6 +58,16 @@ def register() -> None:
label="Verfahren / Konflikt", label="Verfahren / Konflikt",
description="Reaktive Steuerung, Fristen, Entscheidungen", description="Reaktive Steuerung, Fristen, Entscheidungen",
next_action_strategy_key="default", next_action_strategy_key="default",
data_slices=frozenset(
{
"actions",
"blockers",
"evidence",
"decisions",
"reviews",
"steering_methods",
}
),
compatible_archetype_keys=frozenset({"initiative.dispute_case"}), compatible_archetype_keys=frozenset({"initiative.dispute_case"}),
) )
register_stub_method( register_stub_method(
@ -65,5 +75,16 @@ def register() -> None:
label="Kapitel-Entwicklung", label="Kapitel-Entwicklung",
description="Inhaltliche Progression, Reviews", description="Inhaltliche Progression, Reviews",
next_action_strategy_key="default", next_action_strategy_key="default",
data_slices=frozenset(
{
"actions",
"roadmap",
"blockers",
"evidence",
"decisions",
"reviews",
"steering_methods",
}
),
compatible_archetype_keys=frozenset({"initiative.content_project"}), compatible_archetype_keys=frozenset({"initiative.content_project"}),
) )

View File

@ -84,6 +84,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Situativer Steuerungskontext (Next Action) | ✗ | 📄 Vision §7.6; AP1.8b deferred | | Situativer Steuerungskontext (Next Action) | ✗ | 📄 Vision §7.6; AP1.8b deferred |
| AttentionItem | ◐ | | | AttentionItem | ◐ | |
| Initiative Steering Snapshot | ✓ | Actions + linked; Archetyp/Guidance AP2.0c | | 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 AE ✓; AP2.3f Restschuld (Resolver, Route-Gating, uiFeatures, slice reload) ◐ |
| operating_phase | ✗ | entfernt AP1.2 | | operating_phase | ✗ | entfernt AP1.2 |
| signals (Snapshot) | ✓ | `snapshot_signals.py` | | signals (Snapshot) | ✓ | `snapshot_signals.py` |
| Graph Read Models (blocked/ready) | ◐ | AP1.4d/e; Join/OR AP1.15d deferred | | 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.12ad: Baum, Modal, Reorder, Actions; AP-Kanten AP1.16c offen | | Plan-Outline | ◐ | AP1.12ad: Baum, Modal, Reorder, Actions; AP-Kanten AP1.16c offen |
| Execution-Plan (AP-Graph) | ◐ | AP1.16c: Outline + Arbeit-Liste + Action-Detail | | Execution-Plan (AP-Graph) | ◐ | AP1.16c: Outline + Arbeit-Liste + Action-Detail |
| Profil-Modal (Archetyp/EFS) | ◐ | AP1.10c | | 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 | | Modal-Bearbeitung (Gates/Profil) | ◐ | viele Sektionen noch Inline-CRUD |
| Admin-UI | ✗ | | | Admin-UI | ✗ | |
| Team-Modus | ✗ | AP1.9f | | Team-Modus | ✗ | AP1.9f |

View File

@ -5,6 +5,7 @@
**Stand:** 2026-07-12 **Stand:** 2026-07-12
**Ersetzt als Priorisierung:** Dogfooding-Seed als Implementierungs-Hebel; Status-Review §5 (Dogfooding-first) **Ersetzt als Priorisierung:** Dogfooding-Seed als Implementierungs-Hebel; Status-Review §5 (Dogfooding-first)
**Bezug:** `Kairo_MVP_Definition_v0.3.md` §59, `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` §59, `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
--- ---

View File

@ -15,9 +15,8 @@ import {
PlanIndexRedirect, PlanIndexRedirect,
ControlLayout, ControlLayout,
ControlIndexRedirect, ControlIndexRedirect,
MODE_ROUTE_COMPONENTS,
} from './registry/viewRegistry.js' } from './registry/viewRegistry.js'
import { OperatingRouteGate } from './registry/OperatingRouteGate.jsx' import { renderModeRoutes } from './registry/renderModeRoutes.jsx'
import { import {
WorkspaceRedirect, WorkspaceRedirect,
MyActionsRedirect, MyActionsRedirect,
@ -65,90 +64,17 @@ function AppRoutes() {
<Route path="/work" element={<WorkLayout />}> <Route path="/work" element={<WorkLayout />}>
<Route index element={<WorkIndexRedirect />} /> <Route index element={<WorkIndexRedirect />} />
<Route {renderModeRoutes('work')}
path="today"
element={
<OperatingRouteGate>
<MODE_ROUTE_COMPONENTS.workToday />
</OperatingRouteGate>
}
/>
<Route
path="sprint"
element={
<OperatingRouteGate>
<MODE_ROUTE_COMPONENTS.workSprint />
</OperatingRouteGate>
}
/>
<Route
path="mine"
element={
<OperatingRouteGate>
<MODE_ROUTE_COMPONENTS.workMine />
</OperatingRouteGate>
}
/>
</Route> </Route>
<Route path="/plan" element={<PlanLayout />}> <Route path="/plan" element={<PlanLayout />}>
<Route index element={<PlanIndexRedirect />} /> <Route index element={<PlanIndexRedirect />} />
<Route path="portfolio" element={<MODE_ROUTE_COMPONENTS.planPortfolio />} /> {renderModeRoutes('plan')}
<Route path="profile" element={<MODE_ROUTE_COMPONENTS.planProfile />} />
<Route
path="structure"
element={
<OperatingRouteGate>
<MODE_ROUTE_COMPONENTS.planStructure />
</OperatingRouteGate>
}
/>
<Route
path="gates"
element={
<OperatingRouteGate>
<MODE_ROUTE_COMPONENTS.planGates />
</OperatingRouteGate>
}
/>
<Route
path="inbox"
element={
<OperatingRouteGate>
<MODE_ROUTE_COMPONENTS.planInbox />
</OperatingRouteGate>
}
/>
<Route
path="sprint"
element={
<OperatingRouteGate>
<MODE_ROUTE_COMPONENTS.planSprint />
</OperatingRouteGate>
}
/>
<Route path="work" element={<MODE_ROUTE_COMPONENTS.planWork />} />
</Route> </Route>
<Route path="/control" element={<ControlLayout />}> <Route path="/control" element={<ControlLayout />}>
<Route index element={<ControlIndexRedirect />} /> <Route index element={<ControlIndexRedirect />} />
<Route path="status" element={<MODE_ROUTE_COMPONENTS.controlStatus />} /> {renderModeRoutes('control')}
<Route
path="plan-ist"
element={
<OperatingRouteGate>
<MODE_ROUTE_COMPONENTS.controlPlanIst />
</OperatingRouteGate>
}
/>
<Route
path="journey"
element={
<OperatingRouteGate>
<MODE_ROUTE_COMPONENTS.controlJourney />
</OperatingRouteGate>
}
/>
</Route> </Route>
<Route path="/projects/:projectId" element={<ProjectObjectPage />} /> <Route path="/projects/:projectId" element={<ProjectObjectPage />} />

View File

@ -91,6 +91,7 @@ import {
applySliceResults, applySliceResults,
loadOperatingSlices, loadOperatingSlices,
} from '../registry/operatingSliceLoaders.js' } from '../registry/operatingSliceLoaders.js'
import { resolveOperatingProfileFromInput } from '../registry/resolveOperatingProfile.js'
const InitiativeOperationsContext = createContext(null) const InitiativeOperationsContext = createContext(null)
@ -158,6 +159,22 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
[id, sliceSetters], [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 } = {}) => { const load = useCallback(async ({ silent = false, sliceKeys = null } = {}) => {
if (sessionLoading || !id) return if (sessionLoading || !id) return
if (!silent) setLoading(true) if (!silent) setLoading(true)
@ -319,7 +336,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setMethodBusy(true) setMethodBusy(true)
try { try {
await updateInitiativeSteeringMethod(id, methodKey) await updateInitiativeSteeringMethod(id, methodKey)
setSteeringSnapshot(await getInitiativeSteeringSnapshot(id)) await refreshOperatingContext()
await refreshOm(['steering_snapshot', 'steering_methods'])
} catch (err) { } catch (err) {
setSteeringSnapshotError(err.message) setSteeringSnapshotError(err.message)
} finally { } finally {
@ -331,7 +349,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setMethodBusy(true) setMethodBusy(true)
try { try {
await updateInitiativeMethodProfile(id, methodProfileKey || null) await updateInitiativeMethodProfile(id, methodProfileKey || null)
setSteeringSnapshot(await getInitiativeSteeringSnapshot(id)) await refreshOperatingContext()
await refreshOm(['steering_snapshot', 'steering_methods'])
} catch (err) { } catch (err) {
setSteeringSnapshotError(err.message) setSteeringSnapshotError(err.message)
} finally { } finally {
@ -354,7 +373,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
assigned_actor_ids: assigned, assigned_actor_ids: assigned,
}) })
setShowActionForm(false) setShowActionForm(false)
await load() await refreshOm(['actions'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -383,7 +402,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
await setActionAssignments(actionId, payload.assigned_actor_ids || []) await setActionAssignments(actionId, payload.assigned_actor_ids || [])
} }
setEditingActionId(null) setEditingActionId(null)
await load() await refreshOm(['actions'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -410,7 +429,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
if (!capabilities.has('kairo.action.manage')) return if (!capabilities.has('kairo.action.manage')) return
try { try {
await updateAction(action.id, { status }) await updateAction(action.id, { status })
await load() await refreshOm(['actions'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -420,7 +439,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true) setFormBusy(true)
try { try {
await createInitiativeBlocker(id, body) await createInitiativeBlocker(id, body)
await load() await refreshOm(['blockers', 'actions'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -437,7 +456,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
action_id: actionId, action_id: actionId,
set_action_blocked: true, set_action_blocked: true,
}) })
await load() await refreshOm(['blockers', 'actions'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -448,7 +467,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleBlockerStatus(blockerId, status) { async function handleBlockerStatus(blockerId, status) {
try { try {
await updateBlocker(blockerId, { status }) await updateBlocker(blockerId, { status })
await load() await refreshOm(['blockers', 'actions'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -457,7 +476,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleDeleteBlocker(blockerId) { async function handleDeleteBlocker(blockerId) {
try { try {
await deleteBlocker(blockerId) await deleteBlocker(blockerId)
await load() await refreshOm(['blockers', 'actions'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -469,7 +488,11 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
try { try {
await updateInitiative(id, core) await updateInitiative(id, core)
await saveInitiativeDynamicFields(id, dynamicFields) await saveInitiativeDynamicFields(id, dynamicFields)
await load() await refreshOm(['initiative'])
if (core.archetype_key) {
await refreshOperatingContext()
await load({ silent: true })
}
return true return true
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -483,7 +506,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true) setFormBusy(true)
try { try {
await createInitiativeBacklogItem(id, body) await createInitiativeBacklogItem(id, body)
await load() await refreshOm(['backlog'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -495,7 +518,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true) setFormBusy(true)
try { try {
await updateBacklogItem(itemId, body) await updateBacklogItem(itemId, body)
await load() await refreshOm(['backlog'])
return true return true
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -511,7 +534,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
roadmap_item_id: roadmapItemId || undefined, roadmap_item_id: roadmapItemId || undefined,
clear_roadmap_item: !roadmapItemId, clear_roadmap_item: !roadmapItemId,
}) })
await load() await refreshOm(['backlog'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -520,7 +543,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleBacklogStatus(itemId, status) { async function handleBacklogStatus(itemId, status) {
try { try {
await updateBacklogItem(itemId, { status }) await updateBacklogItem(itemId, { status })
await load() await refreshOm(['backlog'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -587,7 +610,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true) setFormBusy(true)
try { try {
const created = await createWorkCycle(id, body) const created = await createWorkCycle(id, body)
await load({ silent: true }) await refreshOm(['work_cycles', 'actions'])
if (created?.id) setSelectedWorkCycleId(created.id) if (created?.id) setSelectedWorkCycleId(created.id)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -600,7 +623,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true) setFormBusy(true)
try { try {
await activateWorkCycle(id, workCycleId) await activateWorkCycle(id, workCycleId)
await load({ silent: true }) await refreshOm(['work_cycles', 'actions'])
setSelectedWorkCycleId(workCycleId) setSelectedWorkCycleId(workCycleId)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -613,7 +636,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true) setFormBusy(true)
try { try {
await completeWorkCycle(id, workCycleId) await completeWorkCycle(id, workCycleId)
await load({ silent: true }) await refreshOm(['work_cycles', 'actions'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -635,7 +658,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
} }
} }
await completeWorkCycle(id, workCycleId) await completeWorkCycle(id, workCycleId)
await load({ silent: true }) await refreshOm(['work_cycles', 'actions'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
throw err throw err
@ -648,7 +671,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true) setFormBusy(true)
try { try {
await reactivateWorkCycle(id, workCycleId) await reactivateWorkCycle(id, workCycleId)
await load({ silent: true }) await refreshOm(['work_cycles', 'actions'])
setSelectedWorkCycleId(workCycleId) setSelectedWorkCycleId(workCycleId)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -660,7 +683,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleDeleteBacklog(itemId) { async function handleDeleteBacklog(itemId) {
try { try {
await deleteBacklogItem(itemId) await deleteBacklogItem(itemId)
await load() await refreshOm(['backlog'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -670,7 +693,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true) setFormBusy(true)
try { try {
const created = await createInitiativeRoadmapItem(id, body) const created = await createInitiativeRoadmapItem(id, body)
await load() await refreshOm(['roadmap'])
return created return created
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -683,7 +706,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleRoadmapItemStatus(itemId, status) { async function handleRoadmapItemStatus(itemId, status) {
try { try {
await updateRoadmapItem(itemId, { status }) await updateRoadmapItem(itemId, { status })
await load() await refreshOm(['roadmap'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -692,7 +715,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleUpdateRoadmapItem(itemId, body) { async function handleUpdateRoadmapItem(itemId, body) {
try { try {
await updateRoadmapItem(itemId, body) await updateRoadmapItem(itemId, body)
await load() await refreshOm(['roadmap'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -708,7 +731,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
status: 'accepted', status: 'accepted',
}) })
await verifyRoadmapItemReached(itemId) await verifyRoadmapItemReached(itemId)
await load() await refreshOm(['roadmap', 'steering_snapshot'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -720,7 +743,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true) setFormBusy(true)
try { try {
await verifyRoadmapItemReached(itemId) await verifyRoadmapItemReached(itemId)
await load() await refreshOm(['roadmap', 'steering_snapshot'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -731,7 +754,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleDeleteRoadmapItem(itemId) { async function handleDeleteRoadmapItem(itemId) {
try { try {
await deleteRoadmapItem(itemId) await deleteRoadmapItem(itemId)
await load() await refreshOm(['roadmap'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -812,7 +835,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
await Promise.all( await Promise.all(
patches.map((patch) => updateBacklogItem(patch.id, { sort_order: patch.sort_order })), patches.map((patch) => updateBacklogItem(patch.id, { sort_order: patch.sort_order })),
) )
await load() await refreshOm(['backlog'])
return true return true
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -830,7 +853,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
await Promise.all( await Promise.all(
patches.map((patch) => updateRoadmapItem(patch.id, { sort_order: patch.sort_order })), patches.map((patch) => updateRoadmapItem(patch.id, { sort_order: patch.sort_order })),
) )
await load() await refreshOm(['roadmap'])
return true return true
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -849,7 +872,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true) setFormBusy(true)
try { try {
await createInitiativeEvidence(id, body) await createInitiativeEvidence(id, body)
await load() await refreshOm(['evidence'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -860,7 +883,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleEvidenceStatus(evidenceId, status) { async function handleEvidenceStatus(evidenceId, status) {
try { try {
await updateEvidence(evidenceId, { status }) await updateEvidence(evidenceId, { status })
await load() await refreshOm(['evidence'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -869,7 +892,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleDeleteEvidence(evidenceId) { async function handleDeleteEvidence(evidenceId) {
try { try {
await deleteEvidence(evidenceId) await deleteEvidence(evidenceId)
await load() await refreshOm(['evidence'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -879,7 +902,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true) setFormBusy(true)
try { try {
await createInitiativeDecision(id, body) await createInitiativeDecision(id, body)
await load() await refreshOm(['decisions'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -890,7 +913,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleDecisionStatus(decisionId, status) { async function handleDecisionStatus(decisionId, status) {
try { try {
await updateDecision(decisionId, { status }) await updateDecision(decisionId, { status })
await load() await refreshOm(['decisions'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -899,7 +922,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleDeleteDecision(decisionId) { async function handleDeleteDecision(decisionId) {
try { try {
await deleteDecision(decisionId) await deleteDecision(decisionId)
await load() await refreshOm(['decisions'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -909,7 +932,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true) setFormBusy(true)
try { try {
await createInitiativeReview(id, body) await createInitiativeReview(id, body)
await load() await refreshOm(['reviews'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -920,7 +943,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleReviewStatus(reviewId, status) { async function handleReviewStatus(reviewId, status) {
try { try {
await updateReview(reviewId, { status }) await updateReview(reviewId, { status })
await load() await refreshOm(['reviews'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -929,7 +952,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleDeleteReview(reviewId) { async function handleDeleteReview(reviewId) {
try { try {
await deleteReview(reviewId) await deleteReview(reviewId)
await load() await refreshOm(['reviews'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -939,7 +962,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true) setFormBusy(true)
try { try {
await createInitiativeRecurring(id, body) await createInitiativeRecurring(id, body)
await load() await refreshOm(['recurring'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -950,7 +973,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleRecurringStatus(recurringId, status) { async function handleRecurringStatus(recurringId, status) {
try { try {
await updateRecurring(recurringId, { status }) await updateRecurring(recurringId, { status })
await load() await refreshOm(['recurring'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@ -959,12 +982,23 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleDeleteRecurring(recurringId) { async function handleDeleteRecurring(recurringId) {
try { try {
await deleteRecurring(recurringId) await deleteRecurring(recurringId)
await load() await refreshOm(['recurring'])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
} }
const operatingProfile = useMemo(
() =>
resolveOperatingProfileFromInput({
operatingContext,
hasActiveSprint: Boolean(activeWorkCycle?.id),
}),
[operatingContext, activeWorkCycle?.id],
)
const uiFeatures = operatingProfile.uiFeatures || {}
const value = { const value = {
initiativeId: id, initiativeId: id,
initiative, initiative,
@ -996,6 +1030,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
steeringMethods, steeringMethods,
operatingContext, operatingContext,
dataSlices: operatingContext?.data_slices || [], dataSlices: operatingContext?.data_slices || [],
uiFeatures,
operatingProfile,
methodBusy, methodBusy,
loading, loading,
error, error,
@ -1015,6 +1051,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
reloadActors: actorsState.reload, reloadActors: actorsState.reload,
reload: load, reload: load,
reloadSlices, reloadSlices,
refreshOm,
refreshOperatingContext,
handleMethodChange, handleMethodChange,
handleMethodProfileChange, handleMethodProfileChange,
handleCreateAction, handleCreateAction,

View File

@ -5,14 +5,6 @@ import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.js
import { CriticalPathPanel } from '../../components/CriticalPathPanel.jsx' import { CriticalPathPanel } from '../../components/CriticalPathPanel.jsx'
import { NextActionWidget } from '../../widgets/NextActionWidget.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() { export function InitiativeOverviewPage() {
const { const {
initiativeId, initiativeId,
@ -26,10 +18,11 @@ export function InitiativeOverviewPage() {
methodBusy, methodBusy,
capabilities, capabilities,
handleMethodChange, handleMethodChange,
uiFeatures,
} = useInitiativeOperations() } = useInitiativeOperations()
const counts = steeringSnapshot?.counts || {} const counts = steeringSnapshot?.counts || {}
const showCriticalPath = usesCriticalPathControl(initiative, steeringSnapshot) const showCriticalPath = Boolean(uiFeatures.criticalPathControl)
return ( return (
<> <>

View File

@ -6,6 +6,8 @@ import {
CONTROL_NAV_ITEMS, CONTROL_NAV_ITEMS,
resolveControlNavActiveKey, resolveControlNavActiveKey,
} from '../../config/controlNav.js' } from '../../config/controlNav.js'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
import { useMethodUiContext } from '../../hooks/useMethodUiContext.js'
export function ControlLayout() { export function ControlLayout() {
return ( return (
@ -29,5 +31,13 @@ export function ControlLayout() {
} }
export function ControlIndexRedirect() { export function ControlIndexRedirect() {
return <Navigate to="/control/status" replace /> const { initiativeId, hrefWithScope } = useProgramScope()
const { controlDefaultRoute, loading, operatingContext } = useMethodUiContext()
if (initiativeId && loading && !operatingContext) {
return null
}
const target = initiativeId ? hrefWithScope(controlDefaultRoute) : '/control/status'
return <Navigate to={target} replace />
} }

View File

@ -1,48 +1,19 @@
import { useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom' import { Navigate } from 'react-router-dom'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx' import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
import { getInitiativeOperatingContext } from '../../api/initiatives.js' import { useMethodUiContext } from '../../hooks/useMethodUiContext.js'
import { getActiveWorkCycle } from '../../api/workCycles.js'
import { resolveOperatingProfileFromInput } from '../../registry/resolveOperatingProfile.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() { export function PlanIndexRedirect() {
const { initiativeId, hrefWithScope } = useProgramScope() const { initiativeId, hrefWithScope } = useProgramScope()
const [target, setTarget] = useState(null) const { planDefaultRoute, loading, operatingContext } = useMethodUiContext()
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])
if (!initiativeId) { if (!initiativeId) {
return <Navigate to="/plan/portfolio" replace /> return <Navigate to="/plan/portfolio" replace />
} }
if (!target) { if (loading && !operatingContext) {
return null return null
} }
return <Navigate to={target} replace /> return <Navigate to={hrefWithScope(planDefaultRoute)} replace />
} }

View File

@ -1,45 +1,22 @@
import { Navigate, Outlet } from 'react-router-dom' import { Navigate, Outlet } from 'react-router-dom'
import { useEffect, useState } from 'react' import { useMemo } from 'react'
import { ModeAreaShell } from '../../components/ModeAreaShell.jsx' import { ModeAreaShell } from '../../components/ModeAreaShell.jsx'
import { ModeAreaNav } from '../../components/ModeAreaNav.jsx' import { ModeAreaNav } from '../../components/ModeAreaNav.jsx'
import { ModeShell } from '../../components/ModeShell.jsx' import { ModeShell } from '../../components/ModeShell.jsx'
import { WORK_NAV_ITEMS, resolveWorkNavActiveKey } from '../../config/workNav.js' import { WORK_NAV_ITEMS, resolveWorkNavActiveKey } from '../../config/workNav.js'
import { resolveWorkNavItems } from '../../config/methodUiDefaults.js'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx' import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
import { getInitiative } from '../../api/initiatives.js' import { useMethodUiContext } from '../../hooks/useMethodUiContext.js'
import { getActiveWorkCycle } from '../../api/workCycles.js' import { resolveWorkNavItemsFromProfile } from '../../registry/resolveOperatingProfile.js'
import { resolveMethodUiDefaults } from '../../config/methodUiDefaults.js'
function WorkAreaNav() { function WorkAreaNav() {
const { initiativeId } = useProgramScope() const { initiativeId } = useProgramScope()
const [navItems, setNavItems] = useState(WORK_NAV_ITEMS) const { operatingContext, hasActiveSprint, loading } = useMethodUiContext()
useEffect(() => { const navItems = useMemo(() => {
if (!initiativeId) { if (!initiativeId) return WORK_NAV_ITEMS
setNavItems(WORK_NAV_ITEMS) if (loading && !operatingContext) return WORK_NAV_ITEMS
return undefined return resolveWorkNavItemsFromProfile({ operatingContext, hasActiveSprint })
} }, [initiativeId, loading, operatingContext, hasActiveSprint])
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])
return ( return (
<ModeAreaNav <ModeAreaNav
@ -63,33 +40,15 @@ export function WorkLayout() {
export function WorkIndexRedirect() { export function WorkIndexRedirect() {
const { initiativeId, hrefWithScope } = useProgramScope() const { initiativeId, hrefWithScope } = useProgramScope()
const [target, setTarget] = useState('/work/today') const { workDefaultRoute, loading, operatingContext } = useMethodUiContext()
useEffect(() => {
if (!initiativeId) { if (!initiativeId) {
setTarget('/work/today') return <Navigate to="/work/today" replace />
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])
return <Navigate to={target} replace /> if (loading && !operatingContext) {
return null
}
return <Navigate to={hrefWithScope(workDefaultRoute)} replace />
} }

View File

@ -1,7 +1,6 @@
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx' import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
import { NextActionWidget } from '../../widgets/NextActionWidget.jsx' import { NextActionWidget } from '../../widgets/NextActionWidget.jsx'
import { LoadingState } from '../../components/LoadingState.jsx' import { LoadingState } from '../../components/LoadingState.jsx'
@ -43,6 +42,7 @@ function WorkSprintInner() {
initiative, initiative,
operatingContext, operatingContext,
dataSlices, dataSlices,
uiFeatures,
steeringSnapshot, steeringSnapshot,
steeringSnapshotLoading, steeringSnapshotLoading,
visibleActions, visibleActions,
@ -50,13 +50,20 @@ function WorkSprintInner() {
} = ops } = ops
const supportsWorkCycles = dataSlices.includes('work_cycles') const supportsWorkCycles = dataSlices.includes('work_cycles')
const isContinuousProduct = operatingContext?.method_key === 'continuous_product' const isContinuousProductMode = uiFeatures.continuousProductWorkMode && supportsWorkCycles
const loadSteeringSnapshot = uiFeatures.steeringSnapshotOnWorkSprint
useEffect(() => { useEffect(() => {
if (initiativeId && !steeringSnapshot && !steeringSnapshotLoading) { if (initiativeId && loadSteeringSnapshot && !steeringSnapshot && !steeringSnapshotLoading) {
reloadSlices(['steering_snapshot']) reloadSlices(['steering_snapshot'])
} }
}, [initiativeId, steeringSnapshot, steeringSnapshotLoading, reloadSlices]) }, [
initiativeId,
loadSteeringSnapshot,
steeringSnapshot,
steeringSnapshotLoading,
reloadSlices,
])
const defaultWorkCycleId = activeWorkCycle?.id || '' const defaultWorkCycleId = activeWorkCycle?.id || ''
@ -74,7 +81,7 @@ function WorkSprintInner() {
return <LoadingState message="Lade Sprint-Backlog …" /> return <LoadingState message="Lade Sprint-Backlog …" />
} }
const isProductArchetype = isContinuousProduct && supportsWorkCycles const isProductArchetype = isContinuousProductMode
return ( return (
<> <>
@ -86,7 +93,7 @@ function WorkSprintInner() {
<h2>Product-Ist (ohne aktiven Sprint)</h2> <h2>Product-Ist (ohne aktiven Sprint)</h2>
<p className="section-lead muted"> <p className="section-lead muted">
Kein aktiver Sprint Kairo steuert nach{' '} Kein aktiver Sprint Kairo steuert nach{' '}
<strong>continuous_product</strong> (wirkungsvollster Schritt). Sprint-Planung <strong>{operatingContext?.method_key || 'continuous_product'}</strong> (wirkungsvollster Schritt). Sprint-Planung
unter{' '} unter{' '}
<Link to={hrefWithScope('/plan/sprint')} className="link-inline"> <Link to={hrefWithScope('/plan/sprint')} className="link-inline">
Plan Sprint Plan Sprint
@ -208,10 +215,8 @@ function WorkSprintInner() {
export function WorkSprintPage() { export function WorkSprintPage() {
return ( return (
<RequireInitiativeScope lead="Sprint-Backlog ist vorhabenspezifisch — wähle ein Product-Vorhaben im Scope."> <RequireInitiativeScope lead="Sprint-Backlog ist vorhabenspezifisch — wähle ein passendes Vorhaben im Scope.">
<ScopedInitiativeProvider>
<WorkSprintInner /> <WorkSprintInner />
</ScopedInitiativeProvider>
</RequireInitiativeScope> </RequireInitiativeScope>
) )
} }

View File

@ -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'
}

View File

@ -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 ? (
<OperatingRouteGate>
<Component />
</OperatingRouteGate>
) : (
<Component />
)
return <Route key={`${layout}-${route.path}`} path={route.path} element={element} />
})
}

View File

@ -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 { PLAN_OUTLINE_NODES } from '../plan/planOutlineNodes.js'
import { WORK_NAV_ITEMS } from '../config/workNav.js' import { WORK_NAV_ITEMS } from '../config/workNav.js'
@ -7,6 +7,10 @@ import {
resolveMethodUiDefaults, resolveMethodUiDefaults,
resolveActiveProcessStepKey as resolveActiveProcessStepKeyFallback, resolveActiveProcessStepKey as resolveActiveProcessStepKeyFallback,
} from '../config/methodUiDefaults.js' } from '../config/methodUiDefaults.js'
import {
isRouteAllowedForMode,
resolveRedirectForModeRoute,
} from './modeRouteRegistry.js'
/** /**
* @typedef {import('../config/methodUiDefaults.js').ProcessStep} ProcessStep * @typedef {import('../config/methodUiDefaults.js').ProcessStep} ProcessStep
@ -16,7 +20,7 @@ import {
* archetype_key?: string | null, * archetype_key?: string | null,
* method_key?: string | null, * method_key?: string | null,
* method_profile_key?: string | null, * method_profile_key?: string | null,
* ui_profile?: Partial<MethodUiDefaults> | null, * ui_profile?: Partial<MethodUiDefaults & { uiFeatures?: Record<string, boolean>, workDefaultRouteWithoutActiveWorkCycle?: string }> | null,
* data_slices?: string[], * data_slices?: string[],
* method_capabilities?: object, * method_capabilities?: object,
* }} OperatingContextResponse * }} OperatingContextResponse
@ -35,32 +39,49 @@ const EMPTY_PROFILE = {
controlDefaultRoute: '/control/status', controlDefaultRoute: '/control/status',
planOutlineKeys: null, planOutlineKeys: null,
workNavKeys: 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 {OperatingContextResponse | null | undefined} context
* @param {{ hasActiveSprint?: boolean }} [options] * @param {{ hasActiveSprint?: boolean }} [options]
* @returns {MethodUiDefaults & { dataSlices: string[] }} * @returns {MethodUiDefaults & { dataSlices: string[], uiFeatures: Record<string, boolean> }}
*/ */
export function resolveOperatingProfile(context, options = {}) { export function resolveOperatingProfile(context, options = {}) {
if (context?.ui_profile) { if (context?.ui_profile) {
const profile = 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 { return {
processSteps: profile.processSteps || [], processSteps: profile.processSteps || [],
planDefaultRoute: profile.planDefaultRoute || EMPTY_PROFILE.planDefaultRoute, planDefaultRoute: profile.planDefaultRoute || EMPTY_PROFILE.planDefaultRoute,
workDefaultRoute, workDefaultRoute: resolveWorkDefaultRoute(profile, options),
controlDefaultRoute: controlDefaultRoute:
profile.controlDefaultRoute || EMPTY_PROFILE.controlDefaultRoute, profile.controlDefaultRoute || EMPTY_PROFILE.controlDefaultRoute,
planOutlineKeys: profile.planOutlineKeys ?? null, planOutlineKeys: profile.planOutlineKeys ?? null,
workNavKeys: profile.workNavKeys ?? null, workNavKeys: profile.workNavKeys ?? null,
dataSlices: context.data_slices || profile.dataSlices || [], dataSlices: context.data_slices || profile.dataSlices || [],
uiFeatures: mergeUiFeatures(profile.uiFeatures, context?.method_key),
} }
} }
@ -72,12 +93,13 @@ export function resolveOperatingProfile(context, options = {}) {
return { return {
...fallback, ...fallback,
dataSlices: context?.data_slices || [], dataSlices: context?.data_slices || [],
uiFeatures: mergeUiFeatures({}, context?.method_key),
} }
} }
/** /**
* @param {ResolveOperatingProfileInput} input * @param {ResolveOperatingProfileInput} input
* @returns {MethodUiDefaults & { dataSlices: string[] }} * @returns {MethodUiDefaults & { dataSlices: string[], uiFeatures: Record<string, boolean> }}
*/ */
export function resolveOperatingProfileFromInput(input = {}) { export function resolveOperatingProfileFromInput(input = {}) {
if (input.operatingContext) { if (input.operatingContext) {
@ -90,7 +112,7 @@ export function resolveOperatingProfileFromInput(input = {}) {
methodKey: input.methodKey || null, methodKey: input.methodKey || null,
hasActiveSprint: input.hasActiveSprint, hasActiveSprint: input.hasActiveSprint,
}) })
return { ...fallback, dataSlices: [] } return { ...fallback, dataSlices: [], uiFeatures: {} }
} }
/** /**
@ -132,23 +154,10 @@ export function resolveActiveProcessStepKey(pathname, steps) {
*/ */
export function isRouteAllowedForProfile(routePath, input = {}) { export function isRouteAllowedForProfile(routePath, input = {}) {
const profile = resolveOperatingProfileFromInput(input) const profile = resolveOperatingProfileFromInput(input)
const normalized = (routePath || '').replace(/\/$/, '') return isRouteAllowedForMode(routePath, {
operatingContext: input.operatingContext,
if (normalized.startsWith('/plan/sprint') || normalized.startsWith('/work/sprint')) { dataSlices: profile.dataSlices,
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
} }
/** /**
@ -157,14 +166,12 @@ export function isRouteAllowedForProfile(routePath, input = {}) {
* @returns {string} * @returns {string}
*/ */
export function resolveRedirectForDisallowedRoute(routePath, input = {}) { export function resolveRedirectForDisallowedRoute(routePath, input = {}) {
if (input.operatingContext) {
return resolveRedirectForModeRoute(routePath, input)
}
const profile = resolveOperatingProfileFromInput(input) const profile = resolveOperatingProfileFromInput(input)
const normalized = (routePath || '').replace(/\/$/, '') const normalized = (routePath || '').replace(/\/$/, '')
if (normalized.startsWith('/work/')) return profile.workDefaultRoute
if (normalized.startsWith('/work/')) { if (normalized.startsWith('/control/')) return profile.controlDefaultRoute
return profile.workDefaultRoute
}
if (normalized.startsWith('/control/')) {
return profile.controlDefaultRoute
}
return profile.planDefaultRoute return profile.planDefaultRoute
} }

View File

@ -17,6 +17,7 @@ const PRODUCT_CONTEXT = {
], ],
planDefaultRoute: '/plan/inbox', planDefaultRoute: '/plan/inbox',
workDefaultRoute: '/work/sprint', workDefaultRoute: '/work/sprint',
workDefaultRouteWithoutActiveWorkCycle: '/work/today',
controlDefaultRoute: '/control/status', controlDefaultRoute: '/control/status',
planOutlineKeys: ['profile', 'inbox', 'sprint', 'gates'], planOutlineKeys: ['profile', 'inbox', 'sprint', 'gates'],
workNavKeys: ['sprint', 'today', 'mine'], workNavKeys: ['sprint', 'today', 'mine'],
@ -73,4 +74,25 @@ describe('resolveOperatingProfile', () => {
const input = { operatingContext: PRODUCT_CONTEXT } const input = { operatingContext: PRODUCT_CONTEXT }
expect(isRouteAllowedForProfile('/plan/sprint', input)).toBe(true) 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)
})
}) })

View File

@ -144,36 +144,54 @@ export const MODE_ROUTE_COMPONENTS = {
controlJourney: ControlJourneyPage, controlJourney: ControlJourneyPage,
} }
/** Modus-Routen mit Slice-Gating (AP2.3d). */ /** Modus-Routen mit Slice-Gating (AP2.3d/f). */
export const MODE_ROUTES = [ /** @typedef {{ path: string, componentKey: keyof typeof MODE_ROUTE_COMPONENTS, requiredSlices?: string[], allowedArchetypes?: string[], routePath?: string, gated?: boolean }} ModeRouteDefinition */
{ path: 'today', componentKey: 'workToday', requiredSlices: ['actions'] },
{ path: 'mine', componentKey: 'workMine', requiredSlices: ['actions'] }, export const MODE_ROUTE_GROUPS = {
work: [
{ path: 'today', componentKey: 'workToday', requiredSlices: ['actions'], gated: true },
{ path: 'mine', componentKey: 'workMine', requiredSlices: ['actions'], gated: true },
{ {
path: 'sprint', path: 'sprint',
componentKey: 'workSprint', componentKey: 'workSprint',
requiredSlices: ['work_cycles'], requiredSlices: ['work_cycles'],
routePath: '/work/sprint', routePath: '/work/sprint',
gated: true,
}, },
{ path: 'portfolio', componentKey: 'planPortfolio', requiredSlices: [] }, ],
{ path: 'profile', componentKey: 'planProfile', requiredSlices: [] }, plan: [
{ path: 'structure', componentKey: 'planStructure', requiredSlices: ['projects'] }, { path: 'portfolio', componentKey: 'planPortfolio', gated: false },
{ path: 'gates', componentKey: 'planGates', requiredSlices: ['roadmap'] }, { path: 'profile', componentKey: 'planProfile', gated: false },
{ path: 'inbox', componentKey: 'planInbox', requiredSlices: ['backlog'] }, { 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', path: 'sprint',
componentKey: 'planSprint', componentKey: 'planSprint',
requiredSlices: ['work_cycles'], requiredSlices: ['work_cycles'],
routePath: '/plan/sprint', routePath: '/plan/sprint',
gated: true,
}, },
{ path: 'work', componentKey: 'planWork', requiredSlices: ['actions'] }, { path: 'work', componentKey: 'planWork', requiredSlices: ['actions'], gated: false },
{ path: 'status', componentKey: 'controlStatus', requiredSlices: [] }, ],
{ path: 'plan-ist', componentKey: 'controlPlanIst', requiredSlices: ['roadmap'] }, control: [
{ path: 'status', componentKey: 'controlStatus', gated: false },
{ path: 'plan-ist', componentKey: 'controlPlanIst', requiredSlices: ['roadmap'], gated: true },
{ {
path: 'journey', path: 'journey',
componentKey: 'controlJourney', componentKey: 'controlJourney',
requiredSlices: ['roadmap'], requiredSlices: ['roadmap'],
routePath: '/control/journey', routePath: '/control/journey',
gated: true,
}, },
],
}
/** @deprecated Use MODE_ROUTE_GROUPS */
export const MODE_ROUTES = [
...MODE_ROUTE_GROUPS.work,
...MODE_ROUTE_GROUPS.plan,
...MODE_ROUTE_GROUPS.control,
] ]
export { export {