feat(plan): Gate-Designer UX — Typ ändern, Löschen, Kanten, Status
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Has been cancelled
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Has been cancelled
Macht den Zielzustands-Designer bedienbar: Overlay-Panel statt schmalem Split, Gate-Typ im Edit, Löschen mit Schutz für erreichte Gates, Kantentyp-Wechsel mit Legende und visuelle Kennzeichnung aktiv/blockiert/abgeschlossen. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b80f52cb50
commit
15f87eada2
|
|
@ -299,9 +299,13 @@ def delete_roadmap_item(
|
|||
item_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
if not roadmap_service.delete_roadmap_item(
|
||||
try:
|
||||
deleted = roadmap_service.delete_roadmap_item(
|
||||
tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
|
||||
):
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -461,6 +461,14 @@ def update_roadmap_item(
|
|||
def delete_roadmap_item(
|
||||
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
|
||||
) -> bool:
|
||||
existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
|
||||
if not existing:
|
||||
return False
|
||||
if existing.get("status") in TERMINAL_STATUSES:
|
||||
raise ValueError(
|
||||
"Erreichte oder terminal abgeschlossene Gates können nicht gelöscht werden"
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
|
|
|
|||
|
|
@ -86,6 +86,13 @@ def test_verify_reached_requires_evidence(client):
|
|||
assert ok.json()["status"] == "reached"
|
||||
assert ok.json()["verify_reason"] in ("criteria_ready", "evidence_accepted")
|
||||
|
||||
blocked_delete = client.delete(
|
||||
f"/api/roadmap-items/{item['id']}",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert blocked_delete.status_code == 400
|
||||
assert "nicht gelöscht" in blocked_delete.json()["detail"].lower() or "terminal" in blocked_delete.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_milestone_compat_api_uses_roadmap(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { gatePath } from '../utils/routes.js'
|
||||
import {
|
||||
|
|
@ -6,6 +7,8 @@ import {
|
|||
isJoinGateItem,
|
||||
} from '../constants/status.js'
|
||||
import { StatusBadge } from './StatusBadge.jsx'
|
||||
import { Modal } from './Modal.jsx'
|
||||
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return null
|
||||
|
|
@ -18,22 +21,62 @@ function formatDate(value) {
|
|||
}
|
||||
}
|
||||
|
||||
function graphNodeHint(item, graphItem, enforceBlocking) {
|
||||
if (item.status === 'reached') {
|
||||
return { label: 'Abgeschlossen', className: 'status-pill status-pill--done' }
|
||||
}
|
||||
if (enforceBlocking && graphItem?.blocked) {
|
||||
return { label: 'Blockiert', className: 'status-pill status-pill--blocked' }
|
||||
}
|
||||
if (graphItem?.ready || item.status === 'active') {
|
||||
return { label: 'Aktiv / bereit', className: 'status-pill status-pill--ready' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function GateDesignerNodePanel({
|
||||
item,
|
||||
graphState,
|
||||
criteriaProgress,
|
||||
canManage = false,
|
||||
busy = false,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}) {
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
|
||||
if (!item) return null
|
||||
|
||||
const joinGate = isJoinGateItem(item)
|
||||
const graphItem = graphState?.items?.[item.id]
|
||||
const enforceBlocking = graphState?.graph_profile?.enforce_gate_blocking !== false
|
||||
const statusHint = graphNodeHint(item, graphItem, enforceBlocking)
|
||||
const terminalStatus = ['reached', 'moved', 'discarded'].includes(item.status)
|
||||
const canDelete = canManage && !terminalStatus && typeof onDelete === 'function'
|
||||
const canEdit = canManage && !terminalStatus && typeof onUpdate === 'function'
|
||||
|
||||
const progress = criteriaProgress?.[item.id]
|
||||
const closed = progress?.closed ?? 0
|
||||
const total = progress?.total ?? 0
|
||||
|
||||
async function handleEditSubmit(payload) {
|
||||
await onUpdate(item.id, payload)
|
||||
setShowEdit(false)
|
||||
}
|
||||
|
||||
async function handleDeleteClick() {
|
||||
const label = item.title || 'dieses Gate'
|
||||
const ok = window.confirm(
|
||||
`„${label}" wirklich löschen? Verknüpfte Kanten werden mit entfernt.`,
|
||||
)
|
||||
if (!ok) return
|
||||
await onDelete(item.id)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="gate-designer-node-panel" aria-label="Gate-Informationen">
|
||||
<div className="gate-designer-node-panel__header">
|
||||
<h3 className="gate-designer-node-panel__title">{item.title || 'Gate'}</h3>
|
||||
|
|
@ -47,6 +90,12 @@ export function GateDesignerNodePanel({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{statusHint && (
|
||||
<p className="gate-designer-node-panel__status-hint">
|
||||
<span className={statusHint.className}>{statusHint.label}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<dl className="gate-designer-node-panel__meta">
|
||||
<div>
|
||||
<dt>Typ</dt>
|
||||
|
|
@ -97,11 +146,51 @@ export function GateDesignerNodePanel({
|
|||
</p>
|
||||
)}
|
||||
|
||||
{terminalStatus && (
|
||||
<p className="gate-designer-node-panel__topology muted">
|
||||
{item.status === 'reached'
|
||||
? 'Abgeschlossene Gates sind geschützt und können nicht gelöscht oder in den Typ geändert werden.'
|
||||
: `Terminaler Status (${MILESTONE_STATUS_LABELS[item.status] || item.status}) — Löschen gesperrt.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="gate-designer-node-panel__actions">
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => setShowEdit(true)}
|
||||
>
|
||||
Typ & Metadaten
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm gate-designer-node-panel__delete"
|
||||
disabled={busy}
|
||||
onClick={handleDeleteClick}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
<Link to={gatePath(item.id)} className="btn btn-secondary btn-sm">
|
||||
Vollständig bearbeiten
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Modal open={showEdit} title="Gate bearbeiten" onClose={() => setShowEdit(false)}>
|
||||
<RoadmapItemForm
|
||||
mode="edit"
|
||||
initial={item}
|
||||
busy={busy}
|
||||
submitLabel="Speichern"
|
||||
onCancel={() => setShowEdit(false)}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,14 @@ import {
|
|||
loadManualGatePositions,
|
||||
storeManualGatePositions,
|
||||
} from '../plan/gateGraphDesigner.js'
|
||||
import { DESIGNER_EDGE_KINDS, DESIGNER_EDGE_LABELS, edgeKindNeedsGroupKey, resolveDependencyEndpoints } from '../plan/gateGraphTopology.js'
|
||||
import {
|
||||
DESIGNER_EDGE_KINDS,
|
||||
DESIGNER_EDGE_DESCRIPTIONS,
|
||||
DESIGNER_EDGE_LABELS,
|
||||
dependencyVisualEndpoints,
|
||||
edgeKindNeedsGroupKey,
|
||||
resolveDependencyEndpoints,
|
||||
} from '../plan/gateGraphTopology.js'
|
||||
import { LoadingState } from './LoadingState.jsx'
|
||||
import { ErrorState } from './ErrorState.jsx'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
|
|
@ -61,6 +68,8 @@ export function GateGraphDesigner({
|
|||
canManage = false,
|
||||
busy: busyProp = false,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onGraphChanged,
|
||||
graphState,
|
||||
}) {
|
||||
|
|
@ -147,6 +156,21 @@ export function GateGraphDesigner({
|
|||
[items, selectedNodeId],
|
||||
)
|
||||
|
||||
const selectedDependency = useMemo(
|
||||
() => dependencies.find((dep) => dep.id === selectedEdgeId) || null,
|
||||
[dependencies, selectedEdgeId],
|
||||
)
|
||||
|
||||
const itemTitleById = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const item of items) {
|
||||
map.set(item.id, item.title || 'Gate')
|
||||
}
|
||||
return map
|
||||
}, [items])
|
||||
|
||||
const enforceBlocking = graphState?.graph_profile?.enforce_gate_blocking !== false
|
||||
|
||||
async function notifyGraphChanged() {
|
||||
await reloadDependencies()
|
||||
onGraphChanged?.()
|
||||
|
|
@ -200,6 +224,74 @@ export function GateGraphDesigner({
|
|||
}
|
||||
}
|
||||
|
||||
async function handleChangeEdgeType(newType) {
|
||||
if (!selectedDependency || !canManage) return
|
||||
if (newType === selectedDependency.dependency_type) return
|
||||
const effectiveGroupKey = (selectedDependency.group_key || groupKey || 'phase-1').trim()
|
||||
if (edgeKindNeedsGroupKey(newType) && !effectiveGroupKey) {
|
||||
setError('Parallelgruppe benötigt einen Schlüssel (group_key).')
|
||||
return
|
||||
}
|
||||
setBusyLocal(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { sourceId, targetId } = dependencyVisualEndpoints(selectedDependency)
|
||||
await deleteRoadmapDependency(selectedDependency.id)
|
||||
const { from_item_id: fromItemId, to_item_id: toItemId } = resolveDependencyEndpoints(
|
||||
newType,
|
||||
sourceId,
|
||||
targetId,
|
||||
)
|
||||
const body = {
|
||||
to_item_id: toItemId,
|
||||
dependency_type: newType,
|
||||
}
|
||||
if (edgeKindNeedsGroupKey(newType)) {
|
||||
body.group_key = effectiveGroupKey
|
||||
}
|
||||
await addRoadmapItemDependency(fromItemId, body)
|
||||
setEdgeType(newType)
|
||||
if (edgeKindNeedsGroupKey(newType)) {
|
||||
setGroupKey(effectiveGroupKey)
|
||||
}
|
||||
await notifyGraphChanged()
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Kantentyp konnte nicht geändert werden.')
|
||||
} finally {
|
||||
setBusyLocal(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteGate(itemId) {
|
||||
if (!onDelete || !canManage) return
|
||||
setBusyLocal(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onDelete(itemId)
|
||||
setSelectedNodeId(null)
|
||||
onGraphChanged?.()
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Gate konnte nicht gelöscht werden.')
|
||||
} finally {
|
||||
setBusyLocal(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateGate(itemId, payload) {
|
||||
if (!onUpdate || !canManage) return
|
||||
setBusyLocal(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onUpdate(itemId, payload)
|
||||
onGraphChanged?.()
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Gate konnte nicht gespeichert werden.')
|
||||
throw err
|
||||
} finally {
|
||||
setBusyLocal(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateAt(point) {
|
||||
setCreatePosition({
|
||||
x: Math.max(0, point.x - GATE_NODE_WIDTH / 2),
|
||||
|
|
@ -514,11 +606,46 @@ export function GateGraphDesigner({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="gate-graph-designer__legends">
|
||||
<div className="gate-map-view__legend muted gate-graph-designer__legend">
|
||||
<span className="gate-map-view__legend-item">
|
||||
<span className="gate-map-view__legend-line gate-map-view__legend-line--requires" />
|
||||
Voraussetzung
|
||||
</span>
|
||||
<span className="gate-map-view__legend-item">
|
||||
<span className="gate-map-view__legend-line gate-map-view__legend-line--blocks" />
|
||||
Blockiert
|
||||
</span>
|
||||
<span className="gate-map-view__legend-item">
|
||||
<span className="gate-map-view__legend-line gate-map-view__legend-line--related" />
|
||||
Bezug
|
||||
</span>
|
||||
<span className="gate-map-view__legend-item">
|
||||
<span className="gate-map-view__legend-line gate-map-view__legend-line--parallel" />
|
||||
Parallel
|
||||
</span>
|
||||
<span className="gate-map-view__legend-item">
|
||||
<span className="gate-map-view__legend-line gate-map-view__legend-line--optional" />
|
||||
Optional
|
||||
</span>
|
||||
</div>
|
||||
<div className="gate-graph-designer__node-legend muted">
|
||||
<span className="gate-graph-designer__node-legend-item gate-graph-designer__node-legend-item--reached">
|
||||
Abgeschlossen
|
||||
</span>
|
||||
<span className="gate-graph-designer__node-legend-item gate-graph-designer__node-legend-item--ready">
|
||||
Aktiv / bereit
|
||||
</span>
|
||||
<span className="gate-graph-designer__node-legend-item gate-graph-designer__node-legend-item--blocked">
|
||||
Blockiert
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<p className="gate-graph-designer__hint muted">
|
||||
Vom unteren Punkt ziehen: Voraussetzung/Block/Bezug/Parallel/Optional. Join-Gates
|
||||
verbinden mehrere Pflicht-Voraussetzungen (AND). Doppelklick auf leere Fläche legt ein
|
||||
Gate an.
|
||||
Vom unteren Punkt ziehen: Kante anlegen. Klick auf Kante oder Gate öffnet Bearbeitung
|
||||
rechts. Join-Gates sammeln Eingänge (AND). Doppelklick auf leere Fläche legt ein Gate an.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -586,10 +713,8 @@ export function GateGraphDesigner({
|
|||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (canManage) {
|
||||
setSelectedEdgeId(edge.id)
|
||||
setSelectedNodeId(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -607,12 +732,24 @@ export function GateGraphDesigner({
|
|||
{layout.nodes.map((node) => {
|
||||
const item = node.item
|
||||
const isSelected = selectedNodeId === node.id
|
||||
const nodeGraph = graphState?.items?.[node.id]
|
||||
const nodeClass =
|
||||
'gate-map-view__node gate-map-view__node--' +
|
||||
(item.status || 'planned') +
|
||||
(item.item_type === 'join_gate' ? ' gate-map-view__node--join_gate' : '') +
|
||||
(enforceBlocking && nodeGraph?.blocked ? ' gate-map-view__node--blocked' : '') +
|
||||
(nodeGraph?.ready ? ' gate-map-view__node--ready' : '') +
|
||||
(item.status === 'reached' ? ' gate-map-view__node--reached' : '') +
|
||||
(isSelected ? ' gate-graph-designer__node--selected' : '') +
|
||||
(draggingNodeId === node.id ? ' gate-graph-designer__node--dragging' : '')
|
||||
const statusLabel =
|
||||
item.status === 'reached'
|
||||
? '✓'
|
||||
: enforceBlocking && nodeGraph?.blocked
|
||||
? '⏸'
|
||||
: nodeGraph?.ready || item.status === 'active'
|
||||
? '●'
|
||||
: null
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
|
|
@ -628,12 +765,27 @@ export function GateGraphDesigner({
|
|||
rx="8"
|
||||
ry="8"
|
||||
/>
|
||||
{statusLabel && (
|
||||
<text
|
||||
className="gate-graph-designer__node-status-mark"
|
||||
x={node.width - 14}
|
||||
y="18"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{statusLabel}
|
||||
</text>
|
||||
)}
|
||||
<text className="gate-map-view__node-title" x="12" y="28">
|
||||
{truncateTitle(item.title)}
|
||||
</text>
|
||||
<text className="gate-map-view__node-meta" x="12" y="50">
|
||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'}
|
||||
</text>
|
||||
{item.status === 'reached' && (
|
||||
<text className="gate-map-view__node-meta gate-graph-designer__node-reached-label" x="12" y="68">
|
||||
Erreicht
|
||||
</text>
|
||||
)}
|
||||
{canManage && (
|
||||
<circle
|
||||
className="gate-graph-designer__port"
|
||||
|
|
@ -649,15 +801,92 @@ export function GateGraphDesigner({
|
|||
</svg>
|
||||
</div>
|
||||
|
||||
{selectedItem && (
|
||||
{(selectedItem || selectedDependency) && (
|
||||
<div className="gate-graph-designer__side-panel">
|
||||
{selectedDependency && (
|
||||
<aside className="gate-designer-edge-panel" aria-label="Kanten-Bearbeitung">
|
||||
<div className="gate-designer-node-panel__header">
|
||||
<h3 className="gate-designer-node-panel__title">Kante</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
aria-label="Panel schließen"
|
||||
onClick={() => setSelectedEdgeId(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted gate-designer-edge-panel__route">
|
||||
{(() => {
|
||||
const { sourceId, targetId } = dependencyVisualEndpoints(selectedDependency)
|
||||
return `${itemTitleById.get(sourceId) || sourceId} → ${itemTitleById.get(targetId) || targetId}`
|
||||
})()}
|
||||
</p>
|
||||
{canManage ? (
|
||||
<>
|
||||
<label className="gate-graph-designer__edge-type">
|
||||
<span className="muted">Kantentyp</span>
|
||||
<select
|
||||
value={selectedDependency.dependency_type}
|
||||
disabled={busy}
|
||||
onChange={(e) => handleChangeEdgeType(e.target.value)}
|
||||
>
|
||||
{DESIGNER_EDGE_KINDS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{DESIGNER_EDGE_LABELS[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{edgeKindNeedsGroupKey(selectedDependency.dependency_type) && (
|
||||
<label className="gate-graph-designer__edge-type">
|
||||
<span className="muted">Gruppenschlüssel</span>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedDependency.group_key || groupKey}
|
||||
maxLength={128}
|
||||
disabled={busy}
|
||||
placeholder="z. B. phase-1"
|
||||
onChange={(e) => setGroupKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<p className="gate-designer-edge-panel__help muted">
|
||||
{DESIGNER_EDGE_DESCRIPTIONS[selectedDependency.dependency_type]}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm gate-designer-node-panel__delete"
|
||||
disabled={busy}
|
||||
onClick={() => handleDeleteEdge(selectedDependency.id)}
|
||||
>
|
||||
Kante entfernen
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">
|
||||
{DESIGNER_EDGE_LABELS[selectedDependency.dependency_type]} —{' '}
|
||||
{DESIGNER_EDGE_DESCRIPTIONS[selectedDependency.dependency_type]}
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{selectedItem && !selectedDependency && (
|
||||
<GateDesignerNodePanel
|
||||
item={selectedItem}
|
||||
graphState={graphState}
|
||||
criteriaProgress={criteriaProgress}
|
||||
canManage={canManage}
|
||||
busy={busy}
|
||||
onClose={() => setSelectedNodeId(null)}
|
||||
onUpdate={handleUpdateGate}
|
||||
onDelete={handleDeleteGate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal open={showCreate} title="Zielzustand anlegen" onClose={() => setShowCreate(false)}>
|
||||
<RoadmapItemForm
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export function GatesPlanPanel({
|
|||
items,
|
||||
canManage,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onReorder,
|
||||
onDelete,
|
||||
busy,
|
||||
|
|
@ -129,6 +130,8 @@ export function GatesPlanPanel({
|
|||
canManage={canManage}
|
||||
busy={busy}
|
||||
onCreate={canManage ? onCreate : undefined}
|
||||
onUpdate={canManage ? onUpdate : undefined}
|
||||
onDelete={canManage ? onDelete : undefined}
|
||||
onGraphChanged={refreshGraphState}
|
||||
graphState={graphState}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -44,16 +44,18 @@ export function RoadmapItemForm({
|
|||
}
|
||||
|
||||
const statusLocked = ['reached', 'moved', 'discarded'].includes(initial.status)
|
||||
const showTypeField = mode === 'create' || (mode === 'edit' && !statusLocked)
|
||||
|
||||
return (
|
||||
<form className="form workspace-form roadmap-item-form" onSubmit={handleSubmit}>
|
||||
{mode === 'create' && (
|
||||
{showTypeField && (
|
||||
<label>
|
||||
Typ
|
||||
<select
|
||||
name="item_type"
|
||||
value={itemType}
|
||||
onChange={(e) => setItemType(e.target.value)}
|
||||
disabled={statusLocked}
|
||||
>
|
||||
{ROADMAP_ITEM_TYPES_FOR_DESIGNER.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
|
|
@ -67,6 +69,11 @@ export function RoadmapItemForm({
|
|||
manuelles Verify.
|
||||
</span>
|
||||
)}
|
||||
{mode === 'edit' && !isJoinGate && initial.item_type === 'join_gate' && (
|
||||
<span className="muted form-hint">
|
||||
Beim Wechsel von Join zu Gate: Checkliste auf der Detailseite ergänzen.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export function InitiativePlanPage() {
|
|||
capabilities,
|
||||
formBusy,
|
||||
handleCreateRoadmapItem,
|
||||
handleUpdateRoadmapItem,
|
||||
handleReorderRoadmapItems,
|
||||
handleDeleteRoadmapItem,
|
||||
handleConvertBacklog,
|
||||
|
|
@ -45,6 +46,7 @@ export function InitiativePlanPage() {
|
|||
items={roadmapItems}
|
||||
canManage={capabilities.has('kairo.milestone.manage')}
|
||||
onCreate={handleCreateRoadmapItem}
|
||||
onUpdate={handleUpdateRoadmapItem}
|
||||
onReorder={handleReorderRoadmapItems}
|
||||
onDelete={handleDeleteRoadmapItem}
|
||||
busy={formBusy}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { createInitiativeEvidence } from '../../api/evidence.js'
|
||||
import {
|
||||
getRoadmapItem,
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
listRoadmapItemDependencies,
|
||||
addRoadmapItemDependency,
|
||||
deleteRoadmapDependency,
|
||||
deleteRoadmapItem,
|
||||
listInitiativeRoadmapItems,
|
||||
} from '../../api/roadmap.js'
|
||||
import { ErrorState } from '../../components/ErrorState.jsx'
|
||||
|
|
@ -32,6 +33,7 @@ import { RoadmapItemForm } from '../../components/RoadmapItemForm.jsx'
|
|||
|
||||
export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||
const { id: routeInitiativeId, itemId: routeItemId, gateId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const ops = useInitiativeOperations()
|
||||
const initiativeId = routeInitiativeId || ops?.initiativeId
|
||||
const itemId = overrideItemId || routeItemId || gateId
|
||||
|
|
@ -180,6 +182,25 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
Stammdaten
|
||||
</button>
|
||||
)}
|
||||
{canManage && !['reached', 'moved', 'discarded'].includes(item.status) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm gate-designer-node-panel__delete"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
const ok = window.confirm(
|
||||
`„${item.title}" wirklich löschen? Verknüpfte Kanten werden mit entfernt.`,
|
||||
)
|
||||
if (!ok) return
|
||||
await runAction(async () => {
|
||||
await deleteRoadmapItem(itemId)
|
||||
navigate(scopedPath('/plan/gates', { initiativeId }))
|
||||
})
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,19 @@ export const DESIGNER_EDGE_LABELS = {
|
|||
optional_branch: 'Optional',
|
||||
}
|
||||
|
||||
/** Kurz-Erklärung der Kantentypen (Semantik from → to in der DB). */
|
||||
export const DESIGNER_EDGE_DESCRIPTIONS = {
|
||||
requires:
|
||||
'Ziel-Gate benötigt Quell-Gate als erreicht (Voraussetzung). Join: alle Eingänge müssen erreicht sein.',
|
||||
blocks:
|
||||
'Quell-Gate blockiert Ziel-Gate, solange es noch aktiv ist (planned/active/at_risk).',
|
||||
related: 'Informativer Bezug — keine Blockade im Graph.',
|
||||
parallel_group:
|
||||
'Markiert parallele Gates in einer Gruppe (group_key) — blockiert nicht, dient der Struktur.',
|
||||
optional_branch:
|
||||
'Wie Voraussetzung, aber optional — blockiert das Ziel-Gate nicht, wenn offen.',
|
||||
}
|
||||
|
||||
/** @deprecated use DESIGNER_EDGE_KINDS */
|
||||
export const DEFERRED_TOPOLOGY_EDGE_KINDS = ['parallel_group', 'optional_branch']
|
||||
|
||||
|
|
@ -40,3 +53,13 @@ export function resolveDependencyEndpoints(edgeKind, sourceId, targetId) {
|
|||
}
|
||||
return { from_item_id: sourceId, to_item_id: targetId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Visuelle Drag-Richtung (Quelle → Ziel) aus gespeicherter Abhängigkeit.
|
||||
*/
|
||||
export function dependencyVisualEndpoints(dep) {
|
||||
if (dep.dependency_type === 'requires') {
|
||||
return { sourceId: dep.to_item_id, targetId: dep.from_item_id }
|
||||
}
|
||||
return { sourceId: dep.from_item_id, targetId: dep.to_item_id }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||
import {
|
||||
DEFERRED_TOPOLOGY_EDGE_KINDS,
|
||||
DESIGNER_EDGE_KINDS,
|
||||
dependencyVisualEndpoints,
|
||||
edgeKindNeedsGroupKey,
|
||||
resolveDependencyEndpoints,
|
||||
} from './gateGraphTopology.js'
|
||||
|
|
@ -31,4 +32,21 @@ describe('gateGraphTopology', () => {
|
|||
to_item_id: 'b',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves visual endpoints from stored dependency', () => {
|
||||
expect(
|
||||
dependencyVisualEndpoints({
|
||||
from_item_id: 'b',
|
||||
to_item_id: 'a',
|
||||
dependency_type: 'requires',
|
||||
}),
|
||||
).toEqual({ sourceId: 'a', targetId: 'b' })
|
||||
expect(
|
||||
dependencyVisualEndpoints({
|
||||
from_item_id: 'a',
|
||||
to_item_id: 'b',
|
||||
dependency_type: 'blocks',
|
||||
}),
|
||||
).toEqual({ sourceId: 'a', targetId: 'b' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2157,14 +2157,38 @@
|
|||
}
|
||||
|
||||
.gate-graph-designer__main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.gate-graph-designer__main .gate-graph-designer__viewport {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gate-graph-designer__side-panel {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(360px, calc(100% - 1rem));
|
||||
max-width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gate-graph-designer__side-panel > * {
|
||||
pointer-events: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.gate-graph-designer__empty-actions {
|
||||
|
|
@ -2193,17 +2217,117 @@
|
|||
}
|
||||
|
||||
.gate-designer-node-panel {
|
||||
flex: 0 0 240px;
|
||||
max-width: 280px;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--jk-border, #dde3ea);
|
||||
border-radius: 8px;
|
||||
background: var(--jk-surface, #fff);
|
||||
align-self: flex-start;
|
||||
max-height: 70vh;
|
||||
align-self: stretch;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gate-designer-edge-panel {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--jk-border, #dde3ea);
|
||||
border-radius: 8px;
|
||||
background: var(--jk-surface, #fff);
|
||||
}
|
||||
|
||||
.gate-designer-edge-panel__route {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.gate-designer-edge-panel__help {
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__status-hint {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__delete {
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gate-graph-designer__legends {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.gate-graph-designer__legend {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gate-graph-designer__node-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.gate-graph-designer__node-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.gate-graph-designer__node-legend-item::before {
|
||||
content: '';
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 3px;
|
||||
border: 1.5px solid var(--jk-border-strong, #c5ced8);
|
||||
}
|
||||
|
||||
.gate-graph-designer__node-legend-item--reached::before {
|
||||
background: #f0fdf4;
|
||||
border-color: #16a34a;
|
||||
}
|
||||
|
||||
.gate-graph-designer__node-legend-item--ready::before {
|
||||
background: #ecfdf5;
|
||||
border-color: #059669;
|
||||
}
|
||||
|
||||
.gate-graph-designer__node-legend-item--blocked::before {
|
||||
background: #fef2f2;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.gate-graph-designer__node-status-mark {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
fill: var(--jk-text-secondary, #445);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gate-map-view__node--reached .gate-graph-designer__node-reached-label {
|
||||
fill: #15803d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-pill--done {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -2250,12 +2374,6 @@
|
|||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gate-graph-designer__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user