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:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,238 @@
'use client'
/**
* ComplianceTrendChart Component
*
* Displays compliance score trend over time using Recharts.
* Shows 12-month history with interactive tooltip.
*/
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Area,
AreaChart,
} from 'recharts'
import { Language, getTerm } from '@/lib/compliance-i18n'
interface TrendDataPoint {
date: string
score: number
label?: string
}
interface ComplianceTrendChartProps {
data: TrendDataPoint[]
lang?: Language
height?: number
showArea?: boolean
}
export default function ComplianceTrendChart({
data,
lang = 'de',
height = 200,
showArea = true,
}: ComplianceTrendChartProps) {
if (!data || data.length === 0) {
return (
<div
className="flex items-center justify-center text-slate-400 text-sm"
style={{ height }}
>
{lang === 'de' ? 'Keine Trenddaten verfuegbar' : 'No trend data available'}
</div>
)
}
const getScoreColor = (score: number) => {
if (score >= 80) return '#22c55e' // green-500
if (score >= 60) return '#eab308' // yellow-500
return '#ef4444' // red-500
}
const latestScore = data[data.length - 1]?.score || 0
const strokeColor = getScoreColor(latestScore)
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const score = payload[0].value
return (
<div className="bg-white px-3 py-2 rounded-lg shadow-lg border border-slate-200">
<p className="text-xs text-slate-500">{label}</p>
<p className="text-lg font-bold" style={{ color: getScoreColor(score) }}>
{score.toFixed(1)}%
</p>
</div>
)
}
return null
}
if (showArea) {
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 5, right: 5, left: -20, bottom: 5 }}>
<defs>
<linearGradient id="colorScore" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={strokeColor} stopOpacity={0.3} />
<stop offset="95%" stopColor={strokeColor} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: '#94a3b8' }}
axisLine={{ stroke: '#e2e8f0' }}
tickLine={false}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 10, fill: '#94a3b8' }}
axisLine={{ stroke: '#e2e8f0' }}
tickLine={false}
tickFormatter={(value) => `${value}%`}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="score"
stroke={strokeColor}
strokeWidth={2}
fillOpacity={1}
fill="url(#colorScore)"
/>
</AreaChart>
</ResponsiveContainer>
)
}
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={data} margin={{ top: 5, right: 5, left: -20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: '#94a3b8' }}
axisLine={{ stroke: '#e2e8f0' }}
tickLine={false}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 10, fill: '#94a3b8' }}
axisLine={{ stroke: '#e2e8f0' }}
tickLine={false}
tickFormatter={(value) => `${value}%`}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="score"
stroke={strokeColor}
strokeWidth={2}
dot={{ fill: strokeColor, strokeWidth: 2, r: 3 }}
activeDot={{ r: 5, stroke: strokeColor, strokeWidth: 2, fill: 'white' }}
/>
</LineChart>
</ResponsiveContainer>
)
}
/**
* TrafficLightIndicator Component
*
* Large circular indicator showing overall compliance status.
*/
interface TrafficLightIndicatorProps {
status: 'green' | 'yellow' | 'red'
score: number
lang?: Language
size?: 'sm' | 'md' | 'lg'
}
export function TrafficLightIndicator({
status,
score,
lang = 'de',
size = 'lg'
}: TrafficLightIndicatorProps) {
const colors = {
green: { bg: 'bg-green-500', ring: 'ring-green-200', text: 'text-green-700' },
yellow: { bg: 'bg-yellow-500', ring: 'ring-yellow-200', text: 'text-yellow-700' },
red: { bg: 'bg-red-500', ring: 'ring-red-200', text: 'text-red-700' },
}
const sizes = {
sm: { container: 'w-16 h-16', text: 'text-lg', label: 'text-xs' },
md: { container: 'w-24 h-24', text: 'text-2xl', label: 'text-sm' },
lg: { container: 'w-32 h-32', text: 'text-4xl', label: 'text-base' },
}
const labels = {
green: { de: 'Gut', en: 'Good' },
yellow: { de: 'Achtung', en: 'Attention' },
red: { de: 'Kritisch', en: 'Critical' },
}
const { bg, ring, text } = colors[status]
const { container, text: textSize, label: labelSize } = sizes[size]
return (
<div className="flex flex-col items-center">
<div
className={`
${container} rounded-full flex items-center justify-center
${bg} ring-4 ${ring} shadow-lg
transition-all duration-300
`}
>
<span className={`${textSize} font-bold text-white`}>
{score.toFixed(0)}%
</span>
</div>
<span className={`mt-2 ${labelSize} font-medium ${text}`}>
{labels[status][lang]}
</span>
</div>
)
}
/**
* MiniSparkline Component
*
* Tiny inline chart for trend indication.
*/
interface MiniSparklineProps {
data: number[]
width?: number
height?: number
}
export function MiniSparkline({ data, width = 60, height = 20 }: MiniSparklineProps) {
if (!data || data.length < 2) {
return <span className="text-slate-300">--</span>
}
const chartData = data.map((value, index) => ({ value, index }))
const trend = data[data.length - 1] - data[0]
const color = trend >= 0 ? '#22c55e' : '#ef4444'
return (
<ResponsiveContainer width={width} height={height}>
<LineChart data={chartData}>
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={1.5}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,566 @@
'use client'
/**
* DependencyMap Component
*
* Visualizes the relationship between Controls and Requirements.
* Shows which controls satisfy which regulatory requirements.
*
* Sprint 5: PDF Reports & Erweiterte Visualisierungen
*/
import { useState, useMemo } from 'react'
import { Language, getTerm } from '@/lib/compliance-i18n'
interface Requirement {
id: string
article: string
title: string
regulation_code: string
}
interface Control {
id: string
control_id: string
title: string
domain: string
status: string
}
interface Mapping {
requirement_id: string
control_id: string
coverage_level: 'full' | 'partial' | 'planned'
}
interface DependencyMapProps {
requirements: Requirement[]
controls: Control[]
mappings: Mapping[]
lang?: Language
onControlClick?: (control: Control) => void
onRequirementClick?: (requirement: Requirement) => void
}
const DOMAIN_COLORS: Record<string, string> = {
gov: '#64748b',
priv: '#3b82f6',
iam: '#a855f7',
crypto: '#eab308',
sdlc: '#22c55e',
ops: '#f97316',
ai: '#ec4899',
cra: '#06b6d4',
aud: '#6366f1',
}
const DOMAIN_LABELS: Record<string, string> = {
gov: 'Governance',
priv: 'Datenschutz',
iam: 'Identity & Access',
crypto: 'Kryptografie',
sdlc: 'Secure Dev',
ops: 'Operations',
ai: 'KI-spezifisch',
cra: 'Supply Chain',
aud: 'Audit',
}
const COVERAGE_COLORS: Record<string, { bg: string; border: string; text: string }> = {
full: { bg: 'bg-green-100', border: 'border-green-500', text: 'text-green-700' },
partial: { bg: 'bg-yellow-100', border: 'border-yellow-500', text: 'text-yellow-700' },
planned: { bg: 'bg-slate-100', border: 'border-slate-400', text: 'text-slate-600' },
}
export default function DependencyMap({
requirements,
controls,
mappings,
lang = 'de',
onControlClick,
onRequirementClick,
}: DependencyMapProps) {
const [selectedControl, setSelectedControl] = useState<string | null>(null)
const [selectedRequirement, setSelectedRequirement] = useState<string | null>(null)
const [filterRegulation, setFilterRegulation] = useState<string>('')
const [filterDomain, setFilterDomain] = useState<string>('')
const [viewMode, setViewMode] = useState<'matrix' | 'sankey'>('matrix')
// Get unique regulations
const regulations = useMemo(() => {
const regs = new Set(requirements.map((r) => r.regulation_code))
return Array.from(regs).sort()
}, [requirements])
// Get unique domains
const domains = useMemo(() => {
const doms = new Set(controls.map((c) => c.domain))
return Array.from(doms).sort()
}, [controls])
// Filter requirements and controls
const filteredRequirements = useMemo(() => {
return requirements.filter((r) => {
if (filterRegulation && r.regulation_code !== filterRegulation) return false
return true
})
}, [requirements, filterRegulation])
const filteredControls = useMemo(() => {
return controls.filter((c) => {
if (filterDomain && c.domain !== filterDomain) return false
return true
})
}, [controls, filterDomain])
// Build mapping lookup
const mappingLookup = useMemo(() => {
const lookup: Record<string, Record<string, Mapping>> = {}
mappings.forEach((m) => {
if (!lookup[m.control_id]) lookup[m.control_id] = {}
lookup[m.control_id][m.requirement_id] = m
})
return lookup
}, [mappings])
// Get connected requirements for a control
const getConnectedRequirements = (controlId: string) => {
return Object.keys(mappingLookup[controlId] || {})
}
// Get connected controls for a requirement
const getConnectedControls = (requirementId: string) => {
return Object.keys(mappingLookup)
.filter((controlId) => mappingLookup[controlId][requirementId])
.map((controlId) => ({
controlId,
coverage: mappingLookup[controlId][requirementId].coverage_level,
}))
}
// Handle control selection
const handleControlClick = (control: Control) => {
if (selectedControl === control.control_id) {
setSelectedControl(null)
} else {
setSelectedControl(control.control_id)
setSelectedRequirement(null)
}
onControlClick?.(control)
}
// Handle requirement selection
const handleRequirementClick = (requirement: Requirement) => {
if (selectedRequirement === requirement.id) {
setSelectedRequirement(null)
} else {
setSelectedRequirement(requirement.id)
setSelectedControl(null)
}
onRequirementClick?.(requirement)
}
// Calculate statistics
const stats = useMemo(() => {
const totalMappings = mappings.length
const fullMappings = mappings.filter((m) => m.coverage_level === 'full').length
const partialMappings = mappings.filter((m) => m.coverage_level === 'partial').length
const plannedMappings = mappings.filter((m) => m.coverage_level === 'planned').length
const coveredRequirements = new Set(mappings.map((m) => m.requirement_id)).size
const usedControls = new Set(mappings.map((m) => m.control_id)).size
return {
totalMappings,
fullMappings,
partialMappings,
plannedMappings,
coveredRequirements,
totalRequirements: requirements.length,
usedControls,
totalControls: controls.length,
coveragePercent: ((coveredRequirements / requirements.length) * 100).toFixed(1),
}
}, [mappings, requirements, controls])
if (requirements.length === 0 || controls.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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p className="text-slate-500">
{lang === 'de' ? 'Keine Mappings vorhanden' : 'No mappings available'}
</p>
</div>
)
}
return (
<div className="space-y-4">
{/* Statistics Header */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg border p-4">
<p className="text-sm text-slate-500">
{lang === 'de' ? 'Abdeckung' : 'Coverage'}
</p>
<p className="text-2xl font-bold text-primary-600">{stats.coveragePercent}%</p>
<p className="text-xs text-slate-400">
{stats.coveredRequirements}/{stats.totalRequirements} {lang === 'de' ? 'Anforderungen' : 'Requirements'}
</p>
</div>
<div className="bg-white rounded-lg border p-4">
<p className="text-sm text-slate-500">
{lang === 'de' ? 'Vollstaendig' : 'Full'}
</p>
<p className="text-2xl font-bold text-green-600">{stats.fullMappings}</p>
<p className="text-xs text-slate-400">{lang === 'de' ? 'Mappings' : 'Mappings'}</p>
</div>
<div className="bg-white rounded-lg border p-4">
<p className="text-sm text-slate-500">
{lang === 'de' ? 'Teilweise' : 'Partial'}
</p>
<p className="text-2xl font-bold text-yellow-600">{stats.partialMappings}</p>
<p className="text-xs text-slate-400">{lang === 'de' ? 'Mappings' : 'Mappings'}</p>
</div>
<div className="bg-white rounded-lg border p-4">
<p className="text-sm text-slate-500">
{lang === 'de' ? 'Geplant' : 'Planned'}
</p>
<p className="text-2xl font-bold text-slate-600">{stats.plannedMappings}</p>
<p className="text-xs text-slate-400">{lang === 'de' ? 'Mappings' : 'Mappings'}</p>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-xl shadow-sm border p-4">
<div className="flex flex-wrap items-center gap-4">
<select
value={filterRegulation}
onChange={(e) => setFilterRegulation(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">{lang === 'de' ? 'Alle Verordnungen' : 'All Regulations'}</option>
{regulations.map((reg) => (
<option key={reg} value={reg}>{reg}</option>
))}
</select>
<select
value={filterDomain}
onChange={(e) => setFilterDomain(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">{lang === 'de' ? 'Alle Domains' : 'All Domains'}</option>
{domains.map((dom) => (
<option key={dom} value={dom}>{DOMAIN_LABELS[dom] || dom}</option>
))}
</select>
<div className="flex-1" />
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => setViewMode('matrix')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'matrix' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
Matrix
</button>
<button
onClick={() => setViewMode('sankey')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'sankey' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
{lang === 'de' ? 'Verbindungen' : 'Connections'}
</button>
</div>
</div>
</div>
{/* Main Visualization */}
{viewMode === 'matrix' ? (
<div className="bg-white rounded-xl shadow-sm border p-4 overflow-auto">
<div className="min-w-[800px]">
{/* Matrix Header */}
<div className="flex">
<div className="w-48 flex-shrink-0" />
<div className="flex-1 flex">
{filteredControls.map((control) => (
<div
key={control.control_id}
onClick={() => handleControlClick(control)}
className={`
w-20 flex-shrink-0 text-center p-2 cursor-pointer transition-colors
${selectedControl === control.control_id ? 'bg-primary-100' : 'hover:bg-slate-50'}
`}
>
<div
className="w-3 h-3 rounded-full mx-auto mb-1"
style={{ backgroundColor: DOMAIN_COLORS[control.domain] || '#94a3b8' }}
/>
<p className="text-xs font-mono font-medium truncate" title={control.control_id}>
{control.control_id}
</p>
</div>
))}
</div>
</div>
{/* Matrix Body */}
{filteredRequirements.map((req) => {
const connectedControls = getConnectedControls(req.id)
const isHighlighted = selectedRequirement === req.id ||
(selectedControl && connectedControls.some((c) => c.controlId === selectedControl))
return (
<div
key={req.id}
className={`flex border-t ${isHighlighted ? 'bg-primary-50' : ''}`}
>
<div
onClick={() => handleRequirementClick(req)}
className={`
w-48 flex-shrink-0 p-2 cursor-pointer transition-colors
${selectedRequirement === req.id ? 'bg-primary-100' : 'hover:bg-slate-50'}
`}
>
<p className="text-xs font-medium text-slate-700 truncate" title={req.article}>
{req.regulation_code} {req.article}
</p>
<p className="text-xs text-slate-400 truncate" title={req.title}>
{req.title}
</p>
</div>
<div className="flex-1 flex">
{filteredControls.map((control) => {
const mapping = mappingLookup[control.control_id]?.[req.id]
const isControlHighlighted = selectedControl === control.control_id
const isConnected = selectedControl && mapping
return (
<div
key={`${req.id}-${control.control_id}`}
className={`
w-20 flex-shrink-0 h-10 flex items-center justify-center
${isControlHighlighted ? 'bg-primary-50' : ''}
${isConnected ? 'ring-2 ring-primary-400' : ''}
`}
>
{mapping && (
<div
className={`
w-6 h-6 rounded flex items-center justify-center
${COVERAGE_COLORS[mapping.coverage_level].bg}
${COVERAGE_COLORS[mapping.coverage_level].text}
border ${COVERAGE_COLORS[mapping.coverage_level].border}
`}
title={`${mapping.coverage_level === 'full' ? 'Vollstaendig' : mapping.coverage_level === 'partial' ? 'Teilweise' : 'Geplant'}`}
>
{mapping.coverage_level === 'full' && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{mapping.coverage_level === 'partial' && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
)}
{mapping.coverage_level === 'planned' && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
) : (
/* Sankey/Connection View */
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex gap-8">
{/* Controls Column */}
<div className="w-1/3 space-y-2">
<h4 className="text-sm font-medium text-slate-700 mb-3">
Controls ({filteredControls.length})
</h4>
{filteredControls.map((control) => {
const connectedReqs = getConnectedRequirements(control.control_id)
const isSelected = selectedControl === control.control_id
return (
<button
key={control.control_id}
onClick={() => handleControlClick(control)}
className={`
w-full text-left p-3 rounded-lg border transition-all
${isSelected ? 'border-primary-500 bg-primary-50 shadow' : 'border-slate-200 hover:border-slate-300'}
`}
>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: DOMAIN_COLORS[control.domain] || '#94a3b8' }}
/>
<span className="font-mono text-sm font-medium">{control.control_id}</span>
<span className="text-xs text-slate-400 ml-auto">{connectedReqs.length}</span>
</div>
<p className="text-xs text-slate-500 mt-1 truncate">{control.title}</p>
</button>
)
})}
</div>
{/* Connection Lines (simplified) */}
<div className="w-1/3 flex items-center justify-center">
<div className="relative w-full h-full min-h-[200px]">
{selectedControl && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
{getConnectedRequirements(selectedControl).slice(0, 10).map((reqId, idx) => {
const req = requirements.find((r) => r.id === reqId)
const mapping = mappingLookup[selectedControl][reqId]
if (!req) return null
return (
<div
key={reqId}
className={`
px-3 py-1.5 rounded-full text-xs font-medium
${COVERAGE_COLORS[mapping.coverage_level].bg}
${COVERAGE_COLORS[mapping.coverage_level].text}
border ${COVERAGE_COLORS[mapping.coverage_level].border}
`}
>
{req.regulation_code} {req.article}
</div>
)
})}
{getConnectedRequirements(selectedControl).length > 10 && (
<span className="text-xs text-slate-400">
+{getConnectedRequirements(selectedControl).length - 10} {lang === 'de' ? 'weitere' : 'more'}
</span>
)}
</div>
)}
{selectedRequirement && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
{getConnectedControls(selectedRequirement).slice(0, 10).map(({ controlId, coverage }) => {
const control = controls.find((c) => c.control_id === controlId)
if (!control) return null
return (
<div
key={controlId}
className={`
px-3 py-1.5 rounded-full text-xs font-medium
${COVERAGE_COLORS[coverage].bg}
${COVERAGE_COLORS[coverage].text}
border ${COVERAGE_COLORS[coverage].border}
`}
>
{control.control_id}
</div>
)
})}
</div>
)}
{!selectedControl && !selectedRequirement && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-sm text-slate-400 text-center">
{lang === 'de'
? 'Waehlen Sie ein Control oder eine Anforderung aus'
: 'Select a control or requirement'}
</p>
</div>
)}
</div>
</div>
{/* Requirements Column */}
<div className="w-1/3 space-y-2">
<h4 className="text-sm font-medium text-slate-700 mb-3">
{lang === 'de' ? 'Anforderungen' : 'Requirements'} ({filteredRequirements.length})
</h4>
{filteredRequirements.slice(0, 15).map((req) => {
const connectedCtrls = getConnectedControls(req.id)
const isSelected = selectedRequirement === req.id
const isHighlighted = selectedControl && connectedCtrls.some((c) => c.controlId === selectedControl)
return (
<button
key={req.id}
onClick={() => handleRequirementClick(req)}
className={`
w-full text-left p-3 rounded-lg border transition-all
${isSelected ? 'border-primary-500 bg-primary-50 shadow' : ''}
${isHighlighted && !isSelected ? 'border-primary-300 bg-primary-25' : ''}
${!isSelected && !isHighlighted ? 'border-slate-200 hover:border-slate-300' : ''}
`}
>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-600">{req.regulation_code}</span>
<span className="font-mono text-sm">{req.article}</span>
<span className="text-xs text-slate-400 ml-auto">{connectedCtrls.length}</span>
</div>
<p className="text-xs text-slate-500 mt-1 truncate">{req.title}</p>
</button>
)
})}
{filteredRequirements.length > 15 && (
<p className="text-xs text-slate-400 text-center py-2">
+{filteredRequirements.length - 15} {lang === 'de' ? 'weitere' : 'more'}
</p>
)}
</div>
</div>
</div>
)}
{/* Legend */}
<div className="bg-white rounded-xl shadow-sm border p-4">
<div className="flex flex-wrap gap-6 justify-center">
<div className="flex items-center gap-2">
<div className={`w-6 h-6 rounded flex items-center justify-center ${COVERAGE_COLORS.full.bg} ${COVERAGE_COLORS.full.text} border ${COVERAGE_COLORS.full.border}`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-sm text-slate-600">
{lang === 'de' ? 'Vollstaendig abgedeckt' : 'Fully covered'}
</span>
</div>
<div className="flex items-center gap-2">
<div className={`w-6 h-6 rounded flex items-center justify-center ${COVERAGE_COLORS.partial.bg} ${COVERAGE_COLORS.partial.text} border ${COVERAGE_COLORS.partial.border}`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</div>
<span className="text-sm text-slate-600">
{lang === 'de' ? 'Teilweise abgedeckt' : 'Partially covered'}
</span>
</div>
<div className="flex items-center gap-2">
<div className={`w-6 h-6 rounded flex items-center justify-center ${COVERAGE_COLORS.planned.bg} ${COVERAGE_COLORS.planned.text} border ${COVERAGE_COLORS.planned.border}`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span className="text-sm text-slate-600">
{lang === 'de' ? 'Geplant' : 'Planned'}
</span>
</div>
</div>
</div>
</div>
)
}

View 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>
)
}

View File

@@ -0,0 +1,24 @@
/**
* Compliance Charts Module
*
* Re-exports all chart components for easy importing:
*
* import {
* ComplianceTrendChart,
* TrafficLightIndicator,
* MiniSparkline,
* DependencyMap,
* RiskHeatmap,
* MiniRiskMatrix,
* RiskDistribution,
* } from '@/components/compliance/charts'
*/
export { default as ComplianceTrendChart } from './ComplianceTrendChart'
export { TrafficLightIndicator, MiniSparkline } from './ComplianceTrendChart'
export { default as DependencyMap } from './DependencyMap'
export { default as RiskHeatmap } from './RiskHeatmap'
export { MiniRiskMatrix, RiskDistribution, calculateRiskLevel } from './RiskHeatmap'
export type { Risk, Control } from './RiskHeatmap'