fix(admin-v2): Restore complete admin-v2 application

The admin-v2 application was incomplete in the repository. This commit
restores all missing components:

- Admin pages (76 pages): dashboard, ai, compliance, dsgvo, education,
  infrastructure, communication, development, onboarding, rbac
- SDK pages (45 pages): tom, dsfa, vvt, loeschfristen, einwilligungen,
  vendor-compliance, tom-generator, dsr, and more
- Developer portal (25 pages): API docs, SDK guides, frameworks
- All components, lib files, hooks, and types
- Updated package.json with all dependencies

The issue was caused by incomplete initial repository state - the full
admin-v2 codebase existed in backend/admin-v2 and docs-src/admin-v2
but was never fully synced to the main admin-v2 directory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
BreakPilot Dev
2026-02-08 23:40:15 -08:00
parent f28244753f
commit 660295e218
385 changed files with 138126 additions and 3079 deletions

View File

@@ -0,0 +1,339 @@
'use client'
/**
* ArchitectureView - Shows backend modules and their connection status
*
* This component helps track which backend modules are connected to the frontend
* during migration and ensures no modules get lost.
*/
import { useState } from 'react'
import Link from 'next/link'
import {
MODULE_REGISTRY,
getModulesByCategory,
getModuleStats,
getCategoryStats,
type BackendModule
} from '@/lib/module-registry'
interface ArchitectureViewProps {
category?: BackendModule['category']
showAllCategories?: boolean
}
const STATUS_CONFIG = {
connected: {
label: 'Verbunden',
color: 'bg-green-100 text-green-700 border-green-200',
dot: 'bg-green-500'
},
partial: {
label: 'Teilweise',
color: 'bg-yellow-100 text-yellow-700 border-yellow-200',
dot: 'bg-yellow-500'
},
'not-connected': {
label: 'Nicht verbunden',
color: 'bg-red-100 text-red-700 border-red-200',
dot: 'bg-red-500'
},
deprecated: {
label: 'Veraltet',
color: 'bg-slate-100 text-slate-700 border-slate-200',
dot: 'bg-slate-500'
}
}
const PRIORITY_CONFIG = {
critical: { label: 'Kritisch', color: 'text-red-600' },
high: { label: 'Hoch', color: 'text-orange-600' },
medium: { label: 'Mittel', color: 'text-yellow-600' },
low: { label: 'Niedrig', color: 'text-slate-600' }
}
const CATEGORY_CONFIG: Record<BackendModule['category'], { name: string; icon: string; color: string }> = {
compliance: { name: 'DSGVO & Compliance', icon: 'shield', color: 'purple' },
ai: { name: 'KI & Automatisierung', icon: 'brain', color: 'teal' },
infrastructure: { name: 'Infrastruktur & DevOps', icon: 'server', color: 'orange' },
education: { name: 'Bildung & Schule', icon: 'graduation', color: 'blue' },
communication: { name: 'Kommunikation & Alerts', icon: 'mail', color: 'green' },
development: { name: 'Entwicklung & Produkte', icon: 'code', color: 'slate' }
}
export function ArchitectureView({ category, showAllCategories = false }: ArchitectureViewProps) {
const [expandedModule, setExpandedModule] = useState<string | null>(null)
const [filterStatus, setFilterStatus] = useState<string>('all')
const modules = category && !showAllCategories
? getModulesByCategory(category)
: MODULE_REGISTRY
const filteredModules = filterStatus === 'all'
? modules
: modules.filter(m => m.frontend.status === filterStatus)
const stats = category && !showAllCategories
? getCategoryStats(category)
: getModuleStats()
return (
<div className="space-y-6">
{/* Stats Overview */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900">
Migrations-Fortschritt
{category && !showAllCategories && (
<span className="ml-2 text-slate-500 font-normal">
- {CATEGORY_CONFIG[category].name}
</span>
)}
</h2>
<span className="text-2xl font-bold text-purple-600">
{stats.percentComplete}%
</span>
</div>
{/* Progress Bar */}
<div className="h-4 bg-slate-200 rounded-full overflow-hidden mb-4">
<div
className="h-full bg-gradient-to-r from-green-500 to-emerald-500 transition-all duration-500"
style={{ width: `${stats.percentComplete}%` }}
/>
</div>
{/* Status Counts */}
<div className="grid grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{stats.connected}</div>
<div className="text-sm text-slate-500">Verbunden</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-600">{stats.partial}</div>
<div className="text-sm text-slate-500">Teilweise</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-600">{stats.notConnected}</div>
<div className="text-sm text-slate-500">Nicht verbunden</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-slate-600">{stats.total}</div>
<div className="text-sm text-slate-500">Gesamt</div>
</div>
</div>
</div>
{/* Filter */}
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">Filter:</span>
<button
onClick={() => setFilterStatus('all')}
className={`px-3 py-1 rounded-full text-sm ${
filterStatus === 'all' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Alle ({modules.length})
</button>
<button
onClick={() => setFilterStatus('connected')}
className={`px-3 py-1 rounded-full text-sm ${
filterStatus === 'connected' ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Verbunden
</button>
<button
onClick={() => setFilterStatus('partial')}
className={`px-3 py-1 rounded-full text-sm ${
filterStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Teilweise
</button>
<button
onClick={() => setFilterStatus('not-connected')}
className={`px-3 py-1 rounded-full text-sm ${
filterStatus === 'not-connected' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Nicht verbunden
</button>
</div>
{/* Module List */}
<div className="space-y-3">
{filteredModules.map((module) => (
<div
key={module.id}
className="bg-white rounded-xl shadow-sm border overflow-hidden"
>
{/* Module Header */}
<button
onClick={() => setExpandedModule(expandedModule === module.id ? null : module.id)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${STATUS_CONFIG[module.frontend.status].dot}`} />
<div className="text-left">
<div className="font-medium text-slate-900">{module.name}</div>
<div className="text-sm text-slate-500">{module.description}</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded text-xs border ${STATUS_CONFIG[module.frontend.status].color}`}>
{STATUS_CONFIG[module.frontend.status].label}
</span>
<span className={`text-xs ${PRIORITY_CONFIG[module.priority].color}`}>
{PRIORITY_CONFIG[module.priority].label}
</span>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedModule === module.id ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{/* Module Details */}
{expandedModule === module.id && (
<div className="px-4 py-4 border-t border-slate-200 bg-slate-50">
<div className="grid grid-cols-2 gap-6">
{/* Backend Info */}
<div>
<h4 className="font-medium text-slate-900 mb-2">Backend</h4>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-slate-500">Service:</span>
<code className="px-2 py-0.5 bg-slate-200 rounded text-slate-700">
{module.backend.service}
</code>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500">Port:</span>
<code className="px-2 py-0.5 bg-slate-200 rounded text-slate-700">
{module.backend.port}
</code>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500">Base Path:</span>
<code className="px-2 py-0.5 bg-slate-200 rounded text-slate-700">
{module.backend.basePath}
</code>
</div>
</div>
<h5 className="font-medium text-slate-700 mt-4 mb-2">Endpoints</h5>
<div className="space-y-1 max-h-48 overflow-y-auto">
{module.backend.endpoints.map((ep, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm">
<span className={`px-1.5 py-0.5 rounded text-xs font-mono ${
ep.method === 'GET' ? 'bg-blue-100 text-blue-700' :
ep.method === 'POST' ? 'bg-green-100 text-green-700' :
ep.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{ep.method}
</span>
<code className="text-slate-600 text-xs">{ep.path}</code>
<span className="text-slate-400 text-xs">- {ep.description}</span>
</div>
))}
</div>
</div>
{/* Frontend Info */}
<div>
<h4 className="font-medium text-slate-900 mb-2">Frontend</h4>
<div className="space-y-3 text-sm">
<div>
<span className="text-slate-500 block mb-1">Admin v2 Seite:</span>
{module.frontend.adminV2Page ? (
<Link
href={module.frontend.adminV2Page}
className="text-purple-600 hover:text-purple-800 hover:underline"
>
{module.frontend.adminV2Page}
</Link>
) : (
<span className="text-red-500 italic">Noch nicht angelegt</span>
)}
</div>
<div>
<span className="text-slate-500 block mb-1">Altes Admin (Referenz):</span>
{module.frontend.oldAdminPage ? (
<code className="px-2 py-0.5 bg-slate-200 rounded text-slate-700">
{module.frontend.oldAdminPage}
</code>
) : (
<span className="text-slate-400 italic">-</span>
)}
</div>
{module.notes && (
<div className="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<span className="text-yellow-700 text-sm">{module.notes}</span>
</div>
)}
{module.dependencies && module.dependencies.length > 0 && (
<div>
<span className="text-slate-500 block mb-1">Abhaengigkeiten:</span>
<div className="flex flex-wrap gap-1">
{module.dependencies.map((dep) => (
<span key={dep} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
{dep}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
))}
</div>
{/* Category Summary (if showing all) */}
{showAllCategories && (
<div className="bg-white rounded-xl shadow-sm border p-6 mt-8">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Kategorie-Uebersicht</h3>
<div className="grid grid-cols-2 gap-4">
{(Object.keys(CATEGORY_CONFIG) as BackendModule['category'][]).map((cat) => {
const catStats = getCategoryStats(cat)
return (
<div key={cat} className="p-4 border border-slate-200 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-slate-900">{CATEGORY_CONFIG[cat].name}</span>
<span className={`text-sm ${catStats.percentComplete === 100 ? 'text-green-600' : 'text-slate-500'}`}>
{catStats.percentComplete}%
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${
catStats.percentComplete === 100 ? 'bg-green-500' :
catStats.percentComplete > 50 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${catStats.percentComplete}%` }}
/>
</div>
<div className="flex justify-between mt-2 text-xs text-slate-500">
<span>{catStats.connected}/{catStats.total} verbunden</span>
{catStats.notConnected > 0 && (
<span className="text-red-500">{catStats.notConnected} offen</span>
)}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { navigation, metaModules, getModuleByHref, getCategoryById, CategoryId } from '@/lib/navigation'
export function Breadcrumbs() {
const pathname = usePathname()
// Build breadcrumb items from path
const items: Array<{ label: string; href: string }> = []
// Always start with Dashboard (Home)
items.push({ label: 'Dashboard', href: '/dashboard' })
// Parse the path
const pathParts = pathname.split('/').filter(Boolean)
if (pathParts.length > 0) {
// Check if it's a category
const categoryId = pathParts[0] as CategoryId
const category = getCategoryById(categoryId)
if (category) {
// Add category
items.push({ label: category.name, href: `/${category.id}` })
// Check if there's a module
if (pathParts.length > 1) {
const moduleHref = `/${pathParts[0]}/${pathParts[1]}`
const result = getModuleByHref(moduleHref)
if (result) {
items.push({ label: result.module.name, href: moduleHref })
}
}
} else {
// Check meta modules
const metaModule = metaModules.find(m => m.href === `/${pathParts[0]}`)
if (metaModule) {
items.push({ label: metaModule.name, href: metaModule.href })
}
}
}
// Don't show breadcrumbs for just dashboard
if (items.length <= 1) {
return null
}
return (
<nav className="flex items-center gap-2 text-sm text-slate-500 mb-4">
{items.map((item, index) => (
<span key={item.href} className="flex items-center gap-2">
{index > 0 && (
<svg className="w-4 h-4 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
{index === items.length - 1 ? (
<span className="text-slate-900 font-medium">{item.label}</span>
) : (
<Link
href={item.href}
className="hover:text-primary-600 transition-colors"
>
{item.label}
</Link>
)}
</span>
))}
</nav>
)
}

View File

@@ -0,0 +1,510 @@
'use client'
/**
* DataFlowDiagram - Visual representation of module dependencies
*
* Shows how backend services, modules, and frontend pages are connected.
* Uses SVG for rendering connections.
*/
import { useState, useRef, useEffect } from 'react'
import {
MODULE_REGISTRY,
type BackendModule
} from '@/lib/module-registry'
interface NodePosition {
x: number
y: number
width: number
height: number
}
interface ServiceGroup {
name: string
port: number
modules: BackendModule[]
}
const SERVICE_COLORS: Record<string, string> = {
'consent-service': '#8b5cf6', // purple
'python-backend': '#f59e0b', // amber
'klausur-service': '#10b981', // emerald
'voice-service': '#3b82f6', // blue
}
const STATUS_COLORS = {
connected: '#22c55e',
partial: '#eab308',
'not-connected': '#ef4444',
deprecated: '#6b7280'
}
export function DataFlowDiagram() {
const [selectedModule, setSelectedModule] = useState<string | null>(null)
const [hoveredModule, setHoveredModule] = useState<string | null>(null)
const [showLegend, setShowLegend] = useState(true)
const containerRef = useRef<HTMLDivElement>(null)
// Group modules by backend service
const serviceGroups: ServiceGroup[] = []
const seenServices = new Set<string>()
MODULE_REGISTRY.forEach(module => {
if (!seenServices.has(module.backend.service)) {
seenServices.add(module.backend.service)
serviceGroups.push({
name: module.backend.service,
port: module.backend.port,
modules: MODULE_REGISTRY.filter(m => m.backend.service === module.backend.service)
})
}
})
// Calculate positions
const serviceWidth = 280
const serviceSpacing = 40
const moduleHeight = 60
const moduleSpacing = 10
const headerHeight = 50
const padding = 20
const totalWidth = serviceGroups.length * serviceWidth + (serviceGroups.length - 1) * serviceSpacing + padding * 2
const maxModulesInService = Math.max(...serviceGroups.map(s => s.modules.length))
const totalHeight = headerHeight + maxModulesInService * (moduleHeight + moduleSpacing) + padding * 2 + 100
// Get connections between modules (dependencies)
const connections: { from: string; to: string }[] = []
MODULE_REGISTRY.forEach(module => {
if (module.dependencies) {
module.dependencies.forEach(dep => {
connections.push({ from: module.id, to: dep })
})
}
})
// Get module position
const getModulePosition = (moduleId: string): NodePosition | null => {
let serviceIndex = 0
for (const service of serviceGroups) {
const moduleIndex = service.modules.findIndex(m => m.id === moduleId)
if (moduleIndex !== -1) {
return {
x: padding + serviceIndex * (serviceWidth + serviceSpacing) + serviceWidth / 2,
y: headerHeight + moduleIndex * (moduleHeight + moduleSpacing) + moduleHeight / 2 + 40,
width: serviceWidth - 40,
height: moduleHeight
}
}
serviceIndex++
}
return null
}
// Check if module is related to selected/hovered module
const isRelated = (moduleId: string): boolean => {
const target = selectedModule || hoveredModule
if (!target) return false
// Direct match
if (moduleId === target) return true
// Check dependencies
const targetModule = MODULE_REGISTRY.find(m => m.id === target)
if (targetModule?.dependencies?.includes(moduleId)) return true
// Check reverse dependencies
const module = MODULE_REGISTRY.find(m => m.id === moduleId)
if (module?.dependencies?.includes(target)) return true
return false
}
return (
<div className="space-y-4">
{/* Controls */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Datenfluss-Diagramm</h3>
<div className="flex items-center gap-4">
<button
onClick={() => setShowLegend(!showLegend)}
className="text-sm text-slate-600 hover:text-slate-900"
>
{showLegend ? 'Legende ausblenden' : 'Legende anzeigen'}
</button>
{selectedModule && (
<button
onClick={() => setSelectedModule(null)}
className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm"
>
Auswahl aufheben
</button>
)}
</div>
</div>
{/* Legend */}
{showLegend && (
<div className="bg-white rounded-xl shadow-sm border p-4 flex flex-wrap items-center gap-6">
<span className="text-sm font-medium text-slate-700">Services:</span>
{Object.entries(SERVICE_COLORS).map(([service, color]) => (
<div key={service} className="flex items-center gap-2">
<div className="w-3 h-3 rounded" style={{ backgroundColor: color }} />
<span className="text-sm text-slate-600">{service}</span>
</div>
))}
<span className="text-sm font-medium text-slate-700 ml-4">Status:</span>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-sm text-slate-600">Verbunden</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<span className="text-sm text-slate-600">Teilweise</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-sm text-slate-600">Nicht verbunden</span>
</div>
</div>
)}
{/* Diagram */}
<div
ref={containerRef}
className="bg-white rounded-xl shadow-sm border overflow-x-auto"
>
<svg
width={totalWidth}
height={totalHeight}
className="min-w-full"
>
<defs>
{/* Arrow marker */}
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="#a78bfa" />
</marker>
<marker
id="arrowhead-highlight"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="#8b5cf6" />
</marker>
</defs>
{/* Background Grid */}
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* Service Groups */}
{serviceGroups.map((service, serviceIdx) => {
const x = padding + serviceIdx * (serviceWidth + serviceSpacing)
const serviceColor = SERVICE_COLORS[service.name] || '#6b7280'
return (
<g key={service.name}>
{/* Service Container */}
<rect
x={x}
y={padding}
width={serviceWidth}
height={totalHeight - padding * 2}
fill={`${serviceColor}10`}
stroke={serviceColor}
strokeWidth="2"
rx="12"
/>
{/* Service Header */}
<rect
x={x}
y={padding}
width={serviceWidth}
height={headerHeight}
fill={serviceColor}
rx="12"
/>
<rect
x={x}
y={padding + headerHeight - 12}
width={serviceWidth}
height={12}
fill={serviceColor}
/>
<text
x={x + serviceWidth / 2}
y={padding + headerHeight / 2 + 5}
textAnchor="middle"
fill="white"
fontSize="14"
fontWeight="600"
>
{service.name}
</text>
<text
x={x + serviceWidth / 2}
y={padding + headerHeight - 8}
textAnchor="middle"
fill="rgba(255,255,255,0.7)"
fontSize="11"
>
Port {service.port}
</text>
{/* Modules */}
{service.modules.map((module, moduleIdx) => {
const moduleX = x + 20
const moduleY = padding + headerHeight + 20 + moduleIdx * (moduleHeight + moduleSpacing)
const isSelected = selectedModule === module.id
const isHovered = hoveredModule === module.id
const related = isRelated(module.id)
const statusColor = STATUS_COLORS[module.frontend.status]
const opacity = (selectedModule || hoveredModule)
? (related ? 1 : 0.3)
: 1
return (
<g
key={module.id}
onClick={() => setSelectedModule(isSelected ? null : module.id)}
onMouseEnter={() => setHoveredModule(module.id)}
onMouseLeave={() => setHoveredModule(null)}
style={{ cursor: 'pointer', opacity }}
className="transition-opacity duration-200"
>
{/* Module Box */}
<rect
x={moduleX}
y={moduleY}
width={serviceWidth - 40}
height={moduleHeight}
fill="white"
stroke={isSelected || isHovered ? serviceColor : '#e2e8f0'}
strokeWidth={isSelected || isHovered ? 2 : 1}
rx="8"
filter={isSelected ? 'drop-shadow(0 4px 6px rgba(0,0,0,0.1))' : undefined}
/>
{/* Status Indicator */}
<circle
cx={moduleX + 16}
cy={moduleY + moduleHeight / 2}
r="5"
fill={statusColor}
/>
{/* Module Name */}
<text
x={moduleX + 30}
y={moduleY + 24}
fill="#1e293b"
fontSize="12"
fontWeight="500"
>
{module.name.length > 25 ? module.name.slice(0, 25) + '...' : module.name}
</text>
{/* Module ID */}
<text
x={moduleX + 30}
y={moduleY + 42}
fill="#94a3b8"
fontSize="10"
>
{module.id}
</text>
{/* Priority Badge */}
<rect
x={moduleX + serviceWidth - 90}
y={moduleY + 20}
width={40}
height={18}
fill={
module.priority === 'critical' ? '#fef2f2' :
module.priority === 'high' ? '#fff7ed' :
module.priority === 'medium' ? '#fefce8' :
'#f1f5f9'
}
rx="4"
/>
<text
x={moduleX + serviceWidth - 70}
y={moduleY + 33}
textAnchor="middle"
fill={
module.priority === 'critical' ? '#dc2626' :
module.priority === 'high' ? '#ea580c' :
module.priority === 'medium' ? '#ca8a04' :
'#64748b'
}
fontSize="9"
fontWeight="500"
>
{module.priority.toUpperCase()}
</text>
{/* Dependency indicator */}
{module.dependencies && module.dependencies.length > 0 && (
<g>
<circle
cx={moduleX + serviceWidth - 55}
cy={moduleY + moduleHeight - 12}
r="8"
fill="#f3e8ff"
/>
<text
x={moduleX + serviceWidth - 55}
y={moduleY + moduleHeight - 8}
textAnchor="middle"
fill="#8b5cf6"
fontSize="9"
fontWeight="600"
>
{module.dependencies.length}
</text>
</g>
)}
</g>
)
})}
</g>
)
})}
{/* Connections (Dependencies) */}
{connections.map((conn, idx) => {
const fromPos = getModulePosition(conn.from)
const toPos = getModulePosition(conn.to)
if (!fromPos || !toPos) return null
const isHighlighted = (selectedModule || hoveredModule) &&
(conn.from === (selectedModule || hoveredModule) || conn.to === (selectedModule || hoveredModule))
const opacity = (selectedModule || hoveredModule)
? (isHighlighted ? 1 : 0.1)
: 0.4
// Calculate curved path
const startX = fromPos.x
const startY = fromPos.y
const endX = toPos.x
const endY = toPos.y
const midX = (startX + endX) / 2
const controlOffset = Math.abs(startX - endX) * 0.3
const path = startX < endX
? `M ${startX + fromPos.width / 2} ${startY}
C ${startX + fromPos.width / 2 + controlOffset} ${startY},
${endX - toPos.width / 2 - controlOffset} ${endY},
${endX - toPos.width / 2} ${endY}`
: `M ${startX - fromPos.width / 2} ${startY}
C ${startX - fromPos.width / 2 - controlOffset} ${startY},
${endX + toPos.width / 2 + controlOffset} ${endY},
${endX + toPos.width / 2} ${endY}`
return (
<path
key={idx}
d={path}
fill="none"
stroke={isHighlighted ? '#8b5cf6' : '#a78bfa'}
strokeWidth={isHighlighted ? 2 : 1.5}
strokeDasharray={isHighlighted ? undefined : '4 2'}
markerEnd={isHighlighted ? 'url(#arrowhead-highlight)' : 'url(#arrowhead)'}
opacity={opacity}
className="transition-opacity duration-200"
/>
)
})}
{/* Frontend Layer (Bottom) */}
<g>
<rect
x={padding}
y={totalHeight - 80}
width={totalWidth - padding * 2}
height={60}
fill="#f8fafc"
stroke="#e2e8f0"
strokeWidth="1"
rx="8"
/>
<text
x={totalWidth / 2}
y={totalHeight - 55}
textAnchor="middle"
fill="#64748b"
fontSize="12"
fontWeight="500"
>
Admin v2 Frontend (Next.js - Port 3002)
</text>
<text
x={totalWidth / 2}
y={totalHeight - 35}
textAnchor="middle"
fill="#94a3b8"
fontSize="11"
>
/compliance | /ai | /infrastructure | /education | /communication | /development
</text>
</g>
</svg>
</div>
{/* Selected Module Details */}
{selectedModule && (
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
<h4 className="font-medium text-purple-900 mb-2">
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.name}
</h4>
<div className="text-sm text-purple-700 space-y-1">
<p>ID: <code className="bg-purple-100 px-1 rounded">{selectedModule}</code></p>
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.dependencies && (
<p>
Abhaengigkeiten:
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.dependencies?.map(dep => (
<button
key={dep}
onClick={() => setSelectedModule(dep)}
className="ml-2 px-2 py-0.5 bg-purple-200 text-purple-800 rounded hover:bg-purple-300"
>
{dep}
</button>
))}
</p>
)}
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.frontend.adminV2Page && (
<p>
Frontend:
<a
href={MODULE_REGISTRY.find(m => m.id === selectedModule)?.frontend.adminV2Page}
className="ml-2 text-purple-600 hover:underline"
>
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.frontend.adminV2Page}
</a>
</p>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,72 @@
interface InfoBoxProps {
variant: 'info' | 'tip' | 'warning' | 'error'
title?: string
children: React.ReactNode
className?: string
}
const variants = {
info: {
bg: 'bg-blue-50',
border: 'border-blue-200',
icon: '💡',
titleColor: 'text-blue-800',
textColor: 'text-blue-700',
},
tip: {
bg: 'bg-green-50',
border: 'border-green-200',
icon: '✨',
titleColor: 'text-green-800',
textColor: 'text-green-700',
},
warning: {
bg: 'bg-amber-50',
border: 'border-amber-200',
icon: '⚠️',
titleColor: 'text-amber-800',
textColor: 'text-amber-700',
},
error: {
bg: 'bg-red-50',
border: 'border-red-200',
icon: '❌',
titleColor: 'text-red-800',
textColor: 'text-red-700',
},
}
export function InfoBox({ variant, title, children, className = '' }: InfoBoxProps) {
const style = variants[variant]
return (
<div className={`${style.bg} ${style.border} border rounded-xl p-4 ${className}`}>
<div className="flex gap-3">
<span className="text-xl flex-shrink-0">{style.icon}</span>
<div>
{title && (
<h4 className={`font-semibold ${style.titleColor} mb-1`}>{title}</h4>
)}
<div className={`text-sm ${style.textColor}`}>{children}</div>
</div>
</div>
</div>
)
}
// Convenience components
export function InfoTip({ title, children, className }: Omit<InfoBoxProps, 'variant'>) {
return <InfoBox variant="tip" title={title} className={className}>{children}</InfoBox>
}
export function InfoWarning({ title, children, className }: Omit<InfoBoxProps, 'variant'>) {
return <InfoBox variant="warning" title={title} className={className}>{children}</InfoBox>
}
export function InfoNote({ title, children, className }: Omit<InfoBoxProps, 'variant'>) {
return <InfoBox variant="info" title={title} className={className}>{children}</InfoBox>
}
export function InfoError({ title, children, className }: Omit<InfoBoxProps, 'variant'>) {
return <InfoBox variant="error" title={title} className={className}>{children}</InfoBox>
}

View File

@@ -0,0 +1,113 @@
import Link from 'next/link'
import { NavModule, NavCategory } from '@/lib/navigation'
interface ModuleCardProps {
module: NavModule
category: NavCategory
showDescription?: boolean
}
export function ModuleCard({ module, category, showDescription = true }: ModuleCardProps) {
return (
<Link
href={module.href}
className={`block p-4 rounded-xl border-2 transition-all hover:shadow-md bg-${category.colorClass}-50 border-${category.colorClass}-200 hover:border-${category.colorClass}-400`}
style={{
backgroundColor: `${category.color}10`,
borderColor: `${category.color}40`,
}}
>
<div className="flex items-start gap-3">
{/* Color indicator */}
<div
className="w-1.5 h-12 rounded-full flex-shrink-0"
style={{ backgroundColor: category.color }}
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-slate-900 truncate">{module.name}</h3>
{showDescription && (
<p className="text-sm text-slate-500 mt-1 line-clamp-2">{module.description}</p>
)}
{/* Audience tags */}
<div className="flex flex-wrap gap-1 mt-2">
{module.audience.slice(0, 2).map((a) => (
<span
key={a}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-slate-600 bg-slate-100"
>
{a}
</span>
))}
</div>
</div>
{/* Arrow */}
<svg
className="w-5 h-5 text-slate-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
)
}
// Category Card for overview pages
interface CategoryCardProps {
category: NavCategory
showModuleCount?: boolean
}
export function CategoryCard({ category, showModuleCount = true }: CategoryCardProps) {
return (
<Link
href={`/${category.id}`}
className="block p-6 rounded-xl border-2 transition-all hover:shadow-lg bg-white"
style={{
borderColor: `${category.color}40`,
}}
>
<div className="flex items-center gap-4">
{/* Icon */}
<div
className="w-12 h-12 rounded-xl flex items-center justify-center"
style={{ backgroundColor: `${category.color}20` }}
>
<span style={{ color: category.color }} className="text-2xl">
{category.icon === 'shield' && '🛡️'}
{category.icon === 'brain' && '🧠'}
{category.icon === 'server' && '🖥️'}
{category.icon === 'graduation' && '🎓'}
{category.icon === 'mail' && '📬'}
{category.icon === 'code' && '💻'}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-lg text-slate-900">{category.name}</h3>
<p className="text-sm text-slate-500 line-clamp-1">{category.description}</p>
{showModuleCount && (
<span className="text-xs text-slate-400 mt-1">
{category.modules.length} Module
</span>
)}
</div>
{/* Arrow */}
<svg
className="w-6 h-6 text-slate-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
)
}

View File

@@ -0,0 +1,169 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
export interface PagePurposeProps {
title: string
purpose: string
audience: string[]
gdprArticles?: string[]
architecture?: {
services: string[]
databases: string[]
diagram?: string
}
relatedPages?: Array<{
name: string
href: string
description: string
}>
collapsible?: boolean
defaultCollapsed?: boolean
}
export function PagePurpose({
title,
purpose,
audience,
gdprArticles,
architecture,
relatedPages,
collapsible = true,
defaultCollapsed = false,
}: PagePurposeProps) {
const [collapsed, setCollapsed] = useState(defaultCollapsed)
const [showArchitecture, setShowArchitecture] = useState(false)
return (
<div className="bg-gradient-to-r from-slate-50 to-slate-100 rounded-xl border border-slate-200 mb-6 overflow-hidden">
{/* Header */}
<div
className={`flex items-center justify-between px-4 py-3 ${
collapsible ? 'cursor-pointer hover:bg-slate-100' : ''
}`}
onClick={collapsible ? () => setCollapsed(!collapsed) : undefined}
>
<div className="flex items-center gap-2">
<span className="text-lg">🎯</span>
<span className="font-semibold text-slate-700">Warum gibt es diese Seite?</span>
</div>
{collapsible && (
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${collapsed ? '' : 'rotate-180'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</div>
{/* Content */}
{!collapsed && (
<div className="px-4 pb-4 space-y-4">
{/* Purpose */}
<p className="text-slate-600">{purpose}</p>
{/* Metadata */}
<div className="flex flex-wrap gap-4 text-sm">
{/* Audience */}
<div className="flex items-center gap-2">
<span className="text-slate-400">👥</span>
<span className="text-slate-500">Zielgruppe:</span>
<span className="text-slate-700">{audience.join(', ')}</span>
</div>
{/* GDPR Articles */}
{gdprArticles && gdprArticles.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-slate-400">📋</span>
<span className="text-slate-500">DSGVO-Bezug:</span>
<span className="text-slate-700">{gdprArticles.join(', ')}</span>
</div>
)}
</div>
{/* Architecture (expandable) */}
{architecture && (
<div className="border-t border-slate-200 pt-3">
<button
onClick={(e) => {
e.stopPropagation()
setShowArchitecture(!showArchitecture)
}}
className="flex items-center gap-2 text-sm text-slate-500 hover:text-slate-700"
>
<span>🏗</span>
<span>Architektur</span>
<svg
className={`w-4 h-4 transition-transform ${showArchitecture ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showArchitecture && (
<div className="mt-2 p-3 bg-white rounded-lg border border-slate-200 text-sm">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="font-medium text-slate-600">Services:</span>
<ul className="mt-1 space-y-1 text-slate-500">
{architecture.services.map((service) => (
<li key={service} className="flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-primary-400 rounded-full" />
{service}
</li>
))}
</ul>
</div>
<div>
<span className="font-medium text-slate-600">Datenbanken:</span>
<ul className="mt-1 space-y-1 text-slate-500">
{architecture.databases.map((db) => (
<li key={db} className="flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-green-400 rounded-full" />
{db}
</li>
))}
</ul>
</div>
</div>
</div>
)}
</div>
)}
{/* Related Pages */}
{relatedPages && relatedPages.length > 0 && (
<div className="border-t border-slate-200 pt-3">
<span className="text-sm text-slate-500 flex items-center gap-2 mb-2">
<span>🔗</span>
<span>Verwandte Seiten</span>
</span>
<div className="flex flex-wrap gap-2">
{relatedPages.map((page) => (
<Link
key={page.href}
href={page.href}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-sm text-slate-600 hover:border-primary-300 hover:text-primary-600 transition-colors"
title={page.description}
>
<span>{page.name}</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,203 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
interface ServiceHealth {
name: string
port: number
status: 'online' | 'offline' | 'checking' | 'degraded'
responseTime?: number
details?: string
category: 'core' | 'ai' | 'database' | 'storage'
}
// Initial services list for loading state
const INITIAL_SERVICES: Omit<ServiceHealth, 'status' | 'responseTime' | 'details'>[] = [
{ name: 'Backend API', port: 8000, category: 'core' },
{ name: 'Consent Service', port: 8081, category: 'core' },
{ name: 'Voice Service', port: 8091, category: 'core' },
{ name: 'Klausur Service', port: 8086, category: 'core' },
{ name: 'Mail Service (Mailpit)', port: 8025, category: 'core' },
{ name: 'Edu Search', port: 8088, category: 'core' },
{ name: 'H5P Service', port: 8092, category: 'core' },
{ name: 'Ollama/LLM', port: 11434, category: 'ai' },
{ name: 'Embedding Service', port: 8087, category: 'ai' },
{ name: 'PostgreSQL', port: 5432, category: 'database' },
{ name: 'Qdrant (Vector DB)', port: 6333, category: 'database' },
{ name: 'Valkey (Cache)', port: 6379, category: 'database' },
{ name: 'MinIO (S3)', port: 9000, category: 'storage' },
]
export function ServiceStatus() {
const [services, setServices] = useState<ServiceHealth[]>(
INITIAL_SERVICES.map(s => ({ ...s, status: 'checking' as const }))
)
const [lastChecked, setLastChecked] = useState<Date | null>(null)
const [isRefreshing, setIsRefreshing] = useState(false)
const checkServices = useCallback(async () => {
setIsRefreshing(true)
try {
// Use server-side API route to avoid mixed-content issues
const response = await fetch('/api/admin/health', {
method: 'GET',
signal: AbortSignal.timeout(15000),
})
if (response.ok) {
const data = await response.json()
setServices(data.services.map((s: ServiceHealth) => ({
...s,
status: s.status as 'online' | 'offline' | 'degraded'
})))
} else {
// If API fails, mark all as offline
setServices(prev => prev.map(s => ({
...s,
status: 'offline' as const,
details: 'Health-Check API nicht erreichbar'
})))
}
} catch (error) {
// Network error - mark all as offline
setServices(prev => prev.map(s => ({
...s,
status: 'offline' as const,
details: error instanceof Error ? error.message : 'Verbindungsfehler'
})))
}
setLastChecked(new Date())
setIsRefreshing(false)
}, [])
useEffect(() => {
checkServices()
// Auto-refresh every 30 seconds
const interval = setInterval(checkServices, 30000)
return () => clearInterval(interval)
}, [checkServices])
const getStatusColor = (status: ServiceHealth['status']) => {
switch (status) {
case 'online': return 'bg-green-500'
case 'offline': return 'bg-red-500'
case 'degraded': return 'bg-yellow-500'
case 'checking': return 'bg-slate-300 animate-pulse'
}
}
const getStatusText = (status: ServiceHealth['status']) => {
switch (status) {
case 'online': return 'Online'
case 'offline': return 'Offline'
case 'degraded': return 'Eingeschränkt'
case 'checking': return 'Prüfe...'
}
}
const getCategoryIcon = (category: ServiceHealth['category']) => {
switch (category) {
case 'core': return '⚙️'
case 'ai': return '🤖'
case 'database': return '🗄️'
case 'storage': return '📦'
}
}
const getCategoryLabel = (category: ServiceHealth['category']) => {
switch (category) {
case 'core': return 'Core Services'
case 'ai': return 'AI / LLM'
case 'database': return 'Datenbanken'
case 'storage': return 'Storage'
}
}
const groupedServices = services.reduce((acc, service) => {
if (!acc[service.category]) {
acc[service.category] = []
}
acc[service.category].push(service)
return acc
}, {} as Record<string, ServiceHealth[]>)
const onlineCount = services.filter(s => s.status === 'online').length
const totalCount = services.length
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-slate-900">System Status</h3>
<span className={`text-xs px-2 py-0.5 rounded-full ${
onlineCount === totalCount
? 'bg-green-100 text-green-700'
: onlineCount > totalCount / 2
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}>
{onlineCount}/{totalCount} online
</span>
</div>
<button
onClick={checkServices}
disabled={isRefreshing}
className="text-sm text-primary-600 hover:text-primary-700 disabled:opacity-50 flex items-center gap-1"
>
<svg className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Aktualisieren
</button>
</div>
<div className="p-4 space-y-4">
{(['ai', 'core', 'database', 'storage'] as const).map(category => (
<div key={category}>
<div className="flex items-center gap-2 mb-2">
<span>{getCategoryIcon(category)}</span>
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">
{getCategoryLabel(category)}
</span>
</div>
<div className="space-y-2">
{groupedServices[category]?.map((service) => (
<div key={service.name} className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${getStatusColor(service.status)}`}></span>
<span className="text-sm text-slate-700">{service.name}</span>
<span className="text-xs text-slate-400">:{service.port}</span>
</div>
<div className="flex items-center gap-2">
{service.details && (
<span className="text-xs text-slate-500">{service.details}</span>
)}
{service.responseTime !== undefined && service.status === 'online' && (
<span className="text-xs text-slate-400">{service.responseTime}ms</span>
)}
<span className={`text-xs ${
service.status === 'online' ? 'text-green-600' :
service.status === 'offline' ? 'text-red-600' :
service.status === 'degraded' ? 'text-yellow-600' :
'text-slate-400'
}`}>
{getStatusText(service.status)}
</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
{lastChecked && (
<div className="px-4 py-2 border-t border-slate-100 text-xs text-slate-400">
Zuletzt geprüft: {lastChecked.toLocaleTimeString('de-DE')}
</div>
)}
</div>
)
}