Files
breakpilot-lehrer/website/components/compliance/charts/DependencyMap.tsx
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

567 lines
23 KiB
TypeScript

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