refactor(admin): split control-library, iace/mitigations, iace/components, controls pages

All 4 page.tsx files reduced well below 500 LOC (235/181/158/262) by
extracting components and hooks into colocated _components/ and _hooks/
subdirectories. Zero behavior changes — logic relocated verbatim.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-17 12:24:58 +02:00
parent ce27636b67
commit 083792dfd7
24 changed files with 2746 additions and 2959 deletions

View File

@@ -0,0 +1,105 @@
'use client'
import { useState } from 'react'
import { Component, ComponentFormData, COMPONENT_TYPES } from './types'
export function ComponentForm({
onSubmit,
onCancel,
initialData,
parentId,
}: {
onSubmit: (data: ComponentFormData) => void
onCancel: () => void
initialData?: Component | null
parentId?: string | null
}) {
const [formData, setFormData] = useState<ComponentFormData>({
name: initialData?.name || '',
type: initialData?.type || 'SW',
version: initialData?.version || '',
description: initialData?.description || '',
safety_relevant: initialData?.safety_relevant || false,
parent_id: parentId || initialData?.parent_id || null,
})
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{initialData ? 'Komponente bearbeiten' : parentId ? 'Unterkomponente hinzufuegen' : 'Neue Komponente'}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Bildverarbeitungsmodul"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
{COMPONENT_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Version</label>
<input
type="text"
value={formData.version}
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
placeholder="z.B. 1.2.0"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="flex items-center gap-3 pt-6">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.safety_relevant}
onChange={(e) => setFormData({ ...formData, safety_relevant: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-red-500" />
</label>
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Kurze Beschreibung der Komponente..."
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => onSubmit(formData)}
disabled={!formData.name}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.name
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
{initialData ? 'Aktualisieren' : 'Hinzufuegen'}
</button>
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,216 @@
'use client'
import { useState, useEffect } from 'react'
import { LibraryComponent, EnergySource, LIBRARY_CATEGORIES } from './types'
import { ComponentTypeIcon } from './ComponentTypeIcon'
export function ComponentLibraryModal({
onAdd,
onClose,
}: {
onAdd: (components: LibraryComponent[], energySources: EnergySource[]) => void
onClose: () => void
}) {
const [libraryComponents, setLibraryComponents] = useState<LibraryComponent[]>([])
const [energySources, setEnergySources] = useState<EnergySource[]>([])
const [selectedComponents, setSelectedComponents] = useState<Set<string>>(new Set())
const [selectedEnergySources, setSelectedEnergySources] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [filterCategory, setFilterCategory] = useState('')
const [activeTab, setActiveTab] = useState<'components' | 'energy'>('components')
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchData() {
try {
const [compRes, enRes] = await Promise.all([
fetch('/api/sdk/v1/iace/component-library'),
fetch('/api/sdk/v1/iace/energy-sources'),
])
if (compRes.ok) {
const json = await compRes.json()
setLibraryComponents(json.components || [])
}
if (enRes.ok) {
const json = await enRes.json()
setEnergySources(json.energy_sources || [])
}
} catch (err) {
console.error('Failed to fetch library:', err)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
function toggleComponent(id: string) {
setSelectedComponents(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleEnergySource(id: string) {
setSelectedEnergySources(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleAllInCategory(category: string) {
const items = libraryComponents.filter(c => c.category === category)
const allIds = items.map(i => i.id)
const allSelected = allIds.every(id => selectedComponents.has(id))
setSelectedComponents(prev => {
const next = new Set(prev)
allIds.forEach(id => allSelected ? next.delete(id) : next.add(id))
return next
})
}
function handleAdd() {
const selComps = libraryComponents.filter(c => selectedComponents.has(c.id))
const selEnergy = energySources.filter(e => selectedEnergySources.has(e.id))
onAdd(selComps, selEnergy)
}
const categories = Object.keys(LIBRARY_CATEGORIES)
const filtered = libraryComponents.filter(c => {
if (filterCategory && c.category !== filterCategory) return false
if (search) {
const q = search.toLowerCase()
return c.name_de.toLowerCase().includes(q) || c.name_en.toLowerCase().includes(q) || c.description_de.toLowerCase().includes(q)
}
return true
})
const grouped = filtered.reduce<Record<string, LibraryComponent[]>>((acc, c) => {
if (!acc[c.category]) acc[c.category] = []
acc[c.category].push(c)
return acc
}, {})
const totalSelected = selectedComponents.size + selectedEnergySources.size
if (loading) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto" />
<p className="mt-3 text-sm text-gray-500">Bibliothek wird geladen...</p>
</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-4xl max-h-[85vh] flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Komponentenbibliothek</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex gap-2 mb-4">
<button onClick={() => setActiveTab('components')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${activeTab === 'components' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'}`}>
Komponenten ({libraryComponents.length})
</button>
<button onClick={() => setActiveTab('energy')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${activeTab === 'energy' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'}`}>
Energiequellen ({energySources.length})
</button>
</div>
{activeTab === 'components' && (
<div className="flex gap-3">
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Suchen..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
<select value={filterCategory} onChange={e => setFilterCategory(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
<option value="">Alle Kategorien</option>
{categories.map(cat => <option key={cat} value={cat}>{LIBRARY_CATEGORIES[cat]}</option>)}
</select>
</div>
)}
</div>
<div className="flex-1 overflow-auto p-4">
{activeTab === 'components' ? (
<div className="space-y-4">
{Object.entries(grouped)
.sort(([a], [b]) => categories.indexOf(a) - categories.indexOf(b))
.map(([category, items]) => (
<div key={category}>
<div className="flex items-center gap-2 mb-2 sticky top-0 bg-white dark:bg-gray-800 py-1 z-10">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">{LIBRARY_CATEGORIES[category] || category}</h4>
<span className="text-xs text-gray-400">({items.length})</span>
<button onClick={() => toggleAllInCategory(category)} className="text-xs text-purple-600 hover:text-purple-700 ml-auto">
{items.every(i => selectedComponents.has(i.id)) ? 'Alle abwaehlen' : 'Alle waehlen'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{items.map(comp => (
<label key={comp.id}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedComponents.has(comp.id) ? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 hover:bg-gray-50 dark:border-gray-700'
}`}>
<input type="checkbox" checked={selectedComponents.has(comp.id)} onChange={() => toggleComponent(comp.id)} className="mt-0.5 accent-purple-600" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-gray-400">{comp.id}</span>
<ComponentTypeIcon type={comp.maps_to_component_type} />
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{comp.name_de}</div>
{comp.description_de && <div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{comp.description_de}</div>}
</div>
</label>
))}
</div>
</div>
))}
{filtered.length === 0 && <div className="text-center py-8 text-gray-500">Keine Komponenten gefunden</div>}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{energySources.map(es => (
<label key={es.id}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedEnergySources.has(es.id) ? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 hover:bg-gray-50 dark:border-gray-700'
}`}>
<input type="checkbox" checked={selectedEnergySources.has(es.id)} onChange={() => toggleEnergySource(es.id)} className="mt-0.5 accent-purple-600" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2"><span className="text-xs font-mono text-gray-400">{es.id}</span></div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{es.name_de}</div>
{es.description_de && <div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{es.description_de}</div>}
</div>
</label>
))}
</div>
)}
</div>
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span className="text-sm text-gray-500">
{selectedComponents.size} Komponenten, {selectedEnergySources.size} Energiequellen ausgewaehlt
</span>
<div className="flex gap-3">
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">Abbrechen</button>
<button onClick={handleAdd} disabled={totalSelected === 0}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${totalSelected > 0 ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}>
{totalSelected > 0 ? `${totalSelected} hinzufuegen` : 'Auswaehlen'}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,102 @@
'use client'
import { useState } from 'react'
import { Component } from './types'
import { ComponentTypeIcon } from './ComponentTypeIcon'
export function ComponentTreeNode({
component,
depth,
onEdit,
onDelete,
onAddChild,
}: {
component: Component
depth: number
onEdit: (c: Component) => void
onDelete: (id: string) => void
onAddChild: (parentId: string) => void
}) {
const [expanded, setExpanded] = useState(true)
const hasChildren = component.children && component.children.length > 0
return (
<div>
<div
className="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 group transition-colors"
style={{ paddingLeft: `${depth * 24 + 12}px` }}
>
<button
onClick={() => setExpanded(!expanded)}
className={`w-5 h-5 flex items-center justify-center text-gray-400 ${hasChildren ? 'visible' : 'invisible'}`}
>
<svg
className={`w-4 h-4 transition-transform ${expanded ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<ComponentTypeIcon type={component.type} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900 dark:text-white">{component.name}</span>
{component.version && <span className="ml-2 text-xs text-gray-400">v{component.version}</span>}
{component.safety_relevant && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">
Sicherheitsrelevant
</span>
)}
{component.library_component_id && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
Bibliothek
</span>
)}
</div>
{component.description && (
<span className="text-xs text-gray-400 truncate max-w-[200px] hidden lg:block">
{component.description}
</span>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => onAddChild(component.id)} title="Unterkomponente hinzufuegen"
className="p-1 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
<button onClick={() => onEdit(component)} title="Bearbeiten"
className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button onClick={() => onDelete(component.id)} title="Loeschen"
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
{expanded && hasChildren && (
<div>
{component.children.map((child) => (
<ComponentTreeNode
key={child.id}
component={child}
depth={depth + 1}
onEdit={onEdit}
onDelete={onDelete}
onAddChild={onAddChild}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,22 @@
'use client'
export function ComponentTypeIcon({ type }: { type: string }) {
const colors: Record<string, string> = {
SW: 'bg-blue-100 text-blue-700',
FW: 'bg-indigo-100 text-indigo-700',
AI: 'bg-purple-100 text-purple-700',
HMI: 'bg-pink-100 text-pink-700',
SENSOR: 'bg-cyan-100 text-cyan-700',
ACTUATOR: 'bg-orange-100 text-orange-700',
CONTROLLER: 'bg-green-100 text-green-700',
NETWORK: 'bg-yellow-100 text-yellow-700',
MECHANICAL: 'bg-gray-100 text-gray-700',
ELECTRICAL: 'bg-red-100 text-red-700',
OTHER: 'bg-gray-100 text-gray-500',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colors[type] || colors.OTHER}`}>
{type}
</span>
)
}

View File

@@ -0,0 +1,93 @@
export interface Component {
id: string
name: string
type: string
version: string
description: string
safety_relevant: boolean
parent_id: string | null
children: Component[]
library_component_id?: string
energy_source_ids?: string[]
}
export interface LibraryComponent {
id: string
name_de: string
name_en: string
category: string
description_de: string
typical_hazard_categories: string[]
typical_energy_sources: string[]
maps_to_component_type: string
tags: string[]
sort_order: number
}
export interface EnergySource {
id: string
name_de: string
name_en: string
description_de: string
typical_components: string[]
typical_hazard_categories: string[]
tags: string[]
sort_order: number
}
export interface ComponentFormData {
name: string
type: string
version: string
description: string
safety_relevant: boolean
parent_id: string | null
}
export const LIBRARY_CATEGORIES: Record<string, string> = {
mechanical: 'Mechanik',
structural: 'Struktur',
drive: 'Antrieb',
hydraulic: 'Hydraulik',
pneumatic: 'Pneumatik',
electrical: 'Elektrik',
control: 'Steuerung',
sensor: 'Sensorik',
actuator: 'Aktorik',
safety: 'Sicherheit',
it_network: 'IT/Netzwerk',
}
export const COMPONENT_TYPES = [
{ value: 'SW', label: 'Software (SW)' },
{ value: 'FW', label: 'Firmware (FW)' },
{ value: 'AI', label: 'KI-Modul (AI)' },
{ value: 'HMI', label: 'Mensch-Maschine-Schnittstelle (HMI)' },
{ value: 'SENSOR', label: 'Sensor' },
{ value: 'ACTUATOR', label: 'Aktor' },
{ value: 'CONTROLLER', label: 'Steuerung' },
{ value: 'NETWORK', label: 'Netzwerk' },
{ value: 'MECHANICAL', label: 'Mechanik' },
{ value: 'ELECTRICAL', label: 'Elektrik' },
{ value: 'OTHER', label: 'Sonstiges' },
]
export function buildTree(components: Component[]): Component[] {
const map = new Map<string, Component>()
const roots: Component[] = []
components.forEach((c) => {
map.set(c.id, { ...c, children: [] })
})
components.forEach((c) => {
const node = map.get(c.id)!
if (c.parent_id && map.has(c.parent_id)) {
map.get(c.parent_id)!.children.push(node)
} else {
roots.push(node)
}
})
return roots
}