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:
+176
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user