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