'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 = { 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 = { low: 'bg-green-500', medium: 'bg-yellow-500', high: 'bg-orange-500', critical: 'bg-red-500', } const CATEGORY_OPTIONS: Record = { 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 = { 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('') const [filterStatus, setFilterStatus] = useState('') const [selectedCell, setSelectedCell] = useState<{ l: number; i: number } | null>(null) const [selectedRisk, setSelectedRisk] = useState(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> = {} 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> = {} 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 = { 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 }) // Calculate residual stats 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]) // Handle cell click 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) } // 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>, title: string) => (

{title}

{/* Column headers (Impact) */}
{[1, 2, 3, 4, 5].map((i) => (
I{i}
))}
{/* Matrix rows */} {[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} )}
) : ( - )}
) })}
))}
) // Render risk details panel 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) && (
{lang === 'de' ? 'Residual' : 'Residual'}:{' '} {risk.residual_likelihood} x {risk.residual_impact} = {risk.residual_likelihood * risk.residual_impact}
)}
{/* Mitigating Controls */} {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 */}
{/* Matrix View(s) */}
{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')}
)} {/* Legend */}
Low (1-5)
Medium (6-11)
High (12-19)
Critical (20-25)
{/* Risk Details Panel */}

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

{renderRiskDetails()}
{/* Risk Movement Summary (when comparison mode) */} {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'}

)}
) } /** * 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> = {} 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 (
{[5, 4, 3, 2, 1].map((l) => (
{[1, 2, 3, 4, 5].map((i) => { const level = calculateRiskLevel(l, i) const count = matrix[l][i] const colors = RISK_LEVEL_COLORS[level] return (
{count > 0 && ( {count} )}
) })}
))}
) } /** * 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 = { 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 (
{['critical', 'high', 'medium', 'low'].map((level) => { const count = stats[level] const percentage = (count / total) * 100 return (
{level}
{count}
) })}
) }