diff --git a/backend/routers/backlog.py b/backend/routers/backlog.py index e2d806c..0d14adc 100644 --- a/backend/routers/backlog.py +++ b/backend/routers/backlog.py @@ -34,6 +34,8 @@ class BacklogUpdateRequest(BaseModel): class BacklogConvertRequest(BaseModel): assigned_actor_ids: list[str] = Field(default_factory=list) + work_cycle_id: Optional[str] = None + assign_active_sprint: bool = True @router.get("/{backlog_item_id}") @@ -103,6 +105,8 @@ def convert_backlog_to_action( backlog_item_id=backlog_item_id, user_id=ctx.user_id, assigned_actor_ids=assigned, + work_cycle_id=body.work_cycle_id, + assign_active_sprint=body.assign_active_sprint, ) except ValueError as exc: detail = str(exc) diff --git a/backend/services/backlog.py b/backend/services/backlog.py index ccb0f78..d509e4b 100644 --- a/backend/services/backlog.py +++ b/backend/services/backlog.py @@ -295,6 +295,8 @@ def convert_backlog_to_action( backlog_item_id: str, user_id: Optional[str] = None, assigned_actor_ids: Optional[list[str]] = None, + work_cycle_id: Optional[str] = None, + assign_active_sprint: bool = True, ) -> dict[str, Any]: existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id) if not existing: @@ -304,6 +306,16 @@ def convert_backlog_to_action( if existing["status"] not in ("accepted", "triaged", "new"): raise ValueError("Backlog-Item kann in diesem Status nicht konvertiert werden") + resolved_cycle_id = work_cycle_id + if not resolved_cycle_id and assign_active_sprint: + from services.work_cycle import get_active_work_cycle + + active = get_active_work_cycle( + tenant_id=tenant_id, initiative_id=existing["initiative_id"] + ) + if active: + resolved_cycle_id = active["id"] + action = create_action( tenant_id=tenant_id, initiative_id=existing["initiative_id"], @@ -311,6 +323,7 @@ def convert_backlog_to_action( description=existing["description"] or "", priority=existing["priority"], roadmap_item_id=existing.get("roadmap_item_id"), + work_cycle_id=resolved_cycle_id, assigned_actor_ids=assigned_actor_ids or [], user_id=user_id, ) diff --git a/backend/steering/signals/engine.py b/backend/steering/signals/engine.py index a96db70..76e49db 100644 --- a/backend/steering/signals/engine.py +++ b/backend/steering/signals/engine.py @@ -25,6 +25,25 @@ def _resolve_method_key(ctx: TenantContext, initiative_id: str | None) -> str: return "generic_operating" +def _resolve_next_action_strategy_key( + ctx: TenantContext, initiative_id: str | None +) -> str: + method_key = _resolve_method_key(ctx, initiative_id) + method = get_method(method_key) + strategy_key = method.next_action_strategy_key if method else default_strategy.key + if not initiative_id: + return strategy_key + + from services.work_cycle import get_active_work_cycle + + active = get_active_work_cycle(tenant_id=ctx.tenant_id, initiative_id=initiative_id) + if active and strategy_key == "continuous_product": + agile = get_next_action_strategy("agile_iteration") + if agile: + return agile.key + return strategy_key + + def evaluate( ctx: TenantContext, kind: SignalKind = "attention", @@ -35,10 +54,6 @@ def evaluate( if kind == "attention": return default_rules.get_attention_items(ctx) - method_key = _resolve_method_key(ctx, initiative_id) - method = get_method(method_key) - strategy_key = ( - method.next_action_strategy_key if method else default_strategy.key - ) + strategy_key = _resolve_next_action_strategy_key(ctx, initiative_id) strategy = get_next_action_strategy(strategy_key) or default_strategy return strategy.evaluate(ctx, initiative_id=initiative_id, limit=limit) diff --git a/backend/tests/test_ap22d_sprint_backlog.py b/backend/tests/test_ap22d_sprint_backlog.py new file mode 100644 index 0000000..7fe9341 --- /dev/null +++ b/backend/tests/test_ap22d_sprint_backlog.py @@ -0,0 +1,44 @@ +"""AP2.2d — Backlog convert assigns active sprint.""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def test_convert_backlog_assigns_active_work_cycle(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Product Backlog", + archetype_key="initiative.product", + ) + initiative_id = created.json()["id"] + + cycle = client.post( + f"/api/initiatives/{initiative_id}/work-cycles", + json={"title": "Sprint R1", "status": "active"}, + headers=_auth(token), + ) + assert cycle.status_code == 201 + cycle_id = cycle.json()["id"] + + backlog = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Feature X", "status": "accepted"}, + headers=_auth(token), + ) + assert backlog.status_code == 201 + backlog_id = backlog.json()["id"] + + converted = client.post( + f"/api/backlog/{backlog_id}/convert-to-action", + json={"assign_active_sprint": True}, + headers=_auth(token), + ) + assert converted.status_code == 201 + action = converted.json()["action"] + assert action["work_cycle_id"] == cycle_id diff --git a/backend/version.py b/backend/version.py index 0c74c91..55795b0 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.20.1-ap2.2bc" +APP_VERSION = "0.20.2-ap2.2d" DB_SCHEMA_VERSION = "025" APP_NAME = "jinkendo-kairo" diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 859eb76..b4c40ee 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -148,8 +148,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. |----------|------------|------------------|-----------|--------|-------| | A1 Reifegrad | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2c + AP2.0e | | A2 Linear | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2b | -| B2b Product | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2d | -| B3 Sprint-Profil | ◐ | ◐ | ◐ | ◐ | AP2.2d | +| B2b Product | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2d ✓ | +| B3 Sprint-Profil | ◐ | ◐ | ◐ | ◐ | AP2.2d ✓ | | B2a Programm | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2e | --- diff --git a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md index cbed10a..fd1d1ca 100644 --- a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md +++ b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md @@ -75,7 +75,7 @@ Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ✓ Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) ◐ (Hints + Recurring) Phase 3 AP2.2b A2 Linear End-to-End ◐ AP2.2c A1 Reifegrad + AP2.0e ◐ Code - AP2.2d B2b Product + B3 Sprint + AP2.2d B2b Product + B3 Sprint ✓ AP2.2e B2a Programm (optional vor AP2.1) Phase 4 AP1.7b Operational API — Pflege-Parität Phase 5 AP2.1 Validation Report v0.3 (alle Stufe-A-Szenarien) diff --git a/frontend/package.json b/frontend/package.json index 091a44c..32b3f44 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kairo-jinkendo-frontend", - "version": "0.20.1-ap2.2bc", + "version": "0.20.2-ap2.2d", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0c9eec1..74cb635 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -63,6 +63,7 @@ function AppRoutes() { }> } /> } /> + } /> } /> @@ -73,6 +74,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/workCycles.js b/frontend/src/api/workCycles.js new file mode 100644 index 0000000..bf988aa --- /dev/null +++ b/frontend/src/api/workCycles.js @@ -0,0 +1,22 @@ +import { apiFetch } from './client.js' + +export function listInitiativeWorkCycles(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/work-cycles`) +} + +export function getActiveWorkCycle(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/work-cycles/active`) +} + +export function createWorkCycle(initiativeId, body) { + return apiFetch(`/api/initiatives/${initiativeId}/work-cycles`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function activateWorkCycle(initiativeId, workCycleId) { + return apiFetch(`/api/initiatives/${initiativeId}/work-cycles/${workCycleId}/activate`, { + method: 'POST', + }) +} diff --git a/frontend/src/components/ActionForm.jsx b/frontend/src/components/ActionForm.jsx index b65a49c..fb0f9e0 100644 --- a/frontend/src/components/ActionForm.jsx +++ b/frontend/src/components/ActionForm.jsx @@ -15,6 +15,8 @@ export function ActionForm({ initial = {}, projects = [], roadmapItems = [], + workCycles = [], + activeWorkCycle = null, actors = [], actorsLoading = false, actorsError = null, @@ -37,6 +39,7 @@ export function ActionForm({ due_at: form.due_at.value ? new Date(form.due_at.value).toISOString() : null, project_id: form.project_id?.value || undefined, roadmap_item_id: form.roadmap_item_id?.value || undefined, + work_cycle_id: form.work_cycle_id?.value || undefined, assigned_actor_ids: selected, }) } @@ -44,6 +47,9 @@ export function ActionForm({ const defaultActors = initial.assigned_actor_ids || [] const leafProjects = getLeafProjects(projects) const projectOptions = flattenLeafProjectOptions(projects) + const cycleOptions = workCycles.filter((c) => c.status !== 'completed') + const defaultCycleId = + initial.work_cycle_id || (activeWorkCycle?.id && !initial.id ? activeWorkCycle.id : '') return (
@@ -100,6 +106,20 @@ export function ActionForm({ roadmapItems={roadmapItems} defaultValue={initial.roadmap_item_id || ''} /> + {cycleOptions.length > 0 && ( + + )} {hint.link.label} + {hint.secondaryLink && ( + <> + {' · '} + + {hint.secondaryLink.label} + + + )}

) } diff --git a/frontend/src/components/BacklogSection.jsx b/frontend/src/components/BacklogSection.jsx index 83967da..da02058 100644 --- a/frontend/src/components/BacklogSection.jsx +++ b/frontend/src/components/BacklogSection.jsx @@ -19,6 +19,8 @@ export function BacklogSection({ onConvert, onDelete, busy, + sectionTitle = 'Product Backlog', + sectionLead = 'Eingang vor dem Commit — triagieren, dann in Arbeitspaket (Sprint) umwandeln.', }) { const [modalMode, setModalMode] = useState(null) const [dragItemId, setDragItemId] = useState('') @@ -106,10 +108,8 @@ export function BacklogSection({
-

Backlog

-

- Reihenfolge per Drag & Drop (Desktop) oder ↑/↓ (Mobile). -

+

{sectionTitle}

+

{sectionLead}

{canManage && ( + + )} + + {workCycles.length === 0 ? ( + + ) : ( +
    + {workCycles.map((cycle) => ( +
  • +
    + {cycle.title}{' '} + + {cycle.goal_description && ( +

    {cycle.goal_description}

    + )} +
    + {canManage && cycle.status !== 'active' && ( + + )} +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/config/modeNav.test.js b/frontend/src/config/modeNav.test.js index 217caa7..16ddcb8 100644 --- a/frontend/src/config/modeNav.test.js +++ b/frontend/src/config/modeNav.test.js @@ -13,6 +13,7 @@ describe('controlNav', () => { describe('workNav', () => { it('resolves active work sub-route', () => { + expect(resolveWorkNavActiveKey('/work/sprint')).toBe('sprint') expect(resolveWorkNavActiveKey('/work/today')).toBe('today') expect(resolveWorkNavActiveKey('/work/mine')).toBe('mine') }) diff --git a/frontend/src/config/workNav.js b/frontend/src/config/workNav.js index 472552b..1628e96 100644 --- a/frontend/src/config/workNav.js +++ b/frontend/src/config/workNav.js @@ -7,6 +7,7 @@ /** @type {ModeNavItem[]} */ export const WORK_NAV_ITEMS = [ + { key: 'sprint', to: '/work/sprint', label: 'Sprint' }, { key: 'today', to: '/work/today', label: 'Heute' }, { key: 'mine', to: '/work/mine', label: 'Meine Queue' }, ] diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx index dbc107f..269b4d0 100644 --- a/frontend/src/context/InitiativeOperationsContext.jsx +++ b/frontend/src/context/InitiativeOperationsContext.jsx @@ -71,6 +71,12 @@ import { updateInitiativeSteeringMethod, updateInitiativeMethodProfile, } from '../api/steering.js' +import { + listInitiativeWorkCycles, + getActiveWorkCycle, + createWorkCycle, + activateWorkCycle, +} from '../api/workCycles.js' import { useCapabilities } from '../hooks/useCapabilities.js' import { useActors } from '../hooks/useActors.js' import { useSession } from './SessionContext.jsx' @@ -98,6 +104,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ const [decisions, setDecisions] = useState([]) const [reviews, setReviews] = useState([]) const [recurringItems, setRecurringItems] = useState([]) + const [workCycles, setWorkCycles] = useState([]) + const [activeWorkCycle, setActiveWorkCycle] = useState(null) const [steeringSnapshot, setSteeringSnapshot] = useState(null) const [steeringSnapshotLoading, setSteeringSnapshotLoading] = useState(false) const [steeringSnapshotError, setSteeringSnapshotError] = useState(null) @@ -135,6 +143,12 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([]))) loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([]))) loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([]))) + loads.push(listInitiativeWorkCycles(id).then(setWorkCycles).catch(() => setWorkCycles([]))) + loads.push( + getActiveWorkCycle(id) + .then((item) => setActiveWorkCycle(item || null)) + .catch(() => setActiveWorkCycle(null)) + ) loads.push( listSteeringMethods().then(setSteeringMethods).catch(() => setSteeringMethods([])) ) @@ -186,6 +200,20 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ [actions, hideDone, selectedProjectId, projects] ) + const sprintActions = useMemo(() => { + if (!activeWorkCycle?.id) return [] + const cycleId = activeWorkCycle.id + let list = hideDone + ? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded') + : actions + list = list.filter((a) => a.work_cycle_id === cycleId) + if (selectedProjectId) { + const subtreeIds = collectProjectSubtreeIds(projects, selectedProjectId) + list = list.filter((a) => a.project_id && subtreeIds.has(a.project_id)) + } + return list + }, [actions, hideDone, activeWorkCycle?.id, selectedProjectId, projects]) + const actionContextById = useMemo( () => Object.fromEntries((steeringSnapshot?.actions || []).map((a) => [a.id, a])), [steeringSnapshot] @@ -257,6 +285,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ clear_project: payload.project_id === '' || payload.project_id === null, roadmap_item_id: payload.roadmap_item_id || undefined, clear_roadmap_item: payload.roadmap_item_id === '' || payload.roadmap_item_id === null, + work_cycle_id: payload.work_cycle_id || undefined, + clear_work_cycle: payload.work_cycle_id === '' || payload.work_cycle_id === null, }) if (capabilities.has('kairo.action.manage')) { await setActionAssignments(actionId, payload.assigned_actor_ids || []) @@ -393,7 +423,31 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ async function handleConvertBacklog(itemId) { setFormBusy(true) try { - await convertBacklogToAction(itemId) + await convertBacklogToAction(itemId, { assign_active_sprint: true }) + await load() + } catch (err) { + setError(err.message) + } finally { + setFormBusy(false) + } + } + + async function handleCreateWorkCycle(body) { + setFormBusy(true) + try { + await createWorkCycle(id, body) + await load() + } catch (err) { + setError(err.message) + } finally { + setFormBusy(false) + } + } + + async function handleActivateWorkCycle(workCycleId) { + setFormBusy(true) + try { + await activateWorkCycle(id, workCycleId) await load() } catch (err) { setError(err.message) @@ -727,6 +781,9 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ decisions, reviews, recurringItems, + workCycles, + activeWorkCycle, + sprintActions, steeringSnapshot, steeringSnapshotLoading, steeringSnapshotError, @@ -764,6 +821,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ handleBacklogGate, handleBacklogStatus, handleConvertBacklog, + handleCreateWorkCycle, + handleActivateWorkCycle, handleDeleteBacklog, handleCreateRoadmapItem, handleRoadmapItemStatus, diff --git a/frontend/src/pages/modes/PlanSprintPage.jsx b/frontend/src/pages/modes/PlanSprintPage.jsx new file mode 100644 index 0000000..7d1c9a9 --- /dev/null +++ b/frontend/src/pages/modes/PlanSprintPage.jsx @@ -0,0 +1,111 @@ +import { useMemo } from 'react' +import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' +import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx' +import { WorkCyclesPanel } from '../../components/WorkCyclesPanel.jsx' +import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx' +import { LoadingState } from '../../components/LoadingState.jsx' +import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' + +function PlanSprintInner() { + const ops = useInitiativeOperations() + const { + initiativeId, + activeWorkCycle, + workCycles, + sprintActions, + projects, + roadmapItems, + capabilities, + hideDone, + setHideDone, + loading, + error, + formBusy, + showActionForm, + setShowActionForm, + editingAction, + setEditingAction, + actionContextById, + actors, + actorsLoading, + actorsError, + actorsUsedFallback, + reloadActors, + handleCreateWorkCycle, + handleActivateWorkCycle, + handleCreateAction, + handleUpdateAction, + handleQuickStatus, + handleCreateBlockerForAction, + } = ops + + const createSprintAction = useMemo( + () => async (payload) => { + await handleCreateAction({ + ...payload, + work_cycle_id: payload.work_cycle_id || activeWorkCycle?.id || undefined, + }) + }, + [handleCreateAction, activeWorkCycle?.id], + ) + + if (loading) { + return + } + + return ( + <> + {error &&

{error}

} + + {activeWorkCycle && ( + setShowActionForm((v) => !v)} + editingActionId={editingAction?.id} + onEditAction={setEditingAction} + onCancelEdit={() => setEditingAction(null)} + onCreateAction={createSprintAction} + onUpdateAction={handleUpdateAction} + onQuickStatus={handleQuickStatus} + onCreateBlockerForAction={handleCreateBlockerForAction} + canManage={capabilities.has('kairo.action.manage')} + canManageBlocker={capabilities.has('kairo.blocker.manage')} + formBusy={formBusy} + actors={actors} + actorsLoading={actorsLoading} + actorsError={actorsError} + actorsUsedFallback={actorsUsedFallback} + onReloadActors={reloadActors} + workCycles={workCycles} + activeWorkCycle={activeWorkCycle} + sectionTitle="Sprint-Backlog" + sectionLead="Arbeitspakete in der aktiven Zeitbox — committet aus dem Product Backlog (Eingang) oder direkt hier." + /> + )} + + ) +} + +export function PlanSprintPage() { + return ( + + + + + + ) +} diff --git a/frontend/src/pages/modes/PlanWorkPage.jsx b/frontend/src/pages/modes/PlanWorkPage.jsx index a6dc21c..67e5cbd 100644 --- a/frontend/src/pages/modes/PlanWorkPage.jsx +++ b/frontend/src/pages/modes/PlanWorkPage.jsx @@ -75,6 +75,8 @@ function PlanWorkInner() { onReloadActors={reloadActors} busy={formBusy} executionGraph={executionGraph} + sectionTitle="Alle Arbeitspakete" + sectionLead="Gesamt-Ist am Vorhaben — Sprint-fokussierte Arbeit unter Ausführen → Sprint." /> ) diff --git a/frontend/src/pages/modes/WorkLayout.jsx b/frontend/src/pages/modes/WorkLayout.jsx index 06f9d08..d81ccb5 100644 --- a/frontend/src/pages/modes/WorkLayout.jsx +++ b/frontend/src/pages/modes/WorkLayout.jsx @@ -1,8 +1,12 @@ import { Navigate, Outlet } from 'react-router-dom' +import { useEffect, useState } 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 { useProgramScope } from '../../context/ProgramScopeContext.jsx' +import { getActiveWorkCycle } from '../../api/workCycles.js' +import { getInitiative } from '../../api/initiatives.js' export function WorkLayout() { return ( @@ -24,5 +28,34 @@ export function WorkLayout() { } export function WorkIndexRedirect() { - return + const { initiativeId } = useProgramScope() + const [target, setTarget] = useState('/work/today') + + useEffect(() => { + if (!initiativeId) { + setTarget('/work/today') + return undefined + } + let cancelled = false + Promise.all([getInitiative(initiativeId), getActiveWorkCycle(initiativeId)]) + .then(([initiative, activeCycle]) => { + if (cancelled) return + if ( + initiative?.archetype_key === 'initiative.product' && + activeCycle?.id + ) { + setTarget(`/work/sprint?initiative=${initiativeId}`) + } else { + setTarget(`/work/today?initiative=${initiativeId}`) + } + }) + .catch(() => { + if (!cancelled) setTarget('/work/today') + }) + return () => { + cancelled = true + } + }, [initiativeId]) + + return } diff --git a/frontend/src/pages/modes/WorkSprintPage.jsx b/frontend/src/pages/modes/WorkSprintPage.jsx new file mode 100644 index 0000000..e1a739c --- /dev/null +++ b/frontend/src/pages/modes/WorkSprintPage.jsx @@ -0,0 +1,148 @@ +import { 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' +import { EmptyState } from '../../components/EmptyState.jsx' +import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' +import { useProgramScope } from '../../context/ProgramScopeContext.jsx' +import { scopedPath } from '../../utils/routes.js' + +function WorkSprintInner() { + const { initiativeId, hrefWithScope } = useProgramScope() + const ops = useInitiativeOperations() + const { + activeWorkCycle, + sprintActions, + projects, + roadmapItems, + capabilities, + hideDone, + setHideDone, + loading, + error, + formBusy, + showActionForm, + setShowActionForm, + editingAction, + setEditingAction, + actionContextById, + actors, + actorsLoading, + actorsError, + actorsUsedFallback, + reloadActors, + handleCreateAction, + handleUpdateAction, + handleQuickStatus, + handleCreateBlockerForAction, + initiative, + steeringSnapshot, + steeringSnapshotLoading, + } = ops + + const defaultWorkCycleId = activeWorkCycle?.id || '' + + const createAction = useMemo( + () => async (payload) => { + await handleCreateAction({ + ...payload, + work_cycle_id: payload.work_cycle_id || defaultWorkCycleId || undefined, + }) + }, + [handleCreateAction, defaultWorkCycleId], + ) + + if (loading) { + return + } + + const isProductArchetype = initiative?.archetype_key === 'initiative.product' + + return ( + <> + {error &&

{error}

} + + {!activeWorkCycle && isProductArchetype && ( +
+ +

+ + Zur Sprint-Planung + +

+
+ )} + + {activeWorkCycle && ( + <> +
+

Sprint-Backlog

+

+ {activeWorkCycle.title} + {activeWorkCycle.goal_description && ` — ${activeWorkCycle.goal_description}`} + {' · '} + Product Backlog triagierst du unter{' '} + + Plan → Eingang + + . +

+
+ + + + setShowActionForm((v) => !v)} + editingActionId={editingAction?.id} + onEditAction={setEditingAction} + onCancelEdit={() => setEditingAction(null)} + onCreateAction={createAction} + onUpdateAction={handleUpdateAction} + onQuickStatus={handleQuickStatus} + onCreateBlockerForAction={handleCreateBlockerForAction} + canManage={capabilities.has('kairo.action.manage')} + canManageBlocker={capabilities.has('kairo.blocker.manage')} + formBusy={formBusy} + actors={actors} + actorsLoading={actorsLoading} + actorsError={actorsError} + actorsUsedFallback={actorsUsedFallback} + onReloadActors={reloadActors} + workCycles={ops.workCycles} + activeWorkCycle={activeWorkCycle} + /> + + )} + + ) +} + +export function WorkSprintPage() { + return ( + + + + + + ) +} diff --git a/frontend/src/plan/planOutlineNodes.js b/frontend/src/plan/planOutlineNodes.js index 6772350..f1c7788 100644 --- a/frontend/src/plan/planOutlineNodes.js +++ b/frontend/src/plan/planOutlineNodes.js @@ -11,6 +11,7 @@ export const PLAN_OUTLINE_NODES = [ { key: 'structure', to: '/plan/structure', label: 'Struktur', requiresInitiative: true }, { key: 'gates', to: '/plan/gates', label: 'Zielzustände', requiresInitiative: true }, { key: 'inbox', to: '/plan/inbox', label: 'Eingang', requiresInitiative: true }, + { key: 'sprint', to: '/plan/sprint', label: 'Sprint', requiresInitiative: true }, { key: 'work', to: '/plan/work', label: 'Arbeit', requiresInitiative: true }, ] diff --git a/frontend/src/plan/planOutlineNodes.test.js b/frontend/src/plan/planOutlineNodes.test.js index d52cad9..f5fda31 100644 --- a/frontend/src/plan/planOutlineNodes.test.js +++ b/frontend/src/plan/planOutlineNodes.test.js @@ -13,6 +13,7 @@ describe('planOutlineNodes', () => { 'structure', 'gates', 'inbox', + 'sprint', 'work', ]) }) @@ -22,6 +23,7 @@ describe('planOutlineNodes', () => { expect(resolvePlanOutlineActiveKey('/plan/structure')).toBe('structure') expect(resolvePlanOutlineActiveKey('/plan/gates')).toBe('gates') expect(resolvePlanOutlineActiveKey('/plan/inbox')).toBe('inbox') + expect(resolvePlanOutlineActiveKey('/plan/sprint')).toBe('sprint') expect(resolvePlanOutlineActiveKey('/plan/work')).toBe('work') expect(resolvePlanOutlineActiveKey('/plan/portfolio')).toBe(null) expect(resolvePlanOutlineActiveKey('/projects/abc-123')).toBe('structure') diff --git a/frontend/src/registry/viewRegistry.js b/frontend/src/registry/viewRegistry.js index c11420c..4d11663 100644 --- a/frontend/src/registry/viewRegistry.js +++ b/frontend/src/registry/viewRegistry.js @@ -2,11 +2,13 @@ import { CockpitPage } from '../pages/modes/CockpitPage.jsx' import { WorkLayout, WorkIndexRedirect } from '../pages/modes/WorkLayout.jsx' import { WorkTodayPage } from '../pages/modes/WorkTodayPage.jsx' import { WorkMinePage } from '../pages/modes/WorkMinePage.jsx' +import { WorkSprintPage } from '../pages/modes/WorkSprintPage.jsx' import { PlanLayout, PlanIndexRedirect } from '../pages/modes/PlanLayout.jsx' import { PlanStructurePage } from '../pages/modes/PlanStructurePage.jsx' import { PlanGatesPage } from '../pages/modes/PlanGatesPage.jsx' import { PlanInboxPage } from '../pages/modes/PlanInboxPage.jsx' import { PlanWorkPage } from '../pages/modes/PlanWorkPage.jsx' +import { PlanSprintPage } from '../pages/modes/PlanSprintPage.jsx' import { PlanProfilePage } from '../pages/modes/PlanProfilePage.jsx' import { PlanPortfolioPage } from '../pages/modes/PlanPortfolioPage.jsx' import { ControlLayout, ControlIndexRedirect } from '../pages/modes/ControlLayout.jsx' @@ -129,11 +131,13 @@ export function getViewByKey(key) { export const MODE_ROUTE_COMPONENTS = { workToday: WorkTodayPage, workMine: WorkMinePage, + workSprint: WorkSprintPage, planPortfolio: PlanPortfolioPage, planProfile: PlanProfilePage, planStructure: PlanStructurePage, planGates: PlanGatesPage, planInbox: PlanInboxPage, + planSprint: PlanSprintPage, planWork: PlanWorkPage, controlStatus: ControlStatusPage, controlPlanIst: ControlPlanIstPage,