AP1.15b: Designer UX mit Drag-Kanten, Gate anlegen und Info-Panel.
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
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0c2ca4a69d
commit
364ad44759
36
backend/steering/graph/join_branch.py
Normal file
36
backend/steering/graph/join_branch.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""Join- und Branch-Topologie — Reservierung für AP1.15c+.
|
||||
|
||||
Heute blockiert `roadmap_engine.compute_initiative_graph_state` nur über
|
||||
`requires` und `blocks`. `parallel_group` und `optional_branch` sind im Schema
|
||||
vorhanden, werden aber noch nicht für blocked/ready ausgewertet.
|
||||
|
||||
Erweiterungspunkt: neue Auswertung hier kapseln, Engine ruft dann
|
||||
`apply_topology_to_blocked_map(...)` auf — ohne Router- oder UI-Sonderlogik.
|
||||
|
||||
Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md § Join/Branch deferred.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Im Schema/API vorhanden; Engine-Logik folgt in AP1.15c
|
||||
DEFERRED_TOPOLOGY_EDGE_KINDS = frozenset({"parallel_group", "optional_branch"})
|
||||
|
||||
# Designer AP1.15c; OR-Pfade nur nach validiertem Use Case + PO-Freigabe
|
||||
PLANNED_TOPOLOGY_PACKAGES = ("AP1.15c",)
|
||||
|
||||
|
||||
def apply_deferred_topology(
|
||||
*,
|
||||
dependencies: list[dict[str, Any]],
|
||||
item_by_id: dict[str, dict[str, Any]],
|
||||
blocked_by_map: dict[str, list[str]],
|
||||
) -> dict[str, list[str]]:
|
||||
"""
|
||||
Erweitert blocked_by_map um parallel_group / optional_branch (AP1.15c).
|
||||
|
||||
Aktuell: No-Op — gibt blocked_by_map unverändert zurück.
|
||||
"""
|
||||
_ = (dependencies, item_by_id)
|
||||
return blocked_by_map
|
||||
|
|
@ -10,6 +10,7 @@ from steering.graph.profiles import (
|
|||
get_graph_profile,
|
||||
graph_profile_as_dict,
|
||||
)
|
||||
from steering.graph.join_branch import apply_deferred_topology
|
||||
|
||||
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
|
||||
ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"})
|
||||
|
|
@ -106,6 +107,12 @@ def compute_initiative_graph_state(
|
|||
if prereq_id not in blocked_by_map[item_id]:
|
||||
blocked_by_map[item_id].append(prereq_id)
|
||||
|
||||
blocked_by_map = apply_deferred_topology(
|
||||
dependencies=dependencies,
|
||||
item_by_id=item_by_id,
|
||||
blocked_by_map=blocked_by_map,
|
||||
)
|
||||
|
||||
item_states: dict[str, dict[str, Any]] = {}
|
||||
blocked_items: list[str] = []
|
||||
ready_items: list[str] = []
|
||||
|
|
|
|||
|
|
@ -221,6 +221,34 @@ Plan-UI: **Detail-Modal/Route** `/initiatives/:id/plan/items/:itemId` — Checkl
|
|||
|
||||
| **AP1.14** (vorgeschlagen) | Plan-Snapshot, Ist-Overlay, Diff, Plan-Revision mit Audit |
|
||||
| **AP1.15** (vorgeschlagen) | Zielzustands-Modellierungswerkzeug (Canvas-Designer) |
|
||||
| **AP1.15a** | Designer MVP: Pan/Zoom, Kanten (Klick), Layout localStorage |
|
||||
| **AP1.15b** | UX: Drag-Kanten, Gate anlegen, Klick-Panel (ohne Navigation) |
|
||||
| **AP1.15c** | Topologie: `parallel_group`, `optional_branch`, Join-Semantik in Engine + Designer |
|
||||
|
||||
---
|
||||
|
||||
## Join / Branch — bewusst zurückgestellt, vorbereitet (PO 2026-07-11)
|
||||
|
||||
**Entscheidung:** Volle Verzweigungslogik (Parallel-Join, optionale Äste, ggf. Alternativpfade/OR) wird **nicht** im aktuellen Slice implementiert. Die Praxis soll zeigen, ob OR-Pfade nötig sind (z. B. persönliche Entwicklung). Bis dahin reicht **AND-Join** über Join-Gates mit mehreren `requires`-Kanten.
|
||||
|
||||
**Geplant (AP1.15c+):**
|
||||
|
||||
| Element | Semantik | Modell |
|
||||
|---------|----------|--------|
|
||||
| AND-Join | Alle Voraussetzungen `reached` | Join-Gate + mehrere `requires` (**heute**) |
|
||||
| Parallel | Gleichzeitige Stränge markieren | `parallel_group` + `group_key` |
|
||||
| Optional | Ast zählt nur wenn „committed“ | `optional_branch` |
|
||||
| OR / Alternativpfad | Eine von mehreren Voraussetzungen reicht | **Später**, nur bei validiertem Use Case |
|
||||
| Split/Join (UI) | Lesbarkeit im Designer | Visuelle Hilfen — **keine** Workflow-Knoten |
|
||||
|
||||
**Vorbereitung der Implementierung (ab jetzt):**
|
||||
|
||||
- Kantentypen bleiben in `roadmap_item_dependencies.dependency_type` — **keine** Joint-Tabelle.
|
||||
- Graph Engine: Blocking-Logik bleibt in `backend/steering/graph/`; Erweiterung über `join_branch.py` (Reservierung AP1.15c).
|
||||
- Designer: nur `requires` / `blocks` / `related` bis AP1.15c; deferred Typen in `gateGraphTopology.js`.
|
||||
- Ist-Read-Models (`blocked`, `ready`) bleiben **getrennt** vom Plan-Designer (Steuerung vs. Modellierung).
|
||||
|
||||
**Guardrail unverändert:** Zielzustands-Graph — kein Workflow, kein OR-Knoten ohne PO-Freigabe.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -105,8 +105,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
|||
| Abhängigkeiten | ◐ | API; Graph Engine AP1.4d |
|
||||
| parallel vs. sequenziell | ◐ | `sequencing_mode` (deprecated); Graph AP1.4d |
|
||||
| Plan-Graph vs. Ist-Overlay | ✗ | AP1.13: ein Graph + Status am Knoten — Plan/Ist vermischt |
|
||||
| Zielzustands-Designer (Joint/Parallel) | ✗ | AP1.4d + AP1.15; AP1.13 nur Auto-Layout |
|
||||
| Plan-Revision / Graph-Historie | ✗ | AP1.14 vorgeschlagen |
|
||||
| Zielzustands-Designer (Joint/Parallel) | ◐ | AP1.15a Designer MVP; Join/Branch → AP1.15c (deferred, vorbereitet) |
|
||||
| Plan-Revision / Graph-Historie | ◐ | AP1.14 Plan-Snapshots |
|
||||
| Verify vor `reached` | ◐ | Kriterienplan + gate_override |
|
||||
| Reopen nach `reached` | ◐ | `active`, Kriterien bleiben AP1.4b |
|
||||
| Decision bei `moved` | ✗ | |
|
||||
|
|
|
|||
|
|
@ -243,6 +243,9 @@ AP1.13b Kanten-CRUD auf Gate-Detail (kein Designer)
|
|||
AP1.4d Graph Engine: parallel_group, edge_kind, blocked/ready
|
||||
AP1.14 Plan-Snapshot / Ist-Overlay / Plan-Revision (vorgeschlagen)
|
||||
AP1.15 Zielzustands-Modellierungswerkzeug (Designer, Mitai-Pattern)
|
||||
AP1.15a Designer MVP (Pan/Zoom, Kanten, Layout)
|
||||
AP1.15b Designer UX (Drag-Kanten, Gate anlegen, Klick-Panel)
|
||||
AP1.15c Join/Branch-Topologie (parallel_group, optional_branch, Engine) — deferred, vorbereitet
|
||||
AP1.5d Tasks in Outline-Knoten „Arbeit“
|
||||
AP1.10 Archetyp + EFS für Initiative-Profil-Modal (parallel ab 10a)
|
||||
```
|
||||
|
|
|
|||
83
frontend/src/components/GateDesignerNodePanel.jsx
Normal file
83
frontend/src/components/GateDesignerNodePanel.jsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { gatePath } from '../utils/routes.js'
|
||||
import {
|
||||
MILESTONE_STATUS_LABELS,
|
||||
ROADMAP_ITEM_TYPE_LABELS,
|
||||
} from '../constants/status.js'
|
||||
import { StatusBadge } from './StatusBadge.jsx'
|
||||
|
||||
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 GateDesignerNodePanel({
|
||||
item,
|
||||
criteriaProgress,
|
||||
onClose,
|
||||
}) {
|
||||
if (!item) return null
|
||||
|
||||
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>
|
||||
|
||||
<dl className="gate-designer-node-panel__meta">
|
||||
<div>
|
||||
<dt>Typ</dt>
|
||||
<dd>{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'}</dd>
|
||||
</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>
|
||||
)}
|
||||
{total > 0 && (
|
||||
<div>
|
||||
<dt>Kriterien</dt>
|
||||
<dd>
|
||||
{closed}/{total} erfüllt
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{item.goal_description && (
|
||||
<p className="gate-designer-node-panel__goal muted">{item.goal_description}</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
addRoadmapItemDependency,
|
||||
deleteRoadmapDependency,
|
||||
listInitiativeRoadmapCriteriaProgress,
|
||||
listInitiativeRoadmapDependencies,
|
||||
} from '../api/roadmap.js'
|
||||
import { ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js'
|
||||
import { gatePath } from '../utils/routes.js'
|
||||
import { computeGateGraphLayout } from '../plan/gateGraphLayout.js'
|
||||
import { computeGateGraphLayout, GATE_NODE_HEIGHT, GATE_NODE_WIDTH } from '../plan/gateGraphLayout.js'
|
||||
import {
|
||||
applyManualGatePositions,
|
||||
clampDesignerZoom,
|
||||
|
|
@ -16,21 +15,13 @@ import {
|
|||
loadManualGatePositions,
|
||||
storeManualGatePositions,
|
||||
} from '../plan/gateGraphDesigner.js'
|
||||
import { DESIGNER_EDGE_KINDS, DESIGNER_EDGE_LABELS } from '../plan/gateGraphTopology.js'
|
||||
import { LoadingState } from './LoadingState.jsx'
|
||||
import { ErrorState } from './ErrorState.jsx'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
|
||||
const EDGE_TYPE_LABELS = {
|
||||
requires: 'Voraussetzung',
|
||||
blocks: 'Blockiert',
|
||||
related: 'Bezug',
|
||||
}
|
||||
|
||||
const CONNECT_HINTS = {
|
||||
requires: 'Schritt 1: Voraussetzung wählen · Schritt 2: abhängiges Gate',
|
||||
blocks: 'Schritt 1: Blocker wählen · Schritt 2: geblocktes Gate',
|
||||
related: 'Schritt 1: erstes Gate · Schritt 2: zweites Gate',
|
||||
}
|
||||
import { Modal } from './Modal.jsx'
|
||||
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
|
||||
import { GateDesignerNodePanel } from './GateDesignerNodePanel.jsx'
|
||||
|
||||
function edgePath(edge) {
|
||||
const midY = (edge.y1 + edge.y2) / 2
|
||||
|
|
@ -42,13 +33,6 @@ function truncateTitle(title, max = 28) {
|
|||
return `${title.slice(0, max - 1)}…`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DOMRect} rect
|
||||
* @param {{ x: number, y: number }} pan
|
||||
* @param {number} zoom
|
||||
* @param {number} clientX
|
||||
* @param {number} clientY
|
||||
*/
|
||||
function clientToGraphPoint(rect, pan, zoom, clientX, clientY) {
|
||||
return {
|
||||
x: (clientX - rect.left - pan.x) / zoom,
|
||||
|
|
@ -56,30 +40,50 @@ function clientToGraphPoint(rect, pan, zoom, clientX, clientY) {
|
|||
}
|
||||
}
|
||||
|
||||
function nodeAtPoint(nodes, point) {
|
||||
for (let i = nodes.length - 1; i >= 0; i -= 1) {
|
||||
const node = nodes[i]
|
||||
if (
|
||||
point.x >= node.x &&
|
||||
point.x <= node.x + node.width &&
|
||||
point.y >= node.y &&
|
||||
point.y <= node.y + node.height
|
||||
) {
|
||||
return node
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function GateGraphDesigner({
|
||||
initiativeId,
|
||||
items,
|
||||
canManage = false,
|
||||
busy: busyProp = false,
|
||||
onCreate,
|
||||
onGraphChanged,
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const viewportRef = useRef(null)
|
||||
const [dependencies, setDependencies] = useState([])
|
||||
const [criteriaProgress, setCriteriaProgress] = useState({})
|
||||
const [manualPositions, setManualPositions] = useState(() =>
|
||||
loadManualGatePositions(initiativeId),
|
||||
)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [tool, setTool] = useState('pan')
|
||||
const [busyLocal, setBusyLocal] = useState(false)
|
||||
const [edgeType, setEdgeType] = useState('requires')
|
||||
const [connectFromId, setConnectFromId] = useState(null)
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState(null)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createPosition, setCreatePosition] = useState(null)
|
||||
const [edgeDrag, setEdgeDrag] = useState(null)
|
||||
const [pan, setPan] = useState({ x: 24, y: 24 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [draggingNodeId, setDraggingNodeId] = useState(null)
|
||||
const dragOffsetRef = useRef({ x: 0, y: 0 })
|
||||
const panStartRef = useRef(null)
|
||||
const busy = busyProp || busyLocal
|
||||
|
||||
const reloadDependencies = useCallback(async () => {
|
||||
if (!initiativeId) {
|
||||
|
|
@ -93,13 +97,20 @@ export function GateGraphDesigner({
|
|||
useEffect(() => {
|
||||
if (!initiativeId) {
|
||||
setDependencies([])
|
||||
setCriteriaProgress({})
|
||||
setLoading(false)
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
reloadDependencies()
|
||||
Promise.all([
|
||||
reloadDependencies(),
|
||||
listInitiativeRoadmapCriteriaProgress(initiativeId).catch(() => ({})),
|
||||
])
|
||||
.then(([_, progress]) => {
|
||||
if (!cancelled) setCriteriaProgress(progress || {})
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err?.message || 'Abhängigkeiten konnten nicht geladen werden.')
|
||||
|
|
@ -111,12 +122,12 @@ export function GateGraphDesigner({
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initiativeId, reloadDependencies])
|
||||
}, [initiativeId, reloadDependencies, items])
|
||||
|
||||
useEffect(() => {
|
||||
setManualPositions(loadManualGatePositions(initiativeId))
|
||||
setConnectFromId(null)
|
||||
setSelectedEdgeId(null)
|
||||
setSelectedNodeId(null)
|
||||
}, [initiativeId])
|
||||
|
||||
const baseLayout = useMemo(
|
||||
|
|
@ -129,6 +140,11 @@ export function GateGraphDesigner({
|
|||
[baseLayout, manualPositions],
|
||||
)
|
||||
|
||||
const selectedItem = useMemo(
|
||||
() => items.find((item) => item.id === selectedNodeId) || null,
|
||||
[items, selectedNodeId],
|
||||
)
|
||||
|
||||
async function notifyGraphChanged() {
|
||||
await reloadDependencies()
|
||||
onGraphChanged?.()
|
||||
|
|
@ -136,7 +152,7 @@ export function GateGraphDesigner({
|
|||
|
||||
async function handleCreateEdge(fromId, toId) {
|
||||
if (!fromId || !toId || fromId === toId) return
|
||||
setBusy(true)
|
||||
setBusyLocal(true)
|
||||
setError(null)
|
||||
try {
|
||||
let fromItemId = fromId
|
||||
|
|
@ -149,18 +165,20 @@ export function GateGraphDesigner({
|
|||
to_item_id: toItemId,
|
||||
dependency_type: edgeType,
|
||||
})
|
||||
setConnectFromId(null)
|
||||
await notifyGraphChanged()
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Kante konnte nicht angelegt werden.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setBusyLocal(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateEdgeRef = useRef(handleCreateEdge)
|
||||
handleCreateEdgeRef.current = handleCreateEdge
|
||||
|
||||
async function handleDeleteEdge(edgeId) {
|
||||
if (!edgeId || !canManage) return
|
||||
setBusy(true)
|
||||
setBusyLocal(true)
|
||||
setError(null)
|
||||
try {
|
||||
await deleteRoadmapDependency(edgeId)
|
||||
|
|
@ -169,35 +187,69 @@ export function GateGraphDesigner({
|
|||
} catch (err) {
|
||||
setError(err?.message || 'Kante konnte nicht entfernt werden.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setBusyLocal(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateAt(point) {
|
||||
setCreatePosition({
|
||||
x: Math.max(0, point.x - GATE_NODE_WIDTH / 2),
|
||||
y: Math.max(0, point.y - GATE_NODE_HEIGHT / 2),
|
||||
})
|
||||
setShowCreate(true)
|
||||
}
|
||||
|
||||
async function handleCreateGateSubmit(payload) {
|
||||
if (!onCreate) return
|
||||
setBusyLocal(true)
|
||||
setError(null)
|
||||
try {
|
||||
const created = await onCreate(payload)
|
||||
if (created?.id && createPosition) {
|
||||
setManualPositions((prev) => {
|
||||
const next = { ...prev, [created.id]: createPosition }
|
||||
storeManualGatePositions(initiativeId, next)
|
||||
return next
|
||||
})
|
||||
setSelectedNodeId(created.id)
|
||||
}
|
||||
setShowCreate(false)
|
||||
setCreatePosition(null)
|
||||
onGraphChanged?.()
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Gate konnte nicht angelegt werden.')
|
||||
} finally {
|
||||
setBusyLocal(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeClick(nodeId, event) {
|
||||
event.stopPropagation()
|
||||
if (busy) return
|
||||
if (busy || edgeDrag) return
|
||||
setSelectedNodeId(nodeId)
|
||||
setSelectedEdgeId(null)
|
||||
}
|
||||
|
||||
if (tool === 'connect' && canManage) {
|
||||
if (!connectFromId) {
|
||||
setConnectFromId(nodeId)
|
||||
setSelectedEdgeId(null)
|
||||
return
|
||||
}
|
||||
if (connectFromId === nodeId) {
|
||||
setConnectFromId(null)
|
||||
return
|
||||
}
|
||||
handleCreateEdge(connectFromId, nodeId)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.detail === 2 && tool === 'pan') {
|
||||
navigate(gatePath(nodeId))
|
||||
}
|
||||
function handlePortPointerDown(node, event) {
|
||||
if (!canManage || event.button !== 0 || busy) return
|
||||
event.stopPropagation()
|
||||
const rect = viewportRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY)
|
||||
setEdgeDrag({
|
||||
fromNodeId: node.id,
|
||||
fromX: node.x + node.width / 2,
|
||||
fromY: node.y + node.height,
|
||||
toX: point.x,
|
||||
toY: point.y,
|
||||
})
|
||||
setSelectedNodeId(null)
|
||||
setSelectedEdgeId(null)
|
||||
}
|
||||
|
||||
function handleNodePointerDown(node, event) {
|
||||
if (!canManage || tool !== 'pan' || event.button !== 0) return
|
||||
if (!canManage || event.button !== 0) return
|
||||
if (event.target.classList?.contains('gate-graph-designer__port')) return
|
||||
event.stopPropagation()
|
||||
const rect = viewportRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
|
@ -214,11 +266,9 @@ export function GateGraphDesigner({
|
|||
if (target.classList?.contains('gate-map-view__edge')) return
|
||||
|
||||
setSelectedEdgeId(null)
|
||||
if (tool === 'connect') {
|
||||
setConnectFromId(null)
|
||||
return
|
||||
}
|
||||
if (tool !== 'pan' || draggingNodeId) return
|
||||
setSelectedNodeId(null)
|
||||
|
||||
if (draggingNodeId || edgeDrag) return
|
||||
|
||||
panStartRef.current = {
|
||||
startX: event.clientX,
|
||||
|
|
@ -228,8 +278,26 @@ export function GateGraphDesigner({
|
|||
}
|
||||
}
|
||||
|
||||
function handleViewportDoubleClick(event) {
|
||||
if (!canManage || !onCreate) return
|
||||
const rect = viewportRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY)
|
||||
openCreateAt(point)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerMove(event) {
|
||||
if (edgeDrag) {
|
||||
const rect = viewportRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY)
|
||||
setEdgeDrag((current) =>
|
||||
current ? { ...current, toX: point.x, toY: point.y } : null,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (draggingNodeId && canManage) {
|
||||
const rect = viewportRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
|
@ -254,7 +322,18 @@ export function GateGraphDesigner({
|
|||
}
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
function handlePointerUp(event) {
|
||||
if (edgeDrag) {
|
||||
const rect = viewportRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY)
|
||||
const targetNode = nodeAtPoint(layout.nodes, point)
|
||||
if (targetNode && targetNode.id !== edgeDrag.fromNodeId) {
|
||||
handleCreateEdgeRef.current(edgeDrag.fromNodeId, targetNode.id)
|
||||
}
|
||||
}
|
||||
setEdgeDrag(null)
|
||||
}
|
||||
setDraggingNodeId(null)
|
||||
panStartRef.current = null
|
||||
}
|
||||
|
|
@ -265,7 +344,7 @@ export function GateGraphDesigner({
|
|||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [canManage, draggingNodeId, initiativeId, pan, zoom])
|
||||
}, [canManage, draggingNodeId, edgeDrag, initiativeId, layout.nodes, pan, zoom])
|
||||
|
||||
function handleWheel(event) {
|
||||
event.preventDefault()
|
||||
|
|
@ -280,14 +359,54 @@ export function GateGraphDesigner({
|
|||
setZoom(1)
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
function defaultCreatePoint() {
|
||||
const rect = viewportRef.current?.getBoundingClientRect()
|
||||
if (!rect) return { x: 80, y: 80 }
|
||||
return clientToGraphPoint(
|
||||
rect,
|
||||
pan,
|
||||
zoom,
|
||||
rect.left + rect.width / 2,
|
||||
rect.top + rect.height / 2,
|
||||
)
|
||||
}
|
||||
|
||||
if (loading && items.length > 0) {
|
||||
return <LoadingState message="Zielzustands-Designer wird geladen …" />
|
||||
}
|
||||
|
||||
if (!items.length && !canManage) {
|
||||
return (
|
||||
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="Zielzustands-Designer wird geladen …" />
|
||||
if (!items.length && canManage) {
|
||||
return (
|
||||
<section className="card gate-graph-designer">
|
||||
<EmptyState message="Noch kein Zielzustand — lege das erste Gate an." />
|
||||
<div className="gate-graph-designer__empty-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !onCreate}
|
||||
onClick={() => {
|
||||
setCreatePosition({ x: 80, y: 80 })
|
||||
setShowCreate(true)
|
||||
}}
|
||||
>
|
||||
Gate anlegen
|
||||
</button>
|
||||
</div>
|
||||
<Modal open={showCreate} title="Zielzustand anlegen" onClose={() => setShowCreate(false)}>
|
||||
<RoadmapItemForm
|
||||
onSubmit={handleCreateGateSubmit}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
busy={busy}
|
||||
/>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -296,8 +415,8 @@ export function GateGraphDesigner({
|
|||
<div>
|
||||
<h2>Zielzustände (Designer)</h2>
|
||||
<p className="section-lead muted">
|
||||
Zielzustands-Graph modellieren — Pan/Zoom, Knoten verschieben, Kanten verbinden. Verify
|
||||
und Kriterien bleiben auf der Gate-Detailseite.
|
||||
Zielzustands-Graph modellieren — Knoten verschieben, Kanten vom unteren Anker ziehen,
|
||||
Klick öffnet Infos. Verify und Kriterien auf der Gate-Detailseite.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -305,55 +424,24 @@ export function GateGraphDesigner({
|
|||
{error && <ErrorState message={error} />}
|
||||
|
||||
<div className="gate-graph-designer__toolbar">
|
||||
<div className="gate-graph-designer__tool-group" role="group" aria-label="Werkzeug">
|
||||
{canManage && onCreate && (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'btn btn-secondary btn-sm' +
|
||||
(tool === 'pan' ? ' gate-graph-designer__tool--active' : '')
|
||||
}
|
||||
aria-pressed={tool === 'pan'}
|
||||
onClick={() => {
|
||||
setTool('pan')
|
||||
setConnectFromId(null)
|
||||
}}
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => openCreateAt(defaultCreatePoint())}
|
||||
>
|
||||
Verschieben
|
||||
Gate anlegen
|
||||
</button>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'btn btn-secondary btn-sm' +
|
||||
(tool === 'connect' ? ' gate-graph-designer__tool--active' : '')
|
||||
}
|
||||
aria-pressed={tool === 'connect'}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setTool('connect')
|
||||
setConnectFromId(null)
|
||||
setSelectedEdgeId(null)
|
||||
}}
|
||||
>
|
||||
Verbinden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManage && tool === 'connect' && (
|
||||
{canManage && (
|
||||
<label className="gate-graph-designer__edge-type">
|
||||
<span className="muted">Kantentyp</span>
|
||||
<select
|
||||
value={edgeType}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setEdgeType(event.target.value)
|
||||
setConnectFromId(null)
|
||||
}}
|
||||
>
|
||||
{Object.entries(EDGE_TYPE_LABELS).map(([value, label]) => (
|
||||
<select value={edgeType} disabled={busy} onChange={(e) => setEdgeType(e.target.value)}>
|
||||
{DESIGNER_EDGE_KINDS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
{DESIGNER_EDGE_LABELS[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -365,7 +453,7 @@ export function GateGraphDesigner({
|
|||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
aria-label="Verkleinern"
|
||||
onClick={() => setZoom((current) => clampDesignerZoom(current - DESIGNER_ZOOM_STEP))}
|
||||
onClick={() => setZoom((c) => clampDesignerZoom(c - DESIGNER_ZOOM_STEP))}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
|
|
@ -374,7 +462,7 @@ export function GateGraphDesigner({
|
|||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
aria-label="Vergrößern"
|
||||
onClick={() => setZoom((current) => clampDesignerZoom(current + DESIGNER_ZOOM_STEP))}
|
||||
onClick={() => setZoom((c) => clampDesignerZoom(c + DESIGNER_ZOOM_STEP))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
|
@ -403,126 +491,155 @@ export function GateGraphDesigner({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{tool === 'connect' && canManage && (
|
||||
{canManage && (
|
||||
<p className="gate-graph-designer__hint muted">
|
||||
{CONNECT_HINTS[edgeType] || 'Zwei Gates nacheinander anklicken.'}
|
||||
{connectFromId ? ' — erstes Gate gewählt, zweites wählen.' : ''}
|
||||
Vom unteren Punkt eines Gates zur Zielkante ziehen. Doppelklick auf leere Fläche legt ein
|
||||
Gate an. Klick auf Gate öffnet das Info-Panel.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!canManage && (
|
||||
<p className="gate-graph-designer__hint muted">
|
||||
Nur Ansicht — zum Modellieren fehlt die Berechtigung „Plan verwalten“.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="gate-graph-designer__viewport"
|
||||
onPointerDown={handleViewportPointerDown}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<svg
|
||||
className="gate-graph-designer__canvas"
|
||||
width={layout.width}
|
||||
height={layout.height}
|
||||
role="img"
|
||||
aria-label="Zielzustands-Designer"
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
<div className="gate-graph-designer__main">
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="gate-graph-designer__viewport"
|
||||
onPointerDown={handleViewportPointerDown}
|
||||
onDoubleClick={handleViewportDoubleClick}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="gate-designer-arrow-requires"
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="6"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
>
|
||||
<path
|
||||
d="M0,0 L8,4 L0,8 Z"
|
||||
className="gate-map-view__arrow gate-map-view__arrow--requires"
|
||||
/>
|
||||
</marker>
|
||||
<marker
|
||||
id="gate-designer-arrow-blocks"
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="6"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
>
|
||||
<path
|
||||
d="M0,0 L8,4 L0,8 Z"
|
||||
className="gate-map-view__arrow gate-map-view__arrow--blocks"
|
||||
/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{layout.edges.map((edge) => (
|
||||
<path
|
||||
key={edge.id}
|
||||
d={edgePath(edge)}
|
||||
className={
|
||||
'gate-map-view__edge gate-map-view__edge--' +
|
||||
edge.dependency_type +
|
||||
(selectedEdgeId === edge.id ? ' gate-graph-designer__edge--selected' : '')
|
||||
}
|
||||
markerEnd={
|
||||
edge.dependency_type === 'related'
|
||||
? undefined
|
||||
: `url(#gate-designer-arrow-${edge.dependency_type})`
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (canManage) setSelectedEdgeId(edge.id)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{layout.nodes.map((node) => {
|
||||
const item = node.item
|
||||
const isConnectSource = connectFromId === node.id
|
||||
const nodeClass =
|
||||
'gate-map-view__node gate-map-view__node--' +
|
||||
(item.status || 'planned') +
|
||||
(isConnectSource ? ' gate-graph-designer__node--connect-source' : '') +
|
||||
(draggingNodeId === node.id ? ' gate-graph-designer__node--dragging' : '')
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
className={nodeClass}
|
||||
transform={`translate(${node.x}, ${node.y})`}
|
||||
onPointerDown={(event) => handleNodePointerDown(node, event)}
|
||||
onClick={(event) => handleNodeClick(node.id, event)}
|
||||
<svg
|
||||
className="gate-graph-designer__canvas"
|
||||
width={layout.width}
|
||||
height={layout.height}
|
||||
role="img"
|
||||
aria-label="Zielzustands-Designer"
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="gate-designer-arrow-requires"
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="6"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
>
|
||||
<rect
|
||||
className="gate-map-view__node-box"
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rx="8"
|
||||
ry="8"
|
||||
<path
|
||||
d="M0,0 L8,4 L0,8 Z"
|
||||
className="gate-map-view__arrow gate-map-view__arrow--requires"
|
||||
/>
|
||||
<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>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</marker>
|
||||
<marker
|
||||
id="gate-designer-arrow-blocks"
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="6"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
>
|
||||
<path
|
||||
d="M0,0 L8,4 L0,8 Z"
|
||||
className="gate-map-view__arrow gate-map-view__arrow--blocks"
|
||||
/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{layout.edges.map((edge) => (
|
||||
<path
|
||||
key={edge.id}
|
||||
d={edgePath(edge)}
|
||||
className={
|
||||
'gate-map-view__edge gate-map-view__edge--' +
|
||||
edge.dependency_type +
|
||||
(selectedEdgeId === edge.id ? ' gate-graph-designer__edge--selected' : '')
|
||||
}
|
||||
markerEnd={
|
||||
edge.dependency_type === 'related'
|
||||
? undefined
|
||||
: `url(#gate-designer-arrow-${edge.dependency_type})`
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (canManage) {
|
||||
setSelectedEdgeId(edge.id)
|
||||
setSelectedNodeId(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{edgeDrag && (
|
||||
<line
|
||||
className="gate-graph-designer__edge-preview"
|
||||
x1={edgeDrag.fromX}
|
||||
y1={edgeDrag.fromY}
|
||||
x2={edgeDrag.toX}
|
||||
y2={edgeDrag.toY}
|
||||
/>
|
||||
)}
|
||||
|
||||
{layout.nodes.map((node) => {
|
||||
const item = node.item
|
||||
const isSelected = selectedNodeId === node.id
|
||||
const nodeClass =
|
||||
'gate-map-view__node gate-map-view__node--' +
|
||||
(item.status || 'planned') +
|
||||
(isSelected ? ' gate-graph-designer__node--selected' : '') +
|
||||
(draggingNodeId === node.id ? ' gate-graph-designer__node--dragging' : '')
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
className={nodeClass}
|
||||
transform={`translate(${node.x}, ${node.y})`}
|
||||
onPointerDown={(event) => handleNodePointerDown(node, event)}
|
||||
onClick={(event) => handleNodeClick(node.id, event)}
|
||||
>
|
||||
<rect
|
||||
className="gate-map-view__node-box"
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rx="8"
|
||||
ry="8"
|
||||
/>
|
||||
<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>
|
||||
{canManage && (
|
||||
<circle
|
||||
className="gate-graph-designer__port"
|
||||
cx={node.width / 2}
|
||||
cy={node.height}
|
||||
r="7"
|
||||
onPointerDown={(event) => handlePortPointerDown(node, event)}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{selectedItem && (
|
||||
<GateDesignerNodePanel
|
||||
item={selectedItem}
|
||||
criteriaProgress={criteriaProgress}
|
||||
onClose={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<p className="muted gate-graph-designer__footer-hint">
|
||||
Doppelklick auf Gate öffnet Details. Layout-Positionen werden lokal gespeichert.
|
||||
</p>
|
||||
)}
|
||||
<Modal open={showCreate} title="Zielzustand anlegen" onClose={() => setShowCreate(false)}>
|
||||
<RoadmapItemForm
|
||||
onSubmit={handleCreateGateSubmit}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
busy={busy}
|
||||
/>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ export function GatesPlanPanel({
|
|||
initiativeId={initiativeId}
|
||||
items={items}
|
||||
canManage={canManage}
|
||||
busy={busy}
|
||||
onCreate={canManage ? onCreate : undefined}
|
||||
onGraphChanged={refreshGraphState}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -414,10 +414,12 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
|||
async function handleCreateRoadmapItem(body) {
|
||||
setFormBusy(true)
|
||||
try {
|
||||
await createInitiativeRoadmapItem(id, body)
|
||||
const created = await createInitiativeRoadmapItem(id, body)
|
||||
await load()
|
||||
return created
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
throw err
|
||||
} finally {
|
||||
setFormBusy(false)
|
||||
}
|
||||
|
|
|
|||
18
frontend/src/plan/gateGraphTopology.js
Normal file
18
frontend/src/plan/gateGraphTopology.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Kantentypen für Zielzustands-Graph — AP1.15c erweitert um Topologie.
|
||||
* Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md § Join/Branch deferred.
|
||||
*/
|
||||
|
||||
/** Im Designer AP1.15a/b nutzbar */
|
||||
export const DESIGNER_EDGE_KINDS = ['requires', 'blocks', 'related']
|
||||
|
||||
/** AP1.15c — parallel_group, optional_branch; ggf. OR später */
|
||||
export const DEFERRED_TOPOLOGY_EDGE_KINDS = ['parallel_group', 'optional_branch']
|
||||
|
||||
export const DESIGNER_EDGE_LABELS = {
|
||||
requires: 'Voraussetzung',
|
||||
blocks: 'Blockiert',
|
||||
related: 'Bezug',
|
||||
parallel_group: 'Parallelgruppe',
|
||||
optional_branch: 'Optional',
|
||||
}
|
||||
15
frontend/src/plan/gateGraphTopology.test.js
Normal file
15
frontend/src/plan/gateGraphTopology.test.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFERRED_TOPOLOGY_EDGE_KINDS,
|
||||
DESIGNER_EDGE_KINDS,
|
||||
} from './gateGraphTopology.js'
|
||||
|
||||
describe('gateGraphTopology', () => {
|
||||
it('keeps designer and deferred edge kinds separate', () => {
|
||||
expect(DESIGNER_EDGE_KINDS).toContain('requires')
|
||||
expect(DEFERRED_TOPOLOGY_EDGE_KINDS).toContain('parallel_group')
|
||||
for (const kind of DEFERRED_TOPOLOGY_EDGE_KINDS) {
|
||||
expect(DESIGNER_EDGE_KINDS).not.toContain(kind)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1874,6 +1874,101 @@
|
|||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-graph-designer__main {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.gate-graph-designer__main .gate-graph-designer__viewport {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gate-graph-designer__empty-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.gate-graph-designer__port {
|
||||
fill: var(--jk-accent, #2563eb);
|
||||
stroke: #fff;
|
||||
stroke-width: 2;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.gate-graph-designer__edge-preview {
|
||||
stroke: var(--jk-accent, #2563eb);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 6 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gate-graph-designer__node--selected .gate-map-view__node-box {
|
||||
stroke: var(--jk-accent, #2563eb);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel {
|
||||
flex: 0 0 240px;
|
||||
max-width: 280px;
|
||||
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;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__meta {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__meta div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__meta dt {
|
||||
margin: 0;
|
||||
color: var(--jk-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__meta dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__goal {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.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