feat(iace): benchmark system + erklaerteil + dedup-fix
Build + Deploy / build-backend-compliance (push) Successful in 3m34s
Build + Deploy / build-ai-sdk (push) Successful in 1m6s
Build + Deploy / build-developer-portal (push) Successful in 1m7s
Build + Deploy / build-tts (push) Successful in 1m58s
Build + Deploy / build-document-crawler (push) Successful in 57s
Build + Deploy / build-dsms-gateway (push) Successful in 34s
Build + Deploy / build-admin-compliance (push) Successful in 2m7s
Build + Deploy / build-dsms-node (push) Successful in 29s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 17s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m28s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 42s
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 27s
CI / test-python-dsms-gateway (push) Successful in 22s
CI / validate-canonical-controls (push) Successful in 15s
Build + Deploy / trigger-orca (push) Successful in 3m10s

- Erklaerteil-Template fuer Risikobeurteilungen (risk_assessment_template.go)
  in PDF-Export, Markdown-Export und Frontend ReportPrintView eingebaut
- Ground Truth Benchmark-System: Datenmodell, Fuzzy-Matching-Engine,
  3 API Endpoints (import-gt, benchmark, benchmark/summary)
- Frontend Benchmark-Tab mit Score-Cards, Kategorie-Breakdown,
  Hazard-Vergleichstabelle (Zugeordnet/Fehlend/Extra), Business Impact
- Erster Benchmark: 13.3% Coverage (Baseline) gegen 60 GT-Eintraege
- Dedup-Fix: seenCat[cat] -> seenCatZone[cat+zone] erlaubt mehrere
  Gefaehrdungen pro Kategorie an verschiedenen Gefahrenstellen
