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 && }