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 { backlogKindLabel, resolveBacklogVocabulary } from '../utils/resolveBacklogVocabulary.js' import { useMinWidth } from '../hooks/useMinWidth.js' import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx' import { hasSteeringElement } from '../registry/steeringElementRegistry.js' import { BacklogIntakeGuide } from './BacklogIntakeGuide.jsx' import { actionTitleById } from '../utils/actionReferences.js' import { epicRollupById, epicRollupProgressPercent, formatEpicRollupSummary, } from '../utils/epicRollup.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 = '', canManage, onCreate, onUpdate, onReorder, onConvert, onBulkConvert, onDelete, busy, sectionTitle = 'Product Backlog', sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.', defaultSprintTargetId = null, lockSprintTarget = false, }) { const { operatingContext, steeringElements, steeringSnapshot } = useInitiativeOperations() 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 vocabulary = useMemo( () => resolveBacklogVocabulary(operatingContext?.backlog_vocabulary), [operatingContext?.backlog_vocabulary], ) const sprintCommitEnabled = useMemo( () => vocabulary.capabilities?.sprint_commit ?? hasSteeringElement(steeringElements, 'work_cycle_scope'), [vocabulary, steeringElements], ) const planableSprints = useMemo( () => workCycles.filter((cycle) => PLANNING_STATUSES.has(cycle.status)), [workCycles], ) const showSprintPlanning = sprintCommitEnabled && planableSprints.length > 0 useEffect(() => { if (!showSprintPlanning || !initiativeId) { return } if ( defaultSprintTargetId && planableSprints.some((cycle) => cycle.id === defaultSprintTargetId) ) { setSprintTargetId(defaultSprintTargetId) 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, defaultSprintTargetId, ]) 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 rollupByEpicId = useMemo( () => epicRollupById(steeringSnapshot?.epic_rollup), [steeringSnapshot?.epic_rollup], ) const showEpicRollup = Boolean(vocabulary.capabilities?.epic_hierarchy) const defaultKind = vocabulary.default_kind || 'story' const hasConvertedItems = items.some((item) => item.status === 'converted') const directWorkEnabled = Boolean( operatingContext?.ui_profile?.planOutlineKeys?.includes('work'), ) const guideCompact = sortedItems.length > 0 && (showSprintPlanning || hasConvertedItems) 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 (

{sectionTitle}

{sectionLead}

{canManage && ( )}
{canManage && ( 0} hasBacklogItems={sortedItems.length > 0} hasCommittableItems={committableItems.length > 0} hasConvertedItems={hasConvertedItems} directWorkEnabled={directWorkEnabled} compact={guideCompact} /> )} {sprintCommitEnabled && canManage && !showSprintPlanning && (

Sprint-Planung: Lege zuerst einen Sprint an.

Plan → Sprint → „Sprint anlegen“ (optional „Sofort aktivieren“). Danach erscheint hier die Sprint-Auswahl zum Committen aus dem Eingang.

)} {showSprintPlanning && canManage && (
{lockSprintTarget && selectedSprint ? (

Ziel-Sprint: {selectedSprint.title} {selectedSprint.status === 'active' ? ' (aktiv)' : selectedSprint.status === 'planned' ? ' (geplant)' : ''}

) : ( )} {selectedCommittable.length > 0 && ( )}

{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.'}

)} {sortedItems.length === 0 && !hasConvertedItems && ( )} {(sortedItems.length > 0 || hasConvertedItems) && (
    {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 epicRollup = isEpic && showEpicRollup ? rollupByEpicId[item.id] : null const flatIndex = sortedItems.findIndex((row) => row.id === item.id) return (
  • 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 && ( )} {showSprintPlanning && canManage && COMMITTABLE_STATUSES.has(item.status) && !isEpic && ( )}
    {reorderable && !isDesktop && ( 0} canMoveDown={flatIndex < sortedItems.length - 1} onMove={(direction) => handleMove(item.id, direction)} busy={busy} /> )} {canManage && item.status !== 'converted' && ( <> {COMMITTABLE_STATUSES.has(item.status) && !isEpic && ( )} )}
  • ) })} {items .filter((item) => item.status === 'converted') .map((item) => (
  • {item.title}

    Konvertiert — nicht mehr sortierbar

  • ))}
)} {modalMode?.kind === 'create' && ( )} {modalMode?.kind === 'edit' && modalMode.item && ( epic.id !== modalMode.item.id)} backlogVocabulary={vocabulary} onSubmit={handleEditSubmit} onCancel={closeModal} busy={busy} /> )}
) }