- Komponenten-spezifische Hazard-Namen und Zone-basierte Zuordnung

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-13 01:02:33 +02:00
parent 185d680669
commit 8bb90d73e5
18 changed files with 4029 additions and 5 deletions
@@ -0,0 +1,46 @@
'use client'
import React from 'react'
import type { CategoryScore } from '../_hooks/useBenchmark'
interface Props { breakdown: CategoryScore[] }
const CATEGORY_LABELS: Record<string, string> = {
'mechanische gefaehrdungen': 'Mechanisch',
'elektrische gefaehrdungen': 'Elektrisch',
'thermische gefaehrdungen': 'Thermisch',
'laerm': 'Laerm',
'vibration': 'Vibration',
'strahlung': 'Strahlung',
'materialien und substanzen': 'Materialien/Substanzen',
'ergonomische gefaehrdungen': 'Ergonomie',
'einsatzumgebung': 'Einsatzumgebung',
}
export function CategoryBreakdown({ breakdown }: Props) {
if (!breakdown || breakdown.length === 0) return null
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Coverage nach Gefaehrdungsgruppe</h3>
<div className="space-y-2">
{breakdown.map((cat) => {
const label = CATEGORY_LABELS[cat.category] || cat.category
const pct = Math.round(cat.coverage * 100)
const barColor = pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
return (
<div key={cat.category}>
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-0.5">
<span>{label}</span>
<span>{cat.match_count}/{cat.gt_count} ({pct}%)</span>
</div>
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,121 @@
'use client'
import React, { useState, useRef } from 'react'
import type { GroundTruthEntry } from '../_hooks/useBenchmark'
interface Props {
onImport: (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => Promise<void>
loading: boolean
}
export function GTImportForm({ onImport, loading }: Props) {
const [jsonText, setJsonText] = useState('')
const [parseError, setParseError] = useState<string | null>(null)
const [preview, setPreview] = useState<{ count: number; groups: Record<string, number> } | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
function tryParse(text: string) {
setJsonText(text)
setParseError(null)
setPreview(null)
if (!text.trim()) return
try {
const parsed = JSON.parse(text)
const entries: GroundTruthEntry[] = parsed.entries || parsed
if (!Array.isArray(entries) || entries.length === 0) {
setParseError('JSON muss ein Array "entries" enthalten')
return
}
// Validate first entry has required fields
const first = entries[0]
if (!first.hazard_type && !first.hazard_group) {
setParseError('Eintraege muessen hazard_type oder hazard_group enthalten')
return
}
// Build preview
const groups: Record<string, number> = {}
for (const e of entries) {
const g = e.hazard_group || 'Unbekannt'
groups[g] = (groups[g] || 0) + 1
}
setPreview({ count: entries.length, groups })
} catch (err) {
setParseError('Ungueltiges JSON: ' + (err instanceof Error ? err.message : String(err)))
}
}
async function handleImport() {
if (!jsonText.trim()) return
try {
const parsed = JSON.parse(jsonText)
const gt = parsed.entries ? parsed : { entries: parsed }
await onImport(gt)
setJsonText('')
setPreview(null)
} catch (err) {
setParseError(err instanceof Error ? err.message : 'Import fehlgeschlagen')
}
}
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
const text = ev.target?.result as string
tryParse(text)
}
reader.readAsText(file)
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Ground Truth importieren</h3>
<p className="text-xs text-gray-500 mb-3">
JSON-Datei mit der professionellen Risikobeurteilung einfuegen oder hochladen.
</p>
<div className="flex gap-2 mb-3">
<button
onClick={() => fileRef.current?.click()}
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-md transition-colors"
>
JSON-Datei waehlen
</button>
<input ref={fileRef} type="file" accept=".json" onChange={handleFileUpload} className="hidden" />
</div>
<textarea
value={jsonText}
onChange={(e) => tryParse(e.target.value)}
placeholder='{"entries": [...], "source_file": "...", "description": "..."}'
rows={6}
className="w-full text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md p-2 bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-200 resize-y"
/>
{parseError && (
<div className="mt-2 px-3 py-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-600">
{parseError}
</div>
)}
{preview && (
<div className="mt-2 px-3 py-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded text-xs text-green-700 dark:text-green-400">
<strong>{preview.count} Eintraege</strong> erkannt:
{Object.entries(preview.groups).map(([g, c]) => (
<span key={g} className="ml-2">{g}: {c}</span>
))}
</div>
)}
<button
onClick={handleImport}
disabled={loading || !preview}
className="mt-3 w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 text-white rounded-md transition-colors"
>
{loading ? 'Importiere...' : 'Ground Truth importieren'}
</button>
</div>
)
}
@@ -0,0 +1,158 @@
'use client'
import React, { useState } from 'react'
import type { HazardMatchPair, GroundTruthEntry, HazardSummary } from '../_hooks/useBenchmark'
interface Props {
matched: HazardMatchPair[]
missing: GroundTruthEntry[]
extra: HazardSummary[]
}
type TabType = 'matched' | 'missing' | 'extra'
export function HazardComparisonTable({ matched, missing, extra }: Props) {
const [tab, setTab] = useState<TabType>('matched')
const tabs: { id: TabType; label: string; count: number; color: string }[] = [
{ id: 'matched', label: 'Zugeordnet', count: matched.length, color: 'text-green-600' },
{ id: 'missing', label: 'Fehlend', count: missing.length, color: 'text-red-600' },
{ id: 'extra', label: 'Zusaetzlich', count: extra.length, color: 'text-gray-500' },
]
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
{/* Tab bar */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex-1 px-4 py-2.5 text-xs font-medium transition-colors ${
tab === t.id
? 'border-b-2 border-purple-600 text-purple-700 dark:text-purple-400'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{t.label} <span className={t.color}>({t.count})</span>
</button>
))}
</div>
<div className="overflow-x-auto">
{tab === 'matched' && <MatchedTable pairs={matched} />}
{tab === 'missing' && <MissingTable entries={missing} />}
{tab === 'extra' && <ExtraTable entries={extra} />}
</div>
</div>
)
}
function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
return (
<table className="w-full text-xs">
<thead>
<tr className="bg-gray-50 dark:bg-gray-700/50">
<th className="px-3 py-2 text-left font-medium text-gray-500">Nr.</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Ground Truth</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">R(GT)</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Engine</th>
<th className="px-3 py-2 text-center font-medium text-gray-500">Score</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Match</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{pairs.map((p, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td className="px-3 py-2 text-gray-400">{p.gt_entry.nr}</td>
<td className="px-3 py-2">
<div className="font-medium text-gray-800 dark:text-gray-200">{p.gt_entry.hazard_type}</div>
<div className="text-gray-400 truncate max-w-[250px]">{p.gt_entry.component_zone}</div>
</td>
<td className="px-3 py-2 text-center">
<RiskBadge risk={p.gt_entry.risk_in.r} />
</td>
<td className="px-3 py-2">
<div className="font-medium text-gray-800 dark:text-gray-200">{p.engine_hazard.name}</div>
<div className="text-gray-400">{p.engine_hazard.category}</div>
</td>
<td className="px-3 py-2 text-center">
<ScoreBadge score={p.match_score} />
</td>
<td className="px-3 py-2 text-gray-400">{p.match_reason}</td>
</tr>
))}
</tbody>
</table>
)
}
function MissingTable({ entries }: { entries: GroundTruthEntry[] }) {
if (entries.length === 0) return <EmptyState text="Keine fehlenden Gefaehrdungen" />
return (
<table className="w-full text-xs">
<thead>
<tr className="bg-red-50 dark:bg-red-900/20">
<th className="px-3 py-2 text-left font-medium text-red-600">Nr.</th>
<th className="px-3 py-2 text-left font-medium text-red-600">Gefaehrdung</th>
<th className="px-3 py-2 text-left font-medium text-red-600">Ursache</th>
<th className="px-3 py-2 text-left font-medium text-red-600">Zone</th>
<th className="px-3 py-2 text-center font-medium text-red-600">R</th>
<th className="px-3 py-2 text-left font-medium text-red-600">Typ</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entries.map((e, i) => (
<tr key={i} className="hover:bg-red-50/50">
<td className="px-3 py-2 text-gray-400">{e.nr}</td>
<td className="px-3 py-2 font-medium text-gray-800 dark:text-gray-200">{e.hazard_type}</td>
<td className="px-3 py-2 text-gray-600 truncate max-w-[200px]">{e.hazard_cause}</td>
<td className="px-3 py-2 text-gray-500">{e.component_zone}</td>
<td className="px-3 py-2 text-center"><RiskBadge risk={e.risk_in.r} /></td>
<td className="px-3 py-2 text-gray-500">{e.measure_type}</td>
</tr>
))}
</tbody>
</table>
)
}
function ExtraTable({ entries }: { entries: HazardSummary[] }) {
if (entries.length === 0) return <EmptyState text="Keine zusaetzlichen Engine-Gefaehrdungen" />
return (
<table className="w-full text-xs">
<thead>
<tr className="bg-gray-50 dark:bg-gray-700/50">
<th className="px-3 py-2 text-left font-medium text-gray-500">Name</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Kategorie</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Zone</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entries.map((e, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td className="px-3 py-2 text-gray-800 dark:text-gray-200">{e.name}</td>
<td className="px-3 py-2 text-gray-500">{e.category}</td>
<td className="px-3 py-2 text-gray-400">{e.zone || '-'}</td>
</tr>
))}
</tbody>
</table>
)
}
function RiskBadge({ risk }: { risk: number }) {
const color = risk >= 30 ? 'bg-red-100 text-red-700' : risk >= 15 ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'
return <span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${color}`}>{risk}</span>
}
function ScoreBadge({ score }: { score: number }) {
const pct = Math.round(score * 100)
const color = pct >= 70 ? 'text-green-600' : pct >= 50 ? 'text-yellow-600' : 'text-red-600'
return <span className={`font-bold ${color}`}>{pct}%</span>
}
function EmptyState({ text }: { text: string }) {
return <div className="px-4 py-8 text-center text-sm text-gray-400">{text}</div>
}
@@ -0,0 +1,115 @@
'use client'
import { useState, useCallback } from 'react'
export interface GTRisk { f: number; w: number; p: number; s: number; r: number }
export interface GTPLr { s: string; f: string; p: string; ew?: string; plr: string }
export interface GroundTruthEntry {
nr: string
hazard_group: string
hazard_group_applicable: boolean
hazard_subgroup: string
hazard_type: string
hazard_cause: string
lifecycle_phases: string[]
component_zone: string
risk_in: GTRisk
plr?: GTPLr | null
measures: string[]
measure_type: string
risk_out: GTRisk
norm_references: string[]
sufficient: boolean
comment?: string
reduction_steps?: {
risk_in: GTRisk; measures: string[]; measure_type: string
risk_out: GTRisk; norm_references: string[]; sufficient: boolean
}[]
}
export interface HazardSummary {
id: string; name: string; category: string
component?: string; zone?: string; risk_level?: string
}
export interface HazardMatchPair {
gt_entry: GroundTruthEntry
engine_hazard: HazardSummary
match_score: number
match_reason: string
}
export interface CategoryScore {
category: string; gt_count: number; match_count: number; coverage: number
}
export interface BenchmarkResult {
coverage_score: number
measure_coverage: number
total_gt: number
total_engine: number
matched_pairs: HazardMatchPair[]
missing_from_engine: GroundTruthEntry[]
extra_in_engine: HazardSummary[]
category_breakdown: CategoryScore[]
risk_rank_pairs: { gt_rank: number; engine_rank: number; hazard_name: string; gt_risk_score: number }[]
}
interface UseBenchmarkReturn {
result: BenchmarkResult | null
gtLoaded: boolean
gtEntryCount: number
loading: boolean
error: string | null
importGT: (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => Promise<void>
runBenchmark: (gtProjectId?: string) => Promise<void>
}
export function useBenchmark(projectId: string): UseBenchmarkReturn {
const [result, setResult] = useState<BenchmarkResult | null>(null)
const [gtLoaded, setGtLoaded] = useState(false)
const [gtEntryCount, setGtEntryCount] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const importGT = useCallback(async (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/benchmark/import-gt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(gt),
})
if (!res.ok) throw new Error(await res.text())
const data = await res.json()
setGtLoaded(true)
setGtEntryCount(data.entry_count || gt.entries.length)
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed')
} finally {
setLoading(false)
}
}, [projectId])
const runBenchmark = useCallback(async (gtProjectId?: string) => {
setLoading(true)
setError(null)
try {
const params = gtProjectId ? `?gt_project_id=${gtProjectId}` : ''
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/benchmark${params}`)
if (!res.ok) throw new Error(await res.text())
const data: BenchmarkResult = await res.json()
setResult(data)
setGtLoaded(true)
setGtEntryCount(data.total_gt)
} catch (err) {
setError(err instanceof Error ? err.message : 'Benchmark failed')
} finally {
setLoading(false)
}
}, [projectId])
return { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark }
}
@@ -0,0 +1,160 @@
'use client'
import React, { useState } from 'react'
import { useParams } from 'next/navigation'
import { useBenchmark } from './_hooks/useBenchmark'
import { GTImportForm } from './_components/GTImportForm'
import { HazardComparisonTable } from './_components/HazardComparisonTable'
import { CategoryBreakdown } from './_components/CategoryBreakdown'
export default function BenchmarkPage() {
const { projectId } = useParams<{ projectId: string }>()
const { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark } = useBenchmark(projectId)
const [gtProjectId, setGtProjectId] = useState('')
const coveragePct = result ? Math.round(result.coverage_score * 100) : 0
const measurePct = result ? Math.round(result.measure_coverage * 100) : 0
return (
<div className="space-y-6 max-w-[1200px]">
{/* Header */}
<div>
<h1 className="text-lg font-bold text-gray-900 dark:text-white">Ground Truth Benchmark</h1>
<p className="text-sm text-gray-500 mt-1">
Vergleich der Engine-Ergebnisse mit einer professionellen Risikobeurteilung
</p>
</div>
{error && (
<div className="px-4 py-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-600">
{error}
</div>
)}
{/* GT Import or Cross-Project Reference */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<GTImportForm onImport={importGT} loading={loading} />
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Benchmark ausfuehren</h3>
<p className="text-xs text-gray-500 mb-3">
GT aus diesem Projekt verwenden, oder eine Projekt-ID mit importierter GT angeben.
</p>
<div className="space-y-2">
<input
type="text"
value={gtProjectId}
onChange={(e) => setGtProjectId(e.target.value)}
placeholder="GT-Projekt-ID (optional — leer = dieses Projekt)"
className="w-full text-xs border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-gray-50 dark:bg-gray-900"
/>
<button
onClick={() => runBenchmark(gtProjectId || undefined)}
disabled={loading}
className="w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 text-white rounded-md transition-colors"
>
{loading ? 'Vergleiche...' : 'Benchmark starten'}
</button>
</div>
{gtLoaded && !result && (
<div className="mt-3 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 rounded text-xs text-blue-600">
{gtEntryCount} GT-Eintraege geladen. Klicke &quot;Benchmark starten&quot;.
</div>
)}
</div>
</div>
{/* Results */}
{result && (
<>
{/* Score Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ScoreCard
label="Hazard Coverage"
value={`${coveragePct}%`}
sub={`${result.matched_pairs?.length || 0} / ${result.total_gt} erkannt`}
color={coveragePct >= 80 ? 'green' : coveragePct >= 50 ? 'yellow' : 'red'}
/>
<ScoreCard
label="Massnahmen-Coverage"
value={`${measurePct}%`}
sub="der zugeordneten Gefaehrdungen"
color={measurePct >= 80 ? 'green' : measurePct >= 50 ? 'yellow' : 'red'}
/>
<ScoreCard
label="GT Eintraege"
value={String(result.total_gt)}
sub="professionelle Beurteilung"
color="gray"
/>
<ScoreCard
label="Engine Eintraege"
value={String(result.total_engine)}
sub={`${result.extra_in_engine?.length || 0} zusaetzlich`}
color="gray"
/>
</div>
{/* Category Breakdown */}
<CategoryBreakdown breakdown={result.category_breakdown || []} />
{/* Hazard Comparison Table */}
<HazardComparisonTable
matched={result.matched_pairs || []}
missing={result.missing_from_engine || []}
extra={result.extra_in_engine || []}
/>
{/* Business Impact */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Business Impact</h3>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">2,5 Tage</div>
<div className="text-xs text-gray-500">Manueller Aufwand</div>
</div>
<div>
<div className="text-2xl font-bold text-purple-600">
{(coveragePct / 100 * 2.5).toFixed(1)} Tage
</div>
<div className="text-xs text-gray-500">Eingespart bei {coveragePct}% Coverage</div>
</div>
<div>
<div className="text-2xl font-bold text-green-600">
{Math.round(coveragePct / 100 * 2.5 * 8 * 100)} EUR
</div>
<div className="text-xs text-gray-500">Einsparung (100 EUR/h)</div>
</div>
</div>
</div>
</>
)}
</div>
)
}
function ScoreCard({ label, value, sub, color }: {
label: string; value: string; sub: string
color: 'green' | 'yellow' | 'red' | 'gray'
}) {
const colors = {
green: 'border-green-200 dark:border-green-800',
yellow: 'border-yellow-200 dark:border-yellow-800',
red: 'border-red-200 dark:border-red-800',
gray: 'border-gray-200 dark:border-gray-700',
}
const textColors = {
green: 'text-green-600', yellow: 'text-yellow-600',
red: 'text-red-600', gray: 'text-gray-900 dark:text-white',
}
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg border-2 ${colors[color]} p-4 text-center`}>
<div className={`text-2xl font-bold ${textColors[color]}`}>{value}</div>
<div className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1">{label}</div>
<div className="text-[10px] text-gray-400 mt-0.5">{sub}</div>
</div>
)
}
@@ -119,10 +119,61 @@ export function ReportPrintView({ data }: ReportPrintViewProps) {
Herstellers nach EU Maschinenverordnung 2023/1230 Art. 10.
</div>
{/* 2. Inhaltsverzeichnis */}
{/* 2. Methodik der Risikobeurteilung (Erklaerteil) */}
<div className="section-break">
<h2>Methodik der Risikobeurteilung</h2>
<p>
Diese Risikobeurteilung orientiert sich an den Grundprinzipien der EN ISO 12100,
EN 62061 und EN ISO 13849-1. Bewertet werden Grenzen des Produkts, identifizierte
Gefaehrdungen, die jeweilige Risikohoehe sowie das Restrisiko nach Anwendung von
Schutzmassnahmen.
</p>
<p>
Der Prozess ist iterativ: Reicht eine Massnahme nicht aus, werden weitere ergriffen
und das Restrisiko erneut bewertet, bis ein akzeptables Niveau erreicht ist.
</p>
<h3>Risikoberechnung</h3>
<p>Das Ausgangsrisiko ergibt sich aus: <strong>R = S &times; F &times; P &times; A</strong></p>
<table>
<thead>
<tr><th>Faktor</th><th>Beschreibung</th><th>Skala</th></tr>
</thead>
<tbody>
<tr><td><strong>S</strong></td><td>Schadensschwere</td><td>1 (Erste Hilfe) 5 (toedlich)</td></tr>
<tr><td><strong>F</strong></td><td>Expositionshaeufigkeit</td><td>1 (selten/kurz) 5 (dauerhaft)</td></tr>
<tr><td><strong>P</strong></td><td>Eintrittswahrscheinlichkeit</td><td>1 (vernachlaessigbar) 5 (fast sicher)</td></tr>
<tr><td><strong>A</strong></td><td>Vermeidbarkeit</td><td>1 (leicht vermeidbar) 5 (unvermeidbar)</td></tr>
</tbody>
</table>
<p>
Bei Sicherheitskreisen wird der Performance Level (PLr) ueber einen Risikographen
abgeleitet und dem Safety Integrity Level (SIL) zugeordnet.
</p>
<h3>Dreistufenmethode</h3>
<p>Schutzmassnahmen werden priorisiert angewandt:</p>
<ol>
<li><strong>Konstruktive Massnahmen (KM)</strong> Inhaerent sichere Gestaltung</li>
<li><strong>Technische Schutzmassnahmen (TM)</strong> Schutzeinrichtungen, Sicherheitssteuerungen</li>
<li><strong>Benutzerinformationen (BI)</strong> Warnhinweise, Betriebsanleitung</li>
</ol>
<h3>Akzeptanz des Restrisikos</h3>
<p>
Ein Restrisiko gilt als hinreichend gemindert, wenn alle praktisch umsetzbaren Massnahmen
ausgeschoepft wurden und Anwender ueber verbleibende Restrisiken informiert sind.
Die Akzeptanz wird pro Gefaehrdung mit <strong>JA</strong> / <strong>NEIN</strong> dokumentiert.
</p>
<p style={{ fontStyle: 'italic', fontSize: '9pt', color: '#374151' }}>
&bdquo;Die Moeglichkeit, einen hoeheren Sicherheitsgrad zu erreichen, oder die Verfuegbarkeit
anderer Produkte, die ein geringeres Risiko darstellen, ist kein ausreichender Grund,
ein Produkt als gefaehrlich anzusehen.&ldquo; § 3 Abs. 2 ProdSG
</p>
</div>
{/* 3. Inhaltsverzeichnis */}
<div className="section-break">
<h2>Inhaltsverzeichnis</h2>
<ol className="toc">
<li>Methodik der Risikobeurteilung</li>
<li>Maschinenbeschreibung</li>
<li>Angewandte Normen</li>
<li>Gefaehrdungsliste</li>
+1
View File
@@ -24,6 +24,7 @@ const IACE_EXTRA_ITEMS = [
{ id: 'knowledge-graph', label: 'Knowledge Graph', href: '/knowledge-graph', icon: 'activity' },
{ id: 'classification', label: 'Klassifikation', href: '/classification', icon: 'tag' },
{ id: 'monitoring', label: 'Monitoring', href: '/monitoring', icon: 'activity' },
{ id: 'benchmark', label: 'Benchmark', href: '/benchmark', icon: 'check' },
]
function NavIcon({ icon, className }: { icon: string; className?: string }) {