fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
680
website/components/compliance/charts/RiskHeatmap.tsx
Normal file
680
website/components/compliance/charts/RiskHeatmap.tsx
Normal file
@@ -0,0 +1,680 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* RiskHeatmap Component
|
||||
*
|
||||
* Enhanced risk matrix visualization with:
|
||||
* - 5x5 likelihood x impact matrix
|
||||
* - Drill-down on matrix cells
|
||||
* - Category filtering
|
||||
* - Inherent vs Residual comparison view
|
||||
* - Linked controls display
|
||||
*
|
||||
* Sprint 5: PDF Reports & Erweiterte Visualisierungen
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Language, getTerm } from '@/lib/compliance-i18n'
|
||||
|
||||
export interface Risk {
|
||||
id: string
|
||||
risk_id: string
|
||||
title: string
|
||||
description?: string
|
||||
category: string
|
||||
likelihood: number
|
||||
impact: number
|
||||
inherent_risk: string
|
||||
mitigating_controls?: string[] | null
|
||||
residual_likelihood?: number | null
|
||||
residual_impact?: number | null
|
||||
residual_risk?: string | null
|
||||
owner?: string
|
||||
status: string
|
||||
treatment_plan?: string
|
||||
}
|
||||
|
||||
export interface Control {
|
||||
id: string
|
||||
control_id: string
|
||||
title: string
|
||||
domain: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface RiskHeatmapProps {
|
||||
risks: Risk[]
|
||||
controls?: Control[]
|
||||
lang?: Language
|
||||
onRiskClick?: (risk: Risk) => void
|
||||
onCellClick?: (likelihood: number, impact: number, risks: Risk[]) => void
|
||||
showComparison?: boolean
|
||||
height?: number
|
||||
}
|
||||
|
||||
const RISK_LEVEL_COLORS: Record<string, { bg: string; text: string; border: string }> = {
|
||||
low: { bg: 'bg-green-100', text: 'text-green-700', border: 'border-green-300' },
|
||||
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700', border: 'border-yellow-300' },
|
||||
high: { bg: 'bg-orange-100', text: 'text-orange-700', border: 'border-orange-300' },
|
||||
critical: { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300' },
|
||||
}
|
||||
|
||||
const RISK_BADGE_COLORS: Record<string, string> = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
critical: 'bg-red-500',
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS: Record<string, { de: string; en: string }> = {
|
||||
data_breach: { de: 'Datenschutzverletzung', en: 'Data Breach' },
|
||||
compliance_gap: { de: 'Compliance-Luecke', en: 'Compliance Gap' },
|
||||
vendor_risk: { de: 'Lieferantenrisiko', en: 'Vendor Risk' },
|
||||
operational: { de: 'Betriebsrisiko', en: 'Operational Risk' },
|
||||
technical: { de: 'Technisches Risiko', en: 'Technical Risk' },
|
||||
legal: { de: 'Rechtliches Risiko', en: 'Legal Risk' },
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: Record<string, { de: string; en: string; color: string }> = {
|
||||
open: { de: 'Offen', en: 'Open', color: 'bg-slate-100 text-slate-700' },
|
||||
mitigated: { de: 'Gemindert', en: 'Mitigated', color: 'bg-green-100 text-green-700' },
|
||||
accepted: { de: 'Akzeptiert', en: 'Accepted', color: 'bg-blue-100 text-blue-700' },
|
||||
transferred: { de: 'Uebertragen', en: 'Transferred', color: 'bg-purple-100 text-purple-700' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate risk level from likelihood and impact
|
||||
*/
|
||||
export const calculateRiskLevel = (likelihood: number, impact: number): string => {
|
||||
const score = likelihood * impact
|
||||
if (score >= 20) return 'critical'
|
||||
if (score >= 12) return 'high'
|
||||
if (score >= 6) return 'medium'
|
||||
return 'low'
|
||||
}
|
||||
|
||||
export default function RiskHeatmap({
|
||||
risks,
|
||||
controls = [],
|
||||
lang = 'de',
|
||||
onRiskClick,
|
||||
onCellClick,
|
||||
showComparison = false,
|
||||
height = 400,
|
||||
}: RiskHeatmapProps) {
|
||||
const [viewMode, setViewMode] = useState<'inherent' | 'residual' | 'comparison'>('inherent')
|
||||
const [filterCategory, setFilterCategory] = useState<string>('')
|
||||
const [filterStatus, setFilterStatus] = useState<string>('')
|
||||
const [selectedCell, setSelectedCell] = useState<{ l: number; i: number } | null>(null)
|
||||
const [selectedRisk, setSelectedRisk] = useState<Risk | null>(null)
|
||||
|
||||
// Get unique categories from risks
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set(risks.map((r) => r.category))
|
||||
return Array.from(cats).sort()
|
||||
}, [risks])
|
||||
|
||||
// Filter risks
|
||||
const filteredRisks = useMemo(() => {
|
||||
return risks.filter((r) => {
|
||||
if (filterCategory && r.category !== filterCategory) return false
|
||||
if (filterStatus && r.status !== filterStatus) return false
|
||||
return true
|
||||
})
|
||||
}, [risks, filterCategory, filterStatus])
|
||||
|
||||
// Build matrix data structure for inherent risk
|
||||
const inherentMatrix = useMemo(() => {
|
||||
const matrix: Record<number, Record<number, Risk[]>> = {}
|
||||
for (let l = 1; l <= 5; l++) {
|
||||
matrix[l] = {}
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
matrix[l][i] = []
|
||||
}
|
||||
}
|
||||
filteredRisks.forEach((risk) => {
|
||||
if (matrix[risk.likelihood] && matrix[risk.likelihood][risk.impact]) {
|
||||
matrix[risk.likelihood][risk.impact].push(risk)
|
||||
}
|
||||
})
|
||||
return matrix
|
||||
}, [filteredRisks])
|
||||
|
||||
// Build matrix data structure for residual risk
|
||||
const residualMatrix = useMemo(() => {
|
||||
const matrix: Record<number, Record<number, Risk[]>> = {}
|
||||
for (let l = 1; l <= 5; l++) {
|
||||
matrix[l] = {}
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
matrix[l][i] = []
|
||||
}
|
||||
}
|
||||
filteredRisks.forEach((risk) => {
|
||||
const likelihood = risk.residual_likelihood ?? risk.likelihood
|
||||
const impact = risk.residual_impact ?? risk.impact
|
||||
if (matrix[likelihood] && matrix[likelihood][impact]) {
|
||||
matrix[likelihood][impact].push(risk)
|
||||
}
|
||||
})
|
||||
return matrix
|
||||
}, [filteredRisks])
|
||||
|
||||
// Get controls for a risk
|
||||
const getControlsForRisk = (risk: Risk): Control[] => {
|
||||
if (!risk.mitigating_controls || risk.mitigating_controls.length === 0) return []
|
||||
return controls.filter((c) => risk.mitigating_controls?.includes(c.control_id))
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
const total = filteredRisks.length
|
||||
const byLevel: Record<string, number> = { low: 0, medium: 0, high: 0, critical: 0 }
|
||||
const byStatus: Record<string, number> = {}
|
||||
|
||||
filteredRisks.forEach((r) => {
|
||||
byLevel[r.inherent_risk] = (byLevel[r.inherent_risk] || 0) + 1
|
||||
byStatus[r.status] = (byStatus[r.status] || 0) + 1
|
||||
})
|
||||
|
||||
// Calculate residual stats
|
||||
const residualByLevel: Record<string, number> = { low: 0, medium: 0, high: 0, critical: 0 }
|
||||
filteredRisks.forEach((r) => {
|
||||
const level = r.residual_risk || r.inherent_risk
|
||||
residualByLevel[level] = (residualByLevel[level] || 0) + 1
|
||||
})
|
||||
|
||||
return { total, byLevel, byStatus, residualByLevel }
|
||||
}, [filteredRisks])
|
||||
|
||||
// Handle cell click
|
||||
const handleCellClick = (likelihood: number, impact: number, matrix: Record<number, Record<number, Risk[]>>) => {
|
||||
const cellRisks = matrix[likelihood][impact]
|
||||
if (selectedCell?.l === likelihood && selectedCell?.i === impact) {
|
||||
setSelectedCell(null)
|
||||
} else {
|
||||
setSelectedCell({ l: likelihood, i: impact })
|
||||
}
|
||||
onCellClick?.(likelihood, impact, cellRisks)
|
||||
}
|
||||
|
||||
// Handle risk click
|
||||
const handleRiskClick = (risk: Risk) => {
|
||||
if (selectedRisk?.id === risk.id) {
|
||||
setSelectedRisk(null)
|
||||
} else {
|
||||
setSelectedRisk(risk)
|
||||
}
|
||||
onRiskClick?.(risk)
|
||||
}
|
||||
|
||||
// Render single matrix
|
||||
const renderMatrix = (matrix: Record<number, Record<number, Risk[]>>, title: string) => (
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3 text-center">{title}</h4>
|
||||
<div className="inline-block">
|
||||
{/* Column headers (Impact) */}
|
||||
<div className="flex ml-12">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="w-16 text-center text-xs font-medium text-slate-500 pb-1">
|
||||
I{i}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Matrix rows */}
|
||||
{[5, 4, 3, 2, 1].map((likelihood) => (
|
||||
<div key={likelihood} className="flex items-center">
|
||||
<div className="w-12 text-xs font-medium text-slate-500 text-right pr-2">
|
||||
L{likelihood}
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5].map((impact) => {
|
||||
const level = calculateRiskLevel(likelihood, impact)
|
||||
const cellRisks = matrix[likelihood][impact]
|
||||
const isSelected = selectedCell?.l === likelihood && selectedCell?.i === impact
|
||||
const colors = RISK_LEVEL_COLORS[level]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={impact}
|
||||
onClick={() => handleCellClick(likelihood, impact, matrix)}
|
||||
className={`
|
||||
w-16 h-14 border m-0.5 rounded flex flex-col items-center justify-center
|
||||
cursor-pointer transition-all
|
||||
${colors.bg} ${colors.border}
|
||||
${isSelected ? 'ring-2 ring-primary-500 ring-offset-1' : 'hover:ring-1 hover:ring-slate-300'}
|
||||
`}
|
||||
>
|
||||
{cellRisks.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-0.5 justify-center max-h-12 overflow-hidden">
|
||||
{cellRisks.slice(0, 4).map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRiskClick(r)
|
||||
}}
|
||||
className={`
|
||||
px-1.5 py-0.5 text-[10px] font-medium rounded text-white
|
||||
${RISK_BADGE_COLORS[r.inherent_risk] || 'bg-slate-500'}
|
||||
hover:opacity-80 transition-opacity
|
||||
${selectedRisk?.id === r.id ? 'ring-2 ring-white' : ''}
|
||||
`}
|
||||
title={r.title}
|
||||
>
|
||||
{r.risk_id.replace('RISK-', 'R')}
|
||||
</button>
|
||||
))}
|
||||
{cellRisks.length > 4 && (
|
||||
<span className="text-[10px] text-slate-600">+{cellRisks.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400">-</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render risk details panel
|
||||
const renderRiskDetails = () => {
|
||||
const risksToShow = selectedRisk
|
||||
? [selectedRisk]
|
||||
: selectedCell
|
||||
? (viewMode === 'residual' ? residualMatrix : inherentMatrix)[selectedCell.l][selectedCell.i]
|
||||
: []
|
||||
|
||||
if (risksToShow.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-slate-400 py-8">
|
||||
{lang === 'de'
|
||||
? 'Klicken Sie auf eine Zelle oder ein Risiko fuer Details'
|
||||
: 'Click a cell or risk for details'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 max-h-[300px] overflow-y-auto">
|
||||
{risksToShow.map((risk) => {
|
||||
const riskControls = getControlsForRisk(risk)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={risk.id}
|
||||
className={`
|
||||
p-3 rounded-lg border transition-colors
|
||||
${selectedRisk?.id === risk.id ? 'border-primary-500 bg-primary-50' : 'border-slate-200'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<span className="font-mono text-sm font-medium text-primary-600">{risk.risk_id}</span>
|
||||
<h4 className="font-medium text-slate-900">{risk.title}</h4>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded text-white ${RISK_BADGE_COLORS[risk.inherent_risk]}`}>
|
||||
{risk.inherent_risk}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${STATUS_OPTIONS[risk.status]?.color || 'bg-slate-100'}`}>
|
||||
{STATUS_OPTIONS[risk.status]?.[lang] || risk.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{risk.description && (
|
||||
<p className="text-sm text-slate-600 mb-2">{risk.description}</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
|
||||
<div>
|
||||
<span className="text-slate-500">{lang === 'de' ? 'Kategorie' : 'Category'}:</span>{' '}
|
||||
<span className="font-medium">{CATEGORY_OPTIONS[risk.category]?.[lang] || risk.category}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">{lang === 'de' ? 'Verantwortlich' : 'Owner'}:</span>{' '}
|
||||
<span className="font-medium">{risk.owner || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">{lang === 'de' ? 'Inhaerent' : 'Inherent'}:</span>{' '}
|
||||
<span className="font-medium">{risk.likelihood} x {risk.impact} = {risk.likelihood * risk.impact}</span>
|
||||
</div>
|
||||
{(risk.residual_likelihood && risk.residual_impact) && (
|
||||
<div>
|
||||
<span className="text-slate-500">{lang === 'de' ? 'Residual' : 'Residual'}:</span>{' '}
|
||||
<span className="font-medium">{risk.residual_likelihood} x {risk.residual_impact} = {risk.residual_likelihood * risk.residual_impact}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mitigating Controls */}
|
||||
{riskControls.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
<p className="text-xs text-slate-500 mb-1">
|
||||
{lang === 'de' ? 'Mitigierende Massnahmen' : 'Mitigating Controls'} ({riskControls.length})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{riskControls.map((ctrl) => (
|
||||
<span
|
||||
key={ctrl.control_id}
|
||||
className="px-2 py-0.5 text-xs bg-slate-100 text-slate-700 rounded"
|
||||
title={ctrl.title}
|
||||
>
|
||||
{ctrl.control_id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{risk.treatment_plan && (
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
<p className="text-xs text-slate-500 mb-1">
|
||||
{lang === 'de' ? 'Behandlungsplan' : 'Treatment Plan'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600">{risk.treatment_plan}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (risks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-8 text-center">
|
||||
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<p className="text-slate-500">
|
||||
{lang === 'de' ? 'Keine Risiken vorhanden' : 'No risks available'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Statistics Header */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<p className="text-xs text-slate-500">{lang === 'de' ? 'Gesamt' : 'Total'}</p>
|
||||
<p className="text-xl font-bold text-slate-800">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<p className="text-xs text-slate-500">Critical</p>
|
||||
<p className="text-xl font-bold text-red-600">{stats.byLevel.critical}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<p className="text-xs text-slate-500">High</p>
|
||||
<p className="text-xl font-bold text-orange-600">{stats.byLevel.high}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<p className="text-xs text-slate-500">Medium</p>
|
||||
<p className="text-xl font-bold text-yellow-600">{stats.byLevel.medium}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<p className="text-xs text-slate-500">Low</p>
|
||||
<p className="text-xl font-bold text-green-600">{stats.byLevel.low}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Controls */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 text-sm"
|
||||
>
|
||||
<option value="">{lang === 'de' ? 'Alle Kategorien' : 'All Categories'}</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{CATEGORY_OPTIONS[cat]?.[lang] || cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 text-sm"
|
||||
>
|
||||
<option value="">{lang === 'de' ? 'Alle Status' : 'All Status'}</option>
|
||||
{Object.entries(STATUS_OPTIONS).map(([key, val]) => (
|
||||
<option key={key} value={key}>
|
||||
{val[lang]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{showComparison && (
|
||||
<div className="flex bg-slate-100 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('inherent')}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
viewMode === 'inherent' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{lang === 'de' ? 'Inhaerent' : 'Inherent'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('residual')}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
viewMode === 'residual' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
Residual
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('comparison')}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
viewMode === 'comparison' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{lang === 'de' ? 'Vergleich' : 'Compare'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Matrix View(s) */}
|
||||
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border p-4">
|
||||
{viewMode === 'comparison' ? (
|
||||
<div className="flex gap-6 overflow-x-auto">
|
||||
{renderMatrix(inherentMatrix, lang === 'de' ? 'Inhaerent' : 'Inherent')}
|
||||
<div className="w-px bg-slate-200 self-stretch" />
|
||||
{renderMatrix(residualMatrix, 'Residual')}
|
||||
</div>
|
||||
) : viewMode === 'residual' ? (
|
||||
<div className="flex justify-center">
|
||||
{renderMatrix(residualMatrix, lang === 'de' ? 'Residuales Risiko' : 'Residual Risk')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
{renderMatrix(inherentMatrix, lang === 'de' ? 'Inhaeentes Risiko' : 'Inherent Risk')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-4 mt-4 pt-4 border-t justify-center flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-green-500 rounded" />
|
||||
<span className="text-xs text-slate-600">Low (1-5)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-yellow-500 rounded" />
|
||||
<span className="text-xs text-slate-600">Medium (6-11)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-orange-500 rounded" />
|
||||
<span className="text-xs text-slate-600">High (12-19)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-red-500 rounded" />
|
||||
<span className="text-xs text-slate-600">Critical (20-25)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Details Panel */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-3">
|
||||
{lang === 'de' ? 'Risiko-Details' : 'Risk Details'}
|
||||
</h3>
|
||||
{renderRiskDetails()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Movement Summary (when comparison mode) */}
|
||||
{viewMode === 'comparison' && (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-3">
|
||||
{lang === 'de' ? 'Risikoveraenderung durch Massnahmen' : 'Risk Change from Controls'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{stats.byLevel.critical - stats.residualByLevel.critical}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{lang === 'de' ? 'Critical reduziert' : 'Critical reduced'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-orange-600">
|
||||
{stats.byLevel.high - stats.residualByLevel.high}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{lang === 'de' ? 'High reduziert' : 'High reduced'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-yellow-600">
|
||||
{stats.byLevel.medium - stats.residualByLevel.medium}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{lang === 'de' ? 'Medium reduziert' : 'Medium reduced'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-slate-600">
|
||||
{filteredRisks.filter((r) => r.residual_likelihood && r.residual_impact).length}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{lang === 'de' ? 'Bewertet' : 'Assessed'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini Risk Matrix for compact display
|
||||
*/
|
||||
interface MiniRiskMatrixProps {
|
||||
risks: Risk[]
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
export function MiniRiskMatrix({ risks, size = 'sm' }: MiniRiskMatrixProps) {
|
||||
const matrix = useMemo(() => {
|
||||
const m: Record<number, Record<number, number>> = {}
|
||||
for (let l = 1; l <= 5; l++) {
|
||||
m[l] = {}
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
m[l][i] = 0
|
||||
}
|
||||
}
|
||||
risks.forEach((r) => {
|
||||
if (m[r.likelihood] && m[r.likelihood][r.impact] !== undefined) {
|
||||
m[r.likelihood][r.impact]++
|
||||
}
|
||||
})
|
||||
return m
|
||||
}, [risks])
|
||||
|
||||
const cellSize = size === 'sm' ? 'w-6 h-6' : 'w-8 h-8'
|
||||
const fontSize = size === 'sm' ? 'text-[8px]' : 'text-[10px]'
|
||||
|
||||
return (
|
||||
<div className="inline-block">
|
||||
{[5, 4, 3, 2, 1].map((l) => (
|
||||
<div key={l} className="flex">
|
||||
{[1, 2, 3, 4, 5].map((i) => {
|
||||
const level = calculateRiskLevel(l, i)
|
||||
const count = matrix[l][i]
|
||||
const colors = RISK_LEVEL_COLORS[level]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${cellSize} ${colors.bg} border ${colors.border} flex items-center justify-center m-px rounded-sm`}
|
||||
>
|
||||
{count > 0 && (
|
||||
<span className={`${fontSize} font-bold ${colors.text}`}>{count}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Risk Distribution Chart (simple bar representation)
|
||||
*/
|
||||
interface RiskDistributionProps {
|
||||
risks: Risk[]
|
||||
lang?: Language
|
||||
}
|
||||
|
||||
export function RiskDistribution({ risks, lang = 'de' }: RiskDistributionProps) {
|
||||
const stats = useMemo(() => {
|
||||
const byLevel: Record<string, number> = { critical: 0, high: 0, medium: 0, low: 0 }
|
||||
risks.forEach((r) => {
|
||||
byLevel[r.inherent_risk] = (byLevel[r.inherent_risk] || 0) + 1
|
||||
})
|
||||
return byLevel
|
||||
}, [risks])
|
||||
|
||||
const total = risks.length || 1
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{['critical', 'high', 'medium', 'low'].map((level) => {
|
||||
const count = stats[level]
|
||||
const percentage = (count / total) * 100
|
||||
|
||||
return (
|
||||
<div key={level} className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-16 capitalize">{level}</span>
|
||||
<div className="flex-1 h-4 bg-slate-100 rounded overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${RISK_BADGE_COLORS[level]} transition-all`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-slate-700 w-8 text-right">{count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user