Files
breakpilot-compliance/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx
Benjamin Admin 9c1355c05f
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 34s
CI/CD / test-python-backend-compliance (push) Successful in 33s
CI/CD / test-python-document-crawler (push) Successful in 23s
CI/CD / test-python-dsms-gateway (push) Successful in 19s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 2s
feat(iace): Phase 5+6 — frontend integration, RAG library search, comprehensive tests
Phase 5 — Frontend Integration:
- components/page.tsx: ComponentLibraryModal with 120 components + 20 energy sources
- hazards/page.tsx: AutoSuggestPanel with 3-column pattern matching review
- mitigations/page.tsx: SuggestMeasuresModal per hazard with 3-level grouping
- verification/page.tsx: SuggestEvidenceModal per mitigation with evidence types

Phase 6 — RAG Library Search:
- Added bp_iace_libraries to AllowedCollections whitelist in rag_handlers.go
- SearchLibrary endpoint: POST /iace/library-search (semantic search across libraries)
- EnrichTechFileSection endpoint: POST /projects/:id/tech-file/:section/enrich
- Created ingest-iace-libraries.sh ingestion script for Qdrant collection

Tests (123 passing):
- tag_taxonomy_test.go: 8 tests for taxonomy entries, domains, essential tags
- controls_library_test.go: 7 tests for measures, reduction types, subtypes
- integration_test.go: 7 integration tests for full match flow and library consistency
- Extended tag_resolver_test.go: 9 new tests for FindByTags and cross-category resolution

Documentation:
- Updated iace.md with Hazard-Matching-Engine, RAG enrichment, and new DB tables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:22:49 +01:00

