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
@@ -0,0 +1,258 @@
'use client'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import Link from 'next/link'
import { usePathname, useParams } from 'next/navigation'
interface CEStep {
step: number
label: string
href: string | null
external?: boolean
sameAs?: number
note?: string
}
const CE_STEPS: CEStep[] = [
{ step: 3, label: 'Grenzen & Verwendung', href: '/interview' },
{ step: 4, label: 'Normenrecherche', href: null, external: true },
{ step: 5, label: 'Komponenten', href: '/components' },
{ step: 6, label: 'Gefaehrdungen', href: '/hazards' },
{ step: 7, label: 'Risikobewertung', href: '/hazards', sameAs: 6 },
{ step: 8, label: 'Massnahmen', href: '/mitigations' },
{ step: 9, label: 'Nachweise', href: '/evidence' },
{ step: 10, label: 'Restrisiko', href: '/hazards', note: 'Reassessment' },
{ step: 11, label: 'Verifikation', href: '/verification' },
{ step: 14, label: 'CE-Akte', href: '/tech-file' },
]
function getNavigableSteps(basePath: string): CEStep[] {
return CE_STEPS.filter((s) => s.href !== null && !s.external)
}
export default function IACEFlowFAB() {
const [isOpen, setIsOpen] = useState(false)
const panelRef = useRef<HTMLDivElement>(null)
const fabRef = useRef<HTMLButtonElement>(null)
const pathname = usePathname()
const params = useParams()
const projectId = params?.projectId as string
const basePath = `/sdk/iace/${projectId}`
const activeStepIndex = CE_STEPS.findIndex((s) => {
if (!s.href) return false
return pathname.startsWith(`${basePath}${s.href}`)
})
const navigableSteps = getNavigableSteps(basePath)
const currentNavIndex = navigableSteps.findIndex((s) => {
if (!s.href) return false
return pathname.startsWith(`${basePath}${s.href}`)
})
const completedCount = CE_STEPS.filter((s) => s.href && !s.external).length
const totalSteps = CE_STEPS.length
const handleClose = useCallback(() => setIsOpen(false), [])
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') handleClose()
}
function onClickOutside(e: MouseEvent) {
if (
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
fabRef.current &&
!fabRef.current.contains(e.target as Node)
) {
handleClose()
}
}
if (isOpen) {
document.addEventListener('keydown', onKeyDown)
document.addEventListener('mousedown', onClickOutside)
}
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('mousedown', onClickOutside)
}
}, [isOpen, handleClose])
const goPrev = () => {
if (currentNavIndex > 0) {
const prev = navigableSteps[currentNavIndex - 1]
if (prev.href) window.location.href = `${basePath}${prev.href}`
}
}
const goNext = () => {
if (currentNavIndex < navigableSteps.length - 1) {
const next = navigableSteps[currentNavIndex + 1]
if (next.href) window.location.href = `${basePath}${next.href}`
}
}
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
{/* Expanded Panel */}
<div
ref={panelRef}
className={`mb-3 w-[300px] max-h-[70vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 transition-all duration-200 origin-bottom-right ${
isOpen
? 'opacity-100 scale-100 translate-y-0'
: 'opacity-0 scale-95 translate-y-2 pointer-events-none'
}`}
>
{/* Header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 px-4 py-3 border-b border-gray-100 dark:border-gray-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
CE-Prozessschritte
</h3>
<p className="text-xs text-gray-500 mt-0.5">
{completedCount}/{totalSteps} Schritte im Tool
</p>
</div>
{/* Steps */}
<div className="py-2 px-2">
{CE_STEPS.map((step, idx) => {
const isActive = idx === activeStepIndex
const isExternal = step.external || step.href === null
const fullHref = step.href ? `${basePath}${step.href}` : null
const rowContent = (
<div
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
isActive
? 'bg-purple-50 dark:bg-purple-900/40'
: isExternal
? 'opacity-50 cursor-default'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
}`}
>
{/* Step number circle */}
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
isActive
? 'bg-purple-600 text-white'
: isExternal
? 'bg-gray-200 dark:bg-gray-600 text-gray-400 dark:text-gray-500'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
}`}
>
{isActive ? (
<span className="w-2 h-2 rounded-full bg-white" />
) : !isExternal ? (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
) : (
step.step
)}
</div>
{/* Label */}
<div className="flex-1 min-w-0">
<span
className={`block truncate font-medium ${
isActive
? 'text-purple-700 dark:text-purple-300'
: isExternal
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-700 dark:text-gray-200'
}`}
>
{step.label}
</span>
{(step.note || isExternal) && (
<span className="text-[10px] text-gray-400">
{step.note || '(extern)'}
</span>
)}
</div>
{/* Step badge */}
<span className="text-[10px] text-gray-400 flex-shrink-0">
#{step.step}
</span>
</div>
)
if (fullHref && !isExternal) {
return (
<Link key={idx} href={fullHref} onClick={handleClose}>
{rowContent}
</Link>
)
}
return <div key={idx}>{rowContent}</div>
})}
</div>
{/* Prev/Next navigation */}
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700 px-4 py-2.5 flex items-center justify-between">
<button
onClick={goPrev}
disabled={currentNavIndex <= 0}
className="flex items-center gap-1 text-xs font-medium text-purple-600 hover:text-purple-700 disabled:text-gray-300 disabled:cursor-not-allowed transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck
</button>
<span className="text-[10px] text-gray-400">
{currentNavIndex >= 0 ? currentNavIndex + 1 : '-'}/{navigableSteps.length}
</span>
<button
onClick={goNext}
disabled={currentNavIndex >= navigableSteps.length - 1 || currentNavIndex < 0}
className="flex items-center gap-1 text-xs font-medium text-purple-600 hover:text-purple-700 disabled:text-gray-300 disabled:cursor-not-allowed transition-colors"
>
Weiter
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
{/* FAB Button */}
<button
ref={fabRef}
onClick={() => setIsOpen((o) => !o)}
className="w-14 h-14 rounded-full bg-gradient-to-br from-purple-600 to-indigo-600 text-white shadow-lg hover:shadow-xl hover:scale-105 active:scale-95 transition-all flex items-center justify-center"
title="CE-Prozessschritte"
>
{/* Steps/flow icon */}
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
{/* Progress ring */}
<svg className="absolute w-14 h-14" viewBox="0 0 56 56">
<circle
cx="28"
cy="28"
r="25"
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="3"
/>
<circle
cx="28"
cy="28"
r="25"
fill="none"
stroke="white"
strokeWidth="3"
strokeDasharray={`${(completedCount / totalSteps) * 157} 157`}
strokeLinecap="round"
transform="rotate(-90 28 28)"
/>
</svg>
</button>
</div>
)
}
@@ -0,0 +1,139 @@
'use client'
import { useState, useEffect } from 'react'
interface NormRef {
id: string
number: string
title_de: string
norm_type: string
scope_de: string
mandatory: boolean
}
interface NormSuggestion {
norm: NormRef
reason: string
confidence: number
}
interface NormResult {
a_norms: NormSuggestion[]
b1_norms: NormSuggestion[]
b2_norms: NormSuggestion[]
c_norms: NormSuggestion[]
total: number
}
const TYPE_CONFIG: Record<string, { label: string; color: string; desc: string }> = {
a_norms: { label: 'A-Normen', color: 'border-red-200 bg-red-50 text-red-800', desc: 'Grundnormen (immer anwendbar)' },
b1_norms: { label: 'B1-Normen', color: 'border-blue-200 bg-blue-50 text-blue-800', desc: 'Sicherheitsgrundnormen' },
b2_norms: { label: 'B2-Normen', color: 'border-green-200 bg-green-50 text-green-800', desc: 'Sicherheitsfachgrundnormen' },
c_norms: { label: 'C-Normen', color: 'border-purple-200 bg-purple-50 text-purple-800', desc: 'Maschinenspezifische Normen' },
}
export function SuggestedNorms({ projectId }: { projectId: string }) {
const [data, setData] = useState<NormResult | null>(null)
const [loading, setLoading] = useState(true)
const [collapsed, setCollapsed] = useState(false)
useEffect(() => {
fetch(`/api/sdk/v1/iace/projects/${projectId}/suggested-norms`)
.then((r) => r.ok ? r.json() : null)
.then((json) => {
if (json?.suggestions) setData(json.suggestions)
else if (json?.a_norms !== undefined) setData(json)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [projectId])
if (loading) return null
if (!data || data.total === 0) return null
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button
onClick={() => setCollapsed(!collapsed)}
className="w-full flex items-center justify-between p-6 text-left"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-amber-600" 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>
</div>
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Normenrecherche {data.total} relevante Normen
</h2>
<p className="text-xs text-gray-500">
Automatisch ermittelt aus Maschinentyp, Gefaehrdungen und Komponenten
</p>
</div>
</div>
<svg className={`w-5 h-5 text-gray-400 transition-transform ${collapsed ? '' : 'rotate-180'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{!collapsed && (
<div className="px-6 pb-6 space-y-4">
{/* Legend */}
<div className="flex flex-wrap gap-2 text-xs">
{Object.entries(TYPE_CONFIG).map(([key, cfg]) => (
<span key={key} className={`px-2 py-0.5 rounded border ${cfg.color}`}>{cfg.label}: {cfg.desc}</span>
))}
</div>
{/* Norm groups */}
{(['a_norms', 'b1_norms', 'b2_norms', 'c_norms'] as const).map((type) => {
const norms = data[type]
if (!norms || norms.length === 0) return null
const cfg = TYPE_CONFIG[type]
return (
<div key={type}>
<h3 className={`text-xs font-semibold px-2 py-1 rounded inline-block mb-2 border ${cfg.color}`}>
{cfg.label} ({norms.length})
</h3>
<div className="space-y-2">
{norms.map((s) => (
<div key={s.norm.id} className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-100 dark:border-gray-600">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-mono font-semibold text-gray-900 dark:text-white">
{s.norm.number}
</span>
{s.norm.mandatory && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-red-100 text-red-700 rounded">
Pflicht
</span>
)}
<span className="px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded">
{Math.round(s.confidence * 100)}%
</span>
</div>
<p className="text-xs text-gray-700 dark:text-gray-300 mt-0.5">{s.norm.title_de}</p>
<p className="text-xs text-gray-500 mt-1">{s.norm.scope_de}</p>
<p className="text-xs text-amber-600 mt-1">
Grund: {s.reason}
</p>
</div>
</div>
))}
</div>
</div>
)
})}
{/* Disclaimer */}
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-800">
<strong>Hinweis:</strong> Diese Normenvorschlaege basieren auf dem Maschinentyp und den identifizierten
Gefaehrdungen. Der CE-Fachmann muss die Anwendbarkeit pruefen und ggf. weitere Normen ergaenzen.
Nur Normennummern und -titel werden angezeigt der Normtext muss separat beschafft werden (z.B. ueber Beuth/DIN).
</div>
</div>
)}
</div>
)
}