'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('') const [filterStatus, setFilterStatus] = useState('') const [selectedCell, setSelectedCell] = useState<{ l: number; i: number } | null>(null) const [selectedRisk, setSelectedRisk] = useState(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> = {} 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 = { low: 0, medium: 0, high: 0, critical: 0 } const byStatus: Record = {} filteredRisks.forEach((r) => { byLevel[r.inherent_risk] = (byLevel[r.inherent_risk] || 0) + 1; byStatus[r.status] = (byStatus[r.status] || 0) + 1 }) const residualByLevel: Record = { 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>) => { 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>, title: string) => (

{title}

{[1, 2, 3, 4, 5].map((i) => (
I{i}
))}
{[5, 4, 3, 2, 1].map((likelihood) => (
L{likelihood}
{[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 (
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 ? (
{cellRisks.slice(0, 4).map((r) => ( ))} {cellRisks.length > 4 && +{cellRisks.length - 4}}
) : -}
) })}
))}
) const renderRiskDetails = () => { const risksToShow = selectedRisk ? [selectedRisk] : selectedCell ? (viewMode === 'residual' ? residualMatrix : inherentMatrix)[selectedCell.l][selectedCell.i] : [] if (risksToShow.length === 0) return (
{lang === 'de' ? 'Klicken Sie auf eine Zelle oder ein Risiko fuer Details' : 'Click a cell or risk for details'}
) return (
{risksToShow.map((risk) => { const riskControls = getControlsForRisk(risk) return (
{risk.risk_id}

{risk.title}

{risk.inherent_risk} {STATUS_OPTIONS[risk.status]?.[lang] || risk.status}
{risk.description &&

{risk.description}

}
{lang === 'de' ? 'Kategorie' : 'Category'}: {CATEGORY_OPTIONS[risk.category]?.[lang] || risk.category}
{lang === 'de' ? 'Verantwortlich' : 'Owner'}: {risk.owner || '-'}
{lang === 'de' ? 'Inhaerent' : 'Inherent'}: {risk.likelihood} x {risk.impact} = {risk.likelihood * risk.impact}
{(risk.residual_likelihood && risk.residual_impact) && (
Residual: {risk.residual_likelihood} x {risk.residual_impact} = {risk.residual_likelihood * risk.residual_impact}
)}
{riskControls.length > 0 && (

{lang === 'de' ? 'Mitigierende Massnahmen' : 'Mitigating Controls'} ({riskControls.length})

{riskControls.map((ctrl) => ({ctrl.control_id}))}
)} {risk.treatment_plan && (

{lang === 'de' ? 'Behandlungsplan' : 'Treatment Plan'}

{risk.treatment_plan}

)}
) })}
) } if (risks.length === 0) { return (

{lang === 'de' ? 'Keine Risiken vorhanden' : 'No risks available'}

) } return (
{/* Statistics Header */}

{lang === 'de' ? 'Gesamt' : 'Total'}

{stats.total}

Critical

{stats.byLevel.critical}

High

{stats.byLevel.high}

Medium

{stats.byLevel.medium}

Low

{stats.byLevel.low}

{/* Filters and Controls */}
{showComparison && (
)}
{/* Main Content */}
{viewMode === 'comparison' ? (
{renderMatrix(inherentMatrix, lang === 'de' ? 'Inhaerent' : 'Inherent')}
{renderMatrix(residualMatrix, 'Residual')}
) : viewMode === 'residual' ? (
{renderMatrix(residualMatrix, lang === 'de' ? 'Residuales Risiko' : 'Residual Risk')}
) : (
{renderMatrix(inherentMatrix, lang === 'de' ? 'Inhaeentes Risiko' : 'Inherent Risk')}
)}
Low (1-5)
Medium (6-11)
High (12-19)
Critical (20-25)

{lang === 'de' ? 'Risiko-Details' : 'Risk Details'}

{renderRiskDetails()}
{/* Risk Movement Summary */} {viewMode === 'comparison' && (

{lang === 'de' ? 'Risikoveraenderung durch Massnahmen' : 'Risk Change from Controls'}

{stats.byLevel.critical - stats.residualByLevel.critical}

{lang === 'de' ? 'Critical reduziert' : 'Critical reduced'}

{stats.byLevel.high - stats.residualByLevel.high}

{lang === 'de' ? 'High reduziert' : 'High reduced'}

{stats.byLevel.medium - stats.residualByLevel.medium}

{lang === 'de' ? 'Medium reduziert' : 'Medium reduced'}

{filteredRisks.filter((r) => r.residual_likelihood && r.residual_impact).length}

{lang === 'de' ? 'Bewertet' : 'Assessed'}

)}
) }