1329 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import React, { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
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
}
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[]
}
interface LifecyclePhase {
id: string
label_de: string
label_en: string
sort_order: number
}
interface RoleInfo {
id: string
label_de: string
label_en: string
sort_order: number
}
// Pattern matching types (Phase 5)
interface PatternMatch {
pattern_id: string
pattern_name: string
priority: number
matched_tags: string[]
}
interface HazardSuggestion {
category: string
source_patterns: string[]
confidence: number
}
interface MeasureSuggestion {
measure_id: string
source_patterns: string[]
}
interface EvidenceSuggestion {
evidence_id: string
source_patterns: string[]
}
interface MatchOutput {
matched_patterns: PatternMatch[]
suggested_hazards: HazardSuggestion[]
suggested_measures: MeasureSuggestion[]
suggested_evidence: EvidenceSuggestion[]
resolved_tags: string[]
}
// ISO 12100 Hazard Categories (A-J)
const HAZARD_CATEGORIES = [
'mechanical', 'electrical', 'thermal',
'pneumatic_hydraulic', 'noise_vibration', 'ergonomic',
'material_environmental', 'software_control', 'cyber_network',
'ai_specific',
]
const CATEGORY_LABELS: Record<string, string> = {
// Primary categories (new naming)
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 for existing data)
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',
}
const STATUS_LABELS: Record<string, string> = {
identified: 'Identifiziert',
assessed: 'Bewertet',
mitigated: 'Gemindert',
accepted: 'Akzeptiert',
closed: 'Geschlossen',
}
const REVIEW_STATUS_LABELS: Record<string, string> = {
draft: 'Entwurf',
in_review: 'In Pruefung',
reviewed: 'Geprueft',
approved: 'Freigegeben',
rejected: 'Abgelehnt',
}
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'
}
}
// ISO 12100 mode risk levels (S*F*P*A, max 625)
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'
}
// Legacy mode (S*E*P, max 125)
function getRiskLevelLegacy(r: number): string {
if (r >= 100) return 'critical'
if (r >= 50) return 'high'
if (r >= 20) return 'medium'
return 'low'
}
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
}
}
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>
)
}
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>
)
}
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
}
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)
// ISO 12100 mode: S * F * P * A when avoidance is set
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>
{/* Lifecycle Phase */}
<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>
{/* Extended fields toggle */}
<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">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ausloeseereignis</label>
<input
type="text"
value={formData.trigger_event}
onChange={(e) => setFormData({ ...formData, trigger_event: e.target.value })}
placeholder="z.B. Schutztuer offen bei Betrieb"
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">Moeglicher Schaden</label>
<input
type="text"
value={formData.possible_harm}
onChange={(e) => setFormData({ ...formData, possible_harm: e.target.value })}
placeholder="z.B. Schwere Quetschverletzung"
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">Gefahrenzone</label>
<input
type="text"
value={formData.hazardous_zone}
onChange={(e) => setFormData({ ...formData, hazardous_zone: e.target.value })}
placeholder="z.B. Roboter-Arbeitsbereich"
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">Maschinenmodul</label>
<input
type="text"
value={formData.machine_module}
onChange={(e) => setFormData({ ...formData, machine_module: e.target.value })}
placeholder="z.B. Antriebseinheit"
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>
)}
{/* S/F/P/A Sliders */}
<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">
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Schwere (S): <span className="font-bold">{formData.severity}</span>
</label>
<input
type="range" min={1} max={5} value={formData.severity}
onChange={(e) => setFormData({ ...formData, severity: Number(e.target.value) })}
className="w-full accent-purple-600"
/>
<div className="flex justify-between text-xs text-gray-400">
<span>Gering</span><span>Toedlich</span>
</div>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Haeufigkeit (F): <span className="font-bold">{formData.exposure}</span>
</label>
<input
type="range" min={1} max={5} value={formData.exposure}
onChange={(e) => setFormData({ ...formData, exposure: Number(e.target.value) })}
className="w-full accent-purple-600"
/>
<div className="flex justify-between text-xs text-gray-400">
<span>Selten</span><span>Staendig</span>
</div>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Wahrscheinlichkeit (P): <span className="font-bold">{formData.probability}</span>
</label>
<input
type="range" min={1} max={5} value={formData.probability}
onChange={(e) => setFormData({ ...formData, probability: Number(e.target.value) })}
className="w-full accent-purple-600"
/>
<div className="flex justify-between text-xs text-gray-400">
<span>Unwahrscheinlich</span><span>Sehr wahrscheinlich</span>
</div>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Vermeidbarkeit (A): <span className="font-bold">{formData.avoidance}</span>
</label>
<input
type="range" min={1} max={5} value={formData.avoidance}
onChange={(e) => setFormData({ ...formData, avoidance: Number(e.target.value) })}
className="w-full accent-purple-600"
/>
<div className="flex justify-between text-xs text-gray-400">
<span>Leicht</span><span>Unmoeglich</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>
)
}
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>
)
}
// ============================================================================
// Auto-Suggest Panel (Phase 5 — Pattern Matching)
// ============================================================================
function AutoSuggestPanel({
projectId,
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 toggleHazard(cat: string) {
setSelectedHazards(prev => {
const next = new Set(prev)
if (next.has(cat)) next.delete(cat)
else next.add(cat)
return next
})
}
function toggleMeasure(id: string) {
setSelectedMeasures(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleEvidence(id: string) {
setSelectedEvidence(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return 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>
{/* Matched Patterns */}
<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>
{/* 3-Column Review */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Hazards */}
<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={() => toggleHazard(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>
{/* Measures */}
<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={() => toggleMeasure(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>
{/* Evidence */}
<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={() => toggleEvidence(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>
{/* Tags */}
{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>
)}
{/* Actions */}
<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>
)
}
// ============================================================================
// Main Page
// ============================================================================
export default function HazardsPage() {
const params = useParams()
const projectId = params.projectId as 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)
// Pattern matching state (Phase 5)
const [matchingPatterns, setMatchingPatterns] = useState(false)
const [matchResult, setMatchResult] = useState<MatchOutput | null>(null)
const [applyingPatterns, setApplyingPatterns] = useState(false)
useEffect(() => {
fetchHazards()
fetchLifecyclePhases()
fetchRoles()
}, [projectId])
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)
}
}
// Pattern matching (Phase 5)
async function handlePatternMatching() {
setMatchingPatterns(true)
setMatchResult(null)
try {
// First, fetch component library IDs and energy source IDs from project components
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) {
const result: MatchOutput = await res.json()
setMatchResult(result)
}
} 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 {
// Build the accepted hazard requests from categories
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)
}
}
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">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Hazard Log</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Gefaehrdungsanalyse mit 4-Faktor-Risikobewertung (S x F x P x A).
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handlePatternMatching}
disabled={matchingPatterns}
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 disabled:opacity-50 text-sm"
>
{matchingPatterns ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600" />
) : (
<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>
)}
Auto-Erkennung
</button>
<button
onClick={handleAISuggestions}
disabled={suggestingAI}
className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors disabled:opacity-50 text-sm"
>
{suggestingAI ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-600" />
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
)}
KI-Vorschlaege
</button>
<button
onClick={fetchLibrary}
className="flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Aus Bibliothek
</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 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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Manuell hinzufuegen
</button>
</div>
</div>
{/* Pattern Match Results (Phase 5) */}
{matchResult && matchResult.matched_patterns.length > 0 && (
<AutoSuggestPanel
projectId={projectId}
matchResult={matchResult}
applying={applyingPatterns}
onApply={handleApplyPatterns}
onClose={() => setMatchResult(null)}
/>
)}
{/* No patterns matched info */}
{matchResult && matchResult.matched_patterns.length === 0 && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 flex items-start gap-3">
<svg className="w-5 h-5 text-yellow-600 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="text-sm text-yellow-800">
Keine Patterns erkannt. Fuegen Sie zuerst Komponenten aus der Bibliothek hinzu,
damit die automatische Erkennung Gefaehrdungen ableiten kann.
</p>
<button onClick={() => setMatchResult(null)} className="text-xs text-yellow-600 hover:text-yellow-700 mt-1">
Schliessen
</button>
</div>
</div>
)}
{/* Stats */}
{hazards.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-7 gap-3">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
<div className="text-2xl font-bold text-gray-900 dark:text-white">{hazards.length}</div>
<div className="text-xs text-gray-500">Gesamt</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-red-300 p-4 text-center">
<div className="text-2xl font-bold text-red-800">{hazards.filter((h) => h.risk_level === 'not_acceptable').length}</div>
<div className="text-xs text-red-800">Nicht akzeptabel</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-red-200 p-4 text-center">
<div className="text-2xl font-bold text-red-600">{hazards.filter((h) => h.risk_level === 'very_high' || h.risk_level === 'critical').length}</div>
<div className="text-xs text-red-600">Sehr hoch/Kritisch</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-orange-200 p-4 text-center">
<div className="text-2xl font-bold text-orange-600">{hazards.filter((h) => h.risk_level === 'high').length}</div>
<div className="text-xs text-orange-600">Hoch</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-yellow-200 p-4 text-center">
<div className="text-2xl font-bold text-yellow-600">{hazards.filter((h) => h.risk_level === 'medium').length}</div>
<div className="text-xs text-yellow-600">Mittel</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-green-200 p-4 text-center">
<div className="text-2xl font-bold text-green-600">{hazards.filter((h) => h.risk_level === 'low').length}</div>
<div className="text-xs text-green-600">Niedrig</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 p-4 text-center">
<div className="text-2xl font-bold text-gray-500">{hazards.filter((h) => h.risk_level === 'negligible').length}</div>
<div className="text-xs text-gray-500">Vernachlaessigbar</div>
</div>
</div>
)}
{/* Form */}
{showForm && (
<HazardForm
onSubmit={handleSubmit}
onCancel={() => setShowForm(false)}
lifecyclePhases={lifecyclePhases}
roles={roles}
/>
)}
{/* Library Modal */}
{showLibrary && (
<LibraryModal
library={library}
onAdd={handleAddFromLibrary}
onClose={() => setShowLibrary(false)}
/>
)}
{/* Hazard Table */}
{hazards.length > 0 ? (
<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={() => handleDelete(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>
) : (
!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-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Kein Hazard Log vorhanden</h3>
<p className="mt-2 text-gray-500 max-w-md mx-auto">
Beginnen Sie mit der systematischen Erfassung von Gefaehrdungen. Nutzen Sie die Bibliothek,
KI-Vorschlaege oder die automatische Erkennung als Ausgangspunkt.
</p>
<div className="mt-6 flex items-center justify-center gap-3">
<button
onClick={handlePatternMatching}
disabled={matchingPatterns}
className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors disabled:opacity-50"
>
{matchingPatterns ? 'Erkennung laeuft...' : 'Auto-Erkennung starten'}
</button>
<button
onClick={() => setShowForm(true)}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Manuell hinzufuegen
</button>
<button
onClick={fetchLibrary}
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Bibliothek oeffnen
</button>
</div>
</div>
)
)}
</div>
)
}