merge: phases 1–5 refactor, CI hardening, docs (coolify → main)
Some checks failed
Build + Deploy / build-admin-compliance (push) Failing after 47s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-ai-sdk (push) Successful in 34s
Build + Deploy / build-developer-portal (push) Successful in 56s
Build + Deploy / build-tts (push) Successful in 26s
Build + Deploy / build-document-crawler (push) Successful in 15s
Build + Deploy / build-dsms-gateway (push) Successful in 13s
Build + Deploy / trigger-orca (push) Has been skipped
CI/CD / loc-budget (push) Successful in 22s
CI/CD / guardrail-integrity (push) Has been skipped
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been cancelled
CI/CD / test-go-ai-compliance (push) Has been cancelled
CI/CD / test-python-backend-compliance (push) Has been cancelled
CI/CD / test-python-document-crawler (push) Has been cancelled
CI/CD / test-python-dsms-gateway (push) Successful in 28s
CI/CD / sbom-scan (push) Has been cancelled
CI/CD / validate-canonical-controls (push) Successful in 20s

Phase 1: backend-compliance — partial service-layer extraction
Phase 2: ai-compliance-sdk — full hexagonal split; iace/ucca/training handlers
  and stores split into focused files; cmd/server/main.go → internal/app/
Phase 3: admin-compliance — types.ts, tom-generator loader, and major page
  components split; lib document generators extracted
Phase 4: dsms-gateway, consent-sdk, developer-portal, breakpilot-compliance-sdk
Phase 5 CI hardening:
  - loc-budget job now scans whole repo (blocking, no || true)
  - sbom-scan / grype blocking on high+ CVEs
  - ai-compliance-sdk/.golangci.yml: strict golangci-lint config
  - check-loc.sh: skip test_*.py and *.html; loc-exceptions.txt expanded
  - deleted stray routes.py.backup (2512 LOC)
Docs:
  - root README.md with CI badge, service table, quick start, CI pipeline table
  - CONTRIBUTING.md: setup, pre-commit checklist, guardrail marker reference
  - CLAUDE.md: First-Time Setup & Claude Code Onboarding section
  - all 7 service READMEs updated (stale phase refs, current architecture)
  - AGENTS.go/python/typescript.md enhanced with linting, DI, barrel re-export
  - .gitignore: dist/, .turbo/, pnpm-lock.yaml added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-19 16:11:53 +02:00
1258 changed files with 210195 additions and 145532 deletions

View File

@@ -4,7 +4,10 @@ import { useState } from 'react'
import { Component, ComponentFormData, COMPONENT_TYPES } from './types'
export function ComponentForm({
onSubmit, onCancel, initialData, parentId,
onSubmit,
onCancel,
initialData,
parentId,
}: {
onSubmit: (data: ComponentFormData) => void
onCancel: () => void
@@ -28,47 +31,69 @@ export function ComponentForm({
<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}
<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" />
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
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}
<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" />
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}
<input
type="checkbox"
checked={formData.safety_relevant}
onChange={(e) => setFormData({ ...formData, safety_relevant: e.target.checked })}
className="sr-only peer" />
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}
<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" />
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}
<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'
}`}>
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">

View File

@@ -5,7 +5,8 @@ import { LibraryComponent, EnergySource, LIBRARY_CATEGORIES } from './types'
import { ComponentTypeIcon } from './ComponentTypeIcon'
export function ComponentLibraryModal({
onAdd, onClose,
onAdd,
onClose,
}: {
onAdd: (components: LibraryComponent[], energySources: EnergySource[]) => void
onClose: () => void
@@ -26,8 +27,14 @@ export function ComponentLibraryModal({
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 || []) }
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 {
@@ -38,23 +45,41 @@ export function ComponentLibraryModal({
}, [])
function toggleComponent(id: string) {
setSelectedComponents(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next })
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 })
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 })
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) {
@@ -70,7 +95,6 @@ export function ComponentLibraryModal({
return acc
}, {})
const categories = Object.keys(LIBRARY_CATEGORIES)
const totalSelected = selectedComponents.size + selectedEnergySources.size
if (loading) {
@@ -122,35 +146,37 @@ export function ComponentLibraryModal({
<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} />
{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>
<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>
))}
</label>
))}
</div>
</div>
</div>
))}
))}
{filtered.length === 0 && <div className="text-center py-8 text-gray-500">Keine Komponenten gefunden</div>}
</div>
) : (
@@ -158,7 +184,7 @@ export function ComponentLibraryModal({
{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'
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">
@@ -173,7 +199,9 @@ export function ComponentLibraryModal({
</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>
<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}

View File

@@ -5,7 +5,11 @@ import { Component } from './types'
import { ComponentTypeIcon } from './ComponentTypeIcon'
export function ComponentTreeNode({
component, depth, onEdit, onDelete, onAddChild,
component,
depth,
onEdit,
onDelete,
onAddChild,
}: {
component: Component
depth: number
@@ -22,9 +26,14 @@ export function ComponentTreeNode({
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">
<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>
@@ -47,7 +56,9 @@ export function ComponentTreeNode({
</div>
{component.description && (
<span className="text-xs text-gray-400 truncate max-w-[200px] hidden lg:block">{component.description}</span>
<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">
@@ -75,8 +86,14 @@ export function ComponentTreeNode({
{expanded && hasChildren && (
<div>
{component.children.map((child) => (
<ComponentTreeNode key={child.id} component={child} depth={depth + 1}
onEdit={onEdit} onDelete={onDelete} onAddChild={onAddChild} />
<ComponentTreeNode
key={child.id}
component={child}
depth={depth + 1}
onEdit={onEdit}
onDelete={onDelete}
onAddChild={onAddChild}
/>
))}
</div>
)}

View File

@@ -1,3 +1,5 @@
'use client'
export function ComponentTypeIcon({ type }: { type: string }) {
const colors: Record<string, string> = {
SW: 'bg-blue-100 text-blue-700',

View File

@@ -75,7 +75,11 @@ export const COMPONENT_TYPES = [
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) => {
map.set(c.id, { ...c, children: [] })
})
components.forEach((c) => {
const node = map.get(c.id)!
if (c.parent_id && map.has(c.parent_id)) {
@@ -84,5 +88,6 @@ export function buildTree(components: Component[]): Component[] {
roots.push(node)
}
})
return roots
}

View File

@@ -1,22 +1,23 @@
'use client'
import { useState, useEffect } from 'react'
import { Component, LibraryComponent, EnergySource, ComponentFormData, buildTree } from '../_components/types'
import { Component, ComponentFormData, LibraryComponent, EnergySource, buildTree } from '../_components/types'
export function useComponents(projectId: string) {
const [components, setComponents] = useState<Component[]>([])
const [loading, setLoading] = useState(true)
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])
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 || []) }
if (res.ok) {
const json = await res.json()
setComponents(json.components || json || [])
}
} catch (err) {
console.error('Failed to fetch components:', err)
} finally {
@@ -24,15 +25,26 @@ export function useComponents(projectId: string) {
}
}
async function handleSubmit(data: ComponentFormData) {
async function handleSubmit(data: ComponentFormData, editingId?: string) {
try {
const url = editingComponent
? `/api/sdk/v1/iace/projects/${projectId}/components/${editingComponent.id}`
const url = editingId
? `/api/sdk/v1/iace/projects/${projectId}/components/${editingId}`
: `/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) }
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) {
@@ -40,41 +52,41 @@ export function useComponents(projectId: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, { method: 'DELETE' })
if (res.ok) await fetchComponents()
} catch (err) { console.error('Failed to delete component:', err) }
}
function handleEdit(component: Component) {
setEditingComponent(component); setAddingParentId(null); setShowForm(true)
}
function handleAddChild(parentId: string) {
setAddingParentId(parentId); setEditingComponent(null); setShowForm(true)
} catch (err) {
console.error('Failed to delete component:', err)
}
}
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' },
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,
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) }
} catch (err) {
console.error(`Failed to add component ${comp.id}:`, err)
}
}
await fetchComponents()
}
const tree = buildTree(components)
return {
components, loading, tree,
showForm, setShowForm, editingComponent, setEditingComponent,
addingParentId, setAddingParentId, showLibrary, setShowLibrary,
handleSubmit, handleDelete, handleEdit, handleAddChild, handleAddFromLibrary,
components,
loading,
tree: buildTree(components),
handleSubmit,
handleDelete,
handleAddFromLibrary,
}
}

View File

