From e7f2f98da3874009c57ff7bfd963b917951522d7 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 7 May 2026 10:53:26 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20IACE=20CE-Compliance=20Module=20?= =?UTF-8?q?=E2=80=94=20Normen,=20Risikobewertung,=20Production=20Lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../app/api/sdk/v1/iace/[[...path]]/route.ts | 12 +- .../[projectId]/_components/IACEFlowFAB.tsx | 258 +++++++ .../_components/SuggestedNorms.tsx | 139 ++++ .../sdk/iace/[projectId]/components/page.tsx | 4 +- .../hazards/_components/AutoSuggestPanel.tsx | 6 +- .../_components/RiskAssessmentTable.tsx | 274 ++++++++ .../[projectId]/hazards/_hooks/useHazards.ts | 1 + .../app/sdk/iace/[projectId]/hazards/page.tsx | 26 +- .../_components/MitigationCard.tsx | 6 +- .../mitigations/_hooks/useMitigations.ts | 32 +- .../app/sdk/iace/[projectId]/page.tsx | 105 ++- .../sdk/iace/_components/NormsCoverage.tsx | 172 +++++ .../app/sdk/iace/_components/ProcessFlow.tsx | 369 ++++++++++ admin-compliance/app/sdk/iace/layout.tsx | 13 + .../[lineId]/_components/AggregatePanel.tsx | 74 ++ .../[lineId]/_components/StationCard.tsx | 165 +++++ .../[lineId]/_components/StationIcons.tsx | 199 ++++++ .../[lineId]/_components/TransferLine.tsx | 59 ++ .../app/sdk/iace/lines/[lineId]/page.tsx | 194 ++++++ admin-compliance/app/sdk/iace/lines/_types.ts | 66 ++ admin-compliance/app/sdk/iace/lines/page.tsx | 135 ++++ admin-compliance/app/sdk/iace/page.tsx | 62 +- admin-compliance/app/sdk/layout.tsx | 5 +- .../components/sdk/CookieBannerOverlay.tsx | 8 +- .../e2e/playwright-live.config.ts | 13 + .../e2e/specs/iace-module.spec.ts | 328 +++++++++ .../api/handlers/iace_handler_hazards.go | 20 +- .../api/handlers/iace_handler_mitigations.go | 42 ++ .../api/handlers/iace_handler_norms.go | 196 ++++++ .../handlers/iace_handler_production_lines.go | 156 +++++ ai-compliance-sdk/internal/app/routes.go | 11 + .../internal/iace/hazard_pattern_types.go | 8 + .../internal/iace/hazard_patterns.go | 447 +----------- .../internal/iace/hazard_patterns_ai.go | 82 +++ .../internal/iace/hazard_patterns_cobot.go | 42 ++ .../internal/iace/hazard_patterns_cyber.go | 82 +++ .../iace/hazard_patterns_electrical.go | 82 +++ .../iace/hazard_patterns_environment.go | 99 +++ .../iace/hazard_patterns_extended3.go | 652 ++++++++++++++++++ .../iace/hazard_patterns_extended_dguv.go | 80 +++ .../internal/iace/hazard_patterns_fluid.go | 67 ++ .../iace/hazard_patterns_mechanical.go | 157 +++++ .../iace/hazard_patterns_operational.go | 224 +++++- .../internal/iace/hazard_patterns_press.go | 84 +++ .../internal/iace/hazard_patterns_software.go | 98 +++ .../internal/iace/hazard_patterns_thermal.go | 52 ++ .../internal/iace/models_production_line.go | 95 +++ .../internal/iace/norms_engine.go | 168 +++++ .../internal/iace/norms_library.go | 414 +++++++++++ .../internal/iace/norms_library_b2_ext.go | 372 ++++++++++ .../internal/iace/norms_library_c.go | 409 +++++++++++ .../internal/iace/norms_library_c_ext.go | 290 ++++++++ .../internal/iace/norms_library_c_food_pkg.go | 447 ++++++++++++ .../iace/norms_library_c_lift_misc.go | 409 +++++++++++ .../iace/norms_library_c_wood_metal.go | 396 +++++++++++ .../internal/iace/pattern_engine.go | 30 +- .../internal/iace/store_mitigations.go | 52 ++ .../internal/iace/store_production_lines.go | 325 +++++++++ .../migrations/023_iace_production_lines.sql | 38 + 59 files changed, 8326 insertions(+), 525 deletions(-) create mode 100644 admin-compliance/app/sdk/iace/[projectId]/_components/IACEFlowFAB.tsx create mode 100644 admin-compliance/app/sdk/iace/[projectId]/_components/SuggestedNorms.tsx create mode 100644 admin-compliance/app/sdk/iace/[projectId]/hazards/_components/RiskAssessmentTable.tsx create mode 100644 admin-compliance/app/sdk/iace/_components/NormsCoverage.tsx create mode 100644 admin-compliance/app/sdk/iace/_components/ProcessFlow.tsx create mode 100644 admin-compliance/app/sdk/iace/lines/[lineId]/_components/AggregatePanel.tsx create mode 100644 admin-compliance/app/sdk/iace/lines/[lineId]/_components/StationCard.tsx create mode 100644 admin-compliance/app/sdk/iace/lines/[lineId]/_components/StationIcons.tsx create mode 100644 admin-compliance/app/sdk/iace/lines/[lineId]/_components/TransferLine.tsx create mode 100644 admin-compliance/app/sdk/iace/lines/[lineId]/page.tsx create mode 100644 admin-compliance/app/sdk/iace/lines/_types.ts create mode 100644 admin-compliance/app/sdk/iace/lines/page.tsx create mode 100644 admin-compliance/e2e/playwright-live.config.ts create mode 100644 admin-compliance/e2e/specs/iace-module.spec.ts create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_norms.go create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_production_lines.go create mode 100644 ai-compliance-sdk/internal/iace/hazard_patterns_ai.go create mode 100644 ai-compliance-sdk/internal/iace/hazard_patterns_cyber.go create mode 100644 ai-compliance-sdk/internal/iace/hazard_patterns_electrical.go create mode 100644 ai-compliance-sdk/internal/iace/hazard_patterns_environment.go create mode 100644 ai-compliance-sdk/internal/iace/hazard_patterns_extended3.go create mode 100644 ai-compliance-sdk/internal/iace/hazard_patterns_fluid.go create mode 100644 ai-compliance-sdk/internal/iace/hazard_patterns_mechanical.go create mode 100644 ai-compliance-sdk/internal/iace/hazard_patterns_software.go create mode 100644 ai-compliance-sdk/internal/iace/hazard_patterns_thermal.go create mode 100644 ai-compliance-sdk/internal/iace/models_production_line.go create mode 100644 ai-compliance-sdk/internal/iace/norms_engine.go create mode 100644 ai-compliance-sdk/internal/iace/norms_library.go create mode 100644 ai-compliance-sdk/internal/iace/norms_library_b2_ext.go create mode 100644 ai-compliance-sdk/internal/iace/norms_library_c.go create mode 100644 ai-compliance-sdk/internal/iace/norms_library_c_ext.go create mode 100644 ai-compliance-sdk/internal/iace/norms_library_c_food_pkg.go create mode 100644 ai-compliance-sdk/internal/iace/norms_library_c_lift_misc.go create mode 100644 ai-compliance-sdk/internal/iace/norms_library_c_wood_metal.go create mode 100644 ai-compliance-sdk/internal/iace/store_production_lines.go create mode 100644 ai-compliance-sdk/migrations/023_iace_production_lines.sql diff --git a/admin-compliance/app/api/sdk/v1/iace/[[...path]]/route.ts b/admin-compliance/app/api/sdk/v1/iace/[[...path]]/route.ts index 378fa6a..6c620bc 100644 --- a/admin-compliance/app/api/sdk/v1/iace/[[...path]]/route.ts +++ b/admin-compliance/app/api/sdk/v1/iace/[[...path]]/route.ts @@ -30,15 +30,15 @@ async function proxyRequest( headers['Authorization'] = authHeader } + // Default tenant/user for IACE (same pattern as training proxy) + const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' + const DEFAULT_USER = '00000000-0000-0000-0000-000000000001' + const tenantHeader = request.headers.get('x-tenant-id') - if (tenantHeader) { - headers['X-Tenant-Id'] = tenantHeader - } + headers['X-Tenant-Id'] = tenantHeader || DEFAULT_TENANT const userHeader = request.headers.get('x-user-id') - if (userHeader) { - headers['X-User-Id'] = userHeader - } + headers['X-User-Id'] = userHeader || DEFAULT_USER const fetchOptions: RequestInit = { method, diff --git a/admin-compliance/app/sdk/iace/[projectId]/_components/IACEFlowFAB.tsx b/admin-compliance/app/sdk/iace/[projectId]/_components/IACEFlowFAB.tsx new file mode 100644 index 0000000..d8b1388 --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/_components/IACEFlowFAB.tsx @@ -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(null) + const fabRef = useRef(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 ( +
+ {/* Expanded Panel */} +
+ {/* Header */} +
+

