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
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:
parent
e65838118c
commit
04d1dbf1f9
|
|
@ -178,6 +178,7 @@ def set_action_assignments(
|
||||||
@router.post("/{action_id}/unplan-to-backlog", status_code=200)
|
@router.post("/{action_id}/unplan-to-backlog", status_code=200)
|
||||||
def unplan_action_to_backlog(
|
def unplan_action_to_backlog(
|
||||||
action_id: str,
|
action_id: str,
|
||||||
|
carryover: bool = False,
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
|
@ -185,6 +186,7 @@ def unplan_action_to_backlog(
|
||||||
tenant_id=ctx.tenant_id,
|
tenant_id=ctx.tenant_id,
|
||||||
action_id=action_id,
|
action_id=action_id,
|
||||||
user_id=ctx.user_id,
|
user_id=ctx.user_id,
|
||||||
|
carryover=carryover,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
detail = str(exc)
|
detail = str(exc)
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,47 @@ def complete_work_cycle(
|
||||||
raise HTTPException(status_code=400, detail=detail) from exc
|
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")
|
@initiative_router.post("/{work_cycle_id}/activate")
|
||||||
def activate_work_cycle(
|
def activate_work_cycle(
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ _ACTION_COLUMNS = """
|
||||||
|
|
||||||
ACTION_KINDS = frozenset({"delivery", "planning", "review", "bug", "issue"})
|
ACTION_KINDS = frozenset({"delivery", "planning", "review", "bug", "issue"})
|
||||||
UNPLAN_ALLOWED_STATUSES = frozenset({"open", "ready"})
|
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]:
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|
@ -610,14 +613,18 @@ def unplan_action_to_backlog(
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
action_id: str,
|
action_id: str,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
|
carryover: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Entfernt ein Arbeitspaket aus dem Sprint und stellt es im Eingang wieder her."""
|
"""Entfernt ein Arbeitspaket aus dem Sprint und stellt es im Eingang wieder her."""
|
||||||
existing = get_action(tenant_id=tenant_id, action_id=action_id)
|
existing = get_action(tenant_id=tenant_id, action_id=action_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise ValueError("Arbeitspaket nicht gefunden")
|
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(
|
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"]
|
initiative_id = existing["initiative_id"]
|
||||||
|
|
|
||||||
|
|
@ -231,17 +231,106 @@ def complete_work_cycle(
|
||||||
def count_actions_in_work_cycle(
|
def count_actions_in_work_cycle(
|
||||||
*, tenant_id: str, initiative_id: str, work_cycle_id: str
|
*, tenant_id: str, initiative_id: str, work_cycle_id: str
|
||||||
) -> int:
|
) -> 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()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute(
|
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
|
WHERE tenant_id = %s AND initiative_id = %s AND work_cycle_id = %s
|
||||||
AND status NOT IN ('done', 'discarded')
|
AND status NOT IN ('done', 'discarded')
|
||||||
|
ORDER BY sort_order ASC, title ASC
|
||||||
""",
|
""",
|
||||||
(tenant_id, initiative_id, work_cycle_id),
|
(tenant_id, initiative_id, work_cycle_id),
|
||||||
)
|
)
|
||||||
return int(cur.fetchone()[0])
|
rows = [dict(row) for row in cur.fetchall()]
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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
|
||||||
|
|
|
||||||
|
|
@ -170,3 +170,77 @@ def test_complete_sprint_reports_open_actions(client):
|
||||||
assert completed.status_code == 200
|
assert completed.status_code == 200
|
||||||
assert completed.json()["status"] == "reached"
|
assert completed.json()["status"] == "reached"
|
||||||
assert completed.json()["open_action_count_at_completion"] == 1
|
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)
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,9 @@ export function deleteAction(id) {
|
||||||
return apiFetch(`/api/actions/${id}`, { method: 'DELETE' })
|
return apiFetch(`/api/actions/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unplanActionToBacklog(id) {
|
export function unplanActionToBacklog(id, { carryover = false } = {}) {
|
||||||
return apiFetch(`/api/actions/${id}/unplan-to-backlog`, { method: 'POST' })
|
const query = carryover ? '?carryover=true' : ''
|
||||||
|
return apiFetch(`/api/actions/${id}/unplan-to-backlog${query}`, { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listMyOpenActions() {
|
export function listMyOpenActions() {
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,9 @@ export function activateWorkCycle(initiativeId, workCycleId) {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reactivateWorkCycle(initiativeId, workCycleId) {
|
||||||
|
return apiFetch(`/api/initiatives/${initiativeId}/work-cycles/${workCycleId}/reactivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
258
frontend/src/components/SprintCompleteDialog.jsx
Normal file
258
frontend/src/components/SprintCompleteDialog.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { StatusBadge } from './StatusBadge.jsx'
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
import { SprintCompleteDialog } from './SprintCompleteDialog.jsx'
|
||||||
|
import { listOpenActionsForWorkCycle } from '../utils/workCycleActions.js'
|
||||||
|
|
||||||
const SPRINT_LIFECYCLE = [
|
/** Scrum Sprint-Phasen (UI — mapped auf work_cycle-Status). */
|
||||||
{ key: 'planned', label: 'Planung' },
|
const SCRUM_SPRINT_PHASES = [
|
||||||
{ key: 'active', label: 'Ausführung' },
|
{ key: 'planning', label: 'Sprint Planning', hint: 'Backlog & Ziel' },
|
||||||
{ key: 'reached', label: 'Abgeschlossen' },
|
{ 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) {
|
function lifecycleStepIndex(status) {
|
||||||
if (status === 'reached') return 2
|
if (status === 'reached') return 3
|
||||||
if (status === 'active' || status === 'at_risk') return 1
|
if (status === 'active' || status === 'at_risk') return 1
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
@ -18,17 +22,25 @@ export function WorkCyclesPanel({
|
||||||
workCycles = [],
|
workCycles = [],
|
||||||
activeWorkCycle = null,
|
activeWorkCycle = null,
|
||||||
selectedSprintId = '',
|
selectedSprintId = '',
|
||||||
|
actions = [],
|
||||||
actionCountByCycleId = {},
|
actionCountByCycleId = {},
|
||||||
canManage,
|
canManage,
|
||||||
onCreate,
|
onCreate,
|
||||||
onActivate,
|
onActivate,
|
||||||
onComplete,
|
onComplete,
|
||||||
|
onCompleteWithCarryover,
|
||||||
|
onReactivate,
|
||||||
onSelectSprint,
|
onSelectSprint,
|
||||||
busy = false,
|
busy = false,
|
||||||
}) {
|
}) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [goal, setGoal] = useState('')
|
const [goal, setGoal] = useState('')
|
||||||
const [activateOnCreate, setActivateOnCreate] = useState(true)
|
const [activateOnCreate, setActivateOnCreate] = useState(true)
|
||||||
|
const [completeTarget, setCompleteTarget] = useState(null)
|
||||||
|
|
||||||
|
const completeOpenActions = completeTarget
|
||||||
|
? listOpenActionsForWorkCycle(actions, completeTarget.id)
|
||||||
|
: []
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -43,16 +55,30 @@ export function WorkCyclesPanel({
|
||||||
setGoal('')
|
setGoal('')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleComplete(cycle) {
|
function openCompleteDialog(cycle) {
|
||||||
const openCount = actionCountByCycleId[cycle.id] || 0
|
setCompleteTarget(cycle)
|
||||||
if (openCount > 0) {
|
}
|
||||||
const ok = window.confirm(
|
|
||||||
`Sprint „${cycle.title}" hat noch ${openCount} offene Arbeitspaket${openCount === 1 ? '' : 'e'}. ` +
|
function closeCompleteDialog() {
|
||||||
'Trotzdem abschließen? Offene APs bleiben dem Sprint zugeordnet und können in den Eingang zurückgeschoben werden.',
|
if (!busy) setCompleteTarget(null)
|
||||||
)
|
}
|
||||||
if (!ok) return
|
|
||||||
|
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 (
|
return (
|
||||||
|
|
@ -61,9 +87,9 @@ export function WorkCyclesPanel({
|
||||||
<div>
|
<div>
|
||||||
<h2>Sprint</h2>
|
<h2>Sprint</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Sprint anklicken, um den geplanten Sprint-Backlog zu sehen — aktiv oder geplant.
|
Scrum-Lebenszyklus: Sprint Planning → Sprint → Sprint Review → Retro & Done.
|
||||||
Committete Arbeitspakete aus dem Eingang erscheinen beim gewählten Sprint.
|
Beim Abschluss werden offene Arbeitspakete in den Eingang oder einen anderen Sprint
|
||||||
Lebenszyklus: Planung → Ausführung → Abschluss (Scrum).
|
überführt.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -139,8 +165,11 @@ export function WorkCyclesPanel({
|
||||||
<span className="muted work-cycle-row__count">
|
<span className="muted work-cycle-row__count">
|
||||||
{apCount} AP{apCount === 1 ? '' : 's'}
|
{apCount} AP{apCount === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
<div className="work-cycle-lifecycle muted" aria-label="Sprint-Lebenszyklus">
|
<div
|
||||||
{SPRINT_LIFECYCLE.map((phase, index) => (
|
className="work-cycle-lifecycle"
|
||||||
|
aria-label="Scrum Sprint-Lebenszyklus"
|
||||||
|
>
|
||||||
|
{SCRUM_SPRINT_PHASES.map((phase, index) => (
|
||||||
<span
|
<span
|
||||||
key={phase.key}
|
key={phase.key}
|
||||||
className={
|
className={
|
||||||
|
|
@ -148,9 +177,10 @@ export function WorkCyclesPanel({
|
||||||
(index === step ? ' work-cycle-lifecycle__step--current' : '') +
|
(index === step ? ' work-cycle-lifecycle__step--current' : '') +
|
||||||
(index < step ? ' work-cycle-lifecycle__step--done' : '')
|
(index < step ? ' work-cycle-lifecycle__step--done' : '')
|
||||||
}
|
}
|
||||||
|
title={phase.hint}
|
||||||
>
|
>
|
||||||
{phase.label}
|
<span className="work-cycle-lifecycle__label">{phase.label}</span>
|
||||||
{index < SPRINT_LIFECYCLE.length - 1 && (
|
{index < SCRUM_SPRINT_PHASES.length - 1 && (
|
||||||
<span className="work-cycle-lifecycle__sep" aria-hidden="true">
|
<span className="work-cycle-lifecycle__sep" aria-hidden="true">
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -168,9 +198,9 @@ export function WorkCyclesPanel({
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => handleComplete(cycle)}
|
onClick={() => openCompleteDialog(cycle)}
|
||||||
>
|
>
|
||||||
Abschließen
|
Review & Abschließen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canManage && cycle.status !== 'active' && cycle.status !== 'reached' && (
|
{canManage && cycle.status !== 'active' && cycle.status !== 'reached' && (
|
||||||
|
|
@ -183,12 +213,32 @@ export function WorkCyclesPanel({
|
||||||
Aktivieren
|
Aktivieren
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SprintCompleteDialog
|
||||||
|
open={Boolean(completeTarget)}
|
||||||
|
cycle={completeTarget}
|
||||||
|
openActions={completeOpenActions}
|
||||||
|
workCycles={workCycles}
|
||||||
|
busy={busy}
|
||||||
|
onClose={closeCompleteDialog}
|
||||||
|
onConfirm={handleCompleteConfirm}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ import {
|
||||||
createWorkCycle,
|
createWorkCycle,
|
||||||
activateWorkCycle,
|
activateWorkCycle,
|
||||||
completeWorkCycle,
|
completeWorkCycle,
|
||||||
|
reactivateWorkCycle,
|
||||||
} from '../api/workCycles.js'
|
} from '../api/workCycles.js'
|
||||||
import { useCapabilities } from '../hooks/useCapabilities.js'
|
import { useCapabilities } from '../hooks/useCapabilities.js'
|
||||||
import { useActors } from '../hooks/useActors.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) {
|
async function handleDeleteBacklog(itemId) {
|
||||||
try {
|
try {
|
||||||
await deleteBacklogItem(itemId)
|
await deleteBacklogItem(itemId)
|
||||||
|
|
@ -962,6 +999,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
handleCreateWorkCycle,
|
handleCreateWorkCycle,
|
||||||
handleActivateWorkCycle,
|
handleActivateWorkCycle,
|
||||||
handleCompleteWorkCycle,
|
handleCompleteWorkCycle,
|
||||||
|
handleCompleteWorkCycleWithCarryover,
|
||||||
|
handleReactivateWorkCycle,
|
||||||
handleDeleteBacklog,
|
handleDeleteBacklog,
|
||||||
handleCreateRoadmapItem,
|
handleCreateRoadmapItem,
|
||||||
handleRoadmapItemStatus,
|
handleRoadmapItemStatus,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ function PlanSprintInner() {
|
||||||
handleCreateWorkCycle,
|
handleCreateWorkCycle,
|
||||||
handleActivateWorkCycle,
|
handleActivateWorkCycle,
|
||||||
handleCompleteWorkCycle,
|
handleCompleteWorkCycle,
|
||||||
|
handleCompleteWorkCycleWithCarryover,
|
||||||
|
handleReactivateWorkCycle,
|
||||||
handleCreateAction,
|
handleCreateAction,
|
||||||
handleUpdateAction,
|
handleUpdateAction,
|
||||||
handleQuickStatus,
|
handleQuickStatus,
|
||||||
|
|
@ -78,11 +80,14 @@ function PlanSprintInner() {
|
||||||
workCycles={workCycles}
|
workCycles={workCycles}
|
||||||
activeWorkCycle={activeWorkCycle}
|
activeWorkCycle={activeWorkCycle}
|
||||||
selectedSprintId={selectedWorkCycleId}
|
selectedSprintId={selectedWorkCycleId}
|
||||||
|
actions={actions}
|
||||||
actionCountByCycleId={actionCountByCycleId}
|
actionCountByCycleId={actionCountByCycleId}
|
||||||
canManage={capabilities.has('kairo.milestone.manage')}
|
canManage={capabilities.has('kairo.milestone.manage')}
|
||||||
onCreate={handleCreateWorkCycle}
|
onCreate={handleCreateWorkCycle}
|
||||||
onActivate={handleActivateWorkCycle}
|
onActivate={handleActivateWorkCycle}
|
||||||
onComplete={handleCompleteWorkCycle}
|
onComplete={handleCompleteWorkCycle}
|
||||||
|
onCompleteWithCarryover={handleCompleteWorkCycleWithCarryover}
|
||||||
|
onReactivate={handleReactivateWorkCycle}
|
||||||
onSelectSprint={setSelectedWorkCycleId}
|
onSelectSprint={setSelectedWorkCycleId}
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -707,6 +707,87 @@
|
||||||
gap: 6px;
|
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 {
|
.backlog-sprint-setup {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|
|
||||||
|
|
@ -25,3 +25,23 @@ export function countActionsByWorkCycle(actions, { hideDone = true } = {}) {
|
||||||
}
|
}
|
||||||
return counts
|
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 })
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { countActionsByWorkCycle, filterActionsForWorkCycle } from './workCycleActions.js'
|
import {
|
||||||
|
countActionsByWorkCycle,
|
||||||
|
filterActionsForWorkCycle,
|
||||||
|
listCarryoverTargetSprints,
|
||||||
|
listOpenActionsForWorkCycle,
|
||||||
|
} from './workCycleActions.js'
|
||||||
|
|
||||||
describe('workCycleActions', () => {
|
describe('workCycleActions', () => {
|
||||||
const actions = [
|
const actions = [
|
||||||
|
|
@ -18,4 +23,14 @@ describe('workCycleActions', () => {
|
||||||
it('counts actions per sprint', () => {
|
it('counts actions per sprint', () => {
|
||||||
expect(countActionsByWorkCycle(actions)).toEqual({ s1: 1, s2: 1 })
|
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'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user