feat: IACE CE-Compliance Module — Normen, Risikobewertung, Production Lines

Major features:
- 215 norms library with section references + Beuth URLs (A/B1/B2/C norms)
- 173 hazard patterns with detail fields (scenario, trigger, harm, zone)
- Deterministic pattern matching: Component × Lifecycle × Pattern cross-product
- SIL/PL auto-calculation from S×E×P risk graph
- Risk assessment table with editable S/E/P dropdowns
- Production Line Dashboard with animated station flow (Running Dots)
- IACE process flow + norms coverage on start page
- Non-blocking cookie banner, ProcessFlow SSR fix
- 104 Playwright E2E tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-07 10:53:26 +02:00
parent 3853a0838a
commit e7f2f98da3
59 changed files with 8326 additions and 525 deletions
@@ -11,13 +11,13 @@ export function AutoSuggestPanel({ matchResult, applying, onApply, onClose }: {
onClose: () => void
}) {
const [selectedHazards, setSelectedHazards] = useState<Set<string>>(
new Set(matchResult.suggested_hazards.map(h => h.category))
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))
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))
new Set((matchResult.suggested_evidence || []).map(e => e.evidence_id))
)
function toggle<T>(set: Set<T>, setSet: (s: Set<T>) => void, key: T) {
@@ -0,0 +1,274 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import {
Hazard, CATEGORY_LABELS, getRiskColor, getRiskLevelLabel, getRiskLevelISO,
} from './types'
interface RiskAssessmentTableProps {
projectId: string
hazards: Hazard[]
onReassess?: () => void
}
/** Editable S/E/P/A state per hazard for the "after measures" column. */
interface EditState {
severity: number; exposure: number; probability: number; avoidance: number
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function rpz(s: number, e: number, p: number, a: number): number {
return a >= 1 ? s * e * p * a : s * e * p
}
function plFromRpz(r: number): string {
if (r > 300) return 'e'
if (r >= 151) return 'd'
if (r >= 61) return 'c'
if (r >= 21) return 'b'
return 'a'
}
function silFromRpz(r: number): number {
if (r > 300) return 3
if (r >= 151) return 2
if (r >= 61) return 1
return 0
}
const PL_COLORS: Record<string, string> = {
e: 'bg-red-100 text-red-800', d: 'bg-orange-100 text-orange-800',
c: 'bg-yellow-100 text-yellow-800', b: 'bg-green-100 text-green-800',
a: 'bg-gray-100 text-gray-600',
}
const SIL_COLORS: Record<number, string> = {
3: 'bg-red-100 text-red-800', 2: 'bg-orange-100 text-orange-800',
1: 'bg-yellow-100 text-yellow-800', 0: 'bg-gray-100 text-gray-600',
}
const VALUES = [1, 2, 3, 4, 5]
// ---------------------------------------------------------------------------
// Inline editable dropdown
// ---------------------------------------------------------------------------
function InlineSelect({ value, onChange, label }: {
value: number; onChange: (v: number) => void; label: string
}) {
return (
<select value={value} onChange={e => onChange(Number(e.target.value))}
aria-label={label}
className="w-12 text-center text-xs border border-gray-300 rounded bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-white py-0.5 focus:ring-1 focus:ring-purple-400 focus:border-purple-400">
{VALUES.map(v => <option key={v} value={v}>{v}</option>)}
</select>
)
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function RiskAssessmentTable({ projectId, hazards, onReassess }: RiskAssessmentTableProps) {
const [mitCounts, setMitCounts] = useState<Record<string, number>>({})
const [edits, setEdits] = useState<Record<string, EditState>>({})
const [saving, setSaving] = useState<string | null>(null)
// Fetch mitigation counts per hazard
useEffect(() => {
(async () => {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`)
if (!res.ok) return
const json = await res.json()
const mits: { hazard_id: string }[] = json.mitigations || json || []
const counts: Record<string, number> = {}
for (const m of mits) {
counts[m.hazard_id] = (counts[m.hazard_id] || 0) + 1
}
setMitCounts(counts)
} catch { /* ignore */ }
})()
}, [projectId])
// Initialise edit state from hazard defaults
useEffect(() => {
const init: Record<string, EditState> = {}
for (const h of hazards) {
if (!edits[h.id]) {
// Read from risk_assessment if available (enriched response), fallback to hazard fields
const ra = (h as Record<string, unknown>).risk_assessment as Record<string, number> | null
init[h.id] = {
severity: ra?.severity || h.severity || 3,
exposure: ra?.exposure || h.exposure || 3,
probability: ra?.probability || h.probability || 3,
avoidance: h.avoidance || 0,
}
}
}
if (Object.keys(init).length > 0) setEdits(prev => ({ ...prev, ...init }))
}, [hazards]) // eslint-disable-line react-hooks/exhaustive-deps
const updateEdit = useCallback((id: string, field: keyof EditState, value: number) => {
setEdits(prev => ({ ...prev, [id]: { ...prev[id], [field]: value } }))
}, [])
async function handleReassess(hazardId: string) {
const e = edits[hazardId]
if (!e) return
setSaving(hazardId)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/reassess`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hazard_id: hazardId, severity: e.severity, exposure: e.exposure,
probability: e.probability, avoidance: e.avoidance,
control_maturity: 3, control_coverage: 0.5, test_evidence_strength: 0.5,
}),
})
if (res.ok) onReassess?.()
} catch (err) { console.error('Reassess failed:', err) }
finally { setSaving(null) }
}
const sorted = [...hazards].sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Risikobewertungstabelle (ISO 12100)</h2>
<span className="text-xs text-gray-500">{hazards.length} Gefaehrdungen</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs whitespace-nowrap">
<thead>
{/* Group header */}
<tr className="bg-gray-100 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
<th colSpan={2} className="px-3 py-1.5 text-left font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Gefaehrdung</th>
<th colSpan={5} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Erstbewertung</th>
<th colSpan={6} className="px-3 py-1.5 text-center font-semibold text-purple-700 dark:text-purple-400 border-r border-gray-200 dark:border-gray-600">Nach Massnahmen (editierbar)</th>
<th colSpan={2} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">SIL / PL</th>
<th colSpan={2} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300">Status</th>
</tr>
{/* Column header */}
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
<th className="px-3 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Bezeichnung</th>
<th className="px-3 py-2 text-left font-medium text-gray-500 uppercase tracking-wider border-r border-gray-200 dark:border-gray-600">Kategorie</th>
{/* Initial */}
<th className="px-2 py-2 text-center font-medium text-gray-500">S</th>
<th className="px-2 py-2 text-center font-medium text-gray-500">E</th>
<th className="px-2 py-2 text-center font-medium text-gray-500">P</th>
<th className="px-2 py-2 text-center font-medium text-gray-500">RPZ</th>
<th className="px-2 py-2 text-center font-medium text-gray-500 border-r border-gray-200 dark:border-gray-600">Risiko</th>
{/* After */}
<th className="px-2 py-2 text-center font-medium text-purple-600">S</th>
<th className="px-2 py-2 text-center font-medium text-purple-600">E</th>
<th className="px-2 py-2 text-center font-medium text-purple-600">P</th>
<th className="px-2 py-2 text-center font-medium text-purple-600">RPZ</th>
<th className="px-2 py-2 text-center font-medium text-purple-600">Risiko</th>
<th className="px-2 py-2 text-center font-medium text-purple-600 border-r border-gray-200 dark:border-gray-600"></th>
{/* SIL/PL */}
<th className="px-2 py-2 text-center font-medium text-gray-500">SIL</th>
<th className="px-2 py-2 text-center font-medium text-gray-500 border-r border-gray-200 dark:border-gray-600">PL</th>
{/* Status */}
<th className="px-2 py-2 text-center font-medium text-gray-500">Massn.</th>
<th className="px-2 py-2 text-center font-medium text-gray-500">Akzeptabel</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{sorted.map(h => {
const e = edits[h.id]
const initRpz = h.r_inherent || rpz(h.severity, h.exposure, h.probability, h.avoidance)
const afterRpz = e ? rpz(e.severity, e.exposure, e.probability, e.avoidance) : initRpz
const afterLevel = getRiskLevelISO(afterRpz)
const sil = silFromRpz(afterRpz)
const pl = plFromRpz(afterRpz)
const mc = mitCounts[h.id] || 0
const changed = e && (e.severity !== h.severity || e.exposure !== h.exposure || e.probability !== h.probability || e.avoidance !== (h.avoidance || 3))
return (
<tr key={h.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
{/* Hazard info */}
<td className="px-3 py-2 max-w-[200px]">
<div className="font-medium text-gray-900 dark:text-white truncate">{h.name}</div>
{h.component_name && <div className="text-[10px] text-gray-400 truncate">{h.component_name}</div>}
</td>
<td className="px-3 py-2 border-r border-gray-200 dark:border-gray-600">
<span className="inline-block px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-[10px] font-medium">
{CATEGORY_LABELS[h.category] || h.category}
</span>
</td>
{/* Initial S/E/P/RPZ/Risk */}
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{h.severity}</td>
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{h.exposure}</td>
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{h.probability}</td>
<td className="px-2 py-2 text-center font-bold text-gray-900 dark:text-white">{initRpz}</td>
<td className="px-2 py-2 text-center border-r border-gray-200 dark:border-gray-600">
<span className={`inline-block px-1.5 py-0.5 rounded-full text-[10px] font-medium border ${getRiskColor(h.risk_level)}`}>
{getRiskLevelLabel(h.risk_level)}
</span>
</td>
{/* After measures (editable) */}
<td className="px-1 py-2 text-center">{e && <InlineSelect value={e.severity} onChange={v => updateEdit(h.id, 'severity', v)} label="S nach" />}</td>
<td className="px-1 py-2 text-center">{e && <InlineSelect value={e.exposure} onChange={v => updateEdit(h.id, 'exposure', v)} label="E nach" />}</td>
<td className="px-1 py-2 text-center">{e && <InlineSelect value={e.probability} onChange={v => updateEdit(h.id, 'probability', v)} label="P nach" />}</td>
<td className="px-2 py-2 text-center font-bold text-purple-900 dark:text-purple-300">{afterRpz}</td>
<td className="px-2 py-2 text-center">
<span className={`inline-block px-1.5 py-0.5 rounded-full text-[10px] font-medium border ${getRiskColor(afterLevel)}`}>
{getRiskLevelLabel(afterLevel)}
</span>
</td>
<td className="px-1 py-2 text-center border-r border-gray-200 dark:border-gray-600">
{changed && (
<button onClick={() => handleReassess(h.id)} disabled={saving === h.id}
className="px-1.5 py-0.5 bg-purple-600 text-white rounded text-[10px] hover:bg-purple-700 disabled:opacity-50 transition-colors">
{saving === h.id ? '...' : 'Speichern'}
</button>
)}
</td>
{/* SIL / PL */}
<td className="px-2 py-2 text-center">
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${SIL_COLORS[sil]}`}>
{sil > 0 ? `SIL ${sil}` : '-'}
</span>
</td>
<td className="px-2 py-2 text-center border-r border-gray-200 dark:border-gray-600">
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${PL_COLORS[pl]}`}>
PL {pl}
</span>
</td>
{/* Status */}
<td className="px-2 py-2 text-center">
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium ${mc > 0 ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}>
{mc}
</span>
</td>
<td className="px-2 py-2 text-center">
{afterRpz <= 20 ? (
<span className="inline-block w-4 h-4 rounded-full bg-green-500 text-white text-[10px] leading-4 text-center" title="Akzeptabel">&#10003;</span>
) : afterRpz <= 60 ? (
<span className="inline-block w-4 h-4 rounded-full bg-yellow-400 text-yellow-900 text-[10px] leading-4 text-center" title="Bedingt">&#8776;</span>
) : (
<span className="inline-block w-4 h-4 rounded-full bg-red-500 text-white text-[10px] leading-4 text-center" title="Nicht akzeptabel">&#10007;</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{hazards.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-gray-500">
Keine Gefaehrdungen vorhanden. Fuegen Sie zuerst Gefaehrdungen hinzu.
</div>
)}
</div>
)
}