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
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:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
export function ComponentTypeIcon({ type }: { type: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
SW: 'bg-blue-100 text-blue-700',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
planned: 'bg-gray-100 text-gray-700',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface Mitigation {
|
||||
id: string
|
||||
title: string
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user