AP1.13a: Read-only Gate-Graph aus Abhaengigkeiten.
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
Initiative-API fuer Roadmap-Dependencies, SVG-Layout mit Liste/Graph-Umschalter auf der Zielzustaende-Seite. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
83fdb9a2a2
commit
7f03504c2d
|
|
@ -115,6 +115,19 @@ def list_initiative_roadmap_items(
|
|||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap/dependencies")
|
||||
def list_initiative_roadmap_dependencies(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
return roadmap_service.list_dependencies_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201)
|
||||
def create_initiative_roadmap_item(
|
||||
initiative_id: str,
|
||||
|
|
|
|||
|
|
@ -484,6 +484,42 @@ def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
|
|||
conn.close()
|
||||
|
||||
|
||||
def list_dependencies_for_initiative(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT d.id, d.tenant_id, d.from_item_id, d.to_item_id,
|
||||
d.dependency_type, d.created_at
|
||||
FROM roadmap_item_dependencies d
|
||||
INNER JOIN roadmap_items fi
|
||||
ON fi.id = d.from_item_id AND fi.tenant_id = d.tenant_id
|
||||
INNER JOIN roadmaps r
|
||||
ON r.id = fi.roadmap_id AND r.tenant_id = fi.tenant_id
|
||||
WHERE d.tenant_id = %s AND r.initiative_id = %s
|
||||
ORDER BY d.created_at ASC
|
||||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
items = []
|
||||
for row in cur.fetchall():
|
||||
dep = dict(row)
|
||||
for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
|
||||
dep[key] = str(dep[key])
|
||||
if dep.get("created_at"):
|
||||
dep["created_at"] = dep["created_at"].isoformat()
|
||||
items.append(dep)
|
||||
return items
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def add_dependency(
|
||||
*,
|
||||
tenant_id: str,
|
||||
|
|
|
|||
|
|
@ -120,3 +120,29 @@ def test_steering_snapshot_reads_roadmap_items(client):
|
|||
assert snap.get("roadmap_items")
|
||||
assert snap.get("upcoming_milestones")
|
||||
assert len(snap["upcoming_roadmap_items"]) >= 1
|
||||
|
||||
|
||||
def test_initiative_roadmap_dependencies_list(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
gate_a = _create_roadmap_item(client, token, initiative_id, title="Gate A").json()
|
||||
gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B").json()
|
||||
|
||||
created = client.post(
|
||||
f"/api/roadmap-items/{gate_a['id']}/dependencies",
|
||||
json={"to_item_id": gate_b["id"], "dependency_type": "requires"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
listed = client.get(
|
||||
f"/api/initiatives/{initiative_id}/roadmap/dependencies",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert listed.status_code == 200
|
||||
deps = listed.json()
|
||||
assert len(deps) == 1
|
||||
assert deps[0]["from_item_id"] == gate_a["id"]
|
||||
assert deps[0]["to_item_id"] == gate_b["id"]
|
||||
assert deps[0]["dependency_type"] == "requires"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ export function listInitiativeRoadmapItems(initiativeId) {
|
|||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`)
|
||||
}
|
||||
|
||||
export function listInitiativeRoadmapDependencies(initiativeId) {
|
||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/dependencies`)
|
||||
}
|
||||
|
||||
export function createInitiativeRoadmapItem(initiativeId, body) {
|
||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
186
frontend/src/components/GateMapView.jsx
Normal file
186
frontend/src/components/GateMapView.jsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { 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 { LoadingState } from './LoadingState.jsx'
|
||||
import { ErrorState } from './ErrorState.jsx'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
|
||||
function edgePath(edge) {
|
||||
const midY = (edge.y1 + edge.y2) / 2
|
||||
return `M ${edge.x1} ${edge.y1} C ${edge.x1} ${midY}, ${edge.x2} ${midY}, ${edge.x2} ${edge.y2}`
|
||||
}
|
||||
|
||||
function truncateTitle(title, max = 28) {
|
||||
if (!title || title.length <= max) return title || 'Gate'
|
||||
return `${title.slice(0, max - 1)}…`
|
||||
}
|
||||
|
||||
export function GateMapView({ initiativeId, items }) {
|
||||
const navigate = useNavigate()
|
||||
const [dependencies, setDependencies] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!initiativeId) {
|
||||
setDependencies([])
|
||||
setLoading(false)
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
listInitiativeRoadmapDependencies(initiativeId)
|
||||
.then((data) => {
|
||||
if (!cancelled) setDependencies(Array.isArray(data) ? data : [])
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message || 'Abhängigkeiten konnten nicht geladen werden.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initiativeId])
|
||||
|
||||
const layout = useMemo(
|
||||
() => computeGateGraphLayout(items, dependencies),
|
||||
[items, dependencies],
|
||||
)
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="Gate-Graph wird geladen …" />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorState message={error} />
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card gate-map-view">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Zielzustände (Graph)</h2>
|
||||
<p className="section-lead muted">
|
||||
Read-only Übersicht der Gate-Abhängigkeiten — Bearbeitung auf der Gate-Detailseite
|
||||
(AP1.13b).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gate-map-view__legend muted">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="gate-map-view__canvas-wrap">
|
||||
<svg
|
||||
className="gate-map-view__canvas"
|
||||
width={layout.width}
|
||||
height={layout.height}
|
||||
role="img"
|
||||
aria-label="Gate-Abhängigkeitsgraph"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="gate-map-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-map-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}`}
|
||||
markerEnd={
|
||||
edge.dependency_type === 'related'
|
||||
? undefined
|
||||
: `url(#gate-map-arrow-${edge.dependency_type})`
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{layout.nodes.map((node) => {
|
||||
const item = node.item
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
className={`gate-map-view__node gate-map-view__node--${item.status || 'planned'}`}
|
||||
transform={`translate(${node.x}, ${node.y})`}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`${item.title} öffnen`}
|
||||
onClick={() => navigate(gatePath(node.id))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
navigate(gatePath(node.id))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{dependencies.length === 0 && (
|
||||
<p className="muted gate-map-view__hint">
|
||||
Noch keine Kanten — Gates sind nach Reihenfolge angeordnet. Abhängigkeiten können auf den
|
||||
Gate-Detailseiten gepflegt werden (AP1.13b).
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
68
frontend/src/components/GatesPlanPanel.jsx
Normal file
68
frontend/src/components/GatesPlanPanel.jsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { RoadmapPlanSection } from './RoadmapPlanSection.jsx'
|
||||
import { GateMapView } from './GateMapView.jsx'
|
||||
import { resolveGatesViewMode, storeGatesViewMode } from '../plan/gateGraphLayout.js'
|
||||
|
||||
export function GatesPlanPanel({
|
||||
initiativeId,
|
||||
items,
|
||||
canManage,
|
||||
onCreate,
|
||||
onReorder,
|
||||
onDelete,
|
||||
busy,
|
||||
}) {
|
||||
const [viewMode, setViewMode] = useState(() => resolveGatesViewMode(initiativeId, items.length))
|
||||
|
||||
useEffect(() => {
|
||||
setViewMode(resolveGatesViewMode(initiativeId, items.length))
|
||||
}, [initiativeId, items.length])
|
||||
|
||||
function handleViewChange(mode) {
|
||||
setViewMode(mode)
|
||||
storeGatesViewMode(initiativeId, mode)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gates-plan-panel">
|
||||
<div className="gates-plan-panel__toolbar" role="tablist" aria-label="Zielzustände Ansicht">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === 'list'}
|
||||
className={
|
||||
'gates-plan-panel__tab' + (viewMode === 'list' ? ' gates-plan-panel__tab--active' : '')
|
||||
}
|
||||
onClick={() => handleViewChange('list')}
|
||||
>
|
||||
Liste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === 'graph'}
|
||||
className={
|
||||
'gates-plan-panel__tab' + (viewMode === 'graph' ? ' gates-plan-panel__tab--active' : '')
|
||||
}
|
||||
onClick={() => handleViewChange('graph')}
|
||||
>
|
||||
Graph
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewMode === 'list' ? (
|
||||
<RoadmapPlanSection
|
||||
initiativeId={initiativeId}
|
||||
items={items}
|
||||
canManage={canManage}
|
||||
onCreate={onCreate}
|
||||
onReorder={onReorder}
|
||||
onDelete={onDelete}
|
||||
busy={busy}
|
||||
/>
|
||||
) : (
|
||||
<GateMapView initiativeId={initiativeId} items={items} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||
import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx'
|
||||
import { GatesPlanPanel } from '../../components/GatesPlanPanel.jsx'
|
||||
|
||||
export function InitiativePlanPage() {
|
||||
const {
|
||||
|
|
@ -20,7 +20,7 @@ export function InitiativePlanPage() {
|
|||
return (
|
||||
<>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<RoadmapPlanSection
|
||||
<GatesPlanPanel
|
||||
initiativeId={initiativeId}
|
||||
items={roadmapItems}
|
||||
canManage={capabilities.has('kairo.milestone.manage')}
|
||||
|
|
|
|||
194
frontend/src/plan/gateGraphLayout.js
Normal file
194
frontend/src/plan/gateGraphLayout.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/** Layout-Konstanten für read-only Gate-Graph (AP1.13a). */
|
||||
export const GATE_NODE_WIDTH = 200
|
||||
export const GATE_NODE_HEIGHT = 72
|
||||
export const GATE_LAYER_GAP_Y = 56
|
||||
export const GATE_NODE_GAP_X = 48
|
||||
export const GATE_GRAPH_PADDING = 32
|
||||
|
||||
/**
|
||||
* @typedef {{ id: string, title?: string, status?: string, sort_order?: number }} GateNode
|
||||
* @typedef {{ from_item_id: string, to_item_id: string, dependency_type: string }} GateDependency
|
||||
* @typedef {{ id: string, x: number, y: number, width: number, height: number, item: GateNode }} PositionedGateNode
|
||||
* @typedef {{ id: string, fromId: string, toId: string, dependency_type: string, x1: number, y1: number, x2: number, y2: number }} GateGraphEdge
|
||||
*/
|
||||
|
||||
/**
|
||||
* Kanten für Schichtung: requires = Voraussetzung → abhängiges Gate;
|
||||
* blocks = Blocker → geblocktes Gate.
|
||||
*
|
||||
* @param {GateDependency[]} dependencies
|
||||
* @returns {{ from: string, to: string, dependency_type: string }[]}
|
||||
*/
|
||||
export function buildLayoutEdges(dependencies) {
|
||||
const edges = []
|
||||
for (const dep of dependencies) {
|
||||
if (dep.dependency_type === 'requires') {
|
||||
edges.push({
|
||||
from: dep.to_item_id,
|
||||
to: dep.from_item_id,
|
||||
dependency_type: dep.dependency_type,
|
||||
})
|
||||
} else if (dep.dependency_type === 'blocks') {
|
||||
edges.push({
|
||||
from: dep.from_item_id,
|
||||
to: dep.to_item_id,
|
||||
dependency_type: dep.dependency_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GateNode[]} items
|
||||
* @param {GateDependency[]} dependencies
|
||||
* @returns {{ nodes: PositionedGateNode[], edges: GateGraphEdge[], width: number, height: number }}
|
||||
*/
|
||||
export function computeGateGraphLayout(items, dependencies) {
|
||||
if (!items.length) {
|
||||
return { nodes: [], edges: [], width: 0, height: 0 }
|
||||
}
|
||||
|
||||
const sortedItems = [...items].sort(
|
||||
(a, b) =>
|
||||
(a.sort_order ?? 0) - (b.sort_order ?? 0) ||
|
||||
(a.title || '').localeCompare(b.title || '') ||
|
||||
a.id.localeCompare(b.id),
|
||||
)
|
||||
|
||||
const layoutEdges = buildLayoutEdges(dependencies)
|
||||
const layerById = new Map()
|
||||
|
||||
for (const item of sortedItems) {
|
||||
layerById.set(item.id, 0)
|
||||
}
|
||||
|
||||
const maxIterations = sortedItems.length + 1
|
||||
for (let i = 0; i < maxIterations; i += 1) {
|
||||
let changed = false
|
||||
for (const edge of layoutEdges) {
|
||||
const fromLayer = layerById.get(edge.from) ?? 0
|
||||
const toLayer = layerById.get(edge.to) ?? 0
|
||||
const nextLayer = fromLayer + 1
|
||||
if (nextLayer > toLayer) {
|
||||
layerById.set(edge.to, nextLayer)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (!changed) break
|
||||
}
|
||||
|
||||
const layers = new Map()
|
||||
for (const item of sortedItems) {
|
||||
const layer = layerById.get(item.id) ?? 0
|
||||
if (!layers.has(layer)) layers.set(layer, [])
|
||||
layers.get(layer).push(item)
|
||||
}
|
||||
|
||||
const layerIndices = [...layers.keys()].sort((a, b) => a - b)
|
||||
const maxLayerWidth = Math.max(
|
||||
...layerIndices.map((layer) => layers.get(layer).length),
|
||||
1,
|
||||
)
|
||||
|
||||
const nodes = []
|
||||
for (const layer of layerIndices) {
|
||||
const row = layers.get(layer)
|
||||
const rowWidth =
|
||||
row.length * GATE_NODE_WIDTH + Math.max(0, row.length - 1) * GATE_NODE_GAP_X
|
||||
const canvasWidth =
|
||||
maxLayerWidth * GATE_NODE_WIDTH +
|
||||
Math.max(0, maxLayerWidth - 1) * GATE_NODE_GAP_X +
|
||||
GATE_GRAPH_PADDING * 2
|
||||
const startX = GATE_GRAPH_PADDING + Math.max(0, (canvasWidth - rowWidth) / 2 - GATE_GRAPH_PADDING)
|
||||
|
||||
row.forEach((item, index) => {
|
||||
nodes.push({
|
||||
id: item.id,
|
||||
item,
|
||||
width: GATE_NODE_WIDTH,
|
||||
height: GATE_NODE_HEIGHT,
|
||||
x: startX + index * (GATE_NODE_WIDTH + GATE_NODE_GAP_X),
|
||||
y: GATE_GRAPH_PADDING + layer * (GATE_NODE_HEIGHT + GATE_LAYER_GAP_Y),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]))
|
||||
const edges = dependencies
|
||||
.map((dep) => {
|
||||
const sourceNode =
|
||||
dep.dependency_type === 'requires'
|
||||
? nodeById.get(dep.to_item_id)
|
||||
: nodeById.get(dep.from_item_id)
|
||||
const targetNode =
|
||||
dep.dependency_type === 'requires'
|
||||
? nodeById.get(dep.from_item_id)
|
||||
: nodeById.get(dep.to_item_id)
|
||||
if (!sourceNode || !targetNode) return null
|
||||
|
||||
const center = (node) => ({
|
||||
x: node.x + node.width / 2,
|
||||
y: node.y + node.height / 2,
|
||||
})
|
||||
|
||||
if (dep.dependency_type === 'related') {
|
||||
const from = center(sourceNode)
|
||||
const to = center(targetNode)
|
||||
return {
|
||||
id: dep.id || `${dep.from_item_id}-${dep.to_item_id}-${dep.dependency_type}`,
|
||||
fromId: dep.from_item_id,
|
||||
toId: dep.to_item_id,
|
||||
dependency_type: dep.dependency_type,
|
||||
x1: from.x,
|
||||
y1: from.y,
|
||||
x2: to.x,
|
||||
y2: to.y,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: dep.id || `${dep.from_item_id}-${dep.to_item_id}-${dep.dependency_type}`,
|
||||
fromId: dep.from_item_id,
|
||||
toId: dep.to_item_id,
|
||||
dependency_type: dep.dependency_type,
|
||||
x1: sourceNode.x + sourceNode.width / 2,
|
||||
y1: sourceNode.y + sourceNode.height,
|
||||
x2: targetNode.x + targetNode.width / 2,
|
||||
y2: targetNode.y,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const width =
|
||||
Math.max(...nodes.map((node) => node.x + node.width), GATE_NODE_WIDTH) + GATE_GRAPH_PADDING
|
||||
const height =
|
||||
Math.max(...nodes.map((node) => node.y + node.height), GATE_NODE_HEIGHT) + GATE_GRAPH_PADDING
|
||||
|
||||
return { nodes, edges, width, height }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} initiativeId
|
||||
* @param {number} itemCount
|
||||
* @returns {'list' | 'graph'}
|
||||
*/
|
||||
export function resolveGatesViewMode(initiativeId, itemCount) {
|
||||
if (typeof window !== 'undefined' && initiativeId) {
|
||||
const stored = window.localStorage.getItem(`kairo.plan.gates.view:${initiativeId}`)
|
||||
if (stored === 'list' || stored === 'graph') {
|
||||
return stored
|
||||
}
|
||||
}
|
||||
return itemCount <= 5 ? 'list' : 'graph'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} initiativeId
|
||||
* @param {'list' | 'graph'} mode
|
||||
*/
|
||||
export function storeGatesViewMode(initiativeId, mode) {
|
||||
if (typeof window !== 'undefined' && initiativeId) {
|
||||
window.localStorage.setItem(`kairo.plan.gates.view:${initiativeId}`, mode)
|
||||
}
|
||||
}
|
||||
34
frontend/src/plan/gateGraphLayout.test.js
Normal file
34
frontend/src/plan/gateGraphLayout.test.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { buildLayoutEdges, computeGateGraphLayout } from './gateGraphLayout.js'
|
||||
|
||||
describe('gateGraphLayout', () => {
|
||||
it('layers requires dependencies with prerequisite above dependent', () => {
|
||||
const items = [
|
||||
{ id: 'a', title: 'A', sort_order: 0 },
|
||||
{ id: 'b', title: 'B', sort_order: 1 },
|
||||
]
|
||||
const dependencies = [
|
||||
{ id: 'd1', from_item_id: 'a', to_item_id: 'b', dependency_type: 'requires' },
|
||||
]
|
||||
|
||||
expect(buildLayoutEdges(dependencies)).toEqual([
|
||||
{ from: 'b', to: 'a', dependency_type: 'requires' },
|
||||
])
|
||||
|
||||
const layout = computeGateGraphLayout(items, dependencies)
|
||||
const nodeA = layout.nodes.find((node) => node.id === 'a')
|
||||
const nodeB = layout.nodes.find((node) => node.id === 'b')
|
||||
expect(nodeB.y).toBeLessThan(nodeA.y)
|
||||
})
|
||||
|
||||
it('places items without dependencies on one row by sort_order', () => {
|
||||
const items = [
|
||||
{ id: 'b', title: 'B', sort_order: 10 },
|
||||
{ id: 'a', title: 'A', sort_order: 0 },
|
||||
]
|
||||
const layout = computeGateGraphLayout(items, [])
|
||||
expect(layout.nodes.map((node) => node.id)).toEqual(['a', 'b'])
|
||||
expect(layout.nodes[0].y).toBe(layout.nodes[1].y)
|
||||
expect(layout.nodes[0].x).toBeLessThan(layout.nodes[1].x)
|
||||
})
|
||||
})
|
||||
|
|
@ -1611,3 +1611,152 @@
|
|||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gates-plan-panel__toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.gates-plan-panel__tab {
|
||||
border: 1px solid var(--jk-border, #dde3ea);
|
||||
background: var(--jk-surface, #fff);
|
||||
color: var(--jk-text-secondary, #445);
|
||||
border-radius: var(--jk-radius-md, 8px);
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gates-plan-panel__tab--active {
|
||||
background: var(--jk-surface-muted, #eef2f6);
|
||||
color: var(--jk-text, #1a1a1a);
|
||||
border-color: var(--jk-border-strong, #c5ced8);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gate-map-view__legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.gate-map-view__legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gate-map-view__legend-line {
|
||||
display: inline-block;
|
||||
width: 1.75rem;
|
||||
height: 0;
|
||||
border-top-width: 2px;
|
||||
border-top-style: solid;
|
||||
}
|
||||
|
||||
.gate-map-view__legend-line--requires {
|
||||
border-top-color: #2563eb;
|
||||
}
|
||||
|
||||
.gate-map-view__legend-line--blocks {
|
||||
border-top-color: #dc2626;
|
||||
}
|
||||
|
||||
.gate-map-view__legend-line--related {
|
||||
border-top-color: #64748b;
|
||||
border-top-style: dashed;
|
||||
}
|
||||
|
||||
.gate-map-view__canvas-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--jk-border, #dde3ea);
|
||||
border-radius: var(--jk-radius-md, 8px);
|
||||
background: var(--jk-surface-subtle, #f8fafc);
|
||||
}
|
||||
|
||||
.gate-map-view__canvas {
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.gate-map-view__edge {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.gate-map-view__edge--requires {
|
||||
stroke: #2563eb;
|
||||
}
|
||||
|
||||
.gate-map-view__edge--blocks {
|
||||
stroke: #dc2626;
|
||||
}
|
||||
|
||||
.gate-map-view__edge--related {
|
||||
stroke: #64748b;
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
|
||||
.gate-map-view__arrow--requires {
|
||||
fill: #2563eb;
|
||||
}
|
||||
|
||||
.gate-map-view__arrow--blocks {
|
||||
fill: #dc2626;
|
||||
}
|
||||
|
||||
.gate-map-view__node {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gate-map-view__node-box {
|
||||
fill: #fff;
|
||||
stroke: var(--jk-border-strong, #c5ced8);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.gate-map-view__node--active .gate-map-view__node-box,
|
||||
.gate-map-view__node--planned .gate-map-view__node-box {
|
||||
stroke: #2563eb;
|
||||
}
|
||||
|
||||
.gate-map-view__node--at_risk .gate-map-view__node-box {
|
||||
stroke: #d97706;
|
||||
}
|
||||
|
||||
.gate-map-view__node--reached .gate-map-view__node-box {
|
||||
stroke: #16a34a;
|
||||
fill: #f0fdf4;
|
||||
}
|
||||
|
||||
.gate-map-view__node--discarded .gate-map-view__node-box,
|
||||
.gate-map-view__node--moved .gate-map-view__node-box {
|
||||
stroke: #94a3b8;
|
||||
fill: #f8fafc;
|
||||
}
|
||||
|
||||
.gate-map-view__node-title {
|
||||
fill: var(--jk-text, #1a1a1a);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gate-map-view__node-meta {
|
||||
fill: var(--jk-text-secondary, #445);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gate-map-view__node:focus-visible .gate-map-view__node-box {
|
||||
stroke: #2563eb;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.gate-map-view__hint {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user