@@ -1,17 +1,43 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import { ComponentForm } from './_components/ComponentForm'
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 c = useComponents(projectId)
if (c.loading) {
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)
function handleEdit(component: Component) {
setEditingComponent(component)
setAddingParentId(null)
setShowForm(true)
}
function handleAddChild(parentId: string) {
setAddingParentId(parentId)
setEditingComponent(null)
setShowForm(true)
}
function resetForm() {
setShowForm(false)
setEditingComponent(null)
setAddingParentId(null)
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
@@ -39,8 +65,9 @@ export default function ComponentsPage() {
Aus Bibliothek waehlen
</button>
<button
onClick={() => { c.setShowForm(true); c.setEditingComponent(null); c.setAddingParentId(null) }}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
@@ -50,15 +77,22 @@ export default function ComponentsPage() {
)}
</div>
{c.showLibrary && (
<ComponentLibraryModal onAdd={c.handleAddFromLibrary} onClose={() => c.setShowLibrary(false)} />
{showLibrary && (
<ComponentLibraryModal
onAdd={async (comps, energy) => { setShowLibrary(false); await handleAddFromLibrary(comps, energy) }}
onClose={() => setShowLibrary(false)}
/>
)}
{c.showForm && (
{showForm && (
<ComponentForm
onSubmit={c.handleSubmit}
onCancel={() => { c.setShowForm(false); c.setEditingComponent(null); c.setAddingParentId(null) }}
initialData={c.editingComponent} parentId={c.addingParentId}
onSubmit={async (data) => {
const ok = await handleSubmit(data, editingComponent?.id)
if (ok) resetForm()
}}
onCancel={resetForm}
initialData={editingComponent}
parentId={addingParentId}
/>
)}

View File

@@ -0,0 +1,153 @@
'use client'
import { useState } from 'react'
import { MatchOutput, CATEGORY_LABELS } from './types'
export function AutoSuggestPanel({ matchResult, applying, onApply, onClose }: {
projectId: string
matchResult: MatchOutput
applying: boolean
onApply: (acceptedHazardCats: string[], acceptedMeasureIds: string[], acceptedEvidenceIds: string[], patternIds: string[]) => void
onClose: () => void
}) {
const [selectedHazards, setSelectedHazards] = useState<Set<string>>(
new Set(matchResult.suggested_hazards.map(h => h.category))
)
const [selectedMeasures, setSelectedMeasures] = useState<Set<string>>(
new Set(matchResult.suggested_measures.map(m => m.measure_id))
)
const [selectedEvidence, setSelectedEvidence] = useState<Set<string>>(
new Set(matchResult.suggested_evidence.map(e => e.evidence_id))
)
function toggle<T>(set: Set<T>, setSet: (s: Set<T>) => void, key: T) {
const next = new Set(set)
if (next.has(key)) next.delete(key)
else next.add(key)
setSet(next)
}
const totalSelected = selectedHazards.size + selectedMeasures.size + selectedEvidence.size
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border-2 border-purple-300 p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Pattern-Matching Ergebnisse</h3>
<p className="text-sm text-gray-500">
{matchResult.matched_patterns.length} Patterns erkannt, {matchResult.resolved_tags.length} Tags aufgeloest
</p>
</div>
<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>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Erkannte Patterns ({matchResult.matched_patterns.length})
</h4>
<div className="flex flex-wrap gap-2">
{matchResult.matched_patterns.sort((a, b) => b.priority - a.priority).map(p => (
<span key={p.pattern_id} className="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs bg-purple-50 text-purple-700 border border-purple-200">
<span className="font-mono">{p.pattern_id}</span>
<span>{p.pattern_name}</span>
<span className="text-purple-400">P:{p.priority}</span>
</span>
))}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="border border-orange-200 rounded-lg p-3 bg-orange-50/50">
<h4 className="text-sm font-semibold text-orange-800 mb-2">Gefaehrdungen ({matchResult.suggested_hazards.length})</h4>
<div className="space-y-2 max-h-48 overflow-auto">
{matchResult.suggested_hazards.map(h => (
<label key={h.category} className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" checked={selectedHazards.has(h.category)}
onChange={() => toggle(selectedHazards, setSelectedHazards, h.category)}
className="mt-0.5 accent-purple-600" />
<div>
<div className="text-xs font-medium text-gray-900">{CATEGORY_LABELS[h.category] || h.category}</div>
<div className="text-xs text-gray-500">Konfidenz: {Math.round(h.confidence * 100)}%</div>
</div>
</label>
))}
</div>
</div>
<div className="border border-green-200 rounded-lg p-3 bg-green-50/50">
<h4 className="text-sm font-semibold text-green-800 mb-2">Massnahmen ({matchResult.suggested_measures.length})</h4>
<div className="space-y-2 max-h-48 overflow-auto">
{matchResult.suggested_measures.map(m => (
<label key={m.measure_id} className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" checked={selectedMeasures.has(m.measure_id)}
onChange={() => toggle(selectedMeasures, setSelectedMeasures, m.measure_id)}
className="mt-0.5 accent-purple-600" />
<div>
<div className="text-xs font-medium text-gray-900 font-mono">{m.measure_id}</div>
<div className="text-xs text-gray-500">von {m.source_patterns.length} Pattern(s)</div>
</div>
</label>
))}
</div>
</div>
<div className="border border-blue-200 rounded-lg p-3 bg-blue-50/50">
<h4 className="text-sm font-semibold text-blue-800 mb-2">Nachweise ({matchResult.suggested_evidence.length})</h4>
<div className="space-y-2 max-h-48 overflow-auto">
{matchResult.suggested_evidence.map(e => (
<label key={e.evidence_id} className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" checked={selectedEvidence.has(e.evidence_id)}
onChange={() => toggle(selectedEvidence, setSelectedEvidence, e.evidence_id)}
className="mt-0.5 accent-purple-600" />
<div>
<div className="text-xs font-medium text-gray-900 font-mono">{e.evidence_id}</div>
<div className="text-xs text-gray-500">von {e.source_patterns.length} Pattern(s)</div>
</div>
</label>
))}
</div>
</div>
</div>
{matchResult.resolved_tags.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-500 mb-1">Aufgeloeste Tags</h4>
<div className="flex flex-wrap gap-1">
{matchResult.resolved_tags.map(tag => (
<span key={tag} className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">{tag}</span>
))}
</div>
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-gray-200">
<span className="text-sm text-gray-500">{totalSelected} Elemente 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={() => onApply(
Array.from(selectedHazards),
Array.from(selectedMeasures),
Array.from(selectedEvidence),
matchResult.matched_patterns.map(p => p.pattern_id),
)}
disabled={totalSelected === 0 || applying}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${totalSelected > 0 && !applying ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}>
{applying ? (
<span className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
Wird uebernommen...
</span>
) : `${totalSelected} uebernehmen`}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import { useState } from 'react'
import {
HazardFormData, LifecyclePhase, RoleInfo,
HAZARD_CATEGORIES, CATEGORY_LABELS, getRiskColor, getRiskLevelISO, getRiskLevelLegacy,
} from './types'
import { RiskBadge } from './RiskBadge'
export function HazardForm({
onSubmit,
onCancel,
lifecyclePhases,
roles,
}: {
onSubmit: (data: HazardFormData) => void
onCancel: () => void
lifecyclePhases: LifecyclePhase[]
roles: RoleInfo[]
}) {
const [formData, setFormData] = useState<HazardFormData>({
name: '', description: '', category: 'mechanical_hazard', component_id: '',
severity: 3, exposure: 3, probability: 3, avoidance: 3,
lifecycle_phase: '', trigger_event: '', affected_person: '',
possible_harm: '', hazardous_zone: '', machine_module: '',
})
const [showExtended, setShowExtended] = useState(false)
const isISOMode = formData.avoidance > 0
const rInherent = isISOMode
? formData.severity * formData.exposure * formData.probability * formData.avoidance
: formData.severity * formData.exposure * formData.probability
const riskLevel = isISOMode ? getRiskLevelISO(rInherent) : getRiskLevelLegacy(rInherent)
const formulaLabel = isISOMode ? 'R = S × F × P × A' : 'R = S × E × P'
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">Neue Gefaehrdung</h3>
<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">Bezeichnung *</label>
<input type="text" value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Quetschung durch Roboterarm"
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">Kategorie</label>
<select value={formData.category} onChange={(e) => setFormData({ ...formData, category: 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">
{HAZARD_CATEGORIES.map((cat) => <option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</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 Gefaehrdung..."
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="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">Lebensphase</label>
<select value={formData.lifecycle_phase} onChange={(e) => setFormData({ ...formData, lifecycle_phase: 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">
<option value="">-- Keine Auswahl --</option>
{lifecyclePhases.map((p) => <option key={p.id} value={p.id}>{p.label_de}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Betroffene Personen</label>
<select value={formData.affected_person} onChange={(e) => setFormData({ ...formData, affected_person: 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">
<option value="">-- Bitte waehlen --</option>
{roles.map((r) => <option key={r.id} value={r.id}>{r.label_de}</option>)}
</select>
</div>
</div>
<button type="button" onClick={() => setShowExtended(!showExtended)}
className="text-sm text-purple-600 hover:text-purple-700 font-medium">
{showExtended ? 'Weniger Felder anzeigen' : 'Weitere Felder anzeigen (Ausloeser, Gefahrenzone, Modul...)'}
</button>
{showExtended && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-gray-50 dark:bg-gray-750 rounded-lg">
{[
{ label: 'Ausloeseereignis', field: 'trigger_event' as const, placeholder: 'z.B. Schutztuer offen bei Betrieb' },
{ label: 'Moeglicher Schaden', field: 'possible_harm' as const, placeholder: 'z.B. Schwere Quetschverletzung' },
{ label: 'Gefahrenzone', field: 'hazardous_zone' as const, placeholder: 'z.B. Roboter-Arbeitsbereich' },
{ label: 'Maschinenmodul', field: 'machine_module' as const, placeholder: 'z.B. Antriebseinheit' },
].map(({ label, field, placeholder }) => (
<div key={field}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>
<input type="text" value={formData[field] as string}
onChange={(e) => setFormData({ ...formData, [field]: e.target.value })}
placeholder={placeholder}
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="bg-gray-50 dark:bg-gray-750 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Risikobewertung ({formulaLabel})</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{ label: 'Schwere (S)', field: 'severity' as const, low: 'Gering', high: 'Toedlich' },
{ label: 'Haeufigkeit (F)', field: 'exposure' as const, low: 'Selten', high: 'Staendig' },
{ label: 'Wahrscheinlichkeit (P)', field: 'probability' as const, low: 'Unwahrscheinlich', high: 'Sehr wahrscheinlich' },
{ label: 'Vermeidbarkeit (A)', field: 'avoidance' as const, low: 'Leicht', high: 'Unmoeglich' },
].map(({ label, field, low, high }) => (
<div key={field}>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
{label}: <span className="font-bold">{formData[field]}</span>
</label>
<input type="range" min={1} max={5} value={formData[field]}
onChange={(e) => setFormData({ ...formData, [field]: Number(e.target.value) })}
className="w-full accent-purple-600" />
<div className="flex justify-between text-xs text-gray-400"><span>{low}</span><span>{high}</span></div>
</div>
))}
</div>
<div className={`mt-4 p-3 rounded-lg border ${getRiskColor(riskLevel)}`}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{formulaLabel}</span>
<div className="flex items-center gap-2">
<span className="text-lg font-bold">{rInherent}</span>
<RiskBadge level={riskLevel} />
</div>
</div>
</div>
</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'}`}>
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,77 @@
'use client'
import { Hazard, LifecyclePhase, CATEGORY_LABELS, STATUS_LABELS } from './types'
import { RiskBadge, ReviewStatusBadge } from './RiskBadge'
export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
hazards: Hazard[]
lifecyclePhases: LifecyclePhase[]
onDelete: (id: string) => void
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bezeichnung</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">S</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">F</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">P</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">A</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">R</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Risiko</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Review</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{hazards
.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
.map((hazard) => (
<tr key={hazard.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-white">{hazard.name}</div>
{hazard.name.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>
{hazard.description && (
<div className="text-xs text-gray-500 truncate max-w-[250px]">{hazard.description}</div>
)}
{hazard.lifecycle_phase && (
<div className="text-xs text-purple-500 mt-0.5">
{lifecyclePhases.find(p => p.id === hazard.lifecycle_phase)?.label_de || hazard.lifecycle_phase}
</div>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600">{CATEGORY_LABELS[hazard.category] || hazard.category}</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.severity}</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.exposure}</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.probability}</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.avoidance || '-'}</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-bold">{hazard.r_inherent}</td>
<td className="px-4 py-3"><RiskBadge level={hazard.risk_level} /></td>
<td className="px-4 py-3"><ReviewStatusBadge status={hazard.review_status || 'draft'} /></td>
<td className="px-4 py-3">
<span className="text-xs text-gray-500">{STATUS_LABELS[hazard.status] || hazard.status}</span>
</td>
<td className="px-4 py-3 text-right">
<button onClick={() => onDelete(hazard.id)}
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>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
import { LibraryHazard, HAZARD_CATEGORIES, CATEGORY_LABELS } from './types'
export function LibraryModal({ library, onAdd, onClose }: {
library: LibraryHazard[]
onAdd: (item: LibraryHazard) => void
onClose: () => void
}) {
const [search, setSearch] = useState('')
const [filterCat, setFilterCat] = useState('')
const [expandedId, setExpandedId] = useState<string | null>(null)
const filtered = library.filter((h) => {
const matchSearch = !search || h.name.toLowerCase().includes(search.toLowerCase()) || h.description.toLowerCase().includes(search.toLowerCase())
const matchCat = !filterCat || h.category === filterCat
return matchSearch && matchCat
})
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">Gefaehrdungsbibliothek ({filtered.length} Eintraege)</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-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={filterCat} onChange={(e) => setFilterCat(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>
{HAZARD_CATEGORIES.map((cat) => <option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</option>)}
</select>
</div>
</div>
<div className="flex-1 overflow-auto p-4 space-y-2">
{filtered.length > 0 ? (
filtered.map((item) => (
<div key={item.id} className="rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750">
<div className="flex items-center justify-between p-3">
<div className="flex-1 min-w-0 mr-3 cursor-pointer"
onClick={() => setExpandedId(expandedId === item.id ? null : item.id)}>
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.name}</div>
<div className="text-xs text-gray-500 truncate">{item.description}</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-400">{CATEGORY_LABELS[item.category] || item.category}</span>
<span className="text-xs text-gray-400">
S:{item.default_severity} F:{item.default_exposure || 3} P:{item.default_probability} A:{item.default_avoidance || 3}
</span>
</div>
</div>
<button onClick={() => onAdd(item)}
className="flex-shrink-0 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
Hinzufuegen
</button>
</div>
{expandedId === item.id && (
<div className="px-3 pb-3 space-y-2 text-xs">
{item.typical_causes && item.typical_causes.length > 0 && (
<div><span className="font-medium text-gray-600">Typische Ursachen: </span>
<span className="text-gray-500">{item.typical_causes.join(', ')}</span></div>
)}
{item.typical_harm && (
<div><span className="font-medium text-gray-600">Typischer Schaden: </span>
<span className="text-gray-500">{item.typical_harm}</span></div>
)}
{item.recommended_measures_design && item.recommended_measures_design.length > 0 && (
<div><span className="font-medium text-blue-600">Konstruktiv: </span>
<span className="text-gray-500">{item.recommended_measures_design.join(', ')}</span></div>
)}
{item.recommended_measures_technical && item.recommended_measures_technical.length > 0 && (
<div><span className="font-medium text-green-600">Technisch: </span>
<span className="text-gray-500">{item.recommended_measures_technical.join(', ')}</span></div>
)}
{item.recommended_measures_information && item.recommended_measures_information.length > 0 && (
<div><span className="font-medium text-yellow-600">Information: </span>
<span className="text-gray-500">{item.recommended_measures_information.join(', ')}</span></div>
)}
</div>
)}
</div>
))
) : (
<div className="text-center py-8 text-gray-500">Keine Eintraege gefunden</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
'use client'
import { getRiskColor, getRiskLevelLabel, REVIEW_STATUS_LABELS } from './types'
export function RiskBadge({ level }: { level: string }) {
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${getRiskColor(level)}`}>
{getRiskLevelLabel(level)}
</span>
)
}
export function ReviewStatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-600 border-gray-200',
in_review: 'bg-blue-100 text-blue-600 border-blue-200',
reviewed: 'bg-indigo-100 text-indigo-600 border-indigo-200',
approved: 'bg-green-100 text-green-600 border-green-200',
rejected: 'bg-red-100 text-red-600 border-red-200',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${colors[status] || colors.draft}`}>
{REVIEW_STATUS_LABELS[status] || status}
</span>
)
}

View File

@@ -0,0 +1,178 @@
export interface Hazard {
id: string
name: string
description: string
component_id: string | null
component_name: string | null
category: string
sub_category: string
status: string
severity: number
exposure: number
probability: number
avoidance: number
r_inherent: number
risk_level: string
machine_module: string
lifecycle_phase: string
trigger_event: string
affected_person: string
possible_harm: string
hazardous_zone: string
review_status: string
created_at: string
source?: string
}
export interface LibraryHazard {
id: string
name: string
description: string
category: string
sub_category: string
default_severity: number
default_exposure: number
default_probability: number
default_avoidance: number
typical_causes: string[]
typical_harm: string
relevant_lifecycle_phases: string[]
recommended_measures_design: string[]
recommended_measures_technical: string[]
recommended_measures_information: string[]
}
export interface LifecyclePhase {
id: string
label_de: string
label_en: string
sort_order: number
}
export interface RoleInfo {
id: string
label_de: string
label_en: string
sort_order: number
}
export interface HazardFormData {
name: string
description: string
category: string
component_id: string
severity: number
exposure: number
probability: number
avoidance: number
lifecycle_phase: string
trigger_event: string
affected_person: string
possible_harm: string
hazardous_zone: string
machine_module: string
}
// Pattern matching types (Phase 5)
export interface PatternMatch {
pattern_id: string
pattern_name: string
priority: number
matched_tags: string[]
}
export interface HazardSuggestion {
category: string
source_patterns: string[]
confidence: number
}
export interface MeasureSuggestion {
measure_id: string
source_patterns: string[]
}
export interface EvidenceSuggestion {
evidence_id: string
source_patterns: string[]
}
export interface MatchOutput {
matched_patterns: PatternMatch[]
suggested_hazards: HazardSuggestion[]
suggested_measures: MeasureSuggestion[]
suggested_evidence: EvidenceSuggestion[]
resolved_tags: string[]
}
export const HAZARD_CATEGORIES = [
'mechanical', 'electrical', 'thermal',
'pneumatic_hydraulic', 'noise_vibration', 'ergonomic',
'material_environmental', 'software_control', 'cyber_network', 'ai_specific',
]
export const CATEGORY_LABELS: Record<string, string> = {
mechanical: 'A. Mechanisch', electrical: 'B. Elektrisch', thermal: 'C. Thermisch',
pneumatic_hydraulic: 'D. Pneumatik/Hydraulik', noise_vibration: 'E. Laerm/Vibration',
ergonomic: 'F. Ergonomie', material_environmental: 'G. Stoffe/Umwelt',
software_control: 'H. Software/Steuerung', cyber_network: 'I. Cyber/Netzwerk',
ai_specific: 'J. KI-spezifisch',
// Legacy names (backward compat)
mechanical_hazard: 'A. Mechanisch', electrical_hazard: 'B. Elektrisch',
thermal_hazard: 'C. Thermisch', software_fault: 'H. Software/Steuerung',
safety_function_failure: 'H. Sicherheitsfunktionen', false_classification: 'J. KI-spezifisch',
unauthorized_access: 'I. Cyber/Netzwerk', configuration_error: 'H. Konfiguration',
hmi_error: 'H. HMI-Fehler', integration_error: 'H. Integration',
communication_failure: 'I. Kommunikation', sensor_spoofing: 'I. Sensormanipulation',
model_drift: 'J. Modelldrift', data_poisoning: 'J. Daten-Poisoning',
emc_hazard: 'B. EMV', maintenance_hazard: 'F. Wartung', update_failure: 'H. Update-Fehler',
}
export const STATUS_LABELS: Record<string, string> = {
identified: 'Identifiziert', assessed: 'Bewertet',
mitigated: 'Gemindert', accepted: 'Akzeptiert', closed: 'Geschlossen',
}
export const REVIEW_STATUS_LABELS: Record<string, string> = {
draft: 'Entwurf', in_review: 'In Pruefung',
reviewed: 'Geprueft', approved: 'Freigegeben', rejected: 'Abgelehnt',
}
export function getRiskColor(level: string): string {
switch (level) {
case 'not_acceptable': return 'bg-red-200 text-red-900 border-red-300'
case 'very_high': return 'bg-red-100 text-red-700 border-red-200'
case 'critical': return 'bg-red-100 text-red-700 border-red-200'
case 'high': return 'bg-orange-100 text-orange-700 border-orange-200'
case 'medium': return 'bg-yellow-100 text-yellow-700 border-yellow-200'
case 'low': return 'bg-green-100 text-green-700 border-green-200'
default: return 'bg-gray-100 text-gray-700 border-gray-200'
}
}
export function getRiskLevelISO(r: number): string {
if (r > 300) return 'not_acceptable'
if (r >= 151) return 'very_high'
if (r >= 61) return 'high'
if (r >= 21) return 'medium'
return 'low'
}
export function getRiskLevelLegacy(r: number): string {
if (r >= 100) return 'critical'
if (r >= 50) return 'high'
if (r >= 20) return 'medium'
return 'low'
}
export function getRiskLevelLabel(level: string): string {
switch (level) {
case 'not_acceptable': return 'Nicht akzeptabel'
case 'very_high': return 'Sehr hoch'
case 'critical': return 'Kritisch'
case 'high': return 'Hoch'
case 'medium': return 'Mittel'
case 'low': return 'Niedrig'
default: return level
}
}

View File

@@ -0,0 +1,173 @@
'use client'
import { useState, useEffect } from 'react'
import {
Hazard, LibraryHazard, LifecyclePhase, RoleInfo, HazardFormData, MatchOutput,
CATEGORY_LABELS,
} from '../_components/types'
export function useHazards(projectId: string) {
const [hazards, setHazards] = useState<Hazard[]>([])
const [library, setLibrary] = useState<LibraryHazard[]>([])
const [lifecyclePhases, setLifecyclePhases] = useState<LifecyclePhase[]>([])
const [roles, setRoles] = useState<RoleInfo[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [showLibrary, setShowLibrary] = useState(false)
const [suggestingAI, setSuggestingAI] = useState(false)
const [matchingPatterns, setMatchingPatterns] = useState(false)
const [matchResult, setMatchResult] = useState<MatchOutput | null>(null)
const [applyingPatterns, setApplyingPatterns] = useState(false)
useEffect(() => {
fetchHazards()
fetchLifecyclePhases()
fetchRoles()
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
async function fetchHazards() {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`)
if (res.ok) {
const json = await res.json()
setHazards(json.hazards || json || [])
}
} catch (err) {
console.error('Failed to fetch hazards:', err)
} finally {
setLoading(false)
}
}
async function fetchLifecyclePhases() {
try {
const res = await fetch('/api/sdk/v1/iace/lifecycle-phases')
if (res.ok) { const json = await res.json(); setLifecyclePhases(json.lifecycle_phases || []) }
} catch (err) { console.error('Failed to fetch lifecycle phases:', err) }
}
async function fetchRoles() {
try {
const res = await fetch('/api/sdk/v1/iace/roles')
if (res.ok) { const json = await res.json(); setRoles(json.roles || []) }
} catch (err) { console.error('Failed to fetch roles:', err) }
}
async function fetchLibrary() {
try {
const res = await fetch('/api/sdk/v1/iace/hazard-library')
if (res.ok) { const json = await res.json(); setLibrary(json.hazard_library || json.hazards || json || []) }
} catch (err) { console.error('Failed to fetch hazard library:', err) }
setShowLibrary(true)
}
async function handleAddFromLibrary(item: LibraryHazard) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: item.name, description: item.description, category: item.category,
sub_category: item.sub_category || '', severity: item.default_severity,
exposure: item.default_exposure || 3, probability: item.default_probability,
avoidance: item.default_avoidance || 3,
}),
})
if (res.ok) await fetchHazards()
} catch (err) { console.error('Failed to add from library:', err) }
}
async function handleSubmit(data: HazardFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) { setShowForm(false); await fetchHazards() }
} catch (err) { console.error('Failed to add hazard:', err) }
}
async function handleAISuggestions() {
setSuggestingAI(true)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) await fetchHazards()
} catch (err) { console.error('Failed to get AI suggestions:', err) }
finally { setSuggestingAI(false) }
}
async function handlePatternMatching() {
setMatchingPatterns(true)
setMatchResult(null)
try {
const compRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
let componentLibraryIds: string[] = []
let energySourceIds: string[] = []
if (compRes.ok) {
const compJson = await compRes.json()
const comps = compJson.components || compJson || []
componentLibraryIds = comps.map((c: { library_component_id?: string }) => c.library_component_id).filter(Boolean) as string[]
const allEnergyIds = comps.flatMap((c: { energy_source_ids?: string[] }) => c.energy_source_ids || [])
energySourceIds = [...new Set(allEnergyIds)] as string[]
}
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/match-patterns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ component_library_ids: componentLibraryIds, energy_source_ids: energySourceIds, lifecycle_phases: [], custom_tags: [] }),
})
if (res.ok) setMatchResult(await res.json())
} catch (err) { console.error('Failed to match patterns:', err) }
finally { setMatchingPatterns(false) }
}
async function handleApplyPatterns(
acceptedHazardCats: string[], acceptedMeasureIds: string[],
acceptedEvidenceIds: string[], patternIds: string[],
) {
setApplyingPatterns(true)
try {
const acceptedHazards = acceptedHazardCats.map(cat => ({
name: `Auto: ${CATEGORY_LABELS[cat] || cat}`,
description: `Automatisch erkannte Gefaehrdung aus Pattern-Matching (Kategorie: ${cat})`,
category: cat, severity: 3, exposure: 3, probability: 3, avoidance: 3,
}))
const acceptedMeasures = acceptedMeasureIds.map(id => ({
name: `Auto: Massnahme ${id}`,
description: `Automatisch vorgeschlagene Massnahme aus Pattern-Matching`,
reduction_type: 'design',
}))
const acceptedEvidence = acceptedEvidenceIds.map(id => ({
title: `Auto: Nachweis ${id}`,
description: `Automatisch vorgeschlagener Nachweis aus Pattern-Matching`,
method: 'test_report',
}))
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/apply-patterns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted_hazards: acceptedHazards, accepted_measures: acceptedMeasures, accepted_evidence: acceptedEvidence, source_pattern_ids: patternIds }),
})
if (res.ok) { setMatchResult(null); await fetchHazards() }
} catch (err) { console.error('Failed to apply patterns:', err) }
finally { setApplyingPatterns(false) }
}
async function handleDelete(id: string) {
if (!confirm('Gefaehrdung wirklich loeschen?')) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${id}`, { method: 'DELETE' })
if (res.ok) await fetchHazards()
} catch (err) { console.error('Failed to delete hazard:', err) }
}
return {
hazards, library, lifecyclePhases, roles, loading,
showForm, setShowForm, showLibrary, setShowLibrary,
suggestingAI, matchingPatterns, matchResult, setMatchResult, applyingPatterns,
fetchLibrary, handleAddFromLibrary, handleSubmit,
handleAISuggestions, handlePatternMatching, handleApplyPatterns, handleDelete,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
'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">

View File

@@ -4,7 +4,10 @@ import { useState } from 'react'
import { ProtectiveMeasure } from './types'
export function MeasuresLibraryModal({
measures, onSelect, onClose, filterType,
measures,
onSelect,
onClose,
filterType,
}: {
measures: ProtectiveMeasure[]
onSelect: (measure: ProtectiveMeasure) => void
@@ -39,14 +42,23 @@ export function MeasuresLibraryModal({
</button>
</div>
<div className="flex gap-3">
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)}
<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" />
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">
<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>)}
{subTypes.map((st) => (
<option key={st} value={st}>{st}</option>
))}
</select>
)}
</div>
@@ -54,21 +66,27 @@ export function MeasuresLibraryModal({
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-3">
{filtered.map((m) => (
<div key={m.id}
<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)}>
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>}
{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>
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-600">
{ex}
</span>
))}
</div>
)}

View File

@@ -1,8 +1,12 @@
'use client'
import { Mitigation } from './types'
import { StatusBadge } from './StatusBadge'
export function MitigationCard({
mitigation, onVerify, onDelete,
mitigation,
onVerify,
onDelete,
}: {
mitigation: Mitigation
onVerify: (id: string) => void
@@ -14,7 +18,9 @@ export function MitigationCard({
<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>
<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} />
@@ -35,13 +41,17 @@ export function MitigationCard({
)}
<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">
<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">
<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>

View File

@@ -4,7 +4,11 @@ import { useState } from 'react'
import { Hazard, MitigationFormData } from './types'
export function MitigationForm({
onSubmit, onCancel, hazards, preselectedType, onOpenLibrary,
onSubmit,
onCancel,
hazards,
preselectedType,
onOpenLibrary,
}: {
onSubmit: (data: MitigationFormData) => void
onCancel: () => void
@@ -32,8 +36,10 @@ export function MitigationForm({
<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">
<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>
@@ -41,16 +47,21 @@ export function MitigationForm({
<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}
<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" />
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}
<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">
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>
@@ -59,22 +70,28 @@ export function MitigationForm({
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea value={formData.description}
<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" />
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)}
<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>
))}
@@ -83,10 +100,15 @@ export function MitigationForm({
)}
</div>
<div className="mt-4 flex items-center gap-3">
<button onClick={() => onSubmit(formData)} disabled={!formData.title}
<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'
}`}>
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">

View File

@@ -1,3 +1,5 @@
'use client'
export function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
planned: 'bg-gray-100 text-gray-700',

View File

@@ -4,7 +4,10 @@ import { useState } from 'react'
import { Hazard, SuggestedMeasure, REDUCTION_TYPES } from './types'
export function SuggestMeasuresModal({
hazards, projectId, onAddMeasure, onClose,
hazards,
projectId,
onAddMeasure,
onClose,
}: {
hazards: Hazard[]
projectId: string
@@ -28,12 +31,17 @@ export function SuggestMeasuresModal({
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' },
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) { const json = await res.json(); setSuggested(json.suggested_measures || []) }
if (res.ok) {
const json = await res.json()
setSuggested(json.suggested_measures || [])
}
} catch (err) {
console.error('Failed to suggest measures:', err)
} finally {
@@ -59,15 +67,20 @@ export function SuggestMeasuresModal({
</svg>
</button>
</div>
<p className="text-sm text-gray-500 mb-3">Waehlen Sie eine Gefaehrdung, um passende Massnahmen vorgeschlagen zu bekommen.</p>
<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)}
<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>
))}
@@ -99,13 +112,17 @@ export function SuggestMeasuresModal({
<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>}
{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">
<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>
@@ -117,9 +134,13 @@ export function SuggestMeasuresModal({
})}
</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">
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 className="text-center py-12 text-gray-500">
Waehlen Sie eine Gefaehrdung aus, um Vorschlaege zu erhalten.
</div>
)}
</div>
</div>

View File

@@ -1,3 +1,5 @@
import React from 'react'
export interface Mitigation {
id: string
title: string

View File

@@ -7,15 +7,12 @@ export function useMitigations(projectId: string) {
const [mitigations, setMitigations] = useState<Mitigation[]>([])
const [hazards, setHazards] = useState<Hazard[]>([])
const [loading, setLoading] = useState(true)
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[]>([])
const [showSuggest, setShowSuggest] = useState(false)
useEffect(() => { fetchData() }, [projectId])
useEffect(() => {
fetchData()
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
async function fetchData() {
try {
@@ -44,11 +41,22 @@ export function useMitigations(projectId: string) {
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 })) }),
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 */ }
if (res.ok) {
const json = await res.json()
setHierarchyWarning(json.has_warning === true)
}
} catch {
// Non-critical, ignore
}
}
async function fetchMeasuresLibrary(type?: string) {
@@ -57,50 +65,55 @@ export function useMitigations(projectId: string) {
? `/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 || []) }
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)
setShowLibrary(true)
}
function handleSelectMeasure(measure: ProtectiveMeasure) {
setShowLibrary(false)
setShowForm(true)
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),
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) }
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' },
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) }
} 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' },
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) await fetchData()
} catch (err) { console.error('Failed to verify mitigation:', err) }
} catch (err) {
console.error('Failed to verify mitigation:', err)
}
}
async function handleDelete(id: string) {
@@ -108,12 +121,9 @@ export function useMitigations(projectId: string) {
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)
} catch (err) {
console.error('Failed to delete mitigation:', err)
}
}
const byType = {
@@ -123,12 +133,8 @@ export function useMitigations(projectId: string) {
}
return {
mitigations, hazards, loading, byType,
showForm, setShowForm, preselectedType, setPreselectedType,
hierarchyWarning, setHierarchyWarning,
showLibrary, setShowLibrary, libraryFilter, measures,
showSuggest, setShowSuggest,
handleOpenLibrary, handleSelectMeasure, handleSubmit,
handleAddSuggestedMeasure, handleVerify, handleDelete, handleAddForType,
mitigations, hazards, loading, hierarchyWarning, setHierarchyWarning,
measures, byType,
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
}
}

View File

@@ -1,20 +1,50 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import { REDUCTION_TYPES } from './_components/types'
import { HierarchyWarning } from './_components/HierarchyWarning'
import { MitigationForm } from './_components/MitigationForm'
import { MitigationCard } from './_components/MitigationCard'
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 m = useMitigations(projectId)
if (m.loading) {
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 [showLibrary, setShowLibrary] = useState(false)
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
const [showSuggest, setShowSuggest] = useState(false)
function handleOpenLibrary(type?: string) {
setLibraryFilter(type)
fetchMeasuresLibrary(type)
setShowLibrary(true)
}
function handleSelectMeasure(measure: ProtectiveMeasure) {
setShowLibrary(false)
setShowForm(true)
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
}
function handleAddForType(type: 'design' | 'protection' | 'information') {
setPreselectedType(type)
setShowForm(true)
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
@@ -49,8 +79,10 @@ export default function MitigationsPage() {
</svg>
Bibliothek
</button>
<button onClick={() => { m.setPreselectedType(undefined); m.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">
<button
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
@@ -59,25 +91,29 @@ export default function MitigationsPage() {
</div>
</div>
{m.hierarchyWarning && <HierarchyWarning onDismiss={() => m.setHierarchyWarning(false)} />}
{hierarchyWarning && <HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />}
{m.showForm && (
{showForm && (
<MitigationForm
onSubmit={m.handleSubmit}
onCancel={() => { m.setShowForm(false); m.setPreselectedType(undefined) }}
hazards={m.hazards} preselectedType={m.preselectedType}
onOpenLibrary={m.handleOpenLibrary}
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}
/>
)}
{m.showLibrary && (
{showLibrary && (
<MeasuresLibraryModal
measures={m.measures} onSelect={m.handleSelectMeasure}
onClose={() => m.setShowLibrary(false)} filterType={m.libraryFilter}
/>
)}
{m.showSuggest && (
{showSuggest && (
<SuggestMeasuresModal
hazards={m.hazards} projectId={projectId}
onAddMeasure={m.handleAddSuggestedMeasure}
@@ -108,8 +144,8 @@ export default function MitigationsPage() {
))}
</div>
<div className="space-y-3">
{items.map((item) => (
<MitigationCard key={item.id} mitigation={item} onVerify={m.handleVerify} onDelete={m.handleDelete} />
{items.map((m) => (
<MitigationCard key={m.id} mitigation={m} onVerify={handleVerify} onDelete={handleDelete} />
))}
</div>
<div className="mt-3 flex gap-2">

View File

@@ -0,0 +1,71 @@
'use client'
const EVENT_TYPE_CONFIG: Record<string, { label: string; color: string; bgColor: string; icon: string }> = {
incident: {
label: 'Vorfall',
color: 'text-red-700',
bgColor: 'bg-red-100',
icon: '🚨',
},
update: {
label: 'Update',
color: 'text-blue-700',
bgColor: 'bg-blue-100',
icon: '🔄',
},
drift_alert: {
label: 'Drift-Warnung',
color: 'text-orange-700',
bgColor: 'bg-orange-100',
icon: '📉',
},
regulation_change: {
label: 'Regulierungsaenderung',
color: 'text-purple-700',
bgColor: 'bg-purple-100',
icon: '📜',
},
}
const SEVERITY_CONFIG: Record<string, { label: string; color: string }> = {
low: { label: 'Niedrig', color: 'bg-green-100 text-green-700' },
medium: { label: 'Mittel', color: 'bg-yellow-100 text-yellow-700' },
high: { label: 'Hoch', color: 'bg-orange-100 text-orange-700' },
critical: { label: 'Kritisch', color: 'bg-red-100 text-red-700' },
}
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
open: { label: 'Offen', color: 'bg-red-100 text-red-700' },
investigating: { label: 'In Untersuchung', color: 'bg-yellow-100 text-yellow-700' },
resolved: { label: 'Geloest', color: 'bg-green-100 text-green-700' },
closed: { label: 'Geschlossen', color: 'bg-gray-100 text-gray-700' },
}
export { EVENT_TYPE_CONFIG }
export function EventTypeBadge({ type }: { type: string }) {
const config = EVENT_TYPE_CONFIG[type] || EVENT_TYPE_CONFIG.incident
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.bgColor} ${config.color}`}>
{config.icon} {config.label}
</span>
)
}
export function SeverityBadge({ severity }: { severity: string }) {
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.low
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}
export function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.open
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import { useState } from 'react'
import { EventFormData } from '../_hooks/useMonitoring'
export function EventForm({
onSubmit,
onCancel,
}: {
onSubmit: (data: EventFormData) => void
onCancel: () => void
}) {
const [formData, setFormData] = useState<EventFormData>({
event_type: 'incident',
title: '',
description: '',
severity: 'medium',
})
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">Neues Monitoring-Ereignis</h3>
<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. KI-Modell Drift erkannt"
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="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
<select
value={formData.event_type}
onChange={(e) => setFormData({ ...formData, event_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"
>
<option value="incident">Vorfall</option>
<option value="update">Update</option>
<option value="drift_alert">Drift-Warnung</option>
<option value="regulation_change">Regulierungsaenderung</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schwere</label>
<select
value={formData.severity}
onChange={(e) => setFormData({ ...formData, severity: 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"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
<option value="critical">Kritisch</option>
</select>
</div>
</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={3}
placeholder="Beschreiben Sie das Ereignis..."
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.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'
}`}
>
Ereignis erfassen
</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,56 @@
'use client'
import { useState } from 'react'
import { MonitoringEvent } from '../_hooks/useMonitoring'
export function ResolveModal({
event,
onSubmit,
onClose,
}: {
event: MonitoringEvent
onSubmit: (id: string, notes: string) => void
onClose: () => void
}) {
const [notes, setNotes] = useState('')
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-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Ereignis loesen: {event.title}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Loesung / Massnahmen
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
placeholder="Beschreiben Sie die durchgefuehrten Massnahmen..."
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-6 flex items-center gap-3">
<button
onClick={() => onSubmit(event.id, notes)}
disabled={!notes}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
notes
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Als geloest markieren
</button>
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import { MonitoringEvent } from '../_hooks/useMonitoring'
import { EventTypeBadge, SeverityBadge, StatusBadge, EVENT_TYPE_CONFIG } from './Badges'
export function TimelineEvent({
event,
onResolve,
}: {
event: MonitoringEvent
onResolve: (event: MonitoringEvent) => void
}) {
const typeConfig = EVENT_TYPE_CONFIG[event.event_type] || EVENT_TYPE_CONFIG.incident
const lineColor = event.status === 'resolved' || event.status === 'closed' ? 'bg-green-300' : 'bg-gray-300'
return (
<div className="relative flex gap-4 pb-8 last:pb-0">
{/* Timeline line */}
<div className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-lg ${typeConfig.bgColor} flex-shrink-0`}>
{typeConfig.icon}
</div>
<div className={`w-0.5 flex-1 ${lineColor} mt-2`} />
</div>
{/* Content */}
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 -mt-1">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">{event.title}</h4>
<div className="flex items-center gap-2 mt-1">
<EventTypeBadge type={event.event_type} />
<SeverityBadge severity={event.severity} />
<StatusBadge status={event.status} />
</div>
</div>
<span className="text-xs text-gray-400 flex-shrink-0">
{new Date(event.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
{event.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">{event.description}</p>
)}
{event.resolution_notes && (
<div className="mt-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<div className="text-xs font-medium text-green-700 dark:text-green-400 mb-1">Loesung:</div>
<p className="text-sm text-green-800 dark:text-green-300">{event.resolution_notes}</p>
{event.resolved_at && (
<div className="text-xs text-green-600 mt-1">
Geloest am {new Date(event.resolved_at).toLocaleDateString('de-DE')}
</div>
)}
</div>
)}
{(event.status === 'open' || event.status === 'investigating') && (
<div className="mt-3">
<button
onClick={() => onResolve(event)}
className="text-sm px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Loesen
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,99 @@
'use client'
import { useState, useEffect } from 'react'
export interface MonitoringEvent {
id: string
event_type: 'incident' | 'update' | 'drift_alert' | 'regulation_change'
title: string
description: string
severity: 'low' | 'medium' | 'high' | 'critical'
status: 'open' | 'investigating' | 'resolved' | 'closed'
created_at: string
resolved_at: string | null
resolved_by: string | null
resolution_notes: string | null
}
export interface EventFormData {
event_type: string
title: string
description: string
severity: string
}
export function useMonitoring(projectId: string) {
const [events, setEvents] = useState<MonitoringEvent[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [resolvingEvent, setResolvingEvent] = useState<MonitoringEvent | null>(null)
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
useEffect(() => {
fetchEvents()
}, [projectId])
async function fetchEvents() {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`)
if (res.ok) {
const json = await res.json()
setEvents(json.events || json || [])
}
} catch (err) {
console.error('Failed to fetch monitoring events:', err)
} finally {
setLoading(false)
}
}
async function handleSubmit(data: EventFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
setShowForm(false)
await fetchEvents()
}
} catch (err) {
console.error('Failed to add event:', err)
}
}
async function handleResolve(id: string, notes: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring/${id}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolution_notes: notes }),
})
if (res.ok) {
setResolvingEvent(null)
await fetchEvents()
}
} catch (err) {
console.error('Failed to resolve event:', err)
}
}
const filteredEvents = events.filter((e) => {
const matchType = !filterType || e.event_type === filterType
const matchStatus = !filterStatus || e.status === filterStatus
return matchType && matchStatus
})
const openCount = events.filter((e) => e.status === 'open' || e.status === 'investigating').length
const resolvedCount = events.filter((e) => e.status === 'resolved' || e.status === 'closed').length
return {
events, loading, showForm, resolvingEvent,
filterType, filterStatus, filteredEvents,
openCount, resolvedCount,
setShowForm, setResolvingEvent, setFilterType, setFilterStatus,
handleSubmit, handleResolve,
}
}

View File

@@ -1,378 +1,23 @@
'use client'
import React, { useState, useEffect } from 'react'
import React from 'react'
import { useParams } from 'next/navigation'
interface MonitoringEvent {
id: string
event_type: 'incident' | 'update' | 'drift_alert' | 'regulation_change'
title: string
description: string
severity: 'low' | 'medium' | 'high' | 'critical'
status: 'open' | 'investigating' | 'resolved' | 'closed'
created_at: string
resolved_at: string | null
resolved_by: string | null
resolution_notes: string | null
}
const EVENT_TYPE_CONFIG: Record<string, { label: string; color: string; bgColor: string; icon: string }> = {
incident: {
label: 'Vorfall',
color: 'text-red-700',
bgColor: 'bg-red-100',
icon: '🚨',
},
update: {
label: 'Update',
color: 'text-blue-700',
bgColor: 'bg-blue-100',
icon: '🔄',
},
drift_alert: {
label: 'Drift-Warnung',
color: 'text-orange-700',
bgColor: 'bg-orange-100',
icon: '📉',
},
regulation_change: {
label: 'Regulierungsaenderung',
color: 'text-purple-700',
bgColor: 'bg-purple-100',
icon: '📜',
},
}
const SEVERITY_CONFIG: Record<string, { label: string; color: string }> = {
low: { label: 'Niedrig', color: 'bg-green-100 text-green-700' },
medium: { label: 'Mittel', color: 'bg-yellow-100 text-yellow-700' },
high: { label: 'Hoch', color: 'bg-orange-100 text-orange-700' },
critical: { label: 'Kritisch', color: 'bg-red-100 text-red-700' },
}
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
open: { label: 'Offen', color: 'bg-red-100 text-red-700' },
investigating: { label: 'In Untersuchung', color: 'bg-yellow-100 text-yellow-700' },
resolved: { label: 'Geloest', color: 'bg-green-100 text-green-700' },
closed: { label: 'Geschlossen', color: 'bg-gray-100 text-gray-700' },
}
function EventTypeBadge({ type }: { type: string }) {
const config = EVENT_TYPE_CONFIG[type] || EVENT_TYPE_CONFIG.incident
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.bgColor} ${config.color}`}>
{config.icon} {config.label}
</span>
)
}
function SeverityBadge({ severity }: { severity: string }) {
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.low
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}
function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.open
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}
interface EventFormData {
event_type: string
title: string
description: string
severity: string
}
function EventForm({
onSubmit,
onCancel,
}: {
onSubmit: (data: EventFormData) => void
onCancel: () => void
}) {
const [formData, setFormData] = useState<EventFormData>({
event_type: 'incident',
title: '',
description: '',
severity: 'medium',
})
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">Neues Monitoring-Ereignis</h3>
<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. KI-Modell Drift erkannt"
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="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
<select
value={formData.event_type}
onChange={(e) => setFormData({ ...formData, event_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"
>
<option value="incident">Vorfall</option>
<option value="update">Update</option>
<option value="drift_alert">Drift-Warnung</option>
<option value="regulation_change">Regulierungsaenderung</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schwere</label>
<select
value={formData.severity}
onChange={(e) => setFormData({ ...formData, severity: 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"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
<option value="critical">Kritisch</option>
</select>
</div>
</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={3}
placeholder="Beschreiben Sie das Ereignis..."
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.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'
}`}
>
Ereignis erfassen
</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 ResolveModal({
event,
onSubmit,
onClose,
}: {
event: MonitoringEvent
onSubmit: (id: string, notes: string) => void
onClose: () => void
}) {
const [notes, setNotes] = useState('')
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-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Ereignis loesen: {event.title}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Loesung / Massnahmen
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
placeholder="Beschreiben Sie die durchgefuehrten Massnahmen..."
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-6 flex items-center gap-3">
<button
onClick={() => onSubmit(event.id, notes)}
disabled={!notes}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
notes
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Als geloest markieren
</button>
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
</div>
)
}
function TimelineEvent({
event,
onResolve,
}: {
event: MonitoringEvent
onResolve: (event: MonitoringEvent) => void
}) {
const typeConfig = EVENT_TYPE_CONFIG[event.event_type] || EVENT_TYPE_CONFIG.incident
const lineColor = event.status === 'resolved' || event.status === 'closed' ? 'bg-green-300' : 'bg-gray-300'
return (
<div className="relative flex gap-4 pb-8 last:pb-0">
{/* Timeline line */}
<div className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-lg ${typeConfig.bgColor} flex-shrink-0`}>
{typeConfig.icon}
</div>
<div className={`w-0.5 flex-1 ${lineColor} mt-2`} />
</div>
{/* Content */}
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 -mt-1">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">{event.title}</h4>
<div className="flex items-center gap-2 mt-1">
<EventTypeBadge type={event.event_type} />
<SeverityBadge severity={event.severity} />
<StatusBadge status={event.status} />
</div>
</div>
<span className="text-xs text-gray-400 flex-shrink-0">
{new Date(event.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
{event.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">{event.description}</p>
)}
{event.resolution_notes && (
<div className="mt-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<div className="text-xs font-medium text-green-700 dark:text-green-400 mb-1">Loesung:</div>
<p className="text-sm text-green-800 dark:text-green-300">{event.resolution_notes}</p>
{event.resolved_at && (
<div className="text-xs text-green-600 mt-1">
Geloest am {new Date(event.resolved_at).toLocaleDateString('de-DE')}
</div>
)}
</div>
)}
{(event.status === 'open' || event.status === 'investigating') && (
<div className="mt-3">
<button
onClick={() => onResolve(event)}
className="text-sm px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Loesen
</button>
</div>
)}
</div>
</div>
)
}
import { useMonitoring } from './_hooks/useMonitoring'
import { EventForm } from './_components/EventForm'
import { ResolveModal } from './_components/ResolveModal'
import { TimelineEvent } from './_components/TimelineEvent'
export default function MonitoringPage() {
const params = useParams()
const projectId = params.projectId as string
const [events, setEvents] = useState<MonitoringEvent[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [resolvingEvent, setResolvingEvent] = useState<MonitoringEvent | null>(null)
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
useEffect(() => {
fetchEvents()
}, [projectId])
async function fetchEvents() {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`)
if (res.ok) {
const json = await res.json()
setEvents(json.events || json || [])
}
} catch (err) {
console.error('Failed to fetch monitoring events:', err)
} finally {
setLoading(false)
}
}
async function handleSubmit(data: EventFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
setShowForm(false)
await fetchEvents()
}
} catch (err) {
console.error('Failed to add event:', err)
}
}
async function handleResolve(id: string, notes: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring/${id}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolution_notes: notes }),
})
if (res.ok) {
setResolvingEvent(null)
await fetchEvents()
}
} catch (err) {
console.error('Failed to resolve event:', err)
}
}
const filteredEvents = events.filter((e) => {
const matchType = !filterType || e.event_type === filterType
const matchStatus = !filterStatus || e.status === filterStatus
return matchType && matchStatus
})
const openCount = events.filter((e) => e.status === 'open' || e.status === 'investigating').length
const resolvedCount = events.filter((e) => e.status === 'resolved' || e.status === 'closed').length
const {
events, loading, showForm, resolvingEvent,
filterType, filterStatus, filteredEvents,
openCount, resolvedCount,
setShowForm, setResolvingEvent, setFilterType, setFilterStatus,
handleSubmit, handleResolve,
} = useMonitoring(projectId)
if (loading) {
return (

View File

@@ -1,17 +1,10 @@
'use client'
import { useState } from 'react'
interface VerificationItem {
id: string
title: string
[key: string]: unknown
}
import type { VerificationItem } from './verification-types'
export function CompleteModal({
item,
onSubmit,
onClose,
item, onSubmit, onClose,
}: {
item: VerificationItem
onSubmit: (id: string, result: string, passed: boolean) => void
@@ -40,17 +33,13 @@ export function CompleteModal({
<div className="flex gap-3">
<button
onClick={() => setPassed(true)}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
passed ? 'border-green-400 bg-green-50 text-green-700' : 'border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${passed ? 'border-green-400 bg-green-50 text-green-700' : 'border-gray-200 text-gray-500 hover:bg-gray-50'}`}
>
Bestanden
</button>
<button
onClick={() => setPassed(false)}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
!passed ? 'border-red-400 bg-red-50 text-red-700' : 'border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${!passed ? 'border-red-400 bg-red-50 text-red-700' : 'border-gray-200 text-gray-500 hover:bg-gray-50'}`}
>
Nicht bestanden
</button>
@@ -60,9 +49,7 @@ export function CompleteModal({
<div className="mt-6 flex items-center gap-3">
<button
onClick={() => onSubmit(item.id, result, passed)} disabled={!result}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
result ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${result ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}
>
Abschliessen
</button>

View File

@@ -1,33 +1,10 @@
'use client'
import { useState } from 'react'
interface SuggestedEvidence {
id: string
name: string
description: string
method: string
tags?: string[]
}
const VERIFICATION_METHOD_LABELS: Record<string, string> = {
design_review: 'Design-Review',
calculation: 'Berechnung',
test_report: 'Pruefbericht',
validation: 'Validierung',
electrical_test: 'Elektrische Pruefung',
software_test: 'Software-Test',
penetration_test: 'Penetrationstest',
acceptance_protocol: 'Abnahmeprotokoll',
user_test: 'Anwendertest',
documentation_release: 'Dokumentenfreigabe',
}
import { VERIFICATION_METHODS, type SuggestedEvidence } from './verification-types'
export function SuggestEvidenceModal({
mitigations,
projectId,
onAddEvidence,
onClose,
mitigations, projectId, onAddEvidence, onClose,
}: {
mitigations: { id: string; title: string }[]
projectId: string
@@ -69,7 +46,8 @@ export function SuggestEvidenceModal({
</p>
<div className="flex flex-wrap gap-2">
{mitigations.map(m => (
<button key={m.id} onClick={() => handleSelectMitigation(m.id)}
<button
key={m.id} onClick={() => handleSelectMitigation(m.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
selectedMitigation === m.id
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
@@ -81,7 +59,6 @@ export function SuggestEvidenceModal({
))}
</div>
</div>
<div className="flex-1 overflow-auto p-6">
{loadingSuggestions ? (
<div className="flex items-center justify-center py-12">
@@ -97,7 +74,7 @@ export function SuggestEvidenceModal({
<span className="text-xs font-mono text-gray-400">{ev.id}</span>
{ev.method && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">
{VERIFICATION_METHOD_LABELS[ev.method] || ev.method}
{VERIFICATION_METHODS.find(m => m.value === ev.method)?.label || ev.method}
</span>
)}
</div>

View File

@@ -1,33 +1,10 @@
'use client'
import { useState } from 'react'
export interface VerificationFormData {
title: string
description: string
method: string
linked_hazard_id: string
linked_mitigation_id: string
}
const VERIFICATION_METHODS = [
{ value: 'design_review', label: 'Design-Review' },
{ value: 'calculation', label: 'Berechnung' },
{ value: 'test_report', label: 'Pruefbericht' },
{ value: 'validation', label: 'Validierung' },
{ value: 'electrical_test', label: 'Elektrische Pruefung' },
{ value: 'software_test', label: 'Software-Test' },
{ value: 'penetration_test', label: 'Penetrationstest' },
{ value: 'acceptance_protocol', label: 'Abnahmeprotokoll' },
{ value: 'user_test', label: 'Anwendertest' },
{ value: 'documentation_release', label: 'Dokumentenfreigabe' },
]
import { VERIFICATION_METHODS, type VerificationFormData } from './verification-types'
export function VerificationForm({
onSubmit,
onCancel,
hazards,
mitigations,
onSubmit, onCancel, hazards, mitigations,
}: {
onSubmit: (data: VerificationFormData) => void
onCancel: () => void
@@ -59,9 +36,7 @@ export function VerificationForm({
onChange={(e) => setFormData({ ...formData, method: 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"
>
{VERIFICATION_METHODS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
{VERIFICATION_METHODS.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</div>
</div>
@@ -70,7 +45,8 @@ export function VerificationForm({
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2} placeholder="Beschreiben Sie den Verifikationsschritt..."
rows={2}
placeholder="Beschreiben Sie den Verifikationsschritt..."
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>
@@ -102,9 +78,7 @@ export function VerificationForm({
<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'
}`}
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>

View File

@@ -1,30 +1,18 @@
import { StatusBadge } from './StatusBadge'
'use client'
interface VerificationItem {
id: string
title: string
description: string
method: string
status: 'pending' | 'in_progress' | 'completed' | 'failed'
result: string | null
linked_hazard_name: string | null
linked_mitigation_name: string | null
completed_at: string | null
completed_by: string | null
created_at: string
}
import { VERIFICATION_METHODS, STATUS_CONFIG, type VerificationItem } from './verification-types'
const VERIFICATION_METHOD_LABELS: Record<string, string> = {
design_review: 'Design-Review', calculation: 'Berechnung', test_report: 'Pruefbericht',
validation: 'Validierung', electrical_test: 'Elektrische Pruefung', software_test: 'Software-Test',
penetration_test: 'Penetrationstest', acceptance_protocol: 'Abnahmeprotokoll',
user_test: 'Anwendertest', documentation_release: 'Dokumentenfreigabe',
function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}
export function VerificationTable({
items,
onComplete,
onDelete,
items, onComplete, onDelete,
}: {
items: VerificationItem[]
onComplete: (item: VerificationItem) => void
@@ -50,13 +38,11 @@ export function VerificationTable({
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
{item.description && (
<div className="text-xs text-gray-500 truncate max-w-[200px]">{item.description}</div>
)}
{item.description && <div className="text-xs text-gray-500 truncate max-w-[200px]">{item.description}</div>}
</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{VERIFICATION_METHOD_LABELS[item.method] || item.method}
{VERIFICATION_METHODS.find((m) => m.value === item.method)?.label || item.method}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_hazard_name || '--'}</td>

View File

@@ -0,0 +1,51 @@
export interface VerificationItem {
id: string
title: string
description: string
method: string
status: 'pending' | 'in_progress' | 'completed' | 'failed'
result: string | null
linked_hazard_id: string | null
linked_hazard_name: string | null
linked_mitigation_id: string | null
linked_mitigation_name: string | null
completed_at: string | null
completed_by: string | null
created_at: string
}
export interface VerificationFormData {
title: string
description: string
method: string
linked_hazard_id: string
linked_mitigation_id: string
}
export interface SuggestedEvidence {
id: string
name: string
description: string
method: string
tags?: string[]
}
export const VERIFICATION_METHODS = [
{ value: 'design_review', label: 'Design-Review', description: 'Systematische Pruefung der Konstruktionsunterlagen' },
{ value: 'calculation', label: 'Berechnung', description: 'Rechnerischer Nachweis (FEM, Festigkeit, Thermik)' },
{ value: 'test_report', label: 'Pruefbericht', description: 'Dokumentierter Test mit Messprotokoll' },
{ value: 'validation', label: 'Validierung', description: 'Nachweis der Eignung unter realen Betriebsbedingungen' },
{ value: 'electrical_test', label: 'Elektrische Pruefung', description: 'Isolationsmessung, Schutzleiter, Spannungsfestigkeit' },
{ value: 'software_test', label: 'Software-Test', description: 'Unit-, Integrations- oder Systemtest der Steuerungssoftware' },
{ value: 'penetration_test', label: 'Penetrationstest', description: 'Security-Test der Netzwerk- und Steuerungskomponenten' },
{ value: 'acceptance_protocol', label: 'Abnahmeprotokoll', description: 'Formelle Abnahme mit Checkliste und Unterschrift' },
{ value: 'user_test', label: 'Anwendertest', description: 'Pruefung durch Bediener unter realen Einsatzbedingungen' },
{ value: 'documentation_release', label: 'Dokumentenfreigabe', description: 'Formelle Freigabe der technischen Dokumentation' },
]
export const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-700' },
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700' },
}

View File

@@ -2,27 +2,11 @@
import React, { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import type { VerificationItem, VerificationFormData } from './_components/verification-types'
import { VerificationForm } from './_components/VerificationForm'
import { CompleteModal } from './_components/CompleteModal'
import { SuggestEvidenceModal } from './_components/SuggestEvidenceModal'
import { VerificationTable } from './_components/VerificationTable'
import type { VerificationFormData } from './_components/VerificationForm'
interface VerificationItem {
id: string
title: string
description: string
method: string
status: 'pending' | 'in_progress' | 'completed' | 'failed'
result: string | null
linked_hazard_id: string | null
linked_hazard_name: string | null
linked_mitigation_id: string | null
linked_mitigation_name: string | null
completed_at: string | null
completed_by: string | null
created_at: string
}
export default function VerificationPage() {
const params = useParams()
@@ -44,9 +28,9 @@ export default function VerificationPage() {
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
])
if (verRes.ok) { const json = await verRes.json(); setItems(json.verifications || json || []) }
if (hazRes.ok) { const json = await hazRes.json(); setHazards((json.hazards || json || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name }))) }
if (mitRes.ok) { const json = await mitRes.json(); setMitigations((json.mitigations || json || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title }))) }
if (verRes.ok) { const j = await verRes.json(); setItems(j.verifications || j || []) }
if (hazRes.ok) { const j = await hazRes.json(); setHazards((j.hazards || j || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name }))) }
if (mitRes.ok) { const j = await mitRes.json(); setMitigations((j.mitigations || j || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title }))) }
} catch (err) { console.error('Failed to fetch data:', err) }
finally { setLoading(false) }
}
@@ -87,41 +71,33 @@ export default function VerificationPage() {
} catch (err) { console.error('Failed to delete verification:', err) }
}
const completed = items.filter((i) => i.status === 'completed').length
const failed = items.filter((i) => i.status === 'failed').length
const pending = items.filter((i) => i.status === 'pending' || i.status === 'in_progress').length
const completed = items.filter(i => i.status === 'completed').length
const failed = items.filter(i => i.status === 'failed').length
const pending = items.filter(i => i.status === 'pending' || i.status === 'in_progress').length
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
)
}
if (loading) return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikationsplan</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.</p>
</div>
<div className="flex items-center gap-2">
{mitigations.length > 0 && (
<button onClick={() => setShowSuggest(true)}
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm"
>
<button onClick={() => setShowSuggest(true)} className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
Nachweise vorschlagen
</button>
)}
<button onClick={() => 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"
>
<button onClick={() => 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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
@@ -151,45 +127,35 @@ export default function VerificationPage() {
</div>
)}
{showForm && (
<VerificationForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} hazards={hazards} mitigations={mitigations} />
)}
{completingItem && (
<CompleteModal item={completingItem} onSubmit={handleComplete} onClose={() => setCompletingItem(null)} />
)}
{showSuggest && (
<SuggestEvidenceModal mitigations={mitigations} projectId={projectId} onAddEvidence={handleAddSuggestedEvidence} onClose={() => setShowSuggest(false)} />
)}
{showForm && <VerificationForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} hazards={hazards} mitigations={mitigations} />}
{completingItem && <CompleteModal item={completingItem} onSubmit={handleComplete} onClose={() => setCompletingItem(null)} />}
{showSuggest && <SuggestEvidenceModal mitigations={mitigations} projectId={projectId} onAddEvidence={handleAddSuggestedEvidence} onClose={() => setShowSuggest(false)} />}
{items.length > 0 ? (
<VerificationTable items={items} onComplete={setCompletingItem} onDelete={handleDelete} />
) : (
!showForm && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Kein Verifikationsplan vorhanden</h3>
<p className="mt-2 text-gray-500 max-w-md mx-auto">
Definieren Sie Verifikationsschritte fuer Ihre Schutzmassnahmen.
Jede Massnahme sollte durch mindestens eine Verifikation abgedeckt sein.
</p>
<div className="mt-6 flex items-center justify-center gap-3">
{mitigations.length > 0 && (
<button onClick={() => setShowSuggest(true)} className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors">
Nachweise vorschlagen
</button>
)}
<button onClick={() => setShowForm(true)} className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
Erste Verifikation anlegen
</button>
</div>
) : !showForm && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
)
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Kein Verifikationsplan vorhanden</h3>
<p className="mt-2 text-gray-500 max-w-md mx-auto">
Definieren Sie Verifikationsschritte fuer Ihre Schutzmassnahmen.
Jede Massnahme sollte durch mindestens eine Verifikation abgedeckt sein.
</p>
<div className="mt-6 flex items-center justify-center gap-3">
{mitigations.length > 0 && (
<button onClick={() => setShowSuggest(true)} className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors">
Nachweise vorschlagen
</button>
)}
<button onClick={() => setShowForm(true)} className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
Erste Verifikation anlegen
</button>
</div>
</div>
)}
</div>
)