From f6aba9486407ee84c90c1233aed9066c5a6e630e Mon Sep 17 00:00:00 2001
From: Lars
Date: Sun, 12 Jul 2026 19:21:49 +0200
Subject: [PATCH] AP2.2d: Sprint-Planung, Abschluss und Product-Fallback.
Backlog committet in gewaehlten Sprint, Sprint complete-Endpoint, continuous_product-Ansicht ohne aktiven Sprint.
Co-authored-by: Cursor
---
backend/routers/work_cycles.py | 20 +++
backend/services/work_cycle.py | 46 ++++++
backend/tests/test_ap22d_sprint_backlog.py | 70 +++++++++
frontend/src/api/workCycles.js | 6 +
frontend/src/components/BacklogSection.jsx | 133 +++++++++++++++++-
frontend/src/components/WorkCyclesPanel.jsx | 13 +-
.../context/InitiativeOperationsContext.jsx | 40 +++++-
.../pages/initiative/InitiativeInboxPage.jsx | 10 ++
frontend/src/pages/modes/PlanSprintPage.jsx | 2 +
frontend/src/pages/modes/WorkSprintPage.jsx | 64 +++++++--
frontend/src/styles/program-chrome.css | 35 +++++
11 files changed, 422 insertions(+), 17 deletions(-)
diff --git a/backend/routers/work_cycles.py b/backend/routers/work_cycles.py
index 110179a..0e918e6 100644
--- a/backend/routers/work_cycles.py
+++ b/backend/routers/work_cycles.py
@@ -85,6 +85,26 @@ def create_work_cycle(
raise HTTPException(status_code=400, detail=detail) from exc
+@initiative_router.post("/{work_cycle_id}/complete")
+def complete_work_cycle(
+ initiative_id: str,
+ work_cycle_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ try:
+ return work_cycle_service.complete_work_cycle(
+ tenant_id=ctx.tenant_id,
+ initiative_id=initiative_id,
+ work_cycle_id=work_cycle_id,
+ user_id=ctx.user_id,
+ )
+ except ValueError as exc:
+ detail = str(exc)
+ if detail in ("Initiative nicht gefunden", "Sprint nicht gefunden oder bereits abgeschlossen"):
+ raise HTTPException(status_code=404, detail=detail) from exc
+ raise HTTPException(status_code=400, detail=detail) from exc
+
+
@initiative_router.post("/{work_cycle_id}/activate")
def activate_work_cycle(
initiative_id: str,
diff --git a/backend/services/work_cycle.py b/backend/services/work_cycle.py
index a0003e4..1a65248 100644
--- a/backend/services/work_cycle.py
+++ b/backend/services/work_cycle.py
@@ -176,6 +176,52 @@ def activate_work_cycle(
return result
+def complete_work_cycle(
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ work_cycle_id: str,
+ user_id: Optional[str] = None,
+) -> dict[str, Any]:
+ validate_work_cycle_in_initiative(
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ work_cycle_id=work_cycle_id,
+ )
+ if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
+ raise ValueError("Initiative nicht gefunden")
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ UPDATE roadmap_items
+ SET status = 'reached', updated_at = NOW()
+ WHERE id = %s AND tenant_id = %s
+ AND item_type = %s
+ AND status IN ('planned', 'active', 'at_risk')
+ RETURNING id, title, goal_description, status, target_date, sort_order, item_type
+ """,
+ (work_cycle_id, tenant_id, WORK_CYCLE_TYPE),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise ValueError("Sprint nicht gefunden oder bereits abgeschlossen")
+ conn.commit()
+ finally:
+ conn.close()
+
+ result = _serialize_cycle({**dict(row), "initiative_id": initiative_id})
+ log_audit(
+ "work_cycle.completed",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"work_cycle_id": work_cycle_id, "initiative_id": initiative_id},
+ )
+ return result
+
+
def count_actions_in_work_cycle(
*, tenant_id: str, initiative_id: str, work_cycle_id: str
) -> int:
diff --git a/backend/tests/test_ap22d_sprint_backlog.py b/backend/tests/test_ap22d_sprint_backlog.py
index 7fe9341..f428690 100644
--- a/backend/tests/test_ap22d_sprint_backlog.py
+++ b/backend/tests/test_ap22d_sprint_backlog.py
@@ -42,3 +42,73 @@ def test_convert_backlog_assigns_active_work_cycle(client):
assert converted.status_code == 201
action = converted.json()["action"]
assert action["work_cycle_id"] == cycle_id
+
+
+def test_convert_backlog_to_planned_sprint(client):
+ user = provision_user_in_tenant(tenant_role="admin")
+ token = _login(client, user)
+
+ created = _create_initiative(
+ client,
+ token,
+ title="Product Sprint Plan",
+ archetype_key="initiative.product",
+ )
+ initiative_id = created.json()["id"]
+
+ cycle = client.post(
+ f"/api/initiatives/{initiative_id}/work-cycles",
+ json={"title": "Sprint R2", "status": "planned"},
+ headers=_auth(token),
+ )
+ assert cycle.status_code == 201
+ cycle_id = cycle.json()["id"]
+
+ backlog = client.post(
+ f"/api/initiatives/{initiative_id}/backlog",
+ json={"title": "Story Y", "status": "accepted"},
+ headers=_auth(token),
+ )
+ backlog_id = backlog.json()["id"]
+
+ converted = client.post(
+ f"/api/backlog/{backlog_id}/convert-to-action",
+ json={"work_cycle_id": cycle_id, "assign_active_sprint": False},
+ headers=_auth(token),
+ )
+ assert converted.status_code == 201
+ assert converted.json()["action"]["work_cycle_id"] == cycle_id
+
+
+def test_complete_work_cycle(client):
+ user = provision_user_in_tenant(tenant_role="admin")
+ token = _login(client, user)
+
+ created = _create_initiative(
+ client,
+ token,
+ title="Sprint Complete",
+ 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),
+ )
+ cycle_id = cycle.json()["id"]
+
+ completed = client.post(
+ f"/api/initiatives/{initiative_id}/work-cycles/{cycle_id}/complete",
+ headers=_auth(token),
+ )
+ assert completed.status_code == 200
+ assert completed.json()["status"] == "reached"
+
+ active = client.get(
+ f"/api/initiatives/{initiative_id}/work-cycles/active",
+ headers=_auth(token),
+ )
+ assert active.status_code == 200
+ assert active.json() is None
diff --git a/frontend/src/api/workCycles.js b/frontend/src/api/workCycles.js
index bf988aa..dd53e90 100644
--- a/frontend/src/api/workCycles.js
+++ b/frontend/src/api/workCycles.js
@@ -15,6 +15,12 @@ export function createWorkCycle(initiativeId, body) {
})
}
+export function completeWorkCycle(initiativeId, workCycleId) {
+ return apiFetch(`/api/initiatives/${initiativeId}/work-cycles/${workCycleId}/complete`, {
+ method: 'POST',
+ })
+}
+
export function activateWorkCycle(initiativeId, workCycleId) {
return apiFetch(`/api/initiatives/${initiativeId}/work-cycles/${workCycleId}/activate`, {
method: 'POST',
diff --git a/frontend/src/components/BacklogSection.jsx b/frontend/src/components/BacklogSection.jsx
index 46bd780..abf25b2 100644
--- a/frontend/src/components/BacklogSection.jsx
+++ b/frontend/src/components/BacklogSection.jsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { StatusBadge } from './StatusBadge.jsx'
import { PriorityBadge } from './PriorityBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
@@ -9,25 +9,58 @@ import { gateTitleById } from './GateSelect.jsx'
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
import { useMinWidth } from '../hooks/useMinWidth.js'
+const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
+
export function BacklogSection({
items,
roadmapItems = [],
+ workCycles = [],
+ activeWorkCycle = null,
+ sprintPlanningEnabled = false,
canManage,
onCreate,
onUpdate,
onReorder,
onConvert,
+ onBulkConvert,
onDelete,
busy,
sectionTitle = 'Product Backlog',
- sectionLead = 'Eingang vor dem Commit — triagieren, dann in Arbeitspaket (Sprint) umwandeln.',
+ sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.',
}) {
const [modalMode, setModalMode] = useState(null)
const [dragItemId, setDragItemId] = useState('')
const [dropTargetId, setDropTargetId] = useState('')
+ const [selectedIds, setSelectedIds] = useState(() => new Set())
+ const [sprintTargetId, setSprintTargetId] = useState('')
const isDesktop = useMinWidth(1024)
const canReorder = canManage && typeof onReorder === 'function'
+ const planableSprints = useMemo(
+ () => workCycles.filter((cycle) => PLANNING_STATUSES.has(cycle.status)),
+ [workCycles],
+ )
+
+ const showSprintPlanning = sprintPlanningEnabled && planableSprints.length > 0
+
+ useEffect(() => {
+ if (!showSprintPlanning) {
+ setSprintTargetId('')
+ return
+ }
+ if (sprintTargetId && planableSprints.some((cycle) => cycle.id === sprintTargetId)) {
+ return
+ }
+ const preferred = activeWorkCycle?.id || planableSprints[0]?.id || ''
+ setSprintTargetId(preferred)
+ }, [showSprintPlanning, sprintTargetId, planableSprints, activeWorkCycle?.id])
+
+ const selectedSprint = planableSprints.find((cycle) => cycle.id === sprintTargetId) || null
+
+ const convertLabel = selectedSprint
+ ? `In Sprint „${selectedSprint.title}" planen`
+ : 'In Arbeitspaket umwandeln'
+
const sortedItems = useMemo(
() => sortByOrder(items.filter((item) => item.status !== 'converted')),
[items],
@@ -104,6 +137,46 @@ export function BacklogSection({
const modalTitle =
modalMode?.kind === 'create' ? 'Backlog-Item anlegen' : 'Backlog-Item bearbeiten'
+ const acceptedItems = sortedItems.filter((item) => item.status === 'accepted')
+ const selectedAccepted = acceptedItems.filter((item) => selectedIds.has(item.id))
+
+ function toggleSelected(itemId) {
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(itemId)) next.delete(itemId)
+ else next.add(itemId)
+ return next
+ })
+ }
+
+ function convertOptions() {
+ return sprintTargetId ? { work_cycle_id: sprintTargetId } : {}
+ }
+
+ async function handleSingleConvert(itemId) {
+ await onConvert(itemId, convertOptions())
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ next.delete(itemId)
+ return next
+ })
+ }
+
+ async function handleBulkPlan() {
+ if (!selectedAccepted.length) return
+ if (typeof onBulkConvert === 'function') {
+ await onBulkConvert(
+ selectedAccepted.map((item) => item.id),
+ convertOptions(),
+ )
+ } else {
+ for (const item of selectedAccepted) {
+ await onConvert(item.id, convertOptions())
+ }
+ }
+ setSelectedIds(new Set())
+ }
+
return (
@@ -122,6 +195,45 @@ export function BacklogSection({
)}
+ {showSprintPlanning && canManage && acceptedItems.length > 0 && (
+
+
+ {selectedAccepted.length > 0 && (
+
+ )}
+
+ Items markieren und in den gewählten Sprint committen — Planung unter Plan → Sprint.
+
+
+ )}
+
+ {sprintPlanningEnabled && canManage && acceptedItems.length > 0 && planableSprints.length === 0 && (
+
+ Lege zuerst einen Sprint unter Plan → Sprint an, um Backlog-Items zu planen.
+
+ )}
+
{items.length === 0 && }
)}
- {canManage && cycle.status !== 'active' && (
+ {canManage && cycle.status === 'active' && typeof onComplete === 'function' && (
+
+ )}
+ {canManage && cycle.status !== 'active' && cycle.status !== 'reached' && (
}
+ const isProductArchetype = initiative?.archetype_key === 'initiative.product'
+
return (
<>
{error && {error}
}
diff --git a/frontend/src/pages/modes/PlanSprintPage.jsx b/frontend/src/pages/modes/PlanSprintPage.jsx
index 0c8e404..4106cbc 100644
--- a/frontend/src/pages/modes/PlanSprintPage.jsx
+++ b/frontend/src/pages/modes/PlanSprintPage.jsx
@@ -33,6 +33,7 @@ function PlanSprintInner() {
reloadActors,
handleCreateWorkCycle,
handleActivateWorkCycle,
+ handleCompleteWorkCycle,
handleCreateAction,
handleUpdateAction,
handleQuickStatus,
@@ -62,6 +63,7 @@ function PlanSprintInner() {
canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateWorkCycle}
onActivate={handleActivateWorkCycle}
+ onComplete={handleCompleteWorkCycle}
busy={formBusy}
/>
{activeWorkCycle && (
diff --git a/frontend/src/pages/modes/WorkSprintPage.jsx b/frontend/src/pages/modes/WorkSprintPage.jsx
index 0e613ad..ab638f1 100644
--- a/frontend/src/pages/modes/WorkSprintPage.jsx
+++ b/frontend/src/pages/modes/WorkSprintPage.jsx
@@ -41,6 +41,7 @@ function WorkSprintInner() {
initiative,
steeringSnapshot,
steeringSnapshotLoading,
+ visibleActions,
} = ops
const defaultWorkCycleId = activeWorkCycle?.id || ''
@@ -66,16 +67,61 @@ function WorkSprintInner() {
{error && {error}
}
{!activeWorkCycle && isProductArchetype && (
-
-
+
+ Product-Ist (ohne aktiven Sprint)
+
+ Kein aktiver Sprint — Kairo steuert nach{' '}
+ continuous_product (wirkungsvollster Schritt). Sprint-Planung
+ unter{' '}
+
+ Plan → Sprint
+
+ .
+
+
+
+
-
-
- Zur Sprint-Planung
-
-
-
+
+ setShowActionForm((v) => !v)}
+ editingActionId={editingAction?.id}
+ onEditAction={setEditingAction}
+ onCancelEdit={() => setEditingAction(null)}
+ onCreateAction={handleCreateAction}
+ 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}
+ sectionTitle="Committete Arbeitspakete"
+ sectionLead="Ohne aktiven Sprint — alle offenen Arbeitspakete im Product-Vorhaben."
+ />
+ >
)}
{activeWorkCycle && (
diff --git a/frontend/src/styles/program-chrome.css b/frontend/src/styles/program-chrome.css
index 59ea586..e1866bf 100644
--- a/frontend/src/styles/program-chrome.css
+++ b/frontend/src/styles/program-chrome.css
@@ -669,3 +669,38 @@
border-color: var(--jk-primary);
background: var(--jk-surface);
}
+
+.backlog-sprint-planning {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 12px;
+ margin-bottom: 16px;
+ padding: 12px;
+ background: var(--jk-surface-muted, rgba(0, 0, 0, 0.03));
+ border-radius: 8px;
+}
+
+.backlog-sprint-planning__field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 200px;
+}
+
+.backlog-sprint-planning__hint {
+ flex: 1 1 100%;
+ margin: 0;
+ font-size: 13px;
+}
+
+.backlog-select-checkbox {
+ display: flex;
+ align-items: center;
+ padding: 0 4px;
+}
+
+.backlog-select-checkbox input {
+ width: 16px;
+ height: 16px;
+}