Kairo-Jinkendo/frontend/src/components/BacklogSection.jsx
Lars 252b3f05f2
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Has been cancelled
feat(AP1.9d, AP2.2d): Work-Default-UI, Sprint-Planung und Composition Work-Slots
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:05:15 +02:00

544 lines
20 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 { 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 (
<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>
{canManage && (
<BacklogIntakeGuide
initiativeId={initiativeId}
vocabulary={vocabulary}
sprintCommitEnabled={sprintCommitEnabled}
hasPlanableSprint={planableSprints.length > 0}
hasBacklogItems={sortedItems.length > 0}
hasCommittableItems={committableItems.length > 0}
hasConvertedItems={hasConvertedItems}
directWorkEnabled={directWorkEnabled}
compact={guideCompact}
/>
)}
{sprintCommitEnabled && 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">
{lockSprintTarget && selectedSprint ? (
<p className="muted backlog-sprint-planning__locked">
Ziel-Sprint: <strong>{selectedSprint.title}</strong>
{selectedSprint.status === 'active'
? ' (aktiv)'
: selectedSprint.status === 'planned'
? ' (geplant)'
: ''}
</p>
) : (
<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>
)}
{sortedItems.length === 0 && !hasConvertedItems && (
<EmptyState message="Noch keine Items im Eingang — Schritt 2 in der Anleitung oben." />
)}
{(sortedItems.length > 0 || hasConvertedItems) && (
<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 epicRollup = isEpic && showEpicRollup ? rollupByEpicId[item.id] : null
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 !== defaultKind && (
<span className="badge badge--kind muted">
{backlogKindLabel(vocabulary, item.item_kind)}
</span>
)}
{item.description && (
<p className="list-item-desc">{item.description}</p>
)}
{epicRollup && (
<div className="epic-rollup-summary">
<div
className="epic-rollup-summary__bar"
role="progressbar"
aria-valuenow={epicRollupProgressPercent(epicRollup)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Epic-Fortschritt ${epicRollupProgressPercent(epicRollup)} Prozent`}
>
<span
className="epic-rollup-summary__fill"
style={{ width: `${epicRollupProgressPercent(epicRollup)}%` }}
/>
</div>
<p className="list-item-sub muted">{formatEpicRollupSummary(epicRollup)}</p>
</div>
)}
{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}
backlogVocabulary={vocabulary}
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)}
backlogVocabulary={vocabulary}
onSubmit={handleEditSubmit}
onCancel={closeModal}
busy={busy}
/>
)}
</Modal>
</section>
)
}