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
}

View File

@@ -0,0 +1,92 @@
'use client'
import { useState, useEffect } from 'react'
import { Component, ComponentFormData, LibraryComponent, EnergySource, buildTree } from '../_components/types'
export function useComponents(projectId: string) {
const [components, setComponents] = useState<Component[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchComponents()
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
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, editingId?: string) {
try {
const url = editingId
? `/api/sdk/v1/iace/projects/${projectId}/components/${editingId}`
: `/api/sdk/v1/iace/projects/${projectId}/components`
const method = editingId ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
await fetchComponents()
return true
}
} catch (err) {
console.error('Failed to save component:', err)
}
return false
}
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)
}
}
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
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()
}
return {
components,
loading,
tree: buildTree(components),
handleSubmit,
handleDelete,
handleAddFromLibrary,
}
}

View File

@@ -1,687 +1,24 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useState } 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<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',
}
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<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>
)
}
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` }}
>
{/* Expand/collapse */}
<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>
)}
{/* Actions */}
<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>
)
}
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<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>
)
}
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
}
// ============================================================================
// Component Library Modal (Phase 5)
// ============================================================================
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 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 categories = Object.keys(LIBRARY_CATEGORIES)
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">
{/* Header */}
<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>
{/* Tabs */}
<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>
{/* Body */}
<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 dark:hover:bg-gray-750'
}`}
>
<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 dark:hover:bg-gray-750'
}`}
>
<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>
{/* Footer */}
<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>
)
}
// ============================================================================
// Main Page
// ============================================================================
import { Component } from './_components/types'
import { ComponentTreeNode } from './_components/ComponentTreeNode'
import { ComponentForm } from './_components/ComponentForm'
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<Component[]>([])
const [loading, setLoading] = useState(true)
const { loading, tree, handleSubmit, handleDelete, handleAddFromLibrary } = useComponents(projectId)
const [showForm, setShowForm] = useState(false)
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
const [addingParentId, setAddingParentId] = useState<string | null>(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)
@@ -694,34 +31,12 @@ export default function ComponentsPage() {
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()
function resetForm() {
setShowForm(false)
setEditingComponent(null)
setAddingParentId(null)
}
const tree = buildTree(components)
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -752,11 +67,7 @@ export default function ComponentsPage() {
Aus Bibliothek waehlen
</button>
<button
onClick={() => {
setShowForm(true)
setEditingComponent(null)
setAddingParentId(null)
}}
onClick={() => { 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"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -768,23 +79,20 @@ export default function ComponentsPage() {
)}
</div>
{/* Library Modal */}
{showLibrary && (
<ComponentLibraryModal
onAdd={handleAddFromLibrary}
onAdd={async (comps, energy) => { setShowLibrary(false); await handleAddFromLibrary(comps, energy) }}
onClose={() => setShowLibrary(false)}
/>
)}
{/* Form */}
{showForm && (
<ComponentForm
onSubmit={handleSubmit}
onCancel={() => {
setShowForm(false)
setEditingComponent(null)
setAddingParentId(null)
onSubmit={async (data) => {
const ok = await handleSubmit(data, editingComponent?.id)
if (ok) resetForm()
}}
onCancel={resetForm}
initialData={editingComponent}
parentId={addingParentId}
/>

View File

@@ -0,0 +1,24 @@
'use client'
export function HierarchyWarning({ onDismiss }: { onDismiss: () => void }) {
return (
<div className="bg-amber-50 border border-amber-300 rounded-xl p-4 flex items-start gap-3">
<svg className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div className="flex-1">
<h4 className="text-sm font-semibold text-amber-800">Hierarchie-Warnung: Massnahmen vom Typ &quot;Information&quot;</h4>
<p className="text-sm text-amber-700 mt-1">
Hinweismassnahmen (Stufe 3) duerfen <strong>nicht als Primaermassnahme</strong> akzeptiert werden, wenn konstruktive
(Stufe 1) oder technische (Stufe 2) Massnahmen moeglich und zumutbar sind. Pruefen Sie, ob hoeherwertige
Massnahmen ergaenzt werden koennen.
</p>
</div>
<button onClick={onDismiss} className="text-amber-400 hover:text-amber-600 transition-colors">
<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>
)
}

View File

@@ -0,0 +1,107 @@
'use client'
import { useState } from 'react'
import { ProtectiveMeasure } from './types'
export function MeasuresLibraryModal({
measures,
onSelect,
onClose,
filterType,
}: {
measures: ProtectiveMeasure[]
onSelect: (measure: ProtectiveMeasure) => void
onClose: () => void
filterType?: string
}) {
const [search, setSearch] = useState('')
const [selectedSubType, setSelectedSubType] = useState('')
const filtered = measures.filter((m) => {
if (filterType && m.reduction_type !== filterType) return false
if (selectedSubType && m.sub_type !== selectedSubType) return false
if (search) {
const q = search.toLowerCase()
return m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q)
}
return true
})
const subTypes = [...new Set(measures.filter((m) => !filterType || m.reduction_type === filterType).map((m) => m.sub_type))].filter(Boolean)
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-3xl max-h-[80vh] 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">Massnahmen-Bibliothek</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors">
<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-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Massnahme 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"
/>
{subTypes.length > 1 && (
<select
value={selectedSubType}
onChange={(e) => setSelectedSubType(e.target.value)}
className="px-3 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 text-sm"
>
<option value="">Alle Sub-Typen</option>
{subTypes.map((st) => (
<option key={st} value={st}>{st}</option>
))}
</select>
)}
</div>
<div className="mt-2 text-xs text-gray-500">{filtered.length} Massnahmen</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-3">
{filtered.map((m) => (
<div
key={m.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50/30 transition-colors cursor-pointer"
onClick={() => onSelect(m)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.id}</span>
{m.sub_type && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
)}
</div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</h4>
<p className="text-xs text-gray-500 mt-1">{m.description}</p>
{m.examples && m.examples.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{m.examples.map((ex, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-600">
{ex}
</span>
))}
</div>
)}
</div>
<button className="ml-3 px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors flex-shrink-0">
Uebernehmen
</button>
</div>
</div>
))}
{filtered.length === 0 && (
<div className="text-center py-8 text-gray-500">Keine Massnahmen gefunden</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
import { Mitigation } from './types'
import { StatusBadge } from './StatusBadge'
export function MitigationCard({
mitigation,
onVerify,
onDelete,
}: {
mitigation: Mitigation
onVerify: (id: string) => void
onDelete: (id: string) => void
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
{mitigation.title.startsWith('Auto:') && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
Auto
</span>
)}
</div>
<StatusBadge status={mitigation.status} />
</div>
{mitigation.description && (
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
)}
{mitigation.linked_hazard_names.length > 0 && (
<div className="mb-3">
<div className="flex flex-wrap gap-1">
{mitigation.linked_hazard_names.map((name, i) => (
<span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{name}
</span>
))}
</div>
</div>
)}
<div className="flex items-center gap-2">
{mitigation.status !== 'verified' && (
<button
onClick={() => onVerify(mitigation.id)}
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
>
Verifizieren
</button>
)}
<button
onClick={() => onDelete(mitigation.id)}
className="text-xs px-2.5 py-1 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
Loeschen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,120 @@
'use client'
import { useState } from 'react'
import { Hazard, MitigationFormData } from './types'
export function MitigationForm({
onSubmit,
onCancel,
hazards,
preselectedType,
onOpenLibrary,
}: {
onSubmit: (data: MitigationFormData) => void
onCancel: () => void
hazards: Hazard[]
preselectedType?: 'design' | 'protection' | 'information'
onOpenLibrary: (type?: string) => void
}) {
const [formData, setFormData] = useState<MitigationFormData>({
title: '',
description: '',
reduction_type: preselectedType || 'design',
linked_hazard_ids: [],
})
function toggleHazard(id: string) {
setFormData((prev) => ({
...prev,
linked_hazard_ids: prev.linked_hazard_ids.includes(id)
? prev.linked_hazard_ids.filter((h) => h !== id)
: [...prev.linked_hazard_ids, id],
}))
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Neue Massnahme</h3>
<button
onClick={() => onOpenLibrary(formData.reduction_type)}
className="text-sm px-3 py-1.5 bg-purple-50 text-purple-700 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
>
Aus Bibliothek waehlen
</button>
</div>
<div className="space-y-4">
<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">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Lichtvorhang an Gefahrenstelle"
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">Reduktionstyp</label>
<select
value={formData.reduction_type}
onChange={(e) => setFormData({ ...formData, reduction_type: e.target.value as MitigationFormData['reduction_type'] })}
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"
>
<option value="design">Stufe 1: Design - Inhaerent sichere Konstruktion</option>
<option value="protection">Stufe 2: Schutz - Technische Schutzmassnahmen</option>
<option value="information">Stufe 3: Information - Hinweise und Schulungen</option>
</select>
</div>
</div>
<div>
<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 })}
rows={2}
placeholder="Detaillierte Beschreibung der Massnahme..."
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>
{hazards.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Verknuepfte Gefaehrdungen</label>
<div className="flex flex-wrap gap-2">
{hazards.map((h) => (
<button
key={h.id}
onClick={() => toggleHazard(h.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
formData.linked_hazard_ids.includes(h.id)
? 'border-purple-400 bg-purple-50 text-purple-700'
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
}`}
>
{h.name}
</button>
))}
</div>
</div>
)}
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => onSubmit(formData)}
disabled={!formData.title}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.title
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
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,19 @@
'use client'
export function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
planned: 'bg-gray-100 text-gray-700',
implemented: 'bg-blue-100 text-blue-700',
verified: 'bg-green-100 text-green-700',
}
const labels: Record<string, string> = {
planned: 'Geplant',
implemented: 'Umgesetzt',
verified: 'Verifiziert',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colors[status] || colors.planned}`}>
{labels[status] || status}
</span>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useState } from 'react'
import { Hazard, SuggestedMeasure, REDUCTION_TYPES } from './types'
export function SuggestMeasuresModal({
hazards,
projectId,
onAddMeasure,
onClose,
}: {
hazards: Hazard[]
projectId: string
onAddMeasure: (title: string, description: string, reductionType: string, hazardId: string) => void
onClose: () => void
}) {
const [selectedHazard, setSelectedHazard] = useState<string>('')
const [suggested, setSuggested] = useState<SuggestedMeasure[]>([])
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
const riskColors: Record<string, string> = {
not_acceptable: 'border-red-400 bg-red-50',
very_high: 'border-red-300 bg-red-50',
critical: 'border-red-300 bg-red-50',
high: 'border-orange-300 bg-orange-50',
medium: 'border-yellow-300 bg-yellow-50',
low: 'border-green-300 bg-green-50',
}
async function handleSelectHazard(hazardId: string) {
setSelectedHazard(hazardId)
setSuggested([])
if (!hazardId) return
setLoadingSuggestions(true)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/suggest-measures`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) {
const json = await res.json()
setSuggested(json.suggested_measures || [])
}
} catch (err) {
console.error('Failed to suggest measures:', err)
} finally {
setLoadingSuggestions(false)
}
}
const groupedByType = {
design: suggested.filter(m => m.reduction_type === 'design'),
protection: suggested.filter(m => m.reduction_type === 'protection'),
information: suggested.filter(m => m.reduction_type === 'information'),
}
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-3xl 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">Massnahmen-Vorschlaege</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>
<p className="text-sm text-gray-500 mb-3">
Waehlen Sie eine Gefaehrdung, um passende Massnahmen vorgeschlagen zu bekommen.
</p>
<div className="flex flex-wrap gap-2">
{hazards.map(h => (
<button
key={h.id}
onClick={() => handleSelectHazard(h.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
selectedHazard === h.id
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
: `${riskColors[h.risk_level] || 'border-gray-200 bg-white'} text-gray-700 hover:border-purple-300`
}`}
>
{h.name}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-auto p-6">
{loadingSuggestions ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : suggested.length > 0 ? (
<div className="space-y-6">
{(['design', 'protection', 'information'] as const).map(type => {
const items = groupedByType[type]
if (items.length === 0) return null
const config = REDUCTION_TYPES[type]
return (
<div key={type}>
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
{config.icon}
<span className="text-sm font-semibold">{config.label}</span>
<span className="ml-auto text-sm font-bold">{items.length}</span>
</div>
<div className="space-y-2">
{items.map(m => (
<div key={m.id} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.id}</span>
{m.sub_type && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
)}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{m.description}</div>
</div>
<button
onClick={() => onAddMeasure(m.name, m.description, m.reduction_type, selectedHazard)}
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
>
Uebernehmen
</button>
</div>
</div>
))}
</div>
</div>
)
})}
</div>
) : selectedHazard ? (
<div className="text-center py-12 text-gray-500">
Keine Vorschlaege fuer diese Gefaehrdung gefunden.
</div>
) : (
<div className="text-center py-12 text-gray-500">
Waehlen Sie eine Gefaehrdung aus, um Vorschlaege zu erhalten.
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,113 @@
import React from 'react'
export interface Mitigation {
id: string
title: string
description: string
reduction_type: 'design' | 'protection' | 'information'
status: 'planned' | 'implemented' | 'verified'
linked_hazard_ids: string[]
linked_hazard_names: string[]
created_at: string
verified_at: string | null
verified_by: string | null
source?: string
}
export interface Hazard {
id: string
name: string
risk_level: string
category?: string
}
export interface ProtectiveMeasure {
id: string
reduction_type: string
sub_type: string
name: string
description: string
hazard_category: string
examples: string[]
}
export interface SuggestedMeasure {
id: string
reduction_type: string
sub_type: string
name: string
description: string
hazard_category: string
examples: string[]
tags?: string[]
}
export interface MitigationFormData {
title: string
description: string
reduction_type: 'design' | 'protection' | 'information'
linked_hazard_ids: string[]
}
export const REDUCTION_TYPES = {
design: {
label: 'Stufe 1: Design',
description: 'Inhaerent sichere Konstruktion',
color: 'border-blue-200 bg-blue-50',
headerColor: 'bg-blue-100 text-blue-800',
subTypes: [
{ value: 'geometry', label: 'Geometrie & Anordnung' },
{ value: 'force_energy', label: 'Kraft & Energie' },
{ value: 'material', label: 'Material & Stabilitaet' },
{ value: 'ergonomics', label: 'Ergonomie' },
{ value: 'control_design', label: 'Steuerungstechnik' },
{ value: 'fluid_design', label: 'Pneumatik / Hydraulik' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
),
},
protection: {
label: 'Stufe 2: Schutz',
description: 'Technische Schutzmassnahmen',
color: 'border-green-200 bg-green-50',
headerColor: 'bg-green-100 text-green-800',
subTypes: [
{ value: 'fixed_guard', label: 'Feststehende Schutzeinrichtung' },
{ value: 'movable_guard', label: 'Bewegliche Schutzeinrichtung' },
{ value: 'electro_sensitive', label: 'Optoelektronisch' },
{ value: 'pressure_sensitive', label: 'Druckempfindlich' },
{ value: 'emergency_stop', label: 'Not-Halt' },
{ value: 'electrical_protection', label: 'Elektrischer Schutz' },
{ value: 'thermal_protection', label: 'Thermischer Schutz' },
{ value: 'fluid_protection', label: 'Hydraulik/Pneumatik-Schutz' },
{ value: 'extraction', label: 'Absaugung / Kapselung' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
},
information: {
label: 'Stufe 3: Information',
description: 'Hinweise und Schulungen',
color: 'border-yellow-200 bg-yellow-50',
headerColor: 'bg-yellow-100 text-yellow-800',
subTypes: [
{ value: 'signage', label: 'Beschilderung & Kennzeichnung' },
{ value: 'manual', label: 'Betriebsanleitung' },
{ value: 'training', label: 'Schulung & Unterweisung' },
{ value: 'ppe', label: 'PSA (Schutzausruestung)' },
{ value: 'organizational', label: 'Organisatorisch' },
{ value: 'marking', label: 'Markierung & Codierung' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
}

View File

@@ -0,0 +1,140 @@
'use client'
import { useState, useEffect } from 'react'
import { Mitigation, Hazard, ProtectiveMeasure, MitigationFormData } from '../_components/types'
export function useMitigations(projectId: string) {
const [mitigations, setMitigations] = useState<Mitigation[]>([])
const [hazards, setHazards] = useState<Hazard[]>([])
const [loading, setLoading] = useState(true)
const [hierarchyWarning, setHierarchyWarning] = useState<boolean>(false)
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
useEffect(() => {
fetchData()
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
async function fetchData() {
try {
const [mitRes, hazRes] = await Promise.all([
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
])
if (mitRes.ok) {
const json = await mitRes.json()
const mits = json.mitigations || json || []
setMitigations(mits)
validateHierarchy(mits)
}
if (hazRes.ok) {
const json = await hazRes.json()
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category })))
}
} catch (err) {
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
}
async function validateHierarchy(mits: Mitigation[]) {
if (mits.length === 0) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/validate-mitigation-hierarchy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mitigations: mits.map((m) => ({
reduction_type: m.reduction_type,
linked_hazard_ids: m.linked_hazard_ids,
})),
}),
})
if (res.ok) {
const json = await res.json()
setHierarchyWarning(json.has_warning === true)
}
} catch {
// Non-critical, ignore
}
}
async function fetchMeasuresLibrary(type?: string) {
try {
const url = type
? `/api/sdk/v1/iace/protective-measures-library?reduction_type=${type}`
: '/api/sdk/v1/iace/protective-measures-library'
const res = await fetch(url)
if (res.ok) {
const json = await res.json()
setMeasures(json.protective_measures || [])
}
} catch (err) {
console.error('Failed to fetch measures library:', err)
}
}
async function handleSubmit(data: MitigationFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
await fetchData()
return true
}
} catch (err) {
console.error('Failed to add mitigation:', err)
}
return false
}
async function handleAddSuggestedMeasure(title: string, description: string, reductionType: string, hazardId: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description, reduction_type: reductionType, linked_hazard_ids: [hazardId] }),
})
if (res.ok) await fetchData()
} catch (err) {
console.error('Failed to add suggested measure:', err)
}
}
async function handleVerify(id: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) await fetchData()
} catch (err) {
console.error('Failed to verify mitigation:', err)
}
}
async function handleDelete(id: string) {
if (!confirm('Massnahme wirklich loeschen?')) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}`, { method: 'DELETE' })
if (res.ok) await fetchData()
} catch (err) {
console.error('Failed to delete mitigation:', err)
}
}
const byType = {
design: mitigations.filter((m) => m.reduction_type === 'design'),
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
information: mitigations.filter((m) => m.reduction_type === 'information'),
}
return {
mitigations, hazards, loading, hierarchyWarning, setHierarchyWarning,
measures, byType,
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
}
}

