feat(iace): integrate ISO 12100 machine risk model with 4-factor assessment
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 36s
CI/CD / test-python-backend-compliance (push) Successful in 36s
CI/CD / test-python-document-crawler (push) Successful in 22s
CI/CD / test-python-dsms-gateway (push) Successful in 18s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Successful in 2s

Add dual-mode risk engine: legacy S×E×P (avoidance=0) and ISO mode S×F×P×A
(avoidance>=1) with new thresholds (low/medium/high/very_high/not_acceptable).

- 150+ hazard library entries across 28 categories incl. physical hazards
  (mechanical, electrical, thermal, pneumatic/hydraulic, noise/vibration,
  ergonomic, material/environmental)
- 160-entry protective measures library with 3-step hierarchy validation
  (design → protective → information)
- 25 lifecycle phases, 20 affected person roles, 50 evidence types
- 10 verification methods (expanded from 7)
- New API endpoints: lifecycle-phases, roles, evidence-types,
  protective-measures-library, validate-mitigation-hierarchy
- DB migrations 018+019 for extended schema
- Frontend: 4-slider risk assessment, hierarchy warnings, measures library modal
- MkDocs wiki updated with ISO mode docs and legal notice (no norm text)

All content uses original wording — norms referenced as methodology only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-15 23:13:41 +01:00
parent c8fd9cc780
commit c7651796c9
15 changed files with 3708 additions and 479 deletions

View File

