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 { Link } from 'react-router-dom'
|
||||||
import { gatePath } from '../utils/routes.js'
|
import { gatePath } from '../utils/routes.js'
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,6 +9,9 @@ import { StatusBadge } from './StatusBadge.jsx'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
import { Modal } from './Modal.jsx'
|
import { Modal } from './Modal.jsx'
|
||||||
import { RoadmapItemForm } from './RoadmapItemForm.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) {
|
function formatDate(value) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
|
|
@ -26,24 +29,71 @@ export function RoadmapPlanSection({
|
||||||
items,
|
items,
|
||||||
canManage,
|
canManage,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onReorder,
|
||||||
onDelete,
|
onDelete,
|
||||||
busy,
|
busy,
|
||||||
}) {
|
}) {
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
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) {
|
async function handleCreateSubmit(payload) {
|
||||||
await onCreate(payload)
|
await onCreate(payload)
|
||||||
setShowCreate(false)
|
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 (
|
return (
|
||||||
<section className="card roadmap-plan-section">
|
<section className="card roadmap-plan-section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Zielzustände (Gates)</h2>
|
<h2>Zielzustände (Gates)</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Überprüfbare Zielpunkte — optional. Bearbeitung und Verify auf der Gate-Detailseite,
|
Überprüfbare Zielpunkte — optional. Reihenfolge per Drag & Drop (Desktop) oder
|
||||||
nicht inline in der Liste.
|
↑/↓ (Mobile). Bearbeitung und Verify auf der Gate-Detailseite.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
|
|
@ -61,53 +111,85 @@ export function RoadmapPlanSection({
|
||||||
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
|
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="item-list milestone-list">
|
<ul className="item-list milestone-list backlog-reorder-list">
|
||||||
{items.map((item) => (
|
{sortedItems.map((item, index) => {
|
||||||
<li key={item.id} className="list-item card-list-item milestone-list-item">
|
const isDragging = dragItemId === item.id
|
||||||
<div className="list-item-main">
|
const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id
|
||||||
<strong>
|
|
||||||
{initiativeId ? (
|
return (
|
||||||
<Link to={gatePath(item.id)} className="roadmap-plan-section__title-link">
|
<li
|
||||||
{item.title}
|
key={item.id}
|
||||||
</Link>
|
className={
|
||||||
) : (
|
'list-item card-list-item milestone-list-item backlog-reorder-item' +
|
||||||
item.title
|
(isDragging ? ' backlog-reorder-item--dragging' : '') +
|
||||||
)}
|
(isDropTarget ? ' backlog-reorder-item--drop-target' : '') +
|
||||||
</strong>
|
(canReorder && isDesktop ? ' backlog-reorder-item--draggable' : '')
|
||||||
<p className="muted list-item-sub">
|
}
|
||||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
draggable={canReorder && isDesktop}
|
||||||
{' · '}
|
onDragStart={(event) => handleDragStart(event, item.id)}
|
||||||
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
onDragEnd={handleDragEnd}
|
||||||
</p>
|
onDragOver={(event) => handleDragOver(event, item.id)}
|
||||||
{item.goal_description && (
|
onDrop={(event) => handleDrop(event, item.id)}
|
||||||
<p className="list-item-desc">{item.goal_description}</p>
|
>
|
||||||
|
{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">
|
<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>
|
</p>
|
||||||
)}
|
{item.goal_description && (
|
||||||
</div>
|
<p className="list-item-desc">{item.goal_description}</p>
|
||||||
<div className="list-item-meta action-controls">
|
)}
|
||||||
<StatusBadge kind="milestone" status={item.status} />
|
{item.target_date && (
|
||||||
{initiativeId && (
|
<p className="muted list-item-sub">
|
||||||
<Link to={gatePath(item.id)} className="btn btn-secondary btn-sm">
|
Zieltermin: {formatDate(item.target_date)}
|
||||||
Öffnen
|
</p>
|
||||||
</Link>
|
)}
|
||||||
)}
|
</div>
|
||||||
{canManage && item.status !== 'reached' && (
|
<div className="list-item-meta action-controls">
|
||||||
<button
|
<StatusBadge kind="milestone" status={item.status} />
|
||||||
type="button"
|
{canReorder && !isDesktop && (
|
||||||
className="btn btn-secondary btn-sm"
|
<ReorderControls
|
||||||
onClick={() => onDelete(item.id)}
|
itemId={item.id}
|
||||||
disabled={busy}
|
index={index}
|
||||||
>
|
total={sortedItems.length}
|
||||||
Löschen
|
onMove={handleMove}
|
||||||
</button>
|
disabled={busy}
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
</li>
|
{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>
|
</ul>
|
||||||
|
|
||||||
<Modal open={showCreate} title="Plan-Element anlegen" onClose={() => setShowCreate(false)}>
|
<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(
|
const milestones = useMemo(
|
||||||
() => roadmapItems.filter((item) => item.item_type === 'milestone'),
|
() => roadmapItems.filter((item) => item.item_type === 'milestone'),
|
||||||
[roadmapItems]
|
[roadmapItems]
|
||||||
|
|
@ -759,6 +777,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
handleDeleteProject,
|
handleDeleteProject,
|
||||||
handleReorderProjects,
|
handleReorderProjects,
|
||||||
handleReorderBacklog,
|
handleReorderBacklog,
|
||||||
|
handleReorderRoadmapItems,
|
||||||
handleCreateEvidence,
|
handleCreateEvidence,
|
||||||
handleEvidenceStatus,
|
handleEvidenceStatus,
|
||||||
handleDeleteEvidence,
|
handleDeleteEvidence,
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,7 @@ export function InitiativePlanPage() {
|
||||||
capabilities,
|
capabilities,
|
||||||
formBusy,
|
formBusy,
|
||||||
handleCreateRoadmapItem,
|
handleCreateRoadmapItem,
|
||||||
handleRoadmapItemStatus,
|
handleReorderRoadmapItems,
|
||||||
handleUpdateRoadmapItem,
|
|
||||||
handleVerifyWithEvidence,
|
|
||||||
handleDeleteRoadmapItem,
|
handleDeleteRoadmapItem,
|
||||||
error,
|
error,
|
||||||
} = useInitiativeOperations()
|
} = useInitiativeOperations()
|
||||||
|
|
@ -27,6 +25,7 @@ export function InitiativePlanPage() {
|
||||||
items={roadmapItems}
|
items={roadmapItems}
|
||||||
canManage={capabilities.has('kairo.milestone.manage')}
|
canManage={capabilities.has('kairo.milestone.manage')}
|
||||||
onCreate={handleCreateRoadmapItem}
|
onCreate={handleCreateRoadmapItem}
|
||||||
|
onReorder={handleReorderRoadmapItems}
|
||||||
onDelete={handleDeleteRoadmapItem}
|
onDelete={handleDeleteRoadmapItem}
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user