AP2.2d: Sprint-Planung, Abschluss und Product-Fallback.
Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 2m56s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Has been cancelled
Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 2m56s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Has been cancelled
Backlog committet in gewaehlten Sprint, Sprint complete-Endpoint, continuous_product-Ansicht ohne aktiven Sprint. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
09826d146e
commit
f6aba94864
|
|
@ -85,6 +85,26 @@ def create_work_cycle(
|
||||||
raise HTTPException(status_code=400, detail=detail) from exc
|
raise HTTPException(status_code=400, detail=detail) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@initiative_router.post("/{work_cycle_id}/complete")
|
||||||
|
def complete_work_cycle(
|
||||||
|
initiative_id: str,
|
||||||
|
work_cycle_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return work_cycle_service.complete_work_cycle(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
work_cycle_id=work_cycle_id,
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail in ("Initiative nicht gefunden", "Sprint nicht gefunden oder bereits abgeschlossen"):
|
||||||
|
raise HTTPException(status_code=404, detail=detail) from exc
|
||||||
|
raise HTTPException(status_code=400, detail=detail) from exc
|
||||||
|
|
||||||
|
|
||||||
@initiative_router.post("/{work_cycle_id}/activate")
|
@initiative_router.post("/{work_cycle_id}/activate")
|
||||||
def activate_work_cycle(
|
def activate_work_cycle(
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,52 @@ def activate_work_cycle(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def complete_work_cycle(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_id: str,
|
||||||
|
work_cycle_id: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
validate_work_cycle_in_initiative(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
work_cycle_id=work_cycle_id,
|
||||||
|
)
|
||||||
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE roadmap_items
|
||||||
|
SET status = 'reached', updated_at = NOW()
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
AND item_type = %s
|
||||||
|
AND status IN ('planned', 'active', 'at_risk')
|
||||||
|
RETURNING id, title, goal_description, status, target_date, sort_order, item_type
|
||||||
|
""",
|
||||||
|
(work_cycle_id, tenant_id, WORK_CYCLE_TYPE),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Sprint nicht gefunden oder bereits abgeschlossen")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
result = _serialize_cycle({**dict(row), "initiative_id": initiative_id})
|
||||||
|
log_audit(
|
||||||
|
"work_cycle.completed",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"work_cycle_id": work_cycle_id, "initiative_id": initiative_id},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def count_actions_in_work_cycle(
|
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:
|
||||||
|
|
|
||||||
|
|
@ -42,3 +42,73 @@ def test_convert_backlog_assigns_active_work_cycle(client):
|
||||||
assert converted.status_code == 201
|
assert converted.status_code == 201
|
||||||
action = converted.json()["action"]
|
action = converted.json()["action"]
|
||||||
assert action["work_cycle_id"] == cycle_id
|
assert action["work_cycle_id"] == cycle_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_backlog_to_planned_sprint(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="Product Sprint Plan",
|
||||||
|
archetype_key="initiative.product",
|
||||||
|
)
|
||||||
|
initiative_id = created.json()["id"]
|
||||||
|
|
||||||
|
cycle = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/work-cycles",
|
||||||
|
json={"title": "Sprint R2", "status": "planned"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert cycle.status_code == 201
|
||||||
|
cycle_id = cycle.json()["id"]
|
||||||
|
|
||||||
|
backlog = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/backlog",
|
||||||
|
json={"title": "Story Y", "status": "accepted"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
backlog_id = backlog.json()["id"]
|
||||||
|
|
||||||
|
converted = client.post(
|
||||||
|
f"/api/backlog/{backlog_id}/convert-to-action",
|
||||||
|
json={"work_cycle_id": cycle_id, "assign_active_sprint": False},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert converted.status_code == 201
|
||||||
|
assert converted.json()["action"]["work_cycle_id"] == cycle_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_work_cycle(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="Sprint Complete",
|
||||||
|
archetype_key="initiative.product",
|
||||||
|
)
|
||||||
|
initiative_id = created.json()["id"]
|
||||||
|
|
||||||
|
cycle = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/work-cycles",
|
||||||
|
json={"title": "Sprint R1", "status": "active"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
cycle_id = cycle.json()["id"]
|
||||||
|
|
||||||
|
completed = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/work-cycles/{cycle_id}/complete",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert completed.status_code == 200
|
||||||
|
assert completed.json()["status"] == "reached"
|
||||||
|
|
||||||
|
active = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/work-cycles/active",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert active.status_code == 200
|
||||||
|
assert active.json() is None
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ export function createWorkCycle(initiativeId, body) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function completeWorkCycle(initiativeId, workCycleId) {
|
||||||
|
return apiFetch(`/api/initiatives/${initiativeId}/work-cycles/${workCycleId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function activateWorkCycle(initiativeId, workCycleId) {
|
export function activateWorkCycle(initiativeId, workCycleId) {
|
||||||
return apiFetch(`/api/initiatives/${initiativeId}/work-cycles/${workCycleId}/activate`, {
|
return apiFetch(`/api/initiatives/${initiativeId}/work-cycles/${workCycleId}/activate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { StatusBadge } from './StatusBadge.jsx'
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
import { PriorityBadge } from './PriorityBadge.jsx'
|
import { PriorityBadge } from './PriorityBadge.jsx'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
|
@ -9,25 +9,58 @@ import { gateTitleById } from './GateSelect.jsx'
|
||||||
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
|
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
|
||||||
import { useMinWidth } from '../hooks/useMinWidth.js'
|
import { useMinWidth } from '../hooks/useMinWidth.js'
|
||||||
|
|
||||||
|
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
|
||||||
|
|
||||||
export function BacklogSection({
|
export function BacklogSection({
|
||||||
items,
|
items,
|
||||||
roadmapItems = [],
|
roadmapItems = [],
|
||||||
|
workCycles = [],
|
||||||
|
activeWorkCycle = null,
|
||||||
|
sprintPlanningEnabled = false,
|
||||||
canManage,
|
canManage,
|
||||||
onCreate,
|
onCreate,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onReorder,
|
onReorder,
|
||||||
onConvert,
|
onConvert,
|
||||||
|
onBulkConvert,
|
||||||
onDelete,
|
onDelete,
|
||||||
busy,
|
busy,
|
||||||
sectionTitle = 'Product Backlog',
|
sectionTitle = 'Product Backlog',
|
||||||
sectionLead = 'Eingang vor dem Commit — triagieren, dann in Arbeitspaket (Sprint) umwandeln.',
|
sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.',
|
||||||
}) {
|
}) {
|
||||||
const [modalMode, setModalMode] = useState(null)
|
const [modalMode, setModalMode] = useState(null)
|
||||||
const [dragItemId, setDragItemId] = useState('')
|
const [dragItemId, setDragItemId] = useState('')
|
||||||
const [dropTargetId, setDropTargetId] = useState('')
|
const [dropTargetId, setDropTargetId] = useState('')
|
||||||
|
const [selectedIds, setSelectedIds] = useState(() => new Set())
|
||||||
|
const [sprintTargetId, setSprintTargetId] = useState('')
|
||||||
const isDesktop = useMinWidth(1024)
|
const isDesktop = useMinWidth(1024)
|
||||||
const canReorder = canManage && typeof onReorder === 'function'
|
const canReorder = canManage && typeof onReorder === 'function'
|
||||||
|
|
||||||
|
const planableSprints = useMemo(
|
||||||
|
() => workCycles.filter((cycle) => PLANNING_STATUSES.has(cycle.status)),
|
||||||
|
[workCycles],
|
||||||
|
)
|
||||||
|
|
||||||
|
const showSprintPlanning = sprintPlanningEnabled && planableSprints.length > 0
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSprintPlanning) {
|
||||||
|
setSprintTargetId('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (sprintTargetId && planableSprints.some((cycle) => cycle.id === sprintTargetId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const preferred = activeWorkCycle?.id || planableSprints[0]?.id || ''
|
||||||
|
setSprintTargetId(preferred)
|
||||||
|
}, [showSprintPlanning, sprintTargetId, planableSprints, activeWorkCycle?.id])
|
||||||
|
|
||||||
|
const selectedSprint = planableSprints.find((cycle) => cycle.id === sprintTargetId) || null
|
||||||
|
|
||||||
|
const convertLabel = selectedSprint
|
||||||
|
? `In Sprint „${selectedSprint.title}" planen`
|
||||||
|
: 'In Arbeitspaket umwandeln'
|
||||||
|
|
||||||
const sortedItems = useMemo(
|
const sortedItems = useMemo(
|
||||||
() => sortByOrder(items.filter((item) => item.status !== 'converted')),
|
() => sortByOrder(items.filter((item) => item.status !== 'converted')),
|
||||||
[items],
|
[items],
|
||||||
|
|
@ -104,6 +137,46 @@ export function BacklogSection({
|
||||||
const modalTitle =
|
const modalTitle =
|
||||||
modalMode?.kind === 'create' ? 'Backlog-Item anlegen' : 'Backlog-Item bearbeiten'
|
modalMode?.kind === 'create' ? 'Backlog-Item anlegen' : 'Backlog-Item bearbeiten'
|
||||||
|
|
||||||
|
const acceptedItems = sortedItems.filter((item) => item.status === 'accepted')
|
||||||
|
const selectedAccepted = acceptedItems.filter((item) => selectedIds.has(item.id))
|
||||||
|
|
||||||
|
function toggleSelected(itemId) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(itemId)) next.delete(itemId)
|
||||||
|
else next.add(itemId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertOptions() {
|
||||||
|
return sprintTargetId ? { work_cycle_id: sprintTargetId } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSingleConvert(itemId) {
|
||||||
|
await onConvert(itemId, convertOptions())
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(itemId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBulkPlan() {
|
||||||
|
if (!selectedAccepted.length) return
|
||||||
|
if (typeof onBulkConvert === 'function') {
|
||||||
|
await onBulkConvert(
|
||||||
|
selectedAccepted.map((item) => item.id),
|
||||||
|
convertOptions(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
for (const item of selectedAccepted) {
|
||||||
|
await onConvert(item.id, convertOptions())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card backlog-section">
|
<section className="card backlog-section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
|
|
@ -122,6 +195,45 @@ export function BacklogSection({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showSprintPlanning && canManage && acceptedItems.length > 0 && (
|
||||||
|
<div className="backlog-sprint-planning card-list-item">
|
||||||
|
<label className="backlog-sprint-planning__field">
|
||||||
|
<span className="muted">Sprint-Ziel</span>
|
||||||
|
<select
|
||||||
|
value={sprintTargetId}
|
||||||
|
onChange={(e) => setSprintTargetId(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{planableSprints.map((cycle) => (
|
||||||
|
<option key={cycle.id} value={cycle.id}>
|
||||||
|
{cycle.title}
|
||||||
|
{cycle.status === 'active' ? ' (aktiv)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{selectedAccepted.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={busy || !sprintTargetId}
|
||||||
|
onClick={handleBulkPlan}
|
||||||
|
>
|
||||||
|
{selectedAccepted.length} Item{selectedAccepted.length === 1 ? '' : 's'} {convertLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<p className="muted backlog-sprint-planning__hint">
|
||||||
|
Items markieren und in den gewählten Sprint committen — Planung unter Plan → Sprint.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sprintPlanningEnabled && canManage && acceptedItems.length > 0 && planableSprints.length === 0 && (
|
||||||
|
<p className="muted backlog-sprint-planning__hint">
|
||||||
|
Lege zuerst einen Sprint unter Plan → Sprint an, um Backlog-Items zu planen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{items.length === 0 && <EmptyState message="Backlog ist leer." />}
|
{items.length === 0 && <EmptyState message="Backlog ist leer." />}
|
||||||
|
|
||||||
<ul className="item-list backlog-reorder-list">
|
<ul className="item-list backlog-reorder-list">
|
||||||
|
|
@ -150,6 +262,17 @@ export function BacklogSection({
|
||||||
⋮⋮
|
⋮⋮
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showSprintPlanning && canManage && item.status === 'accepted' && (
|
||||||
|
<label className="backlog-select-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(item.id)}
|
||||||
|
onChange={() => toggleSelected(item.id)}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`${item.title} für Sprint-Planung markieren`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="list-item-main list-item-main--clickable"
|
className="list-item-main list-item-main--clickable"
|
||||||
|
|
@ -185,10 +308,10 @@ export function BacklogSection({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={() => onConvert(item.id)}
|
onClick={() => handleSingleConvert(item.id)}
|
||||||
disabled={busy}
|
disabled={busy || (showSprintPlanning && !sprintTargetId)}
|
||||||
>
|
>
|
||||||
In Arbeitspaket umwandeln
|
{convertLabel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export function WorkCyclesPanel({
|
||||||
canManage,
|
canManage,
|
||||||
onCreate,
|
onCreate,
|
||||||
onActivate,
|
onActivate,
|
||||||
|
onComplete,
|
||||||
busy = false,
|
busy = false,
|
||||||
}) {
|
}) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
|
|
@ -100,7 +101,17 @@ export function WorkCyclesPanel({
|
||||||
<p className="muted item-meta">{cycle.goal_description}</p>
|
<p className="muted item-meta">{cycle.goal_description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canManage && cycle.status !== 'active' && (
|
{canManage && cycle.status === 'active' && typeof onComplete === 'function' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onComplete(cycle.id)}
|
||||||
|
>
|
||||||
|
Abschließen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canManage && cycle.status !== 'active' && cycle.status !== 'reached' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ import {
|
||||||
getActiveWorkCycle,
|
getActiveWorkCycle,
|
||||||
createWorkCycle,
|
createWorkCycle,
|
||||||
activateWorkCycle,
|
activateWorkCycle,
|
||||||
|
completeWorkCycle,
|
||||||
} 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'
|
||||||
|
|
@ -420,10 +421,31 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConvertBacklog(itemId) {
|
async function handleConvertBacklog(itemId, options = {}) {
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
try {
|
try {
|
||||||
await convertBacklogToAction(itemId, { assign_active_sprint: true })
|
await convertBacklogToAction(itemId, {
|
||||||
|
assign_active_sprint: options.work_cycle_id ? false : options.assign_active_sprint ?? true,
|
||||||
|
work_cycle_id: options.work_cycle_id,
|
||||||
|
})
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setFormBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBulkConvertBacklog(itemIds, options = {}) {
|
||||||
|
if (!itemIds.length) return
|
||||||
|
setFormBusy(true)
|
||||||
|
try {
|
||||||
|
for (const itemId of itemIds) {
|
||||||
|
await convertBacklogToAction(itemId, {
|
||||||
|
assign_active_sprint: options.work_cycle_id ? false : options.assign_active_sprint ?? true,
|
||||||
|
work_cycle_id: options.work_cycle_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
|
@ -456,6 +478,18 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCompleteWorkCycle(workCycleId) {
|
||||||
|
setFormBusy(true)
|
||||||
|
try {
|
||||||
|
await completeWorkCycle(id, workCycleId)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setFormBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteBacklog(itemId) {
|
async function handleDeleteBacklog(itemId) {
|
||||||
try {
|
try {
|
||||||
await deleteBacklogItem(itemId)
|
await deleteBacklogItem(itemId)
|
||||||
|
|
@ -821,8 +855,10 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
handleBacklogGate,
|
handleBacklogGate,
|
||||||
handleBacklogStatus,
|
handleBacklogStatus,
|
||||||
handleConvertBacklog,
|
handleConvertBacklog,
|
||||||
|
handleBulkConvertBacklog,
|
||||||
handleCreateWorkCycle,
|
handleCreateWorkCycle,
|
||||||
handleActivateWorkCycle,
|
handleActivateWorkCycle,
|
||||||
|
handleCompleteWorkCycle,
|
||||||
handleDeleteBacklog,
|
handleDeleteBacklog,
|
||||||
handleCreateRoadmapItem,
|
handleCreateRoadmapItem,
|
||||||
handleRoadmapItemStatus,
|
handleRoadmapItemStatus,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,11 @@ import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
|
|
||||||
export function InitiativeInboxPage() {
|
export function InitiativeInboxPage() {
|
||||||
const {
|
const {
|
||||||
|
initiative,
|
||||||
backlogItems,
|
backlogItems,
|
||||||
roadmapItems,
|
roadmapItems,
|
||||||
|
workCycles,
|
||||||
|
activeWorkCycle,
|
||||||
capabilities,
|
capabilities,
|
||||||
formBusy,
|
formBusy,
|
||||||
error,
|
error,
|
||||||
|
|
@ -14,6 +17,7 @@ export function InitiativeInboxPage() {
|
||||||
handleUpdateBacklog,
|
handleUpdateBacklog,
|
||||||
handleReorderBacklog,
|
handleReorderBacklog,
|
||||||
handleConvertBacklog,
|
handleConvertBacklog,
|
||||||
|
handleBulkConvertBacklog,
|
||||||
handleDeleteBacklog,
|
handleDeleteBacklog,
|
||||||
} = useInitiativeOperations()
|
} = useInitiativeOperations()
|
||||||
|
|
||||||
|
|
@ -25,17 +29,23 @@ export function InitiativeInboxPage() {
|
||||||
return <LoadingState message="Lade Eingang …" />
|
return <LoadingState message="Lade Eingang …" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isProductArchetype = initiative?.archetype_key === 'initiative.product'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<BacklogSection
|
<BacklogSection
|
||||||
items={backlogItems}
|
items={backlogItems}
|
||||||
roadmapItems={roadmapItems}
|
roadmapItems={roadmapItems}
|
||||||
|
workCycles={workCycles}
|
||||||
|
activeWorkCycle={activeWorkCycle}
|
||||||
|
sprintPlanningEnabled={isProductArchetype}
|
||||||
canManage={capabilities.has('kairo.backlog.manage')}
|
canManage={capabilities.has('kairo.backlog.manage')}
|
||||||
onCreate={handleCreateBacklog}
|
onCreate={handleCreateBacklog}
|
||||||
onUpdate={handleUpdateBacklog}
|
onUpdate={handleUpdateBacklog}
|
||||||
onReorder={handleReorderBacklog}
|
onReorder={handleReorderBacklog}
|
||||||
onConvert={handleConvertBacklog}
|
onConvert={handleConvertBacklog}
|
||||||
|
onBulkConvert={handleBulkConvertBacklog}
|
||||||
onDelete={handleDeleteBacklog}
|
onDelete={handleDeleteBacklog}
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ function PlanSprintInner() {
|
||||||
reloadActors,
|
reloadActors,
|
||||||
handleCreateWorkCycle,
|
handleCreateWorkCycle,
|
||||||
handleActivateWorkCycle,
|
handleActivateWorkCycle,
|
||||||
|
handleCompleteWorkCycle,
|
||||||
handleCreateAction,
|
handleCreateAction,
|
||||||
handleUpdateAction,
|
handleUpdateAction,
|
||||||
handleQuickStatus,
|
handleQuickStatus,
|
||||||
|
|
@ -62,6 +63,7 @@ function PlanSprintInner() {
|
||||||
canManage={capabilities.has('kairo.milestone.manage')}
|
canManage={capabilities.has('kairo.milestone.manage')}
|
||||||
onCreate={handleCreateWorkCycle}
|
onCreate={handleCreateWorkCycle}
|
||||||
onActivate={handleActivateWorkCycle}
|
onActivate={handleActivateWorkCycle}
|
||||||
|
onComplete={handleCompleteWorkCycle}
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
/>
|
/>
|
||||||
{activeWorkCycle && (
|
{activeWorkCycle && (
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ function WorkSprintInner() {
|
||||||
initiative,
|
initiative,
|
||||||
steeringSnapshot,
|
steeringSnapshot,
|
||||||
steeringSnapshotLoading,
|
steeringSnapshotLoading,
|
||||||
|
visibleActions,
|
||||||
} = ops
|
} = ops
|
||||||
|
|
||||||
const defaultWorkCycleId = activeWorkCycle?.id || ''
|
const defaultWorkCycleId = activeWorkCycle?.id || ''
|
||||||
|
|
@ -66,16 +67,61 @@ function WorkSprintInner() {
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
{!activeWorkCycle && isProductArchetype && (
|
{!activeWorkCycle && isProductArchetype && (
|
||||||
<section className="card">
|
<>
|
||||||
<EmptyState
|
<section className="card sprint-context-banner">
|
||||||
message="Kein aktiver Sprint — lege unter Plan → Sprint einen Sprint an und aktiviere ihn."
|
<h2>Product-Ist (ohne aktiven Sprint)</h2>
|
||||||
|
<p className="section-lead muted">
|
||||||
|
Kein aktiver Sprint — Kairo steuert nach{' '}
|
||||||
|
<strong>continuous_product</strong> (wirkungsvollster Schritt). Sprint-Planung
|
||||||
|
unter{' '}
|
||||||
|
<Link to={hrefWithScope('/plan/sprint')} className="link-inline">
|
||||||
|
Plan → Sprint
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NextActionWidget
|
||||||
|
scope="initiative"
|
||||||
|
initiativeId={initiativeId}
|
||||||
|
items={steeringSnapshot?.next_actions}
|
||||||
|
loading={steeringSnapshotLoading}
|
||||||
|
embedded
|
||||||
|
title="Nächster Schritt (Product)"
|
||||||
|
subtitle="Empfehlung ohne Sprint-Zeitbox — committete Arbeitspakete im Vorhaben."
|
||||||
/>
|
/>
|
||||||
<p>
|
|
||||||
<Link to={hrefWithScope('/plan/sprint')} className="link-inline">
|
<InitiativeActionsHub
|
||||||
Zur Sprint-Planung
|
initiativeId={initiativeId}
|
||||||
</Link>
|
actions={visibleActions}
|
||||||
</p>
|
projects={projects}
|
||||||
</section>
|
roadmapItems={roadmapItems}
|
||||||
|
actionContextById={actionContextById}
|
||||||
|
hideDone={hideDone}
|
||||||
|
onHideDoneChange={setHideDone}
|
||||||
|
showForm={showActionForm}
|
||||||
|
onToggleForm={() => setShowActionForm((v) => !v)}
|
||||||
|
editingActionId={editingAction?.id}
|
||||||
|
onEditAction={setEditingAction}
|
||||||
|
onCancelEdit={() => setEditingAction(null)}
|
||||||
|
onCreateAction={handleCreateAction}
|
||||||
|
onUpdateAction={handleUpdateAction}
|
||||||
|
onQuickStatus={handleQuickStatus}
|
||||||
|
onCreateBlockerForAction={handleCreateBlockerForAction}
|
||||||
|
canManage={capabilities.has('kairo.action.manage')}
|
||||||
|
canManageBlocker={capabilities.has('kairo.blocker.manage')}
|
||||||
|
formBusy={formBusy}
|
||||||
|
actors={actors}
|
||||||
|
actorsLoading={actorsLoading}
|
||||||
|
actorsError={actorsError}
|
||||||
|
actorsUsedFallback={actorsUsedFallback}
|
||||||
|
onReloadActors={reloadActors}
|
||||||
|
workCycles={ops.workCycles}
|
||||||
|
activeWorkCycle={activeWorkCycle}
|
||||||
|
sectionTitle="Committete Arbeitspakete"
|
||||||
|
sectionLead="Ohne aktiven Sprint — alle offenen Arbeitspakete im Product-Vorhaben."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeWorkCycle && (
|
{activeWorkCycle && (
|
||||||
|
|
|
||||||
|
|
@ -669,3 +669,38 @@
|
||||||
border-color: var(--jk-primary);
|
border-color: var(--jk-primary);
|
||||||
background: var(--jk-surface);
|
background: var(--jk-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backlog-sprint-planning {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--jk-surface-muted, rgba(0, 0, 0, 0.03));
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backlog-sprint-planning__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backlog-sprint-planning__hint {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backlog-select-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backlog-select-checkbox input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user