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
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:
parent
b1686f3a98
commit
83fdb9a2a2
|
|
@ -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 & Drop (Desktop) oder
|
||||
↑/↓ (Mobile). Bearbeitung und Verify auf der Gate-Detailseite.
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
|
|
@ -61,53 +111,85 @@ 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">
|
||||
<div className="list-item-main">
|
||||
<strong>
|
||||
{initiativeId ? (
|
||||
<Link to={gatePath(item.id)} className="roadmap-plan-section__title-link">
|
||||
{item.title}
|
||||
</Link>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
</strong>
|
||||
<p className="muted list-item-sub">
|
||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
||||
{' · '}
|
||||
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
||||
</p>
|
||||
{item.goal_description && (
|
||||
<p className="list-item-desc">{item.goal_description}</p>
|
||||
<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>
|
||||
)}
|
||||
{item.target_date && (
|
||||
<div className="list-item-main">
|
||||
<strong>
|
||||
{initiativeId ? (
|
||||
<Link to={gatePath(item.id)} className="roadmap-plan-section__title-link">
|
||||
{item.title}
|
||||
</Link>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
</strong>
|
||||
<p className="muted list-item-sub">
|
||||
Zieltermin: {formatDate(item.target_date)}
|
||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
||||
{' · '}
|
||||
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-item-meta action-controls">
|
||||
<StatusBadge kind="milestone" status={item.status} />
|
||||
{initiativeId && (
|
||||
<Link to={gatePath(item.id)} className="btn btn-secondary btn-sm">
|
||||
Öffnen
|
||||
</Link>
|
||||
)}
|
||||
{canManage && item.status !== 'reached' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => onDelete(item.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{item.goal_description && (
|
||||
<p className="list-item-desc">{item.goal_description}</p>
|
||||
)}
|
||||
{item.target_date && (
|
||||
<p className="muted list-item-sub">
|
||||
Zieltermin: {formatDate(item.target_date)}
|
||||
</p>
|
||||
)}
|
||||
</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
|
||||
</Link>
|
||||
)}
|
||||
{canManage && item.status !== 'reached' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => onDelete(item.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<Modal open={showCreate} title="Plan-Element anlegen" onClose={() => setShowCreate(false)}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user