klausur-service (11 files): - cv_gutter_repair, ocr_pipeline_regression, upload_api - ocr_pipeline_sessions, smart_spell, nru_worksheet_generator - ocr_pipeline_overlays, mail/aggregator, zeugnis_api - cv_syllable_detect, self_rag backend-lehrer (17 files): - classroom_engine/suggestions, generators/quiz_generator - worksheets_api, llm_gateway/comparison, state_engine_api - classroom/models (→ 4 submodules), services/file_processor - alerts_agent/api/wizard+digests+routes, content_generators/pdf - classroom/routes/sessions, llm_gateway/inference - classroom_engine/analytics, auth/keycloak_auth - alerts_agent/processing/rule_engine, ai_processor/print_versions agent-core (5 files): - brain/memory_store, brain/knowledge_graph, brain/context_manager - orchestrator/supervisor, sessions/session_manager admin-lehrer (5 components): - GridOverlay, StepGridReview, DevOpsPipelineSidebar - DataFlowDiagram, sbom/wizard/page website (2 files): - DependencyMap, lehrer/abitur-archiv Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
263 lines
13 KiB
TypeScript
263 lines
13 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 type { Control, Requirement, DependencyMapProps } from './DependencyMapTypes'
|
|
import { DOMAIN_COLORS, COVERAGE_COLORS } from './DependencyMapTypes'
|
|
import { DependencyMapSankey } from './DependencyMapSankey'
|
|
|
|
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, typeof mappings[0]>> = {}
|
|
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 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">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">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">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}>{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]">
|
|
<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>
|
|
{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}>
|
|
{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>
|
|
) : (
|
|
<DependencyMapSankey
|
|
filteredControls={filteredControls}
|
|
filteredRequirements={filteredRequirements}
|
|
requirements={requirements}
|
|
controls={controls}
|
|
mappingLookup={mappingLookup}
|
|
selectedControl={selectedControl}
|
|
selectedRequirement={selectedRequirement}
|
|
onControlClick={handleControlClick}
|
|
onRequirementClick={handleRequirementClick}
|
|
lang={lang}
|
|
/>
|
|
)}
|
|
|
|
{/* Legend */}
|
|
<div className="bg-white rounded-xl shadow-sm border p-4">
|
|
<div className="flex flex-wrap gap-6 justify-center">
|
|
{(['full', 'partial', 'planned'] as const).map((level) => (
|
|
<div key={level} className="flex items-center gap-2">
|
|
<div className={`w-6 h-6 rounded flex items-center justify-center ${COVERAGE_COLORS[level].bg} ${COVERAGE_COLORS[level].text} border ${COVERAGE_COLORS[level].border}`}>
|
|
{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>)}
|
|
{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>)}
|
|
{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>
|
|
<span className="text-sm text-slate-600">
|
|
{lang === 'de' ? (level === 'full' ? 'Vollstaendig abgedeckt' : level === 'partial' ? 'Teilweise abgedeckt' : 'Geplant') : (level === 'full' ? 'Fully covered' : level === 'partial' ? 'Partially covered' : 'Planned')}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|