website (17 pages + 3 components): - multiplayer/wizard, middleware/wizard+test-wizard, communication - builds/wizard, staff-search, voice, sbom/wizard - foerderantrag, mail/tasks, tools/communication, sbom - compliance/evidence, uni-crawler, brandbook (already done) - CollectionsTab, IngestionTab, RiskHeatmap backend-lehrer (5 files): - letters_api (641 → 2), certificates_api (636 → 2) - alerts_agent/db/models (636 → 3) - llm_gateway/communication_service (614 → 2) - game/database already done in prior batch klausur-service (2 files): - hybrid_vocab_extractor (664 → 2) - klausur-service/frontend: api.ts (620 → 3), EHUploadWizard (591 → 2) voice-service (3 files): - bqas/rag_judge (618 → 3), runner (529 → 2) - enhanced_task_orchestrator (519 → 2) studio-v2 (6 files): - korrektur/[klausurId] (578 → 4), fairness (569 → 2) - AlertsWizard (552 → 2), OnboardingWizard (513 → 2) - korrektur/api.ts (506 → 3), geo-lernwelt (501 → 2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
259 lines
16 KiB
TypeScript
259 lines
16 KiB
TypeScript
'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 } from '@/lib/compliance-i18n'
|
|
import {
|
|
Risk,
|
|
Control,
|
|
RiskHeatmapProps,
|
|
RISK_LEVEL_COLORS,
|
|
RISK_BADGE_COLORS,
|
|
CATEGORY_OPTIONS,
|
|
STATUS_OPTIONS,
|
|
calculateRiskLevel,
|
|
} from './risk-heatmap-types'
|
|
|
|
// Re-export types and utilities for backward compatibility
|
|
export type { Risk, Control } from './risk-heatmap-types'
|
|
export { calculateRiskLevel } from './risk-heatmap-types'
|
|
export { MiniRiskMatrix } from './MiniRiskMatrix'
|
|
export { RiskDistribution } from './RiskDistribution'
|
|
|
|
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)
|
|
|
|
const categories = useMemo(() => {
|
|
const cats = new Set(risks.map((r) => r.category))
|
|
return Array.from(cats).sort()
|
|
}, [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])
|
|
|
|
const buildMatrix = (useResidual: boolean) => {
|
|
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 = useResidual ? (risk.residual_likelihood ?? risk.likelihood) : risk.likelihood
|
|
const impact = useResidual ? (risk.residual_impact ?? risk.impact) : risk.impact
|
|
if (matrix[likelihood] && matrix[likelihood][impact]) matrix[likelihood][impact].push(risk)
|
|
})
|
|
return matrix
|
|
}
|
|
|
|
const inherentMatrix = useMemo(() => buildMatrix(false), [filteredRisks])
|
|
const residualMatrix = useMemo(() => buildMatrix(true), [filteredRisks])
|
|
|
|
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))
|
|
}
|
|
|
|
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 })
|
|
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])
|
|
|
|
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)
|
|
}
|
|
|
|
const handleRiskClick = (risk: Risk) => {
|
|
if (selectedRisk?.id === risk.id) { setSelectedRisk(null) } else { setSelectedRisk(risk) }
|
|
onRiskClick?.(risk)
|
|
}
|
|
|
|
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">
|
|
<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>
|
|
{[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>
|
|
)
|
|
|
|
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">Residual:</span> <span className="font-medium">{risk.residual_likelihood} x {risk.residual_impact} = {risk.residual_likelihood * risk.residual_impact}</span></div>)}
|
|
</div>
|
|
{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">
|
|
<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>
|
|
)}
|
|
<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>
|
|
<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 */}
|
|
{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>
|
|
)
|
|
}
|