fix: Sprint-Planung einsehbar und Eingang ohne Voll-Reload.
All checks were successful
Deploy Development / deploy (push) Successful in 52s
Test Suite / pytest-backend (push) Successful in 2m46s
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) Successful in 12s
All checks were successful
Deploy Development / deploy (push) Successful in 52s
Test Suite / pytest-backend (push) Successful in 2m46s
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) Successful in 12s
Geplante Sprints in Plan anwaehlbar, lokales Update beim Commit, Sprint-Auswahl bleibt erhalten. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c19b6296ed
commit
e221b5b09f
|
|
@ -12,12 +12,14 @@ import { useMinWidth } from '../hooks/useMinWidth.js'
|
||||||
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
|
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
|
||||||
/** Backend erlaubt Convert für diese Status — UI war nur bei „Freigegeben“ sichtbar. */
|
/** Backend erlaubt Convert für diese Status — UI war nur bei „Freigegeben“ sichtbar. */
|
||||||
const COMMITTABLE_STATUSES = new Set(['accepted', 'triaged', 'new'])
|
const COMMITTABLE_STATUSES = new Set(['accepted', 'triaged', 'new'])
|
||||||
|
const SPRINT_TARGET_STORAGE_PREFIX = 'kairo-sprint-target:'
|
||||||
|
|
||||||
export function BacklogSection({
|
export function BacklogSection({
|
||||||
items,
|
items,
|
||||||
roadmapItems = [],
|
roadmapItems = [],
|
||||||
workCycles = [],
|
workCycles = [],
|
||||||
activeWorkCycle = null,
|
activeWorkCycle = null,
|
||||||
|
initiativeId = '',
|
||||||
sprintPlanningEnabled = false,
|
sprintPlanningEnabled = false,
|
||||||
canManage,
|
canManage,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
|
@ -46,8 +48,12 @@ export function BacklogSection({
|
||||||
const showSprintPlanning = sprintPlanningEnabled && planableSprints.length > 0
|
const showSprintPlanning = sprintPlanningEnabled && planableSprints.length > 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSprintPlanning) {
|
if (!showSprintPlanning || !initiativeId) {
|
||||||
setSprintTargetId('')
|
return
|
||||||
|
}
|
||||||
|
const stored = sessionStorage.getItem(SPRINT_TARGET_STORAGE_PREFIX + initiativeId)
|
||||||
|
if (stored && planableSprints.some((cycle) => cycle.id === stored)) {
|
||||||
|
setSprintTargetId(stored)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (sprintTargetId && planableSprints.some((cycle) => cycle.id === sprintTargetId)) {
|
if (sprintTargetId && planableSprints.some((cycle) => cycle.id === sprintTargetId)) {
|
||||||
|
|
@ -55,7 +61,12 @@ export function BacklogSection({
|
||||||
}
|
}
|
||||||
const preferred = activeWorkCycle?.id || planableSprints[0]?.id || ''
|
const preferred = activeWorkCycle?.id || planableSprints[0]?.id || ''
|
||||||
setSprintTargetId(preferred)
|
setSprintTargetId(preferred)
|
||||||
}, [showSprintPlanning, sprintTargetId, planableSprints, activeWorkCycle?.id])
|
}, [showSprintPlanning, initiativeId, planableSprints, activeWorkCycle?.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initiativeId || !sprintTargetId) return
|
||||||
|
sessionStorage.setItem(SPRINT_TARGET_STORAGE_PREFIX + initiativeId, sprintTargetId)
|
||||||
|
}, [initiativeId, sprintTargetId])
|
||||||
|
|
||||||
const selectedSprint = planableSprints.find((cycle) => cycle.id === sprintTargetId) || null
|
const selectedSprint = planableSprints.find((cycle) => cycle.id === sprintTargetId) || null
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,13 @@ import { EmptyState } from './EmptyState.jsx'
|
||||||
export function WorkCyclesPanel({
|
export function WorkCyclesPanel({
|
||||||
workCycles = [],
|
workCycles = [],
|
||||||
activeWorkCycle = null,
|
activeWorkCycle = null,
|
||||||
|
selectedSprintId = '',
|
||||||
|
actionCountByCycleId = {},
|
||||||
canManage,
|
canManage,
|
||||||
onCreate,
|
onCreate,
|
||||||
onActivate,
|
onActivate,
|
||||||
onComplete,
|
onComplete,
|
||||||
|
onSelectSprint,
|
||||||
busy = false,
|
busy = false,
|
||||||
}) {
|
}) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
|
|
@ -34,8 +37,8 @@ export function WorkCyclesPanel({
|
||||||
<div>
|
<div>
|
||||||
<h2>Sprint</h2>
|
<h2>Sprint</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Der aktive Sprint definiert den Sprint-Backlog — committete Arbeitspakete mit
|
Sprint anklicken, um den geplanten Sprint-Backlog zu sehen — aktiv oder geplant.
|
||||||
Sprint-Zuweisung erscheinen unter Ausführen → Sprint.
|
Committete Arbeitspakete aus dem Eingang erscheinen beim gewählten Sprint.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -46,12 +49,9 @@ export function WorkCyclesPanel({
|
||||||
{activeWorkCycle.goal_description && (
|
{activeWorkCycle.goal_description && (
|
||||||
<span className="muted"> — {activeWorkCycle.goal_description}</span>
|
<span className="muted"> — {activeWorkCycle.goal_description}</span>
|
||||||
)}
|
)}
|
||||||
{typeof activeWorkCycle.open_action_count === 'number' && (
|
|
||||||
<span className="muted"> ({activeWorkCycle.open_action_count} offene APs)</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="muted">Kein aktiver Sprint — committete Arbeitspakete gelten als Product-Ist.</p>
|
<p className="muted">Kein aktiver Sprint — du kannst trotzdem geplante Sprints einsehen.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
|
|
@ -91,38 +91,57 @@ export function WorkCyclesPanel({
|
||||||
{workCycles.length === 0 ? (
|
{workCycles.length === 0 ? (
|
||||||
<EmptyState message="Noch keine Sprints angelegt." />
|
<EmptyState message="Noch keine Sprints angelegt." />
|
||||||
) : (
|
) : (
|
||||||
<ul className="item-list">
|
<ul className="item-list work-cycle-list">
|
||||||
{workCycles.map((cycle) => (
|
{workCycles.map((cycle) => {
|
||||||
<li key={cycle.id} className="list-item card-list-item work-cycle-row">
|
const isSelected = selectedSprintId === cycle.id
|
||||||
<div>
|
const apCount = actionCountByCycleId[cycle.id] || 0
|
||||||
<strong>{cycle.title}</strong>{' '}
|
return (
|
||||||
<StatusBadge status={cycle.status} />
|
<li
|
||||||
{cycle.goal_description && (
|
key={cycle.id}
|
||||||
<p className="muted item-meta">{cycle.goal_description}</p>
|
className={
|
||||||
)}
|
'list-item card-list-item work-cycle-row' +
|
||||||
</div>
|
(isSelected ? ' work-cycle-row--selected' : '')
|
||||||
{canManage && cycle.status === 'active' && typeof onComplete === 'function' && (
|
}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary btn-sm"
|
className="work-cycle-row__select"
|
||||||
disabled={busy}
|
onClick={() => onSelectSprint?.(cycle.id)}
|
||||||
onClick={() => onComplete(cycle.id)}
|
|
||||||
>
|
>
|
||||||
Abschließen
|
<strong>{cycle.title}</strong>{' '}
|
||||||
|
<StatusBadge status={cycle.status} />
|
||||||
|
<span className="muted work-cycle-row__count">
|
||||||
|
{apCount} AP{apCount === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
{cycle.goal_description && (
|
||||||
|
<p className="muted item-meta">{cycle.goal_description}</p>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
<div className="work-cycle-row__actions">
|
||||||
{canManage && cycle.status !== 'active' && cycle.status !== 'reached' && (
|
{canManage && cycle.status === 'active' && typeof onComplete === 'function' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => onActivate(cycle.id)}
|
onClick={() => onComplete(cycle.id)}
|
||||||
>
|
>
|
||||||
Aktivieren
|
Abschließen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</li>
|
{canManage && cycle.status !== 'active' && cycle.status !== 'reached' && (
|
||||||
))}
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onActivate(cycle.id)}
|
||||||
|
>
|
||||||
|
Aktivieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ import { useActors } from '../hooks/useActors.js'
|
||||||
import { useSession } from './SessionContext.jsx'
|
import { useSession } from './SessionContext.jsx'
|
||||||
import { useOptionalProgramScope } from './ProgramScopeContext.jsx'
|
import { useOptionalProgramScope } from './ProgramScopeContext.jsx'
|
||||||
import { collectProjectSubtreeIds } from '../utils/projectTree.js'
|
import { collectProjectSubtreeIds } from '../utils/projectTree.js'
|
||||||
|
import { filterActionsForWorkCycle } from '../utils/workCycleActions.js'
|
||||||
|
|
||||||
const InitiativeOperationsContext = createContext(null)
|
const InitiativeOperationsContext = createContext(null)
|
||||||
|
|
||||||
|
|
@ -118,10 +119,11 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
const [editingAction, setEditingAction] = useState(null)
|
const [editingAction, setEditingAction] = useState(null)
|
||||||
const [formBusy, setFormBusy] = useState(false)
|
const [formBusy, setFormBusy] = useState(false)
|
||||||
const [hideDone, setHideDone] = useState(true)
|
const [hideDone, setHideDone] = useState(true)
|
||||||
|
const [selectedWorkCycleId, setSelectedWorkCycleId] = useState('')
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async ({ silent = false } = {}) => {
|
||||||
if (sessionLoading || !id) return
|
if (sessionLoading || !id) return
|
||||||
setLoading(true)
|
if (!silent) setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const [initData, actionData] = await Promise.all([
|
const [initData, actionData] = await Promise.all([
|
||||||
|
|
@ -203,18 +205,63 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
|
|
||||||
const sprintActions = useMemo(() => {
|
const sprintActions = useMemo(() => {
|
||||||
if (!activeWorkCycle?.id) return []
|
if (!activeWorkCycle?.id) return []
|
||||||
const cycleId = activeWorkCycle.id
|
return filterActionsForWorkCycle(actions, activeWorkCycle.id, { hideDone }).filter((a) => {
|
||||||
let list = hideDone
|
if (!selectedProjectId) return true
|
||||||
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
|
||||||
: actions
|
|
||||||
list = list.filter((a) => a.work_cycle_id === cycleId)
|
|
||||||
if (selectedProjectId) {
|
|
||||||
const subtreeIds = collectProjectSubtreeIds(projects, selectedProjectId)
|
const subtreeIds = collectProjectSubtreeIds(projects, selectedProjectId)
|
||||||
list = list.filter((a) => a.project_id && subtreeIds.has(a.project_id))
|
return a.project_id && subtreeIds.has(a.project_id)
|
||||||
}
|
})
|
||||||
return list
|
|
||||||
}, [actions, hideDone, activeWorkCycle?.id, selectedProjectId, projects])
|
}, [actions, hideDone, activeWorkCycle?.id, selectedProjectId, projects])
|
||||||
|
|
||||||
|
const selectedSprintActions = useMemo(() => {
|
||||||
|
if (!selectedWorkCycleId) return []
|
||||||
|
return filterActionsForWorkCycle(actions, selectedWorkCycleId, { hideDone }).filter((a) => {
|
||||||
|
if (!selectedProjectId) return true
|
||||||
|
const subtreeIds = collectProjectSubtreeIds(projects, selectedProjectId)
|
||||||
|
return a.project_id && subtreeIds.has(a.project_id)
|
||||||
|
})
|
||||||
|
}, [actions, hideDone, selectedWorkCycleId, selectedProjectId, projects])
|
||||||
|
|
||||||
|
const selectedWorkCycle = useMemo(
|
||||||
|
() => workCycles.find((c) => c.id === selectedWorkCycleId) || null,
|
||||||
|
[workCycles, selectedWorkCycleId],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedWorkCycleId && workCycles.some((c) => c.id === selectedWorkCycleId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (activeWorkCycle?.id) {
|
||||||
|
setSelectedWorkCycleId(activeWorkCycle.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const planned = workCycles.find((c) => c.status === 'planned')
|
||||||
|
if (planned) setSelectedWorkCycleId(planned.id)
|
||||||
|
else if (workCycles[0]?.id) setSelectedWorkCycleId(workCycles[0].id)
|
||||||
|
else setSelectedWorkCycleId('')
|
||||||
|
}, [workCycles, activeWorkCycle, selectedWorkCycleId])
|
||||||
|
|
||||||
|
function applyConvertResults(results) {
|
||||||
|
const list = Array.isArray(results) ? results : [results]
|
||||||
|
for (const result of list) {
|
||||||
|
if (!result) continue
|
||||||
|
const backlogItem = result.backlog_item
|
||||||
|
const action = result.action
|
||||||
|
if (backlogItem?.id) {
|
||||||
|
setBacklogItems((prev) =>
|
||||||
|
prev.map((item) => (item.id === backlogItem.id ? backlogItem : item)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (action?.id) {
|
||||||
|
setActions((prev) => {
|
||||||
|
if (prev.some((a) => a.id === action.id)) {
|
||||||
|
return prev.map((a) => (a.id === action.id ? action : a))
|
||||||
|
}
|
||||||
|
return [...prev, action]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const actionContextById = useMemo(
|
const actionContextById = useMemo(
|
||||||
() => Object.fromEntries((steeringSnapshot?.actions || []).map((a) => [a.id, a])),
|
() => Object.fromEntries((steeringSnapshot?.actions || []).map((a) => [a.id, a])),
|
||||||
[steeringSnapshot]
|
[steeringSnapshot]
|
||||||
|
|
@ -423,12 +470,16 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
|
|
||||||
async function handleConvertBacklog(itemId, options = {}) {
|
async function handleConvertBacklog(itemId, options = {}) {
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await convertBacklogToAction(itemId, {
|
const result = await convertBacklogToAction(itemId, {
|
||||||
assign_active_sprint: options.work_cycle_id ? false : options.assign_active_sprint ?? true,
|
assign_active_sprint: options.work_cycle_id ? false : options.assign_active_sprint ?? true,
|
||||||
work_cycle_id: options.work_cycle_id,
|
work_cycle_id: options.work_cycle_id,
|
||||||
})
|
})
|
||||||
await load()
|
applyConvertResults(result)
|
||||||
|
if (options.work_cycle_id) {
|
||||||
|
setSelectedWorkCycleId(options.work_cycle_id)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -439,14 +490,20 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
async function handleBulkConvertBacklog(itemIds, options = {}) {
|
async function handleBulkConvertBacklog(itemIds, options = {}) {
|
||||||
if (!itemIds.length) return
|
if (!itemIds.length) return
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
|
setError(null)
|
||||||
try {
|
try {
|
||||||
for (const itemId of itemIds) {
|
const results = await Promise.all(
|
||||||
await convertBacklogToAction(itemId, {
|
itemIds.map((itemId) =>
|
||||||
assign_active_sprint: options.work_cycle_id ? false : options.assign_active_sprint ?? true,
|
convertBacklogToAction(itemId, {
|
||||||
work_cycle_id: options.work_cycle_id,
|
assign_active_sprint: options.work_cycle_id ? false : options.assign_active_sprint ?? true,
|
||||||
})
|
work_cycle_id: options.work_cycle_id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
applyConvertResults(results)
|
||||||
|
if (options.work_cycle_id) {
|
||||||
|
setSelectedWorkCycleId(options.work_cycle_id)
|
||||||
}
|
}
|
||||||
await load()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -457,8 +514,9 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
async function handleCreateWorkCycle(body) {
|
async function handleCreateWorkCycle(body) {
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
try {
|
try {
|
||||||
await createWorkCycle(id, body)
|
const created = await createWorkCycle(id, body)
|
||||||
await load()
|
await load({ silent: true })
|
||||||
|
if (created?.id) setSelectedWorkCycleId(created.id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -470,7 +528,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
try {
|
try {
|
||||||
await activateWorkCycle(id, workCycleId)
|
await activateWorkCycle(id, workCycleId)
|
||||||
await load()
|
await load({ silent: true })
|
||||||
|
setSelectedWorkCycleId(workCycleId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -482,7 +541,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
try {
|
try {
|
||||||
await completeWorkCycle(id, workCycleId)
|
await completeWorkCycle(id, workCycleId)
|
||||||
await load()
|
await load({ silent: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -817,6 +876,10 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
recurringItems,
|
recurringItems,
|
||||||
workCycles,
|
workCycles,
|
||||||
activeWorkCycle,
|
activeWorkCycle,
|
||||||
|
selectedWorkCycleId,
|
||||||
|
setSelectedWorkCycleId,
|
||||||
|
selectedWorkCycle,
|
||||||
|
selectedSprintActions,
|
||||||
sprintActions,
|
sprintActions,
|
||||||
steeringSnapshot,
|
steeringSnapshot,
|
||||||
steeringSnapshotLoading,
|
steeringSnapshotLoading,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
export function InitiativeInboxPage() {
|
export function InitiativeInboxPage() {
|
||||||
const {
|
const {
|
||||||
initiative,
|
initiative,
|
||||||
|
initiativeId,
|
||||||
backlogItems,
|
backlogItems,
|
||||||
roadmapItems,
|
roadmapItems,
|
||||||
workCycles,
|
workCycles,
|
||||||
|
|
@ -25,7 +26,7 @@ export function InitiativeInboxPage() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading && initiative === null) {
|
||||||
return <LoadingState message="Lade Eingang …" />
|
return <LoadingState message="Lade Eingang …" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +37,7 @@ export function InitiativeInboxPage() {
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<BacklogSection
|
<BacklogSection
|
||||||
items={backlogItems}
|
items={backlogItems}
|
||||||
|
initiativeId={initiativeId}
|
||||||
roadmapItems={roadmapItems}
|
roadmapItems={roadmapItems}
|
||||||
workCycles={workCycles}
|
workCycles={workCycles}
|
||||||
activeWorkCycle={activeWorkCycle}
|
activeWorkCycle={activeWorkCycle}
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,19 @@ import { WorkCyclesPanel } from '../../components/WorkCyclesPanel.jsx'
|
||||||
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
|
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
|
||||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
|
import { countActionsByWorkCycle } from '../../utils/workCycleActions.js'
|
||||||
|
|
||||||
function PlanSprintInner() {
|
function PlanSprintInner() {
|
||||||
const ops = useInitiativeOperations()
|
const ops = useInitiativeOperations()
|
||||||
const {
|
const {
|
||||||
initiativeId,
|
initiativeId,
|
||||||
activeWorkCycle,
|
activeWorkCycle,
|
||||||
|
selectedWorkCycleId,
|
||||||
|
setSelectedWorkCycleId,
|
||||||
|
selectedWorkCycle,
|
||||||
|
selectedSprintActions,
|
||||||
workCycles,
|
workCycles,
|
||||||
sprintActions,
|
actions,
|
||||||
projects,
|
projects,
|
||||||
roadmapItems,
|
roadmapItems,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
|
@ -40,17 +45,28 @@ function PlanSprintInner() {
|
||||||
handleCreateBlockerForAction,
|
handleCreateBlockerForAction,
|
||||||
} = ops
|
} = ops
|
||||||
|
|
||||||
|
const actionCountByCycleId = useMemo(
|
||||||
|
() => countActionsByWorkCycle(actions, { hideDone }),
|
||||||
|
[actions, hideDone],
|
||||||
|
)
|
||||||
|
|
||||||
const createSprintAction = useMemo(
|
const createSprintAction = useMemo(
|
||||||
() => async (payload) => {
|
() => async (payload) => {
|
||||||
await handleCreateAction({
|
await handleCreateAction({
|
||||||
...payload,
|
...payload,
|
||||||
work_cycle_id: payload.work_cycle_id || activeWorkCycle?.id || undefined,
|
work_cycle_id: payload.work_cycle_id || selectedWorkCycleId || activeWorkCycle?.id || undefined,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[handleCreateAction, activeWorkCycle?.id],
|
[handleCreateAction, selectedWorkCycleId, activeWorkCycle?.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (loading) {
|
const sprintLead = selectedWorkCycle
|
||||||
|
? selectedWorkCycle.status === 'active'
|
||||||
|
? `Aktiver Sprint „${selectedWorkCycle.title}" — committet aus Eingang oder direkt hier.`
|
||||||
|
: `Geplanter Sprint „${selectedWorkCycle.title}" — Arbeitspakete aus dem Eingang erscheinen hier nach dem Commit.`
|
||||||
|
: 'Wähle einen Sprint in der Liste.'
|
||||||
|
|
||||||
|
if (loading && workCycles.length === 0) {
|
||||||
return <LoadingState message="Lade Sprint-Planung …" />
|
return <LoadingState message="Lade Sprint-Planung …" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,16 +76,19 @@ function PlanSprintInner() {
|
||||||
<WorkCyclesPanel
|
<WorkCyclesPanel
|
||||||
workCycles={workCycles}
|
workCycles={workCycles}
|
||||||
activeWorkCycle={activeWorkCycle}
|
activeWorkCycle={activeWorkCycle}
|
||||||
|
selectedSprintId={selectedWorkCycleId}
|
||||||
|
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}
|
||||||
|
onSelectSprint={setSelectedWorkCycleId}
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
/>
|
/>
|
||||||
{activeWorkCycle && (
|
{selectedWorkCycleId && selectedWorkCycle && (
|
||||||
<InitiativeActionsHub
|
<InitiativeActionsHub
|
||||||
initiativeId={initiativeId}
|
initiativeId={initiativeId}
|
||||||
actions={sprintActions}
|
actions={selectedSprintActions}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
roadmapItems={roadmapItems}
|
roadmapItems={roadmapItems}
|
||||||
actionContextById={actionContextById}
|
actionContextById={actionContextById}
|
||||||
|
|
@ -93,9 +112,9 @@ function PlanSprintInner() {
|
||||||
actorsUsedFallback={actorsUsedFallback}
|
actorsUsedFallback={actorsUsedFallback}
|
||||||
onReloadActors={reloadActors}
|
onReloadActors={reloadActors}
|
||||||
workCycles={workCycles}
|
workCycles={workCycles}
|
||||||
activeWorkCycle={activeWorkCycle}
|
activeWorkCycle={selectedWorkCycle}
|
||||||
sectionTitle="Sprint-Backlog"
|
sectionTitle={`Sprint-Backlog — ${selectedWorkCycle.title}`}
|
||||||
sectionLead="Arbeitspakete im aktiven Sprint — committet aus dem Product Backlog (Eingang) oder direkt hier."
|
sectionLead={sprintLead}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -670,6 +670,43 @@
|
||||||
background: var(--jk-surface);
|
background: var(--jk-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.work-cycle-list .work-cycle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-cycle-row__select {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-cycle-row__select:hover strong {
|
||||||
|
color: var(--jk-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-cycle-row--selected {
|
||||||
|
box-shadow: inset 3px 0 0 var(--jk-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-cycle-row__count {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-cycle-row__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.backlog-sprint-setup {
|
.backlog-sprint-setup {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|
|
||||||
27
frontend/src/utils/workCycleActions.js
Normal file
27
frontend/src/utils/workCycleActions.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* Arbeitspakete eines Sprints (work_cycle) filtern.
|
||||||
|
* @param {Array<{ id: string, work_cycle_id?: string, status?: string }>} actions
|
||||||
|
* @param {string | null | undefined} cycleId
|
||||||
|
* @param {{ hideDone?: boolean }} options
|
||||||
|
*/
|
||||||
|
export function filterActionsForWorkCycle(actions, cycleId, { hideDone = true } = {}) {
|
||||||
|
if (!cycleId) return []
|
||||||
|
let list = Array.isArray(actions) ? actions : []
|
||||||
|
if (hideDone) {
|
||||||
|
list = list.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
||||||
|
}
|
||||||
|
return list.filter((a) => a.work_cycle_id === cycleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<{ work_cycle_id?: string, status?: string }>} actions
|
||||||
|
*/
|
||||||
|
export function countActionsByWorkCycle(actions, { hideDone = true } = {}) {
|
||||||
|
const counts = {}
|
||||||
|
for (const action of actions || []) {
|
||||||
|
if (!action.work_cycle_id) continue
|
||||||
|
if (hideDone && (action.status === 'done' || action.status === 'discarded')) continue
|
||||||
|
counts[action.work_cycle_id] = (counts[action.work_cycle_id] || 0) + 1
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
21
frontend/src/utils/workCycleActions.test.js
Normal file
21
frontend/src/utils/workCycleActions.test.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { countActionsByWorkCycle, filterActionsForWorkCycle } from './workCycleActions.js'
|
||||||
|
|
||||||
|
describe('workCycleActions', () => {
|
||||||
|
const actions = [
|
||||||
|
{ id: 'a1', work_cycle_id: 's1', status: 'open' },
|
||||||
|
{ id: 'a2', work_cycle_id: 's1', status: 'done' },
|
||||||
|
{ id: 'a3', work_cycle_id: 's2', status: 'ready' },
|
||||||
|
{ id: 'a4', status: 'open' },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('filters actions for a sprint', () => {
|
||||||
|
expect(filterActionsForWorkCycle(actions, 's1').map((a) => a.id)).toEqual(['a1'])
|
||||||
|
expect(filterActionsForWorkCycle(actions, 's2').map((a) => a.id)).toEqual(['a3'])
|
||||||
|
expect(filterActionsForWorkCycle(actions, 'missing')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts actions per sprint', () => {
|
||||||
|
expect(countActionsByWorkCycle(actions)).toEqual({ s1: 1, s2: 1 })
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user