Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 2m18s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
generic_operating ohne harte Blockade, Reifegrad-Methoden mit Erfuellungsgrad; Attention gate_graph_blocked fuer gate-orientierte Methoden. Co-authored-by: Cursor <cursoragent@cursor.com>
253 lines
8.4 KiB
JavaScript
253 lines
8.4 KiB
JavaScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { gatePath } from '../utils/routes.js'
|
|
import { listInitiativeRoadmapCriteriaProgress, listInitiativeRoadmapGraphState } from '../api/roadmap.js'
|
|
import { ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js'
|
|
import { StatusBadge } from './StatusBadge.jsx'
|
|
import { GateGraphStateBadge } from './GateGraphStateBadge.jsx'
|
|
import { GateFulfillmentBadge } from './GateFulfillmentBadge.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
|
|
try {
|
|
return new Date(value.includes('T') ? value : `${value}T12:00:00`).toLocaleDateString(
|
|
'de-DE',
|
|
)
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
export function RoadmapPlanSection({
|
|
initiativeId,
|
|
items,
|
|
canManage,
|
|
onCreate,
|
|
onReorder,
|
|
onDelete,
|
|
busy,
|
|
graphState: graphStateProp,
|
|
}) {
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [dragItemId, setDragItemId] = useState('')
|
|
const [dropTargetId, setDropTargetId] = useState('')
|
|
const [criteriaProgress, setCriteriaProgress] = useState({})
|
|
const [graphStateLocal, setGraphStateLocal] = useState(null)
|
|
const graphState = graphStateProp ?? graphStateLocal
|
|
const isDesktop = useMinWidth(1024)
|
|
const canReorder = canManage && typeof onReorder === 'function'
|
|
|
|
useEffect(() => {
|
|
if (!initiativeId) {
|
|
setCriteriaProgress({})
|
|
if (!graphStateProp) setGraphStateLocal(null)
|
|
return undefined
|
|
}
|
|
let cancelled = false
|
|
const loaders = [listInitiativeRoadmapCriteriaProgress(initiativeId)]
|
|
if (!graphStateProp) {
|
|
loaders.push(listInitiativeRoadmapGraphState(initiativeId))
|
|
}
|
|
Promise.all(loaders)
|
|
.then((results) => {
|
|
if (cancelled) return
|
|
setCriteriaProgress(results[0] || {})
|
|
if (!graphStateProp && results[1]) {
|
|
setGraphStateLocal(results[1] || null)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setCriteriaProgress({})
|
|
if (!graphStateProp) setGraphStateLocal(null)
|
|
}
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [initiativeId, items, graphStateProp])
|
|
|
|
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. Reihenfolge per Drag & Drop (Desktop) oder
|
|
↑/↓ (Mobile). Bearbeitung und Verify auf der Gate-Detailseite.
|
|
</p>
|
|
</div>
|
|
{canManage && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-block-mobile"
|
|
onClick={() => setShowCreate(true)}
|
|
>
|
|
Plan-Element
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{items.length === 0 && (
|
|
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
|
|
)}
|
|
|
|
<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
|
|
const progress = criteriaProgress[item.id]
|
|
const progressLabel =
|
|
progress && progress.total > 0
|
|
? `${progress.closed}/${progress.total} Kriterien`
|
|
: null
|
|
|
|
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 ? (
|
|
<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}
|
|
</p>
|
|
{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>
|
|
)}
|
|
{progressLabel && (
|
|
<p className="muted list-item-sub">Checkliste: {progressLabel}</p>
|
|
)}
|
|
</div>
|
|
<div className="list-item-meta action-controls">
|
|
<StatusBadge kind="milestone" status={item.status} />
|
|
<GateGraphStateBadge
|
|
graphState={graphState}
|
|
itemId={item.id}
|
|
siblingItems={sortedItems}
|
|
/>
|
|
<GateFulfillmentBadge graphState={graphState} itemId={item.id} />
|
|
{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)}>
|
|
<RoadmapItemForm
|
|
onSubmit={handleCreateSubmit}
|
|
onCancel={() => setShowCreate(false)}
|
|
busy={busy}
|
|
submitLabel="Anlegen"
|
|
mode="create"
|
|
/>
|
|
</Modal>
|
|
</section>
|
|
)
|
|
}
|