AP2.2f: Scrum Sprint-Review, Reaktivierung und Carryover beim Abschluss.
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m58s
Test Suite / lint-backend (push) Successful in 4s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 1m1s

Offene APs können beim Abschluss in den Eingang oder einen anderen Sprint überführt werden; abgeschlossene Sprints sind reaktivierbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-24 17:40:59 +02:00
parent e65838118c
commit 04d1dbf1f9
14 changed files with 719 additions and 31 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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"]

View File

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

View File

@ -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)

View File

@ -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() {

View File

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

View File

@ -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 (
<Modal
open={open}
title={`Sprint Review & Abschluss — ${cycle.title}`}
onClose={onClose}
size="lg"
>
<form className="form sprint-complete-dialog" onSubmit={handleSubmit}>
<p className="section-lead muted">
Sprint Review (Scrum): Inkrement prüfen und offene Arbeitspakete vor dem Abschluss
in den Eingang oder einen anderen Sprint überführen.
</p>
{openActions.length === 0 ? (
<p className="muted">Alle Arbeitspakete sind erledigt Sprint kann abgeschlossen werden.</p>
) : (
<>
<div className="sprint-complete-dialog__warning card-list-item">
<strong>
{openActions.length} offene Arbeitspaket
{openActions.length === 1 ? '' : 'e'}
</strong>
<p className="muted">
Nicht erledigte APs sollten vor dem Abschluss umgeplant werden.
</p>
</div>
<div className="sprint-complete-dialog__bulk">
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={applyBulkBacklog}
>
Alle Eingang
</button>
<label className="sprint-complete-dialog__bulk-move">
<span className="muted">Alle Sprint</span>
<select
value={bulkTargetCycleId}
onChange={(e) => setBulkTargetCycleId(e.target.value)}
disabled={busy || targetSprints.length === 0}
>
<option value=""> Sprint wählen </option>
{targetSprints.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
{s.status === 'active' ? ' (aktiv)' : ''}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy || !bulkTargetCycleId}
onClick={applyBulkMove}
>
Anwenden
</button>
</label>
</div>
<ul className="item-list sprint-complete-dialog__list">
{openActions.map((action) => {
const row = dispositions[action.id] || {}
return (
<li key={action.id} className="list-item card-list-item sprint-complete-row">
<div className="sprint-complete-row__main">
<strong>{action.title}</strong>
<StatusBadge status={action.status} />
</div>
<div className="sprint-complete-row__choices">
<label className="radio-label">
<input
type="radio"
name={`disp-${action.id}`}
checked={row.disposition === DISPOSITION.BACKLOG}
onChange={() =>
setDisposition(action.id, { disposition: DISPOSITION.BACKLOG })
}
disabled={busy}
/>
In Eingang
</label>
<label className="radio-label">
<input
type="radio"
name={`disp-${action.id}`}
checked={row.disposition === DISPOSITION.MOVE}
onChange={() =>
setDisposition(action.id, {
disposition: DISPOSITION.MOVE,
targetCycleId: row.targetCycleId || bulkTargetCycleId,
})
}
disabled={busy || targetSprints.length === 0}
/>
Anderer Sprint
</label>
{row.disposition === DISPOSITION.MOVE && (
<select
value={row.targetCycleId || ''}
onChange={(e) =>
setDisposition(action.id, { targetCycleId: e.target.value })
}
disabled={busy}
aria-label={`Ziel-Sprint für ${action.title}`}
>
<option value=""> Sprint </option>
{targetSprints.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
)}
<label className="radio-label">
<input
type="radio"
name={`disp-${action.id}`}
checked={row.disposition === DISPOSITION.KEEP}
onChange={() =>
setDisposition(action.id, { disposition: DISPOSITION.KEEP })
}
disabled={busy}
/>
Am Sprint belassen
</label>
</div>
</li>
)
})}
</ul>
</>
)}
{validationError && <p className="error">{validationError}</p>}
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Abschließen …' : 'Sprint abschließen'}
</button>
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={busy}>
Abbrechen
</button>
</div>
</form>
</Modal>
)
}

View File

@ -1,15 +1,19 @@
import { useState } from 'react'
import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
import { SprintCompleteDialog } from './SprintCompleteDialog.jsx'
import { listOpenActionsForWorkCycle } from '../utils/workCycleActions.js'
const SPRINT_LIFECYCLE = [
{ key: 'planned', label: 'Planung' },
{ key: 'active', label: 'Ausführung' },
{ key: 'reached', label: 'Abgeschlossen' },
/** Scrum Sprint-Phasen (UI — mapped auf work_cycle-Status). */
const SCRUM_SPRINT_PHASES = [
{ key: 'planning', label: 'Sprint Planning', hint: 'Backlog & Ziel' },
{ key: 'execution', label: 'Sprint', hint: 'Daily / Ausführung' },
{ key: 'review', label: 'Sprint Review', hint: 'Inkrement prüfen' },
{ key: 'done', label: 'Retro & Done', hint: 'Abschluss' },
]
function lifecycleStepIndex(status) {
if (status === 'reached') return 2
if (status === 'reached') return 3
if (status === 'active' || status === 'at_risk') return 1
return 0
}
@ -18,17 +22,25 @@ export function WorkCyclesPanel({
workCycles = [],
activeWorkCycle = null,
selectedSprintId = '',
actions = [],
actionCountByCycleId = {},
canManage,
onCreate,
onActivate,
onComplete,
onCompleteWithCarryover,
onReactivate,
onSelectSprint,
busy = false,
}) {
const [title, setTitle] = useState('')
const [goal, setGoal] = useState('')
const [activateOnCreate, setActivateOnCreate] = useState(true)
const [completeTarget, setCompleteTarget] = useState(null)
const completeOpenActions = completeTarget
? listOpenActionsForWorkCycle(actions, completeTarget.id)
: []
async function handleSubmit(e) {
e.preventDefault()
@ -43,16 +55,30 @@ export function WorkCyclesPanel({
setGoal('')
}
async function handleComplete(cycle) {
const openCount = actionCountByCycleId[cycle.id] || 0
if (openCount > 0) {
const ok = window.confirm(
`Sprint „${cycle.title}" hat noch ${openCount} offene Arbeitspaket${openCount === 1 ? '' : 'e'}. ` +
'Trotzdem abschließen? Offene APs bleiben dem Sprint zugeordnet und können in den Eingang zurückgeschoben werden.',
)
if (!ok) return
function openCompleteDialog(cycle) {
setCompleteTarget(cycle)
}
function closeCompleteDialog() {
if (!busy) setCompleteTarget(null)
}
async function handleCompleteConfirm(dispositions) {
if (!completeTarget) return
if (typeof onCompleteWithCarryover === 'function') {
await onCompleteWithCarryover(completeTarget.id, dispositions)
} else {
await onComplete(completeTarget.id)
}
await onComplete(cycle.id)
setCompleteTarget(null)
}
async function handleReactivate(cycleId) {
const ok = window.confirm(
'Sprint wieder aktivieren? Der Sprint wird erneut als laufend markiert (versehentlicher Abschluss).',
)
if (!ok) return
await onReactivate(cycleId)
}
return (
@ -61,9 +87,9 @@ export function WorkCyclesPanel({
<div>
<h2>Sprint</h2>
<p className="section-lead muted">
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.
</p>
</div>
</div>
@ -139,8 +165,11 @@ export function WorkCyclesPanel({
<span className="muted work-cycle-row__count">
{apCount} AP{apCount === 1 ? '' : 's'}
</span>
<div className="work-cycle-lifecycle muted" aria-label="Sprint-Lebenszyklus">
{SPRINT_LIFECYCLE.map((phase, index) => (
<div
className="work-cycle-lifecycle"
aria-label="Scrum Sprint-Lebenszyklus"
>
{SCRUM_SPRINT_PHASES.map((phase, index) => (
<span
key={phase.key}
className={
@ -148,9 +177,10 @@ export function WorkCyclesPanel({
(index === step ? ' work-cycle-lifecycle__step--current' : '') +
(index < step ? ' work-cycle-lifecycle__step--done' : '')
}
title={phase.hint}
>
{phase.label}
{index < SPRINT_LIFECYCLE.length - 1 && (
<span className="work-cycle-lifecycle__label">{phase.label}</span>
{index < SCRUM_SPRINT_PHASES.length - 1 && (
<span className="work-cycle-lifecycle__sep" aria-hidden="true">
</span>
@ -168,9 +198,9 @@ export function WorkCyclesPanel({
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => handleComplete(cycle)}
onClick={() => openCompleteDialog(cycle)}
>
Abschließen
Review & Abschließen
</button>
)}
{canManage && cycle.status !== 'active' && cycle.status !== 'reached' && (
@ -183,12 +213,32 @@ export function WorkCyclesPanel({
Aktivieren
</button>
)}
{canManage && cycle.status === 'reached' && typeof onReactivate === 'function' && (
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => handleReactivate(cycle.id)}
>
Reaktivieren
</button>
)}
</div>
</li>
)
})}
</ul>
)}
<SprintCompleteDialog
open={Boolean(completeTarget)}
cycle={completeTarget}
openActions={completeOpenActions}
workCycles={workCycles}
busy={busy}
onClose={closeCompleteDialog}
onConfirm={handleCompleteConfirm}
/>
</section>
)
}

View File

@ -77,6 +77,7 @@ import {
createWorkCycle,
activateWorkCycle,
completeWorkCycle,
reactivateWorkCycle,
} from '../api/workCycles.js'
import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js'
@ -587,6 +588,42 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
}
}
async function handleCompleteWorkCycleWithCarryover(workCycleId, dispositions) {
setFormBusy(true)
setError(null)
try {
for (const [actionId, item] of Object.entries(dispositions || {})) {
if (item.disposition === 'backlog') {
const result = await unplanActionToBacklog(actionId, { carryover: true })
applyUnplanResult(result)
} else if (item.disposition === 'move' && item.targetCycleId) {
const updated = await updateAction(actionId, { work_cycle_id: item.targetCycleId })
setActions((prev) => prev.map((a) => (a.id === actionId ? updated : a)))
}
}
await completeWorkCycle(id, workCycleId)
await load({ silent: true })
} catch (err) {
setError(err.message)
throw err
} finally {
setFormBusy(false)
}
}
async function handleReactivateWorkCycle(workCycleId) {
setFormBusy(true)
try {
await reactivateWorkCycle(id, workCycleId)
await load({ silent: true })
setSelectedWorkCycleId(workCycleId)
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleDeleteBacklog(itemId) {
try {
await deleteBacklogItem(itemId)
@ -962,6 +999,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
handleCreateWorkCycle,
handleActivateWorkCycle,
handleCompleteWorkCycle,
handleCompleteWorkCycleWithCarryover,
handleReactivateWorkCycle,
handleDeleteBacklog,
handleCreateRoadmapItem,
handleRoadmapItemStatus,

View File

@ -39,6 +39,8 @@ function PlanSprintInner() {
handleCreateWorkCycle,
handleActivateWorkCycle,
handleCompleteWorkCycle,
handleCompleteWorkCycleWithCarryover,
handleReactivateWorkCycle,
handleCreateAction,
handleUpdateAction,
handleQuickStatus,
@ -78,11 +80,14 @@ function PlanSprintInner() {
workCycles={workCycles}
activeWorkCycle={activeWorkCycle}
selectedSprintId={selectedWorkCycleId}
actions={actions}
actionCountByCycleId={actionCountByCycleId}
canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateWorkCycle}
onActivate={handleActivateWorkCycle}
onComplete={handleCompleteWorkCycle}
onCompleteWithCarryover={handleCompleteWorkCycleWithCarryover}
onReactivate={handleReactivateWorkCycle}
onSelectSprint={setSelectedWorkCycleId}
busy={formBusy}
/>

View File

@ -707,6 +707,87 @@
gap: 6px;
}
.work-cycle-lifecycle {
display: flex;
flex-wrap: wrap;
gap: 4px 2px;
margin-top: 6px;
font-size: 11px;
}
.work-cycle-lifecycle__step {
opacity: 0.45;
}
.work-cycle-lifecycle__step--current {
opacity: 1;
font-weight: 600;
color: var(--jk-primary);
}
.work-cycle-lifecycle__step--done {
opacity: 0.75;
}
.work-cycle-lifecycle__sep {
margin: 0 4px;
opacity: 0.5;
}
.sprint-complete-dialog__warning {
margin: 12px 0;
padding: 12px;
border-left: 3px solid var(--jk-warning, #c98200);
background: var(--jk-surface-muted, rgba(0, 0, 0, 0.03));
}
.sprint-complete-dialog__bulk {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.sprint-complete-dialog__bulk-move {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.sprint-complete-dialog__list {
margin: 0 0 16px;
}
.sprint-complete-row {
display: flex;
flex-direction: column;
gap: 8px;
}
.sprint-complete-row__main {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sprint-complete-row__choices {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
font-size: 13px;
}
.radio-label {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.backlog-sprint-setup {
margin-bottom: 16px;
padding: 12px;

View File

@ -25,3 +25,23 @@ export function countActionsByWorkCycle(actions, { hideDone = true } = {}) {
}
return counts
}
export const PLANNING_SPRINT_STATUSES = new Set(['planned', 'active', 'at_risk'])
/**
* @param {Array<{ id: string, status?: string }>} workCycles
* @param {string | undefined} excludeCycleId
*/
export function listCarryoverTargetSprints(workCycles, excludeCycleId) {
return (workCycles || []).filter(
(cycle) => cycle.id !== excludeCycleId && PLANNING_SPRINT_STATUSES.has(cycle.status),
)
}
/**
* @param {Array<{ id: string, work_cycle_id?: string, status?: string }>} actions
* @param {string | undefined} cycleId
*/
export function listOpenActionsForWorkCycle(actions, cycleId) {
return filterActionsForWorkCycle(actions, cycleId, { hideDone: true })
}

View File

@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { countActionsByWorkCycle, filterActionsForWorkCycle } from './workCycleActions.js'
import {
countActionsByWorkCycle,
filterActionsForWorkCycle,
listCarryoverTargetSprints,
listOpenActionsForWorkCycle,
} from './workCycleActions.js'
describe('workCycleActions', () => {
const actions = [
@ -18,4 +23,14 @@ describe('workCycleActions', () => {
it('counts actions per sprint', () => {
expect(countActionsByWorkCycle(actions)).toEqual({ s1: 1, s2: 1 })
})
it('lists open actions and carryover targets', () => {
expect(listOpenActionsForWorkCycle(actions, 's1').map((a) => a.id)).toEqual(['a1'])
const cycles = [
{ id: 's1', status: 'active' },
{ id: 's2', status: 'planned' },
{ id: 's3', status: 'reached' },
]
expect(listCarryoverTargetSprints(cycles, 's1').map((c) => c.id)).toEqual(['s2'])
})
})