AP1.12c: Reihenfolge der Gates per DnD und Pfeiltasten.
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m6s
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 18s
Test Suite / playwright-smoke (push) Successful in 15s

RoadmapPlanSection nutzt sort_order wie Backlog und Projekte; handleReorderRoadmapItems im Operations-Context.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-10 15:43:08 +02:00
parent b1686f3a98
commit 83fdb9a2a2
3 changed files with 150 additions and 50 deletions

View File

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { gatePath } from '../utils/routes.js'
import {
@ -9,6 +9,9 @@ import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
import { Modal } from './Modal.jsx'
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
import { ReorderControls } from './ReorderControls.jsx'
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
import { useMinWidth } from '../hooks/useMinWidth.js'
function formatDate(value) {
if (!value) return null
@ -26,24 +29,71 @@ export function RoadmapPlanSection({
items,
canManage,
onCreate,
onReorder,
onDelete,
busy,
}) {
const [showCreate, setShowCreate] = useState(false)
const [dragItemId, setDragItemId] = useState('')
const [dropTargetId, setDropTargetId] = useState('')
const isDesktop = useMinWidth(1024)
const canReorder = canManage && typeof onReorder === 'function'
const sortedItems = useMemo(() => sortByOrder(items), [items])
async function handleCreateSubmit(payload) {
await onCreate(payload)
setShowCreate(false)
}
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)
}
return (
<section className="card roadmap-plan-section">
<div className="section-header">
<div>
<h2>Zielzustände (Gates)</h2>
<p className="section-lead muted">
Überprüfbare Zielpunkte optional. Bearbeitung und Verify auf der Gate-Detailseite,
nicht inline in der Liste.
Überprüfbare Zielpunkte optional. Reihenfolge per Drag &amp; Drop (Desktop) oder
/ (Mobile). Bearbeitung und Verify auf der Gate-Detailseite.
</p>
</div>
{canManage && (
@ -61,9 +111,31 @@ export function RoadmapPlanSection({
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
)}
<ul className="item-list milestone-list">
{items.map((item) => (
<li key={item.id} className="list-item card-list-item milestone-list-item">
<ul className="item-list milestone-list backlog-reorder-list">
{sortedItems.map((item, index) => {
const isDragging = dragItemId === item.id
const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id
return (
<li
key={item.id}
className={
'list-item card-list-item milestone-list-item backlog-reorder-item' +
(isDragging ? ' backlog-reorder-item--dragging' : '') +
(isDropTarget ? ' backlog-reorder-item--drop-target' : '') +
(canReorder && isDesktop ? ' backlog-reorder-item--draggable' : '')
}
draggable={canReorder && isDesktop}
onDragStart={(event) => handleDragStart(event, item.id)}
onDragEnd={handleDragEnd}
onDragOver={(event) => handleDragOver(event, item.id)}
onDrop={(event) => handleDrop(event, item.id)}
>
{canReorder && isDesktop && (
<span className="backlog-reorder-item__drag-hint" aria-hidden="true">
</span>
)}
<div className="list-item-main">
<strong>
{initiativeId ? (
@ -90,6 +162,15 @@ export function RoadmapPlanSection({
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="milestone" status={item.status} />
{canReorder && !isDesktop && (
<ReorderControls
itemId={item.id}
index={index}
total={sortedItems.length}
onMove={handleMove}
disabled={busy}
/>
)}
{initiativeId && (
<Link to={gatePath(item.id)} className="btn btn-secondary btn-sm">
Öffnen
@ -107,7 +188,8 @@ export function RoadmapPlanSection({
)}
</div>
</li>
))}
)
})}
</ul>
<Modal open={showCreate} title="Plan-Element anlegen" onClose={() => setShowCreate(false)}>

View File

@ -565,6 +565,24 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
}
}
async function handleReorderRoadmapItems(patches) {
if (!patches?.length) return true
setFormBusy(true)
setError(null)
try {
await Promise.all(
patches.map((patch) => updateRoadmapItem(patch.id, { sort_order: patch.sort_order })),
)
await load()
return true
} catch (err) {
setError(err.message)
return false
} finally {
setFormBusy(false)
}
}
const milestones = useMemo(
() => roadmapItems.filter((item) => item.item_type === 'milestone'),
[roadmapItems]
@ -759,6 +777,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
handleDeleteProject,
handleReorderProjects,
handleReorderBacklog,
handleReorderRoadmapItems,
handleCreateEvidence,
handleEvidenceStatus,
handleDeleteEvidence,

View File

@ -8,9 +8,7 @@ export function InitiativePlanPage() {
capabilities,
formBusy,
handleCreateRoadmapItem,
handleRoadmapItemStatus,
handleUpdateRoadmapItem,
handleVerifyWithEvidence,
handleReorderRoadmapItems,
handleDeleteRoadmapItem,
error,
} = useInitiativeOperations()
@ -27,6 +25,7 @@ export function InitiativePlanPage() {
items={roadmapItems}
canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateRoadmapItem}
onReorder={handleReorderRoadmapItems}
onDelete={handleDeleteRoadmapItem}
busy={formBusy}
/>