feat(iace-frontend): Risikobewertung tab with dual risk model + live formula
CI / detect-changes (push) Successful in 7s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 11s
CI / nodejs-build (push) Successful in 2m23s
CI / test-go (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / loc-budget (push) Successful in 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 7s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 11s
CI / nodejs-build (push) Successful in 2m23s
CI / test-go (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / loc-budget (push) Successful in 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
New tab /sdk/iace/[projectId]/risikobewertung. Per hazard it shows BOTH models side by side — EN-62061-style (S/F/W/P) and Fine-Kinney (P/E/C) — with BreakPilot's justified suggested values from public data, the visible formula, and editable fields that recompute the score + risk band live. The professional adjusts the values (e.g. from his own licensed DIN/Beuth data); we only supply the formula + inputs, reproduce no norm table. Consumes GET .../hazards/:hid/risk-suggestion. Registered in IACE_NAV_ITEMS. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+160
@@ -0,0 +1,160 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { HazardLite, RiskSuggestion } from '../_hooks/useRiskAssessment'
|
||||||
|
|
||||||
|
function enLevel(idx: number): string {
|
||||||
|
if (idx >= 45) return 'kritisch'
|
||||||
|
if (idx >= 30) return 'hoch'
|
||||||
|
if (idx >= 18) return 'mittel'
|
||||||
|
if (idx >= 9) return 'gering'
|
||||||
|
return 'vernachlaessigbar'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fkBand(score: number): string {
|
||||||
|
if (score > 400) return 'sehr hoch'
|
||||||
|
if (score > 200) return 'hoch'
|
||||||
|
if (score > 70) return 'wesentlich'
|
||||||
|
if (score > 20) return 'moeglich'
|
||||||
|
return 'gering'
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeColor(label: string): string {
|
||||||
|
switch (label) {
|
||||||
|
case 'kritisch':
|
||||||
|
case 'sehr hoch':
|
||||||
|
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||||
|
case 'hoch':
|
||||||
|
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300'
|
||||||
|
case 'mittel':
|
||||||
|
case 'wesentlich':
|
||||||
|
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300'
|
||||||
|
case 'gering':
|
||||||
|
case 'moeglich':
|
||||||
|
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
|
||||||
|
default:
|
||||||
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
step,
|
||||||
|
justification,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
step?: number
|
||||||
|
justification: string
|
||||||
|
onChange: (v: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">{label}</div>
|
||||||
|
<div className="text-[11px] text-gray-400 leading-tight" title={justification}>
|
||||||
|
{justification}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={step || 1}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-16 px-2 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Panel({
|
||||||
|
title,
|
||||||
|
formula,
|
||||||
|
score,
|
||||||
|
badge,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
formula: string
|
||||||
|
score: number
|
||||||
|
badge: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 dark:border-gray-700 p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-semibold text-gray-800 dark:text-gray-200">{title}</div>
|
||||||
|
<span className={`text-[11px] px-2 py-0.5 rounded-full ${badgeColor(badge)}`}>{badge}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">{children}</div>
|
||||||
|
<div className="pt-2 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<code className="text-[11px] text-gray-500">{formula}</code>
|
||||||
|
<span className="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
R = {Number.isInteger(score) ? score : score.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RiskModelCard({
|
||||||
|
hazard,
|
||||||
|
suggestion,
|
||||||
|
}: {
|
||||||
|
hazard: HazardLite
|
||||||
|
suggestion?: RiskSuggestion
|
||||||
|
}) {
|
||||||
|
const [en, setEn] = useState({ s: 0, f: 0, w: 0, p: 0 })
|
||||||
|
const [fk, setFk] = useState({ p: 0, e: 0, c: 0 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!suggestion) return
|
||||||
|
setEn({
|
||||||
|
s: suggestion.en62061.severity.value,
|
||||||
|
f: suggestion.en62061.frequency.value,
|
||||||
|
w: suggestion.en62061.probability.value,
|
||||||
|
p: suggestion.en62061.avoidance.value,
|
||||||
|
})
|
||||||
|
setFk({
|
||||||
|
p: suggestion.fine_kinney.probability.value,
|
||||||
|
e: suggestion.fine_kinney.exposure.value,
|
||||||
|
c: suggestion.fine_kinney.consequence.value,
|
||||||
|
})
|
||||||
|
}, [suggestion])
|
||||||
|
|
||||||
|
if (!suggestion) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-4 text-sm text-gray-500">
|
||||||
|
{hazard.name} — keine Bewertung verfuegbar
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enScore = en.s * (en.f + en.w + en.p)
|
||||||
|
const fkScore = fk.p * fk.e * fk.c
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-gray-100">{hazard.name}</div>
|
||||||
|
<span className="text-xs text-gray-400">Kontaktart: {suggestion.contact_mode}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<Panel title="EN-62061-Stil" formula={suggestion.en62061.formula} score={enScore} badge={enLevel(enScore)}>
|
||||||
|
<Field label="Schwere S" value={en.s} justification={suggestion.en62061.severity.justification} onChange={(v) => setEn({ ...en, s: v })} />
|
||||||
|
<Field label="Haeufigkeit F" value={en.f} justification={suggestion.en62061.frequency.justification} onChange={(v) => setEn({ ...en, f: v })} />
|
||||||
|
<Field label="Wahrscheinlichkeit W" value={en.w} justification={suggestion.en62061.probability.justification} onChange={(v) => setEn({ ...en, w: v })} />
|
||||||
|
<Field label="Vermeidbarkeit P" value={en.p} justification={suggestion.en62061.avoidance.justification} onChange={(v) => setEn({ ...en, p: v })} />
|
||||||
|
</Panel>
|
||||||
|
<Panel title="Fine-Kinney (US)" formula={suggestion.fine_kinney.formula} score={fkScore} badge={fkBand(fkScore)}>
|
||||||
|
<Field label="Wahrscheinlichkeit P" value={fk.p} step={0.1} justification={suggestion.fine_kinney.probability.justification} onChange={(v) => setFk({ ...fk, p: v })} />
|
||||||
|
<Field label="Exposition E" value={fk.e} step={0.5} justification={suggestion.fine_kinney.exposure.justification} onChange={(v) => setFk({ ...fk, e: v })} />
|
||||||
|
<Field label="Konsequenz C" value={fk.c} justification={suggestion.fine_kinney.consequence.justification} onChange={(v) => setFk({ ...fk, c: v })} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-gray-400">{suggestion.note}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export interface SuggestedValue {
|
||||||
|
value: number
|
||||||
|
justification: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskSuggestion {
|
||||||
|
hazard_id: string
|
||||||
|
contact_mode: string
|
||||||
|
en62061: {
|
||||||
|
severity: SuggestedValue
|
||||||
|
frequency: SuggestedValue
|
||||||
|
probability: SuggestedValue
|
||||||
|
avoidance: SuggestedValue
|
||||||
|
score: number
|
||||||
|
level: string
|
||||||
|
formula: string
|
||||||
|
}
|
||||||
|
fine_kinney: {
|
||||||
|
probability: SuggestedValue
|
||||||
|
exposure: SuggestedValue
|
||||||
|
consequence: SuggestedValue
|
||||||
|
score: number
|
||||||
|
band: string
|
||||||
|
action: string
|
||||||
|
formula: string
|
||||||
|
}
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HazardLite {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
scenario?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRiskAssessment(projectId: string) {
|
||||||
|
const [hazards, setHazards] = useState<HazardLite[]>([])
|
||||||
|
const [suggestions, setSuggestions] = useState<Record<string, RiskSuggestion>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`)
|
||||||
|
const json = res.ok ? await res.json() : {}
|
||||||
|
const hz: HazardLite[] = json.hazards || json || []
|
||||||
|
if (cancelled) return
|
||||||
|
setHazards(hz)
|
||||||
|
const entries = await Promise.all(
|
||||||
|
hz.map(async (h) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(
|
||||||
|
`/api/sdk/v1/iace/projects/${projectId}/hazards/${h.id}/risk-suggestion`,
|
||||||
|
)
|
||||||
|
return r.ok ? ([h.id, (await r.json()) as RiskSuggestion] as const) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (cancelled) return
|
||||||
|
const map: Record<string, RiskSuggestion> = {}
|
||||||
|
for (const e of entries) if (e) map[e[0]] = e[1]
|
||||||
|
setSuggestions(map)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load risk assessment:', err)
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
return { hazards, suggestions, loading }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { useRiskAssessment } from './_hooks/useRiskAssessment'
|
||||||
|
import { RiskModelCard } from './_components/RiskModelCard'
|
||||||
|
|
||||||
|
export default function RisikobewertungPage() {
|
||||||
|
const params = useParams<{ projectId: string }>()
|
||||||
|
const projectId = params.projectId
|
||||||
|
const { hazards, suggestions, loading } = useRiskAssessment(projectId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Risikobewertung</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-3xl mt-1">
|
||||||
|
Zwei Modelle pro Gefaehrdung: <strong>EN-62061-Stil</strong> (F/W/P/S) und{' '}
|
||||||
|
<strong>Fine-Kinney</strong> (P/E/C, US-anerkannt). BreakPilot schlaegt begruendete
|
||||||
|
Werte aus oeffentlichen Datenquellen vor (ESAW/NIOSH/OSHA) — passen Sie sie nach Ihrer
|
||||||
|
Erfahrung bzw. Ihren eigenen Normdaten an; das Tool rechnet die Formel live aus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">Lade Gefaehrdungen…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && hazards.length === 0 && (
|
||||||
|
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-6 text-sm text-gray-500">
|
||||||
|
Keine Gefaehrdungen vorhanden. Bitte zuerst im <strong>Hazard Log</strong> erzeugen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{hazards.map((h) => (
|
||||||
|
<RiskModelCard key={h.id} hazard={h} suggestion={suggestions[h.id]} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const IACE_NAV_ITEMS = [
|
|||||||
{ id: 'norms', label: 'Normenrecherche', href: '/norms', icon: 'book' },
|
{ id: 'norms', label: 'Normenrecherche', href: '/norms', icon: 'book' },
|
||||||
{ id: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
|
{ id: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
|
||||||
{ id: 'hazards', label: 'Hazard Log', href: '/hazards', icon: 'warning' },
|
{ id: 'hazards', label: 'Hazard Log', href: '/hazards', icon: 'warning' },
|
||||||
|
{ id: 'risikobewertung', label: 'Risikobewertung', href: '/risikobewertung', icon: 'activity' },
|
||||||
{ id: 'mitigations', label: 'Massnahmen', href: '/mitigations', icon: 'shield' },
|
{ id: 'mitigations', label: 'Massnahmen', href: '/mitigations', icon: 'shield' },
|
||||||
{ id: 'clarifications', label: 'Klärungen', href: '/clarifications', icon: 'chat' },
|
{ id: 'clarifications', label: 'Klärungen', href: '/clarifications', icon: 'chat' },
|
||||||
{ id: 'verification', label: 'Verifikation', href: '/verification', icon: 'check' },
|
{ id: 'verification', label: 'Verifikation', href: '/verification', icon: 'check' },
|
||||||
|
|||||||
Reference in New Issue
Block a user