+ CE-Prozessschritte +

+

+ {completedCount}/{totalSteps} Schritte im Tool +

+
+ + {/* Steps */} +
+ {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 = ( +
+ {/* Step number circle */} +
+ {isActive ? ( + + ) : !isExternal ? ( + + + + ) : ( + step.step + )} +
+ + {/* Label */} +
+ + {step.label} + + {(step.note || isExternal) && ( + + {step.note || '(extern)'} + + )} +
+ + {/* Step badge */} + + #{step.step} + +
+ ) + + if (fullHref && !isExternal) { + return ( + + {rowContent} + + ) + } + return
{rowContent}
+ })} +
+ + {/* Prev/Next navigation */} +
+ + + {currentNavIndex >= 0 ? currentNavIndex + 1 : '-'}/{navigableSteps.length} + + +
+
+ + {/* FAB Button */} + +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/[projectId]/_components/SuggestedNorms.tsx b/admin-compliance/app/sdk/iace/[projectId]/_components/SuggestedNorms.tsx new file mode 100644 index 0000000..5e6d881 --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/_components/SuggestedNorms.tsx @@ -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 = { + 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(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 ( +
+ + + {!collapsed && ( +
+ {/* Legend */} +
+ {Object.entries(TYPE_CONFIG).map(([key, cfg]) => ( + {cfg.label}: {cfg.desc} + ))} +
+ + {/* 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 ( +
+

+ {cfg.label} ({norms.length}) +

+
+ {norms.map((s) => ( +
+
+
+ + {s.norm.number} + + {s.norm.mandatory && ( + + Pflicht + + )} + + {Math.round(s.confidence * 100)}% + +
+

{s.norm.title_de}

+

{s.norm.scope_de}

+

+ Grund: {s.reason} +

+
+
+ ))} +
+
+ ) + })} + + {/* Disclaimer */} +
+ Hinweis: 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). +
+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/[projectId]/components/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/components/page.tsx index f266029..1809a5e 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/components/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/components/page.tsx @@ -115,7 +115,7 @@ export default function ComponentsPage() { ) : ( - !c.showForm && ( + !showForm && (
@@ -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 -
+
+

Risikobewertungstabelle (ISO 12100)

+ {hazards.length} Gefaehrdungen +
+ +
+ + + {/* Group header */} + + + + + + + + {/* Column header */} + + + + {/* Initial */} + + + + + + {/* After */} + + + + + + + {/* SIL/PL */} + + + {/* Status */} + + + + + + {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 ( + + {/* Hazard info */} + + + {/* Initial S/E/P/RPZ/Risk */} + + + + + + {/* After measures (editable) */} + + + + + + + {/* SIL / PL */} + + + {/* Status */} + + + + ) + })} + +
GefaehrdungErstbewertungNach Massnahmen (editierbar)SIL / PLStatus
BezeichnungKategorieSEPRPZRisikoSEPRPZRisikoSILPLMassn.Akzeptabel
+
{h.name}
+ {h.component_name &&
{h.component_name}
} +
+ + {CATEGORY_LABELS[h.category] || h.category} + + {h.severity}{h.exposure}{h.probability}{initRpz} + + {getRiskLevelLabel(h.risk_level)} + + {e && updateEdit(h.id, 'severity', v)} label="S nach" />}{e && updateEdit(h.id, 'exposure', v)} label="E nach" />}{e && updateEdit(h.id, 'probability', v)} label="P nach" />}{afterRpz} + + {getRiskLevelLabel(afterLevel)} + + + {changed && ( + + )} + + + {sil > 0 ? `SIL ${sil}` : '-'} + + + + PL {pl} + + + 0 ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}> + {mc} + + + {afterRpz <= 20 ? ( + + ) : afterRpz <= 60 ? ( + + ) : ( + + )} +
+
+ + {hazards.length === 0 && ( +
+ Keine Gefaehrdungen vorhanden. Fuegen Sie zuerst Gefaehrdungen hinzu. +
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/[projectId]/hazards/_hooks/useHazards.ts b/admin-compliance/app/sdk/iace/[projectId]/hazards/_hooks/useHazards.ts index 96171d5..72a02c9 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/hazards/_hooks/useHazards.ts +++ b/admin-compliance/app/sdk/iace/[projectId]/hazards/_hooks/useHazards.ts @@ -169,5 +169,6 @@ export function useHazards(projectId: string) { suggestingAI, matchingPatterns, matchResult, setMatchResult, applyingPatterns, fetchLibrary, handleAddFromLibrary, handleSubmit, handleAISuggestions, handlePatternMatching, handleApplyPatterns, handleDelete, + refetch: fetchHazards, } } diff --git a/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx index 06b7b34..0c35641 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx @@ -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('list') if (h.loading) { return ( @@ -29,6 +33,16 @@ export default function HazardsPage() {

Gefaehrdungsanalyse mit 4-Faktor-Risikobewertung (S x F x P x A).

+
+ + +
- {h.matchResult && h.matchResult.matched_patterns.length > 0 && ( + {h.matchResult && h.matchResult.matched_patterns?.length > 0 && ( h.setMatchResult(null)} /> )} - {h.matchResult && h.matchResult.matched_patterns.length === 0 && ( + {h.matchResult && (!h.matchResult.matched_patterns || h.matchResult.matched_patterns.length === 0) && (
@@ -121,7 +135,11 @@ export default function HazardsPage() { )} {h.hazards.length > 0 ? ( - + view === 'risk' ? ( + + ) : ( + + ) ) : ( !h.showForm && (
diff --git a/admin-compliance/app/sdk/iace/[projectId]/mitigations/_components/MitigationCard.tsx b/admin-compliance/app/sdk/iace/[projectId]/mitigations/_components/MitigationCard.tsx index 8fc470f..66b1384 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/mitigations/_components/MitigationCard.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/mitigations/_components/MitigationCard.tsx @@ -16,8 +16,8 @@ export function MitigationCard({
-

{mitigation.title}

- {mitigation.title.startsWith('Auto:') && ( +

{mitigation.title || ''}

+ {(mitigation.title || '').startsWith('Auto:') && ( Auto @@ -28,7 +28,7 @@ export function MitigationCard({ {mitigation.description && (

{mitigation.description}

)} - {mitigation.linked_hazard_names.length > 0 && ( + {(mitigation.linked_hazard_names || []).length > 0 && (
{mitigation.linked_hazard_names.map((name, i) => ( diff --git a/admin-compliance/app/sdk/iace/[projectId]/mitigations/_hooks/useMitigations.ts b/admin-compliance/app/sdk/iace/[projectId]/mitigations/_hooks/useMitigations.ts index 363d545..73ac768 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/mitigations/_hooks/useMitigations.ts +++ b/admin-compliance/app/sdk/iace/[projectId]/mitigations/_hooks/useMitigations.ts @@ -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) => ({ + 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) diff --git a/admin-compliance/app/sdk/iace/[projectId]/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/page.tsx index ee0b113..9491baa 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/page.tsx @@ -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) => 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) => ({ + 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() {
- {/* Risk Summary */} + {/* Risk Summary — live from /risk-summary endpoint */}

Risikozusammenfassung

-
- - - - + {/* Risk level bars */} +
+ {[ + { 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 ( +
+ {level.label} +
+
+
+ {level.value} +
+ ) + })}
+ {/* Counts */}
{project.component_count}
@@ -252,6 +330,10 @@ export default function ProjectOverviewPage() {
Massnahmen
+ {/* RPZ threshold info */} +
+ RPZ-Schwellen: Kritisch >100 | Hoch 60-100 | Mittel 20-60 | Niedrig <20 +
{/* Completeness Gates */} @@ -267,6 +349,9 @@ export default function ProjectOverviewPage() {
+ {/* Suggested Norms */} + + {/* Quick Actions */}

Schnellzugriff

diff --git a/admin-compliance/app/sdk/iace/_components/NormsCoverage.tsx b/admin-compliance/app/sdk/iace/_components/NormsCoverage.tsx new file mode 100644 index 0000000..5fdfe3e --- /dev/null +++ b/admin-compliance/app/sdk/iace/_components/NormsCoverage.tsx @@ -0,0 +1,172 @@ +'use client' + +import { useState, useEffect } from 'react' + +interface NormStats { + total: number + byType: Record + categories: string[] +} + +const TYPE_INFO: Record = { + 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 = { + 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(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 = {} + const machineTypes = new Set() + 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 = { + 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() + 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 ( +
+ + + {expanded && ( +
+ + + + + + + + + + {(['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 ( + + + + + + ) + })} + +
TypAnzahlAbdeckung
+ + {info.label} + + {count}{desc}
+ +
+ Alle Normen mit Abschnittsnummern und{' '} + + Beuth-Kauflinks + {' '} + hinterlegt. Die vollstaendige Bibliothek ist unter "Normenrecherche" in jedem Projekt einsehbar. +
+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/_components/ProcessFlow.tsx b/admin-compliance/app/sdk/iace/_components/ProcessFlow.tsx new file mode 100644 index 0000000..5f38480 --- /dev/null +++ b/admin-compliance/app/sdk/iace/_components/ProcessFlow.tsx @@ -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 = { + 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 ( +
+ {/* Timeline connector */} +
+
+ {step.number} +
+ {step.number < 15 && ( +
+ )} +
+ + {/* Card */} +
+
+

+ {step.title} +

+
+ + {style.badgeText} + +
+
+

+ {step.description} +

+
+ + + + + {step.actor} + + {step.toolNote && ( + + + + + {step.toolNote} + + )} +
+
+
+ ) +} + +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 ( +
+ {/* Header — always visible */} + + + {/* Content — collapsible */} + {!collapsed && ( +
+ {/* Legend */} +
+ + + Im Tool abgedeckt + + + + Teilweise abgedeckt + + + + Nicht im Tool + + + + Geplant + +
+ + {/* Timeline */} +
+ {CE_PROCESS_STEPS.map((step) => ( + + ))} +
+ + {/* Norms Coverage Table */} +
+ +
+ + {/* Disclaimers */} +
+
+

+ Hinweis: 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. +

+
+ +
+

Normenrecherche — Rechtliche Grundlage

+
+
+

Was dieses Tool anzeigt:

+
    +
  • Normennummern (z.B. "ISO 13857:2019") — Identifikatoren, kein geschuetzter Text
  • +
  • Offizielle Normentitel — bibliografische Information
  • +
  • Abschnittsnummern (z.B. "Abschnitt 4.2, Tabelle 1") — Verweisadressen
  • +
  • Eigene Zusammenfassungen des Regelungsbereichs — unser Text, nicht Normtext
  • +
+
+
+

Was dieses Tool NICHT anzeigt:

+
    +
  • Normtext (auch nicht auszugsweise) — urheberrechtlich geschuetzt durch DIN/ISO/CEN
  • +
  • Tabellenwerte oder Grenzwerte aus Normen
  • +
  • Diagramme oder Bilder aus Normen
  • +
+
+

+ Normtexte muessen separat beschafft werden, z.B. ueber{' '} + + www.beuth.de + {' '} + (DIN-Normen) oder{' '} + + www.iso.org + . +

+
+
+
+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/layout.tsx b/admin-compliance/app/sdk/iace/layout.tsx index 05ae2e6..743a191 100644 --- a/admin-compliance/app/sdk/iace/layout.tsx +++ b/admin-compliance/app/sdk/iace/layout.tsx @@ -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 })

CE-Compliance

+ + + + + Produktionslinien +
) } diff --git a/admin-compliance/app/sdk/iace/lines/[lineId]/_components/AggregatePanel.tsx b/admin-compliance/app/sdk/iace/lines/[lineId]/_components/AggregatePanel.tsx new file mode 100644 index 0000000..8b769b3 --- /dev/null +++ b/admin-compliance/app/sdk/iace/lines/[lineId]/_components/AggregatePanel.tsx @@ -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 ( +
+ {/* Title row */} +
+
+

+ {line.name} +

+ {line.description && ( +

+ {line.description} +

+ )} +
+
+ Erstellt: {new Date(line.created_at).toLocaleDateString('de-DE')} +
+
+ + {/* Stats row */} +
+ + + +
+ + {/* Risk dots row */} +
+ {RISK_DOTS.map((rd) => { + const count = aggregate[rd.key] || 0 + return ( + + + {count} + {rd.label} + + ) + })} +
+
+ ) +} + +function StatPill({ label, value }: { label: string; value: number }) { + return ( +
+ {value} + {label} +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/lines/[lineId]/_components/StationCard.tsx b/admin-compliance/app/sdk/iace/lines/[lineId]/_components/StationCard.tsx new file mode 100644 index 0000000..790ee1b --- /dev/null +++ b/admin-compliance/app/sdk/iace/lines/[lineId]/_components/StationCard.tsx @@ -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 = { + 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 = { + 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 ( +
+ {/* Color accent bar */} +
+ + {/* Collapsed content */} +
+ {/* Icon + name */} +
+
+ +
+
+
+ {station.station.station_label} +
+
+ {station.project_name} +
+
+
+ + {/* Hazard count */} +
+ {station.hazard_count} Gefaehrdungen +
+ + {/* Completeness bar */} +
+
+
+
+ + {pctBar}% + +
+ + {/* SIL / PL */} + {(station.sil_max || station.pl_max) && ( +
+ {station.sil_max && SIL {station.sil_max}} + {station.sil_max && station.pl_max && |} + {station.pl_max && PL {station.pl_max}} +
+ )} + + {/* Toggle button */} + +
+ + {/* Expanded content */} + {expanded && ( +
+ {/* Risk breakdown */} +
+ {RISK_LEVELS.map((level) => { + const count = station.risk_summary[level.key] || 0 + const pct = totalRisk > 0 ? Math.round((count / totalRisk) * 100) : 0 + return ( +
+ {level.label} +
+
+
+ {count} +
+ ) + })} +
+ + {/* Mitigation count */} +
+ Massnahmen + {station.mitigation_count} +
+ + {/* Status badge */} +
+ Status + + {STATUS_LABELS[station.status] || station.status} + +
+ + {/* Link to project */} + + Zum Projekt → + +
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/lines/[lineId]/_components/StationIcons.tsx b/admin-compliance/app/sdk/iace/lines/[lineId]/_components/StationIcons.tsx new file mode 100644 index 0000000..2ac2abe --- /dev/null +++ b/admin-compliance/app/sdk/iace/lines/[lineId]/_components/StationIcons.tsx @@ -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 ( + + {/* Ram pressing down */} + + + + + {/* Base block */} + + {/* Workpiece */} + + + ) + + case 'robot': + case 'cobot': + case 'collaborative_robot': + return ( + + {/* Base */} + + {/* Lower arm */} + + {/* Joint */} + + {/* Upper arm */} + + {/* Wrist joint */} + + {/* Gripper */} + + + + + ) + + case 'conveyor': + return ( + + {/* Belt top */} + + {/* Belt bottom */} + + {/* Left roller */} + + {/* Right roller */} + + {/* Flow arrows */} + + + {/* Package on belt */} + + + ) + + case 'assembly': + return ( + + {/* Gear */} + + + {/* Gear teeth */} + + + + + + + + + + ) + + case 'milling': + return ( + + {/* Spindle */} + + {/* Cutter head */} + + {/* Rotation arc */} + + + {/* Workpiece / table */} + + + + ) + + case 'turning': + return ( + + {/* Chuck / rotating workpiece */} + + + {/* Tool holder */} + + + {/* Rotation arrow */} + + + + ) + + case 'welding': + return ( + + {/* Torch body */} + + + {/* Weld point */} + + {/* Sparks */} + + + + + {/* Workpiece */} + + + ) + + case 'inspection': + return ( + + {/* Magnifying glass */} + + + {/* Checkmark inside */} + + + ) + + case 'packaging': + return ( + + {/* Box */} + + + + + + ) + + case 'motor': + case 'electric_motor': + return ( + + {/* Motor body circle */} + + {/* Lightning bolt */} + + {/* Shaft */} + + + ) + + case 'rotary_transfer': + case 'rotary_transfer_machine': + return ( + + {/* Circular path */} + + {/* Center */} + + {/* Station dots around circle */} + + + + + + + {/* Rotation arrow */} + + + ) + + default: + return ( + + + + + ) + } +} diff --git a/admin-compliance/app/sdk/iace/lines/[lineId]/_components/TransferLine.tsx b/admin-compliance/app/sdk/iace/lines/[lineId]/_components/TransferLine.tsx new file mode 100644 index 0000000..710bb87 --- /dev/null +++ b/admin-compliance/app/sdk/iace/lines/[lineId]/_components/TransferLine.tsx @@ -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 ( +
+ + + {/* Background line */} + + {/* Animated running dots */} + + {/* Arrowhead */} + + + {/* Label */} + {transfer.label && ( + + {transfer.label} + + )} +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/lines/[lineId]/page.tsx b/admin-compliance/app/sdk/iace/lines/[lineId]/page.tsx new file mode 100644 index 0000000..a8cd56b --- /dev/null +++ b/admin-compliance/app/sdk/iace/lines/[lineId]/page.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [expandedStation, setExpandedStation] = useState(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 ( +
+
+
+ ) + } + + if (error || !dashboard) { + return ( +
+

+ {error || 'Produktionslinie nicht gefunden'} +

+ + Zurueck zur Uebersicht + +
+ ) + } + + 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 ( +
+ {/* Back link */} + + + + + Alle Produktionslinien + + + {/* Aggregate panel */} + + + {/* Station flow */} +
+

+ Stationsuebersicht +

+ +
+ {rows.map((row, rowIndex) => ( + + ))} +
+
+
+ ) +} + +/** 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 ( +
+ {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 ( + + onToggle(station.station.id)} + /> + {transfer && ( + + )} + + ) + })} +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/lines/_types.ts b/admin-compliance/app/sdk/iace/lines/_types.ts new file mode 100644 index 0000000..35d78ed --- /dev/null +++ b/admin-compliance/app/sdk/iace/lines/_types.ts @@ -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 + 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 +} + +export const STATION_TYPES: Record = { + 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 = { + conveyor: '#22C55E', + robot: '#3B82F6', + manual: '#EAB308', + crane: '#F97316', + agv: '#8B5CF6', +} diff --git a/admin-compliance/app/sdk/iace/lines/page.tsx b/admin-compliance/app/sdk/iace/lines/page.tsx new file mode 100644 index 0000000..5f37b06 --- /dev/null +++ b/admin-compliance/app/sdk/iace/lines/page.tsx @@ -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([]) + 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 ( +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+ + + + + IACE + +
+

+ Produktionslinien +

+

+ Verkettete Fertigungsstrassen mit aggregierter Risikoansicht +

+
+ + + + + Neue Produktionslinie + +
+ + {/* Lines list */} + {lines.length > 0 ? ( +
+ {lines.map((line) => ( + +

+ {line.name} +

+ {line.description && ( +

+ {line.description} +

+ )} +
+ + + + + {line.station_count} Stationen + + + Aktualisiert: {new Date(line.updated_at || line.created_at).toLocaleDateString('de-DE')} + +
+ + ))} +
+ ) : ( +
+
+ + + +
+

+ Noch keine Produktionslinien vorhanden +

+

+ Produktionslinien verketten mehrere CE-Projekte zu einer Fertigungsstrasse. + Sie sehen auf einen Blick den Risikostatus aller Stationen und koennen + Massnahmen priorisieren. +

+ + Erste Produktionslinie erstellen + +
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/page.tsx b/admin-compliance/app/sdk/iace/page.tsx index ef70f1c..2f56c6b 100644 --- a/admin-compliance/app/sdk/iace/page.tsx +++ b/admin-compliance/app/sdk/iace/page.tsx @@ -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 (
- {summary.critical > 0 && ( + {s.critical > 0 && ( - {summary.critical} + {s.critical} )} - {summary.high > 0 && ( + {s.high > 0 && ( - {summary.high} + {s.high} )} - {summary.medium > 0 && ( + {s.medium > 0 && ( - {summary.medium} + {s.medium} )} - {summary.low > 0 && ( + {s.low > 0 && ( - {summary.low} + {s.low} )} - {summary.critical === 0 && summary.high === 0 && summary.medium === 0 && summary.low === 0 && ( + {s.critical === 0 && s.high === 0 && s.medium === 0 && s.low === 0 && ( Keine Risiken )}
@@ -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) => ({ + ...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() {
+ {/* Production Lines Quick Access */} + +
+
+
+ + + +
+
+

+ Produktionslinien +

+

+ Verkettete Fertigungsstrassen mit aggregierter Risikoansicht und animiertem Stationsfluss +

+
+
+ + + +
+ + + {/* Process Flow */} + + {/* Create Form */} {showCreateForm && (
diff --git a/admin-compliance/app/sdk/layout.tsx b/admin-compliance/app/sdk/layout.tsx index b475c20..37a1dfa 100644 --- a/admin-compliance/app/sdk/layout.tsx +++ b/admin-compliance/app/sdk/layout.tsx @@ -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 && setCommandBarOpen(false)} />} - {/* Pipeline Sidebar (FAB on mobile/tablet, fixed on desktop xl+) */} - + {/* Module-specific FAB navigators are rendered by each module's layout */} {/* Compliance Advisor Widget — immer sichtbar, auch ohne Projekt */} diff --git a/admin-compliance/components/sdk/CookieBannerOverlay.tsx b/admin-compliance/components/sdk/CookieBannerOverlay.tsx index bad67b6..372f52d 100644 --- a/admin-compliance/components/sdk/CookieBannerOverlay.tsx +++ b/admin-compliance/components/sdk/CookieBannerOverlay.tsx @@ -93,11 +93,9 @@ export function CookieBannerOverlay() { return ( <> - {/* Overlay — leaves sidebar (left 64px/16px) accessible */} -
setIsOpen(false)} /> - -
-
+ {/* Non-blocking banner — no overlay, no pointer-events blocking */} +
+
{/* Header with EWR toggle + close button */}
diff --git a/admin-compliance/e2e/playwright-live.config.ts b/admin-compliance/e2e/playwright-live.config.ts new file mode 100644 index 0000000..685c4fa --- /dev/null +++ b/admin-compliance/e2e/playwright-live.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './specs', + timeout: 30000, + use: { + baseURL: 'https://macmini:3007', + ignoreHTTPSErrors: true, + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], +}) diff --git a/admin-compliance/e2e/specs/iace-module.spec.ts b/admin-compliance/e2e/specs/iace-module.spec.ts new file mode 100644 index 0000000..9b9000a --- /dev/null +++ b/admin-compliance/e2e/specs/iace-module.spec.ts @@ -0,0 +1,328 @@ +import { test, expect, Page } from '@playwright/test' + +/** + * IACE (CE-Compliance) Module — Comprehensive E2E Tests + * + * Tests all 4 seeded projects across every tab: + * Overview, Components, Hazards, Mitigations, Verification, Evidence, Tech-File, Monitoring. + * + * Run with: + * npx playwright test e2e/specs/iace-module.spec.ts --config e2e/playwright-live.config.ts --reporter=list + */ + +const BASE = 'https://macmini:3007' + +const PROJECTS = [ + { + id: 'bb7d5b88-469d-401f-a0e3-ae5b867e4a1c', + name: 'Kniehebelpresse HP-500', + expectedComps: 14, + expectedHazards: 8, + expectedMeasures: 20, + }, + { + id: 'a4c4031e-75a5-461e-a575-159f1eabd6b3', + name: 'EIGENBAU-Zelle (Cobot)', + expectedComps: 7, + expectedHazards: 8, + expectedMeasures: 26, + }, + { + id: 'c43af8df-14e0-43ff-b26f-ab425f803e53', + name: 'Gleichstrom-/Asynchronmotor', + expectedComps: 6, + expectedHazards: 6, + expectedMeasures: 16, + }, + { + id: '3e0808b2-2eed-4e82-b35d-6dd6857bc379', + name: 'Schwingarm-Rundtaktanlage', + expectedComps: 7, + expectedHazards: 10, + expectedMeasures: 38, + }, +] as const + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Dismiss the cookie consent banner if present (blocks all clicks). */ +async function dismissCookieBanner(page: Page) { + try { + const acceptBtn = page.locator('button', { hasText: 'Nur notwendige Cookies' }) + if (await acceptBtn.isVisible({ timeout: 3000 })) { + await acceptBtn.click({ force: true }) + await page.waitForTimeout(500) + } + } catch { + // Banner not present or already dismissed + } +} + +/** Navigate, wait for async data, and dismiss cookie overlay. */ +async function goTo(page: Page, path: string) { + await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle', timeout: 30000 }) + await page.waitForTimeout(2000) + await dismissCookieBanner(page) +} + +/** Assert that no Next.js / React error overlay is present. */ +async function assertNoAppError(page: Page) { + const body = await page.textContent('body') + expect(body).not.toContain('Application error') + expect(body).not.toContain('Unhandled Runtime Error') +} + +// --------------------------------------------------------------------------- +// 1. IACE Start Page (/sdk/iace) +// --------------------------------------------------------------------------- + +test.describe('IACE Start Page', () => { + test.setTimeout(60_000) + + test('page loads without error', async ({ page }) => { + await goTo(page, '/sdk/iace') + await assertNoAppError(page) + // The page title (h1) should contain CE-Compliance + const body = await page.innerText('body') + expect(body).toContain('CE-Compliance (IACE)') + }) + + test('page description text visible', async ({ page }) => { + await goTo(page, '/sdk/iace') + const body = await page.innerText('body') + expect(body).toContain('Industrial AI Compliance Engine') + }) + + test('create project button visible', async ({ page }) => { + await goTo(page, '/sdk/iace') + await expect( + page.locator('button', { hasText: 'Neues Projekt erstellen' }) + ).toBeVisible({ timeout: 10000 }) + }) + + test('sidebar navigation has IACE link', async ({ page }) => { + await goTo(page, '/sdk/iace') + // The SDK sidebar should have "CE-Compliance (IACE)" as a link + const body = await page.innerText('body') + expect(body).toContain('CE-Compliance (IACE)') + }) +}) + +// --------------------------------------------------------------------------- +// 2–9. Per-project tests +// --------------------------------------------------------------------------- + +for (const project of PROJECTS) { + test.describe(`Project: ${project.name}`, () => { + test.setTimeout(60_000) + + // ------ Overview ------ + test('overview page loads', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}`) + await assertNoAppError(page) + await expect(page.locator('h1')).toContainText(project.name, { timeout: 15000 }) + }) + + test('overview — status workflow visible', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}`) + // Status workflow or risk summary should be visible + await expect( + page.locator('text=Projektstatus').or(page.locator('text=Risikozusammenfassung')) + ).toBeVisible({ timeout: 10000 }) + }) + + test('overview — risk summary section', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}`) + await expect( + page.locator('text=Risikozusammenfassung') + ).toBeVisible({ timeout: 10000 }) + }) + + test('overview — component/hazard/measure counters', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}`) + // The risk summary card has three counters + const body = await page.innerText('body') + expect(body).toContain('Komponenten') + expect(body).toContain('Gefaehrdungen') + }) + + test('overview — completeness gates section', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}`) + await expect( + page.locator('text=Completeness Gates') + ).toBeVisible({ timeout: 10000 }) + }) + + test('overview — quick actions present', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}`) + await expect(page.locator('text=Schnellzugriff')).toBeVisible({ timeout: 10000 }) + await expect(page.locator('text=Komponenten verwalten')).toBeVisible({ timeout: 10000 }) + await expect(page.locator('text=Hazard Log oeffnen')).toBeVisible({ timeout: 10000 }) + }) + + test('overview — IACE sidebar navigation visible', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}`) + // Sidebar with nav items (may be in aside or nav element) + await expect( + page.locator('text=Uebersicht').or(page.locator('text=Hazard Log')).first() + ).toBeVisible({ timeout: 10000 }) + }) + + test('overview — no crash from norms API', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}`) + await assertNoAppError(page) + }) + + // ------ Components ------ + test('components tab loads', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}/components`) + await assertNoAppError(page) + await expect(page.locator('h1')).toContainText('Komponenten', { timeout: 10000 }) + }) + + test('components — "Aus Bibliothek waehlen" button', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}/components`) + await expect( + page.locator('button', { hasText: 'Aus Bibliothek waehlen' }) + ).toBeVisible({ timeout: 10000 }) + }) + + test('components — "Komponente hinzufuegen" button', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}/components`) + await expect( + page.locator('button', { hasText: 'Komponente hinzufuegen' }) + ).toBeVisible({ timeout: 10000 }) + }) + + // ------ Hazards ------ + test('hazards tab loads', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}/hazards`) + await assertNoAppError(page) + await expect(page.locator('h1')).toContainText('Hazard Log', { timeout: 10000 }) + }) + + test('hazards — view toggle exists', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}/hazards`) + await expect( + page.locator('button', { hasText: 'Hazard-Liste' }) + ).toBeVisible({ timeout: 10000 }) + await expect( + page.locator('button', { hasText: 'Risikobewertung' }) + ).toBeVisible({ timeout: 10000 }) + }) + + test('hazards — switch to risk assessment view', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}/hazards`) + // Click the "Risikobewertung" toggle + await page.locator('button', { hasText: 'Risikobewertung' }).click() + // Wait for the RiskAssessmentTable to render (fetches mitigations) + await page.waitForTimeout(3000) + await assertNoAppError(page) + // Risk assessment table renders