@@ -10,12 +10,21 @@ interface Hazard {
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
}
@@ -24,31 +33,71 @@ interface LibraryHazard {
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
}
// ISO 12100 Hazard Categories (A-J)
const HAZARD_CATEGORIES = [
'mechanical', 'electrical', 'thermal', 'noise', 'vibration',
'radiation', 'material', 'ergonomic', 'software', 'ai_specific',
'cybersecurity', 'functional_safety', 'environmental',
'mechanical', 'electrical', 'thermal',
'pneumatic_hydraulic', 'noise_vibration', 'ergonomic',
'material_environmental', 'software_control', 'cyber_network',
'ai_specific',
]
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',
// 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> = {
@@ -59,8 +108,18 @@ const STATUS_LABELS: Record<string, string> = {
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'
@@ -69,7 +128,17 @@ function getRiskColor(level: string): string {
}
}
function getRiskLevel(r: number): string {
// 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'
@@ -78,6 +147,8 @@ function getRiskLevel(r: number): string {
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'
@@ -94,6 +165,21 @@ function RiskBadge({ level }: { level: string }) {
)
}
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
@@ -102,27 +188,52 @@ interface HazardFormData {
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',
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 rInherent = formData.severity * formData.exposure * formData.probability
const riskLevel = getRiskLevel(rInherent)
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">
@@ -164,42 +275,120 @@ function HazardForm({
/>
</div>
{/* S/E/P Sliders */}
{/* 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 (S x E x P)</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<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}
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>
<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>
Haeufigkeit (F): <span className="font-bold">{formData.exposure}</span>
</label>
<input
type="range"
min={1}
max={5}
value={formData.exposure}
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>
<span>Selten</span><span>Staendig</span>
</div>
</div>
<div>
@@ -207,23 +396,32 @@ function HazardForm({
Wahrscheinlichkeit (P): <span className="font-bold">{formData.probability}</span>
</label>
<input
type="range"
min={1}
max={5}
value={formData.probability}
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>
<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">R_inherent = S x E x P</span>
<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} />
@@ -264,6 +462,7 @@ function LibraryModal({
}) {
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())
@@ -273,10 +472,10 @@ function LibraryModal({
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="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</h3>
<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" />
@@ -306,24 +505,62 @@ function LibraryModal({
<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 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>
<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>
{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>
))
) : (
@@ -340,6 +577,8 @@ export default function HazardsPage() {
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)
@@ -347,6 +586,8 @@ export default function HazardsPage() {
useEffect(() => {
fetchHazards()
fetchLifecyclePhases()
fetchRoles()
}, [projectId])
async function fetchHazards() {
@@ -363,12 +604,36 @@ export default function HazardsPage() {
}
}
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.hazards || json || [])
setLibrary(json.hazard_library || json.hazards || json || [])
}
} catch (err) {
console.error('Failed to fetch hazard library:', err)
@@ -385,9 +650,11 @@ export default function HazardsPage() {
name: item.name,
description: item.description,
category: item.category,
sub_category: item.sub_category || '',
severity: item.default_severity,
exposure: item.default_exposure,
exposure: item.default_exposure || 3,
probability: item.default_probability,
avoidance: item.default_avoidance || 3,
}),
})
if (res.ok) {
@@ -458,7 +725,7 @@ export default function HazardsPage() {
<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.
Gefaehrdungsanalyse mit 4-Faktor-Risikobewertung (S x F x P x A).
</p>
</div>
<div className="flex items-center gap-2">
@@ -499,14 +766,18 @@ export default function HazardsPage() {
{/* Stats */}
{hazards.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<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 === 'critical').length}</div>
<div className="text-xs text-red-600">Kritisch</div>
<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>
@@ -520,12 +791,21 @@ export default function HazardsPage() {
<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)} />
<HazardForm
onSubmit={handleSubmit}
onCancel={() => setShowForm(false)}
lifecyclePhases={lifecyclePhases}
roles={roles}
/>
)}
{/* Library Modal */}
@@ -546,19 +826,20 @@ export default function HazardsPage() {
<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">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 - a.r_inherent)
.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">
@@ -566,14 +847,20 @@ export default function HazardsPage() {
{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-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-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>

View File

@@ -20,14 +20,33 @@ interface Hazard {
id: string
name: string
risk_level: string
category?: string
}
interface ProtectiveMeasure {
id: string
reduction_type: string
sub_type: string
name: string
description: string
hazard_category: string
examples: string[]
}
const REDUCTION_TYPES = {
design: {
label: 'Design',
label: 'Stufe 1: Design',
description: 'Inhaerent sichere Konstruktion',
color: 'border-blue-200 bg-blue-50',
headerColor: 'bg-blue-100 text-blue-800',
subTypes: [
{ value: 'geometry', label: 'Geometrie & Anordnung' },
{ value: 'force_energy', label: 'Kraft & Energie' },
{ value: 'material', label: 'Material & Stabilitaet' },
{ value: 'ergonomics', label: 'Ergonomie' },
{ value: 'control_design', label: 'Steuerungstechnik' },
{ value: 'fluid_design', label: 'Pneumatik / Hydraulik' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
@@ -35,10 +54,21 @@ const REDUCTION_TYPES = {
),
},
protection: {
label: 'Schutz',
label: 'Stufe 2: Schutz',
description: 'Technische Schutzmassnahmen',
color: 'border-green-200 bg-green-50',
headerColor: 'bg-green-100 text-green-800',
subTypes: [
{ value: 'fixed_guard', label: 'Feststehende Schutzeinrichtung' },
{ value: 'movable_guard', label: 'Bewegliche Schutzeinrichtung' },
{ value: 'electro_sensitive', label: 'Optoelektronisch' },
{ value: 'pressure_sensitive', label: 'Druckempfindlich' },
{ value: 'emergency_stop', label: 'Not-Halt' },
{ value: 'electrical_protection', label: 'Elektrischer Schutz' },
{ value: 'thermal_protection', label: 'Thermischer Schutz' },
{ value: 'fluid_protection', label: 'Hydraulik/Pneumatik-Schutz' },
{ value: 'extraction', label: 'Absaugung / Kapselung' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
@@ -46,10 +76,18 @@ const REDUCTION_TYPES = {
),
},
information: {
label: 'Information',
label: 'Stufe 3: Information',
description: 'Hinweise und Schulungen',
color: 'border-yellow-200 bg-yellow-50',
headerColor: 'bg-yellow-100 text-yellow-800',
subTypes: [
{ value: 'signage', label: 'Beschilderung & Kennzeichnung' },
{ value: 'manual', label: 'Betriebsanleitung' },
{ value: 'training', label: 'Schulung & Unterweisung' },
{ value: 'ppe', label: 'PSA (Schutzausruestung)' },
{ value: 'organizational', label: 'Organisatorisch' },
{ value: 'marking', label: 'Markierung & Codierung' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -76,6 +114,132 @@ function StatusBadge({ status }: { status: string }) {
)
}
function HierarchyWarning({ onDismiss }: { onDismiss: () => void }) {
return (
<div className="bg-amber-50 border border-amber-300 rounded-xl p-4 flex items-start gap-3">
<svg className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div className="flex-1">
<h4 className="text-sm font-semibold text-amber-800">Hierarchie-Warnung: Massnahmen vom Typ &quot;Information&quot;</h4>
<p className="text-sm text-amber-700 mt-1">
Hinweismassnahmen (Stufe 3) duerfen <strong>nicht als Primaermassnahme</strong> akzeptiert werden, wenn konstruktive
(Stufe 1) oder technische (Stufe 2) Massnahmen moeglich und zumutbar sind. Pruefen Sie, ob hoeherwertige
Massnahmen ergaenzt werden koennen.
</p>
</div>
<button onClick={onDismiss} className="text-amber-400 hover:text-amber-600 transition-colors">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)
}
function MeasuresLibraryModal({
measures,
onSelect,
onClose,
filterType,
}: {
measures: ProtectiveMeasure[]
onSelect: (measure: ProtectiveMeasure) => void
onClose: () => void
filterType?: string
}) {
const [search, setSearch] = useState('')
const [selectedSubType, setSelectedSubType] = useState('')
const filtered = measures.filter((m) => {
if (filterType && m.reduction_type !== filterType) return false
if (selectedSubType && m.sub_type !== selectedSubType) return false
if (search) {
const q = search.toLowerCase()
return m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q)
}
return true
})
const subTypes = [...new Set(measures.filter((m) => !filterType || m.reduction_type === filterType).map((m) => m.sub_type))].filter(Boolean)
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[80vh] flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Bibliothek</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex gap-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Massnahme suchen..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
{subTypes.length > 1 && (
<select
value={selectedSubType}
onChange={(e) => setSelectedSubType(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
>
<option value="">Alle Sub-Typen</option>
{subTypes.map((st) => (
<option key={st} value={st}>{st}</option>
))}
</select>
)}
</div>
<div className="mt-2 text-xs text-gray-500">{filtered.length} Massnahmen</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-3">
{filtered.map((m) => (
<div
key={m.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50/30 transition-colors cursor-pointer"
onClick={() => onSelect(m)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.id}</span>
{m.sub_type && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
)}
</div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</h4>
<p className="text-xs text-gray-500 mt-1">{m.description}</p>
{m.examples && m.examples.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{m.examples.map((ex, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-600">
{ex}
</span>
))}
</div>
)}
</div>
<button className="ml-3 px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors flex-shrink-0">
Uebernehmen
</button>
</div>
</div>
))}
{filtered.length === 0 && (
<div className="text-center py-8 text-gray-500">Keine Massnahmen gefunden</div>
)}
</div>
</div>
</div>
)
}
interface MitigationFormData {
title: string
description: string
@@ -88,11 +252,13 @@ function MitigationForm({
onCancel,
hazards,
preselectedType,
onOpenLibrary,
}: {
onSubmit: (data: MitigationFormData) => void
onCancel: () => void
hazards: Hazard[]
preselectedType?: 'design' | 'protection' | 'information'
onOpenLibrary: (type?: string) => void
}) {
const [formData, setFormData] = useState<MitigationFormData>({
title: '',
@@ -112,7 +278,15 @@ function MitigationForm({
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 Massnahme</h3>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Neue Massnahme</h3>
<button
onClick={() => onOpenLibrary(formData.reduction_type)}
className="text-sm px-3 py-1.5 bg-purple-50 text-purple-700 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
>
Aus Bibliothek waehlen
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -132,9 +306,9 @@ function MitigationForm({
onChange={(e) => setFormData({ ...formData, reduction_type: e.target.value as MitigationFormData['reduction_type'] })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="design">Design - Inhaerent sichere Konstruktion</option>
<option value="protection">Schutz - Technische Schutzmassnahmen</option>
<option value="information">Information - Hinweise und Schulungen</option>
<option value="design">Stufe 1: Design - Inhaerent sichere Konstruktion</option>
<option value="protection">Stufe 2: Schutz - Technische Schutzmassnahmen</option>
<option value="information">Stufe 3: Information - Hinweise und Schulungen</option>
</select>
</div>
</div>
@@ -246,6 +420,10 @@ export default function MitigationsPage() {
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[]>([])
useEffect(() => {
fetchData()
@@ -259,11 +437,14 @@ export default function MitigationsPage() {
])
if (mitRes.ok) {
const json = await mitRes.json()
setMitigations(json.mitigations || json || [])
const mits = json.mitigations || json || []
setMitigations(mits)
// Check hierarchy: if information-only measures exist without design/protection
validateHierarchy(mits)
}
if (hazRes.ok) {
const json = await hazRes.json()
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level })))
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category })))
}
} catch (err) {
console.error('Failed to fetch data:', err)
@@ -272,6 +453,56 @@ export default function MitigationsPage() {
}
}
async function validateHierarchy(mits: Mitigation[]) {
if (mits.length === 0) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/validate-mitigation-hierarchy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mitigations: mits.map((m) => ({
reduction_type: m.reduction_type,
linked_hazard_ids: m.linked_hazard_ids,
})),
}),
})
if (res.ok) {
const json = await res.json()
setHierarchyWarning(json.has_warning === true)
}
} catch {
// Non-critical, ignore
}
}
async function fetchMeasuresLibrary(type?: string) {
try {
const url = type
? `/api/sdk/v1/iace/protective-measures-library?reduction_type=${type}`
: '/api/sdk/v1/iace/protective-measures-library'
const res = await fetch(url)
if (res.ok) {
const json = await res.json()
setMeasures(json.protective_measures || [])
}
} catch (err) {
console.error('Failed to fetch measures library:', err)
}
}
function handleOpenLibrary(type?: string) {
setLibraryFilter(type)
fetchMeasuresLibrary(type)
setShowLibrary(true)
}
function handleSelectMeasure(measure: ProtectiveMeasure) {
setShowLibrary(false)
setShowForm(true)
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
// The form will be pre-filled with the type; user can edit title/description
}
async function handleSubmit(data: MitigationFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
@@ -341,23 +572,39 @@ export default function MitigationsPage() {
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Massnahmen</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Risikominderung nach dem 3-Stufen-Verfahren: Design, Schutz, Information.
Risikominderung nach dem 3-Stufen-Verfahren: Design &rarr; Schutz &rarr; Information.
</p>
</div>
<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>
Massnahme hinzufuegen
</button>
<div className="flex items-center gap-3">
<button
onClick={() => handleOpenLibrary()}
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 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>
Bibliothek
</button>
<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>
Massnahme hinzufuegen
</button>
</div>
</div>
{/* Hierarchy Warning */}
{hierarchyWarning && (
<HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />
)}
{/* Form */}
{showForm && (
<MitigationForm
@@ -368,6 +615,17 @@ export default function MitigationsPage() {
}}
hazards={hazards}
preselectedType={preselectedType}
onOpenLibrary={handleOpenLibrary}
/>
)}
{/* Measures Library Modal */}
{showLibrary && (
<MeasuresLibraryModal
measures={measures}
onSelect={handleSelectMeasure}
onClose={() => setShowLibrary(false)}
filterType={libraryFilter}
/>
)}
@@ -378,7 +636,7 @@ export default function MitigationsPage() {
const items = byType[type]
return (
<div key={type} className={`rounded-xl border ${config.color} p-4`}>
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-4`}>
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
{config.icon}
<div>
<h3 className="text-sm font-semibold">{config.label}</h3>
@@ -387,6 +645,15 @@ export default function MitigationsPage() {
<span className="ml-auto text-sm font-bold">{items.length}</span>
</div>
{/* Sub-types overview */}
<div className="mb-3 flex flex-wrap gap-1">
{config.subTypes.map((st) => (
<span key={st.value} className="text-xs px-1.5 py-0.5 rounded bg-white/60 text-gray-500 border border-gray-200/50">
{st.label}
</span>
))}
</div>
<div className="space-y-3">
{items.map((m) => (
<MitigationCard
@@ -398,12 +665,23 @@ export default function MitigationsPage() {
))}
</div>
<button
onClick={() => handleAddForType(type)}
className="mt-3 w-full py-2 text-sm text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
>
+ Massnahme hinzufuegen
</button>
<div className="mt-3 flex gap-2">
<button
onClick={() => handleAddForType(type)}
className="flex-1 py-2 text-sm text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
>
+ Hinzufuegen
</button>
<button
onClick={() => handleOpenLibrary(type)}
className="py-2 px-3 text-sm text-gray-400 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
title="Aus Bibliothek waehlen"
>
<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>
</button>
</div>
</div>
)
})}

View File

@@ -20,13 +20,16 @@ interface VerificationItem {
}
const VERIFICATION_METHODS = [
{ value: 'test', label: 'Test' },
{ value: 'analysis', label: 'Analyse' },
{ value: 'inspection', label: 'Inspektion' },
{ value: 'simulation', label: 'Simulation' },
{ value: 'review', label: 'Review' },
{ value: 'demonstration', label: 'Demonstration' },
{ value: 'certification', label: 'Zertifizierung' },
{ 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' },
]
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {