- {totalCount} Controls gefunden
- {totalCount !== (meta?.total ?? totalCount) && ` (von ${meta?.total} gesamt)`}
- {loading && Lade...}
+ {lib.totalCount} Controls gefunden
+ {lib.totalCount !== (lib.meta?.total ?? lib.totalCount) && ` (von ${lib.meta?.total} gesamt)`}
+ {lib.loading && Lade...}
- {stateFilter && ['needs_review', 'too_close', 'duplicate'].includes(stateFilter) && totalCount > 0 && (
-
-
Seite {currentPage} von {totalPages}
+
Seite {lib.currentPage} von {lib.totalPages}
- {/* Control List */}
- {controls.map((ctrl, idx) => {
- // Show source group header when sorting by source
- const prevSource = idx > 0 ? (controls[idx - 1].source_citation?.source || 'Ohne Quelle') : null
- const curSource = ctrl.source_citation?.source || 'Ohne Quelle'
- const showSourceHeader = sortBy === 'source' && curSource !== prevSource
-
- return (
-
- {showSourceHeader && (
-
- )}
-
{ setSelectedControl(ctrl); setMode('detail') }}
- className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group"
- >
-
-
-
- {ctrl.control_id}
-
-
-
-
-
-
-
-
-
- {ctrl.risk_score !== null && (
- Score: {ctrl.risk_score}
- )}
-
-
{ctrl.title}
-
{ctrl.objective}
-
- {/* Open anchors summary + timestamp */}
-
-
-
- {ctrl.open_anchors.length} Referenzen
-
- {ctrl.source_citation?.source && (
- <>
- |
-
- {ctrl.source_citation.source}
- {ctrl.source_citation.article && ` ${ctrl.source_citation.article}`}
- {ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
-
- >
- )}
- |
-
-
- {ctrl.created_at ? new Date(ctrl.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '–'}
-
-
-
-
-
-
-
- )
- })}
-
- {controls.length === 0 && !loading && (
+ {lib.controls.map((ctrl, idx) => (
+
0 ? (lib.controls[idx - 1].source_citation?.source || 'Ohne Quelle') : null}
+ onClick={() => { lib.setSelectedControl(ctrl); lib.setMode('detail') }}
+ />
+ ))}
+ {lib.controls.length === 0 && !lib.loading && (
- {totalCount === 0 && !debouncedSearch && !severityFilter && !domainFilter
+ {lib.totalCount === 0 && !lib.debouncedSearch && !lib.severityFilter && !lib.domainFilter
? 'Noch keine Controls vorhanden. Klicke auf "Neues Control" um zu starten.'
: 'Keine Controls gefunden.'}
)}
- {/* Pagination Controls */}
- {totalPages > 1 && (
+ {lib.totalPages > 1 && (
- setCurrentPage(1)}
- disabled={currentPage === 1}
- className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed"
- title="Erste Seite"
- >
+ lib.setCurrentPage(1)} disabled={lib.currentPage === 1}
+ className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Erste Seite">
- setCurrentPage(p => Math.max(1, p - 1))}
- disabled={currentPage === 1}
- className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed"
- title="Vorherige Seite"
- >
+ lib.setCurrentPage(p => Math.max(1, p - 1))} disabled={lib.currentPage === 1}
+ className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Vorherige Seite">
-
- {/* Page numbers */}
- {Array.from({ length: totalPages }, (_, i) => i + 1)
- .filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
+ {Array.from({ length: lib.totalPages }, (_, i) => i + 1)
+ .filter(p => p === 1 || p === lib.totalPages || Math.abs(p - lib.currentPage) <= 2)
.reduce<(number | 'dots')[]>((acc, p, i, arr) => {
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots')
- acc.push(p)
- return acc
+ acc.push(p); return acc
}, [])
- .map((p, i) =>
- p === 'dots' ? (
- ...
- ) : (
- setCurrentPage(p as number)}
- className={`w-8 h-8 text-sm rounded-lg ${
- currentPage === p
- ? 'bg-purple-600 text-white'
- : 'text-gray-600 hover:bg-purple-50 hover:text-purple-600'
- }`}
- >
- {p}
-
- )
- )
- }
-
- setCurrentPage(p => Math.min(totalPages, p + 1))}
- disabled={currentPage === totalPages}
- className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed"
- title="Naechste Seite"
- >
+ .map((p, i) => p === 'dots' ? (
+ ...
+ ) : (
+ lib.setCurrentPage(p as number)}
+ className={`w-8 h-8 text-sm rounded-lg ${lib.currentPage === p ? 'bg-purple-600 text-white' : 'text-gray-600 hover:bg-purple-50 hover:text-purple-600'}`}>
+ {p}
+
+ ))}
+ lib.setCurrentPage(p => Math.min(lib.totalPages, p + 1))} disabled={lib.currentPage === lib.totalPages}
+ className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Naechste Seite">
- setCurrentPage(totalPages)}
- disabled={currentPage === totalPages}
- className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed"
- title="Letzte Seite"
- >
+ lib.setCurrentPage(lib.totalPages)} disabled={lib.currentPage === lib.totalPages}
+ className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Letzte Seite">
diff --git a/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentForm.tsx b/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentForm.tsx
new file mode 100644
index 0000000..c370bee
--- /dev/null
+++ b/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentForm.tsx
@@ -0,0 +1,80 @@
+'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
({
+ 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 (
+
+
+ {initialData ? 'Komponente bearbeiten' : parentId ? 'Unterkomponente hinzufuegen' : 'Neue Komponente'}
+
+
+
+
+ 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" />
+
+
+
+
+
+
+
+ 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" />
+
+
+
+
Sicherheitsrelevant
+
+
+
+
+
+
+ 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'}
+
+
+ Abbrechen
+
+
+
+ )
+}
diff --git a/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentLibraryModal.tsx b/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentLibraryModal.tsx
new file mode 100644
index 0000000..d2d83f7
--- /dev/null
+++ b/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentLibraryModal.tsx
@@ -0,0 +1,188 @@
+'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([])
+ const [energySources, setEnergySources] = useState([])
+ const [selectedComponents, setSelectedComponents] = useState>(new Set())
+ const [selectedEnergySources, setSelectedEnergySources] = useState>(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 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>((acc, c) => {
+ if (!acc[c.category]) acc[c.category] = []
+ acc[c.category].push(c)
+ return acc
+ }, {})
+
+ const categories = Object.keys(LIBRARY_CATEGORIES)
+ const totalSelected = selectedComponents.size + selectedEnergySources.size
+
+ if (loading) {
+ return (
+
+
+
+
Bibliothek wird geladen...
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
Komponentenbibliothek
+
+
+
+
+
+ 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})
+
+ 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})
+
+
+ {activeTab === 'components' && (
+
+ 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" />
+
+
+ )}
+
+
+
+ {activeTab === 'components' ? (
+
+ {Object.entries(grouped).sort(([a], [b]) => categories.indexOf(a) - categories.indexOf(b)).map(([category, items]) => (
+
+
+
{LIBRARY_CATEGORIES[category] || category}
+ ({items.length})
+ 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'}
+
+
+
+ {items.map(comp => (
+
+ ))}
+
+
+ ))}
+ {filtered.length === 0 &&
Keine Komponenten gefunden
}
+
+ ) : (
+
+ {energySources.map(es => (
+
+ ))}
+
+ )}
+
+
+
+
{selectedComponents.size} Komponenten, {selectedEnergySources.size} Energiequellen ausgewaehlt
+
+ Abbrechen
+ 0 ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}>
+ {totalSelected > 0 ? `${totalSelected} hinzufuegen` : 'Auswaehlen'}
+
+
+
+
+
+ )
+}
diff --git a/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentTreeNode.tsx b/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentTreeNode.tsx
new file mode 100644
index 0000000..4714fa6
--- /dev/null
+++ b/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentTreeNode.tsx
@@ -0,0 +1,85 @@
+'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 (
+
+
+
setExpanded(!expanded)}
+ className={`w-5 h-5 flex items-center justify-center text-gray-400 ${hasChildren ? 'visible' : 'invisible'}`}>
+
+
+
+
+
+
+ {component.name}
+ {component.version && v{component.version}}
+ {component.safety_relevant && (
+
+ Sicherheitsrelevant
+
+ )}
+ {component.library_component_id && (
+
+ Bibliothek
+
+ )}
+
+
+ {component.description && (
+
{component.description}
+ )}
+
+
+
onAddChild(component.id)} title="Unterkomponente hinzufuegen"
+ className="p-1 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded transition-colors">
+
+
+
onEdit(component)} title="Bearbeiten"
+ className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors">
+
+
+
onDelete(component.id)} title="Loeschen"
+ className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors">
+
+
+
+
+
+ {expanded && hasChildren && (
+
+ {component.children.map((child) => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentTypeIcon.tsx b/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentTypeIcon.tsx
new file mode 100644
index 0000000..54eaba6
--- /dev/null
+++ b/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentTypeIcon.tsx
@@ -0,0 +1,20 @@
+export function ComponentTypeIcon({ type }: { type: string }) {
+ const colors: Record = {
+ 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 (
+
+ {type}
+
+ )
+}
diff --git a/admin-compliance/app/sdk/iace/[projectId]/components/_components/types.ts b/admin-compliance/app/sdk/iace/[projectId]/components/_components/types.ts
new file mode 100644
index 0000000..4d8ef03
--- /dev/null
+++ b/admin-compliance/app/sdk/iace/[projectId]/components/_components/types.ts
@@ -0,0 +1,88 @@
+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 = {
+ 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()
+ 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
+}
diff --git a/admin-compliance/app/sdk/iace/[projectId]/components/_hooks/useComponents.ts b/admin-compliance/app/sdk/iace/[projectId]/components/_hooks/useComponents.ts
new file mode 100644
index 0000000..d1cee42
--- /dev/null
+++ b/admin-compliance/app/sdk/iace/[projectId]/components/_hooks/useComponents.ts
@@ -0,0 +1,80 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Component, LibraryComponent, EnergySource, ComponentFormData, buildTree } from '../_components/types'
+
+export function useComponents(projectId: string) {
+ const [components, setComponents] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [showForm, setShowForm] = useState(false)
+ const [editingComponent, setEditingComponent] = useState(null)
+ const [addingParentId, setAddingParentId] = useState(null)
+ const [showLibrary, setShowLibrary] = useState(false)
+
+ useEffect(() => { fetchComponents() }, [projectId])
+
+ async function fetchComponents() {
+ try {
+ const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
+ if (res.ok) { const json = await res.json(); setComponents(json.components || json || []) }
+ } catch (err) {
+ console.error('Failed to fetch components:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ async function handleSubmit(data: ComponentFormData) {
+ try {
+ const url = editingComponent
+ ? `/api/sdk/v1/iace/projects/${projectId}/components/${editingComponent.id}`
+ : `/api/sdk/v1/iace/projects/${projectId}/components`
+ const method = editingComponent ? 'PUT' : 'POST'
+ const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
+ if (res.ok) { setShowForm(false); setEditingComponent(null); setAddingParentId(null); await fetchComponents() }
+ } catch (err) { console.error('Failed to save component:', err) }
+ }
+
+ async function handleDelete(id: string) {
+ if (!confirm('Komponente wirklich loeschen? Unterkomponenten werden ebenfalls entfernt.')) return
+ try {
+ const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, { method: 'DELETE' })
+ if (res.ok) await fetchComponents()
+ } catch (err) { console.error('Failed to delete component:', err) }
+ }
+
+ function handleEdit(component: Component) {
+ setEditingComponent(component); setAddingParentId(null); setShowForm(true)
+ }
+
+ function handleAddChild(parentId: string) {
+ setAddingParentId(parentId); setEditingComponent(null); setShowForm(true)
+ }
+
+ async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
+ setShowLibrary(false)
+ const energySourceIds = energySrcs.map(e => e.id)
+ for (const comp of libraryComps) {
+ try {
+ await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: comp.name_de, type: comp.maps_to_component_type,
+ description: comp.description_de, safety_relevant: false,
+ library_component_id: comp.id, energy_source_ids: energySourceIds, tags: comp.tags,
+ }),
+ })
+ } catch (err) { console.error(`Failed to add component ${comp.id}:`, err) }
+ }
+ await fetchComponents()
+ }
+
+ const tree = buildTree(components)
+
+ return {
+ components, loading, tree,
+ showForm, setShowForm, editingComponent, setEditingComponent,
+ addingParentId, setAddingParentId, showLibrary, setShowLibrary,
+ handleSubmit, handleDelete, handleEdit, handleAddChild, handleAddFromLibrary,
+ }
+}
diff --git a/admin-compliance/app/sdk/iace/[projectId]/components/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/components/page.tsx
index ba7dcbe..749fa12 100644
--- a/admin-compliance/app/sdk/iace/[projectId]/components/page.tsx
+++ b/admin-compliance/app/sdk/iace/[projectId]/components/page.tsx
@@ -1,728 +1,17 @@
'use client'
-import React, { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
-
-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[]
-}
-
-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
-}
-
-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
-}
-
-const LIBRARY_CATEGORIES: Record = {
- mechanical: 'Mechanik',
- structural: 'Struktur',
- drive: 'Antrieb',
- hydraulic: 'Hydraulik',
- pneumatic: 'Pneumatik',
- electrical: 'Elektrik',
- control: 'Steuerung',
- sensor: 'Sensorik',
- actuator: 'Aktorik',
- safety: 'Sicherheit',
- it_network: 'IT/Netzwerk',
-}
-
-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' },
-]
-
-function ComponentTypeIcon({ type }: { type: string }) {
- const colors: Record = {
- 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 (
-
- {type}
-
- )
-}
-
-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 (
-
-
- {/* Expand/collapse */}
-
setExpanded(!expanded)}
- className={`w-5 h-5 flex items-center justify-center text-gray-400 ${hasChildren ? 'visible' : 'invisible'}`}
- >
-
-
-
-
-
-
- {component.name}
- {component.version && (
- v{component.version}
- )}
- {component.safety_relevant && (
-
- Sicherheitsrelevant
-
- )}
- {component.library_component_id && (
-
- Bibliothek
-
- )}
-
-
- {component.description && (
-
- {component.description}
-
- )}
-
- {/* Actions */}
-
-
onAddChild(component.id)}
- title="Unterkomponente hinzufuegen"
- className="p-1 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded transition-colors"
- >
-
-
-
onEdit(component)}
- title="Bearbeiten"
- className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
- >
-
-
-
onDelete(component.id)}
- title="Loeschen"
- className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
- >
-
-
-
-
-
- {expanded && hasChildren && (
-
- {component.children.map((child) => (
-
- ))}
-
- )}
-
- )
-}
-
-interface ComponentFormData {
- name: string
- type: string
- version: string
- description: string
- safety_relevant: boolean
- parent_id: string | null
-}
-
-function ComponentForm({
- onSubmit,
- onCancel,
- initialData,
- parentId,
-}: {
- onSubmit: (data: ComponentFormData) => void
- onCancel: () => void
- initialData?: Component | null
- parentId?: string | null
-}) {
- const [formData, setFormData] = useState({
- 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 (
-
-
- {initialData ? 'Komponente bearbeiten' : parentId ? 'Unterkomponente hinzufuegen' : 'Neue Komponente'}
-
-
-
-
- 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"
- />
-
-
-
-
-
-
-
- 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"
- />
-
-
-
-
Sicherheitsrelevant
-
-
-
-
-
-
- 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'}
-
-
- Abbrechen
-
-
-
- )
-}
-
-function buildTree(components: Component[]): Component[] {
- const map = new Map()
- 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
-}
-
-// ============================================================================
-// Component Library Modal (Phase 5)
-// ============================================================================
-
-function ComponentLibraryModal({
- onAdd,
- onClose,
-}: {
- onAdd: (components: LibraryComponent[], energySources: EnergySource[]) => void
- onClose: () => void
-}) {
- const [libraryComponents, setLibraryComponents] = useState([])
- const [energySources, setEnergySources] = useState([])
- const [selectedComponents, setSelectedComponents] = useState>(new Set())
- const [selectedEnergySources, setSelectedEnergySources] = useState>(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 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>((acc, c) => {
- if (!acc[c.category]) acc[c.category] = []
- acc[c.category].push(c)
- return acc
- }, {})
-
- const categories = Object.keys(LIBRARY_CATEGORIES)
- const totalSelected = selectedComponents.size + selectedEnergySources.size
-
- if (loading) {
- return (
-
-
-
-
Bibliothek wird geladen...
-
-
- )
- }
-
- return (
-
-
- {/* Header */}
-
-
-
Komponentenbibliothek
-
-
-
-
-
- {/* Tabs */}
-
- 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})
-
- 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})
-
-
-
- {activeTab === 'components' && (
-
- 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"
- />
-
-
- )}
-
-
- {/* Body */}
-
- {activeTab === 'components' ? (
-
- {Object.entries(grouped)
- .sort(([a], [b]) => categories.indexOf(a) - categories.indexOf(b))
- .map(([category, items]) => (
-
-
-
- {LIBRARY_CATEGORIES[category] || category}
-
- ({items.length})
- 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'}
-
-
-
- {items.map(comp => (
-
- ))}
-
-
- ))}
- {filtered.length === 0 && (
-
Keine Komponenten gefunden
- )}
-
- ) : (
-
- {energySources.map(es => (
-
- ))}
-
- )}
-
-
- {/* Footer */}
-
-
- {selectedComponents.size} Komponenten, {selectedEnergySources.size} Energiequellen ausgewaehlt
-
-
-
- Abbrechen
-
- 0
- ? 'bg-purple-600 text-white hover:bg-purple-700'
- : 'bg-gray-200 text-gray-400 cursor-not-allowed'
- }`}
- >
- {totalSelected > 0 ? `${totalSelected} hinzufuegen` : 'Auswaehlen'}
-
-
-
-
-
- )
-}
-
-// ============================================================================
-// Main Page
-// ============================================================================
+import { ComponentForm } from './_components/ComponentForm'
+import { ComponentTreeNode } from './_components/ComponentTreeNode'
+import { ComponentLibraryModal } from './_components/ComponentLibraryModal'
+import { useComponents } from './_hooks/useComponents'
export default function ComponentsPage() {
const params = useParams()
const projectId = params.projectId as string
- const [components, setComponents] = useState([])
- const [loading, setLoading] = useState(true)
- const [showForm, setShowForm] = useState(false)
- const [editingComponent, setEditingComponent] = useState(null)
- const [addingParentId, setAddingParentId] = useState(null)
- const [showLibrary, setShowLibrary] = useState(false)
+ const c = useComponents(projectId)
- useEffect(() => {
- fetchComponents()
- }, [projectId])
-
- async function fetchComponents() {
- try {
- const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
- if (res.ok) {
- const json = await res.json()
- setComponents(json.components || json || [])
- }
- } catch (err) {
- console.error('Failed to fetch components:', err)
- } finally {
- setLoading(false)
- }
- }
-
- async function handleSubmit(data: ComponentFormData) {
- try {
- const url = editingComponent
- ? `/api/sdk/v1/iace/projects/${projectId}/components/${editingComponent.id}`
- : `/api/sdk/v1/iace/projects/${projectId}/components`
- const method = editingComponent ? 'PUT' : 'POST'
-
- const res = await fetch(url, {
- method,
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data),
- })
- if (res.ok) {
- setShowForm(false)
- setEditingComponent(null)
- setAddingParentId(null)
- await fetchComponents()
- }
- } catch (err) {
- console.error('Failed to save component:', err)
- }
- }
-
- async function handleDelete(id: string) {
- if (!confirm('Komponente wirklich loeschen? Unterkomponenten werden ebenfalls entfernt.')) return
- try {
- const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, {
- method: 'DELETE',
- })
- if (res.ok) {
- await fetchComponents()
- }
- } catch (err) {
- console.error('Failed to delete component:', err)
- }
- }
-
- function handleEdit(component: Component) {
- setEditingComponent(component)
- setAddingParentId(null)
- setShowForm(true)
- }
-
- function handleAddChild(parentId: string) {
- setAddingParentId(parentId)
- setEditingComponent(null)
- setShowForm(true)
- }
-
- async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
- setShowLibrary(false)
- const energySourceIds = energySrcs.map(e => e.id)
-
- for (const comp of libraryComps) {
- try {
- await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- name: comp.name_de,
- type: comp.maps_to_component_type,
- description: comp.description_de,
- safety_relevant: false,
- library_component_id: comp.id,
- energy_source_ids: energySourceIds,
- tags: comp.tags,
- }),
- })
- } catch (err) {
- console.error(`Failed to add component ${comp.id}:`, err)
- }
- }
- await fetchComponents()
- }
-
- const tree = buildTree(components)
-
- if (loading) {
+ if (c.loading) {
return (
@@ -740,25 +29,18 @@ export default function ComponentsPage() {
Erfassen Sie alle Software-, Firmware-, KI- und Hardware-Komponenten der Maschine.
- {!showForm && (
+ {!c.showForm && (
-
setShowLibrary(true)}
- className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors text-sm"
- >
+ c.setShowLibrary(true)}
+ className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors text-sm">
Aus Bibliothek waehlen
{
- setShowForm(true)
- setEditingComponent(null)
- setAddingParentId(null)
- }}
- className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
- >
+ onClick={() => { c.setShowForm(true); c.setEditingComponent(null); c.setAddingParentId(null) }}
+ className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
@@ -768,30 +50,19 @@ export default function ComponentsPage() {
)}
- {/* Library Modal */}
- {showLibrary && (
- setShowLibrary(false)}
- />
+ {c.showLibrary && (
+ c.setShowLibrary(false)} />
)}
- {/* Form */}
- {showForm && (
+ {c.showForm && (
{
- setShowForm(false)
- setEditingComponent(null)
- setAddingParentId(null)
- }}
- initialData={editingComponent}
- parentId={addingParentId}
+ onSubmit={c.handleSubmit}
+ onCancel={() => { c.setShowForm(false); c.setEditingComponent(null); c.setAddingParentId(null) }}
+ initialData={c.editingComponent} parentId={c.addingParentId}
/>
)}
- {/* Component Tree */}
- {tree.length > 0 ? (
+ {c.tree.length > 0 ? (
@@ -803,20 +74,14 @@ export default function ComponentsPage() {
- {tree.map((component) => (
-
+ {c.tree.map((component) => (
+
))}
) : (
- !showForm && (
+ !c.showForm && (
- {/* Hierarchy Warning */}
- {hierarchyWarning && (
-
setHierarchyWarning(false)} />
- )}
+ {m.hierarchyWarning && m.setHierarchyWarning(false)} />}
- {/* Form */}
- {showForm && (
+ {m.showForm && (
{
- setShowForm(false)
- setPreselectedType(undefined)
- }}
- hazards={hazards}
- preselectedType={preselectedType}
- onOpenLibrary={handleOpenLibrary}
+ onSubmit={m.handleSubmit}
+ onCancel={() => { m.setShowForm(false); m.setPreselectedType(undefined) }}
+ hazards={m.hazards} preselectedType={m.preselectedType}
+ onOpenLibrary={m.handleOpenLibrary}
/>
)}
- {/* Measures Library Modal */}
- {showLibrary && (
+ {m.showLibrary && (
setShowLibrary(false)}
- filterType={libraryFilter}
+ measures={m.measures} onSelect={m.handleSelectMeasure}
+ onClose={() => m.setShowLibrary(false)} filterType={m.libraryFilter}
/>
)}
- {/* Suggest Measures Modal (Phase 5) */}
- {showSuggest && (
+ {m.showSuggest && (
setShowSuggest(false)}
+ hazards={m.hazards} projectId={projectId}
+ onAddMeasure={m.handleAddSuggestedMeasure}
+ onClose={() => m.setShowSuggest(false)}
/>
)}
@@ -843,7 +89,7 @@ export default function MitigationsPage() {
{(['design', 'protection', 'information'] as const).map((type) => {
const config = REDUCTION_TYPES[type]
- const items = byType[type]
+ const items = m.byType[type]
return (
@@ -854,8 +100,6 @@ export default function MitigationsPage() {
{items.length}
-
- {/* Sub-types overview */}
{config.subTypes.map((st) => (
@@ -863,30 +107,19 @@ export default function MitigationsPage() {
))}
-
- {items.map((m) => (
-
+ {items.map((item) => (
+
))}
-
-
handleAddForType(type)}
- className="flex-1 py-2 text-sm text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
- >
+ m.handleAddForType(type)}
+ className="flex-1 py-2 text-sm text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors">
+ Hinzufuegen
- handleOpenLibrary(type)}
+ m.handleOpenLibrary(type)}
className="py-2 px-3 text-sm text-gray-400 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
- title="Aus Bibliothek waehlen"
- >
+ title="Aus Bibliothek waehlen">