e7f2f98da3
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>
259 lines
9.6 KiB
TypeScript
259 lines
9.6 KiB
TypeScript
'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>
|
|
)
|
|
}
|