All checks were successful
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 3m54s
Test Suite / lint-backend (push) Successful in 3s
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 15s
Epics als Backlog-Container mit Baum-UI, Convert-Guard und pytest-Abdeckung — Roll-up bleibt für P4 offen. Co-authored-by: Cursor <cursoragent@cursor.com>
447 lines
16 KiB
JavaScript
447 lines
16 KiB
JavaScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { StatusBadge } from './StatusBadge.jsx'
|
|
import { PriorityBadge } from './PriorityBadge.jsx'
|
|
import { EmptyState } from './EmptyState.jsx'
|
|
import { Modal } from './Modal.jsx'
|
|
import { BacklogItemForm } from './BacklogItemForm.jsx'
|
|
import { ReorderControls } from './ReorderControls.jsx'
|
|
import { gateTitleById } from './GateSelect.jsx'
|
|
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
|
|
import { buildBacklogDisplayRows, listEpicBacklogItems } from '../utils/backlogTree.js'
|
|
import { useMinWidth } from '../hooks/useMinWidth.js'
|
|
import { BACKLOG_ITEM_KIND_LABELS } from '../constants/status.js'
|
|
import { actionTitleById } from '../utils/actionReferences.js'
|
|
|
|
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
|
|
/** Backend erlaubt Convert für diese Status — UI war nur bei „Freigegeben“ sichtbar. */
|
|
const COMMITTABLE_STATUSES = new Set(['accepted', 'triaged', 'new'])
|
|
const SPRINT_TARGET_STORAGE_PREFIX = 'kairo-sprint-target:'
|
|
|
|
export function BacklogSection({
|
|
items,
|
|
roadmapItems = [],
|
|
workCycles = [],
|
|
activeWorkCycle = null,
|
|
featureActions = [],
|
|
initiativeId = '',
|
|
sprintPlanningEnabled = false,
|
|
canManage,
|
|
onCreate,
|
|
onUpdate,
|
|
onReorder,
|
|
onConvert,
|
|
onBulkConvert,
|
|
onDelete,
|
|
busy,
|
|
sectionTitle = 'Product Backlog',
|
|
sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.',
|
|
}) {
|
|
const [modalMode, setModalMode] = useState(null)
|
|
const [dragItemId, setDragItemId] = useState('')
|
|
const [dropTargetId, setDropTargetId] = useState('')
|
|
const [selectedIds, setSelectedIds] = useState(() => new Set())
|
|
const [sprintTargetId, setSprintTargetId] = useState('')
|
|
const isDesktop = useMinWidth(1024)
|
|
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 || !initiativeId) {
|
|
return
|
|
}
|
|
const stored = sessionStorage.getItem(SPRINT_TARGET_STORAGE_PREFIX + initiativeId)
|
|
if (stored && planableSprints.some((cycle) => cycle.id === stored)) {
|
|
setSprintTargetId(stored)
|
|
return
|
|
}
|
|
if (sprintTargetId && planableSprints.some((cycle) => cycle.id === sprintTargetId)) {
|
|
return
|
|
}
|
|
const preferred = activeWorkCycle?.id || planableSprints[0]?.id || ''
|
|
setSprintTargetId(preferred)
|
|
}, [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 convertLabel = selectedSprint
|
|
? `In Sprint „${selectedSprint.title}" planen`
|
|
: 'In Arbeitspaket umwandeln'
|
|
|
|
const sortedItems = useMemo(
|
|
() => sortByOrder(items.filter((item) => item.status !== 'converted')),
|
|
[items],
|
|
)
|
|
|
|
const epicItems = useMemo(() => listEpicBacklogItems(items), [items])
|
|
const displayRows = useMemo(() => buildBacklogDisplayRows(items), [items])
|
|
const epicTitleById = useMemo(() => {
|
|
const map = new Map()
|
|
for (const epic of epicItems) {
|
|
map.set(epic.id, epic.title)
|
|
}
|
|
return map
|
|
}, [epicItems])
|
|
|
|
const epicHierarchyEnabled = sprintPlanningEnabled
|
|
|
|
function closeModal() {
|
|
setModalMode(null)
|
|
}
|
|
|
|
async function handleCreateSubmit(payload) {
|
|
await onCreate({
|
|
title: payload.title,
|
|
description: payload.description,
|
|
priority: payload.priority,
|
|
roadmap_item_id: payload.roadmap_item_id,
|
|
item_kind: payload.item_kind,
|
|
parent_action_id: payload.parent_action_id,
|
|
parent_backlog_id: payload.parent_backlog_id,
|
|
status: payload.status || 'new',
|
|
})
|
|
closeModal()
|
|
}
|
|
|
|
async function handleEditSubmit(payload) {
|
|
if (!modalMode?.item) return
|
|
const ok = await onUpdate(modalMode.item.id, {
|
|
title: payload.title,
|
|
description: payload.description,
|
|
status: payload.status,
|
|
priority: payload.priority,
|
|
roadmap_item_id: payload.roadmap_item_id,
|
|
clear_roadmap_item: payload.clear_roadmap_item,
|
|
item_kind: payload.item_kind,
|
|
parent_action_id: payload.parent_action_id,
|
|
clear_parent_action: payload.clear_parent_action,
|
|
parent_backlog_id: payload.parent_backlog_id,
|
|
clear_parent_backlog: payload.clear_parent_backlog,
|
|
})
|
|
if (ok !== false) closeModal()
|
|
}
|
|
|
|
async function applyPatches(patches) {
|
|
if (!patches.length) return
|
|
await onReorder(patches)
|
|
}
|
|
|
|
async function handleMove(itemId, direction) {
|
|
const patches = computeMovePatches(sortedItems, itemId, direction)
|
|
await applyPatches(patches)
|
|
}
|
|
|
|
function handleDragStart(event, itemId) {
|
|
if (!canReorder || !isDesktop) return
|
|
setDragItemId(itemId)
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
event.dataTransfer.setData('text/plain', itemId)
|
|
}
|
|
|
|
function handleDragEnd() {
|
|
setDragItemId('')
|
|
setDropTargetId('')
|
|
}
|
|
|
|
function handleDragOver(event, itemId) {
|
|
if (!canReorder || !isDesktop || !dragItemId) return
|
|
event.preventDefault()
|
|
event.dataTransfer.dropEffect = 'move'
|
|
setDropTargetId(itemId)
|
|
}
|
|
|
|
async function handleDrop(event, targetId) {
|
|
event.preventDefault()
|
|
if (!canReorder || !isDesktop || !dragItemId) {
|
|
handleDragEnd()
|
|
return
|
|
}
|
|
const patches = computeDropPatches(sortedItems, dragItemId, targetId)
|
|
handleDragEnd()
|
|
await applyPatches(patches)
|
|
}
|
|
|
|
const modalTitle =
|
|
modalMode?.kind === 'create' ? 'Backlog-Item anlegen' : 'Backlog-Item bearbeiten'
|
|
|
|
const committableItems = sortedItems.filter(
|
|
(item) => COMMITTABLE_STATUSES.has(item.status) && item.item_kind !== 'epic',
|
|
)
|
|
const selectedCommittable = committableItems.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 (!selectedCommittable.length) return
|
|
if (typeof onBulkConvert === 'function') {
|
|
await onBulkConvert(
|
|
selectedCommittable.map((item) => item.id),
|
|
convertOptions(),
|
|
)
|
|
} else {
|
|
for (const item of selectedCommittable) {
|
|
await onConvert(item.id, convertOptions())
|
|
}
|
|
}
|
|
setSelectedIds(new Set())
|
|
}
|
|
|
|
return (
|
|
<section className="card backlog-section">
|
|
<div className="section-header">
|
|
<div>
|
|
<h2>{sectionTitle}</h2>
|
|
<p className="section-lead muted">{sectionLead}</p>
|
|
</div>
|
|
{canManage && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-block-mobile"
|
|
onClick={() => setModalMode({ kind: 'create' })}
|
|
>
|
|
Backlog-Item
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{sprintPlanningEnabled && canManage && !showSprintPlanning && (
|
|
<div className="backlog-sprint-setup card-list-item">
|
|
<p className="backlog-sprint-setup__title">
|
|
<strong>Sprint-Planung:</strong> Lege zuerst einen Sprint an.
|
|
</p>
|
|
<p className="muted backlog-sprint-planning__hint">
|
|
Plan → Sprint → „Sprint anlegen“ (optional „Sofort aktivieren“). Danach erscheint hier
|
|
die Sprint-Auswahl zum Committen aus dem Eingang.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{showSprintPlanning && canManage && (
|
|
<div className="backlog-sprint-planning card-list-item">
|
|
<label className="backlog-sprint-planning__field">
|
|
<span className="muted">Ziel-Sprint</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)' : cycle.status === 'planned' ? ' (geplant)' : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{selectedCommittable.length > 0 && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
disabled={busy || !sprintTargetId}
|
|
onClick={handleBulkPlan}
|
|
>
|
|
{selectedCommittable.length} Item{selectedCommittable.length === 1 ? '' : 's'}{' '}
|
|
{convertLabel}
|
|
</button>
|
|
)}
|
|
<p className="muted backlog-sprint-planning__hint">
|
|
{committableItems.length === 0
|
|
? 'Lege Backlog-Items an — dann per Checkbox markieren und in den Sprint committen.'
|
|
: 'Checkbox am Item → „In Sprint planen“. Committen erzeugt ein Arbeitspaket mit Sprint-Zuweisung.'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{items.length === 0 && <EmptyState message="Backlog ist leer." />}
|
|
|
|
<ul className="item-list backlog-reorder-list">
|
|
{displayRows.map(({ item, depth }) => {
|
|
const isDragging = dragItemId === item.id
|
|
const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id
|
|
const reorderable = canReorder && item.status !== 'converted'
|
|
const isEpic = item.item_kind === 'epic'
|
|
const flatIndex = sortedItems.findIndex((row) => row.id === item.id)
|
|
|
|
return (
|
|
<li
|
|
key={item.id}
|
|
className={
|
|
'list-item card-list-item backlog-reorder-item' +
|
|
(depth > 0 ? ' backlog-reorder-item--child' : '') +
|
|
(isDragging ? ' backlog-reorder-item--dragging' : '') +
|
|
(isDropTarget ? ' backlog-reorder-item--drop-target' : '') +
|
|
(reorderable && isDesktop ? ' backlog-reorder-item--draggable' : '')
|
|
}
|
|
style={depth > 0 ? { marginLeft: `${depth * 1.25}rem` } : undefined}
|
|
draggable={reorderable && isDesktop}
|
|
onDragStart={(event) => handleDragStart(event, item.id)}
|
|
onDragEnd={handleDragEnd}
|
|
onDragOver={(event) => handleDragOver(event, item.id)}
|
|
onDrop={(event) => handleDrop(event, item.id)}
|
|
>
|
|
{reorderable && isDesktop && (
|
|
<span className="backlog-reorder-item__drag-hint" aria-hidden="true">
|
|
⋮⋮
|
|
</span>
|
|
)}
|
|
{showSprintPlanning && canManage && COMMITTABLE_STATUSES.has(item.status) && !isEpic && (
|
|
<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
|
|
type="button"
|
|
className="list-item-main list-item-main--clickable"
|
|
onClick={() => canManage && setModalMode({ kind: 'edit', item })}
|
|
disabled={!canManage}
|
|
>
|
|
<strong>{item.title}</strong>
|
|
{item.item_kind && item.item_kind !== 'story' && (
|
|
<span className="badge badge--kind muted">
|
|
{BACKLOG_ITEM_KIND_LABELS[item.item_kind] || item.item_kind}
|
|
</span>
|
|
)}
|
|
{item.description && (
|
|
<p className="list-item-desc">{item.description}</p>
|
|
)}
|
|
{item.parent_backlog_id && (
|
|
<p className="list-item-sub muted">
|
|
Epic: {epicTitleById.get(item.parent_backlog_id) || '…'}
|
|
</p>
|
|
)}
|
|
{item.parent_action_id && (
|
|
<p className="list-item-sub muted">
|
|
Feature: {actionTitleById(featureActions, item.parent_action_id) || '…'}
|
|
</p>
|
|
)}
|
|
{item.roadmap_item_id && (
|
|
<p className="list-item-sub muted">
|
|
Gate:{' '}
|
|
{gateTitleById(roadmapItems, item.roadmap_item_id) || item.roadmap_item_id}
|
|
</p>
|
|
)}
|
|
</button>
|
|
<div className="list-item-meta action-controls">
|
|
{reorderable && !isDesktop && (
|
|
<ReorderControls
|
|
itemId={item.id}
|
|
canMoveUp={flatIndex > 0}
|
|
canMoveDown={flatIndex < sortedItems.length - 1}
|
|
onMove={(direction) => handleMove(item.id, direction)}
|
|
busy={busy}
|
|
/>
|
|
)}
|
|
<StatusBadge kind="backlog" status={item.status} />
|
|
<PriorityBadge priority={item.priority} />
|
|
{canManage && item.status !== 'converted' && (
|
|
<>
|
|
{COMMITTABLE_STATUSES.has(item.status) && !isEpic && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
onClick={() => handleSingleConvert(item.id)}
|
|
disabled={busy || (showSprintPlanning && !sprintTargetId)}
|
|
>
|
|
{showSprintPlanning ? convertLabel : 'In Arbeitspaket umwandeln'}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary btn-sm"
|
|
onClick={() => setModalMode({ kind: 'edit', item })}
|
|
disabled={busy}
|
|
>
|
|
Bearbeiten
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary btn-sm"
|
|
onClick={() => onDelete(item.id)}
|
|
>
|
|
Löschen
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
{items
|
|
.filter((item) => item.status === 'converted')
|
|
.map((item) => (
|
|
<li key={item.id} className="list-item card-list-item backlog-reorder-item--converted">
|
|
<div className="list-item-main">
|
|
<strong>{item.title}</strong>
|
|
<p className="list-item-sub muted">Konvertiert — nicht mehr sortierbar</p>
|
|
</div>
|
|
<StatusBadge kind="backlog" status={item.status} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<Modal open={Boolean(modalMode)} title={modalTitle} onClose={closeModal}>
|
|
{modalMode?.kind === 'create' && (
|
|
<BacklogItemForm
|
|
roadmapItems={roadmapItems}
|
|
featureActions={featureActions}
|
|
epicItems={epicItems}
|
|
epicHierarchyEnabled={epicHierarchyEnabled}
|
|
onSubmit={handleCreateSubmit}
|
|
onCancel={closeModal}
|
|
busy={busy}
|
|
submitLabel="Anlegen"
|
|
/>
|
|
)}
|
|
{modalMode?.kind === 'edit' && modalMode.item && (
|
|
<BacklogItemForm
|
|
initial={modalMode.item}
|
|
roadmapItems={roadmapItems}
|
|
featureActions={featureActions}
|
|
epicItems={epicItems.filter((epic) => epic.id !== modalMode.item.id)}
|
|
epicHierarchyEnabled={epicHierarchyEnabled}
|
|
onSubmit={handleEditSubmit}
|
|
onCancel={closeModal}
|
|
busy={busy}
|
|
/>
|
|
)}
|
|
</Modal>
|
|
</section>
|
|
)
|
|
}
|