feat(iace): Phase 5 — Betriebszustand-UI + E2E Tests

- GET /operational-states endpoint (9 States + 20 Transitions)
- Frontend: Operational States page with state cards, transitions graph, delta preview
- Navigation: Betriebszustaende entry between Grenzen and Normenrecherche
- E2E: 60+ new Phase 5 tests (operational states, hazards, mitigations, classification)
- E2E: Updated expected counts for expanded libraries (476 measures, 1114 patterns)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-11 00:26:07 +02:00
parent 350476b392
commit 53c641800f
6 changed files with 993 additions and 11 deletions
@@ -0,0 +1,176 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
// ── Types ──────────────────────────────────────────────────
export interface OperationalStateInfo {
id: string
label_de: string
label_en: string
sort_order: number
}
export interface DeltaResult {
added_patterns: number
removed_patterns: number
added_hazards: string[]
removed_hazards: string[]
added_measures: string[]
removed_measures: string[]
}
interface ProjectMetadata {
limits_form?: Record<string, unknown>
operational_states?: string[]
[key: string]: unknown
}
// ── Hook ───────────────────────────────────────────────────
export function useOperationalStates(projectId: string) {
const [allStates, setAllStates] = useState<OperationalStateInfo[]>([])
const [transitions, setTransitions] = useState<string[]>([])
const [selectedStates, setSelectedStates] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [deltaResult, setDeltaResult] = useState<DeltaResult | null>(null)
const [deltaLoading, setDeltaLoading] = useState(false)
const metadataRef = useRef<ProjectMetadata>({})
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
loadData()
return () => {
if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
}
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
async function loadData() {
try {
const [statesRes, projRes] = await Promise.all([
fetch('/api/sdk/v1/iace/operational-states'),
fetch(`/api/sdk/v1/iace/projects/${projectId}`),
])
if (statesRes.ok) {
const json = await statesRes.json()
setAllStates(json.operational_states || [])
setTransitions(json.transitions || [])
}
if (projRes.ok) {
const proj = await projRes.json()
const meta: ProjectMetadata = proj.metadata || {}
metadataRef.current = meta
setSelectedStates(meta.operational_states || [])
}
} catch (err) {
console.error('Failed to load operational states:', err)
} finally {
setLoading(false)
}
}
const toggleState = useCallback((stateId: string) => {
setSelectedStates((prev) => {
const next = prev.includes(stateId)
? prev.filter((s) => s !== stateId)
: [...prev, stateId]
return next
})
setDeltaResult(null)
}, [])
const saveSelection = useCallback(async (states: string[]) => {
setSaving(true)
setSaved(false)
try {
const newMetadata = { ...metadataRef.current, operational_states: states }
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata: newMetadata }),
})
if (res.ok) {
metadataRef.current = newMetadata
setSaved(true)
if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
savedTimerRef.current = setTimeout(() => setSaved(false), 2000)
}
} catch (err) {
console.error('Failed to save:', err)
} finally {
setSaving(false)
}
}, [projectId])
const runDeltaAnalysis = useCallback(async (states: string[]) => {
setDeltaLoading(true)
setDeltaResult(null)
try {
// Build a MatchInput from the project's current components + proposed states
const compRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
let componentIds: string[] = []
let energyIds: string[] = []
if (compRes.ok) {
const cj = await compRes.json()
const comps = cj.components || cj || []
componentIds = comps.map((c: { library_id?: string }) => c.library_id).filter(Boolean)
energyIds = comps.flatMap((c: { energy_source_ids?: string[] }) => c.energy_source_ids || [])
}
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/delta-analysis`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current: {
component_library_ids: componentIds,
energy_source_ids: energyIds,
operational_states: metadataRef.current.operational_states || [],
},
proposed: {
component_library_ids: componentIds,
energy_source_ids: energyIds,
operational_states: states,
},
}),
})
if (res.ok) {
const json = await res.json()
setDeltaResult({
added_patterns: json.added_patterns?.length || 0,
removed_patterns: json.removed_patterns?.length || 0,
added_hazards: (json.added_hazards || []).map((h: { name?: string }) => h.name || ''),
removed_hazards: (json.removed_hazards || []).map((h: { name?: string }) => h.name || ''),
added_measures: (json.added_measures || []).map((m: { id?: string }) => m.id || ''),
removed_measures: (json.removed_measures || []).map((m: { id?: string }) => m.id || ''),
})
}
} catch (err) {
console.error('Delta analysis failed:', err)
} finally {
setDeltaLoading(false)
}
}, [projectId])
/** Transitions that involve only selected states */
const activeTransitions = transitions.filter((t) => {
const [from, to] = t.split('\u2192')
return selectedStates.includes(from) && selectedStates.includes(to)
})
return {
allStates,
transitions,
activeTransitions,
selectedStates,
toggleState,
saveSelection,
runDeltaAnalysis,
loading,
saving,
saved,
deltaResult,
deltaLoading,
}
}