feat(iace): project-wide risk matrix (Severity × Probability)
Adds GET /projects/:id/risk-matrix — a confidence-aware risk view computed on read from each hazard's category/scenario/lifecycle using the SAME model as the GT benchmark (no persistence, so it never goes stale against the model; the hand-defaulted iace_hazards risk columns stay untouched). - risk_matrix.go: EstimateHazardRisk (single source of truth for S/F/W/P + range + level + confidence) and BuildRiskMatrix (per-hazard list + a 5×5 Severity×Probability aggregation grid with dominant level per cell). - Frontend: RiskMatrix grid in the Risikobewertung tab (muted colours per the confidence-aware tonality), level counts + tool-confidence summary, fed by useRiskMatrix. Shows risk for EVERY project, not only GT ones. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import type { RiskMatrixData } from '../_hooks/useRiskMatrix'
|
||||
|
||||
// Severity × Probability(W) grid — the classic risk matrix. Cells are coloured by
|
||||
// the WORST (dominant) risk band of the hazards they hold, deliberately muted
|
||||
// (confidence-aware tonality: inform, don't alarm). The number is the hazard
|
||||
// count in that bucket.
|
||||
|
||||
const LEVELS = ['vernachlaessigbar', 'gering', 'mittel', 'hoch', 'kritisch'] as const
|
||||
const LEVEL_LABEL: Record<string, string> = {
|
||||
vernachlaessigbar: 'vernachlässigbar',
|
||||
gering: 'gering',
|
||||
mittel: 'mittel',
|
||||
hoch: 'hoch',
|
||||
kritisch: 'kritisch',
|
||||
}
|
||||
const LEVEL_BG: Record<string, string> = {
|
||||
kritisch: 'bg-red-200 text-red-900 dark:bg-red-900/50 dark:text-red-200',
|
||||
hoch: 'bg-orange-200 text-orange-900 dark:bg-orange-900/50 dark:text-orange-200',
|
||||
mittel: 'bg-yellow-200 text-yellow-900 dark:bg-yellow-900/40 dark:text-yellow-200',
|
||||
gering: 'bg-lime-200 text-lime-900 dark:bg-lime-900/40 dark:text-lime-200',
|
||||
vernachlaessigbar: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200',
|
||||
}
|
||||
const LEVEL_DOT: Record<string, string> = {
|
||||
kritisch: 'bg-red-400', hoch: 'bg-orange-400', mittel: 'bg-yellow-400',
|
||||
gering: 'bg-lime-400', vernachlaessigbar: 'bg-green-300',
|
||||
}
|
||||
|
||||
export function RiskMatrix({ data }: { data: RiskMatrixData }) {
|
||||
if (!data || data.total === 0) return null
|
||||
|
||||
// Index cells by "severity-probability" for O(1) lookup.
|
||||
const cellMap = new Map(data.matrix.map((c) => [`${c.severity}-${c.probability}`, c]))
|
||||
const severities = [5, 4, 3, 2, 1] // rows, worst on top
|
||||
const probabilities = [1, 2, 3, 4, 5] // columns
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Risiko-Matrix (Schwere × Wahrscheinlichkeit)</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{data.total} Gefährdungen, automatisch geschätzt (BreakPilot-Modell). Die Zahl je Feld ist die
|
||||
Anzahl der Gefährdungen; die Farbe zeigt das höchste Risiko-Band im Feld. Schätzung — Sie
|
||||
entscheiden mit Ihrem/Ihrer Sachverständigen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 flex-wrap items-start">
|
||||
{/* Matrix grid */}
|
||||
<div className="inline-block">
|
||||
<div className="flex">
|
||||
<div className="w-16" />
|
||||
<div className="text-center text-[10px] text-gray-500 flex-1" style={{ minWidth: 200 }}>
|
||||
Wahrscheinlichkeit (W) →
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-16 flex items-center justify-center -rotate-90 text-[10px] text-gray-500 whitespace-nowrap">
|
||||
Schwere (S) ↑
|
||||
</div>
|
||||
<table className="border-collapse">
|
||||
<tbody>
|
||||
{severities.map((s) => (
|
||||
<tr key={s}>
|
||||
<td className="text-[10px] text-gray-400 pr-1 text-right align-middle w-4">{s}</td>
|
||||
{probabilities.map((w) => {
|
||||
const cell = cellMap.get(`${s}-${w}`)
|
||||
const bg = cell ? LEVEL_BG[cell.dominant_level] : 'bg-gray-50 dark:bg-gray-900/40 text-gray-300'
|
||||
return (
|
||||
<td key={w} className="p-0.5">
|
||||
<div
|
||||
className={`w-11 h-11 rounded flex items-center justify-center text-sm font-bold ${bg}`}
|
||||
title={cell ? `S${s} × W${w}: ${cell.count} Gefährdung(en) · ${LEVEL_LABEL[cell.dominant_level]}` : `S${s} × W${w}: 0`}
|
||||
>
|
||||
{cell ? cell.count : ''}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td />
|
||||
{probabilities.map((w) => (
|
||||
<td key={w} className="text-[10px] text-gray-400 text-center">{w}</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level summary + confidence */}
|
||||
<div className="space-y-2 text-xs min-w-[180px]">
|
||||
{LEVELS.slice().reverse().map((lvl) => (
|
||||
<div key={lvl} className="flex items-center gap-2">
|
||||
<span className={`inline-block w-3 h-3 rounded-sm ${LEVEL_DOT[lvl]}`} />
|
||||
<span className="text-gray-600 dark:text-gray-300 flex-1">{LEVEL_LABEL[lvl]}</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{data.level_counts[lvl] || 0}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2 mt-1 border-t border-gray-100 dark:border-gray-700 text-gray-500">
|
||||
Tool-Konfidenz: <strong>{Math.round(data.high_confidence_pct)}%</strong> mit hoher Konfidenz
|
||||
(Verletzungsmechanismus eindeutig).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user