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:
@@ -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) {
|
||||
|
||||
+274
@@ -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">✓</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">≈</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">✗</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">
|
||||
|
||||
+3
-3
@@ -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 >100 | Hoch 60-100 | Mittel 20-60 | Niedrig <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>
|
||||
|
||||
Reference in New Issue
Block a user