AP2.2d: Sprint-Planung, Abschluss und Product-Fallback.
Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 2m56s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Has been cancelled

Backlog committet in gewaehlten Sprint, Sprint complete-Endpoint, continuous_product-Ansicht ohne aktiven Sprint.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-12 19:21:49 +02:00
parent 09826d146e
commit f6aba94864
11 changed files with 422 additions and 17 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<section className="card backlog-section">
<div className="section-header">
@ -122,6 +195,45 @@ export function BacklogSection({
)}
</div>
{showSprintPlanning && canManage && acceptedItems.length > 0 && (
<div className="backlog-sprint-planning card-list-item">
<label className="backlog-sprint-planning__field">
<span className="muted">Sprint-Ziel</span>
<select
value={sprintTargetId}
onChange={(e) => setSprintTargetId(e.target.value)}
disabled={busy}
>
{planableSprints.map((cycle) => (
<option key={cycle.id} value={cycle.id}>
{cycle.title}
{cycle.status === 'active' ? ' (aktiv)' : ''}
</option>
))}
</select>
</label>
{selectedAccepted.length > 0 && (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busy || !sprintTargetId}
onClick={handleBulkPlan}
>
{selectedAccepted.length} Item{selectedAccepted.length === 1 ? '' : 's'} {convertLabel}
</button>
)}
<p className="muted backlog-sprint-planning__hint">
Items markieren und in den gewählten Sprint committen Planung unter Plan Sprint.
</p>
</div>
)}
{sprintPlanningEnabled && canManage && acceptedItems.length > 0 && planableSprints.length === 0 && (
<p className="muted backlog-sprint-planning__hint">
Lege zuerst einen Sprint unter Plan Sprint an, um Backlog-Items zu planen.
</p>
)}
{items.length === 0 && <EmptyState message="Backlog ist leer." />}
<ul className="item-list backlog-reorder-list">
@ -150,6 +262,17 @@ export function BacklogSection({
</span>
)}
{showSprintPlanning && canManage && item.status === 'accepted' && (
<label className="backlog-select-checkbox">
<input
type="checkbox"
checked={selectedIds.has(item.id)}
onChange={() => toggleSelected(item.id)}
disabled={busy}
aria-label={`${item.title} für Sprint-Planung markieren`}
/>
</label>
)}
<button
type="button"
className="list-item-main list-item-main--clickable"
@ -185,10 +308,10 @@ export function BacklogSection({
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => onConvert(item.id)}
disabled={busy}
onClick={() => handleSingleConvert(item.id)}
disabled={busy || (showSprintPlanning && !sprintTargetId)}
>
In Arbeitspaket umwandeln
{convertLabel}
</button>
)}
<button

View File

