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,
|
item_id: str,
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||||
):
|
):
|
||||||
if not roadmap_service.delete_roadmap_item(
|
try:
|
||||||
tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
|
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")
|
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -461,6 +461,14 @@ def update_roadmap_item(
|
||||||
def delete_roadmap_item(
|
def delete_roadmap_item(
|
||||||
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
|
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
|
||||||
) -> bool:
|
) -> 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()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,13 @@ def test_verify_reached_requires_evidence(client):
|
||||||
assert ok.json()["status"] == "reached"
|
assert ok.json()["status"] == "reached"
|
||||||
assert ok.json()["verify_reason"] in ("criteria_ready", "evidence_accepted")
|
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):
|
def test_milestone_compat_api_uses_roadmap(client):
|
||||||
user = provision_user_in_tenant(tenant_role="member")
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { gatePath } from '../utils/routes.js'
|
import { gatePath } from '../utils/routes.js'
|
||||||
import {
|
import {
|
||||||
|
|
@ -6,6 +7,8 @@ import {
|
||||||
isJoinGateItem,
|
isJoinGateItem,
|
||||||
} from '../constants/status.js'
|
} from '../constants/status.js'
|
||||||
import { StatusBadge } from './StatusBadge.jsx'
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
|
import { Modal } from './Modal.jsx'
|
||||||
|
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
if (!value) return null
|
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({
|
export function GateDesignerNodePanel({
|
||||||
item,
|
item,
|
||||||
graphState,
|
graphState,
|
||||||
criteriaProgress,
|
criteriaProgress,
|
||||||
|
canManage = false,
|
||||||
|
busy = false,
|
||||||
onClose,
|
onClose,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
}) {
|
}) {
|
||||||
|
const [showEdit, setShowEdit] = useState(false)
|
||||||
|
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
|
|
||||||
const joinGate = isJoinGateItem(item)
|
const joinGate = isJoinGateItem(item)
|
||||||
const graphItem = graphState?.items?.[item.id]
|
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 progress = criteriaProgress?.[item.id]
|
||||||
const closed = progress?.closed ?? 0
|
const closed = progress?.closed ?? 0
|
||||||
const total = progress?.total ?? 0
|
const total = progress?.total ?? 0
|
||||||
|
|
||||||
return (
|
async function handleEditSubmit(payload) {
|
||||||
<aside className="gate-designer-node-panel" aria-label="Gate-Informationen">
|
await onUpdate(item.id, payload)
|
||||||
<div className="gate-designer-node-panel__header">
|
setShowEdit(false)
|
||||||
<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">
|
async function handleDeleteClick() {
|
||||||
<div>
|
const label = item.title || 'dieses Gate'
|
||||||
<dt>Typ</dt>
|
const ok = window.confirm(
|
||||||
<dd>{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'}</dd>
|
`„${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>
|
||||||
<div>
|
|
||||||
<dt>Status</dt>
|
{statusHint && (
|
||||||
<dd>
|
<p className="gate-designer-node-panel__status-hint">
|
||||||
<StatusBadge status={item.status || 'planned'} />
|
<span className={statusHint.className}>{statusHint.label}</span>
|
||||||
</dd>
|
</p>
|
||||||
</div>
|
|
||||||
{item.target_date && (
|
|
||||||
<div>
|
|
||||||
<dt>Zieltermin</dt>
|
|
||||||
<dd>{formatDate(item.target_date)}</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{total > 0 && (
|
|
||||||
|
<dl className="gate-designer-node-panel__meta">
|
||||||
<div>
|
<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>
|
<dd>
|
||||||
{closed}/{total} erfüllt
|
<StatusBadge status={item.status || 'planned'} />
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</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 && (
|
{item.goal_description && (
|
||||||
<p className="gate-designer-node-panel__topology muted">
|
<p className="gate-designer-node-panel__goal muted">{item.goal_description}</p>
|
||||||
Schaltknoten — schaltet, wenn alle Eingänge (requires) erreicht sind. Keine Checkliste,
|
)}
|
||||||
kein manuelles Verify.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{item.goal_description && (
|
{graphItem?.parallel_group_keys?.length > 0 && (
|
||||||
<p className="gate-designer-node-panel__goal muted">{item.goal_description}</p>
|
<p className="gate-designer-node-panel__topology muted">
|
||||||
)}
|
Parallel: {graphItem.parallel_group_keys.join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{graphItem?.parallel_group_keys?.length > 0 && (
|
{graphItem?.optional_prerequisites?.length > 0 && (
|
||||||
<p className="gate-designer-node-panel__topology muted">
|
<p className="gate-designer-node-panel__topology muted">
|
||||||
Parallel: {graphItem.parallel_group_keys.join(', ')}
|
Optional: {graphItem.optional_prerequisites.length} Zweig(e) — blockieren nicht
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{graphItem?.optional_prerequisites?.length > 0 && (
|
{terminalStatus && (
|
||||||
<p className="gate-designer-node-panel__topology muted">
|
<p className="gate-designer-node-panel__topology muted">
|
||||||
Optional: {graphItem.optional_prerequisites.length} Zweig(e) — blockieren nicht
|
{item.status === 'reached'
|
||||||
</p>
|
? '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">
|
<div className="gate-designer-node-panel__actions">
|
||||||
<Link to={gatePath(item.id)} className="btn btn-secondary btn-sm">
|
{canEdit && (
|
||||||
Vollständig bearbeiten
|
<button
|
||||||
</Link>
|
type="button"
|
||||||
</div>
|
className="btn btn-secondary btn-sm"
|
||||||
</aside>
|
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,
|
loadManualGatePositions,
|
||||||
storeManualGatePositions,
|
storeManualGatePositions,
|
||||||
} from '../plan/gateGraphDesigner.js'
|
} 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 { LoadingState } from './LoadingState.jsx'
|
||||||
import { ErrorState } from './ErrorState.jsx'
|
import { ErrorState } from './ErrorState.jsx'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
|
@ -61,6 +68,8 @@ export function GateGraphDesigner({
|
||||||
canManage = false,
|
canManage = false,
|
||||||
busy: busyProp = false,
|
busy: busyProp = false,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
onGraphChanged,
|
onGraphChanged,
|
||||||
graphState,
|
graphState,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -147,6 +156,21 @@ export function GateGraphDesigner({
|
||||||
[items, selectedNodeId],
|
[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() {
|
async function notifyGraphChanged() {
|
||||||
await reloadDependencies()
|
await reloadDependencies()
|
||||||
onGraphChanged?.()
|
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) {
|
function openCreateAt(point) {
|
||||||
setCreatePosition({
|
setCreatePosition({
|
||||||
x: Math.max(0, point.x - GATE_NODE_WIDTH / 2),
|
x: Math.max(0, point.x - GATE_NODE_WIDTH / 2),
|
||||||
|
|
@ -514,11 +606,46 @@ export function GateGraphDesigner({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{canManage && (
|
||||||
<p className="gate-graph-designer__hint muted">
|
<p className="gate-graph-designer__hint muted">
|
||||||
Vom unteren Punkt ziehen: Voraussetzung/Block/Bezug/Parallel/Optional. Join-Gates
|
Vom unteren Punkt ziehen: Kante anlegen. Klick auf Kante oder Gate öffnet Bearbeitung
|
||||||
verbinden mehrere Pflicht-Voraussetzungen (AND). Doppelklick auf leere Fläche legt ein
|
rechts. Join-Gates sammeln Eingänge (AND). Doppelklick auf leere Fläche legt ein Gate an.
|
||||||
Gate an.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -586,10 +713,8 @@ export function GateGraphDesigner({
|
||||||
}
|
}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (canManage) {
|
setSelectedEdgeId(edge.id)
|
||||||
setSelectedEdgeId(edge.id)
|
setSelectedNodeId(null)
|
||||||
setSelectedNodeId(null)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -607,12 +732,24 @@ export function GateGraphDesigner({
|
||||||
{layout.nodes.map((node) => {
|
{layout.nodes.map((node) => {
|
||||||
const item = node.item
|
const item = node.item
|
||||||
const isSelected = selectedNodeId === node.id
|
const isSelected = selectedNodeId === node.id
|
||||||
|
const nodeGraph = graphState?.items?.[node.id]
|
||||||
const nodeClass =
|
const nodeClass =
|
||||||
'gate-map-view__node gate-map-view__node--' +
|
'gate-map-view__node gate-map-view__node--' +
|
||||||
(item.status || 'planned') +
|
(item.status || 'planned') +
|
||||||
(item.item_type === 'join_gate' ? ' gate-map-view__node--join_gate' : '') +
|
(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' : '') +
|
(isSelected ? ' gate-graph-designer__node--selected' : '') +
|
||||||
(draggingNodeId === node.id ? ' gate-graph-designer__node--dragging' : '')
|
(draggingNodeId === node.id ? ' gate-graph-designer__node--dragging' : '')
|
||||||
|
const statusLabel =
|
||||||
|
item.status === 'reached'
|
||||||
|
? '✓'
|
||||||
|
: enforceBlocking && nodeGraph?.blocked
|
||||||
|
? '⏸'
|
||||||
|
: nodeGraph?.ready || item.status === 'active'
|
||||||
|
? '●'
|
||||||
|
: null
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={node.id}
|
key={node.id}
|
||||||
|
|
@ -628,12 +765,27 @@ export function GateGraphDesigner({
|
||||||
rx="8"
|
rx="8"
|
||||||
ry="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">
|
<text className="gate-map-view__node-title" x="12" y="28">
|
||||||
{truncateTitle(item.title)}
|
{truncateTitle(item.title)}
|
||||||
</text>
|
</text>
|
||||||
<text className="gate-map-view__node-meta" x="12" y="50">
|
<text className="gate-map-view__node-meta" x="12" y="50">
|
||||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'}
|
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'}
|
||||||
</text>
|
</text>
|
||||||
|
{item.status === 'reached' && (
|
||||||
|
<text className="gate-map-view__node-meta gate-graph-designer__node-reached-label" x="12" y="68">
|
||||||
|
Erreicht
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<circle
|
<circle
|
||||||
className="gate-graph-designer__port"
|
className="gate-graph-designer__port"
|
||||||
|
|
@ -649,13 +801,90 @@ export function GateGraphDesigner({
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedItem && (
|
{(selectedItem || selectedDependency) && (
|
||||||
<GateDesignerNodePanel
|
<div className="gate-graph-designer__side-panel">
|
||||||
item={selectedItem}
|
{selectedDependency && (
|
||||||
graphState={graphState}
|
<aside className="gate-designer-edge-panel" aria-label="Kanten-Bearbeitung">
|
||||||
criteriaProgress={criteriaProgress}
|
<div className="gate-designer-node-panel__header">
|
||||||
onClose={() => setSelectedNodeId(null)}
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export function GatesPlanPanel({
|
||||||
items,
|
items,
|
||||||
canManage,
|
canManage,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
onReorder,
|
onReorder,
|
||||||
onDelete,
|
onDelete,
|
||||||
busy,
|
busy,
|
||||||
|
|
@ -129,6 +130,8 @@ export function GatesPlanPanel({
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onCreate={canManage ? onCreate : undefined}
|
onCreate={canManage ? onCreate : undefined}
|
||||||
|
onUpdate={canManage ? onUpdate : undefined}
|
||||||
|
onDelete={canManage ? onDelete : undefined}
|
||||||
onGraphChanged={refreshGraphState}
|
onGraphChanged={refreshGraphState}
|
||||||
graphState={graphState}
|
graphState={graphState}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -44,16 +44,18 @@ export function RoadmapItemForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusLocked = ['reached', 'moved', 'discarded'].includes(initial.status)
|
const statusLocked = ['reached', 'moved', 'discarded'].includes(initial.status)
|
||||||
|
const showTypeField = mode === 'create' || (mode === 'edit' && !statusLocked)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="form workspace-form roadmap-item-form" onSubmit={handleSubmit}>
|
<form className="form workspace-form roadmap-item-form" onSubmit={handleSubmit}>
|
||||||
{mode === 'create' && (
|
{showTypeField && (
|
||||||
<label>
|
<label>
|
||||||
Typ
|
Typ
|
||||||
<select
|
<select
|
||||||
name="item_type"
|
name="item_type"
|
||||||
value={itemType}
|
value={itemType}
|
||||||
onChange={(e) => setItemType(e.target.value)}
|
onChange={(e) => setItemType(e.target.value)}
|
||||||
|
disabled={statusLocked}
|
||||||
>
|
>
|
||||||
{ROADMAP_ITEM_TYPES_FOR_DESIGNER.map((t) => (
|
{ROADMAP_ITEM_TYPES_FOR_DESIGNER.map((t) => (
|
||||||
<option key={t} value={t}>
|
<option key={t} value={t}>
|
||||||
|
|
@ -67,6 +69,11 @@ export function RoadmapItemForm({
|
||||||
manuelles Verify.
|
manuelles Verify.
|
||||||
</span>
|
</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>
|
||||||
)}
|
)}
|
||||||
<label>
|
<label>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export function InitiativePlanPage() {
|
||||||
capabilities,
|
capabilities,
|
||||||
formBusy,
|
formBusy,
|
||||||
handleCreateRoadmapItem,
|
handleCreateRoadmapItem,
|
||||||
|
handleUpdateRoadmapItem,
|
||||||
handleReorderRoadmapItems,
|
handleReorderRoadmapItems,
|
||||||
handleDeleteRoadmapItem,
|
handleDeleteRoadmapItem,
|
||||||
handleConvertBacklog,
|
handleConvertBacklog,
|
||||||
|
|
@ -45,6 +46,7 @@ export function InitiativePlanPage() {
|
||||||
items={roadmapItems}
|
items={roadmapItems}
|
||||||
canManage={capabilities.has('kairo.milestone.manage')}
|
canManage={capabilities.has('kairo.milestone.manage')}
|
||||||
onCreate={handleCreateRoadmapItem}
|
onCreate={handleCreateRoadmapItem}
|
||||||
|
onUpdate={handleUpdateRoadmapItem}
|
||||||
onReorder={handleReorderRoadmapItems}
|
onReorder={handleReorderRoadmapItems}
|
||||||
onDelete={handleDeleteRoadmapItem}
|
onDelete={handleDeleteRoadmapItem}
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
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 { createInitiativeEvidence } from '../../api/evidence.js'
|
||||||
import {
|
import {
|
||||||
getRoadmapItem,
|
getRoadmapItem,
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
listRoadmapItemDependencies,
|
listRoadmapItemDependencies,
|
||||||
addRoadmapItemDependency,
|
addRoadmapItemDependency,
|
||||||
deleteRoadmapDependency,
|
deleteRoadmapDependency,
|
||||||
|
deleteRoadmapItem,
|
||||||
listInitiativeRoadmapItems,
|
listInitiativeRoadmapItems,
|
||||||
} from '../../api/roadmap.js'
|
} from '../../api/roadmap.js'
|
||||||
import { ErrorState } from '../../components/ErrorState.jsx'
|
import { ErrorState } from '../../components/ErrorState.jsx'
|
||||||
|
|
@ -32,6 +33,7 @@ import { RoadmapItemForm } from '../../components/RoadmapItemForm.jsx'
|
||||||
|
|
||||||
export function RoadmapItemDetailPage({ overrideItemId }) {
|
export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
const { id: routeInitiativeId, itemId: routeItemId, gateId } = useParams()
|
const { id: routeInitiativeId, itemId: routeItemId, gateId } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
const ops = useInitiativeOperations()
|
const ops = useInitiativeOperations()
|
||||||
const initiativeId = routeInitiativeId || ops?.initiativeId
|
const initiativeId = routeInitiativeId || ops?.initiativeId
|
||||||
const itemId = overrideItemId || routeItemId || gateId
|
const itemId = overrideItemId || routeItemId || gateId
|
||||||
|
|
@ -180,6 +182,25 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
Stammdaten
|
Stammdaten
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,19 @@ export const DESIGNER_EDGE_LABELS = {
|
||||||
optional_branch: 'Optional',
|
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 */
|
/** @deprecated use DESIGNER_EDGE_KINDS */
|
||||||
export const DEFERRED_TOPOLOGY_EDGE_KINDS = ['parallel_group', 'optional_branch']
|
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 }
|
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 {
|
import {
|
||||||
DEFERRED_TOPOLOGY_EDGE_KINDS,
|
DEFERRED_TOPOLOGY_EDGE_KINDS,
|
||||||
DESIGNER_EDGE_KINDS,
|
DESIGNER_EDGE_KINDS,
|
||||||
|
dependencyVisualEndpoints,
|
||||||
edgeKindNeedsGroupKey,
|
edgeKindNeedsGroupKey,
|
||||||
resolveDependencyEndpoints,
|
resolveDependencyEndpoints,
|
||||||
} from './gateGraphTopology.js'
|
} from './gateGraphTopology.js'
|
||||||
|
|
@ -31,4 +32,21 @@ describe('gateGraphTopology', () => {
|
||||||
to_item_id: 'b',
|
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 {
|
.gate-graph-designer__main {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-graph-designer__main .gate-graph-designer__viewport {
|
.gate-graph-designer__main .gate-graph-designer__viewport {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
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 {
|
.gate-graph-designer__empty-actions {
|
||||||
|
|
@ -2193,17 +2217,117 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-designer-node-panel {
|
.gate-designer-node-panel {
|
||||||
flex: 0 0 240px;
|
flex: 0 0 auto;
|
||||||
max-width: 280px;
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border: 1px solid var(--jk-border, #dde3ea);
|
border: 1px solid var(--jk-border, #dde3ea);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--jk-surface, #fff);
|
background: var(--jk-surface, #fff);
|
||||||
align-self: flex-start;
|
align-self: stretch;
|
||||||
max-height: 70vh;
|
max-height: none;
|
||||||
overflow-y: auto;
|
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 {
|
.gate-designer-node-panel__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
@ -2250,12 +2374,6 @@
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-designer-node-panel__actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gate-graph-designer__toolbar {
|
.gate-graph-designer__toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user