View File

@@ -1,665 +1,32 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useState } from 'react'
import { useParams } from 'next/navigation'
interface Mitigation {
id: string
title: string
description: string
reduction_type: 'design' | 'protection' | 'information'
status: 'planned' | 'implemented' | 'verified'
linked_hazard_ids: string[]
linked_hazard_names: string[]
created_at: string
verified_at: string | null
verified_by: string | null
source?: string
}
interface Hazard {
id: string
name: string
risk_level: string
category?: string
}
interface ProtectiveMeasure {
id: string
reduction_type: string
sub_type: string
name: string
description: string
hazard_category: string
examples: string[]
}
interface SuggestedMeasure {
id: string
reduction_type: string
sub_type: string
name: string
description: string
hazard_category: string
examples: string[]
tags?: string[]
}
const REDUCTION_TYPES = {
design: {
label: 'Stufe 1: Design',
description: 'Inhaerent sichere Konstruktion',
color: 'border-blue-200 bg-blue-50',
headerColor: 'bg-blue-100 text-blue-800',
subTypes: [
{ value: 'geometry', label: 'Geometrie & Anordnung' },
{ value: 'force_energy', label: 'Kraft & Energie' },
{ value: 'material', label: 'Material & Stabilitaet' },
{ value: 'ergonomics', label: 'Ergonomie' },
{ value: 'control_design', label: 'Steuerungstechnik' },
{ value: 'fluid_design', label: 'Pneumatik / Hydraulik' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
),
},
protection: {
label: 'Stufe 2: Schutz',
description: 'Technische Schutzmassnahmen',
color: 'border-green-200 bg-green-50',
headerColor: 'bg-green-100 text-green-800',
subTypes: [
{ value: 'fixed_guard', label: 'Feststehende Schutzeinrichtung' },
{ value: 'movable_guard', label: 'Bewegliche Schutzeinrichtung' },
{ value: 'electro_sensitive', label: 'Optoelektronisch' },
{ value: 'pressure_sensitive', label: 'Druckempfindlich' },
{ value: 'emergency_stop', label: 'Not-Halt' },
{ value: 'electrical_protection', label: 'Elektrischer Schutz' },
{ value: 'thermal_protection', label: 'Thermischer Schutz' },
{ value: 'fluid_protection', label: 'Hydraulik/Pneumatik-Schutz' },
{ value: 'extraction', label: 'Absaugung / Kapselung' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
},
information: {
label: 'Stufe 3: Information',
description: 'Hinweise und Schulungen',
color: 'border-yellow-200 bg-yellow-50',
headerColor: 'bg-yellow-100 text-yellow-800',
subTypes: [
{ value: 'signage', label: 'Beschilderung & Kennzeichnung' },
{ value: 'manual', label: 'Betriebsanleitung' },
{ value: 'training', label: 'Schulung & Unterweisung' },
{ value: 'ppe', label: 'PSA (Schutzausruestung)' },
{ value: 'organizational', label: 'Organisatorisch' },
{ value: 'marking', label: 'Markierung & Codierung' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
planned: 'bg-gray-100 text-gray-700',
implemented: 'bg-blue-100 text-blue-700',
verified: 'bg-green-100 text-green-700',
}
const labels: Record<string, string> = {
planned: 'Geplant',
implemented: 'Umgesetzt',
verified: 'Verifiziert',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colors[status] || colors.planned}`}>
{labels[status] || status}
</span>
)
}
function HierarchyWarning({ onDismiss }: { onDismiss: () => void }) {
return (
<div className="bg-amber-50 border border-amber-300 rounded-xl p-4 flex items-start gap-3">
<svg className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div className="flex-1">
<h4 className="text-sm font-semibold text-amber-800">Hierarchie-Warnung: Massnahmen vom Typ &quot;Information&quot;</h4>
<p className="text-sm text-amber-700 mt-1">
Hinweismassnahmen (Stufe 3) duerfen <strong>nicht als Primaermassnahme</strong> akzeptiert werden, wenn konstruktive
(Stufe 1) oder technische (Stufe 2) Massnahmen moeglich und zumutbar sind. Pruefen Sie, ob hoeherwertige
Massnahmen ergaenzt werden koennen.
</p>
</div>
<button onClick={onDismiss} className="text-amber-400 hover:text-amber-600 transition-colors">
<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>
)
}
function MeasuresLibraryModal({
measures,
onSelect,
onClose,
filterType,
}: {
measures: ProtectiveMeasure[]
onSelect: (measure: ProtectiveMeasure) => void
onClose: () => void
filterType?: string
}) {
const [search, setSearch] = useState('')
const [selectedSubType, setSelectedSubType] = useState('')
const filtered = measures.filter((m) => {
if (filterType && m.reduction_type !== filterType) return false
if (selectedSubType && m.sub_type !== selectedSubType) return false
if (search) {
const q = search.toLowerCase()
return m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q)
}
return true
})
const subTypes = [...new Set(measures.filter((m) => !filterType || m.reduction_type === filterType).map((m) => m.sub_type))].filter(Boolean)
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-3xl max-h-[80vh] 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">Massnahmen-Bibliothek</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors">
<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-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Massnahme 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"
/>
{subTypes.length > 1 && (
<select
value={selectedSubType}
onChange={(e) => setSelectedSubType(e.target.value)}
className="px-3 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 text-sm"
>
<option value="">Alle Sub-Typen</option>
{subTypes.map((st) => (
<option key={st} value={st}>{st}</option>
))}
</select>
)}
</div>
<div className="mt-2 text-xs text-gray-500">{filtered.length} Massnahmen</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-3">
{filtered.map((m) => (
<div
key={m.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50/30 transition-colors cursor-pointer"
onClick={() => onSelect(m)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.id}</span>
{m.sub_type && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
)}
</div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</h4>
<p className="text-xs text-gray-500 mt-1">{m.description}</p>
{m.examples && m.examples.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{m.examples.map((ex, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-600">
{ex}
</span>
))}
</div>
)}
</div>
<button className="ml-3 px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors flex-shrink-0">
Uebernehmen
</button>
</div>
</div>
))}
{filtered.length === 0 && (
<div className="text-center py-8 text-gray-500">Keine Massnahmen gefunden</div>
)}
</div>
</div>
</div>
)
}
// ============================================================================
// Suggest Measures Modal (Phase 5)
// ============================================================================
function SuggestMeasuresModal({
hazards,
projectId,
onAddMeasure,
onClose,
}: {
hazards: Hazard[]
projectId: string
onAddMeasure: (title: string, description: string, reductionType: string, hazardId: string) => void
onClose: () => void
}) {
const [selectedHazard, setSelectedHazard] = useState<string>('')
const [suggested, setSuggested] = useState<SuggestedMeasure[]>([])
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
const riskColors: Record<string, string> = {
not_acceptable: 'border-red-400 bg-red-50',
very_high: 'border-red-300 bg-red-50',
critical: 'border-red-300 bg-red-50',
high: 'border-orange-300 bg-orange-50',
medium: 'border-yellow-300 bg-yellow-50',
low: 'border-green-300 bg-green-50',
}
async function handleSelectHazard(hazardId: string) {
setSelectedHazard(hazardId)
setSuggested([])
if (!hazardId) return
setLoadingSuggestions(true)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/suggest-measures`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) {
const json = await res.json()
setSuggested(json.suggested_measures || [])
}
} catch (err) {
console.error('Failed to suggest measures:', err)
} finally {
setLoadingSuggestions(false)
}
}
const groupedByType = {
design: suggested.filter(m => m.reduction_type === 'design'),
protection: suggested.filter(m => m.reduction_type === 'protection'),
information: suggested.filter(m => m.reduction_type === 'information'),
}
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-3xl 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">Massnahmen-Vorschlaege</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>
<p className="text-sm text-gray-500 mb-3">
Waehlen Sie eine Gefaehrdung, um passende Massnahmen vorgeschlagen zu bekommen.
</p>
<div className="flex flex-wrap gap-2">
{hazards.map(h => (
<button
key={h.id}
onClick={() => handleSelectHazard(h.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
selectedHazard === h.id
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
: `${riskColors[h.risk_level] || 'border-gray-200 bg-white'} text-gray-700 hover:border-purple-300`
}`}
>
{h.name}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-auto p-6">
{loadingSuggestions ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : suggested.length > 0 ? (
<div className="space-y-6">
{(['design', 'protection', 'information'] as const).map(type => {
const items = groupedByType[type]
if (items.length === 0) return null
const config = REDUCTION_TYPES[type]
return (
<div key={type}>
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
{config.icon}
<span className="text-sm font-semibold">{config.label}</span>
<span className="ml-auto text-sm font-bold">{items.length}</span>
</div>
<div className="space-y-2">
{items.map(m => (
<div key={m.id} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.id}</span>
{m.sub_type && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
)}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{m.description}</div>
</div>
<button
onClick={() => onAddMeasure(m.name, m.description, m.reduction_type, selectedHazard)}
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
>
Uebernehmen
</button>
</div>
</div>
))}
</div>
</div>
)
})}
</div>
) : selectedHazard ? (
<div className="text-center py-12 text-gray-500">
Keine Vorschlaege fuer diese Gefaehrdung gefunden.
</div>
) : (
<div className="text-center py-12 text-gray-500">
Waehlen Sie eine Gefaehrdung aus, um Vorschlaege zu erhalten.
</div>
)}
</div>
</div>
</div>
)
}
interface MitigationFormData {
title: string
description: string
reduction_type: 'design' | 'protection' | 'information'
linked_hazard_ids: string[]
}
function MitigationForm({
onSubmit,
onCancel,
hazards,
preselectedType,
onOpenLibrary,
}: {
onSubmit: (data: MitigationFormData) => void
onCancel: () => void
hazards: Hazard[]
preselectedType?: 'design' | 'protection' | 'information'
onOpenLibrary: (type?: string) => void
}) {
const [formData, setFormData] = useState<MitigationFormData>({
title: '',
description: '',
reduction_type: preselectedType || 'design',
linked_hazard_ids: [],
})
function toggleHazard(id: string) {
setFormData((prev) => ({
...prev,
linked_hazard_ids: prev.linked_hazard_ids.includes(id)
? prev.linked_hazard_ids.filter((h) => h !== id)
: [...prev.linked_hazard_ids, id],
}))
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Neue Massnahme</h3>
<button
onClick={() => onOpenLibrary(formData.reduction_type)}
className="text-sm px-3 py-1.5 bg-purple-50 text-purple-700 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
>
Aus Bibliothek waehlen
</button>
</div>
<div className="space-y-4">
<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">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Lichtvorhang an Gefahrenstelle"
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">Reduktionstyp</label>
<select
value={formData.reduction_type}
onChange={(e) => setFormData({ ...formData, reduction_type: e.target.value as MitigationFormData['reduction_type'] })}
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"
>
<option value="design">Stufe 1: Design - Inhaerent sichere Konstruktion</option>
<option value="protection">Stufe 2: Schutz - Technische Schutzmassnahmen</option>
<option value="information">Stufe 3: Information - Hinweise und Schulungen</option>
</select>
</div>
</div>
<div>
<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 })}
rows={2}
placeholder="Detaillierte Beschreibung der Massnahme..."
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>
{hazards.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Verknuepfte Gefaehrdungen</label>
<div className="flex flex-wrap gap-2">
{hazards.map((h) => (
<button
key={h.id}
onClick={() => toggleHazard(h.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
formData.linked_hazard_ids.includes(h.id)
? 'border-purple-400 bg-purple-50 text-purple-700'
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
}`}
>
{h.name}
</button>
))}
</div>
</div>
)}
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => onSubmit(formData)}
disabled={!formData.title}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.title
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
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>
)
}
function MitigationCard({
mitigation,
onVerify,
onDelete,
}: {
mitigation: Mitigation
onVerify: (id: string) => void
onDelete: (id: string) => void
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
{mitigation.title.startsWith('Auto:') && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
Auto
</span>
)}
</div>
<StatusBadge status={mitigation.status} />
</div>
{mitigation.description && (
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
)}
{mitigation.linked_hazard_names.length > 0 && (
<div className="mb-3">
<div className="flex flex-wrap gap-1">
{mitigation.linked_hazard_names.map((name, i) => (
<span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{name}
</span>
))}
</div>
</div>
)}
<div className="flex items-center gap-2">
{mitigation.status !== 'verified' && (
<button
onClick={() => onVerify(mitigation.id)}
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
>
Verifizieren
</button>
)}
<button
onClick={() => onDelete(mitigation.id)}
className="text-xs px-2.5 py-1 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
Loeschen
</button>
</div>
</div>
)
}
import { REDUCTION_TYPES } from './_components/types'
import { HierarchyWarning } from './_components/HierarchyWarning'
import { MeasuresLibraryModal } from './_components/MeasuresLibraryModal'
import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
import { MitigationForm } from './_components/MitigationForm'
import { MitigationCard } from './_components/MitigationCard'
import { ProtectiveMeasure } from './_components/types'
import { useMitigations } from './_hooks/useMitigations'
export default function MitigationsPage() {
const params = useParams()
const projectId = params.projectId as string
const [mitigations, setMitigations] = useState<Mitigation[]>([])
const [hazards, setHazards] = useState<Hazard[]>([])
const [loading, setLoading] = useState(true)
const {
hazards, loading, hierarchyWarning, setHierarchyWarning,
measures, byType,
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
} = useMitigations(projectId)
const [showForm, setShowForm] = useState(false)
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
const [hierarchyWarning, setHierarchyWarning] = useState<boolean>(false)
const [showLibrary, setShowLibrary] = useState(false)
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
// Phase 5: Suggest measures
const [showSuggest, setShowSuggest] = useState(false)
useEffect(() => {
fetchData()
}, [projectId])
async function fetchData() {
try {
const [mitRes, hazRes] = await Promise.all([
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
])
if (mitRes.ok) {
const json = await mitRes.json()
const mits = json.mitigations || json || []
setMitigations(mits)
// Check hierarchy: if information-only measures exist without design/protection
validateHierarchy(mits)
}
if (hazRes.ok) {
const json = await hazRes.json()
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category })))
}
} catch (err) {
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
}
async function validateHierarchy(mits: Mitigation[]) {
if (mits.length === 0) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/validate-mitigation-hierarchy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mitigations: mits.map((m) => ({
reduction_type: m.reduction_type,
linked_hazard_ids: m.linked_hazard_ids,
})),
}),
})
if (res.ok) {
const json = await res.json()
setHierarchyWarning(json.has_warning === true)
}
} catch {
// Non-critical, ignore
}
}
async function fetchMeasuresLibrary(type?: string) {
try {
const url = type
? `/api/sdk/v1/iace/protective-measures-library?reduction_type=${type}`
: '/api/sdk/v1/iace/protective-measures-library'
const res = await fetch(url)
if (res.ok) {
const json = await res.json()
setMeasures(json.protective_measures || [])
}
} catch (err) {
console.error('Failed to fetch measures library:', err)
}
}
function handleOpenLibrary(type?: string) {
setLibraryFilter(type)
fetchMeasuresLibrary(type)
@@ -672,80 +39,11 @@ export default function MitigationsPage() {
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
}
async function handleSubmit(data: MitigationFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
setShowForm(false)
setPreselectedType(undefined)
await fetchData()
}
} catch (err) {
console.error('Failed to add mitigation:', err)
}
}
async function handleAddSuggestedMeasure(title: string, description: string, reductionType: string, hazardId: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description,
reduction_type: reductionType,
linked_hazard_ids: [hazardId],
}),
})
if (res.ok) {
await fetchData()
}
} catch (err) {
console.error('Failed to add suggested measure:', err)
}
}
async function handleVerify(id: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) {
await fetchData()
}
} catch (err) {
console.error('Failed to verify mitigation:', err)
}
}
async function handleDelete(id: string) {
if (!confirm('Massnahme wirklich loeschen?')) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}`, { method: 'DELETE' })
if (res.ok) {
await fetchData()
}
} catch (err) {
console.error('Failed to delete mitigation:', err)
}
}
function handleAddForType(type: 'design' | 'protection' | 'information') {
setPreselectedType(type)
setShowForm(true)
}
const byType = {
design: mitigations.filter((m) => m.reduction_type === 'design'),
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
information: mitigations.filter((m) => m.reduction_type === 'information'),
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -786,10 +84,7 @@ export default function MitigationsPage() {
Bibliothek
</button>
<button
onClick={() => {
setPreselectedType(undefined)
setShowForm(true)
}}
onClick={() => { setPreselectedType(undefined); setShowForm(true) }}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -800,26 +95,21 @@ export default function MitigationsPage() {
</div>
</div>
{/* Hierarchy Warning */}
{hierarchyWarning && (
<HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />
)}
{hierarchyWarning && <HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />}
{/* Form */}
{showForm && (
<MitigationForm
onSubmit={handleSubmit}
onCancel={() => {
setShowForm(false)
setPreselectedType(undefined)
onSubmit={async (data) => {
const ok = await handleSubmit(data)
if (ok) { setShowForm(false); setPreselectedType(undefined) }
}}
onCancel={() => { setShowForm(false); setPreselectedType(undefined) }}
hazards={hazards}
preselectedType={preselectedType}
onOpenLibrary={handleOpenLibrary}
/>
)}
{/* Measures Library Modal */}
{showLibrary && (
<MeasuresLibraryModal
measures={measures}
@@ -829,7 +119,6 @@ export default function MitigationsPage() {
/>
)}
{/* Suggest Measures Modal (Phase 5) */}
{showSuggest && (
<SuggestMeasuresModal
hazards={hazards}
@@ -854,8 +143,6 @@ export default function MitigationsPage() {
</div>
<span className="ml-auto text-sm font-bold">{items.length}</span>
</div>
{/* Sub-types overview */}
<div className="mb-3 flex flex-wrap gap-1">
{config.subTypes.map((st) => (
<span key={st.value} className="text-xs px-1.5 py-0.5 rounded bg-white/60 text-gray-500 border border-gray-200/50">
@@ -863,18 +150,11 @@ export default function MitigationsPage() {
</span>
))}
</div>
<div className="space-y-3">
{items.map((m) => (
<MitigationCard
key={m.id}
mitigation={m}
onVerify={handleVerify}
onDelete={handleDelete}
/>
<MitigationCard key={m.id} mitigation={m} onVerify={handleVerify} onDelete={handleDelete} />
))}
</div>
<div className="mt-3 flex gap-2">
<button
onClick={() => handleAddForType(type)}