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 Review (Scrum): Inkrement prüfen und offene Arbeitspakete vor dem Abschluss + in den Eingang oder einen anderen Sprint überführen. +

+ + {openActions.length === 0 ? ( +

Alle Arbeitspakete sind erledigt — Sprint kann abgeschlossen werden.

+ ) : ( + <> +
+ + {openActions.length} offene Arbeitspaket + {openActions.length === 1 ? '' : 'e'} + +

+ Nicht erledigte APs sollten vor dem Abschluss umgeplant werden. +

+
+ +
+ + +
+ + + + )} + + {validationError &&

{validationError}

} + +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/WorkCyclesPanel.jsx b/frontend/src/components/WorkCyclesPanel.jsx index ed62952..2775fb7 100644 --- a/frontend/src/components/WorkCyclesPanel.jsx +++ b/frontend/src/components/WorkCyclesPanel.jsx @@ -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({

Sprint

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

@@ -139,8 +165,11 @@ export function WorkCyclesPanel({ {apCount} AP{apCount === 1 ? '' : 's'} -
- {SPRINT_LIFECYCLE.map((phase, index) => ( +
+ {SCRUM_SPRINT_PHASES.map((phase, index) => ( - {phase.label} - {index < SPRINT_LIFECYCLE.length - 1 && ( + {phase.label} + {index < SCRUM_SPRINT_PHASES.length - 1 && ( @@ -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 )} {canManage && cycle.status !== 'active' && cycle.status !== 'reached' && ( @@ -183,12 +213,32 @@ export function WorkCyclesPanel({ Aktivieren )} + {canManage && cycle.status === 'reached' && typeof onReactivate === 'function' && ( + + )}
) })} )} + + ) } diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx index ae346c7..c8ca673 100644 --- a/frontend/src/context/InitiativeOperationsContext.jsx +++ b/frontend/src/context/InitiativeOperationsContext.jsx @@ -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, diff --git a/frontend/src/pages/modes/PlanSprintPage.jsx b/frontend/src/pages/modes/PlanSprintPage.jsx index f834a8e..f0316a9 100644 --- a/frontend/src/pages/modes/PlanSprintPage.jsx +++ b/frontend/src/pages/modes/PlanSprintPage.jsx @@ -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} /> diff --git a/frontend/src/styles/program-chrome.css b/frontend/src/styles/program-chrome.css index eab49bb..1d4a0ac 100644 --- a/frontend/src/styles/program-chrome.css +++ b/frontend/src/styles/program-chrome.css @@ -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; diff --git a/frontend/src/utils/workCycleActions.js b/frontend/src/utils/workCycleActions.js index c075cd4..c432d10 100644 --- a/frontend/src/utils/workCycleActions.js +++ b/frontend/src/utils/workCycleActions.js @@ -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 }) +} diff --git a/frontend/src/utils/workCycleActions.test.js b/frontend/src/utils/workCycleActions.test.js index efc4127..803468b 100644 --- a/frontend/src/utils/workCycleActions.test.js +++ b/frontend/src/utils/workCycleActions.test.js @@ -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']) + }) })