diff --git a/backend/routers/actions.py b/backend/routers/actions.py
index 0342777..f5cc923 100644
--- a/backend/routers/actions.py
+++ b/backend/routers/actions.py
@@ -178,6 +178,7 @@ def set_action_assignments(
@router.post("/{action_id}/unplan-to-backlog", status_code=200)
def unplan_action_to_backlog(
action_id: str,
+ carryover: bool = False,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
try:
@@ -185,6 +186,7 @@ def unplan_action_to_backlog(
tenant_id=ctx.tenant_id,
action_id=action_id,
user_id=ctx.user_id,
+ carryover=carryover,
)
except ValueError as exc:
detail = str(exc)
diff --git a/backend/routers/work_cycles.py b/backend/routers/work_cycles.py
index 0e918e6..4185d19 100644
--- a/backend/routers/work_cycles.py
+++ b/backend/routers/work_cycles.py
@@ -105,6 +105,47 @@ def complete_work_cycle(
raise HTTPException(status_code=400, detail=detail) from exc
+@initiative_router.get("/{work_cycle_id}/open-actions")
+def list_work_cycle_open_actions(
+ initiative_id: str,
+ work_cycle_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
+):
+ try:
+ work_cycle_service.validate_work_cycle_in_initiative(
+ tenant_id=ctx.tenant_id,
+ initiative_id=initiative_id,
+ work_cycle_id=work_cycle_id,
+ )
+ return work_cycle_service.list_open_actions_in_work_cycle(
+ tenant_id=ctx.tenant_id,
+ initiative_id=initiative_id,
+ work_cycle_id=work_cycle_id,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@initiative_router.post("/{work_cycle_id}/reactivate")
+def reactivate_work_cycle(
+ initiative_id: str,
+ work_cycle_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ try:
+ return work_cycle_service.reactivate_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"):
+ 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/actions.py b/backend/services/actions.py
index 4e36dd0..323c3f3 100644
--- a/backend/services/actions.py
+++ b/backend/services/actions.py
@@ -31,6 +31,9 @@ _ACTION_COLUMNS = """
ACTION_KINDS = frozenset({"delivery", "planning", "review", "bug", "issue"})
UNPLAN_ALLOWED_STATUSES = frozenset({"open", "ready"})
+CARRYOVER_UNPLAN_STATUSES = frozenset(
+ {"open", "ready", "in_progress", "blocked", "review_required"}
+)
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
@@ -610,14 +613,18 @@ def unplan_action_to_backlog(
tenant_id: str,
action_id: str,
user_id: Optional[str] = None,
+ carryover: bool = False,
) -> dict[str, Any]:
"""Entfernt ein Arbeitspaket aus dem Sprint und stellt es im Eingang wieder her."""
existing = get_action(tenant_id=tenant_id, action_id=action_id)
if not existing:
raise ValueError("Arbeitspaket nicht gefunden")
- if existing["status"] not in UNPLAN_ALLOWED_STATUSES:
+ allowed = CARRYOVER_UNPLAN_STATUSES if carryover else UNPLAN_ALLOWED_STATUSES
+ if existing["status"] not in allowed:
raise ValueError(
- "Nur offene oder bereite Arbeitspakete können zurück in den Eingang"
+ "Arbeitspaket kann in diesem Status nicht zurück in den Eingang geschoben werden"
+ if carryover
+ else "Nur offene oder bereite Arbeitspakete können zurück in den Eingang"
)
initiative_id = existing["initiative_id"]
diff --git a/backend/services/work_cycle.py b/backend/services/work_cycle.py
index 197e90e..e64e8e5 100644
--- a/backend/services/work_cycle.py
+++ b/backend/services/work_cycle.py
@@ -231,17 +231,106 @@ def complete_work_cycle(
def count_actions_in_work_cycle(
*, tenant_id: str, initiative_id: str, work_cycle_id: str
) -> int:
+ return len(
+ list_open_actions_in_work_cycle(
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ work_cycle_id=work_cycle_id,
+ )
+ )
+
+
+def list_open_actions_in_work_cycle(
+ *, tenant_id: str, initiative_id: str, work_cycle_id: str
+) -> list[dict[str, Any]]:
conn = get_connection()
try:
- with conn.cursor() as cur:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
- SELECT COUNT(*) FROM actions
+ SELECT id, title, status, priority, action_kind, work_cycle_id
+ FROM actions
WHERE tenant_id = %s AND initiative_id = %s AND work_cycle_id = %s
AND status NOT IN ('done', 'discarded')
+ ORDER BY sort_order ASC, title ASC
""",
(tenant_id, initiative_id, work_cycle_id),
)
- return int(cur.fetchone()[0])
+ rows = [dict(row) for row in cur.fetchall()]
finally:
conn.close()
+ for row in rows:
+ for key in ("id", "work_cycle_id"):
+ if row.get(key):
+ row[key] = str(row[key])
+ return rows
+
+
+def reactivate_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(
+ """
+ SELECT status FROM roadmap_items
+ WHERE id = %s AND tenant_id = %s AND item_type = %s
+ """,
+ (work_cycle_id, tenant_id, WORK_CYCLE_TYPE),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise ValueError("Sprint nicht gefunden")
+ if row["status"] != "reached":
+ raise ValueError("Nur abgeschlossene Sprints können reaktiviert werden")
+
+ cur.execute(
+ """
+ UPDATE roadmap_items ri
+ SET status = 'planned', updated_at = NOW()
+ FROM roadmaps r
+ WHERE ri.roadmap_id = r.id
+ AND r.initiative_id = %s AND ri.tenant_id = %s
+ AND ri.item_type = %s AND ri.status = 'active'
+ AND ri.id <> %s
+ """,
+ (initiative_id, tenant_id, WORK_CYCLE_TYPE, work_cycle_id),
+ )
+ cur.execute(
+ """
+ UPDATE roadmap_items
+ SET status = 'active', updated_at = NOW()
+ WHERE id = %s AND tenant_id = %s
+ AND item_type = %s AND status = 'reached'
+ RETURNING id, title, goal_description, status, target_date, sort_order, item_type
+ """,
+ (work_cycle_id, tenant_id, WORK_CYCLE_TYPE),
+ )
+ updated = cur.fetchone()
+ if not updated:
+ raise ValueError("Sprint konnte nicht reaktiviert werden")
+ conn.commit()
+ finally:
+ conn.close()
+
+ result = _serialize_cycle({**dict(updated), "initiative_id": initiative_id})
+ log_audit(
+ "work_cycle.reactivated",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"work_cycle_id": work_cycle_id, "initiative_id": initiative_id},
+ )
+ return result
diff --git a/backend/tests/test_ap22e_sprint_planning_lifecycle.py b/backend/tests/test_ap22e_sprint_planning_lifecycle.py
index d414b2b..809a510 100644
--- a/backend/tests/test_ap22e_sprint_planning_lifecycle.py
+++ b/backend/tests/test_ap22e_sprint_planning_lifecycle.py
@@ -170,3 +170,77 @@ def test_complete_sprint_reports_open_actions(client):
assert completed.status_code == 200
assert completed.json()["status"] == "reached"
assert completed.json()["open_action_count_at_completion"] == 1
+
+
+def test_reactivate_completed_sprint(client):
+ user = provision_user_in_tenant(tenant_role="admin")
+ token = _login(client, user)
+
+ created = _create_initiative(
+ client,
+ token,
+ title="Reactivate Sprint",
+ 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"
+
+ reactivated = client.post(
+ f"/api/initiatives/{initiative_id}/work-cycles/{cycle_id}/reactivate",
+ headers=_auth(token),
+ )
+ assert reactivated.status_code == 200
+ assert reactivated.json()["status"] == "active"
+
+ active = client.get(
+ f"/api/initiatives/{initiative_id}/work-cycles/active",
+ headers=_auth(token),
+ )
+ assert active.status_code == 200
+ assert active.json()["id"] == cycle_id
+
+
+def test_carryover_unplan_allows_in_progress(client):
+ user = provision_user_in_tenant(tenant_role="admin")
+ token = _login(client, user)
+
+ created = _create_initiative(
+ client,
+ token,
+ title="Carryover Unplan",
+ archetype_key="initiative.product",
+ )
+ initiative_id = created.json()["id"]
+
+ action = client.post(
+ f"/api/initiatives/{initiative_id}/actions",
+ json={"title": "In Arbeit", "status": "in_progress"},
+ headers=_auth(token),
+ )
+ action_id = action.json()["id"]
+
+ unplan = client.post(
+ f"/api/actions/{action_id}/unplan-to-backlog?carryover=true",
+ headers=_auth(token),
+ )
+ assert unplan.status_code == 200
+ assert unplan.json()["backlog_item"]["status"] == "accepted"
+
+ normal_unplan = client.post(
+ f"/api/actions/{action_id}/unplan-to-backlog",
+ headers=_auth(token),
+ )
+ assert normal_unplan.status_code in (400, 404)
diff --git a/frontend/src/api/actions.js b/frontend/src/api/actions.js
index 8a7fac8..24babb1 100644
--- a/frontend/src/api/actions.js
+++ b/frontend/src/api/actions.js
@@ -22,8 +22,9 @@ export function deleteAction(id) {
return apiFetch(`/api/actions/${id}`, { method: 'DELETE' })
}
-export function unplanActionToBacklog(id) {
- return apiFetch(`/api/actions/${id}/unplan-to-backlog`, { method: 'POST' })
+export function unplanActionToBacklog(id, { carryover = false } = {}) {
+ const query = carryover ? '?carryover=true' : ''
+ return apiFetch(`/api/actions/${id}/unplan-to-backlog${query}`, { method: 'POST' })
}
export function listMyOpenActions() {
diff --git a/frontend/src/api/workCycles.js b/frontend/src/api/workCycles.js
index dd53e90..94c0ffa 100644
--- a/frontend/src/api/workCycles.js
+++ b/frontend/src/api/workCycles.js
@@ -26,3 +26,9 @@ export function activateWorkCycle(initiativeId, workCycleId) {
method: 'POST',
})
}
+
+export function reactivateWorkCycle(initiativeId, workCycleId) {
+ return apiFetch(`/api/initiatives/${initiativeId}/work-cycles/${workCycleId}/reactivate`, {
+ method: 'POST',
+ })
+}
diff --git a/frontend/src/components/SprintCompleteDialog.jsx b/frontend/src/components/SprintCompleteDialog.jsx
new file mode 100644
index 0000000..30940f9
--- /dev/null
+++ b/frontend/src/components/SprintCompleteDialog.jsx
@@ -0,0 +1,258 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Modal } from './Modal.jsx'
+import { StatusBadge } from './StatusBadge.jsx'
+import { listCarryoverTargetSprints } from '../utils/workCycleActions.js'
+
+const DISPOSITION = {
+ BACKLOG: 'backlog',
+ MOVE: 'move',
+ KEEP: 'keep',
+}
+
+/**
+ * Sprint Review & Abschluss — offene APs vor Complete behandeln (Scrum Carryover).
+ */
+export function SprintCompleteDialog({
+ open,
+ cycle,
+ openActions = [],
+ workCycles = [],
+ busy = false,
+ onClose,
+ onConfirm,
+}) {
+ const targetSprints = useMemo(
+ () => listCarryoverTargetSprints(workCycles, cycle?.id),
+ [workCycles, cycle?.id],
+ )
+
+ const [dispositions, setDispositions] = useState({})
+ const [bulkTargetCycleId, setBulkTargetCycleId] = useState('')
+ const [validationError, setValidationError] = useState('')
+
+ useEffect(() => {
+ if (!open || !openActions.length) return
+ const initial = {}
+ for (const action of openActions) {
+ initial[action.id] = {
+ disposition: DISPOSITION.BACKLOG,
+ targetCycleId: targetSprints[0]?.id || '',
+ }
+ }
+ setDispositions(initial)
+ setBulkTargetCycleId(targetSprints[0]?.id || '')
+ setValidationError('')
+ }, [open, openActions, targetSprints])
+
+ function setDisposition(actionId, patch) {
+ setDispositions((prev) => ({
+ ...prev,
+ [actionId]: { ...prev[actionId], ...patch },
+ }))
+ setValidationError('')
+ }
+
+ function applyBulkBacklog() {
+ setDispositions((prev) => {
+ const next = { ...prev }
+ for (const action of openActions) {
+ next[action.id] = { disposition: DISPOSITION.BACKLOG, targetCycleId: '' }
+ }
+ return next
+ })
+ }
+
+ function applyBulkMove() {
+ if (!bulkTargetCycleId) {
+ setValidationError('Bitte einen Ziel-Sprint für die Sammel-Verschiebung wählen.')
+ return
+ }
+ setDispositions((prev) => {
+ const next = { ...prev }
+ for (const action of openActions) {
+ next[action.id] = {
+ disposition: DISPOSITION.MOVE,
+ targetCycleId: bulkTargetCycleId,
+ }
+ }
+ return next
+ })
+ setValidationError('')
+ }
+
+ async function handleSubmit(e) {
+ e.preventDefault()
+ const keepCount = openActions.filter(
+ (a) => dispositions[a.id]?.disposition === DISPOSITION.KEEP,
+ ).length
+ const moveMissing = openActions.some(
+ (a) =>
+ dispositions[a.id]?.disposition === DISPOSITION.MOVE &&
+ !dispositions[a.id]?.targetCycleId,
+ )
+ if (moveMissing) {
+ setValidationError('Für „Anderer Sprint" muss ein Ziel-Sprint gewählt sein.')
+ return
+ }
+ if (keepCount > 0) {
+ const ok = window.confirm(
+ `${keepCount} Arbeitspaket${keepCount === 1 ? '' : 'e'} bleiben am abgeschlossenen Sprint. ` +
+ 'Das entspricht nicht dem üblichen Scrum-Carryover. Trotzdem abschließen?',
+ )
+ if (!ok) return
+ }
+ await onConfirm(dispositions)
+ }
+
+ if (!cycle) return null
+
+ return (
+
- Sprint anklicken, um den geplanten Sprint-Backlog zu sehen — aktiv oder geplant. - Committete Arbeitspakete aus dem Eingang erscheinen beim gewählten Sprint. - Lebenszyklus: Planung → Ausführung → Abschluss (Scrum). + Scrum-Lebenszyklus: Sprint Planning → Sprint → Sprint Review → Retro & Done. + Beim Abschluss werden offene Arbeitspakete in den Eingang oder einen anderen Sprint + überführt.