diff --git a/frontend/src/components/RoadmapPlanSection.jsx b/frontend/src/components/RoadmapPlanSection.jsx index 4d2a1eb..f149562 100644 --- a/frontend/src/components/RoadmapPlanSection.jsx +++ b/frontend/src/components/RoadmapPlanSection.jsx @@ -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 (

Zielzustände (Gates)

- Ü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.

{canManage && ( @@ -61,53 +111,85 @@ export function RoadmapPlanSection({ )} -
    - {items.map((item) => ( -
  • -
    - - {initiativeId ? ( - - {item.title} - - ) : ( - item.title - )} - -

    - {ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type} - {' · '} - {SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode} -

    - {item.goal_description && ( -

    {item.goal_description}

    +
      + {sortedItems.map((item, index) => { + const isDragging = dragItemId === item.id + const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id + + return ( +
    • handleDragStart(event, item.id)} + onDragEnd={handleDragEnd} + onDragOver={(event) => handleDragOver(event, item.id)} + onDrop={(event) => handleDrop(event, item.id)} + > + {canReorder && isDesktop && ( + )} - {item.target_date && ( +
      + + {initiativeId ? ( + + {item.title} + + ) : ( + item.title + )} +

      - Zieltermin: {formatDate(item.target_date)} + {ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type} + {' · '} + {SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}

      - )} -
      -
      - - {initiativeId && ( - - Öffnen - - )} - {canManage && item.status !== 'reached' && ( - - )} -
      -
    • - ))} + {item.goal_description && ( +

      {item.goal_description}

      + )} + {item.target_date && ( +

      + Zieltermin: {formatDate(item.target_date)} +

      + )} +
    +
    + + {canReorder && !isDesktop && ( + + )} + {initiativeId && ( + + Öffnen + + )} + {canManage && item.status !== 'reached' && ( + + )} +
    +
  • + ) + })}
setShowCreate(false)}> diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx index 8d667e5..8795fbb 100644 --- a/frontend/src/context/InitiativeOperationsContext.jsx +++ b/frontend/src/context/InitiativeOperationsContext.jsx @@ -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, diff --git a/frontend/src/pages/initiative/InitiativePlanPage.jsx b/frontend/src/pages/initiative/InitiativePlanPage.jsx index ac9c2fa..04fc506 100644 --- a/frontend/src/pages/initiative/InitiativePlanPage.jsx +++ b/frontend/src/pages/initiative/InitiativePlanPage.jsx @@ -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} />