All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
Kaputtes (admin) Layout geloescht (Role-Selection, 404-Sidebar, localhost-Dashboard). SDK-Flow nach /sdk/sdk-flow verschoben. Route-Gruppe (sdk) aufgeloest. Root-Seite redirected auf /sdk. ~25 ungenutzte Dateien/Verzeichnisse entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
629 lines
26 KiB
TypeScript
629 lines
26 KiB
TypeScript
'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
|
|
status: string
|
|
severity: number
|
|
exposure: number
|
|
probability: number
|
|
r_inherent: number
|
|
risk_level: string
|
|
created_at: string
|
|
}
|
|
|
|
interface LibraryHazard {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
category: string
|
|
default_severity: number
|
|
default_exposure: number
|
|
default_probability: number
|
|
}
|
|
|
|
const HAZARD_CATEGORIES = [
|
|
'mechanical', 'electrical', 'thermal', 'noise', 'vibration',
|
|
'radiation', 'material', 'ergonomic', 'software', 'ai_specific',
|
|
'cybersecurity', 'functional_safety', 'environmental',
|
|
]
|
|
|
|
const CATEGORY_LABELS: Record<string, string> = {
|
|
mechanical: 'Mechanisch',
|
|
electrical: 'Elektrisch',
|
|
thermal: 'Thermisch',
|
|
noise: 'Laerm',
|
|
vibration: 'Vibration',
|
|
radiation: 'Strahlung',
|
|
material: 'Stoffe/Materialien',
|
|
ergonomic: 'Ergonomie',
|
|
software: 'Software',
|
|
ai_specific: 'KI-spezifisch',
|
|
cybersecurity: 'Cybersecurity',
|
|
functional_safety: 'Funktionale Sicherheit',
|
|
environmental: 'Umgebung',
|
|
}
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
identified: 'Identifiziert',
|
|
assessed: 'Bewertet',
|
|
mitigated: 'Gemindert',
|
|
accepted: 'Akzeptiert',
|
|
closed: 'Geschlossen',
|
|
}
|
|
|
|
function getRiskColor(level: string): string {
|
|
switch (level) {
|
|
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'
|
|
}
|
|
}
|
|
|
|
function getRiskLevel(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 '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>
|
|
)
|
|
}
|
|
|
|
interface HazardFormData {
|
|
name: string
|
|
description: string
|
|
category: string
|
|
component_id: string
|
|
severity: number
|
|
exposure: number
|
|
probability: number
|
|
}
|
|
|
|
function HazardForm({
|
|
onSubmit,
|
|
onCancel,
|
|
}: {
|
|
onSubmit: (data: HazardFormData) => void
|
|
onCancel: () => void
|
|
}) {
|
|
const [formData, setFormData] = useState<HazardFormData>({
|
|
name: '',
|
|
description: '',
|
|
category: 'mechanical',
|
|
component_id: '',
|
|
severity: 3,
|
|
exposure: 3,
|
|
probability: 3,
|
|
})
|
|
|
|
const rInherent = formData.severity * formData.exposure * formData.probability
|
|
const riskLevel = getRiskLevel(rInherent)
|
|
|
|
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>
|
|
|
|
{/* S/E/P 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 (S x E x P)</h4>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 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">
|
|
Exposition (E): <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>
|
|
|
|
<div className={`mt-4 p-3 rounded-lg border ${getRiskColor(riskLevel)}`}>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">R_inherent = S x E x P</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 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-2xl max-h-[80vh] flex flex-col">
|
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Gefaehrdungsbibliothek</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="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750"
|
|
>
|
|
<div className="flex-1 min-w-0 mr-3">
|
|
<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} E:{item.default_exposure} P:{item.default_probability}</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>
|
|
))
|
|
) : (
|
|
<div className="text-center py-8 text-gray-500">Keine Eintraege gefunden</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function HazardsPage() {
|
|
const params = useParams()
|
|
const projectId = params.projectId as string
|
|
const [hazards, setHazards] = useState<Hazard[]>([])
|
|
const [library, setLibrary] = useState<LibraryHazard[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showForm, setShowForm] = useState(false)
|
|
const [showLibrary, setShowLibrary] = useState(false)
|
|
const [suggestingAI, setSuggestingAI] = useState(false)
|
|
|
|
useEffect(() => {
|
|
fetchHazards()
|
|
}, [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 fetchLibrary() {
|
|
try {
|
|
const res = await fetch('/api/sdk/v1/iace/hazard-library')
|
|
if (res.ok) {
|
|
const json = await res.json()
|
|
setLibrary(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,
|
|
severity: item.default_severity,
|
|
exposure: item.default_exposure,
|
|
probability: item.default_probability,
|
|
}),
|
|
})
|
|
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 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 Risikobewertung nach S x E x P Methode.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<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>
|
|
|
|
{/* Stats */}
|
|
{hazards.length > 0 && (
|
|
<div className="grid grid-cols-2 md:grid-cols-5 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-200 p-4 text-center">
|
|
<div className="text-2xl font-bold text-red-600">{hazards.filter((h) => h.risk_level === 'critical').length}</div>
|
|
<div className="text-xs text-red-600">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>
|
|
)}
|
|
|
|
{/* Form */}
|
|
{showForm && (
|
|
<HazardForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} />
|
|
)}
|
|
|
|
{/* 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-left text-xs font-medium text-gray-500 uppercase tracking-wider">Komponente</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">E</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">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">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 - a.r_inherent)
|
|
.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="text-sm font-medium text-gray-900 dark:text-white">{hazard.name}</div>
|
|
{hazard.description && (
|
|
<div className="text-xs text-gray-500 truncate max-w-[250px]">{hazard.description}</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-600">{hazard.component_name || '--'}</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-bold">{hazard.r_inherent}</td>
|
|
<td className="px-4 py-3"><RiskBadge level={hazard.risk_level} /></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
|
|
oder KI-Vorschlaege als Ausgangspunkt.
|
|
</p>
|
|
<div className="mt-6 flex items-center justify-center gap-3">
|
|
<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>
|
|
)
|
|
}
|