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>
)
}
@@ -115,7 +115,7 @@ export default function ComponentsPage() {
</div>
</div>
) : (
!c.showForm && (
!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-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -132,7 +132,7 @@ export default function ComponentsPage() {
className="px-6 py-3 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors">
Aus Bibliothek waehlen
</button>
<button onClick={() => c.setShowForm(true)}
<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>
@@ -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>
)
}
@@ -169,5 +169,6 @@ export function useHazards(projectId: string) {
suggestingAI, matchingPatterns, matchResult, setMatchResult, applyingPatterns,
fetchLibrary, handleAddFromLibrary, handleSubmit,
handleAISuggestions, handlePatternMatching, handleApplyPatterns, handleDelete,
refetch: fetchHazards,
}
}
@@ -1,17 +1,21 @@
'use client'
import React from 'react'
import React, { useState } from 'react'
import { useParams } from 'next/navigation'
import { HazardForm } from './_components/HazardForm'
import { HazardTable } from './_components/HazardTable'
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
import { LibraryModal } from './_components/LibraryModal'
import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
import { useHazards } from './_hooks/useHazards'
type ViewMode = 'list' | 'risk'
export default function HazardsPage() {
const params = useParams()
const projectId = params.projectId as string
const h = useHazards(projectId)
const [view, setView] = useState<ViewMode>('list')
if (h.loading) {
return (
@@ -29,6 +33,16 @@ export default function HazardsPage() {
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Gefaehrdungsanalyse mit 4-Faktor-Risikobewertung (S x F x P x A).
</p>
<div className="mt-2 flex rounded-lg border border-gray-200 dark:border-gray-600 overflow-hidden text-xs">
<button onClick={() => setView('list')}
className={`px-3 py-1.5 font-medium transition-colors ${view === 'list' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
Hazard-Liste
</button>
<button onClick={() => setView('risk')}
className={`px-3 py-1.5 font-medium transition-colors border-l border-gray-200 dark:border-gray-600 ${view === 'risk' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
Risikobewertung
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={h.handlePatternMatching} disabled={h.matchingPatterns}
@@ -70,12 +84,12 @@ export default function HazardsPage() {
</div>
</div>
{h.matchResult && h.matchResult.matched_patterns.length > 0 && (
{h.matchResult && h.matchResult.matched_patterns?.length > 0 && (
<AutoSuggestPanel projectId={projectId} matchResult={h.matchResult} applying={h.applyingPatterns}
onApply={h.handleApplyPatterns} onClose={() => h.setMatchResult(null)} />
)}
{h.matchResult && h.matchResult.matched_patterns.length === 0 && (
{h.matchResult && (!h.matchResult.matched_patterns || h.matchResult.matched_patterns.length === 0) && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 flex items-start gap-3">
<svg className="w-5 h-5 text-yellow-600 mt-0.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" />
@@ -121,7 +135,11 @@ export default function HazardsPage() {
)}
{h.hazards.length > 0 ? (
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
view === 'risk' ? (
<RiskAssessmentTable projectId={projectId} hazards={h.hazards} onReassess={h.refetch} />
) : (
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
)
) : (
!h.showForm && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
@@ -16,8 +16,8 @@ export function MitigationCard({
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
{mitigation.title.startsWith('Auto:') && (
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title || ''}</h4>
{(mitigation.title || '').startsWith('Auto:') && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
Auto
</span>
@@ -28,7 +28,7 @@ export function MitigationCard({
{mitigation.description && (
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
)}
{mitigation.linked_hazard_names.length > 0 && (
{(mitigation.linked_hazard_names || []).length > 0 && (
<div className="mb-3">
<div className="flex flex-wrap gap-1">
{mitigation.linked_hazard_names.map((name, i) => (
@@ -20,15 +20,33 @@ export function useMitigations(projectId: string) {
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
])
if (mitRes.ok) {
const json = await mitRes.json()
const mits = json.mitigations || json || []
setMitigations(mits)
validateHierarchy(mits)
}
let hazardList: Hazard[] = []
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, category: h.category })))
hazardList = (json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category }))
setHazards(hazardList)
}
if (mitRes.ok) {
const json = await mitRes.json()
const raw = json.mitigations || json || []
// Map API fields (name, hazard_id) to frontend fields (title, linked_hazard_ids/names)
const hazardMap = Object.fromEntries(hazardList.map((h) => [h.id, h.name]))
const mits: Mitigation[] = raw.map((m: Record<string, unknown>) => ({
id: m.id as string,
title: (m.title || m.name || '') as string,
description: (m.description || '') as string,
reduction_type: (m.reduction_type === 'protective' ? 'protection' : m.reduction_type || 'design') as Mitigation['reduction_type'],
status: (m.status || 'planned') as Mitigation['status'],
linked_hazard_ids: m.linked_hazard_ids ? (m.linked_hazard_ids as string[]) : m.hazard_id ? [m.hazard_id as string] : [],
linked_hazard_names: m.linked_hazard_ids
? (m.linked_hazard_ids as string[]).map((id) => hazardMap[id] || id)
: m.hazard_id ? [hazardMap[m.hazard_id as string] || (m.hazard_id as string)] : [],
created_at: (m.created_at || '') as string,
verified_at: (m.verified_at || null) as string | null,
verified_by: (m.verified_by || null) as string | null,
}))
setMitigations(mits)
validateHierarchy(mits)
}
} catch (err) {
console.error('Failed to fetch data:', err)
@@ -3,6 +3,7 @@
import React, { useState, useEffect } from 'react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { SuggestedNorms } from './_components/SuggestedNorms'
interface ProjectOverview {
id: string
@@ -120,11 +121,72 @@ export default function ProjectOverviewPage() {
async function fetchProject() {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`)
if (res.ok) {
const json = await res.json()
setProject(json)
// Fetch project detail + live risk summary + mitigations count in parallel
const [projRes, riskRes, mitRes, hazRes] = await Promise.all([
fetch(`/api/sdk/v1/iace/projects/${projectId}`),
fetch(`/api/sdk/v1/iace/projects/${projectId}/risk-summary`),
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
])
if (!projRes.ok) return
const json = await projRes.json()
// Live risk summary from dedicated endpoint
let rs = json.risk_summary || {}
if (riskRes.ok) {
const riskJson = await riskRes.json()
const live = riskJson.risk_summary || riskJson || {}
rs = {
critical: live.critical || 0,
high: live.high || 0,
medium: live.medium || 0,
low: live.low || 0,
negligible: live.negligible || 0,
total: live.total_hazards || live.total || 0,
}
}
// Live counts
let mitCount = 0
if (mitRes.ok) {
const mitJson = await mitRes.json()
mitCount = mitJson.total || (mitJson.mitigations || []).length || 0
}
let hazCount = 0
if (hazRes.ok) {
const hazJson = await hazRes.json()
hazCount = hazJson.total || (hazJson.hazards || []).length || 0
}
// Calculate dynamic completeness percentage
const compCount = json.components?.length || 0
const gates = (json.completeness_gates || json.gates || [])
const gatesPassed = gates.filter((g: Record<string, unknown>) => g.passed === true).length
const gatesTotal = gates.length || 1
const completeness = Math.round((gatesPassed / gatesTotal) * 100)
setProject({
...json,
completeness_pct: completeness,
component_count: compCount,
hazard_count: hazCount,
mitigation_count: mitCount,
risk_summary: {
critical: rs.critical || 0,
high: rs.high || 0,
medium: rs.medium || 0,
low: rs.low || 0,
total: rs.total || hazCount,
},
gates: gates.map((g: Record<string, unknown>) => ({
id: g.id,
name: g.name || g.label || '',
description: g.description || g.details || '',
passed: g.passed,
required: g.required,
})),
})
} catch (err) {
console.error('Failed to fetch project:', err)
} finally {
@@ -229,15 +291,31 @@ export default function ProjectOverviewPage() {
</dl>
</div>
{/* Risk Summary */}
{/* Risk Summary — live from /risk-summary endpoint */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Risikozusammenfassung</h2>
<div className="flex items-center justify-around">
<RiskGauge label="Kritisch" value={project.risk_summary?.critical || 0} max={project.risk_summary?.total || 1} color="#EF4444" />
<RiskGauge label="Hoch" value={project.risk_summary?.high || 0} max={project.risk_summary?.total || 1} color="#F97316" />
<RiskGauge label="Mittel" value={project.risk_summary?.medium || 0} max={project.risk_summary?.total || 1} color="#EAB308" />
<RiskGauge label="Niedrig" value={project.risk_summary?.low || 0} max={project.risk_summary?.total || 1} color="#22C55E" />
{/* Risk level bars */}
<div className="space-y-2">
{[
{ label: 'Kritisch', value: project.risk_summary?.critical || 0, color: 'bg-red-500', text: 'text-red-700' },
{ label: 'Hoch', value: project.risk_summary?.high || 0, color: 'bg-orange-500', text: 'text-orange-700' },
{ label: 'Mittel', value: project.risk_summary?.medium || 0, color: 'bg-yellow-500', text: 'text-yellow-700' },
{ label: 'Niedrig', value: project.risk_summary?.low || 0, color: 'bg-green-500', text: 'text-green-700' },
].map((level) => {
const total = project.risk_summary?.total || 1
const pct = Math.round((level.value / total) * 100)
return (
<div key={level.label} className="flex items-center gap-3">
<span className={`text-xs font-medium w-16 ${level.text}`}>{level.label}</span>
<div className="flex-1 bg-gray-100 rounded-full h-4 overflow-hidden">
<div className={`${level.color} h-4 rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
<span className="text-sm font-bold text-gray-900 dark:text-white w-8 text-right">{level.value}</span>
</div>
)
})}
</div>
{/* Counts */}
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">{project.component_count}</div>
@@ -252,6 +330,10 @@ export default function ProjectOverviewPage() {
<div className="text-xs text-gray-500">Massnahmen</div>
</div>
</div>
{/* RPZ threshold info */}
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500">
RPZ-Schwellen: Kritisch &gt;100 | Hoch 60-100 | Mittel 20-60 | Niedrig &lt;20
</div>
</div>
{/* Completeness Gates */}
@@ -267,6 +349,9 @@ export default function ProjectOverviewPage() {
</div>
</div>
{/* Suggested Norms */}
<SuggestedNorms projectId={projectId} />
{/* Quick Actions */}
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Schnellzugriff</h2>
@@ -0,0 +1,172 @@
'use client'
import { useState, useEffect } from 'react'
interface NormStats {
total: number
byType: Record<string, number>
categories: string[]
}
const TYPE_INFO: Record<string, { label: string; color: string }> = {
A: { label: 'A-Normen (Grundnormen)', color: 'bg-red-50 text-red-800 border-red-200' },
B1: { label: 'B1-Normen (Sicherheitsgrundnormen)', color: 'bg-blue-50 text-blue-800 border-blue-200' },
B2: { label: 'B2-Normen (Sicherheitsfachgrundnormen)', color: 'bg-green-50 text-green-800 border-green-200' },
C: { label: 'C-Normen (Maschinenspezifisch)', color: 'bg-purple-50 text-purple-800 border-purple-200' },
}
const CATEGORY_DESCRIPTIONS: Record<string, string> = {
A: 'ISO 12100 (Grundnorm Risikobeurteilung)',
B1: 'ISO 13849-1/2, IEC 62061, IEC 61508 (SIL/PL, Funktionale Sicherheit)',
B2: 'Elektrik, Ergonomie, Vibration, Laerm, Brandschutz, Hydraulik/Pneumatik, Software-Safety, Emissionen, Schutzeinrichtungen, Zugaenge, Signale',
C: '',
}
export function NormsCoverage() {
const [stats, setStats] = useState<NormStats | null>(null)
const [loading, setLoading] = useState(true)
const [expanded, setExpanded] = useState(false)
useEffect(() => {
fetch('/api/sdk/v1/iace/norms-library')
.then((r) => (r.ok ? r.json() : null))
.then((json) => {
if (!json?.norms) return
const norms = json.norms as Array<{ norm_type: string; machine_types?: string[] }>
const byType: Record<string, number> = {}
const machineTypes = new Set<string>()
for (const n of norms) {
byType[n.norm_type] = (byType[n.norm_type] || 0) + 1
if (n.machine_types) {
for (const mt of n.machine_types) machineTypes.add(mt)
}
}
// Group machine types into readable categories
const catMap: Record<string, string> = {
press: 'Pressen', hydraulic_press: 'Pressen', mechanical_press: 'Pressen', press_brake: 'Pressen',
robot: 'Roboter', industrial_robot: 'Roboter', robot_cell: 'Roboter',
collaborative_robot: 'Kollaborierende Roboter', cobot: 'Kollaborierende Roboter',
woodworking: 'Holzbearbeitung', saw: 'Holzbearbeitung', circular_saw: 'Holzbearbeitung',
panel_saw: 'Holzbearbeitung', table_saw: 'Holzbearbeitung', miter_saw: 'Holzbearbeitung',
log_saw: 'Holzbearbeitung', planer: 'Holzbearbeitung', router: 'Holzbearbeitung',
lathe: 'Metallbearbeitung', turning_machine: 'Metallbearbeitung', large_lathe: 'Metallbearbeitung',
small_lathe: 'Metallbearbeitung', milling_machine: 'Metallbearbeitung', drilling_machine: 'Metallbearbeitung',
grinding_machine: 'Metallbearbeitung', metal_saw: 'Metallbearbeitung', band_saw: 'Metallbearbeitung',
cold_saw: 'Metallbearbeitung', shearing_machine: 'Metallbearbeitung', bending_machine: 'Metallbearbeitung',
cnc: 'Metallbearbeitung', machining_center: 'Metallbearbeitung', transfer_machine: 'Metallbearbeitung',
injection_molding: 'Kunststoff/Gummi', plastics_machine: 'Kunststoff/Gummi',
compression_molding: 'Kunststoff/Gummi', blow_molding: 'Kunststoff/Gummi',
extruder: 'Kunststoff/Gummi', plastics_press: 'Kunststoff/Gummi',
rubber_machine: 'Kunststoff/Gummi', two_roll_mill: 'Kunststoff/Gummi',
reaction_molding: 'Kunststoff/Gummi', calender: 'Kunststoff/Gummi',
food_machine: 'Lebensmittel', meat_grinder: 'Lebensmittel', bread_slicer: 'Lebensmittel',
bakery: 'Lebensmittel', mixer: 'Lebensmittel', cooker: 'Lebensmittel',
cutter: 'Lebensmittel', food_cutter: 'Lebensmittel', filling_machine: 'Lebensmittel',
packaging_machine: 'Verpackung', palletizer: 'Verpackung', pallet_wrapper: 'Verpackung',
wrapping_machine: 'Verpackung', strapping_machine: 'Verpackung',
textile_machine: 'Textilmaschinen', spinning_machine: 'Textilmaschinen',
weaving_machine: 'Textilmaschinen', dyeing_machine: 'Textilmaschinen',
nonwoven_machine: 'Textilmaschinen',
agricultural_machine: 'Landmaschinen', combine_harvester: 'Landmaschinen',
mower: 'Landmaschinen', baler: 'Landmaschinen', sprayer: 'Landmaschinen', tiller: 'Landmaschinen',
crane: 'Krane/Hebezeuge', bridge_crane: 'Krane/Hebezeuge', gantry_crane: 'Krane/Hebezeuge',
tower_crane: 'Krane/Hebezeuge', mobile_crane: 'Krane/Hebezeuge', hoist: 'Krane/Hebezeuge',
winch: 'Krane/Hebezeuge', slewing_crane: 'Krane/Hebezeuge',
elevator: 'Aufzuege', lift: 'Aufzuege', construction_hoist: 'Aufzuege',
conveyor: 'Foerdertechnik', belt_conveyor: 'Foerdertechnik', screw_conveyor: 'Foerdertechnik',
transfer_system: 'Foerdertechnik', rotary_transfer_machine: 'Foerdertechnik',
forklift: 'Flurfoerderzeuge', industrial_truck: 'Flurfoerderzeuge',
earth_moving: 'Erdbaumaschinen', excavator: 'Erdbaumaschinen',
wheel_loader: 'Erdbaumaschinen', bulldozer: 'Erdbaumaschinen',
welding_machine: 'Schweissmaschinen', arc_welder: 'Schweissmaschinen',
printing_press: 'Druckmaschinen', coating_machine: 'Druckmaschinen',
pump: 'Pumpen/Kompressoren', compressor: 'Pumpen/Kompressoren', vacuum_pump: 'Pumpen/Kompressoren',
foundry_machine: 'Giesserei', casting_machine: 'Giesserei', die_casting: 'Giesserei',
industrial_furnace: 'Industrieoefen', heat_treatment: 'Industrieoefen',
dryer: 'Trockner/Oefen', oven: 'Trockner/Oefen', kiln: 'Trockner/Oefen',
paper_machine: 'Papiermaschinen', slitter_rewinder: 'Papiermaschinen', pulper: 'Papiermaschinen',
centrifuge: 'Zentrifugen',
aerial_platform: 'Hubarbeitsbuehnen', cherry_picker: 'Hubarbeitsbuehnen',
scissor_lift: 'Hubtische', lift_table: 'Hubtische',
powered_gate: 'Tore/Tueren', industrial_door: 'Tore/Tueren',
laser_machine: 'Lasermaschinen', laser_cutter: 'Lasermaschinen',
silo: 'Schuettgutanlagen', bunker: 'Schuettgutanlagen',
suspended_platform: 'Haengebuehnen', scaffold: 'Haengebuehnen',
storage_retrieval: 'Lagertechnik', automated_warehouse: 'Lagertechnik',
pressure_vessel: 'Druckbehaelter', hydraulic_accumulator: 'Druckbehaelter',
}
const cats = new Set<string>()
for (const mt of machineTypes) {
cats.add(catMap[mt] || mt)
}
const sortedCats = Array.from(cats).sort()
setStats({ total: norms.length, byType, categories: sortedCats })
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
if (loading || !stats) return null
const cDesc = stats.categories.join(', ')
return (
<div className="p-4 bg-purple-50 dark:bg-purple-900/10 border border-purple-200 dark:border-purple-800 rounded-lg">
<button onClick={() => setExpanded(!expanded)} className="w-full text-left">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-purple-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>
<span className="text-sm font-semibold text-purple-800 dark:text-purple-300">
Normen-Bibliothek: {stats.total} Normen in {stats.categories.length} Branchen
</span>
</div>
<svg className={`w-4 h-4 text-purple-400 transition-transform ${expanded ? '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>
</div>
</button>
{expanded && (
<div className="mt-3 space-y-2">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-purple-200 dark:border-purple-700">
<th className="text-left py-1.5 px-2 font-semibold text-purple-800 dark:text-purple-300 w-48">Typ</th>
<th className="text-center py-1.5 px-2 font-semibold text-purple-800 dark:text-purple-300 w-16">Anzahl</th>
<th className="text-left py-1.5 px-2 font-semibold text-purple-800 dark:text-purple-300">Abdeckung</th>
</tr>
</thead>
<tbody>
{(['A', 'B1', 'B2', 'C'] as const).map((type) => {
const info = TYPE_INFO[type]
const count = stats.byType[type] || 0
const desc = type === 'C' ? cDesc : CATEGORY_DESCRIPTIONS[type]
return (
<tr key={type} className="border-b border-purple-100 dark:border-purple-800/50">
<td className="py-2 px-2">
<span className={`inline-block px-2 py-0.5 rounded border text-xs font-medium ${info.color}`}>
{info.label}
</span>
</td>
<td className="py-2 px-2 text-center font-bold text-purple-900 dark:text-purple-200">{count}</td>
<td className="py-2 px-2 text-gray-700 dark:text-gray-300">{desc}</td>
</tr>
)
})}
</tbody>
</table>
<div className="pt-2 text-xs text-purple-600 dark:text-purple-400">
Alle Normen mit Abschnittsnummern und{' '}
<a href="https://www.beuth.de" target="_blank" rel="noopener noreferrer" className="underline font-medium hover:text-purple-800">
Beuth-Kauflinks
</a>{' '}
hinterlegt. Die vollstaendige Bibliothek ist unter &quot;Normenrecherche&quot; in jedem Projekt einsehbar.
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,369 @@
'use client'
import React, { useState, useEffect } from 'react'
import { NormsCoverage } from './NormsCoverage'
type ScopeStatus = 'in_scope' | 'partially' | 'not_in_scope' | 'planned'
interface ProcessStep {
number: number
title: string
description: string
actor: string
scope: ScopeStatus
toolNote?: string
}
const CE_PROCESS_STEPS: ProcessStep[] = [
{
number: 1,
title: 'Maschinenplanung',
description: 'Hersteller plant Maschine/Anlage',
actor: 'Hersteller',
scope: 'not_in_scope',
},
{
number: 2,
title: 'CE-Firma beauftragen',
description: 'Hersteller beauftragt CE-Beratungsfirma oder internes CE-Team',
actor: 'Hersteller',
scope: 'not_in_scope',
},
{
number: 3,
title: 'Grenzen definieren',
description:
'Bestimmungsgemasse Verwendung, vorhersehbare Fehlanwendung, Betriebsarten, raeumliche/zeitliche Grenzen',
actor: 'Gemeinsam',
scope: 'in_scope',
toolNote: 'Interview/Wizard tab',
},
{
number: 4,
title: 'Normenrecherche',
description:
'C-Normen (maschinenspezifisch), B-Normen (Sicherheitsfunktionen), A-Normen (ISO 12100). Harmonisierte Normen ermoeglichen Konformitaetsvermutung.',
actor: 'CE-Firma',
scope: 'in_scope',
toolNote: 'manueller Eintrag',
},
{
number: 5,
title: 'Maschinenbeschreibung',
description:
'Komponentenbaum, Energiequellen, technische Daten, Betriebsarten systematisch erfassen',
actor: 'CE-Firma',
scope: 'in_scope',
toolNote: 'Komponenten tab',
},
{
number: 6,
title: 'Gefaehrdungen identifizieren',
description:
'Systematisch pro Komponente x Lebenszyklus. Deterministisches Pattern-Matching generiert Vorschlaege.',
actor: 'CE-Firma + Tool',
scope: 'in_scope',
toolNote: 'Hazard Log',
},
{
number: 7,
title: 'Risiko bewerten',
description:
'Schwere x Exposition x Eintrittswahrscheinlichkeit. Automatische SIL/PL-Ableitung aus Risikograph.',
actor: 'CE-Firma + Tool',
scope: 'in_scope',
toolNote: 'Hazard Log',
},
{
number: 8,
title: 'Massnahmen definieren',
description:
'3-Stufen-Hierarchie (PFLICHT): 1. Design, 2. Schutzeinrichtung, 3. Information. Tool schlaegt kategorienspezifisch vor.',
actor: 'CE-Firma + Tool',
scope: 'in_scope',
toolNote: 'Massnahmen tab',
},
{
number: 9,
title: 'Massnahmen umsetzen',
description:
'Hersteller implementiert konstruktive Aenderungen, Schutzeinrichtungen, Beschilderung etc.',
actor: 'Hersteller',
scope: 'partially',
toolNote: 'Nachweis-Upload',
},
{
number: 10,
title: 'Restrisiko bewerten',
description:
'Iterativ: Nach Massnahmen-Umsetzung erneut bewerten. Akzeptabel? Wenn nein: zurueck zu Schritt 8.',
actor: 'CE-Firma',
scope: 'in_scope',
toolNote: 'Reassessment',
},
{
number: 11,
title: 'Verifikation',
description: 'Messungen, Berechnungen, Pruefungen. Nachweise den Massnahmen zuordnen.',
actor: 'CE-Firma',
scope: 'in_scope',
toolNote: 'Verifikation tab',
},
{
number: 12,
title: 'Benannte Stelle',
description:
'NUR fuer Annex-IV-Maschinen (Pressen, Holzbearbeitung, Hebezeuge): Formale Baumusterpruefung durch TUeV/DGUV Test o.ae.',
actor: 'Notified Body',
scope: 'not_in_scope',
},
{
number: 13,
title: 'Betriebsanleitung',
description:
'Restrisiken fuer Bediener dokumentieren, Sicherheitshinweise, bestimmungsgemasse Verwendung',
actor: 'CE-Firma',
scope: 'planned',
},
{
number: 14,
title: 'Technische Unterlagen',
description:
'Gesamtdossier: Plaene, Schaltbilder, Berechnungen, Risikobeurteilung, Normen, Pruefberichte, Betriebsanleitung',
actor: 'CE-Firma',
scope: 'in_scope',
toolNote: 'CE-Akte tab',
},
{
number: 15,
title: 'CE-Erklaerung',
description:
'Hersteller unterschreibt EU-Konformitaetserklaerung und bringt CE-Kennzeichnung an. Die CE-Firma gibt KEIN CE — der Hersteller traegt die Verantwortung.',
actor: 'Hersteller',
scope: 'not_in_scope',
},
]
const SCOPE_STYLES: Record<ScopeStatus, { border: string; bg: string; badge: string; badgeText: string }> = {
in_scope: {
border: 'border-l-purple-500',
bg: 'bg-purple-50 dark:bg-purple-900/10',
badge: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
badgeText: 'Im Tool',
},
partially: {
border: 'border-l-yellow-500',
bg: 'bg-yellow-50 dark:bg-yellow-900/10',
badge: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
badgeText: 'Teilweise',
},
not_in_scope: {
border: 'border-l-gray-300',
bg: 'bg-gray-50 dark:bg-gray-800/50',
badge: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
badgeText: 'Nicht im Tool',
},
planned: {
border: 'border-l-gray-300 border-dashed',
bg: 'bg-gray-50 dark:bg-gray-800/50',
badge: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300',
badgeText: 'Geplant',
},
}
const STORAGE_KEY = 'iace-process-flow-collapsed'
function StepCard({ step }: { step: ProcessStep }) {
const style = SCOPE_STYLES[step.scope]
const muted = step.scope === 'not_in_scope' || step.scope === 'planned'
return (
<div className={`relative flex gap-4 ${muted ? 'opacity-75' : ''}`}>
{/* Timeline connector */}
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
step.scope === 'in_scope'
? 'bg-purple-600 text-white'
: step.scope === 'partially'
? 'bg-yellow-500 text-white'
: 'bg-gray-300 text-gray-600 dark:bg-gray-600 dark:text-gray-300'
}`}
>
{step.number}
</div>
{step.number < 15 && (
<div className="w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-1" />
)}
</div>
{/* Card */}
<div
className={`flex-1 mb-3 p-4 rounded-lg border-l-4 ${style.border} ${style.bg} ${
step.scope === 'planned' ? 'border-dashed border border-gray-300 dark:border-gray-600' : ''
}`}
>
<div className="flex items-start justify-between gap-2 mb-1">
<h4 className={`font-semibold text-sm ${muted ? 'text-gray-600 dark:text-gray-400' : 'text-gray-900 dark:text-white'}`}>
{step.title}
</h4>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${style.badge}`}>
{style.badgeText}
</span>
</div>
</div>
<p className={`text-xs leading-relaxed ${muted ? 'text-gray-500 dark:text-gray-500' : 'text-gray-700 dark:text-gray-300'}`}>
{step.description}
</p>
<div className="mt-2 flex items-center gap-3">
<span className="inline-flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{step.actor}
</span>
{step.toolNote && (
<span className="inline-flex items-center gap-1 text-xs text-purple-600 dark:text-purple-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{step.toolNote}
</span>
)}
</div>
</div>
</div>
)
}
export function ProcessFlow() {
// Default to expanded (false) — avoids SSR hydration mismatch
const [collapsed, setCollapsed] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'true') {
setCollapsed(true)
}
setMounted(true)
}, [])
function toggle() {
const next = !collapsed
setCollapsed(next)
localStorage.setItem(STORAGE_KEY, String(next))
}
const inScopeCount = CE_PROCESS_STEPS.filter((s) => s.scope === 'in_scope').length
const partialCount = CE_PROCESS_STEPS.filter((s) => s.scope === 'partially').length
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header — always visible */}
<button
onClick={toggle}
className="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
>
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
CE-Prozess: 15 Schritte zur Konformitaet
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{inScopeCount} Schritte im Tool abgedeckt, {partialCount} teilweise
</p>
</div>
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${collapsed ? '' : 'rotate-180'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Content — collapsible */}
{!collapsed && (
<div className="px-6 pb-6 border-t border-gray-100 dark:border-gray-700">
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 py-3 mb-4">
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
<span className="w-3 h-3 rounded-sm bg-purple-500" />
Im Tool abgedeckt
</span>
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
<span className="w-3 h-3 rounded-sm bg-yellow-500" />
Teilweise abgedeckt
</span>
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
<span className="w-3 h-3 rounded-sm bg-gray-300 dark:bg-gray-600" />
Nicht im Tool
</span>
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
<span className="w-3 h-3 rounded-sm border border-dashed border-gray-400" />
Geplant
</span>
</div>
{/* Timeline */}
<div className="space-y-0">
{CE_PROCESS_STEPS.map((step) => (
<StepCard key={step.number} step={step} />
))}
</div>
{/* Norms Coverage Table */}
<div className="mt-4">
<NormsCoverage />
</div>
{/* Disclaimers */}
<div className="mt-4 space-y-3">
<div className="p-3 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-lg">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>Hinweis:</strong> Dieses Tool ersetzt NICHT die Fachkompetenz eines CE-Beraters.
Es automatisiert die systematische Dokumentation und schlaegt Gefaehrdungen/Massnahmen vor.
Die fachliche Bewertung und Verantwortung verbleibt beim CE-Experten und Hersteller.
</p>
</div>
<div className="p-3 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-xs font-semibold text-blue-800 dark:text-blue-300 mb-2">Normenrecherche Rechtliche Grundlage</p>
<div className="text-xs text-blue-700 dark:text-blue-400 leading-relaxed space-y-2">
<div>
<p className="font-medium mb-1">Was dieses Tool anzeigt:</p>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>Normennummern (z.B. &quot;ISO 13857:2019&quot;) Identifikatoren, kein geschuetzter Text</li>
<li>Offizielle Normentitel bibliografische Information</li>
<li>Abschnittsnummern (z.B. &quot;Abschnitt 4.2, Tabelle 1&quot;) Verweisadressen</li>
<li>Eigene Zusammenfassungen des Regelungsbereichs unser Text, nicht Normtext</li>
</ul>
</div>
<div>
<p className="font-medium mb-1">Was dieses Tool NICHT anzeigt:</p>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>Normtext (auch nicht auszugsweise) urheberrechtlich geschuetzt durch DIN/ISO/CEN</li>
<li>Tabellenwerte oder Grenzwerte aus Normen</li>
<li>Diagramme oder Bilder aus Normen</li>
</ul>
</div>
<p className="text-blue-600 dark:text-blue-300 pt-1">
Normtexte muessen separat beschafft werden, z.B. ueber{' '}
<a href="https://www.beuth.de" target="_blank" rel="noopener noreferrer" className="underline font-medium hover:text-blue-800">
www.beuth.de
</a>{' '}
(DIN-Normen) oder{' '}
<a href="https://www.iso.org" target="_blank" rel="noopener noreferrer" className="underline font-medium hover:text-blue-800">
www.iso.org
</a>.
</p>
</div>
</div>
</div>
</div>
)}
</div>
)
}
+13
View File
@@ -3,6 +3,7 @@
import React from 'react'
import Link from 'next/link'
import { usePathname, useParams } from 'next/navigation'
import IACEFlowFAB from './[projectId]/_components/IACEFlowFAB'
const IACE_NAV_ITEMS = [
{ id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' },
@@ -112,6 +113,15 @@ export default function IACELayout({ children }: { children: React.ReactNode })
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mt-2">
CE-Compliance
</h2>
<Link
href="/sdk/iace/lines"
className="mt-2 flex items-center gap-1.5 text-xs text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400 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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Produktionslinien
</Link>
</div>
<nav className="p-2 space-y-0.5">
{IACE_NAV_ITEMS.map((item) => (
@@ -136,6 +146,9 @@ export default function IACELayout({ children }: { children: React.ReactNode })
<main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900">
<div className="p-6">{children}</div>
</main>
{/* CE Process Step Navigator FAB */}
{projectId && <IACEFlowFAB />}
</div>
)
}
@@ -0,0 +1,74 @@
'use client'
import React from 'react'
import type { LineDashboard } from '../../_types'
interface AggregatePanelProps {
dashboard: LineDashboard
}
const RISK_DOTS = [
{ key: 'critical', label: 'Kritisch', dotColor: 'bg-red-500' },
{ key: 'high', label: 'Hoch', dotColor: 'bg-orange-500' },
{ key: 'medium', label: 'Mittel', dotColor: 'bg-yellow-500' },
{ key: 'low', label: 'Niedrig', dotColor: 'bg-green-500' },
]
export function AggregatePanel({ dashboard }: AggregatePanelProps) {
const { line, stations, aggregate } = dashboard
const totalHazards = stations.reduce((sum, s) => sum + s.hazard_count, 0)
const totalMitigations = stations.reduce((sum, s) => sum + s.mitigation_count, 0)
const stationCount = stations.length
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
{/* Title row */}
<div className="flex items-start justify-between mb-3">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
{line.name}
</h1>
{line.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{line.description}
</p>
)}
</div>
<div className="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500">
<span>Erstellt: {new Date(line.created_at).toLocaleDateString('de-DE')}</span>
</div>
</div>
{/* Stats row */}
<div className="flex flex-wrap items-center gap-6 mb-3">
<StatPill label="Stationen" value={stationCount} />
<StatPill label="Gefaehrdungen" value={totalHazards} />
<StatPill label="Massnahmen" value={totalMitigations} />
</div>
{/* Risk dots row */}
<div className="flex flex-wrap items-center gap-4">
{RISK_DOTS.map((rd) => {
const count = aggregate[rd.key] || 0
return (
<span key={rd.key} className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
<span className={`w-2.5 h-2.5 rounded-full ${rd.dotColor}`} />
<span className="font-semibold">{count}</span>
<span>{rd.label}</span>
</span>
)
})}
</div>
</div>
)
}
function StatPill({ label, value }: { label: string; value: number }) {
return (
<div className="flex items-center gap-1.5 text-sm">
<span className="font-bold text-gray-900 dark:text-white">{value}</span>
<span className="text-gray-500 dark:text-gray-400">{label}</span>
</div>
)
}
@@ -0,0 +1,165 @@
'use client'
import React from 'react'
import Link from 'next/link'
import { StationIcon } from './StationIcons'
import { STATION_TYPES } from '../../_types'
import type { StationDashboard } from '../../_types'
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-gray-100 text-gray-700',
in_progress: 'bg-blue-100 text-blue-700',
review: 'bg-yellow-100 text-yellow-700',
approved: 'bg-green-100 text-green-700',
archived: 'bg-gray-100 text-gray-500',
}
const STATUS_LABELS: Record<string, string> = {
draft: 'Entwurf',
in_progress: 'In Bearbeitung',
review: 'In Pruefung',
approved: 'Freigegeben',
archived: 'Archiviert',
}
const RISK_LEVELS = [
{ key: 'critical', label: 'Kritisch', color: 'bg-red-500', text: 'text-red-700' },
{ key: 'high', label: 'Hoch', color: 'bg-orange-500', text: 'text-orange-700' },
{ key: 'medium', label: 'Mittel', color: 'bg-yellow-500', text: 'text-yellow-700' },
{ key: 'low', label: 'Niedrig', color: 'bg-green-500', text: 'text-green-700' },
]
interface StationCardProps {
station: StationDashboard
expanded: boolean
onToggle: () => void
}
export function StationCard({ station, expanded, onToggle }: StationCardProps) {
const stationType = STATION_TYPES[station.station.station_type]
const bgColor = stationType?.bgColor || 'bg-gray-50'
const accentColor = stationType?.color || '#6B7280'
const totalRisk = Object.values(station.risk_summary).reduce((a, b) => a + b, 0)
const pctBar = station.completeness_pct
return (
<div
className={`w-56 flex-shrink-0 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden shadow-sm hover:shadow-md transition-shadow`}
>
{/* Color accent bar */}
<div className="h-1.5" style={{ backgroundColor: accentColor }} />
{/* Collapsed content */}
<div className="p-4">
{/* Icon + name */}
<div className="flex items-center gap-3 mb-2">
<div
className={`w-10 h-10 ${bgColor} dark:bg-opacity-20 rounded-lg flex items-center justify-center flex-shrink-0`}
style={{ color: accentColor }}
>
<StationIcon type={station.station.station_type} size={22} />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white truncate">
{station.station.station_label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{station.project_name}
</div>
</div>
</div>
{/* Hazard count */}
<div className="text-xs text-gray-600 dark:text-gray-400 mb-2">
{station.hazard_count} Gefaehrdungen
</div>
{/* Completeness bar */}
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="h-2 rounded-full transition-all"
style={{
width: `${pctBar}%`,
backgroundColor: accentColor,
}}
/>
</div>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 w-8 text-right">
{pctBar}%
</span>
</div>
{/* SIL / PL */}
{(station.sil_max || station.pl_max) && (
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mb-3">
{station.sil_max && <span>SIL {station.sil_max}</span>}
{station.sil_max && station.pl_max && <span>|</span>}
{station.pl_max && <span>PL {station.pl_max}</span>}
</div>
)}
{/* Toggle button */}
<button
onClick={onToggle}
className="w-full text-left text-xs text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 font-medium flex items-center gap-1"
>
{expanded ? 'Weniger anzeigen' : 'Details anzeigen'}
<svg
className={`w-3.5 h-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`}
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>
{/* Expanded content */}
{expanded && (
<div className="px-4 pb-4 border-t border-gray-100 dark:border-gray-700 pt-3 space-y-3">
{/* Risk breakdown */}
<div className="space-y-1.5">
{RISK_LEVELS.map((level) => {
const count = station.risk_summary[level.key] || 0
const pct = totalRisk > 0 ? Math.round((count / totalRisk) * 100) : 0
return (
<div key={level.key} className="flex items-center gap-2">
<span className={`text-[10px] font-medium w-12 ${level.text}`}>{level.label}</span>
<div className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden">
<div className={`${level.color} h-2.5 rounded-full`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs font-bold text-gray-700 dark:text-gray-300 w-6 text-right">{count}</span>
</div>
)
})}
</div>
{/* Mitigation count */}
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">Massnahmen</span>
<span className="font-semibold text-gray-700 dark:text-gray-300">{station.mitigation_count}</span>
</div>
{/* Status badge */}
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">Status</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[station.status] || STATUS_COLORS.draft}`}>
{STATUS_LABELS[station.status] || station.status}
</span>
</div>
{/* Link to project */}
<Link
href={`/sdk/iace/${station.station.project_id}`}
className="block text-center text-xs font-medium text-purple-600 dark:text-purple-400 hover:text-purple-700 bg-purple-50 dark:bg-purple-900/20 rounded-lg py-2 transition-colors"
>
Zum Projekt &rarr;
</Link>
</div>
)}
</div>
)
}
@@ -0,0 +1,199 @@
import React from 'react'
interface StationIconProps {
type: string
size?: number
}
export function StationIcon({ type, size = 24 }: StationIconProps) {
const s = size
const sw = 1.5
switch (type) {
case 'press':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Ram pressing down */}
<rect x="7" y="2" width="10" height="4" rx="1" />
<line x1="12" y1="6" x2="12" y2="12" />
<path d="M6 12h12v3H6z" />
<line x1="12" y1="12" x2="12" y2="10" strokeWidth={2.5} />
{/* Base block */}
<rect x="5" y="18" width="14" height="4" rx="1" />
{/* Workpiece */}
<rect x="9" y="15" width="6" height="3" rx="0.5" />
</svg>
)
case 'robot':
case 'cobot':
case 'collaborative_robot':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Base */}
<rect x="8" y="19" width="8" height="3" rx="1" />
{/* Lower arm */}
<line x1="12" y1="19" x2="8" y2="13" />
{/* Joint */}
<circle cx="8" cy="13" r="1.5" />
{/* Upper arm */}
<line x1="8" y1="13" x2="15" y2="7" />
{/* Wrist joint */}
<circle cx="15" cy="7" r="1.5" />
{/* Gripper */}
<line x1="15" y1="7" x2="18" y2="4" />
<line x1="18" y1="4" x2="19" y2="3" />
<line x1="18" y1="4" x2="19" y2="5" />
</svg>
)
case 'conveyor':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Belt top */}
<line x1="3" y1="14" x2="21" y2="14" />
{/* Belt bottom */}
<line x1="3" y1="18" x2="21" y2="18" />
{/* Left roller */}
<circle cx="5" cy="16" r="2" />
{/* Right roller */}
<circle cx="19" cy="16" r="2" />
{/* Flow arrows */}
<path d="M8 10l3-2 3 2" />
<path d="M11 8v-2" />
{/* Package on belt */}
<rect x="9" y="10" width="6" height="4" rx="0.5" />
</svg>
)
case 'assembly':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Gear */}
<circle cx="12" cy="12" r="4" />
<circle cx="12" cy="12" r="1.5" />
{/* Gear teeth */}
<line x1="12" y1="3" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="21" />
<line x1="3" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="21" y2="12" />
<line x1="5.6" y1="5.6" x2="7.8" y2="7.8" />
<line x1="16.2" y1="16.2" x2="18.4" y2="18.4" />
<line x1="5.6" y1="18.4" x2="7.8" y2="16.2" />
<line x1="16.2" y1="7.8" x2="18.4" y2="5.6" />
</svg>
)
case 'milling':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Spindle */}
<rect x="10" y="2" width="4" height="6" rx="1" />
{/* Cutter head */}
<circle cx="12" cy="11" r="3" />
{/* Rotation arc */}
<path d="M7 11a5 5 0 0 1 2.5-4.3" strokeDasharray="2 2" />
<path d="M17 11a5 5 0 0 0-2.5-4.3" strokeDasharray="2 2" />
{/* Workpiece / table */}
<rect x="4" y="17" width="16" height="3" rx="1" />
<rect x="8" y="14" width="8" height="3" rx="0.5" />
</svg>
)
case 'turning':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Chuck / rotating workpiece */}
<circle cx="9" cy="12" r="5" />
<circle cx="9" cy="12" r="2" />
{/* Tool holder */}
<line x1="16" y1="12" x2="14" y2="12" />
<path d="M16 9v6l4-1v-4z" />
{/* Rotation arrow */}
<path d="M5 5a8 8 0 0 1 4 1" />
<path d="M5 5l1.5 1.5L4.5 7" />
</svg>
)
case 'welding':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Torch body */}
<line x1="6" y1="4" x2="12" y2="14" />
<path d="M4 3h4l-2 3z" />
{/* Weld point */}
<circle cx="12" cy="16" r="1" fill="currentColor" />
{/* Sparks */}
<line x1="12" y1="16" x2="15" y2="13" />
<line x1="12" y1="16" x2="16" y2="15" />
<line x1="12" y1="16" x2="14" y2="19" />
<line x1="12" y1="16" x2="9" y2="19" />
{/* Workpiece */}
<rect x="3" y="19" width="18" height="3" rx="1" />
</svg>
)
case 'inspection':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Magnifying glass */}
<circle cx="10" cy="10" r="6" />
<line x1="14.5" y1="14.5" x2="20" y2="20" strokeWidth={2} />
{/* Checkmark inside */}
<path d="M7.5 10l2 2 3.5-4" />
</svg>
)
case 'packaging':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Box */}
<path d="M3 8l9-5 9 5v10l-9 5-9-5z" />
<line x1="12" y1="3" x2="12" y2="23" />
<line x1="3" y1="8" x2="12" y2="13" />
<line x1="21" y1="8" x2="12" y2="13" />
</svg>
)
case 'motor':
case 'electric_motor':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Motor body circle */}
<circle cx="12" cy="12" r="8" />
{/* Lightning bolt */}
<path d="M13 6l-3 6h4l-3 6" strokeWidth={2} />
{/* Shaft */}
<line x1="20" y1="12" x2="23" y2="12" strokeWidth={2} />
</svg>
)
case 'rotary_transfer':
case 'rotary_transfer_machine':
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{/* Circular path */}
<circle cx="12" cy="12" r="8" strokeDasharray="4 2" />
{/* Center */}
<circle cx="12" cy="12" r="2" />
{/* Station dots around circle */}
<circle cx="12" cy="4" r="1.5" fill="currentColor" />
<circle cx="19" cy="8" r="1.5" fill="currentColor" />
<circle cx="19" cy="16" r="1.5" fill="currentColor" />
<circle cx="12" cy="20" r="1.5" fill="currentColor" />
<circle cx="5" cy="16" r="1.5" fill="currentColor" />
<circle cx="5" cy="8" r="1.5" fill="currentColor" />
{/* Rotation arrow */}
<path d="M16 3.5l-1 2h2z" fill="currentColor" />
</svg>
)
default:
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="4" width="16" height="16" rx="2" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}
}
@@ -0,0 +1,59 @@
'use client'
import React from 'react'
import type { TransferInfo } from '../../_types'
interface TransferLineProps {
transfer: TransferInfo
color: string
}
export function TransferLine({ transfer, color }: TransferLineProps) {
return (
<div className="flex flex-col items-center justify-center w-20 flex-shrink-0 py-4">
<style>{`
@keyframes iace-running-dots {
0% { stroke-dashoffset: 12; }
100% { stroke-dashoffset: 0; }
}
.iace-transfer-dots {
animation: iace-running-dots 0.8s linear infinite;
}
`}</style>
<svg width="80" height="32" viewBox="0 0 80 32" className="overflow-visible">
{/* Background line */}
<line
x1="0"
y1="16"
x2="80"
y2="16"
stroke={color}
strokeWidth="2"
strokeOpacity="0.3"
/>
{/* Animated running dots */}
<line
x1="0"
y1="16"
x2="80"
y2="16"
stroke={color}
strokeWidth="2.5"
strokeDasharray="4 8"
className="iace-transfer-dots"
/>
{/* Arrowhead */}
<polygon
points="74,11 80,16 74,21"
fill={color}
/>
</svg>
{/* Label */}
{transfer.label && (
<span className="text-[10px] text-gray-500 dark:text-gray-400 mt-1 text-center leading-tight max-w-[80px] truncate">
{transfer.label}
</span>
)}
</div>
)
}
@@ -0,0 +1,194 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { AggregatePanel } from './_components/AggregatePanel'
import { StationCard } from './_components/StationCard'
import { TransferLine } from './_components/TransferLine'
import type { LineDashboard, StationDashboard, TransferInfo } from '../_types'
import { TRANSFER_COLORS } from '../_types'
/** Number of stations per visual row before wrapping */
const STATIONS_PER_ROW = 4
export default function LineDashboardPage() {
const params = useParams()
const lineId = params.lineId as string
const [dashboard, setDashboard] = useState<LineDashboard | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedStation, setExpandedStation] = useState<string | null>(null)
const fetchDashboard = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/iace/production-lines/${lineId}/dashboard`)
if (!res.ok) {
setError('Produktionslinie konnte nicht geladen werden')
return
}
const json = await res.json()
setDashboard(json)
} catch (err) {
console.error('Failed to fetch line dashboard:', err)
setError('Verbindung zum Backend fehlgeschlagen')
} finally {
setLoading(false)
}
}, [lineId])
useEffect(() => {
fetchDashboard()
}, [fetchDashboard])
function handleToggle(stationId: string) {
setExpandedStation((prev) => (prev === stationId ? null : stationId))
}
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>
)
}
if (error || !dashboard) {
return (
<div className="text-center py-12 space-y-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{error || 'Produktionslinie nicht gefunden'}
</h2>
<Link href="/sdk/iace/lines" className="text-purple-600 hover:text-purple-700">
Zurueck zur Uebersicht
</Link>
</div>
)
}
const sortedStations = [...dashboard.stations].sort(
(a, b) => a.station.sort_order - b.station.sort_order
)
// Build rows of stations for display
const rows = buildStationRows(sortedStations, STATIONS_PER_ROW)
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Back link */}
<Link
href="/sdk/iace/lines"
className="inline-flex items-center gap-1 text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300 font-medium"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Alle Produktionslinien
</Link>
{/* Aggregate panel */}
<AggregatePanel dashboard={dashboard} />
{/* Station flow */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
Stationsuebersicht
</h2>
<div className="space-y-6 overflow-x-auto">
{rows.map((row, rowIndex) => (
<StationRow
key={rowIndex}
stations={row}
transfers={dashboard.transfers}
expandedStation={expandedStation}
onToggle={handleToggle}
reversed={rowIndex % 2 === 1}
/>
))}
</div>
</div>
</div>
)
}
/** Split sorted stations into rows of N for layout */
function buildStationRows(
stations: StationDashboard[],
perRow: number
): StationDashboard[][] {
const rows: StationDashboard[][] = []
for (let i = 0; i < stations.length; i += perRow) {
rows.push(stations.slice(i, i + perRow))
}
return rows
}
/** Find the transfer between two adjacent station sort orders */
function findTransfer(
transfers: TransferInfo[],
fromOrder: number,
toOrder: number
): TransferInfo | null {
return (
transfers.find(
(t) => t.from_station === fromOrder && t.to_station === toOrder
) || null
)
}
/** Default transfer for stations without an explicit transfer entry */
function defaultTransfer(from: number, to: number): TransferInfo {
return { from_station: from, to_station: to, type: 'conveyor', label: '' }
}
interface StationRowProps {
stations: StationDashboard[]
transfers: TransferInfo[]
expandedStation: string | null
onToggle: (id: string) => void
reversed: boolean
}
function StationRow({ stations, transfers, expandedStation, onToggle, reversed }: StationRowProps) {
// Reverse even rows for a serpentine layout
const ordered = reversed ? [...stations].reverse() : stations
return (
<div className="flex items-start gap-0 overflow-x-auto pb-2">
{ordered.map((station, idx) => {
const nextStation = ordered[idx + 1]
const transfer = nextStation
? findTransfer(
transfers,
station.station.sort_order,
nextStation.station.sort_order
) ||
findTransfer(
transfers,
nextStation.station.sort_order,
station.station.sort_order
) ||
defaultTransfer(station.station.sort_order, nextStation.station.sort_order)
: null
const transferColor = transfer
? TRANSFER_COLORS[transfer.type] || TRANSFER_COLORS.conveyor
: '#22C55E'
return (
<React.Fragment key={station.station.id}>
<StationCard
station={station}
expanded={expandedStation === station.station.id}
onToggle={() => onToggle(station.station.id)}
/>
{transfer && (
<TransferLine transfer={transfer} color={transferColor} />
)}
</React.Fragment>
)
})}
</div>
)
}
@@ -0,0 +1,66 @@
export interface ProductionLine {
id: string
name: string
description: string
created_at: string
}
export interface StationDashboard {
station: {
id: string
line_id: string
project_id: string
station_type: string
station_label: string
sort_order: number
}
project_name: string
machine_type: string
status: string
risk_summary: Record<string, number>
hazard_count: number
mitigation_count: number
completeness_pct: number
sil_max: string
pl_max: string
}
export interface TransferInfo {
from_station: number
to_station: number
type: string
label: string
}
export interface LineDashboard {
line: ProductionLine
stations: StationDashboard[]
transfers: TransferInfo[]
aggregate: Record<string, number>
}
export const STATION_TYPES: Record<string, { label: string; color: string; bgColor: string }> = {
press: { label: 'Presse', color: '#EF4444', bgColor: 'bg-red-50' },
robot: { label: 'Roboter', color: '#3B82F6', bgColor: 'bg-blue-50' },
cobot: { label: 'Cobot', color: '#3B82F6', bgColor: 'bg-blue-50' },
collaborative_robot: { label: 'Cobot', color: '#3B82F6', bgColor: 'bg-blue-50' },
conveyor: { label: 'Foerderer', color: '#22C55E', bgColor: 'bg-green-50' },
assembly: { label: 'Montage', color: '#F97316', bgColor: 'bg-orange-50' },
milling: { label: 'Fraese', color: '#8B5CF6', bgColor: 'bg-purple-50' },
turning: { label: 'Drehmaschine', color: '#1D4ED8', bgColor: 'bg-blue-50' },
welding: { label: 'Schweissen', color: '#EAB308', bgColor: 'bg-yellow-50' },
inspection: { label: 'Pruefung', color: '#06B6D4', bgColor: 'bg-cyan-50' },
packaging: { label: 'Verpackung', color: '#92400E', bgColor: 'bg-amber-50' },
motor: { label: 'Motor', color: '#6B7280', bgColor: 'bg-gray-50' },
electric_motor: { label: 'Elektromotor', color: '#6B7280', bgColor: 'bg-gray-50' },
rotary_transfer: { label: 'Rundtakt', color: '#7C3AED', bgColor: 'bg-violet-50' },
rotary_transfer_machine: { label: 'Rundtakt', color: '#7C3AED', bgColor: 'bg-violet-50' },
}
export const TRANSFER_COLORS: Record<string, string> = {
conveyor: '#22C55E',
robot: '#3B82F6',
manual: '#EAB308',
crane: '#F97316',
agv: '#8B5CF6',
}
@@ -0,0 +1,135 @@
'use client'
import React, { useState, useEffect } from 'react'
import Link from 'next/link'
interface ProductionLineItem {
id: string
name: string
description: string
station_count: number
created_at: string
updated_at: string
}
export default function ProductionLinesListPage() {
const [lines, setLines] = useState<ProductionLineItem[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchLines()
}, [])
async function fetchLines() {
try {
const res = await fetch('/api/sdk/v1/iace/production-lines')
if (res.ok) {
const json = await res.json()
setLines(json.lines || json || [])
}
} catch (err) {
console.error('Failed to fetch production lines:', err)
} finally {
setLoading(false)
}
}
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 max-w-6xl mx-auto">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<Link
href="/sdk/iace"
className="text-xs text-purple-600 hover:text-purple-700 font-medium flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
IACE
</Link>
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Produktionslinien
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Verkettete Fertigungsstrassen mit aggregierter Risikoansicht
</p>
</div>
<Link
href="/sdk/iace/lines/new"
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
>
<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>
Neue Produktionslinie
</Link>
</div>
{/* Lines list */}
{lines.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{lines.map((line) => (
<Link
key={line.id}
href={`/sdk/iace/lines/${line.id}`}
className="block bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md hover:border-purple-300 transition-all"
>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
{line.name}
</h3>
{line.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 line-clamp-2">
{line.description}
</p>
)}
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{line.station_count} Stationen
</span>
<span>
Aktualisiert: {new Date(line.updated_at || line.created_at).toLocaleDateString('de-DE')}
</span>
</div>
</Link>
))}
</div>
) : (
<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-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Noch keine Produktionslinien vorhanden
</h3>
<p className="mt-2 text-gray-500 dark:text-gray-400 max-w-lg mx-auto">
Produktionslinien verketten mehrere CE-Projekte zu einer Fertigungsstrasse.
Sie sehen auf einen Blick den Risikostatus aller Stationen und koennen
Massnahmen priorisieren.
</p>
<Link
href="/sdk/iace/lines/new"
className="inline-block mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
>
Erste Produktionslinie erstellen
</Link>
</div>
)}
</div>
)
}
+50 -12
View File
@@ -2,6 +2,7 @@
import React, { useState, useEffect } from 'react'
import Link from 'next/link'
import { ProcessFlow } from './_components/ProcessFlow'
interface IACEProject {
id: string
@@ -10,7 +11,7 @@ interface IACEProject {
manufacturer: string
status: string
completeness_pct: number
risk_summary: {
risk_summary?: {
critical: number
high: number
medium: number
@@ -54,34 +55,35 @@ function CompletenessBar({ pct }: { pct: number }) {
)
}
function RiskDots({ summary }: { summary: IACEProject['risk_summary'] }) {
function RiskDots({ summary }: { summary?: IACEProject['risk_summary'] }) {
const s = summary || { critical: 0, high: 0, medium: 0, low: 0 }
return (
<div className="flex items-center gap-3 text-xs">
{summary.critical > 0 && (
{s.critical > 0 && (
<span className="flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded-full bg-red-500" />
<span className="text-gray-600">{summary.critical}</span>
<span className="text-gray-600">{s.critical}</span>
</span>
)}
{summary.high > 0 && (
{s.high > 0 && (
<span className="flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded-full bg-orange-500" />
<span className="text-gray-600">{summary.high}</span>
<span className="text-gray-600">{s.high}</span>
</span>
)}
{summary.medium > 0 && (
{s.medium > 0 && (
<span className="flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500" />
<span className="text-gray-600">{summary.medium}</span>
<span className="text-gray-600">{s.medium}</span>
</span>
)}
{summary.low > 0 && (
{s.low > 0 && (
<span className="flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded-full bg-green-500" />
<span className="text-gray-600">{summary.low}</span>
<span className="text-gray-600">{s.low}</span>
</span>
)}
{summary.critical === 0 && summary.high === 0 && summary.medium === 0 && summary.low === 0 && (
{s.critical === 0 && s.high === 0 && s.medium === 0 && s.low === 0 && (
<span className="text-gray-400">Keine Risiken</span>
)}
</div>
@@ -142,7 +144,13 @@ export default function IACEDashboardPage() {
const res = await fetch('/api/sdk/v1/iace/projects')
if (res.ok) {
const json = await res.json()
setProjects(json.projects || json || [])
const raw = json.projects || json || []
// Map API fields to frontend expectations
setProjects(raw.map((p: Record<string, unknown>) => ({
...p,
completeness_pct: p.completeness_pct ?? p.completeness_score ?? 0,
risk_summary: p.risk_summary || { critical: 0, high: 0, medium: 0, low: 0 },
})))
}
} catch (err) {
console.error('Failed to fetch IACE projects:', err)
@@ -219,6 +227,36 @@ export default function IACEDashboardPage() {
</button>
</div>
{/* Production Lines Quick Access */}
<Link
href="/sdk/iace/lines"
className="block bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20 rounded-xl border border-purple-200 dark:border-purple-800 p-6 hover:shadow-md hover:border-purple-300 transition-all group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/40 rounded-xl flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Produktionslinien
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Verkettete Fertigungsstrassen mit aggregierter Risikoansicht und animiertem Stationsfluss
</p>
</div>
</div>
<svg className="w-5 h-5 text-purple-400 group-hover:text-purple-600 transition-colors flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
{/* Process Flow */}
<ProcessFlow />
{/* Create Form */}
{showCreateForm && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
+2 -3
View File
@@ -5,7 +5,7 @@ import { usePathname, useSearchParams } from 'next/navigation'
import { SDKProvider } from '@/lib/sdk'
import { SDKSidebar } from '@/components/sdk/Sidebar/SDKSidebar'
import { CommandBar } from '@/components/sdk/CommandBar'
import { SDKPipelineSidebar } from '@/components/sdk/SDKPipelineSidebar'
// SDKPipelineSidebar removed — replaced by per-module FAB navigators
import { ComplianceAdvisorWidget } from '@/components/sdk/ComplianceAdvisorWidget'
import { CookieBannerOverlay, CookieBannerFAB } from '@/components/sdk/CookieBannerOverlay'
import { useSDK } from '@/lib/sdk'
@@ -208,8 +208,7 @@ function SDKInnerLayout({ children }: { children: React.ReactNode }) {
{/* Command Bar Modal */}
{isCommandBarOpen && <CommandBar onClose={() => setCommandBarOpen(false)} />}
{/* Pipeline Sidebar (FAB on mobile/tablet, fixed on desktop xl+) */}
<SDKPipelineSidebar />
{/* Module-specific FAB navigators are rendered by each module's layout */}
{/* Compliance Advisor Widget — immer sichtbar, auch ohne Projekt */}
<ComplianceAdvisorWidget currentStep={currentStep} />