@ -8,6 +8,7 @@ export function WorkCyclesPanel({
canManage,
onCreate,
onActivate,
onComplete,
busy = false,
}) {
const [title, setTitle] = useState('')
@ -100,7 +101,17 @@ export function WorkCyclesPanel({
<p className="muted item-meta">{cycle.goal_description}</p>
)}
</div>
{canManage && cycle.status !== 'active' && (
{canManage && cycle.status === 'active' && typeof onComplete === 'function' && (
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => onComplete(cycle.id)}
>
Abschließen
</button>
)}
{canManage && cycle.status !== 'active' && cycle.status !== 'reached' && (
<button
type="button"
className="btn btn-secondary btn-sm"

View File

@ -76,6 +76,7 @@ import {
getActiveWorkCycle,
createWorkCycle,
activateWorkCycle,
completeWorkCycle,
} from '../api/workCycles.js'
import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js'
@ -420,10 +421,31 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
}
}
async function handleConvertBacklog(itemId) {
async function handleConvertBacklog(itemId, options = {}) {
setFormBusy(true)
try {
await convertBacklogToAction(itemId, { assign_active_sprint: true })
await convertBacklogToAction(itemId, {
assign_active_sprint: options.work_cycle_id ? false : options.assign_active_sprint ?? true,
work_cycle_id: options.work_cycle_id,
})
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleBulkConvertBacklog(itemIds, options = {}) {
if (!itemIds.length) return
setFormBusy(true)
try {
for (const itemId of itemIds) {
await convertBacklogToAction(itemId, {
assign_active_sprint: options.work_cycle_id ? false : options.assign_active_sprint ?? true,
work_cycle_id: options.work_cycle_id,
})
}
await load()
} catch (err) {
setError(err.message)
@ -456,6 +478,18 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
}
}
async function handleCompleteWorkCycle(workCycleId) {
setFormBusy(true)
try {
await completeWorkCycle(id, workCycleId)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleDeleteBacklog(itemId) {
try {
await deleteBacklogItem(itemId)
@ -821,8 +855,10 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
handleBacklogGate,
handleBacklogStatus,
handleConvertBacklog,
handleBulkConvertBacklog,
handleCreateWorkCycle,
handleActivateWorkCycle,
handleCompleteWorkCycle,
handleDeleteBacklog,
handleCreateRoadmapItem,
handleRoadmapItemStatus,

View File

@ -4,8 +4,11 @@ import { LoadingState } from '../../components/LoadingState.jsx'
export function InitiativeInboxPage() {
const {
initiative,
backlogItems,
roadmapItems,
workCycles,
activeWorkCycle,
capabilities,
formBusy,
error,
@ -14,6 +17,7 @@ export function InitiativeInboxPage() {
handleUpdateBacklog,
handleReorderBacklog,
handleConvertBacklog,
handleBulkConvertBacklog,
handleDeleteBacklog,
} = useInitiativeOperations()
@ -25,17 +29,23 @@ export function InitiativeInboxPage() {
return <LoadingState message="Lade Eingang …" />
}
const isProductArchetype = initiative?.archetype_key === 'initiative.product'
return (
<>
{error && <p className="error">{error}</p>}
<BacklogSection
items={backlogItems}
roadmapItems={roadmapItems}
workCycles={workCycles}
activeWorkCycle={activeWorkCycle}
sprintPlanningEnabled={isProductArchetype}
canManage={capabilities.has('kairo.backlog.manage')}
onCreate={handleCreateBacklog}
onUpdate={handleUpdateBacklog}
onReorder={handleReorderBacklog}
onConvert={handleConvertBacklog}
onBulkConvert={handleBulkConvertBacklog}
onDelete={handleDeleteBacklog}
busy={formBusy}
/>

View File

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

View File

@ -41,6 +41,7 @@ function WorkSprintInner() {
initiative,
steeringSnapshot,
steeringSnapshotLoading,
visibleActions,
} = ops
const defaultWorkCycleId = activeWorkCycle?.id || ''
@ -66,16 +67,61 @@ function WorkSprintInner() {
{error && <p className="error">{error}</p>}
{!activeWorkCycle && isProductArchetype && (
<section className="card">
<EmptyState
message="Kein aktiver Sprint — lege unter Plan → Sprint einen Sprint an und aktiviere ihn."
<>
<section className="card sprint-context-banner">
<h2>Product-Ist (ohne aktiven Sprint)</h2>
<p className="section-lead muted">
Kein aktiver Sprint Kairo steuert nach{' '}
<strong>continuous_product</strong> (wirkungsvollster Schritt). Sprint-Planung
unter{' '}
<Link to={hrefWithScope('/plan/sprint')} className="link-inline">
Plan Sprint
</Link>
.
</p>
</section>
<NextActionWidget
scope="initiative"
initiativeId={initiativeId}
items={steeringSnapshot?.next_actions}
loading={steeringSnapshotLoading}
embedded
title="Nächster Schritt (Product)"
subtitle="Empfehlung ohne Sprint-Zeitbox — committete Arbeitspakete im Vorhaben."
/>
<p>
<Link to={hrefWithScope('/plan/sprint')} className="link-inline">
Zur Sprint-Planung
</Link>
</p>
</section>
<InitiativeActionsHub
initiativeId={initiativeId}
actions={visibleActions}
projects={projects}
roadmapItems={roadmapItems}
actionContextById={actionContextById}
hideDone={hideDone}
onHideDoneChange={setHideDone}
showForm={showActionForm}
onToggleForm={() => 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 && (

View File

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