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

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:
Lars 2026-07-29 08:39:15 +02:00
parent b80f52cb50
commit 15f87eada2
12 changed files with 617 additions and 88 deletions

View File

@ -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(
tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
):
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")

View File

@ -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:

View File

@ -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")

View File

@ -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,90 +21,176 @@ 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
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>
<button
type="button"
className="btn btn-secondary btn-sm"
aria-label="Panel schließen"
onClick={onClose}
>
×
</button>
</div>
async function handleEditSubmit(payload) {
await onUpdate(item.id, payload)
setShowEdit(false)
}
<dl className="gate-designer-node-panel__meta">
<div>
<dt>Typ</dt>
<dd>{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'}</dd>
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>
<button
type="button"
className="btn btn-secondary btn-sm"
aria-label="Panel schließen"
onClick={onClose}
>
×
</button>
</div>
<div>
<dt>Status</dt>
<dd>
<StatusBadge status={item.status || 'planned'} />
</dd>
</div>
{item.target_date && (
<div>
<dt>Zieltermin</dt>
<dd>{formatDate(item.target_date)}</dd>
</div>
{statusHint && (
<p className="gate-designer-node-panel__status-hint">
<span className={statusHint.className}>{statusHint.label}</span>
</p>
)}
{total > 0 && (
<dl className="gate-designer-node-panel__meta">
<div>
<dt>Kriterien</dt>
<dt>Typ</dt>
<dd>{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'}</dd>
</div>
<div>
<dt>Status</dt>
<dd>
{closed}/{total} erfüllt
<StatusBadge status={item.status || 'planned'} />
</dd>
</div>
{item.target_date && (
<div>
<dt>Zieltermin</dt>
<dd>{formatDate(item.target_date)}</dd>
</div>
)}
{total > 0 && (
<div>
<dt>Kriterien</dt>
<dd>
{closed}/{total} erfüllt
</dd>
</div>
)}
</dl>
{joinGate && (
<p className="gate-designer-node-panel__topology muted">
Schaltknoten schaltet, wenn alle Eingänge (requires) erreicht sind. Keine Checkliste,
kein manuelles Verify.
</p>
)}
</dl>
{joinGate && (
<p className="gate-designer-node-panel__topology muted">
Schaltknoten schaltet, wenn alle Eingänge (requires) erreicht sind. Keine Checkliste,
kein manuelles Verify.
</p>
)}
{item.goal_description && (
<p className="gate-designer-node-panel__goal muted">{item.goal_description}</p>
)}
{item.goal_description && (
<p className="gate-designer-node-panel__goal muted">{item.goal_description}</p>
)}
{graphItem?.parallel_group_keys?.length > 0 && (
<p className="gate-designer-node-panel__topology muted">
Parallel: {graphItem.parallel_group_keys.join(', ')}
</p>
)}
{graphItem?.parallel_group_keys?.length > 0 && (
<p className="gate-designer-node-panel__topology muted">
Parallel: {graphItem.parallel_group_keys.join(', ')}
</p>
)}
{graphItem?.optional_prerequisites?.length > 0 && (
<p className="gate-designer-node-panel__topology muted">
Optional: {graphItem.optional_prerequisites.length} Zweig(e) blockieren nicht
</p>
)}
{graphItem?.optional_prerequisites?.length > 0 && (
<p className="gate-designer-node-panel__topology muted">
Optional: {graphItem.optional_prerequisites.length} Zweig(e) blockieren nicht
</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">
<Link to={gatePath(item.id)} className="btn btn-secondary btn-sm">
Vollständig bearbeiten
</Link>
</div>
</aside>
<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>
</>
)
}

View File

@ -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)
}
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,13 +801,90 @@ export function GateGraphDesigner({
</svg>
</div>
{selectedItem && (
<GateDesignerNodePanel
item={selectedItem}
graphState={graphState}
criteriaProgress={criteriaProgress}
onClose={() => setSelectedNodeId(null)}
/>
{(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>

View File

@ -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}
/>

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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 }
}

View File

@ -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' })
})
})

View File

@ -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;