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

View File

@@ -0,0 +1,313 @@
'use client'
import React from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Book, Code, FileText, HelpCircle, Zap, Terminal, Database, Shield, ChevronRight, Clock } from 'lucide-react'
interface NavItem {
title: string
href: string
icon?: React.ReactNode
items?: NavItem[]
}
const navigation: NavItem[] = [
{
title: 'Getting Started',
href: '/developers/getting-started',
icon: <Zap className="w-4 h-4" />,
items: [
{ title: 'Quick Start', href: '/developers/getting-started' },
],
},
{
title: 'SDK Documentation',
href: '/developers/sdk',
icon: <Code className="w-4 h-4" />,
items: [
{ title: 'Overview', href: '/developers/sdk' },
{ title: 'Installation', href: '/developers/sdk/installation' },
{ title: 'Configuration', href: '/developers/sdk/configuration' },
],
},
{
title: 'Consent SDK',
href: '/developers/sdk/consent',
icon: <Shield className="w-4 h-4" />,
items: [
{ title: 'Uebersicht', href: '/developers/sdk/consent' },
{ title: 'Installation', href: '/developers/sdk/consent/installation' },
{ title: 'API Referenz', href: '/developers/sdk/consent/api-reference' },
{ title: 'Frameworks', href: '/developers/sdk/consent/frameworks' },
{ title: 'Mobile SDKs', href: '/developers/sdk/consent/mobile' },
{ title: 'Sicherheit', href: '/developers/sdk/consent/security' },
],
},
{
title: 'API Reference',
href: '/developers/api',
icon: <Terminal className="w-4 h-4" />,
items: [
{ title: 'Overview', href: '/developers/api' },
{ title: 'State API', href: '/developers/api/state' },
{ title: 'RAG Search API', href: '/developers/api/rag' },
{ title: 'Generation API', href: '/developers/api/generate' },
{ title: 'Export API', href: '/developers/api/export' },
],
},
{
title: 'Guides',
href: '/developers/guides',
icon: <Book className="w-4 h-4" />,
items: [
{ title: 'Overview', href: '/developers/guides' },
{ title: 'Phase 1: Assessment', href: '/developers/guides/phase1' },
{ title: 'Phase 2: Dokumentation', href: '/developers/guides/phase2' },
],
},
{
title: 'Changelog',
href: '/developers/changelog',
icon: <Clock className="w-4 h-4" />,
items: [
{ title: 'Versionshistorie', href: '/developers/changelog' },
],
},
]
interface DevPortalLayoutProps {
children: React.ReactNode
title?: string
description?: string
}
export function DevPortalLayout({ children, title, description }: DevPortalLayoutProps) {
const pathname = usePathname()
return (
<div className="min-h-screen bg-white">
{/* Header */}
<header className="sticky top-0 z-50 border-b border-gray-200 bg-white/95 backdrop-blur">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-4">
<Link href="/developers" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center">
<FileText className="w-5 h-5 text-white" />
</div>
<span className="font-semibold text-gray-900">Developer Portal</span>
</Link>
<span className="text-gray-300">|</span>
<span className="text-sm text-gray-500">AI Compliance SDK</span>
</div>
<div className="flex items-center gap-4">
<Link
href="/sdk"
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
>
SDK Dashboard
</Link>
<a
href="https://github.com/breakpilot/compliance-sdk"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
>
GitHub
</a>
</div>
</div>
</div>
</header>
<div className="max-w-7xl mx-auto flex">
{/* Sidebar */}
<aside className="w-64 shrink-0 border-r border-gray-200 h-[calc(100vh-64px)] sticky top-16 overflow-y-auto">
<nav className="p-4 space-y-6">
{navigation.map((section) => (
<div key={section.href}>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900 mb-2">
{section.icon}
<span>{section.title}</span>
</div>
{section.items && (
<ul className="ml-6 space-y-1">
{section.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className={`block py-1.5 text-sm transition-colors ${
pathname === item.href
? 'text-blue-600 font-medium'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{item.title}
</Link>
</li>
))}
</ul>
)}
</div>
))}
</nav>
</aside>
{/* Main Content */}
<main className="flex-1 min-w-0">
<div className="max-w-3xl mx-auto px-8 py-12">
{(title || description) && (
<div className="mb-8">
{title && (
<h1 className="text-3xl font-bold text-gray-900 mb-2">{title}</h1>
)}
{description && (
<p className="text-lg text-gray-600">{description}</p>
)}
</div>
)}
<div className="prose prose-gray prose-blue max-w-none">
{children}
</div>
</div>
</main>
</div>
</div>
)
}
// Re-usable components for documentation
export function ApiEndpoint({
method,
path,
description,
}: {
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
path: string
description: string
}) {
const methodColors = {
GET: 'bg-green-100 text-green-800',
POST: 'bg-blue-100 text-blue-800',
PUT: 'bg-yellow-100 text-yellow-800',
DELETE: 'bg-red-100 text-red-800',
}
return (
<div className="border border-gray-200 rounded-lg p-4 my-4 not-prose">
<div className="flex items-center gap-3 mb-2">
<span className={`px-2 py-1 text-xs font-bold rounded ${methodColors[method]}`}>
{method}
</span>
<code className="text-sm font-mono text-gray-800">{path}</code>
</div>
<p className="text-sm text-gray-600">{description}</p>
</div>
)
}
export function CodeBlock({
language,
children,
filename,
}: {
language: string
children: string
filename?: string
}) {
return (
<div className="my-4 not-prose">
{filename && (
<div className="bg-gray-800 text-gray-300 text-xs px-4 py-2 rounded-t-lg border-b border-gray-700">
{filename}
</div>
)}
<pre className={`bg-gray-900 text-gray-100 p-4 overflow-x-auto text-sm ${filename ? 'rounded-b-lg' : 'rounded-lg'}`}>
<code className={`language-${language}`}>{children}</code>
</pre>
</div>
)
}
export function ParameterTable({
parameters,
}: {
parameters: Array<{
name: string
type: string
required?: boolean
description: string
}>
}) {
return (
<div className="my-4 overflow-x-auto not-prose">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Parameter</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Required</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Description</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{parameters.map((param) => (
<tr key={param.name}>
<td className="px-4 py-3">
<code className="text-sm text-blue-600">{param.name}</code>
</td>
<td className="px-4 py-3">
<code className="text-sm text-gray-600">{param.type}</code>
</td>
<td className="px-4 py-3">
{param.required ? (
<span className="text-red-600 text-sm">Yes</span>
) : (
<span className="text-gray-400 text-sm">No</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600">{param.description}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export function InfoBox({
type = 'info',
title,
children,
}: {
type?: 'info' | 'warning' | 'success' | 'error'
title?: string
children: React.ReactNode
}) {
const styles = {
info: 'bg-blue-50 border-blue-200 text-blue-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
success: 'bg-green-50 border-green-200 text-green-800',
error: 'bg-red-50 border-red-200 text-red-800',
}
const icons = {
info: <HelpCircle className="w-5 h-5" />,
warning: <Shield className="w-5 h-5" />,
success: <Zap className="w-5 h-5" />,
error: <Shield className="w-5 h-5" />,
}
return (
<div className={`my-4 p-4 border rounded-lg ${styles[type]} not-prose`}>
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5">{icons[type]}</div>
<div>
{title && <p className="font-medium mb-1">{title}</p>}
<div className="text-sm">{children}</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,165 @@
'use client'
import React from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
Shield, Download, FileCode, Layers, Smartphone, Lock,
ChevronDown, ChevronRight, Home, BookOpen,
Code2
} from 'lucide-react'
interface NavItem {
title: string
href: string
icon?: React.ReactNode
children?: NavItem[]
}
const navigation: NavItem[] = [
{
title: 'Uebersicht',
href: '/developers/sdk/consent',
icon: <Home className="w-4 h-4" />,
},
{
title: 'Installation',
href: '/developers/sdk/consent/installation',
icon: <Download className="w-4 h-4" />,
},
{
title: 'API Referenz',
href: '/developers/sdk/consent/api-reference',
icon: <FileCode className="w-4 h-4" />,
},
{
title: 'Frameworks',
href: '/developers/sdk/consent/frameworks',
icon: <Layers className="w-4 h-4" />,
children: [
{ title: 'React', href: '/developers/sdk/consent/frameworks/react' },
{ title: 'Vue', href: '/developers/sdk/consent/frameworks/vue' },
{ title: 'Angular', href: '/developers/sdk/consent/frameworks/angular' },
],
},
{
title: 'Mobile SDKs',
href: '/developers/sdk/consent/mobile',
icon: <Smartphone className="w-4 h-4" />,
children: [
{ title: 'iOS (Swift)', href: '/developers/sdk/consent/mobile/ios' },
{ title: 'Android (Kotlin)', href: '/developers/sdk/consent/mobile/android' },
{ title: 'Flutter', href: '/developers/sdk/consent/mobile/flutter' },
],
},
{
title: 'Sicherheit',
href: '/developers/sdk/consent/security',
icon: <Lock className="w-4 h-4" />,
},
]
function NavLink({ item, depth = 0 }: { item: NavItem; depth?: number }) {
const pathname = usePathname()
const isActive = pathname === item.href
const isParentActive = item.children?.some((child) => pathname === child.href)
const [isOpen, setIsOpen] = React.useState(isActive || isParentActive)
const hasChildren = item.children && item.children.length > 0
return (
<div>
<div className="flex items-center">
<Link
href={item.href}
className={`flex-1 flex items-center gap-2 px-3 py-2 text-sm rounded-lg transition-colors ${
isActive
? 'bg-violet-100 text-violet-900 font-medium'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
style={{ paddingLeft: `${12 + depth * 12}px` }}
>
{item.icon && <span className="shrink-0">{item.icon}</span>}
<span>{item.title}</span>
</Link>
{hasChildren && (
<button
onClick={() => setIsOpen(!isOpen)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
{isOpen ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
)}
</button>
)}
</div>
{hasChildren && isOpen && (
<div className="mt-1 space-y-1">
{item.children?.map((child) => (
<NavLink key={child.href} item={child} depth={depth + 1} />
))}
</div>
)}
</div>
)
}
export function SDKDocsSidebar() {
return (
<aside className="w-64 h-[calc(100vh-64px)] fixed top-16 left-0 border-r border-gray-200 bg-white overflow-y-auto">
<div className="p-4">
{/* Header */}
<div className="mb-6">
<Link
href="/developers/sdk/consent"
className="flex items-center gap-3 p-3 rounded-xl bg-gradient-to-r from-violet-50 to-purple-50 border border-violet-100"
>
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-violet-600 to-purple-600 flex items-center justify-center">
<Shield className="w-5 h-5 text-white" />
</div>
<div>
<div className="font-semibold text-gray-900">Consent SDK</div>
<div className="text-xs text-gray-500">v1.0.0</div>
</div>
</Link>
</div>
{/* Navigation */}
<nav className="space-y-1">
{navigation.map((item) => (
<NavLink key={item.href} item={item} />
))}
</nav>
{/* Resources */}
<div className="mt-8 pt-6 border-t border-gray-200">
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3 px-3">
Ressourcen
</div>
<div className="space-y-1">
<a
href="https://github.com/breakpilot/consent-sdk"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 rounded-lg transition-colors"
>
<Code2 className="w-4 h-4" />
<span>GitHub</span>
</a>
<Link
href="/developers"
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 rounded-lg transition-colors"
>
<BookOpen className="w-4 h-4" />
<span>Developer Portal</span>
</Link>
</div>
</div>
</div>
</aside>
)
}
export default SDKDocsSidebar

View File

@@ -0,0 +1,76 @@
'use client'
import { usePathname } from 'next/navigation'
import { navigation, metaModules, getModuleByHref } from '@/lib/navigation'
interface HeaderProps {
title?: string
description?: string
}
export function Header({ title, description }: HeaderProps) {
const pathname = usePathname()
// Auto-detect title and description from navigation
let pageTitle = title
let pageDescription = description
if (!pageTitle) {
// Check meta modules first
const metaModule = metaModules.find(m => pathname === m.href || pathname.startsWith(m.href + '/'))
if (metaModule) {
pageTitle = metaModule.name
pageDescription = metaModule.description
} else {
// Check navigation modules
const result = getModuleByHref(pathname)
if (result) {
pageTitle = result.module.name
pageDescription = result.module.description
} else {
// Check category pages
const category = navigation.find(cat => pathname === `/${cat.id}`)
if (category) {
pageTitle = category.name
pageDescription = category.description
}
}
}
}
return (
<header className="h-16 bg-white border-b border-slate-200 flex items-center px-6 sticky top-0 z-10">
<div className="flex-1">
{pageTitle && <h1 className="text-xl font-semibold text-slate-900">{pageTitle}</h1>}
{pageDescription && <p className="text-sm text-slate-500">{pageDescription}</p>}
</div>
{/* Search */}
<div className="flex items-center gap-4">
<div className="relative">
<input
type="text"
placeholder="Suchen... (Ctrl+K)"
className="w-64 pl-10 pr-4 py-2 bg-slate-100 border border-transparent rounded-lg text-sm focus:bg-white focus:border-primary-300 focus:outline-none transition-colors"
/>
<svg
className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* User Area */}
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500">Admin v2</span>
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-medium">
A
</div>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,107 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { roles, getRoleById, getStoredRole, storeRole, RoleId } from '@/lib/roles'
interface RoleIndicatorProps {
collapsed?: boolean
onRoleChange?: () => void
}
export function RoleIndicator({ collapsed, onRoleChange }: RoleIndicatorProps) {
const router = useRouter()
const [currentRole, setCurrentRole] = useState<RoleId | null>(null)
const [showDropdown, setShowDropdown] = useState(false)
useEffect(() => {
const role = getStoredRole()
setCurrentRole(role)
}, [])
const handleRoleChange = (roleId: RoleId) => {
storeRole(roleId)
setCurrentRole(roleId)
setShowDropdown(false)
onRoleChange?.()
// Refresh the page to update navigation
router.refresh()
}
const role = currentRole ? getRoleById(currentRole) : null
if (!role) {
return null
}
// Role icons
const roleIcons: Record<RoleId, React.ReactNode> = {
developer: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
manager: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
auditor: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
),
dsb: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
}
return (
<div className="relative">
<button
onClick={() => setShowDropdown(!showDropdown)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-slate-300 hover:bg-slate-800 transition-colors ${
collapsed ? 'justify-center' : ''
}`}
title={collapsed ? `Rolle: ${role.name}` : undefined}
>
{currentRole && roleIcons[currentRole]}
{!collapsed && (
<>
<span className="flex-1 text-left">Rolle: {role.name}</span>
<svg
className={`w-4 h-4 transition-transform ${showDropdown ? '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>
{/* Dropdown */}
{showDropdown && (
<div className={`absolute ${collapsed ? 'left-full ml-2' : 'left-0 right-0'} bottom-full mb-2 bg-slate-800 rounded-lg shadow-lg border border-slate-700 overflow-hidden`}>
{roles.map((r) => (
<button
key={r.id}
onClick={() => handleRoleChange(r.id)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors ${
r.id === currentRole
? 'bg-primary-600 text-white'
: 'text-slate-300 hover:bg-slate-700'
}`}
>
{roleIcons[r.id]}
<span>{r.name}</span>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,284 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState, useEffect } from 'react'
import { navigation, metaModules, NavCategory, CategoryId } from '@/lib/navigation'
import { RoleId, getStoredRole, isCategoryVisibleForRole } from '@/lib/roles'
import { RoleIndicator } from './RoleIndicator'
// Icons mapping
const categoryIcons: Record<string, React.ReactNode> = {
'shield-check': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
'clipboard-check': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
),
shield: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
brain: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
),
server: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
),
graduation: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l9-5-9-5-9 5 9 5z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l9-5-9-5-9 5 9 5zm0 0l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998a12.078 12.078 0 01.665-6.479L12 14zm-4 6v-7.5l4-2.222" />
</svg>
),
mail: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
code: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
}
const metaIcons: Record<string, React.ReactNode> = {
dashboard: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
architecture: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
onboarding: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
),
backlog: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
rbac: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
),
}
interface SidebarProps {
onRoleChange?: () => void
}
export function Sidebar({ onRoleChange }: SidebarProps) {
const pathname = usePathname()
const [collapsed, setCollapsed] = useState(false)
const [expandedCategories, setExpandedCategories] = useState<Set<CategoryId>>(new Set())
const [currentRole, setCurrentRole] = useState<RoleId | null>(null)
useEffect(() => {
const role = getStoredRole()
setCurrentRole(role)
// Auto-expand category based on current path
if (role) {
const category = navigation.find(cat =>
cat.modules.some(m => pathname.startsWith(m.href.split('/')[1] ? `/${m.href.split('/')[1]}` : m.href))
)
if (category) {
setExpandedCategories(new Set([category.id]))
}
}
}, [pathname])
const toggleCategory = (categoryId: CategoryId) => {
setExpandedCategories(prev => {
const newSet = new Set(prev)
if (newSet.has(categoryId)) {
newSet.delete(categoryId)
} else {
newSet.add(categoryId)
}
return newSet
})
}
const isModuleActive = (href: string) => {
if (href === '/dashboard') {
return pathname === '/dashboard'
}
return pathname.startsWith(href)
}
const visibleCategories = currentRole
? navigation.filter(cat => isCategoryVisibleForRole(cat.id, currentRole))
: navigation
return (
<aside
className={`${
collapsed ? 'w-16' : 'w-64'
} bg-slate-900 text-white flex flex-col transition-all duration-300 fixed h-full z-20`}
>
{/* Logo/Header */}
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-700">
{!collapsed && (
<Link href="/dashboard" className="font-bold text-lg">
Admin v2
</Link>
)}
<button
onClick={() => setCollapsed(!collapsed)}
className="p-2 rounded-lg hover:bg-slate-800 transition-colors"
title={collapsed ? 'Sidebar erweitern' : 'Sidebar einklappen'}
>
<svg
className={`w-5 h-5 transition-transform ${collapsed ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
</div>
{/* Navigation */}
<nav className="flex-1 py-4 overflow-y-auto">
{/* Meta Modules */}
<div className="px-2 mb-4">
{metaModules.map((module) => (
<Link
key={module.id}
href={module.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
isModuleActive(module.href)
? 'bg-primary-600 text-white'
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
}`}
title={collapsed ? module.name : undefined}
>
<span className="flex-shrink-0">{metaIcons[module.id]}</span>
{!collapsed && <span className="truncate">{module.name}</span>}
</Link>
))}
</div>
{/* Divider */}
<div className="mx-4 border-t border-slate-700 my-2" />
{/* Categories */}
<div className="px-2 space-y-1">
{visibleCategories.map((category) => {
const categoryHref = `/${category.id === 'compliance' ? 'compliance' : category.id}`
const isCategoryActive = pathname.startsWith(categoryHref)
return (
<div key={category.id}>
<div
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
isCategoryActive
? 'bg-slate-800 text-white'
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
}`}
>
<Link
href={categoryHref}
className="flex items-center gap-3 flex-1 min-w-0"
title={collapsed ? category.name : undefined}
>
<span
className="flex-shrink-0"
style={{ color: category.color }}
>
{categoryIcons[category.icon]}
</span>
{!collapsed && (
<span className="flex-1 text-left truncate">{category.name}</span>
)}
</Link>
{!collapsed && (
<button
onClick={(e) => {
e.preventDefault()
toggleCategory(category.id)
}}
className="p-1 rounded hover:bg-slate-700 transition-colors"
title={expandedCategories.has(category.id) ? 'Einklappen' : 'Aufklappen'}
>
<svg
className={`w-4 h-4 transition-transform ${
expandedCategories.has(category.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>
</button>
)}
</div>
{/* Category Modules */}
{!collapsed && expandedCategories.has(category.id) && (
<div className="ml-4 mt-1 space-y-1">
{category.modules.map((module) => (
<Link
key={module.id}
href={module.href}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
isModuleActive(module.href)
? 'bg-primary-600 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: category.color }}
/>
<span className="truncate">{module.name}</span>
</Link>
))}
</div>
)}
</div>
)
})}
</div>
</nav>
{/* Footer with Role Indicator */}
<div className="p-4 border-t border-slate-700">
<RoleIndicator collapsed={collapsed} onRoleChange={onRoleChange} />
<Link
href="/"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors mt-2 ${
collapsed ? 'justify-center' : ''
}`}
title="Zur Website"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{!collapsed && <span>Altes Admin</span>}
</Link>
</div>
</aside>
)
}

View File

@@ -0,0 +1,464 @@
'use client'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK, SDK_STEPS, CommandType, CommandHistory, downloadExport } from '@/lib/sdk'
// =============================================================================
// TYPES
// =============================================================================
interface Suggestion {
id: string
type: CommandType
label: string
description: string
shortcut?: string
icon: React.ReactNode
action: () => void | Promise<void>
}
// =============================================================================
// ICONS
// =============================================================================
const icons = {
navigation: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
),
action: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
search: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
),
generate: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
),
help: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
}
// =============================================================================
// COMMAND BAR
// =============================================================================
interface CommandBarProps {
onClose: () => void
}
export function CommandBar({ onClose }: CommandBarProps) {
const router = useRouter()
const { state, dispatch, goToStep } = useSDK()
const inputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const [isLoading, setIsLoading] = useState(false)
// Focus input on mount
useEffect(() => {
inputRef.current?.focus()
}, [])
// Generate suggestions based on query
const suggestions = useMemo((): Suggestion[] => {
const results: Suggestion[] = []
const lowerQuery = query.toLowerCase()
// Navigation suggestions
SDK_STEPS.forEach(step => {
const matchesName = step.name.toLowerCase().includes(lowerQuery) ||
step.nameShort.toLowerCase().includes(lowerQuery)
const matchesDescription = step.description.toLowerCase().includes(lowerQuery)
if (!query || matchesName || matchesDescription) {
results.push({
id: `nav-${step.id}`,
type: 'NAVIGATION',
label: `Gehe zu ${step.name}`,
description: step.description,
icon: icons.navigation,
action: () => {
goToStep(step.id)
onClose()
},
})
}
})
// Action suggestions
const actions: Suggestion[] = [
{
id: 'action-new-usecase',
type: 'ACTION',
label: 'Neuen Anwendungsfall erstellen',
description: 'Startet die Anwendungsfall-Erfassung',
icon: icons.action,
action: () => {
goToStep('use-case-assessment')
onClose()
},
},
{
id: 'action-export-pdf',
type: 'ACTION',
label: 'Als PDF exportieren',
description: 'Exportiert Compliance-Bericht als PDF',
icon: icons.action,
action: async () => {
setIsLoading(true)
try {
await downloadExport(state, 'pdf')
} catch (error) {
console.error('PDF export failed:', error)
} finally {
setIsLoading(false)
onClose()
}
},
},
{
id: 'action-export-zip',
type: 'ACTION',
label: 'Als ZIP exportieren',
description: 'Exportiert alle Daten und Dokumente als ZIP-Archiv',
icon: icons.action,
action: async () => {
setIsLoading(true)
try {
await downloadExport(state, 'zip')
} catch (error) {
console.error('ZIP export failed:', error)
} finally {
setIsLoading(false)
onClose()
}
},
},
{
id: 'action-export-json',
type: 'ACTION',
label: 'Als JSON exportieren',
description: 'Exportiert den kompletten State als JSON',
icon: icons.action,
action: async () => {
try {
await downloadExport(state, 'json')
} catch (error) {
console.error('JSON export failed:', error)
} finally {
onClose()
}
},
},
{
id: 'action-validate',
type: 'ACTION',
label: 'Checkpoint validieren',
description: 'Validiert den aktuellen Schritt',
icon: icons.action,
action: () => {
// Trigger validation
onClose()
},
},
]
actions.forEach(action => {
if (!query || action.label.toLowerCase().includes(lowerQuery)) {
results.push(action)
}
})
// Generate suggestions
const generateSuggestions: Suggestion[] = [
{
id: 'gen-dsfa',
type: 'GENERATE',
label: 'DSFA generieren',
description: 'Generiert eine Datenschutz-Folgenabschätzung',
icon: icons.generate,
action: () => {
goToStep('dsfa')
onClose()
},
},
{
id: 'gen-tom',
type: 'GENERATE',
label: 'TOMs generieren',
description: 'Generiert technische und organisatorische Maßnahmen',
icon: icons.generate,
action: () => {
goToStep('tom')
onClose()
},
},
{
id: 'gen-vvt',
type: 'GENERATE',
label: 'VVT generieren',
description: 'Generiert das Verarbeitungsverzeichnis',
icon: icons.generate,
action: () => {
goToStep('vvt')
onClose()
},
},
{
id: 'gen-cookie',
type: 'GENERATE',
label: 'Cookie Banner generieren',
description: 'Generiert Cookie-Consent-Banner Code',
icon: icons.generate,
action: () => {
goToStep('cookie-banner')
onClose()
},
},
]
generateSuggestions.forEach(suggestion => {
if (!query || suggestion.label.toLowerCase().includes(lowerQuery)) {
results.push(suggestion)
}
})
// Help suggestions
const helpSuggestions: Suggestion[] = [
{
id: 'help-docs',
type: 'HELP',
label: 'Dokumentation öffnen',
description: 'Öffnet die SDK-Dokumentation',
icon: icons.help,
action: () => {
window.open('/docs/sdk', '_blank')
onClose()
},
},
{
id: 'help-next',
type: 'HELP',
label: 'Was muss ich als nächstes tun?',
description: 'Zeigt den nächsten empfohlenen Schritt',
icon: icons.help,
action: () => {
// Show contextual help
onClose()
},
},
]
helpSuggestions.forEach(suggestion => {
if (!query || suggestion.label.toLowerCase().includes(lowerQuery)) {
results.push(suggestion)
}
})
return results.slice(0, 10)
}, [query, state, goToStep, onClose])
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex(prev => Math.max(prev - 1, 0))
break
case 'Enter':
e.preventDefault()
if (suggestions[selectedIndex]) {
executeSuggestion(suggestions[selectedIndex])
}
break
case 'Escape':
onClose()
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [suggestions, selectedIndex, onClose])
// Reset selected index when query changes
useEffect(() => {
setSelectedIndex(0)
}, [query])
const executeSuggestion = async (suggestion: Suggestion) => {
// Add to history
const historyEntry: CommandHistory = {
id: `${Date.now()}`,
query: suggestion.label,
type: suggestion.type,
timestamp: new Date(),
success: true,
}
dispatch({ type: 'ADD_COMMAND_HISTORY', payload: historyEntry })
try {
await suggestion.action()
} catch (error) {
console.error('Command execution failed:', error)
}
}
const getTypeLabel = (type: CommandType): string => {
switch (type) {
case 'NAVIGATION':
return 'Navigation'
case 'ACTION':
return 'Aktion'
case 'SEARCH':
return 'Suche'
case 'GENERATE':
return 'Generieren'
case 'HELP':
return 'Hilfe'
default:
return type
}
}
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 transition-opacity"
onClick={onClose}
/>
{/* Dialog */}
<div className="relative min-h-screen flex items-start justify-center pt-[15vh] px-4">
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl overflow-hidden">
{/* Search Input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-200">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
ref={inputRef}
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Suchen oder Befehl eingeben..."
className="flex-1 text-lg bg-transparent border-none outline-none placeholder-gray-400"
/>
{isLoading && (
<svg className="animate-spin w-5 h-5 text-purple-600" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
<kbd className="px-2 py-1 text-xs text-gray-400 bg-gray-100 rounded">ESC</kbd>
</div>
{/* Suggestions */}
<div className="max-h-96 overflow-y-auto py-2">
{suggestions.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-500">
Keine Ergebnisse für &quot;{query}&quot;
</div>
) : (
suggestions.map((suggestion, index) => (
<button
key={suggestion.id}
onClick={() => executeSuggestion(suggestion)}
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ${
index === selectedIndex
? 'bg-purple-50 text-purple-900'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
<div
className={`flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${
index === selectedIndex
? 'bg-purple-100 text-purple-600'
: 'bg-gray-100 text-gray-500'
}`}
>
{suggestion.icon}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{suggestion.label}</div>
<div className="text-sm text-gray-500 truncate">{suggestion.description}</div>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
<span
className={`px-2 py-0.5 text-xs rounded-full ${
suggestion.type === 'NAVIGATION'
? 'bg-blue-100 text-blue-700'
: suggestion.type === 'ACTION'
? 'bg-green-100 text-green-700'
: suggestion.type === 'GENERATE'
? 'bg-purple-100 text-purple-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{getTypeLabel(suggestion.type)}
</span>
{suggestion.shortcut && (
<kbd className="px-1.5 py-0.5 text-xs bg-gray-100 rounded">
{suggestion.shortcut}
</kbd>
)}
</div>
</button>
))
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2 border-t border-gray-200 bg-gray-50 text-xs text-gray-500">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-gray-200 rounded"></kbd>
<kbd className="px-1.5 py-0.5 bg-gray-200 rounded"></kbd>
navigieren
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-gray-200 rounded"></kbd>
auswählen
</span>
</div>
<span>AI Compliance SDK v1.0</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { CommandBar } from './CommandBar'

View File

@@ -0,0 +1,154 @@
'use client'
import { CustomerType } from '@/lib/sdk/types'
interface CustomerTypeSelectorProps {
onSelect: (type: CustomerType) => void
}
export function CustomerTypeSelector({ onSelect }: CustomerTypeSelectorProps) {
return (
<div className="max-w-4xl mx-auto">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Wie moechten Sie starten?</h2>
<p className="text-gray-600">
Waehlen Sie Ihren Einstiegspunkt basierend auf Ihrem aktuellen Compliance-Stand
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Neukunde Option */}
<button
onClick={() => onSelect('new')}
className="group relative bg-white rounded-2xl border-2 border-gray-200 p-8 text-left hover:border-purple-400 hover:shadow-xl transition-all duration-300"
>
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
<svg
className="w-6 h-6 text-purple-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
</div>
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-2xl flex items-center justify-center mb-6">
<span className="text-3xl">🚀</span>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Neues Projekt</h3>
<p className="text-gray-600 mb-6">
Ich starte bei Null und brauche alle Compliance-Dokumente von Grund auf.
</p>
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Schritt-fuer-Schritt Anleitung</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Alle Dokumente werden generiert</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Ideal fuer Startups & neue Projekte</span>
</div>
</div>
<div className="mt-6 pt-4 border-t border-gray-100">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Empfohlen fuer</span>
<span className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm font-medium">
Startups
</span>
</div>
</div>
</button>
{/* Bestandskunde Option */}
<button
onClick={() => onSelect('existing')}
className="group relative bg-white rounded-2xl border-2 border-gray-200 p-8 text-left hover:border-indigo-400 hover:shadow-xl transition-all duration-300"
>
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
<svg
className="w-6 h-6 text-indigo-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
</div>
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-blue-600 rounded-2xl flex items-center justify-center mb-6">
<span className="text-3xl">📄</span>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Bestehendes Projekt</h3>
<p className="text-gray-600 mb-6">
Ich habe bereits Compliance-Dokumente und moechte diese erweitern (z.B. fuer KI).
</p>
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Dokumente hochladen & analysieren</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>KI-gestuetzte Gap-Analyse</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Nur Delta-Anforderungen bearbeiten</span>
</div>
</div>
<div className="mt-6 pt-4 border-t border-gray-100">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Empfohlen fuer</span>
<span className="px-3 py-1 bg-indigo-100 text-indigo-700 rounded-full text-sm font-medium">
Bestandskunden
</span>
</div>
</div>
</button>
</div>
<div className="mt-8 text-center">
<p className="text-sm text-gray-500">
Sie koennen diese Auswahl spaeter in den Einstellungen aendern.
</p>
</div>
</div>
)
}
export default CustomerTypeSelector

View File

@@ -0,0 +1,2 @@
export { CustomerTypeSelector } from './CustomerTypeSelector'
export { default } from './CustomerTypeSelector'

View File

@@ -0,0 +1,583 @@
'use client'
import React, { useState, useCallback, useEffect } from 'react'
import { useRouter } from 'next/navigation'
// =============================================================================
// TYPES
// =============================================================================
export interface UploadedDocument {
id: string
name: string
type: string
size: number
uploadedAt: Date
extractedVersion?: string
extractedContent?: ExtractedContent
status: 'uploading' | 'processing' | 'ready' | 'error'
error?: string
}
export interface ExtractedContent {
title?: string
version?: string
lastModified?: string
sections?: ExtractedSection[]
metadata?: Record<string, string>
}
export interface ExtractedSection {
title: string
content: string
type?: string
}
export interface DocumentUploadSectionProps {
/** Type of document being uploaded (tom, dsfa, vvt, loeschfristen, etc.) */
documentType: 'tom' | 'dsfa' | 'vvt' | 'loeschfristen' | 'consent' | 'policy' | 'custom'
/** Title displayed in the upload section */
title?: string
/** Description text */
description?: string
/** Accepted file types */
acceptedTypes?: string
/** Callback when document is uploaded and processed */
onDocumentProcessed?: (doc: UploadedDocument) => void
/** Callback to open document in workflow editor */
onOpenInEditor?: (doc: UploadedDocument) => void
/** Session ID for QR upload */
sessionId?: string
/** Custom CSS classes */
className?: string
}
// =============================================================================
// ICONS
// =============================================================================
const UploadIcon = () => (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
)
const QRIcon = () => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
)
const DocumentIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)
const CheckIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)
const EditIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
)
const CloseIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
function detectVersionFromFilename(filename: string): string | undefined {
// Common version patterns: v1.0, V2.1, _v3, -v1.2.3, version-2
const patterns = [
/[vV](\d+(?:\.\d+)*)/,
/version[_-]?(\d+(?:\.\d+)*)/i,
/[_-]v?(\d+\.\d+(?:\.\d+)?)[_-]/,
]
for (const pattern of patterns) {
const match = filename.match(pattern)
if (match) {
return match[1]
}
}
return undefined
}
function suggestNextVersion(currentVersion?: string): string {
if (!currentVersion) return '1.0'
const parts = currentVersion.split('.').map(Number)
if (parts.length >= 2) {
parts[parts.length - 1] += 1
} else {
parts.push(1)
}
return parts.join('.')
}
// =============================================================================
// QR CODE MODAL
// =============================================================================
interface QRModalProps {
isOpen: boolean
onClose: () => void
sessionId: string
onFileUploaded?: (file: File) => void
}
function QRCodeModal({ isOpen, onClose, sessionId }: QRModalProps) {
const [uploadUrl, setUploadUrl] = useState('')
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null)
useEffect(() => {
if (!isOpen) return
let baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
// Hostname to IP mapping for local network
const hostnameToIP: Record<string, string> = {
'macmini': '192.168.178.100',
'macmini.local': '192.168.178.100',
}
Object.entries(hostnameToIP).forEach(([hostname, ip]) => {
if (baseUrl.includes(hostname)) {
baseUrl = baseUrl.replace(hostname, ip)
}
})
// Force HTTP for mobile access (SSL cert is for hostname, not IP)
// This is safe because it's only used on the local network
if (baseUrl.startsWith('https://')) {
baseUrl = baseUrl.replace('https://', 'http://')
}
const uploadPath = `/upload/sdk/${sessionId}`
const fullUrl = `${baseUrl}${uploadPath}`
setUploadUrl(fullUrl)
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(fullUrl)}`
setQrCodeUrl(qrApiUrl)
}, [isOpen, sessionId])
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(uploadUrl)
} catch (err) {
console.error('Copy failed:', err)
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<QRIcon />
</div>
<div>
<h3 className="font-semibold text-gray-900">Mit Handy hochladen</h3>
<p className="text-sm text-gray-500">QR-Code scannen</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-lg">
<CloseIcon />
</button>
</div>
<div className="flex flex-col items-center">
<div className="p-4 bg-white border border-gray-200 rounded-xl">
{qrCodeUrl ? (
<img src={qrCodeUrl} alt="QR Code" className="w-[200px] h-[200px]" />
) : (
<div className="w-[200px] h-[200px] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
<p className="mt-4 text-center text-sm text-gray-600">
Scannen Sie den Code mit Ihrem Handy,<br />
um Dokumente hochzuladen.
</p>
<div className="mt-4 w-full">
<p className="text-xs text-gray-400 mb-2">Oder Link teilen:</p>
<div className="flex gap-2">
<input
type="text"
value={uploadUrl}
readOnly
className="flex-1 px-3 py-2 text-sm border border-gray-200 rounded-lg bg-gray-50"
/>
<button
onClick={copyToClipboard}
className="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Kopieren
</button>
</div>
</div>
<div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg w-full">
<p className="text-xs text-amber-800">
<strong>Hinweis:</strong> Ihr Handy muss im gleichen Netzwerk sein.
</p>
</div>
</div>
</div>
</div>
)
}
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export function DocumentUploadSection({
documentType,
title,
description,
acceptedTypes = '.pdf,.docx,.doc',
onDocumentProcessed,
onOpenInEditor,
sessionId,
className = '',
}: DocumentUploadSectionProps) {
const router = useRouter()
const [isDragging, setIsDragging] = useState(false)
const [uploadedDocs, setUploadedDocs] = useState<UploadedDocument[]>([])
const [showQRModal, setShowQRModal] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const effectiveSessionId = sessionId || `sdk-${documentType}-${Date.now()}`
const defaultTitles: Record<string, string> = {
tom: 'Bestehende TOMs hochladen',
dsfa: 'Bestehende DSFA hochladen',
vvt: 'Bestehendes VVT hochladen',
loeschfristen: 'Bestehende Löschfristen hochladen',
consent: 'Bestehende Einwilligungsdokumente hochladen',
policy: 'Bestehende Richtlinie hochladen',
custom: 'Dokument hochladen',
}
const defaultDescriptions: Record<string, string> = {
tom: 'Laden Sie Ihre bestehenden technischen und organisatorischen Maßnahmen hoch. Wir erkennen die Version und zeigen Ihnen, was aktualisiert werden sollte.',
dsfa: 'Laden Sie Ihre bestehende Datenschutz-Folgenabschätzung hoch. Wir analysieren den Inhalt und schlagen Ergänzungen vor.',
vvt: 'Laden Sie Ihr bestehendes Verarbeitungsverzeichnis hoch. Wir erkennen die Struktur und helfen bei der Aktualisierung.',
loeschfristen: 'Laden Sie Ihre bestehenden Löschfristen-Dokumente hoch. Wir analysieren die Fristen und prüfen auf Vollständigkeit.',
consent: 'Laden Sie Ihre bestehenden Einwilligungsdokumente hoch.',
policy: 'Laden Sie Ihre bestehende Richtlinie hoch.',
custom: 'Laden Sie ein bestehendes Dokument hoch.',
}
const displayTitle = title || defaultTitles[documentType]
const displayDescription = description || defaultDescriptions[documentType]
// Drag & Drop handlers
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const processFile = useCallback(async (file: File) => {
const docId = `doc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const newDoc: UploadedDocument = {
id: docId,
name: file.name,
type: file.type,
size: file.size,
uploadedAt: new Date(),
extractedVersion: detectVersionFromFilename(file.name),
status: 'uploading',
}
setUploadedDocs(prev => [...prev, newDoc])
setIsExpanded(true)
try {
// Upload to API
const formData = new FormData()
formData.append('file', file)
formData.append('documentType', documentType)
formData.append('sessionId', effectiveSessionId)
const response = await fetch('/api/sdk/v1/documents/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) {
throw new Error('Upload fehlgeschlagen')
}
const result = await response.json()
const updatedDoc: UploadedDocument = {
...newDoc,
status: 'ready',
extractedVersion: result.extractedVersion || newDoc.extractedVersion,
extractedContent: result.extractedContent,
}
setUploadedDocs(prev => prev.map(d => d.id === docId ? updatedDoc : d))
if (onDocumentProcessed) {
onDocumentProcessed(updatedDoc)
}
} catch (error) {
setUploadedDocs(prev => prev.map(d =>
d.id === docId
? { ...d, status: 'error', error: error instanceof Error ? error.message : 'Fehler' }
: d
))
}
}, [documentType, effectiveSessionId, onDocumentProcessed])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files).filter(f =>
f.type === 'application/pdf' ||
f.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
f.type === 'application/msword'
)
files.forEach(processFile)
}, [processFile])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return
Array.from(e.target.files).forEach(processFile)
e.target.value = '' // Reset input
}, [processFile])
const removeDocument = useCallback((docId: string) => {
setUploadedDocs(prev => prev.filter(d => d.id !== docId))
}, [])
const handleOpenInEditor = useCallback((doc: UploadedDocument) => {
if (onOpenInEditor) {
onOpenInEditor(doc)
} else {
// Default: navigate to workflow editor
router.push(`/compliance/workflow?documentType=${documentType}&documentId=${doc.id}`)
}
}, [documentType, onOpenInEditor, router])
return (
<div className={`bg-white border border-gray-200 rounded-xl overflow-hidden ${className}`}>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center text-blue-600">
<DocumentIcon />
</div>
<div className="text-left">
<h3 className="font-medium text-gray-900">{displayTitle}</h3>
<p className="text-sm text-gray-500">
{uploadedDocs.length > 0
? `${uploadedDocs.length} Dokument(e) hochgeladen`
: 'Optional - bestehende Dokumente importieren'
}
</p>
</div>
</div>
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? '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>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 pt-0 space-y-4">
<p className="text-sm text-gray-600">{displayDescription}</p>
{/* Upload Area */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`relative p-6 rounded-lg border-2 border-dashed transition-colors ${
isDragging
? 'border-purple-400 bg-purple-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="file"
accept={acceptedTypes}
multiple
onChange={handleFileSelect}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<div className="text-center">
<div className="text-gray-400 mb-2">
<UploadIcon />
</div>
<p className="text-sm font-medium text-gray-700">
{isDragging ? 'Dateien hier ablegen' : 'Dateien hierher ziehen'}
</p>
<p className="text-xs text-gray-500 mt-1">
oder klicken zum Auswählen (PDF, DOCX)
</p>
</div>
</div>
{/* QR Upload Button */}
<button
onClick={() => setShowQRModal(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm text-purple-600 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors"
>
<QRIcon />
<span>Mit Handy hochladen (QR-Code)</span>
</button>
{/* Uploaded Documents */}
{uploadedDocs.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">Hochgeladene Dokumente:</p>
{uploadedDocs.map(doc => (
<div
key={doc.id}
className={`flex items-center gap-3 p-3 rounded-lg border ${
doc.status === 'error'
? 'bg-red-50 border-red-200'
: doc.status === 'ready'
? 'bg-green-50 border-green-200'
: 'bg-gray-50 border-gray-200'
}`}
>
<div className="flex-shrink-0">
{doc.status === 'uploading' || doc.status === 'processing' ? (
<div className="w-5 h-5 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
) : doc.status === 'ready' ? (
<div className="w-5 h-5 rounded-full bg-green-500 text-white flex items-center justify-center">
<CheckIcon />
</div>
) : (
<div className="w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center text-xs">!</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{doc.name}</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>{formatFileSize(doc.size)}</span>
{doc.extractedVersion && (
<>
<span></span>
<span className="text-purple-600">Version {doc.extractedVersion}</span>
<span></span>
<span className="text-green-600 font-medium">
Vorschlag: v{suggestNextVersion(doc.extractedVersion)}
</span>
</>
)}
</div>
{doc.error && (
<p className="text-xs text-red-600 mt-1">{doc.error}</p>
)}
</div>
<div className="flex items-center gap-1">
{doc.status === 'ready' && (
<button
onClick={() => handleOpenInEditor(doc)}
className="p-2 text-purple-600 hover:bg-purple-100 rounded-lg transition-colors"
title="Im Editor öffnen"
>
<EditIcon />
</button>
)}
<button
onClick={() => removeDocument(doc.id)}
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="Entfernen"
>
<CloseIcon />
</button>
</div>
</div>
))}
</div>
)}
{/* Extracted Content Preview */}
{uploadedDocs.some(d => d.status === 'ready' && d.extractedContent) && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="text-sm font-medium text-blue-900 mb-2">Erkannte Inhalte</h4>
{uploadedDocs
.filter(d => d.status === 'ready' && d.extractedContent)
.map(doc => (
<div key={doc.id} className="text-sm text-blue-800">
{doc.extractedContent?.title && (
<p><strong>Titel:</strong> {doc.extractedContent.title}</p>
)}
{doc.extractedContent?.sections && (
<p><strong>Abschnitte:</strong> {doc.extractedContent.sections.length} gefunden</p>
)}
</div>
))
}
<button
onClick={() => uploadedDocs.find(d => d.status === 'ready') && handleOpenInEditor(uploadedDocs.find(d => d.status === 'ready')!)}
className="mt-3 w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Im Änderungsmodus öffnen
</button>
</div>
)}
</div>
)}
{/* QR Modal */}
<QRCodeModal
isOpen={showQRModal}
onClose={() => setShowQRModal(false)}
sessionId={effectiveSessionId}
/>
</div>
)
}
export default DocumentUploadSection

View File

@@ -0,0 +1 @@
export { DocumentUploadSection, type DocumentUploadSectionProps, type UploadedDocument, type ExtractedContent, type ExtractedSection } from './DocumentUploadSection'

View File

@@ -0,0 +1,215 @@
'use client'
import React from 'react'
import { useSDK } from '@/lib/sdk'
import { SDKSidebar } from '../Sidebar'
import { CommandBar } from '../CommandBar'
import { SDKPipelineSidebar } from '../SDKPipelineSidebar'
// =============================================================================
// SDK HEADER
// =============================================================================
function SDKHeader() {
const { currentStep, setCommandBarOpen, completionPercentage } = useSDK()
return (
<header className="sticky top-0 z-30 bg-white border-b border-gray-200">
<div className="flex items-center justify-between px-6 py-3">
{/* Breadcrumb / Current Step */}
<div className="flex items-center gap-3">
<nav className="flex items-center text-sm text-gray-500">
<span>SDK</span>
<svg className="w-4 h-4 mx-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="text-gray-900 font-medium">
{currentStep?.name || 'Dashboard'}
</span>
</nav>
</div>
{/* Actions */}
<div className="flex items-center gap-4">
{/* Command Bar Trigger */}
<button
onClick={() => setCommandBarOpen(true)}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-500 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<span>Suchen...</span>
<kbd className="ml-2 px-1.5 py-0.5 text-xs bg-gray-200 rounded">K</kbd>
</button>
{/* Progress Indicator */}
<div className="flex items-center gap-2">
<div className="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full transition-all duration-500"
style={{ width: `${completionPercentage}%` }}
/>
</div>
<span className="text-sm font-medium text-gray-600">{completionPercentage}%</span>
</div>
{/* Help Button */}
<button className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
</div>
</div>
</header>
)
}
// =============================================================================
// NAVIGATION FOOTER
// =============================================================================
function NavigationFooter() {
const { goToNextStep, goToPreviousStep, canGoNext, canGoPrevious, currentStep, validateCheckpoint } = useSDK()
const [isValidating, setIsValidating] = React.useState(false)
const handleNext = async () => {
if (!currentStep) return
setIsValidating(true)
try {
const status = await validateCheckpoint(currentStep.checkpointId)
if (status.passed) {
goToNextStep()
} else {
// Show error notification (in production, use toast)
console.error('Checkpoint validation failed:', status.errors)
}
} finally {
setIsValidating(false)
}
}
return (
<footer className="sticky bottom-0 bg-white border-t border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
{/* Previous Button */}
<button
onClick={goToPreviousStep}
disabled={!canGoPrevious}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
canGoPrevious
? 'text-gray-700 hover:bg-gray-100'
: 'text-gray-300 cursor-not-allowed'
}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>Zurück</span>
</button>
{/* Current Step Info */}
<div className="text-sm text-gray-500">
Schritt {currentStep?.order || 1} von {currentStep?.phase === 1 ? 8 : 11}
</div>
{/* Next / Complete Button */}
<div className="flex items-center gap-3">
<button
onClick={goToNextStep}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
Überspringen
</button>
<button
onClick={handleNext}
disabled={!canGoNext || isValidating}
className={`flex items-center gap-2 px-6 py-2 rounded-lg font-medium transition-colors ${
canGoNext && !isValidating
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
{isValidating ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Validiere...</span>
</>
) : (
<>
<span>Weiter</span>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</>
)}
</button>
</div>
</div>
</footer>
)
}
// =============================================================================
// MAIN LAYOUT
// =============================================================================
interface SDKLayoutProps {
children: React.ReactNode
showNavigation?: boolean
}
export function SDKLayout({ children, showNavigation = true }: SDKLayoutProps) {
const { isCommandBarOpen, setCommandBarOpen } = useSDK()
return (
<div className="min-h-screen bg-gray-50">
{/* Sidebar */}
<SDKSidebar />
{/* Main Content */}
<div className="ml-64 flex flex-col min-h-screen">
{/* Header */}
<SDKHeader />
{/* Page Content */}
<main className="flex-1 p-6">{children}</main>
{/* Navigation Footer */}
{showNavigation && <NavigationFooter />}
</div>
{/* Command Bar Modal */}
{isCommandBarOpen && <CommandBar onClose={() => setCommandBarOpen(false)} />}
{/* Pipeline Sidebar (FAB on mobile, fixed on desktop) */}
<SDKPipelineSidebar />
</div>
)
}

View File

@@ -0,0 +1 @@
export { SDKLayout } from './SDKLayout'

View File

@@ -0,0 +1,540 @@
'use client'
/**
* SDK Pipeline Sidebar
*
* Floating Action Button mit Drawer zur Visualisierung der SDK-Pipeline.
* Zeigt die 5 Pakete mit Fortschritt und ermoeglicht schnelle Navigation.
*
* Features:
* - Desktop (xl+): Fixierte Sidebar rechts
* - Mobile/Tablet: Floating Action Button mit Slide-In Drawer
*/
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { useSDK, SDK_STEPS, SDK_PACKAGES, getStepsForPackage, type SDKStep, type SDKPackageId } from '@/lib/sdk'
// =============================================================================
// ICONS
// =============================================================================
const CheckIcon = () => (
<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>
)
const LockIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)
const ArrowIcon = () => (
<svg className="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)
const CloseIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)
const PipelineIcon = () => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
)
// =============================================================================
// STEP ITEM
// =============================================================================
interface StepItemProps {
step: SDKStep
isActive: boolean
isCompleted: boolean
onNavigate: () => void
}
function StepItem({ step, isActive, isCompleted, onNavigate }: StepItemProps) {
return (
<Link
href={step.url}
onClick={onNavigate}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-all ${
isActive
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 font-medium'
: isCompleted
? 'text-green-600 dark:text-green-400 hover:bg-slate-100 dark:hover:bg-gray-800'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
}`}
>
<span className="flex-1 text-sm truncate">{step.nameShort}</span>
{isCompleted && !isActive && (
<span className="flex-shrink-0 w-4 h-4 bg-green-500 text-white rounded-full flex items-center justify-center">
<CheckIcon />
</span>
)}
{isActive && (
<span className="flex-shrink-0 w-2 h-2 bg-purple-500 rounded-full animate-pulse" />
)}
</Link>
)
}
// =============================================================================
// PACKAGE SECTION
// =============================================================================
interface PackageSectionProps {
pkg: (typeof SDK_PACKAGES)[number]
steps: SDKStep[]
completion: number
currentStepId: string
completedSteps: string[]
isLocked: boolean
onNavigate: () => void
isExpanded: boolean
onToggle: () => void
}
function PackageSection({
pkg,
steps,
completion,
currentStepId,
completedSteps,
isLocked,
onNavigate,
isExpanded,
onToggle,
}: PackageSectionProps) {
return (
<div className="space-y-2">
{/* Package Header */}
<button
onClick={onToggle}
disabled={isLocked}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${
isLocked
? 'bg-slate-100 dark:bg-gray-800 opacity-50 cursor-not-allowed'
: 'bg-slate-50 dark:bg-gray-800 hover:bg-slate-100 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center gap-2">
<div
className={`w-7 h-7 rounded-full flex items-center justify-center text-sm ${
isLocked
? 'bg-gray-200 text-gray-400'
: completion === 100
? 'bg-green-500 text-white'
: 'bg-purple-600 text-white'
}`}
>
{isLocked ? <LockIcon /> : completion === 100 ? <CheckIcon /> : pkg.icon}
</div>
<div className="text-left">
<div className={`text-sm font-medium ${isLocked ? 'text-slate-400' : 'text-slate-700 dark:text-slate-200'}`}>
{pkg.order}. {pkg.nameShort}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">{completion}%</div>
</div>
</div>
{!isLocked && (
<svg
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? '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>
{/* Progress Bar */}
{!isLocked && (
<div className="px-3">
<div className="h-1.5 bg-slate-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
completion === 100 ? 'bg-green-500' : 'bg-purple-600'
}`}
style={{ width: `${completion}%` }}
/>
</div>
</div>
)}
{/* Steps List */}
{isExpanded && !isLocked && (
<div className="space-y-1 pl-2">
{steps.map(step => (
<StepItem
key={step.id}
step={step}
isActive={currentStepId === step.id}
isCompleted={completedSteps.includes(step.id)}
onNavigate={onNavigate}
/>
))}
</div>
)}
</div>
)
}
// =============================================================================
// PIPELINE FLOW VISUALIZATION
// =============================================================================
function PipelineFlow() {
return (
<div className="pt-3 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
Datenfluss
</div>
<div className="p-3 bg-slate-50 dark:bg-gray-900 rounded-lg">
<div className="flex flex-col gap-1.5">
{SDK_PACKAGES.map((pkg, idx) => (
<div key={pkg.id} className="flex items-center gap-2 text-xs">
<span className="w-5 h-5 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
{pkg.icon}
</span>
<span className="text-slate-600 dark:text-slate-400 flex-1">{pkg.nameShort}</span>
{idx < SDK_PACKAGES.length - 1 && <ArrowIcon />}
</div>
))}
</div>
</div>
</div>
)
}
// =============================================================================
// SIDEBAR CONTENT
// =============================================================================
interface SidebarContentProps {
onNavigate: () => void
}
function SidebarContent({ onNavigate }: SidebarContentProps) {
const pathname = usePathname()
const { state, packageCompletion } = useSDK()
const [expandedPackages, setExpandedPackages] = useState<Record<SDKPackageId, boolean>>({
'vorbereitung': true,
'analyse': false,
'dokumentation': false,
'rechtliche-texte': false,
'betrieb': false,
})
// Find current step
const currentStep = SDK_STEPS.find(s => s.url === pathname)
const currentStepId = currentStep?.id || ''
// Auto-expand current package
useEffect(() => {
if (currentStep) {
setExpandedPackages(prev => ({
...prev,
[currentStep.package]: true,
}))
}
}, [currentStep])
const togglePackage = (packageId: SDKPackageId) => {
setExpandedPackages(prev => ({ ...prev, [packageId]: !prev[packageId] }))
}
const isPackageLocked = (packageId: SDKPackageId): boolean => {
if (state.preferences?.allowParallelWork) return false
const pkg = SDK_PACKAGES.find(p => p.id === packageId)
if (!pkg || pkg.order === 1) return false
const prevPkg = SDK_PACKAGES.find(p => p.order === pkg.order - 1)
if (!prevPkg) return false
return packageCompletion[prevPkg.id] < 100
}
// Get visible steps based on customer type
const getVisibleSteps = (packageId: SDKPackageId): SDKStep[] => {
const steps = getStepsForPackage(packageId)
return steps.filter(step => {
if (step.id === 'import' && state.customerType === 'new') {
return false
}
return true
})
}
return (
<div className="space-y-4">
{/* Packages */}
{SDK_PACKAGES.map(pkg => (
<PackageSection
key={pkg.id}
pkg={pkg}
steps={getVisibleSteps(pkg.id)}
completion={packageCompletion[pkg.id]}
currentStepId={currentStepId}
completedSteps={state.completedSteps}
isLocked={isPackageLocked(pkg.id)}
onNavigate={onNavigate}
isExpanded={expandedPackages[pkg.id]}
onToggle={() => togglePackage(pkg.id)}
/>
))}
{/* Pipeline Flow */}
<PipelineFlow />
{/* Quick Info */}
{currentStep && (
<div className="pt-3 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-lg">
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong>{' '}
{currentStep.description}
</div>
</div>
)}
</div>
)
}
// =============================================================================
// MAIN COMPONENT - RESPONSIVE
// =============================================================================
export interface SDKPipelineSidebarProps {
/** Position des FAB auf Mobile */
fabPosition?: 'bottom-right' | 'bottom-left'
}
export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipelineSidebarProps) {
const [isMobileOpen, setIsMobileOpen] = useState(false)
const [isDesktopCollapsed, setIsDesktopCollapsed] = useState(true) // Start collapsed
const { completionPercentage } = useSDK()
// Load collapsed state from localStorage
useEffect(() => {
const stored = localStorage.getItem('sdk-pipeline-sidebar-collapsed')
if (stored !== null) {
setIsDesktopCollapsed(stored === 'true')
}
}, [])
// Save collapsed state to localStorage
const toggleDesktopSidebar = () => {
const newState = !isDesktopCollapsed
setIsDesktopCollapsed(newState)
localStorage.setItem('sdk-pipeline-sidebar-collapsed', String(newState))
}
// Close drawer on route change or escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsMobileOpen(false)
setIsDesktopCollapsed(true)
}
}
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [])
// Prevent body scroll when drawer is open
useEffect(() => {
if (isMobileOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isMobileOpen])
const fabPositionClasses = fabPosition === 'bottom-right'
? 'right-4 bottom-20'
: 'left-4 bottom-20'
return (
<>
{/* Desktop: Fixed Sidebar (when expanded) */}
{!isDesktopCollapsed && (
<div className="hidden xl:block fixed right-6 top-24 w-72 z-10">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-slate-200 dark:border-gray-700 overflow-hidden">
{/* Header with close button */}
<div className="px-4 py-3 bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20 border-b border-slate-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-purple-600 dark:text-purple-400">
<PipelineIcon />
</span>
<div>
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
SDK Pipeline
</span>
<span className="ml-2 text-xs text-purple-600 dark:text-purple-400">
{completionPercentage}%
</span>
</div>
</div>
<button
onClick={toggleDesktopSidebar}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Sidebar einklappen"
title="Einklappen"
>
<CloseIcon />
</button>
</div>
</div>
{/* Content */}
<div className="p-3 max-h-[calc(100vh-200px)] overflow-y-auto">
<SidebarContent onNavigate={() => {}} />
</div>
</div>
</div>
)}
{/* Desktop: FAB (when collapsed) */}
{isDesktopCollapsed && (
<button
onClick={toggleDesktopSidebar}
className={`hidden xl:flex fixed right-6 bottom-20 z-40 w-14 h-14 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all items-center justify-center group`}
aria-label="SDK Pipeline Navigation oeffnen"
title="Pipeline anzeigen"
>
<PipelineIcon />
{/* Progress indicator */}
<svg
className="absolute inset-0 w-full h-full -rotate-90"
viewBox="0 0 56 56"
>
<circle
cx="28"
cy="28"
r="26"
fill="none"
stroke="rgba(255,255,255,0.3)"
strokeWidth="2"
/>
<circle
cx="28"
cy="28"
r="26"
fill="none"
stroke="white"
strokeWidth="2"
strokeDasharray={`${(completionPercentage / 100) * 163.36} 163.36`}
strokeLinecap="round"
/>
</svg>
</button>
)}
{/* Mobile/Tablet: FAB */}
<button
onClick={() => setIsMobileOpen(true)}
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
aria-label="SDK Pipeline Navigation oeffnen"
>
<PipelineIcon />
{/* Progress indicator */}
<svg
className="absolute inset-0 w-full h-full -rotate-90"
viewBox="0 0 56 56"
>
<circle
cx="28"
cy="28"
r="26"
fill="none"
stroke="rgba(255,255,255,0.3)"
strokeWidth="2"
/>
<circle
cx="28"
cy="28"
r="26"
fill="none"
stroke="white"
strokeWidth="2"
strokeDasharray={`${(completionPercentage / 100) * 163.36} 163.36`}
strokeLinecap="round"
/>
</svg>
</button>
{/* Mobile/Tablet: Drawer Overlay */}
{isMobileOpen && (
<div className="xl:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
onClick={() => setIsMobileOpen(false)}
/>
{/* Drawer */}
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
{/* Drawer Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20">
<div className="flex items-center gap-2">
<span className="text-purple-600 dark:text-purple-400">
<PipelineIcon />
</span>
<div>
<span className="font-semibold text-slate-700 dark:text-slate-200">
SDK Pipeline
</span>
<span className="ml-2 text-sm text-purple-600 dark:text-purple-400">
{completionPercentage}%
</span>
</div>
</div>
<button
onClick={() => setIsMobileOpen(false)}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Schliessen"
>
<CloseIcon />
</button>
</div>
{/* Drawer Content */}
<div className="p-4 overflow-y-auto max-h-[calc(100vh-80px)]">
<SidebarContent onNavigate={() => setIsMobileOpen(false)} />
</div>
</div>
</div>
)}
{/* CSS for slide-in animation */}
<style jsx>{`
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: slide-in-right 0.2s ease-out;
}
`}</style>
</>
)
}
export default SDKPipelineSidebar

View File

@@ -0,0 +1,2 @@
export { SDKPipelineSidebar } from './SDKPipelineSidebar'
export type { SDKPipelineSidebarProps } from './SDKPipelineSidebar'

View File

@@ -0,0 +1,527 @@
'use client'
import React from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
useSDK,
SDK_STEPS,
SDK_PACKAGES,
getStepsForPackage,
type SDKPackageId,
type SDKStep,
} from '@/lib/sdk'
// =============================================================================
// ICONS
// =============================================================================
const CheckIcon = () => (
<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>
)
const LockIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)
const WarningIcon = () => (
<svg className="w-4 h-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>
)
const ChevronDownIcon = ({ className = '' }: { className?: string }) => (
<svg className={`w-4 h-4 ${className}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)
const CollapseIcon = ({ collapsed }: { collapsed: boolean }) => (
<svg
className={`w-5 h-5 transition-transform duration-300 ${collapsed ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
)
// =============================================================================
// PROGRESS BAR
// =============================================================================
interface ProgressBarProps {
value: number
className?: string
}
function ProgressBar({ value, className = '' }: ProgressBarProps) {
return (
<div className={`h-1 bg-gray-200 rounded-full overflow-hidden ${className}`}>
<div
className="h-full bg-purple-600 rounded-full transition-all duration-500"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
)
}
// =============================================================================
// PACKAGE INDICATOR
// =============================================================================
interface PackageIndicatorProps {
packageId: SDKPackageId
order: number
name: string
icon: string
completion: number
isActive: boolean
isExpanded: boolean
isLocked: boolean
onToggle: () => void
collapsed: boolean
}
function PackageIndicator({
order,
name,
icon,
completion,
isActive,
isExpanded,
isLocked,
onToggle,
collapsed,
}: PackageIndicatorProps) {
if (collapsed) {
return (
<button
onClick={onToggle}
className={`w-full flex items-center justify-center py-3 transition-colors ${
isActive
? 'bg-purple-50 border-l-4 border-purple-600'
: isLocked
? 'border-l-4 border-transparent opacity-50'
: 'hover:bg-gray-50 border-l-4 border-transparent'
}`}
title={`${order}. ${name} (${completion}%)`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-lg ${
isLocked
? 'bg-gray-200 text-gray-400'
: isActive
? 'bg-purple-600 text-white'
: completion === 100
? 'bg-green-500 text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
{isLocked ? <LockIcon /> : completion === 100 ? <CheckIcon /> : icon}
</div>
</button>
)
}
return (
<button
onClick={onToggle}
disabled={isLocked}
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${
isLocked
? 'opacity-50 cursor-not-allowed'
: isActive
? 'bg-purple-50 border-l-4 border-purple-600'
: 'hover:bg-gray-50 border-l-4 border-transparent'
}`}
>
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-lg ${
isLocked
? 'bg-gray-200 text-gray-400'
: isActive
? 'bg-purple-600 text-white'
: completion === 100
? 'bg-green-500 text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
{isLocked ? <LockIcon /> : completion === 100 ? <CheckIcon /> : icon}
</div>
<div>
<div className={`font-medium text-sm ${isActive ? 'text-purple-900' : isLocked ? 'text-gray-400' : 'text-gray-700'}`}>
{order}. {name}
</div>
<div className="text-xs text-gray-500">{completion}%</div>
</div>
</div>
{!isLocked && <ChevronDownIcon className={`transition-transform ${isExpanded ? 'rotate-180' : ''}`} />}
</button>
)
}
// =============================================================================
// STEP ITEM
// =============================================================================
interface StepItemProps {
step: SDKStep
isActive: boolean
isCompleted: boolean
isLocked: boolean
checkpointStatus?: 'passed' | 'failed' | 'warning' | 'pending'
collapsed: boolean
}
function StepItem({ step, isActive, isCompleted, isLocked, checkpointStatus, collapsed }: StepItemProps) {
const content = (
<div
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
collapsed ? 'justify-center' : ''
} ${
isActive
? 'bg-purple-100 text-purple-900 font-medium'
: isLocked
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
title={collapsed ? step.name : undefined}
>
{/* Step indicator */}
<div className="flex-shrink-0">
{isCompleted ? (
<div className="w-5 h-5 rounded-full bg-green-500 text-white flex items-center justify-center">
<CheckIcon />
</div>
) : isLocked ? (
<div className="w-5 h-5 rounded-full bg-gray-200 text-gray-400 flex items-center justify-center">
<LockIcon />
</div>
) : isActive ? (
<div className="w-5 h-5 rounded-full bg-purple-600 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-white" />
</div>
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-300" />
)}
</div>
{/* Step name - hidden when collapsed */}
{!collapsed && <span className="flex-1 truncate">{step.nameShort}</span>}
{/* Checkpoint status - hidden when collapsed */}
{!collapsed && checkpointStatus && checkpointStatus !== 'pending' && (
<div className="flex-shrink-0">
{checkpointStatus === 'passed' ? (
<div className="w-4 h-4 rounded-full bg-green-100 text-green-600 flex items-center justify-center">
<CheckIcon />
</div>
) : checkpointStatus === 'failed' ? (
<div className="w-4 h-4 rounded-full bg-red-100 text-red-600 flex items-center justify-center">
<span className="text-xs font-bold">!</span>
</div>
) : (
<div className="w-4 h-4 rounded-full bg-yellow-100 text-yellow-600 flex items-center justify-center">
<WarningIcon />
</div>
)}
</div>
)}
</div>
)
if (isLocked) {
return content
}
return (
<Link href={step.url} className="block">
{content}
</Link>
)
}
// =============================================================================
// ADDITIONAL MODULE ITEM
// =============================================================================
interface AdditionalModuleItemProps {
href: string
icon: React.ReactNode
label: string
isActive: boolean
collapsed: boolean
}
function AdditionalModuleItem({ href, icon, label, isActive, collapsed }: AdditionalModuleItemProps) {
return (
<Link
href={href}
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
collapsed ? 'justify-center' : ''
} ${
isActive
? 'bg-purple-100 text-purple-900 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
title={collapsed ? label : undefined}
>
{icon}
{!collapsed && <span>{label}</span>}
</Link>
)
}
// =============================================================================
// MAIN SIDEBAR
// =============================================================================
interface SDKSidebarProps {
collapsed?: boolean
onCollapsedChange?: (collapsed: boolean) => void
}
export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarProps) {
const pathname = usePathname()
const { state, packageCompletion, completionPercentage, getCheckpointStatus } = useSDK()
const [expandedPackages, setExpandedPackages] = React.useState<Record<SDKPackageId, boolean>>({
'vorbereitung': true,
'analyse': false,
'dokumentation': false,
'rechtliche-texte': false,
'betrieb': false,
})
// Auto-expand current package
React.useEffect(() => {
const currentStep = SDK_STEPS.find(s => s.url === pathname)
if (currentStep) {
setExpandedPackages(prev => ({
...prev,
[currentStep.package]: true,
}))
}
}, [pathname])
const togglePackage = (packageId: SDKPackageId) => {
setExpandedPackages(prev => ({ ...prev, [packageId]: !prev[packageId] }))
}
const isStepLocked = (step: SDKStep): boolean => {
if (state.preferences?.allowParallelWork) return false
return step.prerequisiteSteps.some(prereq => !state.completedSteps.includes(prereq))
}
const isPackageLocked = (packageId: SDKPackageId): boolean => {
if (state.preferences?.allowParallelWork) return false
const pkg = SDK_PACKAGES.find(p => p.id === packageId)
if (!pkg || pkg.order === 1) return false
// Check if previous package is complete
const prevPkg = SDK_PACKAGES.find(p => p.order === pkg.order - 1)
if (!prevPkg) return false
return packageCompletion[prevPkg.id] < 100
}
const getStepCheckpointStatus = (step: SDKStep): 'passed' | 'failed' | 'warning' | 'pending' => {
const status = getCheckpointStatus(step.checkpointId)
if (!status) return 'pending'
if (status.passed) return 'passed'
if (status.errors.length > 0) return 'failed'
if (status.warnings.length > 0) return 'warning'
return 'pending'
}
const isStepActive = (stepUrl: string) => pathname === stepUrl
const isPackageActive = (packageId: SDKPackageId) => {
const steps = getStepsForPackage(packageId)
return steps.some(s => s.url === pathname)
}
// Filter steps based on customer type
const getVisibleSteps = (packageId: SDKPackageId): SDKStep[] => {
const steps = getStepsForPackage(packageId)
return steps.filter(step => {
// Hide import step for new customers
if (step.id === 'import' && state.customerType === 'new') {
return false
}
return true
})
}
return (
<aside className={`fixed left-0 top-0 h-screen ${collapsed ? 'w-16' : 'w-64'} bg-white border-r border-gray-200 flex flex-col z-40 transition-all duration-300`}>
{/* Header */}
<div className={`p-4 border-b border-gray-200 ${collapsed ? 'flex justify-center' : ''}`}>
<Link href="/sdk" className={`flex items-center gap-3 ${collapsed ? 'justify-center' : ''}`}>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-600 to-indigo-600 flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
{!collapsed && (
<div>
<div className="font-bold text-gray-900">AI Compliance</div>
<div className="text-xs text-gray-500">SDK</div>
</div>
)}
</Link>
</div>
{/* Overall Progress - hidden when collapsed */}
{!collapsed && (
<div className="px-4 py-3 border-b border-gray-100">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-gray-600">Gesamtfortschritt</span>
<span className="font-medium text-purple-600">{completionPercentage}%</span>
</div>
<ProgressBar value={completionPercentage} />
</div>
)}
{/* Navigation - 5 Packages */}
<nav className="flex-1 overflow-y-auto">
{SDK_PACKAGES.map(pkg => {
const steps = getVisibleSteps(pkg.id)
const isLocked = isPackageLocked(pkg.id)
const isActive = isPackageActive(pkg.id)
return (
<div key={pkg.id} className={pkg.order > 1 ? 'border-t border-gray-100' : ''}>
<PackageIndicator
packageId={pkg.id}
order={pkg.order}
name={pkg.name}
icon={pkg.icon}
completion={packageCompletion[pkg.id]}
isActive={isActive}
isExpanded={expandedPackages[pkg.id]}
isLocked={isLocked}
onToggle={() => togglePackage(pkg.id)}
collapsed={collapsed}
/>
{expandedPackages[pkg.id] && !isLocked && (
<div className="py-1">
{steps.map(step => (
<StepItem
key={step.id}
step={step}
isActive={isStepActive(step.url)}
isCompleted={state.completedSteps.includes(step.id)}
isLocked={isStepLocked(step)}
checkpointStatus={getStepCheckpointStatus(step)}
collapsed={collapsed}
/>
))}
</div>
)}
</div>
)
})}
{/* Additional Modules */}
<div className="border-t border-gray-100 py-2">
{!collapsed && (
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
Zusatzmodule
</div>
)}
<AdditionalModuleItem
href="/sdk/rag"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
}
label="Legal RAG"
isActive={pathname === '/sdk/rag'}
collapsed={collapsed}
/>
<AdditionalModuleItem
href="/sdk/quality"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
}
label="AI Quality"
isActive={pathname === '/sdk/quality'}
collapsed={collapsed}
/>
<AdditionalModuleItem
href="/sdk/security-backlog"
icon={
<svg className="w-5 h-5" 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>
}
label="Security Backlog"
isActive={pathname === '/sdk/security-backlog'}
collapsed={collapsed}
/>
</div>
</nav>
{/* Footer */}
<div className={`${collapsed ? 'p-2' : 'p-4'} border-t border-gray-200 bg-gray-50`}>
{/* Collapse Toggle */}
<button
onClick={() => onCollapsedChange?.(!collapsed)}
className={`w-full flex items-center justify-center gap-2 ${collapsed ? 'p-2' : 'px-4 py-2'} text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors ${collapsed ? '' : 'mb-2'}`}
title={collapsed ? 'Sidebar erweitern' : 'Sidebar einklappen'}
>
<CollapseIcon collapsed={collapsed} />
{!collapsed && <span>Einklappen</span>}
</button>
{/* Export Button */}
{!collapsed && (
<button
onClick={() => {}}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm text-purple-600 hover:text-purple-700 hover:bg-purple-50 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
<span>Exportieren</span>
</button>
)}
</div>
</aside>
)
}

View File

@@ -0,0 +1 @@
export { SDKSidebar } from './SDKSidebar'

View File

@@ -0,0 +1,664 @@
'use client'
import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useSDK, getStepById, getNextStep, getPreviousStep, SDKStep } from '@/lib/sdk'
// =============================================================================
// TYPES
// =============================================================================
export interface StepTip {
icon: 'info' | 'warning' | 'success' | 'lightbulb'
title: string
description: string
}
interface StepHeaderProps {
stepId: string
title: string
description: string
explanation: string
tips?: StepTip[]
showNavigation?: boolean
showProgress?: boolean
onComplete?: () => void
isCompleted?: boolean
children?: React.ReactNode
}
// =============================================================================
// ICONS
// =============================================================================
const icons = {
info: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
warning: (
<svg className="w-5 h-5" 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>
),
success: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
lightbulb: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
),
arrowLeft: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
),
arrowRight: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
),
check: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
),
help: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
}
const tipColors = {
info: 'bg-blue-50 border-blue-200 text-blue-800',
warning: 'bg-amber-50 border-amber-200 text-amber-800',
success: 'bg-green-50 border-green-200 text-green-800',
lightbulb: 'bg-purple-50 border-purple-200 text-purple-800',
}
const tipIconColors = {
info: 'text-blue-500',
warning: 'text-amber-500',
success: 'text-green-500',
lightbulb: 'text-purple-500',
}
// =============================================================================
// STEP HEADER COMPONENT
// =============================================================================
export function StepHeader({
stepId,
title,
description,
explanation,
tips = [],
showNavigation = true,
showProgress = true,
onComplete,
isCompleted = false,
children,
}: StepHeaderProps) {
const router = useRouter()
const { state, dispatch } = useSDK()
const [showHelp, setShowHelp] = useState(false)
const currentStep = getStepById(stepId)
const prevStep = getPreviousStep(stepId)
const nextStep = getNextStep(stepId)
const stepCompleted = state.completedSteps.includes(stepId)
const handleComplete = () => {
if (onComplete) {
onComplete()
}
dispatch({ type: 'COMPLETE_STEP', payload: stepId })
if (nextStep) {
router.push(nextStep.url)
}
}
const handleSkip = () => {
if (nextStep) {
router.push(nextStep.url)
}
}
// Calculate step progress within phase
const phaseSteps = currentStep ?
(currentStep.phase === 1 ? 8 : 11) : 0
const stepNumber = currentStep?.order || 0
return (
<div className="space-y-6">
{/* Breadcrumb & Progress */}
{showProgress && currentStep && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-gray-500">
<Link href="/sdk" className="hover:text-purple-600 transition-colors">
SDK
</Link>
<span>/</span>
<span className="text-gray-700">
Phase {currentStep.phase}: {currentStep.phase === 1 ? 'Assessment' : 'Dokumente'}
</span>
<span>/</span>
<span className="text-purple-600 font-medium">{currentStep.nameShort}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-500">Schritt {stepNumber} von {phaseSteps}</span>
<div className="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 rounded-full transition-all"
style={{ width: `${(stepNumber / phaseSteps) * 100}%` }}
/>
</div>
</div>
</div>
)}
{/* Main Header Card */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
{/* Header */}
<div className="p-6 border-b border-gray-100">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
{stepCompleted && (
<span className="flex items-center gap-1 px-2 py-1 text-xs bg-green-100 text-green-700 rounded-full">
{icons.check}
Abgeschlossen
</span>
)}
</div>
<p className="mt-2 text-gray-500">{description}</p>
</div>
<button
onClick={() => setShowHelp(!showHelp)}
className={`p-2 rounded-lg transition-colors ${
showHelp ? 'bg-purple-100 text-purple-600' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-600'
}`}
title="Hilfe anzeigen"
>
{icons.help}
</button>
</div>
</div>
{/* Explanation Panel (collapsible) */}
{showHelp && (
<div className="px-6 py-4 bg-gradient-to-r from-purple-50 to-indigo-50 border-b border-purple-100">
<div className="flex items-start gap-3">
<div className="p-2 bg-purple-100 rounded-lg text-purple-600">
{icons.lightbulb}
</div>
<div>
<h3 className="font-medium text-purple-900">Was ist das?</h3>
<p className="mt-1 text-sm text-purple-800">{explanation}</p>
</div>
</div>
</div>
)}
{/* Tips */}
{tips.length > 0 && (
<div className="px-6 py-4 space-y-3 bg-gray-50 border-b border-gray-100">
{tips.map((tip, index) => (
<div
key={index}
className={`flex items-start gap-3 p-3 rounded-lg border ${tipColors[tip.icon]}`}
>
<div className={tipIconColors[tip.icon]}>
{icons[tip.icon]}
</div>
<div>
<h4 className="font-medium text-sm">{tip.title}</h4>
<p className="text-sm opacity-80">{tip.description}</p>
</div>
</div>
))}
</div>
)}
{/* Action Buttons */}
{children && (
<div className="px-6 py-4 bg-gray-50">
{children}
</div>
)}
</div>
{/* Navigation */}
{showNavigation && (
<div className="flex items-center justify-between">
<div>
{prevStep ? (
<Link
href={prevStep.url}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
{icons.arrowLeft}
<span>Zurueck: {prevStep.nameShort}</span>
</Link>
) : (
<Link
href="/sdk"
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
{icons.arrowLeft}
<span>Zur Uebersicht</span>
</Link>
)}
</div>
<div className="flex items-center gap-3">
{nextStep && !stepCompleted && (
<button
onClick={handleSkip}
className="px-4 py-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Ueberspringen
</button>
)}
{nextStep ? (
<button
onClick={handleComplete}
className="flex items-center gap-2 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<span>{stepCompleted ? 'Weiter' : 'Abschliessen & Weiter'}</span>
{icons.arrowRight}
</button>
) : (
<button
onClick={handleComplete}
className="flex items-center gap-2 px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
{icons.check}
<span>Phase abschliessen</span>
</button>
)}
</div>
</div>
)}
</div>
)
}
// =============================================================================
// STEP EXPLANATION PRESETS
// =============================================================================
export const STEP_EXPLANATIONS = {
'company-profile': {
title: 'Unternehmensprofil',
description: 'Erfassen Sie Ihr Geschäftsmodell und Ihre Zielmärkte',
explanation: 'Im Unternehmensprofil erfassen wir grundlegende Informationen zu Ihrem Unternehmen: Geschäftsmodell (B2B/B2C), Angebote, Firmengröße und Zielmärkte. Diese Informationen helfen uns, die für Sie relevanten Regulierungen zu identifizieren und ehrlich zu kommunizieren, wo unsere Grenzen liegen.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Ehrliche Einschätzung',
description: 'Wir zeigen Ihnen transparent, welche Regulierungen wir abdecken und wann Sie einen Anwalt hinzuziehen sollten.',
},
{
icon: 'info' as const,
title: 'Zielmärkte',
description: 'Je nach Zielmarkt (Deutschland, DACH, EU, weltweit) gelten unterschiedliche Datenschutzgesetze.',
},
],
},
'use-case-assessment': {
title: 'Anwendungsfall-Erfassung',
description: 'Erfassen Sie Ihre KI-Anwendungsfälle systematisch',
explanation: 'In der Anwendungsfall-Erfassung dokumentieren Sie Ihre KI-Anwendungsfälle in 5 Schritten: Grunddaten, Datenkategorien, Risikobewertung, Stakeholder und Compliance-Anforderungen. Dies bildet die Basis für alle weiteren Compliance-Maßnahmen.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Tipp: Vollständigkeit',
description: 'Je detaillierter Sie den Anwendungsfall beschreiben, desto besser kann das System passende Compliance-Anforderungen ableiten.',
},
{
icon: 'info' as const,
title: 'Mehrere Anwendungsfälle',
description: 'Sie können mehrere Anwendungsfälle erfassen. Jeder wird separat bewertet und durchläuft den Compliance-Prozess.',
},
],
},
'screening': {
title: 'System Screening',
description: 'Analysieren Sie Ihre Systemlandschaft auf Schwachstellen',
explanation: 'Das System Screening generiert eine Software Bill of Materials (SBOM) und fuehrt einen Security-Scan durch. So erkennen Sie Schwachstellen in Ihren Abhaengigkeiten fruehzeitig.',
tips: [
{
icon: 'warning' as const,
title: 'Kritische Schwachstellen',
description: 'CVEs mit CVSS >= 7.0 sollten priorisiert behandelt werden. Diese werden automatisch in den Security Backlog uebernommen.',
},
{
icon: 'info' as const,
title: 'SBOM-Format',
description: 'Die SBOM wird im CycloneDX-Format generiert und kann fuer Audits exportiert werden.',
},
],
},
'modules': {
title: 'Compliance Module',
description: 'Waehlen Sie die relevanten Regulierungen fuer Ihr Unternehmen',
explanation: 'Compliance-Module sind vordefinierte Regelwerke (z.B. DSGVO, AI Act, ISO 27001). Durch die Aktivierung eines Moduls werden automatisch die zugehoerigen Anforderungen und Kontrollen geladen.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Modul-Auswahl',
description: 'Aktivieren Sie nur Module, die fuer Ihr Unternehmen relevant sind. Weniger ist oft mehr - fokussieren Sie sich auf die wichtigsten Regulierungen.',
},
{
icon: 'info' as const,
title: 'Abhaengigkeiten',
description: 'Manche Module haben Ueberschneidungen. Das System erkennt dies automatisch und vermeidet doppelte Anforderungen.',
},
],
},
'requirements': {
title: 'Anforderungen',
description: 'Pruefen und verwalten Sie die Compliance-Anforderungen',
explanation: 'Anforderungen sind konkrete Vorgaben aus den aktivierten Modulen. Jede Anforderung verweist auf einen Gesetzesartikel und muss durch Kontrollen abgedeckt werden.',
tips: [
{
icon: 'warning' as const,
title: 'Kritische Anforderungen',
description: 'Anforderungen mit Kritikalitaet "HOCH" sollten priorisiert werden, da Verstoesse zu hohen Bussgeldern fuehren koennen.',
},
{
icon: 'success' as const,
title: 'Status-Workflow',
description: 'Anforderungen durchlaufen: Nicht begonnen → In Bearbeitung → Implementiert → Verifiziert.',
},
],
},
'controls': {
title: 'Kontrollen',
description: 'Definieren Sie technische und organisatorische Massnahmen',
explanation: 'Kontrollen (auch TOMs genannt) sind konkrete Massnahmen zur Erfuellung der Anforderungen. Sie koennen praeventiv, detektiv oder korrektiv sein.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Wirksamkeit',
description: 'Bewerten Sie die Wirksamkeit jeder Kontrolle. Eine hohe Wirksamkeit (>80%) reduziert das Restrisiko erheblich.',
},
{
icon: 'info' as const,
title: 'Verantwortlichkeiten',
description: 'Weisen Sie jeder Kontrolle einen Verantwortlichen zu. Dies ist fuer Audits wichtig.',
},
],
},
'evidence': {
title: 'Nachweise',
description: 'Dokumentieren Sie die Umsetzung mit Belegen',
explanation: 'Nachweise sind Dokumente, Screenshots oder Berichte, die belegen, dass Kontrollen implementiert sind. Sie sind essentiell fuer Audits und Zertifizierungen.',
tips: [
{
icon: 'warning' as const,
title: 'Gueltigkeit',
description: 'Achten Sie auf das Ablaufdatum von Nachweisen. Abgelaufene Zertifikate oder Berichte muessen erneuert werden.',
},
{
icon: 'success' as const,
title: 'Verknuepfung',
description: 'Verknuepfen Sie Nachweise direkt mit den zugehoerigen Kontrollen fuer eine lueckenlose Dokumentation.',
},
],
},
'audit-checklist': {
title: 'Audit-Checkliste',
description: 'Systematische Pruefung der Compliance-Konformitaet',
explanation: 'Die Audit-Checkliste wird automatisch aus den Anforderungen generiert. Gehen Sie jeden Punkt durch und dokumentieren Sie den Compliance-Status.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Regelmaessige Pruefung',
description: 'Fuehren Sie die Checkliste mindestens jaehrlich durch, um Compliance-Luecken fruehzeitig zu erkennen.',
},
{
icon: 'info' as const,
title: 'Notizen',
description: 'Nutzen Sie das Notizfeld, um Massnahmen oder Abweichungen zu dokumentieren.',
},
],
},
'risks': {
title: 'Risiko-Matrix',
description: 'Bewerten und priorisieren Sie Ihre Compliance-Risiken',
explanation: 'Die 5x5 Risiko-Matrix visualisiert Ihre Risiken nach Wahrscheinlichkeit und Auswirkung. Risiken mit hohem Score erfordern Mitigationsmassnahmen.',
tips: [
{
icon: 'warning' as const,
title: 'Kritische Risiken',
description: 'Risiken im roten Bereich (Score >= 15) muessen priorisiert behandelt werden.',
},
{
icon: 'success' as const,
title: 'Mitigation',
description: 'Definieren Sie fuer jedes Risiko Mitigationsmassnahmen. Abgeschlossene Massnahmen reduzieren den Residual-Risk.',
},
],
},
'ai-act': {
title: 'AI Act Klassifizierung',
description: 'Bestimmen Sie die Risikostufe Ihrer KI-Systeme',
explanation: 'Der EU AI Act klassifiziert KI-Systeme in Risikostufen: Minimal, Begrenzt, Hoch und Unzulaessig. Die Einstufung bestimmt die regulatorischen Anforderungen.',
tips: [
{
icon: 'warning' as const,
title: 'Hochrisiko-Systeme',
description: 'Hochrisiko-KI-Systeme unterliegen strengen Anforderungen bezueglich Transparenz, Dokumentation und menschlicher Aufsicht.',
},
{
icon: 'info' as const,
title: 'Deadline',
description: 'Die AI Act Anforderungen treten schrittweise in Kraft. Beginnen Sie fruehzeitig mit der Compliance.',
},
],
},
'dsfa': {
title: 'Datenschutz-Folgenabschaetzung',
description: 'Erstellen Sie eine DSFA fuer Hochrisiko-Verarbeitungen',
explanation: 'Eine DSFA (Art. 35 DSGVO) ist erforderlich, wenn eine Verarbeitung voraussichtlich hohe Risiken fuer Betroffene birgt. Das Tool fuehrt Sie durch alle erforderlichen Abschnitte.',
tips: [
{
icon: 'warning' as const,
title: 'Pflicht',
description: 'Eine DSFA ist Pflicht bei: Profiling mit rechtlicher Wirkung, umfangreicher Verarbeitung besonderer Datenkategorien, systematischer Ueberwachung.',
},
{
icon: 'lightbulb' as const,
title: 'Konsultation',
description: 'Bei hohem Restrisiko muss die Aufsichtsbehoerde konsultiert werden (Art. 36 DSGVO).',
},
],
},
'tom': {
title: 'Technische und Organisatorische Massnahmen',
description: 'Dokumentieren Sie Ihre TOMs nach Art. 32 DSGVO',
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Sie umfassen Zutrittskontrolle, Zugangskontrolle, Zugriffskontrolle und mehr.',
tips: [
{
icon: 'info' as const,
title: 'Kategorien',
description: 'TOMs werden in technische (z.B. Verschluesselung) und organisatorische (z.B. Schulungen) Massnahmen unterteilt.',
},
{
icon: 'success' as const,
title: 'Nachweis',
description: 'Dokumentieren Sie fuer jede TOM einen Nachweis der Umsetzung.',
},
],
},
'vvt': {
title: 'Verarbeitungsverzeichnis',
description: 'Erstellen Sie Ihr Verzeichnis nach Art. 30 DSGVO',
explanation: 'Das Verarbeitungsverzeichnis dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Es ist fuer die meisten Unternehmen Pflicht.',
tips: [
{
icon: 'warning' as const,
title: 'Vollstaendigkeit',
description: 'Das VVT muss alle Verarbeitungen enthalten. Fehlende Eintraege koennen bei Audits zu Beanstandungen fuehren.',
},
{
icon: 'info' as const,
title: 'Pflichtangaben',
description: 'Jeder Eintrag muss enthalten: Zweck, Datenkategorien, Empfaenger, Loeschfristen, TOMs.',
},
],
},
'cookie-banner': {
title: 'Cookie Banner',
description: 'Generieren Sie einen DSGVO-konformen Cookie Banner',
explanation: 'Der Cookie Banner Generator erstellt einen rechtssicheren Banner mit Opt-In fuer nicht-essentielle Cookies. Der generierte Code kann direkt eingebunden werden.',
tips: [
{
icon: 'warning' as const,
title: 'Opt-In Pflicht',
description: 'Fuer Marketing- und Analytics-Cookies ist eine aktive Einwilligung erforderlich. Vorangekreuzte Checkboxen sind nicht erlaubt.',
},
{
icon: 'lightbulb' as const,
title: 'Design',
description: 'Der Banner kann an Ihr Corporate Design angepasst werden.',
},
],
},
'obligations': {
title: 'Pflichtenuebersicht',
description: 'Alle regulatorischen Pflichten auf einen Blick',
explanation: 'Die Pflichtenuebersicht aggregiert alle Anforderungen aus DSGVO, AI Act, NIS2 und weiteren Regulierungen. Sie sehen auf einen Blick, welche Pflichten fuer Ihr Unternehmen gelten.',
tips: [
{
icon: 'info' as const,
title: 'Filterung',
description: 'Filtern Sie nach Regulierung, Prioritaet oder Status, um die relevanten Pflichten schnell zu finden.',
},
{
icon: 'warning' as const,
title: 'Fristen',
description: 'Achten Sie auf die Umsetzungsfristen. Einige Pflichten haben feste Deadlines.',
},
],
},
'loeschfristen': {
title: 'Loeschfristen',
description: 'Definieren Sie Aufbewahrungsrichtlinien fuer Ihre Daten',
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Nach Ablauf muessen die Daten geloescht oder anonymisiert werden.',
tips: [
{
icon: 'warning' as const,
title: 'Gesetzliche Fristen',
description: 'Beachten Sie gesetzliche Aufbewahrungspflichten (z.B. Steuerrecht: 10 Jahre, Handelsrecht: 6 Jahre).',
},
{
icon: 'lightbulb' as const,
title: 'Automatisierung',
description: 'Richten Sie automatische Loeschprozesse ein, um Compliance sicherzustellen.',
},
],
},
'consent': {
title: 'Rechtliche Vorlagen',
description: 'Generieren Sie AGB, Datenschutzerklaerung und Nutzungsbedingungen',
explanation: 'Die rechtlichen Vorlagen werden basierend auf Ihren Verarbeitungstaetigkeiten und Use Cases generiert. Sie sind auf Ihre spezifische Situation zugeschnitten.',
tips: [
{
icon: 'info' as const,
title: 'Anpassung',
description: 'Die generierten Vorlagen koennen und sollten an Ihre spezifischen Anforderungen angepasst werden.',
},
{
icon: 'warning' as const,
title: 'Rechtspruefung',
description: 'Lassen Sie die finalen Dokumente von einem Rechtsanwalt pruefen, bevor Sie sie veroeffentlichen.',
},
],
},
'einwilligungen': {
title: 'Einwilligungen',
description: 'Verwalten Sie Consent-Tracking und Einwilligungsnachweise',
explanation: 'Hier konfigurieren Sie, wie Einwilligungen erfasst, gespeichert und nachgewiesen werden. Dies ist essentiell fuer den Nachweis der Rechtmaessigkeit.',
tips: [
{
icon: 'success' as const,
title: 'Nachweis',
description: 'Speichern Sie fuer jede Einwilligung: Zeitpunkt, Version des Textes, Art der Einwilligung.',
},
{
icon: 'info' as const,
title: 'Widerruf',
description: 'Stellen Sie sicher, dass Nutzer ihre Einwilligung jederzeit widerrufen koennen.',
},
],
},
'dsr': {
title: 'DSR Portal',
description: 'Richten Sie ein Portal fuer Betroffenenrechte ein',
explanation: 'Das DSR (Data Subject Rights) Portal ermoeglicht Betroffenen, ihre Rechte nach DSGVO auszuueben: Auskunft, Loeschung, Berichtigung, Datenportabilitaet.',
tips: [
{
icon: 'warning' as const,
title: 'Fristen',
description: 'Anfragen muessen innerhalb von 30 Tagen beantwortet werden. Richten Sie Workflows ein, um dies sicherzustellen.',
},
{
icon: 'lightbulb' as const,
title: 'Identitaetspruefung',
description: 'Implementieren Sie eine sichere Identitaetspruefung, bevor Sie Daten herausgeben.',
},
],
},
'escalations': {
title: 'Eskalations-Workflows',
description: 'Definieren Sie Management-Workflows fuer Compliance-Vorfaelle',
explanation: 'Eskalations-Workflows legen fest, wie auf Compliance-Vorfaelle reagiert wird: Wer wird informiert, welche Massnahmen werden ergriffen, wie wird dokumentiert.',
tips: [
{
icon: 'info' as const,
title: 'Datenpannen',
description: 'Bei Datenpannen muss die Aufsichtsbehoerde innerhalb von 72 Stunden informiert werden.',
},
{
icon: 'success' as const,
title: 'Verantwortlichkeiten',
description: 'Definieren Sie klare Verantwortlichkeiten fuer jeden Schritt im Eskalationsprozess.',
},
],
},
'document-generator': {
title: 'Dokumentengenerator',
description: 'Generieren Sie rechtliche Dokumente aus lizenzkonformen Vorlagen',
explanation: 'Der Dokumentengenerator nutzt frei lizenzierte Textbausteine (CC0, MIT, CC BY 4.0) um Datenschutzerklaerungen, AGB, Cookie-Banner und andere rechtliche Dokumente zu erstellen. Die Quellen werden mit korrekter Lizenz-Compliance und Attribution gehandhabt.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Lizenzfreie Vorlagen',
description: 'Alle verwendeten Textbausteine stammen aus lizenzierten Quellen (CC0, MIT, CC BY 4.0). Die Attribution wird automatisch hinzugefuegt.',
},
{
icon: 'info' as const,
title: 'Platzhalter',
description: 'Fuellen Sie die Platzhalter (z.B. [FIRMENNAME], [ADRESSE]) mit Ihren Unternehmensdaten aus.',
},
{
icon: 'warning' as const,
title: 'Rechtspruefung',
description: 'Lassen Sie generierte Dokumente vor der Veroeffentlichung von einem Rechtsanwalt pruefen.',
},
],
},
}
export default StepHeader

View File

@@ -0,0 +1,2 @@
export { StepHeader, STEP_EXPLANATIONS } from './StepHeader'
export type { StepTip } from './StepHeader'

View File

@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest'
import { STEP_EXPLANATIONS } from '../StepHeader'
// Focus on testing the STEP_EXPLANATIONS data structure
// Component tests require more complex SDK context mocking
describe('STEP_EXPLANATIONS', () => {
it('should have explanations for all Phase 1 steps', () => {
const phase1Steps = [
'use-case-workshop',
'screening',
'modules',
'requirements',
'controls',
'evidence',
'audit-checklist',
'risks',
]
phase1Steps.forEach(stepId => {
expect(STEP_EXPLANATIONS[stepId]).toBeDefined()
expect(STEP_EXPLANATIONS[stepId].title).toBeDefined()
expect(STEP_EXPLANATIONS[stepId].title.length).toBeGreaterThan(0)
expect(STEP_EXPLANATIONS[stepId].description).toBeDefined()
expect(STEP_EXPLANATIONS[stepId].description.length).toBeGreaterThan(0)
expect(STEP_EXPLANATIONS[stepId].explanation).toBeDefined()
expect(STEP_EXPLANATIONS[stepId].explanation.length).toBeGreaterThan(0)
})
})
it('should have explanations for all Phase 2 steps', () => {
const phase2Steps = [
'ai-act',
'obligations',
'dsfa',
'tom',
'loeschfristen',
'vvt',
'consent',
'cookie-banner',
'einwilligungen',
'dsr',
'escalations',
]
phase2Steps.forEach(stepId => {
expect(STEP_EXPLANATIONS[stepId]).toBeDefined()
expect(STEP_EXPLANATIONS[stepId].title).toBeDefined()
expect(STEP_EXPLANATIONS[stepId].description).toBeDefined()
})
})
it('should have tips array for each step explanation', () => {
Object.entries(STEP_EXPLANATIONS).forEach(([stepId, explanation]) => {
expect(explanation.tips).toBeDefined()
expect(Array.isArray(explanation.tips)).toBe(true)
expect(explanation.tips.length).toBeGreaterThan(0)
})
})
it('should have valid tip icons', () => {
const validIcons = ['info', 'warning', 'success', 'lightbulb']
Object.entries(STEP_EXPLANATIONS).forEach(([stepId, explanation]) => {
explanation.tips.forEach((tip, tipIndex) => {
expect(validIcons).toContain(tip.icon)
expect(tip.title).toBeDefined()
expect(tip.title.length).toBeGreaterThan(0)
expect(tip.description).toBeDefined()
expect(tip.description.length).toBeGreaterThan(0)
})
})
})
it('should have a use-case-workshop explanation', () => {
const ucWorkshop = STEP_EXPLANATIONS['use-case-workshop']
expect(ucWorkshop.title).toContain('Use Case')
expect(ucWorkshop.tips.length).toBeGreaterThanOrEqual(2)
})
it('should have a risks explanation', () => {
const risks = STEP_EXPLANATIONS['risks']
expect(risks.title).toBeDefined()
expect(risks.explanation).toContain('Risiko')
})
it('should have a dsfa explanation', () => {
const dsfa = STEP_EXPLANATIONS['dsfa']
expect(dsfa.title).toContain('Datenschutz')
expect(dsfa.explanation.length).toBeGreaterThan(50)
})
it('should cover all 19 SDK steps', () => {
const allStepIds = [
// Phase 1
'use-case-workshop',
'screening',
'modules',
'requirements',
'controls',
'evidence',
'audit-checklist',
'risks',
// Phase 2
'ai-act',
'obligations',
'dsfa',
'tom',
'loeschfristen',
'vvt',
'consent',
'cookie-banner',
'einwilligungen',
'dsr',
'escalations',
]
expect(Object.keys(STEP_EXPLANATIONS).length).toBe(allStepIds.length)
allStepIds.forEach(stepId => {
expect(STEP_EXPLANATIONS[stepId]).toBeDefined()
})
})
})

View File

@@ -0,0 +1,288 @@
'use client'
import React, { useState } from 'react'
import {
DSRCommunication,
CommunicationType,
CommunicationChannel,
DSRSendCommunicationRequest
} from '@/lib/sdk/dsr/types'
interface DSRCommunicationLogProps {
communications: DSRCommunication[]
onSendMessage?: (message: DSRSendCommunicationRequest) => Promise<void>
isLoading?: boolean
}
const CHANNEL_ICONS: Record<CommunicationChannel, string> = {
email: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
letter: 'M3 19v-8.93a2 2 0 01.89-1.664l7-4.666a2 2 0 012.22 0l7 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5',
phone: 'M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z',
portal: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
internal_note: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z'
}
const TYPE_COLORS: Record<CommunicationType, { bg: string; border: string; icon: string }> = {
incoming: { bg: 'bg-blue-50', border: 'border-blue-200', icon: 'text-blue-600' },
outgoing: { bg: 'bg-green-50', border: 'border-green-200', icon: 'text-green-600' },
internal: { bg: 'bg-gray-50', border: 'border-gray-200', icon: 'text-gray-600' }
}
const CHANNEL_LABELS: Record<CommunicationChannel, string> = {
email: 'E-Mail',
letter: 'Brief',
phone: 'Telefon',
portal: 'Portal',
internal_note: 'Interne Notiz'
}
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
export function DSRCommunicationLog({
communications,
onSendMessage,
isLoading = false
}: DSRCommunicationLogProps) {
const [showComposeForm, setShowComposeForm] = useState(false)
const [newMessage, setNewMessage] = useState<DSRSendCommunicationRequest>({
type: 'outgoing',
channel: 'email',
subject: '',
content: ''
})
const [isSending, setIsSending] = useState(false)
const sortedCommunications = [...communications].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
const handleSendMessage = async () => {
if (!onSendMessage || !newMessage.content.trim()) return
setIsSending(true)
try {
await onSendMessage(newMessage)
setNewMessage({ type: 'outgoing', channel: 'email', subject: '', content: '' })
setShowComposeForm(false)
} catch (error) {
console.error('Failed to send message:', error)
} finally {
setIsSending(false)
}
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Kommunikation</h3>
{onSendMessage && (
<button
onClick={() => setShowComposeForm(!showComposeForm)}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Nachricht
</button>
)}
</div>
{/* Compose Form */}
{showComposeForm && (
<div className="bg-white rounded-xl border border-gray-200 p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
<select
value={newMessage.type}
onChange={(e) => setNewMessage({ ...newMessage, type: e.target.value as CommunicationType })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="outgoing">Ausgehend</option>
<option value="incoming">Eingehend</option>
<option value="internal">Interne Notiz</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kanal</label>
<select
value={newMessage.channel}
onChange={(e) => setNewMessage({ ...newMessage, channel: e.target.value as CommunicationChannel })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="email">E-Mail</option>
<option value="letter">Brief</option>
<option value="phone">Telefon</option>
<option value="portal">Portal</option>
<option value="internal_note">Interne Notiz</option>
</select>
</div>
</div>
{newMessage.type !== 'internal' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Betreff</label>
<input
type="text"
value={newMessage.subject || ''}
onChange={(e) => setNewMessage({ ...newMessage, subject: e.target.value })}
placeholder="Betreff eingeben..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Inhalt</label>
<textarea
value={newMessage.content}
onChange={(e) => setNewMessage({ ...newMessage, content: e.target.value })}
placeholder="Nachricht eingeben..."
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none"
/>
</div>
<div className="flex items-center justify-end gap-3">
<button
onClick={() => setShowComposeForm(false)}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={handleSendMessage}
disabled={!newMessage.content.trim() || isSending}
className={`
px-4 py-2 rounded-lg font-medium transition-colors
${newMessage.content.trim() && !isSending
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}
`}
>
{isSending ? 'Sende...' : 'Speichern'}
</button>
</div>
</div>
)}
{/* Communication Timeline */}
{isLoading ? (
<div className="flex items-center justify-center py-8">
<svg className="animate-spin w-6 h-6 text-purple-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : sortedCommunications.length === 0 ? (
<div className="bg-gray-50 rounded-xl p-8 text-center">
<div className="w-12 h-12 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<p className="text-gray-500">Noch keine Kommunikation vorhanden</p>
</div>
) : (
<div className="relative">
{/* Timeline Line */}
<div className="absolute left-5 top-0 bottom-0 w-0.5 bg-gray-200" />
{/* Timeline Items */}
<div className="space-y-4">
{sortedCommunications.map((comm) => {
const colors = TYPE_COLORS[comm.type]
return (
<div key={comm.id} className="relative pl-12">
{/* Timeline Dot */}
<div className={`
absolute left-3 w-5 h-5 rounded-full border-2 ${colors.border} ${colors.bg}
flex items-center justify-center
`}>
<svg className={`w-3 h-3 ${colors.icon}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={CHANNEL_ICONS[comm.channel]} />
</svg>
</div>
{/* Content Card */}
<div className={`${colors.bg} border ${colors.border} rounded-xl p-4`}>
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div>
<div className="flex items-center gap-2">
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${colors.bg} ${colors.icon} border ${colors.border}`}>
{comm.type === 'incoming' ? 'Eingehend' :
comm.type === 'outgoing' ? 'Ausgehend' : 'Intern'}
</span>
<span className="text-xs text-gray-500">
{CHANNEL_LABELS[comm.channel]}
</span>
</div>
{comm.subject && (
<div className="font-medium text-gray-900 mt-1">
{comm.subject}
</div>
)}
</div>
<div className="text-xs text-gray-500">
{formatDate(comm.createdAt)}
</div>
</div>
{/* Content */}
<div className="text-sm text-gray-700 whitespace-pre-wrap">
{comm.content}
</div>
{/* Attachments */}
{comm.attachments && comm.attachments.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200">
<div className="text-xs text-gray-500 mb-2">Anhaenge:</div>
<div className="flex flex-wrap gap-2">
{comm.attachments.map((attachment, idx) => (
<a
key={idx}
href={attachment.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-2 py-1 bg-white rounded border border-gray-200 text-xs text-gray-600 hover:bg-gray-50"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
{attachment.name}
</a>
))}
</div>
</div>
)}
{/* Footer */}
{comm.sentBy && (
<div className="mt-2 text-xs text-gray-500">
Von: {comm.sentBy}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,304 @@
'use client'
import React, { useState } from 'react'
import { DSRDataExport, DSRType } from '@/lib/sdk/dsr/types'
interface DSRDataExportProps {
dsrId: string
dsrType: DSRType // 'access' or 'portability'
existingExport?: DSRDataExport
onGenerate?: (format: 'json' | 'csv' | 'xml' | 'pdf') => Promise<void>
onDownload?: () => Promise<void>
isGenerating?: boolean
}
const FORMAT_OPTIONS: {
value: 'json' | 'csv' | 'xml' | 'pdf'
label: string
description: string
icon: string
recommended?: boolean
}[] = [
{
value: 'json',
label: 'JSON',
description: 'Maschinenlesbar, ideal fuer technische Uebertragung',
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
recommended: true
},
{
value: 'csv',
label: 'CSV',
description: 'Tabellen-Format, mit Excel oeffenbar',
icon: 'M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z'
},
{
value: 'xml',
label: 'XML',
description: 'Strukturiertes Format fuer System-Integration',
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4'
},
{
value: 'pdf',
label: 'PDF',
description: 'Menschenlesbar, ideal fuer direkte Zusendung',
icon: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z'
}
]
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
export function DSRDataExportComponent({
dsrId,
dsrType,
existingExport,
onGenerate,
onDownload,
isGenerating = false
}: DSRDataExportProps) {
const [selectedFormat, setSelectedFormat] = useState<'json' | 'csv' | 'xml' | 'pdf'>(
dsrType === 'portability' ? 'json' : 'pdf'
)
const [includeThirdParty, setIncludeThirdParty] = useState(true)
const [transferRecipient, setTransferRecipient] = useState('')
const [showTransferSection, setShowTransferSection] = useState(false)
const isPortability = dsrType === 'portability'
const handleGenerate = async () => {
if (onGenerate) {
await onGenerate(selectedFormat)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h3 className="text-lg font-semibold text-gray-900">
{isPortability ? 'Datenexport (Art. 20)' : 'Datenauskunft (Art. 15)'}
</h3>
<p className="text-sm text-gray-500 mt-1">
{isPortability
? 'Exportieren Sie die Daten in einem maschinenlesbaren Format zur Uebertragung'
: 'Erstellen Sie eine Uebersicht aller gespeicherten personenbezogenen Daten'
}
</p>
</div>
{/* Existing Export */}
{existingExport && existingExport.generatedAt && (
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-green-800">Export vorhanden</div>
<div className="text-sm text-green-700 mt-1 space-y-1">
<div>Format: <span className="font-medium">{existingExport.format.toUpperCase()}</span></div>
<div>Erstellt: <span className="font-medium">{formatDate(existingExport.generatedAt)}</span></div>
{existingExport.fileName && (
<div>Datei: <span className="font-medium">{existingExport.fileName}</span></div>
)}
{existingExport.fileSize && (
<div>Groesse: <span className="font-medium">{formatFileSize(existingExport.fileSize)}</span></div>
)}
</div>
</div>
{onDownload && (
<button
onClick={onDownload}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Herunterladen
</button>
)}
</div>
</div>
)}
{/* Format Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Export-Format waehlen
</label>
<div className="grid grid-cols-2 gap-3">
{FORMAT_OPTIONS.map(format => (
<button
key={format.value}
onClick={() => setSelectedFormat(format.value)}
className={`
p-4 rounded-xl border-2 text-left transition-all
${selectedFormat === format.value
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}
`}
>
<div className="flex items-start gap-3">
<div className={`
w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0
${selectedFormat === format.value ? 'bg-purple-100' : 'bg-gray-100'}
`}>
<svg
className={`w-4 h-4 ${selectedFormat === format.value ? 'text-purple-600' : 'text-gray-500'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={format.icon} />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`font-medium ${selectedFormat === format.value ? 'text-purple-700' : 'text-gray-900'}`}>
{format.label}
</span>
{format.recommended && isPortability && (
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">
Empfohlen
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{format.description}
</div>
</div>
</div>
</button>
))}
</div>
</div>
{/* Options */}
<div className="space-y-4">
{/* Include Third-Party Data */}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={includeThirdParty}
onChange={(e) => setIncludeThirdParty(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900">Drittanbieter-Daten einbeziehen</div>
<div className="text-sm text-gray-500">
Daten von externen Diensten (Google Analytics, etc.) mit exportieren
</div>
</div>
</label>
{/* Data Transfer (Art. 20 only) */}
{isPortability && (
<div className="pt-2">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={showTransferSection}
onChange={(e) => setShowTransferSection(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900">Direkte Uebertragung an Dritten</div>
<div className="text-sm text-gray-500">
Daten direkt an einen anderen Verantwortlichen uebertragen
</div>
</div>
</label>
{showTransferSection && (
<div className="mt-3 ml-8 p-4 bg-gray-50 rounded-xl">
<label className="block text-sm font-medium text-gray-700 mb-1">
Empfaenger der Daten
</label>
<input
type="text"
value={transferRecipient}
onChange={(e) => setTransferRecipient(e.target.value)}
placeholder="Name des Unternehmens oder E-Mail-Adresse"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<p className="mt-2 text-xs text-gray-500">
Die Daten werden an den angegebenen Empfaenger uebermittelt,
sofern dies technisch machbar ist.
</p>
</div>
)}
</div>
)}
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-700">
<p className="font-medium">Enthaltene Datenkategorien</p>
<ul className="mt-2 space-y-1 list-disc list-inside">
<li>Stammdaten (Name, E-Mail, Adresse)</li>
<li>Nutzungsdaten (Login-Historie, Aktivitaeten)</li>
<li>Kommunikationsdaten (E-Mails, Support-Anfragen)</li>
{includeThirdParty && <li>Tracking-Daten (Analytics, Cookies)</li>}
</ul>
</div>
</div>
</div>
{/* Generate Button */}
{onGenerate && (
<div className="flex items-center justify-end gap-3">
<button
onClick={handleGenerate}
disabled={isGenerating}
className={`
px-6 py-2.5 rounded-lg font-medium transition-colors flex items-center gap-2
${!isGenerating
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}
`}
>
{isGenerating ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Export wird erstellt...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
{existingExport ? 'Neuen Export erstellen' : 'Export generieren'}
</>
)}
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,293 @@
'use client'
import React, { useState } from 'react'
import {
DSRErasureChecklist,
DSRErasureChecklistItem,
ERASURE_EXCEPTIONS
} from '@/lib/sdk/dsr/types'
interface DSRErasureChecklistProps {
checklist?: DSRErasureChecklist
onChange?: (checklist: DSRErasureChecklist) => void
readOnly?: boolean
}
export function DSRErasureChecklistComponent({
checklist,
onChange,
readOnly = false
}: DSRErasureChecklistProps) {
const [localChecklist, setLocalChecklist] = useState<DSRErasureChecklist>(() => {
if (checklist) return checklist
return {
items: ERASURE_EXCEPTIONS.map(exc => ({
...exc,
checked: false,
applies: false
})),
canProceedWithErasure: true
}
})
const handleItemChange = (
itemId: string,
field: 'checked' | 'applies' | 'notes',
value: boolean | string
) => {
const updatedItems = localChecklist.items.map(item => {
if (item.id !== itemId) return item
return { ...item, [field]: value }
})
// Calculate if erasure can proceed (no exceptions apply)
const canProceedWithErasure = !updatedItems.some(item => item.checked && item.applies)
const updatedChecklist: DSRErasureChecklist = {
...localChecklist,
items: updatedItems,
canProceedWithErasure
}
setLocalChecklist(updatedChecklist)
onChange?.(updatedChecklist)
}
const appliedExceptions = localChecklist.items.filter(item => item.checked && item.applies)
const allChecked = localChecklist.items.every(item => item.checked)
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">
Art. 17(3) Ausnahmen-Pruefung
</h3>
<p className="text-sm text-gray-500 mt-1">
Pruefen Sie, ob eine der Ausnahmen zur Loeschung zutrifft
</p>
</div>
{/* Status Badge */}
<div className={`
px-3 py-1.5 rounded-lg text-sm font-medium
${localChecklist.canProceedWithErasure
? 'bg-green-100 text-green-700 border border-green-200'
: 'bg-red-100 text-red-700 border border-red-200'
}
`}>
{localChecklist.canProceedWithErasure
? 'Loeschung moeglich'
: `${appliedExceptions.length} Ausnahme(n)`
}
</div>
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-700">
<p className="font-medium">Hinweis</p>
<p className="mt-1">
Nach Art. 17(3) DSGVO bestehen Ausnahmen vom Loeschungsanspruch.
Pruefen Sie jeden Punkt und dokumentieren Sie, ob eine Ausnahme greift.
</p>
</div>
</div>
</div>
{/* Checklist Items */}
<div className="space-y-3">
{localChecklist.items.map((item, index) => (
<ChecklistItem
key={item.id}
item={item}
index={index}
readOnly={readOnly}
onChange={(field, value) => handleItemChange(item.id, field, value)}
/>
))}
</div>
{/* Summary */}
{allChecked && (
<div className={`
rounded-xl p-4 border
${localChecklist.canProceedWithErasure
? 'bg-green-50 border-green-200'
: 'bg-red-50 border-red-200'
}
`}>
<div className="flex items-start gap-3">
<div className={`
w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0
${localChecklist.canProceedWithErasure ? 'bg-green-100' : 'bg-red-100'}
`}>
{localChecklist.canProceedWithErasure ? (
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</div>
<div>
<div className={`font-medium ${localChecklist.canProceedWithErasure ? 'text-green-800' : 'text-red-800'}`}>
{localChecklist.canProceedWithErasure
? 'Alle Ausnahmen geprueft - Loeschung kann durchgefuehrt werden'
: 'Ausnahme(n) greifen - Loeschung nicht oder nur teilweise moeglich'
}
</div>
{!localChecklist.canProceedWithErasure && (
<ul className="mt-2 space-y-1">
{appliedExceptions.map(exc => (
<li key={exc.id} className="text-sm text-red-700">
- {exc.article}: {exc.label}
</li>
))}
</ul>
)}
</div>
</div>
</div>
)}
{/* Progress Indicator */}
{!allChecked && (
<div className="text-sm text-gray-500 text-center">
{localChecklist.items.filter(i => i.checked).length} von {localChecklist.items.length} Ausnahmen geprueft
</div>
)}
</div>
)
}
// Individual Checklist Item Component
function ChecklistItem({
item,
index,
readOnly,
onChange
}: {
item: DSRErasureChecklistItem
index: number
readOnly: boolean
onChange: (field: 'checked' | 'applies' | 'notes', value: boolean | string) => void
}) {
const [expanded, setExpanded] = useState(false)
return (
<div className={`
rounded-xl border transition-all
${item.checked
? item.applies
? 'border-red-200 bg-red-50'
: 'border-green-200 bg-green-50'
: 'border-gray-200 bg-white'
}
`}>
{/* Main Row */}
<div className="p-4">
<div className="flex items-start gap-4">
{/* Checkbox */}
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={item.checked}
onChange={(e) => onChange('checked', e.target.checked)}
disabled={readOnly}
className="w-5 h-5 rounded border-gray-300 text-purple-600 focus:ring-purple-500 disabled:opacity-50"
/>
</label>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
{item.article}
</span>
<span className="font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-sm text-gray-600">{item.description}</p>
</div>
{/* Toggle Expand */}
<button
onClick={() => setExpanded(!expanded)}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
>
<svg
className={`w-5 h-5 transition-transform ${expanded ? '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>
</div>
{/* Applies Toggle - Show when checked */}
{item.checked && (
<div className="mt-3 ml-9 flex items-center gap-4">
<span className="text-sm text-gray-600">Trifft diese Ausnahme zu?</span>
<div className="flex items-center gap-2">
<button
onClick={() => onChange('applies', false)}
disabled={readOnly}
className={`
px-3 py-1 text-sm rounded-lg transition-colors
${!item.applies
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}
${readOnly ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
Nein
</button>
<button
onClick={() => onChange('applies', true)}
disabled={readOnly}
className={`
px-3 py-1 text-sm rounded-lg transition-colors
${item.applies
? 'bg-red-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}
${readOnly ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
Ja
</button>
</div>
</div>
)}
</div>
{/* Expanded Notes Section */}
{expanded && (
<div className="px-4 pb-4 pt-0 border-t border-gray-100">
<div className="ml-9 mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Notizen / Begruendung
</label>
<textarea
value={item.notes || ''}
onChange={(e) => onChange('notes', e.target.value)}
disabled={readOnly}
placeholder="Dokumentieren Sie Ihre Pruefung..."
rows={3}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none disabled:bg-gray-50 disabled:text-gray-500"
/>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,263 @@
'use client'
import React, { useState } from 'react'
import {
IdentityVerificationMethod,
DSRVerifyIdentityRequest
} from '@/lib/sdk/dsr/types'
interface DSRIdentityModalProps {
isOpen: boolean
onClose: () => void
onVerify: (verification: DSRVerifyIdentityRequest) => Promise<void>
requesterName: string
requesterEmail: string
}
const VERIFICATION_METHODS: {
value: IdentityVerificationMethod
label: string
description: string
icon: string
}[] = [
{
value: 'id_document',
label: 'Ausweisdokument',
description: 'Kopie von Personalausweis oder Reisepass',
icon: 'M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2'
},
{
value: 'email',
label: 'E-Mail-Bestaetigung',
description: 'Bestaetigung ueber verifizierte E-Mail-Adresse',
icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z'
},
{
value: 'existing_account',
label: 'Bestehendes Konto',
description: 'Anmeldung ueber bestehendes Kundenkonto',
icon: 'M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z'
},
{
value: 'phone',
label: 'Telefonische Bestaetigung',
description: 'Verifizierung per Telefonanruf',
icon: 'M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z'
},
{
value: 'postal',
label: 'Postalische Bestaetigung',
description: 'Bestaetigung per Brief',
icon: 'M3 19v-8.93a2 2 0 01.89-1.664l7-4.666a2 2 0 012.22 0l7 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5m0 0l-1.14.76a2 2 0 01-2.22 0l-1.14-.76'
},
{
value: 'other',
label: 'Sonstige Methode',
description: 'Andere Verifizierungsmethode',
icon: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
}
]
export function DSRIdentityModal({
isOpen,
onClose,
onVerify,
requesterName,
requesterEmail
}: DSRIdentityModalProps) {
const [selectedMethod, setSelectedMethod] = useState<IdentityVerificationMethod | null>(null)
const [notes, setNotes] = useState('')
const [documentRef, setDocumentRef] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!isOpen) return null
const handleVerify = async () => {
if (!selectedMethod) {
setError('Bitte waehlen Sie eine Verifizierungsmethode')
return
}
setIsLoading(true)
setError(null)
try {
await onVerify({
method: selectedMethod,
notes: notes || undefined,
documentRef: documentRef || undefined
})
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Verifizierung fehlgeschlagen')
} finally {
setIsLoading(false)
}
}
const handleClose = () => {
setSelectedMethod(null)
setNotes('')
setDocumentRef('')
setError(null)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={handleClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
Identitaet verifizieren
</h2>
<button
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="px-6 py-4 overflow-y-auto max-h-[60vh]">
{/* Requester Info */}
<div className="bg-gray-50 rounded-xl p-4 mb-6">
<div className="text-sm text-gray-500 mb-1">Antragsteller</div>
<div className="font-medium text-gray-900">{requesterName}</div>
<div className="text-sm text-gray-600">{requesterEmail}</div>
</div>
{/* Error */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
{/* Verification Methods */}
<div className="space-y-2 mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Verifizierungsmethode
</label>
{VERIFICATION_METHODS.map(method => (
<button
key={method.value}
onClick={() => setSelectedMethod(method.value)}
className={`
w-full flex items-start gap-3 p-3 rounded-xl border-2 text-left transition-all
${selectedMethod === method.value
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}
`}
>
<div className={`
w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0
${selectedMethod === method.value ? 'bg-purple-100' : 'bg-gray-100'}
`}>
<svg
className={`w-5 h-5 ${selectedMethod === method.value ? 'text-purple-600' : 'text-gray-500'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={method.icon} />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className={`font-medium ${selectedMethod === method.value ? 'text-purple-700' : 'text-gray-900'}`}>
{method.label}
</div>
<div className="text-sm text-gray-500">
{method.description}
</div>
</div>
{selectedMethod === method.value && (
<svg className="w-5 h-5 text-purple-600 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
)}
</button>
))}
</div>
{/* Document Reference */}
{selectedMethod === 'id_document' && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Dokumentenreferenz (optional)
</label>
<input
type="text"
value={documentRef}
onChange={(e) => setDocumentRef(e.target.value)}
placeholder="z.B. Datei-ID oder Speicherort"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
)}
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notizen (optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Weitere Informationen zur Verifizierung..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none"
/>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-end gap-3">
<button
onClick={handleClose}
className="px-4 py-2 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={handleVerify}
disabled={!selectedMethod || isLoading}
className={`
px-4 py-2 rounded-lg font-medium transition-colors
${selectedMethod && !isLoading
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}
`}
>
{isLoading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Verifiziere...
</span>
) : (
'Identitaet bestaetigen'
)}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,176 @@
'use client'
import React from 'react'
import { DSRStatus, DSR_STATUS_INFO } from '@/lib/sdk/dsr/types'
interface WorkflowStep {
id: DSRStatus
label: string
description?: string
}
const WORKFLOW_STEPS: WorkflowStep[] = [
{ id: 'intake', label: 'Eingang', description: 'Anfrage dokumentiert' },
{ id: 'identity_verification', label: 'ID-Pruefung', description: 'Identitaet verifizieren' },
{ id: 'processing', label: 'Bearbeitung', description: 'Anfrage bearbeiten' },
{ id: 'completed', label: 'Abschluss', description: 'Antwort versenden' }
]
interface DSRWorkflowStepperProps {
currentStatus: DSRStatus
onStepClick?: (status: DSRStatus) => void
className?: string
}
export function DSRWorkflowStepper({
currentStatus,
onStepClick,
className = ''
}: DSRWorkflowStepperProps) {
const currentIndex = WORKFLOW_STEPS.findIndex(s => s.id === currentStatus)
const isRejectedOrCancelled = currentStatus === 'rejected' || currentStatus === 'cancelled'
const getStepState = (index: number): 'completed' | 'current' | 'upcoming' => {
if (isRejectedOrCancelled) {
return index <= currentIndex ? 'completed' : 'upcoming'
}
if (index < currentIndex) return 'completed'
if (index === currentIndex) return 'current'
return 'upcoming'
}
return (
<div className={`${className}`}>
<div className="flex items-center justify-between">
{WORKFLOW_STEPS.map((step, index) => {
const state = getStepState(index)
const isLast = index === WORKFLOW_STEPS.length - 1
return (
<React.Fragment key={step.id}>
{/* Step */}
<div
className={`flex flex-col items-center ${
onStepClick && state !== 'upcoming' ? 'cursor-pointer' : ''
}`}
onClick={() => onStepClick && state !== 'upcoming' && onStepClick(step.id)}
>
{/* Circle */}
<div
className={`
w-10 h-10 rounded-full flex items-center justify-center font-medium text-sm
transition-all duration-200
${state === 'completed'
? 'bg-green-500 text-white'
: state === 'current'
? 'bg-purple-600 text-white ring-4 ring-purple-100'
: 'bg-gray-200 text-gray-400'
}
`}
>
{state === 'completed' ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
index + 1
)}
</div>
{/* Label */}
<div className="mt-2 text-center">
<div
className={`text-sm font-medium ${
state === 'current' ? 'text-purple-600' :
state === 'completed' ? 'text-green-600' : 'text-gray-400'
}`}
>
{step.label}
</div>
{step.description && (
<div className="text-xs text-gray-500 mt-0.5 hidden sm:block">
{step.description}
</div>
)}
</div>
</div>
{/* Connector Line */}
{!isLast && (
<div
className={`
flex-1 h-1 mx-2 rounded-full
${state === 'completed' || getStepState(index + 1) === 'completed' || getStepState(index + 1) === 'current'
? 'bg-green-500'
: 'bg-gray-200'
}
`}
/>
)}
</React.Fragment>
)
})}
</div>
{/* Rejected/Cancelled Badge */}
{isRejectedOrCancelled && (
<div className={`
mt-4 px-4 py-2 rounded-lg text-center text-sm font-medium
${currentStatus === 'rejected'
? 'bg-red-100 text-red-700 border border-red-200'
: 'bg-gray-100 text-gray-700 border border-gray-200'
}
`}>
{currentStatus === 'rejected' ? 'Anfrage wurde abgelehnt' : 'Anfrage wurde storniert'}
</div>
)}
</div>
)
}
// Compact version for list views
export function DSRWorkflowStepperCompact({
currentStatus,
className = ''
}: {
currentStatus: DSRStatus
className?: string
}) {
const statusInfo = DSR_STATUS_INFO[currentStatus]
const currentIndex = WORKFLOW_STEPS.findIndex(s => s.id === currentStatus)
const totalSteps = WORKFLOW_STEPS.length
const isTerminal = currentStatus === 'rejected' || currentStatus === 'cancelled' || currentStatus === 'completed'
return (
<div className={`flex items-center gap-2 ${className}`}>
{/* Mini progress dots */}
<div className="flex items-center gap-1">
{WORKFLOW_STEPS.map((step, index) => (
<div
key={step.id}
className={`
w-2 h-2 rounded-full transition-all
${index < currentIndex
? 'bg-green-500'
: index === currentIndex
? isTerminal
? currentStatus === 'completed'
? 'bg-green-500'
: currentStatus === 'rejected'
? 'bg-red-500'
: 'bg-gray-500'
: 'bg-purple-500'
: 'bg-gray-200'
}
`}
/>
))}
</div>
{/* Status label */}
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
)
}

View File

@@ -0,0 +1,9 @@
/**
* DSR Components Exports
*/
export { DSRWorkflowStepper, DSRWorkflowStepperCompact } from './DSRWorkflowStepper'
export { DSRIdentityModal } from './DSRIdentityModal'
export { DSRCommunicationLog } from './DSRCommunicationLog'
export { DSRErasureChecklistComponent } from './DSRErasureChecklist'
export { DSRDataExportComponent } from './DSRDataExport'

View File

@@ -0,0 +1,658 @@
'use client'
/**
* DataPointCatalog Component
*
* Zeigt den vollstaendigen Datenpunktkatalog an.
* Ermoeglicht Filterung, Suche und Auswahl von Datenpunkten.
* Unterstützt 18 Kategorien (A-R) inkl. Art. 9 DSGVO Warnungen.
*/
import { useState, useMemo } from 'react'
import {
Search,
Filter,
CheckCircle,
Circle,
Shield,
AlertTriangle,
ChevronDown,
ChevronRight,
Key,
Megaphone,
MessageSquare,
CreditCard,
Users,
Bot,
Lock,
User,
Mail,
Activity,
MapPin,
Smartphone,
BarChart3,
Share2,
Heart,
Briefcase,
FileText,
FileCode,
Info,
X,
} from 'lucide-react'
import {
DataPoint,
DataPointCategory,
RiskLevel,
LegalBasis,
SupportedLanguage,
CATEGORY_METADATA,
RISK_LEVEL_STYLING,
LEGAL_BASIS_INFO,
RETENTION_PERIOD_INFO,
ARTICLE_9_WARNING,
EMPLOYEE_DATA_WARNING,
AI_DATA_WARNING,
} from '@/lib/sdk/einwilligungen/types'
import { searchDataPoints } from '@/lib/sdk/einwilligungen/catalog/loader'
// =============================================================================
// TYPES
// =============================================================================
interface DataPointCatalogProps {
dataPoints: DataPoint[]
selectedIds: string[]
onToggle: (id: string) => void
onSelectAll?: () => void
onDeselectAll?: () => void
language?: SupportedLanguage
showFilters?: boolean
readOnly?: boolean
}
// =============================================================================
// HELPER COMPONENTS
// =============================================================================
const CategoryIcon: React.FC<{ category: DataPointCategory; className?: string }> = ({
category,
className = 'w-5 h-5',
}) => {
const icons: Record<DataPointCategory, React.ReactNode> = {
// 18 Kategorien (A-R)
MASTER_DATA: <User className={className} />,
CONTACT_DATA: <Mail className={className} />,
AUTHENTICATION: <Key className={className} />,
CONSENT: <CheckCircle className={className} />,
COMMUNICATION: <MessageSquare className={className} />,
PAYMENT: <CreditCard className={className} />,
USAGE_DATA: <Activity className={className} />,
LOCATION: <MapPin className={className} />,
DEVICE_DATA: <Smartphone className={className} />,
MARKETING: <Megaphone className={className} />,
ANALYTICS: <BarChart3 className={className} />,
SOCIAL_MEDIA: <Share2 className={className} />,
HEALTH_DATA: <Heart className={className} />,
EMPLOYEE_DATA: <Briefcase className={className} />,
CONTRACT_DATA: <FileText className={className} />,
LOG_DATA: <FileCode className={className} />,
AI_DATA: <Bot className={className} />,
SECURITY: <Shield className={className} />,
}
return <>{icons[category] || <Circle className={className} />}</>
}
const RiskBadge: React.FC<{ level: RiskLevel; language: SupportedLanguage }> = ({
level,
language,
}) => {
const styling = RISK_LEVEL_STYLING[level]
return (
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full ${styling.bgColor} ${styling.color}`}
>
{styling.label[language]}
</span>
)
}
const LegalBasisBadge: React.FC<{ basis: LegalBasis; language: SupportedLanguage }> = ({
basis,
language,
}) => {
const info = LEGAL_BASIS_INFO[basis]
const colors: Record<LegalBasis, string> = {
CONTRACT: 'bg-blue-100 text-blue-700',
CONSENT: 'bg-purple-100 text-purple-700',
EXPLICIT_CONSENT: 'bg-rose-100 text-rose-700',
LEGITIMATE_INTEREST: 'bg-amber-100 text-amber-700',
LEGAL_OBLIGATION: 'bg-slate-100 text-slate-700',
VITAL_INTERESTS: 'bg-emerald-100 text-emerald-700',
PUBLIC_INTEREST: 'bg-cyan-100 text-cyan-700',
}
return (
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${colors[basis] || 'bg-gray-100 text-gray-700'}`}>
{info?.name[language] || basis}
</span>
)
}
/**
* Warnung fuer besondere Kategorien (Art. 9 DSGVO, BDSG § 26, AI Act)
*/
const SpecialCategoryWarning: React.FC<{
category: DataPointCategory
language: SupportedLanguage
onClose?: () => void
}> = ({ category, language, onClose }) => {
// Bestimme welche Warnung angezeigt werden soll
let warning = null
let bgColor = ''
let borderColor = ''
let iconColor = ''
if (category === 'HEALTH_DATA') {
warning = ARTICLE_9_WARNING
bgColor = 'bg-rose-50'
borderColor = 'border-rose-200'
iconColor = 'text-rose-600'
} else if (category === 'EMPLOYEE_DATA') {
warning = EMPLOYEE_DATA_WARNING
bgColor = 'bg-orange-50'
borderColor = 'border-orange-200'
iconColor = 'text-orange-600'
} else if (category === 'AI_DATA') {
warning = AI_DATA_WARNING
bgColor = 'bg-fuchsia-50'
borderColor = 'border-fuchsia-200'
iconColor = 'text-fuchsia-600'
}
if (!warning) return null
return (
<div className={`p-4 rounded-lg border ${bgColor} ${borderColor} mb-4`}>
<div className="flex items-start gap-3">
<AlertTriangle className={`w-5 h-5 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1">
<div className="flex items-center justify-between">
<h4 className={`font-semibold ${iconColor}`}>
{warning.title[language]}
</h4>
{onClose && (
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<p className="text-sm text-slate-600 mt-1">
{warning.description[language]}
</p>
<ul className="mt-3 space-y-1">
{warning.requirements.map((req, idx) => (
<li key={idx} className="text-sm text-slate-700 flex items-start gap-2">
<span className={`${iconColor} font-bold`}></span>
<span>{req[language]}</span>
</li>
))}
</ul>
</div>
</div>
</div>
)
}
/**
* Inline-Hinweis fuer Art. 9 Datenpunkte
*/
const Article9Badge: React.FC<{ language: SupportedLanguage }> = ({ language }) => (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-rose-100 text-rose-700 border border-rose-200">
<Heart className="w-3 h-3" />
{language === 'de' ? 'Art. 9 DSGVO' : 'Art. 9 GDPR'}
</span>
)
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export function DataPointCatalog({
dataPoints,
selectedIds,
onToggle,
onSelectAll,
onDeselectAll,
language = 'de',
showFilters = true,
readOnly = false,
}: DataPointCatalogProps) {
// Alle 18 Kategorien in der richtigen Reihenfolge (A-R)
const ALL_CATEGORIES: DataPointCategory[] = [
'MASTER_DATA', // A
'CONTACT_DATA', // B
'AUTHENTICATION', // C
'CONSENT', // D
'COMMUNICATION', // E
'PAYMENT', // F
'USAGE_DATA', // G
'LOCATION', // H
'DEVICE_DATA', // I
'MARKETING', // J
'ANALYTICS', // K
'SOCIAL_MEDIA', // L
'HEALTH_DATA', // M - Art. 9 DSGVO
'EMPLOYEE_DATA', // N - BDSG § 26
'CONTRACT_DATA', // O
'LOG_DATA', // P
'AI_DATA', // Q - AI Act
'SECURITY', // R
]
// State
const [searchQuery, setSearchQuery] = useState('')
const [expandedCategories, setExpandedCategories] = useState<Set<DataPointCategory>>(
new Set(ALL_CATEGORIES)
)
const [filterCategory, setFilterCategory] = useState<DataPointCategory | 'ALL'>('ALL')
const [filterRisk, setFilterRisk] = useState<RiskLevel | 'ALL'>('ALL')
const [filterBasis, setFilterBasis] = useState<LegalBasis | 'ALL'>('ALL')
const [dismissedWarnings, setDismissedWarnings] = useState<Set<DataPointCategory>>(new Set())
// Filtered and searched data points
const filteredDataPoints = useMemo(() => {
let result = dataPoints
// Search
if (searchQuery.trim()) {
result = searchDataPoints(result, searchQuery, language)
}
// Filter by category
if (filterCategory !== 'ALL') {
result = result.filter((dp) => dp.category === filterCategory)
}
// Filter by risk
if (filterRisk !== 'ALL') {
result = result.filter((dp) => dp.riskLevel === filterRisk)
}
// Filter by legal basis
if (filterBasis !== 'ALL') {
result = result.filter((dp) => dp.legalBasis === filterBasis)
}
return result
}, [dataPoints, searchQuery, filterCategory, filterRisk, filterBasis, language])
// Group by category (18 Kategorien)
const groupedDataPoints = useMemo(() => {
const grouped = new Map<DataPointCategory, DataPoint[]>()
for (const cat of ALL_CATEGORIES) {
grouped.set(cat, [])
}
for (const dp of filteredDataPoints) {
const existing = grouped.get(dp.category) || []
grouped.set(dp.category, [...existing, dp])
}
return grouped
}, [filteredDataPoints])
// Zaehle ausgewaehlte spezielle Kategorien fuer Warnungen
const selectedSpecialCategories = useMemo(() => {
const special = new Set<DataPointCategory>()
for (const id of selectedIds) {
const dp = dataPoints.find(d => d.id === id)
if (dp) {
if (dp.category === 'HEALTH_DATA' || dp.isSpecialCategory) {
special.add('HEALTH_DATA')
}
if (dp.category === 'EMPLOYEE_DATA') {
special.add('EMPLOYEE_DATA')
}
if (dp.category === 'AI_DATA') {
special.add('AI_DATA')
}
}
}
return special
}, [selectedIds, dataPoints])
// Toggle category expansion
const toggleCategory = (category: DataPointCategory) => {
setExpandedCategories((prev) => {
const next = new Set(prev)
if (next.has(category)) {
next.delete(category)
} else {
next.add(category)
}
return next
})
}
// Stats
const totalSelected = selectedIds.length
const totalDataPoints = dataPoints.length
return (
<div className="space-y-4">
{/* Header with Stats */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-slate-600">
<span className="font-semibold text-slate-900">{totalSelected}</span> von{' '}
<span className="font-semibold">{totalDataPoints}</span> Datenpunkte ausgewaehlt
</div>
</div>
{!readOnly && (
<div className="flex items-center gap-2">
<button
onClick={onSelectAll}
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium"
>
Alle auswaehlen
</button>
<span className="text-slate-300">|</span>
<button
onClick={onDeselectAll}
className="text-sm text-slate-600 hover:text-slate-700 font-medium"
>
Auswahl aufheben
</button>
</div>
)}
</div>
{/* Art. 9 DSGVO / BDSG § 26 / AI Act Warnungen */}
{selectedSpecialCategories.size > 0 && (
<div className="space-y-3">
{selectedSpecialCategories.has('HEALTH_DATA') && !dismissedWarnings.has('HEALTH_DATA') && (
<SpecialCategoryWarning
category="HEALTH_DATA"
language={language}
onClose={() => setDismissedWarnings(prev => new Set([...prev, 'HEALTH_DATA']))}
/>
)}
{selectedSpecialCategories.has('EMPLOYEE_DATA') && !dismissedWarnings.has('EMPLOYEE_DATA') && (
<SpecialCategoryWarning
category="EMPLOYEE_DATA"
language={language}
onClose={() => setDismissedWarnings(prev => new Set([...prev, 'EMPLOYEE_DATA']))}
/>
)}
{selectedSpecialCategories.has('AI_DATA') && !dismissedWarnings.has('AI_DATA') && (
<SpecialCategoryWarning
category="AI_DATA"
language={language}
onClose={() => setDismissedWarnings(prev => new Set([...prev, 'AI_DATA']))}
/>
)}
</div>
)}
{/* Search and Filters */}
{showFilters && (
<div className="flex flex-wrap gap-3">
{/* Search */}
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Datenpunkte suchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
{/* Category Filter */}
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value as DataPointCategory | 'ALL')}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="ALL">Alle Kategorien</option>
{Object.entries(CATEGORY_METADATA).map(([key, meta]) => (
<option key={key} value={key}>
{meta.name[language]}
</option>
))}
</select>
{/* Risk Filter */}
<select
value={filterRisk}
onChange={(e) => setFilterRisk(e.target.value as RiskLevel | 'ALL')}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="ALL">Alle Risikostufen</option>
<option value="LOW">{RISK_LEVEL_STYLING.LOW.label[language]}</option>
<option value="MEDIUM">{RISK_LEVEL_STYLING.MEDIUM.label[language]}</option>
<option value="HIGH">{RISK_LEVEL_STYLING.HIGH.label[language]}</option>
</select>
{/* Legal Basis Filter */}
<select
value={filterBasis}
onChange={(e) => setFilterBasis(e.target.value as LegalBasis | 'ALL')}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="ALL">Alle Rechtsgrundlagen</option>
{Object.entries(LEGAL_BASIS_INFO).map(([key, info]) => (
<option key={key} value={key}>
{info.name[language]}
</option>
))}
</select>
</div>
)}
{/* Data Points by Category */}
<div className="space-y-3">
{Array.from(groupedDataPoints.entries()).map(([category, categoryDataPoints]) => {
if (categoryDataPoints.length === 0) return null
const meta = CATEGORY_METADATA[category]
const isExpanded = expandedCategories.has(category)
const selectedInCategory = categoryDataPoints.filter((dp) =>
selectedIds.includes(dp.id)
).length
return (
<div
key={category}
className="border border-slate-200 rounded-xl overflow-hidden bg-white"
>
{/* Category Header */}
<button
onClick={() => toggleCategory(category)}
className={`w-full flex items-center justify-between px-4 py-3 transition-colors ${
category === 'HEALTH_DATA'
? 'bg-rose-50 hover:bg-rose-100 border-l-4 border-rose-400'
: category === 'EMPLOYEE_DATA'
? 'bg-orange-50 hover:bg-orange-100 border-l-4 border-orange-400'
: category === 'AI_DATA'
? 'bg-fuchsia-50 hover:bg-fuchsia-100 border-l-4 border-fuchsia-400'
: 'bg-slate-50 hover:bg-slate-100'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${
category === 'HEALTH_DATA' ? 'bg-rose-100' :
category === 'EMPLOYEE_DATA' ? 'bg-orange-100' :
category === 'AI_DATA' ? 'bg-fuchsia-100' :
`bg-${meta.color}-100`
}`}>
<CategoryIcon
category={category}
className={`w-5 h-5 ${
category === 'HEALTH_DATA' ? 'text-rose-600' :
category === 'EMPLOYEE_DATA' ? 'text-orange-600' :
category === 'AI_DATA' ? 'text-fuchsia-600' :
`text-${meta.color}-600`
}`}
/>
</div>
<div className="text-left">
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-900">
{meta.code}. {meta.name[language]}
</span>
{category === 'HEALTH_DATA' && (
<span className="text-xs bg-rose-200 text-rose-700 px-1.5 py-0.5 rounded font-medium">
Art. 9 DSGVO
</span>
)}
{category === 'EMPLOYEE_DATA' && (
<span className="text-xs bg-orange-200 text-orange-700 px-1.5 py-0.5 rounded font-medium">
BDSG § 26
</span>
)}
{category === 'AI_DATA' && (
<span className="text-xs bg-fuchsia-200 text-fuchsia-700 px-1.5 py-0.5 rounded font-medium">
AI Act
</span>
)}
</div>
<div className="text-xs text-slate-500">{meta.description[language]}</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500">
{selectedInCategory}/{categoryDataPoints.length}
</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-slate-400" />
) : (
<ChevronRight className="w-5 h-5 text-slate-400" />
)}
</div>
</button>
{/* Data Points List */}
{isExpanded && (
<div className="divide-y divide-slate-100">
{categoryDataPoints.map((dp) => {
const isSelected = selectedIds.includes(dp.id)
return (
<div
key={dp.id}
className={`flex items-start gap-4 p-4 ${
readOnly ? '' : 'cursor-pointer hover:bg-slate-50'
} transition-colors ${isSelected ? 'bg-indigo-50/50' : ''}`}
onClick={() => !readOnly && onToggle(dp.id)}
>
{/* Checkbox */}
{!readOnly && (
<div className="flex-shrink-0 pt-0.5">
{isSelected ? (
<CheckCircle className="w-5 h-5 text-indigo-600" />
) : (
<Circle className="w-5 h-5 text-slate-300" />
)}
</div>
)}
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{dp.code}
</span>
<span className="font-medium text-slate-900">
{dp.name[language]}
</span>
{dp.isSpecialCategory && (
<Article9Badge language={language} />
)}
{dp.isCustom && (
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">
{language === 'de' ? 'Benutzerdefiniert' : 'Custom'}
</span>
)}
</div>
<p className="text-sm text-slate-600 mt-1">
{dp.description[language]}
</p>
</div>
<div className="flex-shrink-0 flex flex-col items-end gap-1">
<RiskBadge level={dp.riskLevel} language={language} />
<LegalBasisBadge basis={dp.legalBasis} language={language} />
</div>
</div>
{/* Details */}
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
<span>
<strong>{language === 'de' ? 'Zweck' : 'Purpose'}:</strong> {dp.purpose[language]}
</span>
<span>
<strong>{language === 'de' ? 'Loeschfrist' : 'Retention'}:</strong>{' '}
{RETENTION_PERIOD_INFO[dp.retentionPeriod]?.label[language] || dp.retentionPeriod}
</span>
{dp.cookieCategory && (
<span>
<strong>Cookie:</strong> {dp.cookieCategory}
</span>
)}
</div>
{/* Spezielle Warnungen fuer Art. 9 / BDSG / AI Act */}
{(dp.requiresExplicitConsent || dp.isSpecialCategory) && (
<div className="mt-2 p-2 rounded-md bg-rose-50 border border-rose-200">
<div className="flex items-start gap-2 text-xs text-rose-700">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<div>
<strong>
{language === 'de'
? 'Ausdrueckliche Einwilligung erforderlich'
: 'Explicit consent required'}
</strong>
{dp.legalBasis === 'EXPLICIT_CONSENT' && (
<span className="block mt-1 text-rose-600">
{language === 'de'
? 'Art. 9 Abs. 2 lit. a DSGVO - Separate Einwilligungserklaerung notwendig'
: 'Art. 9(2)(a) GDPR - Separate consent declaration required'}
</span>
)}
</div>
</div>
</div>
)}
{/* Third Party Recipients */}
{dp.thirdPartyRecipients.length > 0 && (
<div className="mt-2 text-xs text-slate-500">
<strong>Drittanbieter:</strong>{' '}
{dp.thirdPartyRecipients.join(', ')}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
{/* Empty State */}
{filteredDataPoints.length === 0 && (
<div className="text-center py-12 text-slate-500">
<Search className="w-12 h-12 mx-auto mb-4 text-slate-300" />
<p className="font-medium">Keine Datenpunkte gefunden</p>
<p className="text-sm mt-1">Versuchen Sie andere Suchbegriffe oder Filter</p>
</div>
)}
</div>
)
}
export default DataPointCatalog

View File

@@ -0,0 +1,321 @@
'use client'
/**
* PrivacyPolicyPreview Component
*
* Zeigt eine Vorschau der generierten Datenschutzerklaerung.
*/
import { useState } from 'react'
import {
FileText,
Download,
Globe,
Eye,
Code,
ChevronDown,
ChevronRight,
Copy,
Check,
} from 'lucide-react'
import {
GeneratedPrivacyPolicy,
PrivacyPolicySection,
SupportedLanguage,
ExportFormat,
} from '@/lib/sdk/einwilligungen/types'
// =============================================================================
// TYPES
// =============================================================================
interface PrivacyPolicyPreviewProps {
policy: GeneratedPrivacyPolicy | null
isLoading?: boolean
language: SupportedLanguage
format: ExportFormat
onLanguageChange: (language: SupportedLanguage) => void
onFormatChange: (format: ExportFormat) => void
onGenerate: () => void
onDownload?: (format: ExportFormat) => void
}
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export function PrivacyPolicyPreview({
policy,
isLoading = false,
language,
format,
onLanguageChange,
onFormatChange,
onGenerate,
onDownload,
}: PrivacyPolicyPreviewProps) {
const [viewMode, setViewMode] = useState<'preview' | 'source'>('preview')
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
const [copied, setCopied] = useState(false)
const toggleSection = (sectionId: string) => {
setExpandedSections((prev) => {
const next = new Set(prev)
if (next.has(sectionId)) {
next.delete(sectionId)
} else {
next.add(sectionId)
}
return next
})
}
const expandAll = () => {
if (policy) {
setExpandedSections(new Set(policy.sections.map((s) => s.id)))
}
}
const collapseAll = () => {
setExpandedSections(new Set())
}
const copyToClipboard = async () => {
if (policy?.content) {
await navigator.clipboard.writeText(policy.content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<div className="space-y-4">
{/* Controls */}
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-slate-50 rounded-xl">
<div className="flex items-center gap-3">
{/* Language Selector */}
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-slate-400" />
<select
value={language}
onChange={(e) => onLanguageChange(e.target.value as SupportedLanguage)}
className="px-3 py-1.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
{/* Format Selector */}
<select
value={format}
onChange={(e) => onFormatChange(e.target.value as ExportFormat)}
className="px-3 py-1.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="HTML">HTML</option>
<option value="MARKDOWN">Markdown</option>
<option value="PDF">PDF</option>
<option value="DOCX">Word (DOCX)</option>
</select>
{/* View Mode Toggle */}
<div className="flex items-center border border-slate-300 rounded-lg overflow-hidden">
<button
onClick={() => setViewMode('preview')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm ${
viewMode === 'preview'
? 'bg-indigo-600 text-white'
: 'bg-white text-slate-600 hover:bg-slate-50'
}`}
>
<Eye className="w-4 h-4" />
Vorschau
</button>
<button
onClick={() => setViewMode('source')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm ${
viewMode === 'source'
? 'bg-indigo-600 text-white'
: 'bg-white text-slate-600 hover:bg-slate-50'
}`}
>
<Code className="w-4 h-4" />
Quelltext
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onGenerate}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Generiere...
</>
) : (
<>
<FileText className="w-4 h-4" />
Generieren
</>
)}
</button>
{policy && onDownload && (
<button
onClick={() => onDownload(format)}
className="flex items-center gap-2 px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
<Download className="w-4 h-4" />
Download
</button>
)}
</div>
</div>
{/* Content */}
{policy ? (
<div className="border border-slate-200 rounded-xl overflow-hidden bg-white">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-slate-50 border-b border-slate-200">
<div>
<h3 className="font-semibold text-slate-900">
{language === 'de' ? 'Datenschutzerklaerung' : 'Privacy Policy'}
</h3>
<p className="text-xs text-slate-500">
Version {policy.version} |{' '}
{new Date(policy.generatedAt).toLocaleDateString(
language === 'de' ? 'de-DE' : 'en-US'
)}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={expandAll}
className="text-xs text-indigo-600 hover:text-indigo-700"
>
Alle aufklappen
</button>
<span className="text-slate-300">|</span>
<button
onClick={collapseAll}
className="text-xs text-slate-600 hover:text-slate-700"
>
Alle zuklappen
</button>
{viewMode === 'source' && (
<>
<span className="text-slate-300">|</span>
<button
onClick={copyToClipboard}
className="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-700"
>
{copied ? (
<>
<Check className="w-3 h-3 text-green-600" />
Kopiert
</>
) : (
<>
<Copy className="w-3 h-3" />
Kopieren
</>
)}
</button>
</>
)}
</div>
</div>
{/* Sections */}
{viewMode === 'preview' ? (
<div className="divide-y divide-slate-100">
{policy.sections.map((section) => {
const isExpanded = expandedSections.has(section.id)
return (
<div key={section.id}>
<button
onClick={() => toggleSection(section.id)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-50 transition-colors"
>
<span className="font-medium text-slate-900">
{section.title[language]}
</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-slate-400" />
) : (
<ChevronRight className="w-5 h-5 text-slate-400" />
)}
</button>
{isExpanded && (
<div className="px-4 pb-4">
<div
className="prose prose-sm prose-slate max-w-none"
dangerouslySetInnerHTML={{
__html: formatContent(section.content[language]),
}}
/>
{section.isGenerated && (
<div className="mt-2 text-xs text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-green-400 rounded-full" />
Automatisch aus Datenpunkten generiert
</div>
)}
</div>
)}
</div>
)
})}
</div>
) : (
<div className="p-4">
<pre className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono whitespace-pre-wrap">
{policy.content}
</pre>
</div>
)}
</div>
) : (
<div className="border border-slate-200 rounded-xl p-12 text-center bg-white">
<FileText className="w-16 h-16 mx-auto mb-4 text-slate-300" />
<h3 className="font-semibold text-slate-900 mb-2">
Keine Datenschutzerklaerung generiert
</h3>
<p className="text-sm text-slate-500 mb-4">
Waehlen Sie die gewuenschten Datenpunkte aus und klicken Sie auf "Generieren", um eine
Datenschutzerklaerung zu erstellen.
</p>
<button
onClick={onGenerate}
disabled={isLoading}
className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700"
>
<FileText className="w-4 h-4" />
Jetzt generieren
</button>
</div>
)}
</div>
)
}
/**
* Formatiert Markdown-aehnlichen Content zu HTML
*/
function formatContent(content: string): string {
return content
.replace(/### (.+)/g, '<h4 class="font-semibold text-slate-800 mt-4 mb-2">$1</h4>')
.replace(/## (.+)/g, '<h3 class="font-semibold text-lg text-slate-900 mt-6 mb-3">$1</h3>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\n\n/g, '</p><p class="mb-3">')
.replace(/\n- /g, '</p><ul class="list-disc pl-5 mb-3"><li>')
.replace(/<li>(.+?)(?=<li>|<\/p>|$)/g, '<li class="mb-1">$1</li>')
.replace(/(<li[^>]*>.*?<\/li>)+/g, '<ul class="list-disc pl-5 mb-3">$&</ul>')
.replace(/<\/ul><ul[^>]*>/g, '')
.replace(/\n/g, '<br>')
}
export default PrivacyPolicyPreview

View File

@@ -0,0 +1,350 @@
'use client'
/**
* RetentionMatrix Component
*
* Visualisiert die Loeschfristen-Matrix nach Kategorien.
*/
import { useState, useMemo } from 'react'
import {
Clock,
Calendar,
Info,
AlertTriangle,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import {
RetentionMatrixEntry,
RetentionPeriod,
DataPointCategory,
SupportedLanguage,
CATEGORY_METADATA,
RETENTION_PERIOD_INFO,
DataPoint,
} from '@/lib/sdk/einwilligungen/types'
// =============================================================================
// TYPES
// =============================================================================
interface RetentionMatrixProps {
matrix: RetentionMatrixEntry[]
dataPoints: DataPoint[]
language?: SupportedLanguage
showDetails?: boolean
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function getRetentionColor(period: RetentionPeriod): string {
const days = RETENTION_PERIOD_INFO[period].days
if (days === null) return 'bg-purple-100 text-purple-700'
if (days <= 30) return 'bg-green-100 text-green-700'
if (days <= 365) return 'bg-blue-100 text-blue-700'
if (days <= 1095) return 'bg-amber-100 text-amber-700'
return 'bg-red-100 text-red-700'
}
function getRetentionBarWidth(period: RetentionPeriod): number {
const days = RETENTION_PERIOD_INFO[period].days
if (days === null) return 100
const maxDays = 3650 // 10 Jahre
return Math.min(100, (days / maxDays) * 100)
}
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export function RetentionMatrix({
matrix,
dataPoints,
language = 'de',
showDetails = true,
}: RetentionMatrixProps) {
const [expandedCategories, setExpandedCategories] = useState<Set<DataPointCategory>>(new Set())
const toggleCategory = (category: DataPointCategory) => {
setExpandedCategories((prev) => {
const next = new Set(prev)
if (next.has(category)) {
next.delete(category)
} else {
next.add(category)
}
return next
})
}
// Group data points by category
const dataPointsByCategory = useMemo(() => {
const grouped = new Map<DataPointCategory, DataPoint[]>()
for (const dp of dataPoints) {
const existing = grouped.get(dp.category) || []
grouped.set(dp.category, [...existing, dp])
}
return grouped
}, [dataPoints])
// Stats
const stats = useMemo(() => {
const periodCounts: Record<string, number> = {}
for (const dp of dataPoints) {
periodCounts[dp.retentionPeriod] = (periodCounts[dp.retentionPeriod] || 0) + 1
}
return periodCounts
}, [dataPoints])
return (
<div className="space-y-6">
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-green-50 rounded-xl p-4">
<div className="flex items-center gap-2 text-green-600 mb-1">
<Clock className="w-4 h-4" />
<span className="text-sm font-medium">Kurzfristig</span>
</div>
<div className="text-2xl font-bold text-green-700">
{(stats['24_HOURS'] || 0) + (stats['30_DAYS'] || 0)}
</div>
<div className="text-xs text-green-600"> 30 Tage</div>
</div>
<div className="bg-blue-50 rounded-xl p-4">
<div className="flex items-center gap-2 text-blue-600 mb-1">
<Calendar className="w-4 h-4" />
<span className="text-sm font-medium">Mittelfristig</span>
</div>
<div className="text-2xl font-bold text-blue-700">
{(stats['90_DAYS'] || 0) + (stats['12_MONTHS'] || 0)}
</div>
<div className="text-xs text-blue-600">90 Tage - 12 Monate</div>
</div>
<div className="bg-amber-50 rounded-xl p-4">
<div className="flex items-center gap-2 text-amber-600 mb-1">
<Calendar className="w-4 h-4" />
<span className="text-sm font-medium">Langfristig</span>
</div>
<div className="text-2xl font-bold text-amber-700">
{(stats['24_MONTHS'] || 0) + (stats['36_MONTHS'] || 0)}
</div>
<div className="text-xs text-amber-600">2-3 Jahre</div>
</div>
<div className="bg-red-50 rounded-xl p-4">
<div className="flex items-center gap-2 text-red-600 mb-1">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-medium">Gesetzlich</span>
</div>
<div className="text-2xl font-bold text-red-700">
{(stats['6_YEARS'] || 0) + (stats['10_YEARS'] || 0)}
</div>
<div className="text-xs text-red-600">6-10 Jahre (AO/HGB)</div>
</div>
</div>
{/* Matrix Table */}
<div className="border border-slate-200 rounded-xl overflow-hidden bg-white">
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-4 py-3 text-sm font-semibold text-slate-700">
Kategorie
</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-slate-700">
Standard-Loeschfrist
</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-slate-700 hidden md:table-cell">
Rechtsgrundlage
</th>
<th className="text-center px-4 py-3 text-sm font-semibold text-slate-700 w-24">
Datenpunkte
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{matrix.map((entry) => {
const meta = CATEGORY_METADATA[entry.category]
const categoryDataPoints = dataPointsByCategory.get(entry.category) || []
const isExpanded = expandedCategories.has(entry.category)
return (
<>
<tr
key={entry.category}
className="hover:bg-slate-50 cursor-pointer transition-colors"
onClick={() => showDetails && toggleCategory(entry.category)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{showDetails && (
<div className="text-slate-400">
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</div>
)}
<div>
<div className="font-medium text-slate-900">
{meta.code}. {entry.categoryName[language]}
</div>
{entry.exceptions.length > 0 && (
<div className="text-xs text-slate-500">
{entry.exceptions.length} Ausnahme(n)
</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<span
className={`inline-flex px-2.5 py-1 text-xs font-medium rounded-full w-fit ${getRetentionColor(
entry.standardPeriod
)}`}
>
{RETENTION_PERIOD_INFO[entry.standardPeriod].label[language]}
</span>
<div className="h-1.5 bg-slate-100 rounded-full w-full max-w-[200px]">
<div
className={`h-full rounded-full ${
getRetentionColor(entry.standardPeriod).includes('green')
? 'bg-green-400'
: getRetentionColor(entry.standardPeriod).includes('blue')
? 'bg-blue-400'
: getRetentionColor(entry.standardPeriod).includes('amber')
? 'bg-amber-400'
: getRetentionColor(entry.standardPeriod).includes('red')
? 'bg-red-400'
: 'bg-purple-400'
}`}
style={{ width: `${getRetentionBarWidth(entry.standardPeriod)}%` }}
/>
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-slate-600 hidden md:table-cell">
{entry.legalBasis}
</td>
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-slate-100 text-sm font-medium text-slate-700">
{categoryDataPoints.length}
</span>
</td>
</tr>
{/* Expanded Details */}
{showDetails && isExpanded && (
<tr key={`${entry.category}-details`}>
<td colSpan={4} className="bg-slate-50 px-4 py-4">
<div className="space-y-4">
{/* Exceptions */}
{entry.exceptions.length > 0 && (
<div>
<h4 className="text-sm font-medium text-slate-700 mb-2 flex items-center gap-1">
<Info className="w-4 h-4" />
Ausnahmen von der Standardfrist
</h4>
<div className="space-y-2">
{entry.exceptions.map((exc, idx) => (
<div
key={idx}
className="flex items-start gap-3 p-3 bg-white rounded-lg border border-slate-200"
>
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5" />
<div>
<div className="text-sm font-medium text-slate-900">
{exc.condition[language]}
</div>
<div className="text-sm text-slate-600">
Loeschfrist:{' '}
<span className="font-medium">
{RETENTION_PERIOD_INFO[exc.period].label[language]}
</span>
</div>
<div className="text-xs text-slate-500 mt-1">
{exc.reason[language]}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Data Points in Category */}
{categoryDataPoints.length > 0 && (
<div>
<h4 className="text-sm font-medium text-slate-700 mb-2">
Datenpunkte in dieser Kategorie
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{categoryDataPoints.map((dp) => (
<div
key={dp.id}
className="flex items-center justify-between p-2 bg-white rounded-lg border border-slate-200"
>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{dp.code}
</span>
<span className="text-sm text-slate-700">
{dp.name[language]}
</span>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full ${getRetentionColor(
dp.retentionPeriod
)}`}
>
{RETENTION_PERIOD_INFO[dp.retentionPeriod].label[language]}
</span>
</div>
))}
</div>
</div>
)}
</div>
</td>
</tr>
)}
</>
)
})}
</tbody>
</table>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-600">
<span className="font-medium">Legende:</span>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-green-400" />
30 Tage
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-blue-400" />
90 Tage - 12 Monate
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-amber-400" />
2-3 Jahre
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-400" />
6-10 Jahre
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-purple-400" />
Variabel
</div>
</div>
</div>
)
}
export default RetentionMatrix

View File

@@ -0,0 +1,9 @@
/**
* Einwilligungen Components
*
* UI-Komponenten fuer das Datenpunktkatalog & DSI-Generator Modul.
*/
export { DataPointCatalog } from './DataPointCatalog'
export { PrivacyPolicyPreview } from './PrivacyPolicyPreview'
export { RetentionMatrix } from './RetentionMatrix'

View File

@@ -0,0 +1,16 @@
/**
* AI Compliance SDK - Components
*/
// Layout
export { SDKLayout } from './Layout'
// Sidebar
export { SDKSidebar } from './Sidebar'
// Command Bar
export { CommandBar } from './CommandBar'
// Document Upload
export { DocumentUploadSection } from './DocumentUpload'
export type { DocumentUploadSectionProps, UploadedDocument, ExtractedContent, ExtractedSection } from './DocumentUpload'

View File

@@ -0,0 +1,286 @@
'use client'
// =============================================================================
// TOM Generator Wizard Component
// Main wizard container with step navigation
// =============================================================================
import React from 'react'
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
import { TOM_GENERATOR_STEPS, TOMGeneratorStepId } from '@/lib/sdk/tom-generator/types'
// =============================================================================
// STEP INDICATOR
// =============================================================================
interface StepIndicatorProps {
stepId: TOMGeneratorStepId
stepNumber: number
title: string
isActive: boolean
isCompleted: boolean
onClick: () => void
}
function StepIndicator({
stepNumber,
title,
isActive,
isCompleted,
onClick,
}: StepIndicatorProps) {
return (
<button
onClick={onClick}
className={`flex items-center gap-3 p-3 rounded-lg transition-all w-full text-left ${
isActive
? 'bg-blue-50 border-2 border-blue-500'
: isCompleted
? 'bg-green-50 border border-green-300 hover:bg-green-100'
: 'bg-gray-50 border border-gray-200 hover:bg-gray-100'
}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
isActive
? 'bg-blue-500 text-white'
: isCompleted
? 'bg-green-500 text-white'
: 'bg-gray-300 text-gray-600'
}`}
>
{isCompleted ? (
<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>
) : (
stepNumber
)}
</div>
<span
className={`text-sm font-medium ${
isActive ? 'text-blue-700' : isCompleted ? 'text-green-700' : 'text-gray-600'
}`}
>
{title}
</span>
</button>
)
}
// =============================================================================
// WIZARD NAVIGATION
// =============================================================================
interface WizardNavigationProps {
onPrevious: () => void
onNext: () => void
onSave: () => void
canGoPrevious: boolean
canGoNext: boolean
isLastStep: boolean
isSaving: boolean
}
function WizardNavigation({
onPrevious,
onNext,
onSave,
canGoPrevious,
canGoNext,
isLastStep,
isSaving,
}: WizardNavigationProps) {
return (
<div className="flex justify-between items-center mt-8 pt-6 border-t">
<button
onClick={onPrevious}
disabled={!canGoPrevious}
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 ${
canGoPrevious
? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
: 'bg-gray-50 text-gray-400 cursor-not-allowed'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurück
</button>
<div className="flex gap-3">
<button
onClick={onSave}
disabled={isSaving}
className="px-4 py-2 rounded-lg font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 flex items-center gap-2"
>
{isSaving ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
)}
Speichern
</button>
<button
onClick={onNext}
disabled={!canGoNext && !isLastStep}
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 ${
canGoNext || isLastStep
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-blue-300 text-white cursor-not-allowed'
}`}
>
{isLastStep ? 'Abschließen' : 'Weiter'}
{!isLastStep && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
</button>
</div>
</div>
)
}
// =============================================================================
// PROGRESS BAR
// =============================================================================
interface ProgressBarProps {
percentage: number
}
function ProgressBar({ percentage }: ProgressBarProps) {
return (
<div className="mb-6">
<div className="flex justify-between text-sm text-gray-600 mb-2">
<span>Fortschritt</span>
<span>{percentage}%</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
}
// =============================================================================
// MAIN WIZARD COMPONENT
// =============================================================================
interface TOMGeneratorWizardProps {
children: React.ReactNode
showSidebar?: boolean
showProgress?: boolean
}
export function TOMGeneratorWizard({
children,
showSidebar = true,
showProgress = true,
}: TOMGeneratorWizardProps) {
const {
state,
currentStepIndex,
canGoNext,
canGoPrevious,
goToStep,
goToNextStep,
goToPreviousStep,
isStepCompleted,
getCompletionPercentage,
saveState,
isLoading,
} = useTOMGenerator()
const [isSaving, setIsSaving] = React.useState(false)
const handleSave = async () => {
setIsSaving(true)
try {
await saveState()
} catch (error) {
console.error('Failed to save:', error)
} finally {
setIsSaving(false)
}
}
const isLastStep = currentStepIndex === TOM_GENERATOR_STEPS.length - 1
return (
<div className="flex gap-8">
{/* Sidebar */}
{showSidebar && (
<div className="w-72 flex-shrink-0">
<div className="bg-white rounded-lg shadow-sm border p-4 sticky top-4">
<h3 className="font-semibold text-gray-900 mb-4">Wizard-Schritte</h3>
{showProgress && <ProgressBar percentage={getCompletionPercentage()} />}
<div className="space-y-2">
{TOM_GENERATOR_STEPS.map((step, index) => (
<StepIndicator
key={step.id}
stepId={step.id}
stepNumber={index + 1}
title={step.title.de}
isActive={state.currentStep === step.id}
isCompleted={isStepCompleted(step.id)}
onClick={() => goToStep(step.id)}
/>
))}
</div>
</div>
</div>
)}
{/* Main Content */}
<div className="flex-1 min-w-0">
<div className="bg-white rounded-lg shadow-sm border p-6">
{/* Step Header */}
<div className="mb-6">
<div className="text-sm text-blue-600 font-medium mb-1">
Schritt {currentStepIndex + 1} von {TOM_GENERATOR_STEPS.length}
</div>
<h2 className="text-2xl font-bold text-gray-900">
{TOM_GENERATOR_STEPS[currentStepIndex].title.de}
</h2>
<p className="text-gray-600 mt-1">
{TOM_GENERATOR_STEPS[currentStepIndex].description.de}
</p>
</div>
{/* Step Content */}
<div className="min-h-[400px]">{children}</div>
{/* Navigation */}
<WizardNavigation
onPrevious={goToPreviousStep}
onNext={goToNextStep}
onSave={handleSave}
canGoPrevious={canGoPrevious}
canGoNext={canGoNext}
isLastStep={isLastStep}
isSaving={isSaving}
/>
</div>
</div>
</div>
)
}
// =============================================================================
// EXPORTS
// =============================================================================
export { StepIndicator, WizardNavigation, ProgressBar }

View File

@@ -0,0 +1,19 @@
// =============================================================================
// TOM Generator Components - Public API
// =============================================================================
// Main Wizard
export {
TOMGeneratorWizard,
StepIndicator,
WizardNavigation,
ProgressBar,
} from './TOMGeneratorWizard'
// Step Components
export { ScopeRolesStep } from './steps/ScopeRolesStep'
export { DataCategoriesStep } from './steps/DataCategoriesStep'
export { ArchitectureStep } from './steps/ArchitectureStep'
export { SecurityProfileStep } from './steps/SecurityProfileStep'
export { RiskProtectionStep } from './steps/RiskProtectionStep'
export { ReviewExportStep } from './steps/ReviewExportStep'

View File

@@ -0,0 +1,460 @@
'use client'
// =============================================================================
// Step 3: Architecture & Hosting
// Hosting model, location, and provider configuration
// =============================================================================
import React, { useState, useEffect } from 'react'
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
import {
ArchitectureProfile,
HostingModel,
HostingLocation,
MultiTenancy,
CloudProvider,
} from '@/lib/sdk/tom-generator/types'
// =============================================================================
// CONSTANTS
// =============================================================================
const HOSTING_MODELS: { value: HostingModel; label: string; description: string; icon: string }[] = [
{
value: 'ON_PREMISE',
label: 'On-Premise',
description: 'Eigenes Rechenzentrum oder Co-Location',
icon: '🏢',
},
{
value: 'PRIVATE_CLOUD',
label: 'Private Cloud',
description: 'Dedizierte Cloud-Infrastruktur',
icon: '☁️',
},
{
value: 'PUBLIC_CLOUD',
label: 'Public Cloud',
description: 'AWS, Azure, GCP oder andere',
icon: '🌐',
},
{
value: 'HYBRID',
label: 'Hybrid',
description: 'Kombination aus On-Premise und Cloud',
icon: '🔄',
},
]
const HOSTING_LOCATIONS: { value: HostingLocation; label: string; description: string }[] = [
{ value: 'DE', label: 'Deutschland', description: 'Rechenzentrum in Deutschland' },
{ value: 'EU', label: 'EU (nicht DE)', description: 'Innerhalb der EU, aber nicht in Deutschland' },
{ value: 'EEA', label: 'EWR', description: 'Europäischer Wirtschaftsraum' },
{ value: 'THIRD_COUNTRY_ADEQUATE', label: 'Drittland (Angemessenheit)', description: 'Mit Angemessenheitsbeschluss' },
{ value: 'THIRD_COUNTRY', label: 'Drittland (andere)', description: 'Ohne Angemessenheitsbeschluss' },
]
const MULTI_TENANCY_OPTIONS: { value: MultiTenancy; label: string; description: string }[] = [
{ value: 'SINGLE_TENANT', label: 'Single-Tenant', description: 'Dedizierte Instanz pro Kunde' },
{ value: 'MULTI_TENANT', label: 'Multi-Tenant', description: 'Geteilte Infrastruktur mit logischer Trennung' },
{ value: 'DEDICATED', label: 'Dedicated', description: 'Dedizierte Hardware, aber gemeinsame Software' },
]
const COMMON_CERTIFICATIONS = [
'ISO 27001',
'SOC 2 Type II',
'C5',
'TISAX',
'PCI DSS',
'HIPAA',
'FedRAMP',
]
// =============================================================================
// COMPONENT
// =============================================================================
export function ArchitectureStep() {
const { state, setArchitectureProfile, completeCurrentStep } = useTOMGenerator()
const [formData, setFormData] = useState<Partial<ArchitectureProfile>>({
hostingModel: 'PUBLIC_CLOUD',
hostingLocation: 'EU',
providers: [],
multiTenancy: 'MULTI_TENANT',
hasSubprocessors: false,
subprocessorCount: 0,
encryptionAtRest: false,
encryptionInTransit: false,
})
const [newProvider, setNewProvider] = useState<Partial<CloudProvider>>({
name: '',
location: 'EU',
certifications: [],
})
const [certificationInput, setCertificationInput] = useState('')
// Load existing data
useEffect(() => {
if (state.architectureProfile) {
setFormData(state.architectureProfile)
}
}, [state.architectureProfile])
// Handle provider addition
const addProvider = () => {
if (newProvider.name?.trim()) {
const provider: CloudProvider = {
name: newProvider.name.trim(),
location: newProvider.location || 'EU',
certifications: newProvider.certifications || [],
}
setFormData((prev) => ({
...prev,
providers: [...(prev.providers || []), provider],
}))
setNewProvider({ name: '', location: 'EU', certifications: [] })
}
}
const removeProvider = (index: number) => {
setFormData((prev) => ({
...prev,
providers: (prev.providers || []).filter((_, i) => i !== index),
}))
}
// Handle certification toggle
const toggleCertification = (cert: string) => {
setNewProvider((prev) => {
const current = prev.certifications || []
const updated = current.includes(cert)
? current.filter((c) => c !== cert)
: [...current, cert]
return { ...prev, certifications: updated }
})
}
// Handle submit
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const profile: ArchitectureProfile = {
hostingModel: formData.hostingModel || 'PUBLIC_CLOUD',
hostingLocation: formData.hostingLocation || 'EU',
providers: formData.providers || [],
multiTenancy: formData.multiTenancy || 'MULTI_TENANT',
hasSubprocessors: formData.hasSubprocessors || false,
subprocessorCount: formData.subprocessorCount || 0,
encryptionAtRest: formData.encryptionAtRest || false,
encryptionInTransit: formData.encryptionInTransit || false,
}
setArchitectureProfile(profile)
completeCurrentStep(profile)
}
const showProviderSection = formData.hostingModel !== 'ON_PREMISE'
return (
<form onSubmit={handleSubmit} className="space-y-8">
{/* Hosting Model */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Hosting-Modell</h3>
<p className="text-sm text-gray-600 mb-4">
Wie wird Ihre Infrastruktur betrieben?
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{HOSTING_MODELS.map((model) => (
<label
key={model.value}
className={`relative flex items-start p-4 border rounded-lg cursor-pointer transition-all ${
formData.hostingModel === model.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="hostingModel"
value={model.value}
checked={formData.hostingModel === model.value}
onChange={(e) => setFormData((prev) => ({ ...prev, hostingModel: e.target.value as HostingModel }))}
className="sr-only"
/>
<span className="text-2xl mr-3">{model.icon}</span>
<div className="flex-1">
<span className="font-medium text-gray-900">{model.label}</span>
<p className="text-sm text-gray-500 mt-1">{model.description}</p>
</div>
{formData.hostingModel === model.value && (
<svg className="w-5 h-5 text-blue-500 absolute top-3 right-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
)}
</label>
))}
</div>
</div>
{/* Hosting Location */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Primärer Hosting-Standort</h3>
<p className="text-sm text-gray-600 mb-4">
Wo werden die Daten primär gespeichert?
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{HOSTING_LOCATIONS.map((location) => (
<label
key={location.value}
className={`relative flex items-start p-3 border rounded-lg cursor-pointer transition-all ${
formData.hostingLocation === location.value
? location.value.startsWith('THIRD_COUNTRY')
? 'border-amber-500 bg-amber-50'
: 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="hostingLocation"
value={location.value}
checked={formData.hostingLocation === location.value}
onChange={(e) => setFormData((prev) => ({ ...prev, hostingLocation: e.target.value as HostingLocation }))}
className="sr-only"
/>
<div className="flex-1">
<span className="font-medium text-gray-900">{location.label}</span>
<p className="text-xs text-gray-500 mt-0.5">{location.description}</p>
</div>
</label>
))}
</div>
{formData.hostingLocation?.startsWith('THIRD_COUNTRY') && (
<div className="mt-4 bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">
<strong>Hinweis:</strong> Bei Hosting in Drittländern sind zusätzliche Garantien nach Art. 46 DSGVO
erforderlich (z.B. Standardvertragsklauseln).
</p>
</div>
)}
</div>
{/* Cloud Providers */}
{showProviderSection && (
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Cloud-Provider / Rechenzentren</h3>
<p className="text-sm text-gray-600 mb-4">
Fügen Sie Ihre genutzten Provider hinzu.
</p>
{/* Existing providers */}
{formData.providers && formData.providers.length > 0 && (
<div className="space-y-2 mb-4">
{formData.providers.map((provider, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-gray-50 border rounded-lg"
>
<div>
<span className="font-medium text-gray-900">{provider.name}</span>
<span className="text-sm text-gray-500 ml-2">({provider.location})</span>
{provider.certifications.length > 0 && (
<div className="flex gap-1 mt-1">
{provider.certifications.map((cert) => (
<span
key={cert}
className="px-2 py-0.5 text-xs bg-green-100 text-green-800 rounded"
>
{cert}
</span>
))}
</div>
)}
</div>
<button
type="button"
onClick={() => removeProvider(index)}
className="text-gray-400 hover:text-red-500"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
)}
{/* Add new provider */}
<div className="border rounded-lg p-4 bg-gray-50">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Provider-Name
</label>
<input
type="text"
value={newProvider.name || ''}
onChange={(e) => setNewProvider((prev) => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="z.B. AWS, Azure, Hetzner"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Standort
</label>
<select
value={newProvider.location || 'EU'}
onChange={(e) => setNewProvider((prev) => ({ ...prev, location: e.target.value as HostingLocation }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
{HOSTING_LOCATIONS.map((loc) => (
<option key={loc.value} value={loc.value}>{loc.label}</option>
))}
</select>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Zertifizierungen
</label>
<div className="flex flex-wrap gap-2">
{COMMON_CERTIFICATIONS.map((cert) => (
<button
key={cert}
type="button"
onClick={() => toggleCertification(cert)}
className={`px-3 py-1 text-sm rounded-full border transition-all ${
newProvider.certifications?.includes(cert)
? 'bg-green-100 border-green-300 text-green-800'
: 'bg-white border-gray-300 text-gray-600 hover:border-gray-400'
}`}
>
{cert}
</button>
))}
</div>
</div>
<button
type="button"
onClick={addProvider}
disabled={!newProvider.name?.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
Provider hinzufügen
</button>
</div>
</div>
)}
{/* Multi-Tenancy */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Mandantentrennung</h3>
<p className="text-sm text-gray-600 mb-4">
Wie ist die Trennung zwischen verschiedenen Mandanten/Kunden umgesetzt?
</p>
<div className="space-y-3">
{MULTI_TENANCY_OPTIONS.map((option) => (
<label
key={option.value}
className={`relative flex items-start p-4 border rounded-lg cursor-pointer transition-all ${
formData.multiTenancy === option.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="multiTenancy"
value={option.value}
checked={formData.multiTenancy === option.value}
onChange={(e) => setFormData((prev) => ({ ...prev, multiTenancy: e.target.value as MultiTenancy }))}
className="sr-only"
/>
<div className="flex-1">
<span className="font-medium text-gray-900">{option.label}</span>
<p className="text-sm text-gray-500 mt-1">{option.description}</p>
</div>
</label>
))}
</div>
</div>
{/* Subprocessors */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Unterauftragsverarbeiter</h3>
<label className="flex items-center gap-3 cursor-pointer mb-4">
<input
type="checkbox"
checked={formData.hasSubprocessors || false}
onChange={(e) => setFormData((prev) => ({ ...prev, hasSubprocessors: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-gray-700">
Wir setzen Unterauftragsverarbeiter ein
</span>
</label>
{formData.hasSubprocessors && (
<div className="pl-8">
<label className="block text-sm font-medium text-gray-700 mb-1">
Anzahl der Unterauftragsverarbeiter
</label>
<input
type="number"
min="0"
value={formData.subprocessorCount || 0}
onChange={(e) => setFormData((prev) => ({ ...prev, subprocessorCount: parseInt(e.target.value) || 0 }))}
className="w-32 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
)}
</div>
{/* Encryption */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Verschlüsselung</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.encryptionAtRest || false}
onChange={(e) => setFormData((prev) => ({ ...prev, encryptionAtRest: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-gray-700 font-medium">Verschlüsselung ruhender Daten (at rest)</span>
<p className="text-sm text-gray-500">Daten werden verschlüsselt gespeichert</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.encryptionInTransit || false}
onChange={(e) => setFormData((prev) => ({ ...prev, encryptionInTransit: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-gray-700 font-medium">Transportverschlüsselung (in transit)</span>
<p className="text-sm text-gray-500">TLS/SSL für alle Datenübertragungen</p>
</div>
</label>
</div>
</div>
</form>
)
}
export default ArchitectureStep

View File

@@ -0,0 +1,374 @@
'use client'
// =============================================================================
// Step 2: Data Categories
// Data categories and data subjects selection
// =============================================================================
import React, { useState, useEffect } from 'react'
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
import {
DataProfile,
DataCategory,
DataSubject,
DataVolume,
DATA_CATEGORIES_METADATA,
DATA_SUBJECTS_METADATA,
} from '@/lib/sdk/tom-generator/types'
// =============================================================================
// CONSTANTS
// =============================================================================
const DATA_VOLUMES: { value: DataVolume; label: string; description: string }[] = [
{ value: 'LOW', label: 'Niedrig', description: '< 1.000 Datensätze' },
{ value: 'MEDIUM', label: 'Mittel', description: '1.000 - 100.000 Datensätze' },
{ value: 'HIGH', label: 'Hoch', description: '100.000 - 1 Mio. Datensätze' },
{ value: 'VERY_HIGH', label: 'Sehr hoch', description: '> 1 Mio. Datensätze' },
]
// =============================================================================
// COMPONENT
// =============================================================================
export function DataCategoriesStep() {
const { state, setDataProfile, completeCurrentStep } = useTOMGenerator()
const [formData, setFormData] = useState<Partial<DataProfile>>({
categories: [],
subjects: [],
hasSpecialCategories: false,
processesMinors: false,
dataVolume: 'MEDIUM',
thirdCountryTransfers: false,
thirdCountryList: [],
})
const [thirdCountryInput, setThirdCountryInput] = useState('')
// Load existing data
useEffect(() => {
if (state.dataProfile) {
setFormData(state.dataProfile)
}
}, [state.dataProfile])
// Check for special categories
useEffect(() => {
const hasSpecial = formData.categories?.some((cat) => {
const meta = DATA_CATEGORIES_METADATA.find((m) => m.id === cat)
return meta?.isSpecialCategory
})
setFormData((prev) => ({ ...prev, hasSpecialCategories: hasSpecial || false }))
}, [formData.categories])
// Check for minors
useEffect(() => {
const hasMinors = formData.subjects?.includes('MINORS')
setFormData((prev) => ({ ...prev, processesMinors: hasMinors || false }))
}, [formData.subjects])
// Handle category toggle
const toggleCategory = (category: DataCategory) => {
setFormData((prev) => {
const current = prev.categories || []
const updated = current.includes(category)
? current.filter((c) => c !== category)
: [...current, category]
return { ...prev, categories: updated }
})
}
// Handle subject toggle
const toggleSubject = (subject: DataSubject) => {
setFormData((prev) => {
const current = prev.subjects || []
const updated = current.includes(subject)
? current.filter((s) => s !== subject)
: [...current, subject]
return { ...prev, subjects: updated }
})
}
// Handle third country addition
const addThirdCountry = () => {
if (thirdCountryInput.trim()) {
setFormData((prev) => ({
...prev,
thirdCountryList: [...(prev.thirdCountryList || []), thirdCountryInput.trim()],
}))
setThirdCountryInput('')
}
}
const removeThirdCountry = (index: number) => {
setFormData((prev) => ({
...prev,
thirdCountryList: (prev.thirdCountryList || []).filter((_, i) => i !== index),
}))
}
// Handle submit
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const profile: DataProfile = {
categories: formData.categories || [],
subjects: formData.subjects || [],
hasSpecialCategories: formData.hasSpecialCategories || false,
processesMinors: formData.processesMinors || false,
dataVolume: formData.dataVolume || 'MEDIUM',
thirdCountryTransfers: formData.thirdCountryTransfers || false,
thirdCountryList: formData.thirdCountryList || [],
}
setDataProfile(profile)
completeCurrentStep(profile)
}
const selectedSpecialCategories = (formData.categories || []).filter((cat) => {
const meta = DATA_CATEGORIES_METADATA.find((m) => m.id === cat)
return meta?.isSpecialCategory
})
return (
<form onSubmit={handleSubmit} className="space-y-8">
{/* Data Categories */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Datenkategorien</h3>
<p className="text-sm text-gray-600 mb-4">
Wählen Sie alle Kategorien personenbezogener Daten, die Sie verarbeiten.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{DATA_CATEGORIES_METADATA.map((category) => (
<label
key={category.id}
className={`relative flex items-start p-3 border rounded-lg cursor-pointer transition-all ${
formData.categories?.includes(category.id)
? category.isSpecialCategory
? 'border-amber-500 bg-amber-50'
: 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="checkbox"
checked={formData.categories?.includes(category.id) || false}
onChange={() => toggleCategory(category.id)}
className="sr-only"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{category.name.de}</span>
{category.isSpecialCategory && (
<span className="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 rounded">
Art. 9
</span>
)}
</div>
</div>
{formData.categories?.includes(category.id) && (
<svg className="w-5 h-5 text-blue-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
)}
</label>
))}
</div>
{/* Special Categories Warning */}
{selectedSpecialCategories.length > 0 && (
<div className="mt-4 bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div>
<h4 className="font-medium text-amber-900">Besondere Kategorien nach Art. 9 DSGVO</h4>
<p className="text-sm text-amber-700 mt-1">
Sie verarbeiten besonders schützenswerte Daten. Dies erfordert zusätzliche Schutzmaßnahmen
und möglicherweise eine Datenschutz-Folgenabschätzung (DSFA).
</p>
</div>
</div>
</div>
)}
</div>
{/* Data Subjects */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Betroffene Personen</h3>
<p className="text-sm text-gray-600 mb-4">
Wählen Sie alle Personengruppen, deren Daten Sie verarbeiten.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{DATA_SUBJECTS_METADATA.map((subject) => (
<label
key={subject.id}
className={`relative flex items-start p-3 border rounded-lg cursor-pointer transition-all ${
formData.subjects?.includes(subject.id)
? subject.isVulnerable
? 'border-red-500 bg-red-50'
: 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="checkbox"
checked={formData.subjects?.includes(subject.id) || false}
onChange={() => toggleSubject(subject.id)}
className="sr-only"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{subject.name.de}</span>
{subject.isVulnerable && (
<span className="px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 rounded">
Schutzbedürftig
</span>
)}
</div>
</div>
{formData.subjects?.includes(subject.id) && (
<svg className="w-5 h-5 text-blue-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
)}
</label>
))}
</div>
{/* Minors Warning */}
{formData.subjects?.includes('MINORS') && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div>
<h4 className="font-medium text-red-900">Verarbeitung von Minderjährigen-Daten</h4>
<p className="text-sm text-red-700 mt-1">
Die Verarbeitung von Daten Minderjähriger erfordert besondere Schutzmaßnahmen nach Art. 8 DSGVO
und erhöhte Sorgfaltspflichten.
</p>
</div>
</div>
</div>
)}
</div>
{/* Data Volume */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Datenvolumen</h3>
<p className="text-sm text-gray-600 mb-4">
Geschätzte Anzahl der Datensätze, die Sie verarbeiten.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{DATA_VOLUMES.map((volume) => (
<label
key={volume.value}
className={`relative flex flex-col items-center p-4 border rounded-lg cursor-pointer transition-all text-center ${
formData.dataVolume === volume.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="dataVolume"
value={volume.value}
checked={formData.dataVolume === volume.value}
onChange={(e) => setFormData((prev) => ({ ...prev, dataVolume: e.target.value as DataVolume }))}
className="sr-only"
/>
<span className="font-medium text-gray-900">{volume.label}</span>
<span className="text-xs text-gray-500 mt-1">{volume.description}</span>
</label>
))}
</div>
</div>
{/* Third Country Transfers */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Drittlandübermittlungen</h3>
<p className="text-sm text-gray-600 mb-4">
Werden Daten in Länder außerhalb der EU/EWR übermittelt?
</p>
<div className="space-y-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.thirdCountryTransfers || false}
onChange={(e) => setFormData((prev) => ({ ...prev, thirdCountryTransfers: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-gray-700">
Ja, wir übermitteln Daten in Drittländer
</span>
</label>
{formData.thirdCountryTransfers && (
<div className="pl-8">
<label className="block text-sm font-medium text-gray-700 mb-1">
Zielländer
</label>
<div className="flex gap-2">
<input
type="text"
value={thirdCountryInput}
onChange={(e) => setThirdCountryInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addThirdCountry())}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="z.B. USA, Schweiz, UK"
/>
<button
type="button"
onClick={addThirdCountry}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
>
Hinzufügen
</button>
</div>
{formData.thirdCountryList && formData.thirdCountryList.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{formData.thirdCountryList.map((country, index) => (
<span
key={index}
className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 text-gray-800 rounded-full text-sm"
>
{country}
<button
type="button"
onClick={() => removeThirdCountry(index)}
className="hover:text-gray-600"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
)}
{formData.thirdCountryList?.includes('USA') && (
<div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm text-yellow-800">
<strong>Hinweis:</strong> Für Übermittlungen in die USA ist seit dem EU-US Data Privacy Framework
ein Angemessenheitsbeschluss vorhanden. Prüfen Sie, ob Ihr US-Partner zertifiziert ist.
</p>
</div>
)}
</div>
)}
</div>
</div>
</form>
)
}
export default DataCategoriesStep

View File

@@ -0,0 +1,593 @@
'use client'
// =============================================================================
// Step 6: Review & Export
// Summary, derived TOMs table, gap analysis, and export
// =============================================================================
import React, { useEffect, useState } from 'react'
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
import { CONTROL_CATEGORIES } from '@/lib/sdk/tom-generator/types'
import { getControlById } from '@/lib/sdk/tom-generator/controls/loader'
import { generateDOCXBlob } from '@/lib/sdk/tom-generator/export/docx'
import { generatePDFBlob } from '@/lib/sdk/tom-generator/export/pdf'
import { generateZIPBlob } from '@/lib/sdk/tom-generator/export/zip'
// =============================================================================
// SUMMARY CARD COMPONENT
// =============================================================================
interface SummaryCardProps {
title: string
value: string | number
description?: string
variant?: 'default' | 'success' | 'warning' | 'danger'
}
function SummaryCard({ title, value, description, variant = 'default' }: SummaryCardProps) {
const colors = {
default: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
danger: 'bg-red-100 text-red-800',
}
return (
<div className={`rounded-lg p-4 ${colors[variant]}`}>
<div className="text-2xl font-bold">{value}</div>
<div className="font-medium">{title}</div>
{description && <div className="text-sm opacity-75 mt-1">{description}</div>}
</div>
)
}
// =============================================================================
// TOMS TABLE COMPONENT
// =============================================================================
function TOMsTable() {
const { state } = useTOMGenerator()
const [selectedCategory, setSelectedCategory] = useState<string>('all')
const [selectedApplicability, setSelectedApplicability] = useState<string>('all')
const filteredTOMs = state.derivedTOMs.filter((tom) => {
const control = getControlById(tom.controlId)
const categoryMatch = selectedCategory === 'all' || control?.category === selectedCategory
const applicabilityMatch = selectedApplicability === 'all' || tom.applicability === selectedApplicability
return categoryMatch && applicabilityMatch
})
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string }> = {
IMPLEMENTED: { bg: 'bg-green-100', text: 'text-green-800' },
PARTIAL: { bg: 'bg-yellow-100', text: 'text-yellow-800' },
NOT_IMPLEMENTED: { bg: 'bg-red-100', text: 'text-red-800' },
}
const labels: Record<string, string> = {
IMPLEMENTED: 'Umgesetzt',
PARTIAL: 'Teilweise',
NOT_IMPLEMENTED: 'Offen',
}
const config = badges[status] || badges.NOT_IMPLEMENTED
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>
{labels[status] || status}
</span>
)
}
const getApplicabilityBadge = (applicability: string) => {
const badges: Record<string, { bg: string; text: string }> = {
REQUIRED: { bg: 'bg-red-100', text: 'text-red-800' },
RECOMMENDED: { bg: 'bg-blue-100', text: 'text-blue-800' },
OPTIONAL: { bg: 'bg-gray-100', text: 'text-gray-800' },
NOT_APPLICABLE: { bg: 'bg-gray-50', text: 'text-gray-500' },
}
const labels: Record<string, string> = {
REQUIRED: 'Erforderlich',
RECOMMENDED: 'Empfohlen',
OPTIONAL: 'Optional',
NOT_APPLICABLE: 'N/A',
}
const config = badges[applicability] || badges.OPTIONAL
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>
{labels[applicability] || applicability}
</span>
)
}
return (
<div>
{/* Filters */}
<div className="flex flex-wrap gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="all">Alle Kategorien</option>
{CONTROL_CATEGORIES.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name.de}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Anwendbarkeit</label>
<select
value={selectedApplicability}
onChange={(e) => setSelectedApplicability(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="all">Alle</option>
<option value="REQUIRED">Erforderlich</option>
<option value="RECOMMENDED">Empfohlen</option>
<option value="OPTIONAL">Optional</option>
<option value="NOT_APPLICABLE">Nicht anwendbar</option>
</select>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto border rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
ID
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Maßnahme
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Anwendbarkeit
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nachweise
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredTOMs.map((tom) => (
<tr key={tom.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-mono text-gray-900">
{tom.controlId}
</td>
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900">{tom.name}</div>
<div className="text-xs text-gray-500 max-w-md truncate">{tom.applicabilityReason}</div>
</td>
<td className="px-4 py-3">
{getApplicabilityBadge(tom.applicability)}
</td>
<td className="px-4 py-3">
{getStatusBadge(tom.implementationStatus)}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{tom.linkedEvidence.length > 0 ? (
<span className="text-green-600">{tom.linkedEvidence.length} Dok.</span>
) : tom.evidenceGaps.length > 0 ? (
<span className="text-red-600">{tom.evidenceGaps.length} fehlen</span>
) : (
'-'
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-2 text-sm text-gray-500">
{filteredTOMs.length} von {state.derivedTOMs.length} Maßnahmen angezeigt
</div>
</div>
)
}
// =============================================================================
// GAP ANALYSIS PANEL
// =============================================================================
function GapAnalysisPanel() {
const { state, runGapAnalysis } = useTOMGenerator()
useEffect(() => {
if (!state.gapAnalysis && state.derivedTOMs.length > 0) {
runGapAnalysis()
}
}, [state.derivedTOMs, state.gapAnalysis, runGapAnalysis])
if (!state.gapAnalysis) {
return (
<div className="text-center py-8 text-gray-500">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2" />
Lückenanalyse wird durchgeführt...
</div>
)
}
const { overallScore, missingControls, partialControls, recommendations } = state.gapAnalysis
const getScoreColor = (score: number) => {
if (score >= 80) return 'text-green-600'
if (score >= 50) return 'text-yellow-600'
return 'text-red-600'
}
return (
<div className="space-y-6">
{/* Score */}
<div className="text-center">
<div className={`text-5xl font-bold ${getScoreColor(overallScore)}`}>
{overallScore}%
</div>
<div className="text-gray-600 mt-1">Compliance Score</div>
</div>
{/* Progress bar */}
<div className="h-4 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${
overallScore >= 80 ? 'bg-green-500' : overallScore >= 50 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${overallScore}%` }}
/>
</div>
{/* Missing Controls */}
{missingControls.length > 0 && (
<div>
<h4 className="font-medium text-gray-900 mb-2">
Fehlende Maßnahmen ({missingControls.length})
</h4>
<div className="space-y-2 max-h-48 overflow-y-auto">
{missingControls.map((mc) => {
const control = getControlById(mc.controlId)
return (
<div key={mc.controlId} className="flex items-center justify-between p-2 bg-red-50 rounded-lg">
<div>
<span className="font-mono text-sm text-gray-600">{mc.controlId}</span>
<span className="ml-2 text-sm text-gray-900">{control?.name.de}</span>
</div>
<span className={`px-2 py-0.5 text-xs rounded ${
mc.priority === 'CRITICAL' ? 'bg-red-200 text-red-800' :
mc.priority === 'HIGH' ? 'bg-orange-200 text-orange-800' :
'bg-gray-200 text-gray-800'
}`}>
{mc.priority}
</span>
</div>
)
})}
</div>
</div>
)}
{/* Partial Controls */}
{partialControls.length > 0 && (
<div>
<h4 className="font-medium text-gray-900 mb-2">
Teilweise umgesetzt ({partialControls.length})
</h4>
<div className="space-y-2 max-h-48 overflow-y-auto">
{partialControls.map((pc) => {
const control = getControlById(pc.controlId)
return (
<div key={pc.controlId} className="p-2 bg-yellow-50 rounded-lg">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-gray-600">{pc.controlId}</span>
<span className="text-sm text-gray-900">{control?.name.de}</span>
</div>
<div className="text-xs text-yellow-700 mt-1">
Fehlend: {pc.missingAspects.join(', ')}
</div>
</div>
)
})}
</div>
</div>
)}
{/* Recommendations */}
{recommendations.length > 0 && (
<div>
<h4 className="font-medium text-gray-900 mb-2">Empfehlungen</h4>
<ul className="space-y-2">
{recommendations.map((rec, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-gray-600">
<svg className="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
{rec}
</li>
))}
</ul>
</div>
)}
</div>
)
}
// =============================================================================
// EXPORT PANEL
// =============================================================================
function ExportPanel() {
const { state, addExport } = useTOMGenerator()
const [isExporting, setIsExporting] = useState<string | null>(null)
const handleExport = async (format: 'docx' | 'pdf' | 'json' | 'zip') => {
setIsExporting(format)
try {
let blob: Blob
let filename: string
switch (format) {
case 'docx':
blob = await generateDOCXBlob(state, { language: 'de' })
filename = `TOM-Dokumentation-${new Date().toISOString().split('T')[0]}.docx`
break
case 'pdf':
blob = await generatePDFBlob(state, { language: 'de' })
filename = `TOM-Dokumentation-${new Date().toISOString().split('T')[0]}.pdf`
break
case 'json':
blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
filename = `TOM-Export-${new Date().toISOString().split('T')[0]}.json`
break
case 'zip':
blob = await generateZIPBlob(state, { language: 'de' })
filename = `TOM-Package-${new Date().toISOString().split('T')[0]}.zip`
break
default:
return
}
// Download
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
// Record export
addExport({
id: `export-${Date.now()}`,
format: format.toUpperCase() as 'DOCX' | 'PDF' | 'JSON' | 'ZIP',
generatedAt: new Date(),
filename,
})
} catch (error) {
console.error('Export failed:', error)
} finally {
setIsExporting(null)
}
}
const exportFormats = [
{ id: 'docx', label: 'Word (.docx)', icon: '📄', description: 'Bearbeitbares Dokument' },
{ id: 'pdf', label: 'PDF', icon: '📕', description: 'Druckversion' },
{ id: 'json', label: 'JSON', icon: '💾', description: 'Maschinelles Format' },
{ id: 'zip', label: 'ZIP-Paket', icon: '📦', description: 'Vollständiges Paket' },
]
return (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{exportFormats.map((format) => (
<button
key={format.id}
onClick={() => handleExport(format.id as 'docx' | 'pdf' | 'json' | 'zip')}
disabled={isExporting !== null}
className={`p-4 border rounded-lg text-center transition-all ${
isExporting === format.id
? 'bg-blue-50 border-blue-300'
: 'hover:bg-gray-50 hover:border-gray-300'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<div className="text-3xl mb-2">{format.icon}</div>
<div className="font-medium text-gray-900">{format.label}</div>
<div className="text-xs text-gray-500">{format.description}</div>
{isExporting === format.id && (
<div className="mt-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mx-auto" />
</div>
)}
</button>
))}
</div>
{/* Export History */}
{state.exports.length > 0 && (
<div className="mt-6">
<h4 className="font-medium text-gray-900 mb-2">Letzte Exporte</h4>
<div className="space-y-2">
{state.exports.slice(-5).reverse().map((exp) => (
<div key={exp.id} className="flex items-center justify-between p-2 bg-gray-50 rounded-lg text-sm">
<span className="font-medium">{exp.filename}</span>
<span className="text-gray-500">
{new Date(exp.generatedAt).toLocaleString('de-DE')}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export function ReviewExportStep() {
const { state, deriveTOMs, completeCurrentStep } = useTOMGenerator()
const [activeTab, setActiveTab] = useState<'summary' | 'toms' | 'gaps' | 'export'>('summary')
// Derive TOMs if not already done
useEffect(() => {
if (state.derivedTOMs.length === 0 && state.companyProfile && state.dataProfile) {
deriveTOMs()
}
}, [state, deriveTOMs])
// Mark step as complete when viewing
useEffect(() => {
completeCurrentStep({ reviewed: true })
}, [completeCurrentStep])
// Statistics
const stats = {
totalTOMs: state.derivedTOMs.length,
required: state.derivedTOMs.filter((t) => t.applicability === 'REQUIRED').length,
implemented: state.derivedTOMs.filter((t) => t.implementationStatus === 'IMPLEMENTED').length,
partial: state.derivedTOMs.filter((t) => t.implementationStatus === 'PARTIAL').length,
documents: state.documents.length,
score: state.gapAnalysis?.overallScore ?? 0,
}
const tabs = [
{ id: 'summary', label: 'Zusammenfassung' },
{ id: 'toms', label: 'TOMs-Tabelle' },
{ id: 'gaps', label: 'Lückenanalyse' },
{ id: 'export', label: 'Export' },
]
return (
<div className="space-y-6">
{/* Tabs */}
<div className="border-b">
<nav className="flex space-x-8">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-all ${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab Content */}
<div className="min-h-[400px]">
{activeTab === 'summary' && (
<div className="space-y-6">
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<SummaryCard
title="Gesamt TOMs"
value={stats.totalTOMs}
variant="default"
/>
<SummaryCard
title="Erforderlich"
value={stats.required}
variant="danger"
/>
<SummaryCard
title="Umgesetzt"
value={stats.implemented}
variant="success"
/>
<SummaryCard
title="Teilweise"
value={stats.partial}
variant="warning"
/>
<SummaryCard
title="Dokumente"
value={stats.documents}
variant="default"
/>
<SummaryCard
title="Score"
value={`${stats.score}%`}
variant={stats.score >= 80 ? 'success' : stats.score >= 50 ? 'warning' : 'danger'}
/>
</div>
{/* Profile Summaries */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Company */}
{state.companyProfile && (
<div className="bg-white border rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Unternehmen</h4>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-500">Name:</dt>
<dd className="text-gray-900">{state.companyProfile.name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Branche:</dt>
<dd className="text-gray-900">{state.companyProfile.industry}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Rolle:</dt>
<dd className="text-gray-900">{state.companyProfile.role}</dd>
</div>
</dl>
</div>
)}
{/* Risk */}
{state.riskProfile && (
<div className="bg-white border rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Schutzbedarf</h4>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-500">Level:</dt>
<dd className={`font-medium ${
state.riskProfile.protectionLevel === 'VERY_HIGH' ? 'text-red-600' :
state.riskProfile.protectionLevel === 'HIGH' ? 'text-yellow-600' : 'text-green-600'
}`}>
{state.riskProfile.protectionLevel}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">DSFA erforderlich:</dt>
<dd className={state.riskProfile.dsfaRequired ? 'text-red-600 font-medium' : 'text-gray-900'}>
{state.riskProfile.dsfaRequired ? 'Ja' : 'Nein'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">CIA (V/I/V):</dt>
<dd className="text-gray-900">
{state.riskProfile.ciaAssessment.confidentiality}/
{state.riskProfile.ciaAssessment.integrity}/
{state.riskProfile.ciaAssessment.availability}
</dd>
</div>
</dl>
</div>
)}
</div>
</div>
)}
{activeTab === 'toms' && <TOMsTable />}
{activeTab === 'gaps' && <GapAnalysisPanel />}
{activeTab === 'export' && <ExportPanel />}
</div>
</div>
)
}
export default ReviewExportStep

View File

@@ -0,0 +1,422 @@
'use client'
// =============================================================================
// Step 5: Risk & Protection Level
// CIA assessment and protection level determination
// =============================================================================
import React, { useState, useEffect } from 'react'
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
import {
RiskProfile,
CIARating,
ProtectionLevel,
calculateProtectionLevel,
isDSFARequired,
} from '@/lib/sdk/tom-generator/types'
// =============================================================================
// CONSTANTS
// =============================================================================
const CIA_LEVELS: { value: CIARating; label: string; description: string }[] = [
{ value: 1, label: 'Sehr gering', description: 'Kein nennenswerter Schaden bei Verletzung' },
{ value: 2, label: 'Gering', description: 'Begrenzter, beherrschbarer Schaden' },
{ value: 3, label: 'Mittel', description: 'Erheblicher Schaden, aber kompensierbar' },
{ value: 4, label: 'Hoch', description: 'Schwerwiegender Schaden, schwer kompensierbar' },
{ value: 5, label: 'Sehr hoch', description: 'Existenzbedrohender oder irreversibler Schaden' },
]
const REGULATORY_REQUIREMENTS = [
'DSGVO',
'BDSG',
'MaRisk (Finanz)',
'BAIT (Finanz)',
'PSD2 (Zahlungsdienste)',
'SGB (Gesundheit)',
'MDR (Medizinprodukte)',
'TISAX (Automotive)',
'KRITIS (Kritische Infrastruktur)',
'NIS2',
'ISO 27001',
'SOC 2',
]
// =============================================================================
// CIA SLIDER COMPONENT
// =============================================================================
interface CIASliderProps {
label: string
description: string
value: CIARating
onChange: (value: CIARating) => void
}
function CIASlider({ label, description, value, onChange }: CIASliderProps) {
const level = CIA_LEVELS.find((l) => l.value === value)
const getColor = (v: CIARating) => {
if (v <= 2) return 'bg-green-500'
if (v === 3) return 'bg-yellow-500'
return 'bg-red-500'
}
return (
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex justify-between items-start mb-3">
<div>
<h4 className="font-medium text-gray-900">{label}</h4>
<p className="text-sm text-gray-500">{description}</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium text-white ${getColor(value)}`}>
{level?.label}
</span>
</div>
<input
type="range"
min="1"
max="5"
value={value}
onChange={(e) => onChange(parseInt(e.target.value) as CIARating)}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
<div className="flex justify-between mt-1 text-xs text-gray-400">
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
</div>
<p className="text-sm text-gray-600 mt-2 italic">{level?.description}</p>
</div>
)
}
// =============================================================================
// PROTECTION LEVEL DISPLAY
// =============================================================================
interface ProtectionLevelDisplayProps {
level: ProtectionLevel
}
function ProtectionLevelDisplay({ level }: ProtectionLevelDisplayProps) {
const config: Record<ProtectionLevel, { label: string; color: string; bg: string; description: string }> = {
NORMAL: {
label: 'Normal',
color: 'text-green-800',
bg: 'bg-green-100',
description: 'Standard-Schutzmaßnahmen ausreichend',
},
HIGH: {
label: 'Hoch',
color: 'text-yellow-800',
bg: 'bg-yellow-100',
description: 'Erweiterte Schutzmaßnahmen erforderlich',
},
VERY_HIGH: {
label: 'Sehr hoch',
color: 'text-red-800',
bg: 'bg-red-100',
description: 'Höchste Schutzmaßnahmen erforderlich',
},
}
const { label, color, bg, description } = config[level]
return (
<div className={`${bg} rounded-lg p-4 border`}>
<div className="flex items-center gap-3">
<div className={`text-2xl font-bold ${color}`}>{label}</div>
</div>
<p className={`text-sm ${color} mt-1`}>{description}</p>
</div>
)
}
// =============================================================================
// COMPONENT
// =============================================================================
export function RiskProtectionStep() {
const { state, setRiskProfile, completeCurrentStep } = useTOMGenerator()
const [formData, setFormData] = useState<Partial<RiskProfile>>({
ciaAssessment: {
confidentiality: 3,
integrity: 3,
availability: 3,
justification: '',
},
protectionLevel: 'HIGH',
specialRisks: [],
regulatoryRequirements: ['DSGVO'],
hasHighRiskProcessing: false,
dsfaRequired: false,
})
const [specialRiskInput, setSpecialRiskInput] = useState('')
// Load existing data
useEffect(() => {
if (state.riskProfile) {
setFormData(state.riskProfile)
}
}, [state.riskProfile])
// Calculate protection level when CIA changes
useEffect(() => {
if (formData.ciaAssessment) {
const level = calculateProtectionLevel(formData.ciaAssessment)
const dsfaReq = isDSFARequired(state.dataProfile, {
...formData,
protectionLevel: level,
} as RiskProfile)
setFormData((prev) => ({
...prev,
protectionLevel: level,
dsfaRequired: dsfaReq,
}))
}
}, [formData.ciaAssessment, state.dataProfile])
// Handle CIA changes
const handleCIAChange = (field: 'confidentiality' | 'integrity' | 'availability', value: CIARating) => {
setFormData((prev) => ({
...prev,
ciaAssessment: {
...prev.ciaAssessment!,
[field]: value,
},
}))
}
// Handle regulatory requirements toggle
const toggleRequirement = (req: string) => {
setFormData((prev) => {
const current = prev.regulatoryRequirements || []
const updated = current.includes(req)
? current.filter((r) => r !== req)
: [...current, req]
return { ...prev, regulatoryRequirements: updated }
})
}
// Handle special risk addition
const addSpecialRisk = () => {
if (specialRiskInput.trim()) {
setFormData((prev) => ({
...prev,
specialRisks: [...(prev.specialRisks || []), specialRiskInput.trim()],
}))
setSpecialRiskInput('')
}
}
const removeSpecialRisk = (index: number) => {
setFormData((prev) => ({
...prev,
specialRisks: (prev.specialRisks || []).filter((_, i) => i !== index),
}))
}
// Handle submit
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const profile: RiskProfile = {
ciaAssessment: formData.ciaAssessment!,
protectionLevel: formData.protectionLevel || 'HIGH',
specialRisks: formData.specialRisks || [],
regulatoryRequirements: formData.regulatoryRequirements || [],
hasHighRiskProcessing: formData.hasHighRiskProcessing || false,
dsfaRequired: formData.dsfaRequired || false,
}
setRiskProfile(profile)
completeCurrentStep(profile)
}
return (
<form onSubmit={handleSubmit} className="space-y-8">
{/* CIA Assessment */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">CIA-Bewertung</h3>
<p className="text-sm text-gray-600 mb-4">
Bewerten Sie die Schutzziele für Ihre Datenverarbeitung. Was passiert, wenn die Vertraulichkeit,
Integrität oder Verfügbarkeit der Daten beeinträchtigt wird?
</p>
<div className="space-y-4">
<CIASlider
label="Vertraulichkeit (Confidentiality)"
description="Schutz vor unbefugtem Zugriff auf Daten"
value={formData.ciaAssessment?.confidentiality || 3}
onChange={(v) => handleCIAChange('confidentiality', v)}
/>
<CIASlider
label="Integrität (Integrity)"
description="Schutz vor unbefugter Änderung von Daten"
value={formData.ciaAssessment?.integrity || 3}
onChange={(v) => handleCIAChange('integrity', v)}
/>
<CIASlider
label="Verfügbarkeit (Availability)"
description="Sicherstellung des Zugriffs auf Daten"
value={formData.ciaAssessment?.availability || 3}
onChange={(v) => handleCIAChange('availability', v)}
/>
</div>
{/* Justification */}
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Begründung der Bewertung
</label>
<textarea
value={formData.ciaAssessment?.justification || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
ciaAssessment: {
...prev.ciaAssessment!,
justification: e.target.value,
},
}))
}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Beschreiben Sie kurz, warum Sie diese Bewertung gewählt haben..."
/>
</div>
</div>
{/* Calculated Protection Level */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Ermittelter Schutzbedarf</h3>
<p className="text-sm text-gray-600 mb-4">
Basierend auf Ihrer CIA-Bewertung ergibt sich folgender Schutzbedarf:
</p>
<ProtectionLevelDisplay level={formData.protectionLevel || 'HIGH'} />
</div>
{/* DSFA Indicator */}
{formData.dsfaRequired && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex gap-3">
<svg className="w-6 h-6 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div>
<h4 className="font-medium text-red-900">DSFA erforderlich</h4>
<p className="text-sm text-red-700 mt-1">
Aufgrund Ihrer Datenverarbeitung (besondere Kategorien, Minderjährige oder sehr hoher Schutzbedarf)
ist eine Datenschutz-Folgenabschätzung nach Art. 35 DSGVO erforderlich.
</p>
</div>
</div>
</div>
)}
{/* High Risk Processing */}
<div>
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
<input
type="checkbox"
checked={formData.hasHighRiskProcessing || false}
onChange={(e) => setFormData((prev) => ({ ...prev, hasHighRiskProcessing: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-gray-700 font-medium">Hochrisiko-Verarbeitung</span>
<p className="text-sm text-gray-500">
z.B. Profiling, automatisierte Entscheidungen, systematische Überwachung
</p>
</div>
</label>
</div>
{/* Special Risks */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Besondere Risiken</h3>
<p className="text-sm text-gray-600 mb-4">
Identifizieren Sie spezifische Risiken Ihrer Datenverarbeitung.
</p>
<div className="flex gap-2 mb-3">
<input
type="text"
value={specialRiskInput}
onChange={(e) => setSpecialRiskInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addSpecialRisk())}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="z.B. Cloud-Abhängigkeit, Insider-Bedrohungen"
/>
<button
type="button"
onClick={addSpecialRisk}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
>
Hinzufügen
</button>
</div>
{formData.specialRisks && formData.specialRisks.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.specialRisks.map((risk, index) => (
<span
key={index}
className="inline-flex items-center gap-1 px-3 py-1 bg-red-100 text-red-800 rounded-full text-sm"
>
{risk}
<button
type="button"
onClick={() => removeSpecialRisk(index)}
className="hover:text-red-600"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
)}
</div>
{/* Regulatory Requirements */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Regulatorische Anforderungen</h3>
<p className="text-sm text-gray-600 mb-4">
Welche regulatorischen Anforderungen gelten für Ihre Datenverarbeitung?
</p>
<div className="flex flex-wrap gap-2">
{REGULATORY_REQUIREMENTS.map((req) => (
<button
key={req}
type="button"
onClick={() => toggleRequirement(req)}
className={`px-4 py-2 rounded-full border text-sm font-medium transition-all ${
formData.regulatoryRequirements?.includes(req)
? 'bg-blue-100 border-blue-300 text-blue-800'
: 'bg-white border-gray-300 text-gray-600 hover:border-gray-400'
}`}
>
{req}
</button>
))}
</div>
</div>
</form>
)
}
export default RiskProtectionStep

View File

@@ -0,0 +1,403 @@
'use client'
// =============================================================================
// Step 1: Scope & Roles
// Company profile and role definition
// =============================================================================
import React, { useState, useEffect } from 'react'
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
import {
CompanyProfile,
CompanyRole,
CompanySize,
} from '@/lib/sdk/tom-generator/types'
// =============================================================================
// CONSTANTS
// =============================================================================
const COMPANY_SIZES: { value: CompanySize; label: string; description: string }[] = [
{ value: 'MICRO', label: 'Kleinstunternehmen', description: '< 10 Mitarbeiter' },
{ value: 'SMALL', label: 'Kleinunternehmen', description: '10-49 Mitarbeiter' },
{ value: 'MEDIUM', label: 'Mittelunternehmen', description: '50-249 Mitarbeiter' },
{ value: 'LARGE', label: 'Großunternehmen', description: '250-999 Mitarbeiter' },
{ value: 'ENTERPRISE', label: 'Konzern', description: '1000+ Mitarbeiter' },
]
const COMPANY_ROLES: { value: CompanyRole; label: string; description: string }[] = [
{
value: 'CONTROLLER',
label: 'Verantwortlicher',
description: 'Sie bestimmen Zweck und Mittel der Datenverarbeitung',
},
{
value: 'PROCESSOR',
label: 'Auftragsverarbeiter',
description: 'Sie verarbeiten Daten im Auftrag eines Verantwortlichen',
},
{
value: 'JOINT_CONTROLLER',
label: 'Gemeinsam Verantwortlicher',
description: 'Sie bestimmen gemeinsam mit anderen Zweck und Mittel',
},
]
const INDUSTRIES = [
'Software / IT',
'Finanzdienstleistungen',
'Gesundheitswesen',
'E-Commerce / Handel',
'Beratung / Professional Services',
'Produktion / Industrie',
'Bildung / Forschung',
'Öffentlicher Sektor',
'Medien / Kommunikation',
'Transport / Logistik',
'Sonstige',
]
// =============================================================================
// COMPONENT
// =============================================================================
export function ScopeRolesStep() {
const { state, setCompanyProfile, completeCurrentStep } = useTOMGenerator()
const [formData, setFormData] = useState<Partial<CompanyProfile>>({
id: '',
name: '',
industry: '',
size: 'MEDIUM',
role: 'CONTROLLER',
products: [],
dpoPerson: '',
dpoEmail: '',
itSecurityContact: '',
})
const [productInput, setProductInput] = useState('')
const [errors, setErrors] = useState<Record<string, string>>({})
// Load existing data
useEffect(() => {
if (state.companyProfile) {
setFormData(state.companyProfile)
}
}, [state.companyProfile])
// Validation
const validate = (): boolean => {
const newErrors: Record<string, string> = {}
if (!formData.name?.trim()) {
newErrors.name = 'Unternehmensname ist erforderlich'
}
if (!formData.industry) {
newErrors.industry = 'Bitte wählen Sie eine Branche'
}
if (!formData.role) {
newErrors.role = 'Bitte wählen Sie eine Rolle'
}
if (formData.dpoEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.dpoEmail)) {
newErrors.dpoEmail = 'Bitte geben Sie eine gültige E-Mail-Adresse ein'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
// Handle form changes
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target
setFormData((prev) => ({ ...prev, [name]: value }))
// Clear error when field is edited
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: '' }))
}
}
// Handle product addition
const addProduct = () => {
if (productInput.trim()) {
setFormData((prev) => ({
...prev,
products: [...(prev.products || []), productInput.trim()],
}))
setProductInput('')
}
}
const removeProduct = (index: number) => {
setFormData((prev) => ({
...prev,
products: (prev.products || []).filter((_, i) => i !== index),
}))
}
// Handle submit
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
const profile: CompanyProfile = {
id: formData.id || `company-${Date.now()}`,
name: formData.name!,
industry: formData.industry!,
size: formData.size!,
role: formData.role!,
products: formData.products || [],
dpoPerson: formData.dpoPerson || null,
dpoEmail: formData.dpoEmail || null,
itSecurityContact: formData.itSecurityContact || null,
}
setCompanyProfile(profile)
completeCurrentStep(profile)
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Company Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Unternehmensname <span className="text-red-500">*</span>
</label>
<input
type="text"
name="name"
value={formData.name || ''}
onChange={handleChange}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.name ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="z.B. Muster GmbH"
/>
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
</div>
{/* Industry */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Branche <span className="text-red-500">*</span>
</label>
<select
name="industry"
value={formData.industry || ''}
onChange={handleChange}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.industry ? 'border-red-500' : 'border-gray-300'
}`}
>
<option value="">Bitte wählen...</option>
{INDUSTRIES.map((industry) => (
<option key={industry} value={industry}>
{industry}
</option>
))}
</select>
{errors.industry && <p className="mt-1 text-sm text-red-500">{errors.industry}</p>}
</div>
{/* Company Size */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Unternehmensgröße <span className="text-red-500">*</span>
</label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{COMPANY_SIZES.map((size) => (
<label
key={size.value}
className={`relative flex items-start p-3 border rounded-lg cursor-pointer transition-all ${
formData.size === size.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="size"
value={size.value}
checked={formData.size === size.value}
onChange={handleChange}
className="sr-only"
/>
<div>
<span className="font-medium text-gray-900">{size.label}</span>
<p className="text-sm text-gray-500">{size.description}</p>
</div>
{formData.size === size.value && (
<div className="absolute top-2 right-2">
<svg className="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
)}
</label>
))}
</div>
</div>
{/* Company Role */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Ihre Rolle nach DSGVO <span className="text-red-500">*</span>
</label>
<div className="space-y-3">
{COMPANY_ROLES.map((role) => (
<label
key={role.value}
className={`relative flex items-start p-4 border rounded-lg cursor-pointer transition-all ${
formData.role === role.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="role"
value={role.value}
checked={formData.role === role.value}
onChange={handleChange}
className="sr-only"
/>
<div className="flex-1">
<span className="font-medium text-gray-900">{role.label}</span>
<p className="text-sm text-gray-500 mt-1">{role.description}</p>
</div>
{formData.role === role.value && (
<div className="flex-shrink-0 ml-3">
<svg className="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
)}
</label>
))}
</div>
{errors.role && <p className="mt-1 text-sm text-red-500">{errors.role}</p>}
</div>
{/* Products/Services */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Produkte / Services
</label>
<div className="flex gap-2">
<input
type="text"
value={productInput}
onChange={(e) => setProductInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addProduct())}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="z.B. Cloud CRM, API Services"
/>
<button
type="button"
onClick={addProduct}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
>
Hinzufügen
</button>
</div>
{formData.products && formData.products.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{formData.products.map((product, index) => (
<span
key={index}
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
>
{product}
<button
type="button"
onClick={() => removeProduct(index)}
className="hover:text-blue-600"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
)}
</div>
{/* Contact Information */}
<div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Kontaktinformationen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Datenschutzbeauftragter
</label>
<input
type="text"
name="dpoPerson"
value={formData.dpoPerson || ''}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Name des DSB"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
E-Mail des DSB
</label>
<input
type="email"
name="dpoEmail"
value={formData.dpoEmail || ''}
onChange={handleChange}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.dpoEmail ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="dpo@example.de"
/>
{errors.dpoEmail && <p className="mt-1 text-sm text-red-500">{errors.dpoEmail}</p>}
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
IT-Security Ansprechpartner
</label>
<input
type="text"
name="itSecurityContact"
value={formData.itSecurityContact || ''}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Name oder Team"
/>
</div>
</div>
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<div>
<h4 className="font-medium text-blue-900">Hinweis zur Rollenwahl</h4>
<p className="text-sm text-blue-700 mt-1">
Die Wahl Ihrer DSGVO-Rolle beeinflusst, welche TOMs für Sie relevant sind.
Als <strong>Auftragsverarbeiter</strong> gelten zusätzliche Anforderungen nach Art. 28 DSGVO.
</p>
</div>
</div>
</div>
</form>
)
}
export default ScopeRolesStep

View File

@@ -0,0 +1,417 @@
'use client'
// =============================================================================
// Step 4: Security Profile
// Authentication, encryption, and security configuration
// =============================================================================
import React, { useState, useEffect } from 'react'
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
import {
SecurityProfile,
AuthMethodType,
BackupFrequency,
} from '@/lib/sdk/tom-generator/types'
// =============================================================================
// CONSTANTS
// =============================================================================
const AUTH_METHODS: { value: AuthMethodType; label: string; description: string }[] = [
{ value: 'PASSWORD', label: 'Passwort', description: 'Standard-Passwortauthentifizierung' },
{ value: 'MFA', label: 'Multi-Faktor (MFA)', description: 'Zweiter Faktor erforderlich' },
{ value: 'SSO', label: 'Single Sign-On', description: 'Zentralisierte Anmeldung' },
{ value: 'CERTIFICATE', label: 'Zertifikat', description: 'Client-Zertifikate' },
{ value: 'BIOMETRIC', label: 'Biometrisch', description: 'Fingerabdruck, Gesicht, etc.' },
]
const BACKUP_FREQUENCIES: { value: BackupFrequency; label: string }[] = [
{ value: 'HOURLY', label: 'Stündlich' },
{ value: 'DAILY', label: 'Täglich' },
{ value: 'WEEKLY', label: 'Wöchentlich' },
{ value: 'MONTHLY', label: 'Monatlich' },
]
// =============================================================================
// COMPONENT
// =============================================================================
export function SecurityProfileStep() {
const { state, setSecurityProfile, completeCurrentStep } = useTOMGenerator()
const [formData, setFormData] = useState<Partial<SecurityProfile>>({
authMethods: [],
hasMFA: false,
hasSSO: false,
hasIAM: false,
hasPAM: false,
hasEncryptionAtRest: false,
hasEncryptionInTransit: false,
hasLogging: false,
logRetentionDays: 90,
hasBackup: false,
backupFrequency: 'DAILY',
backupRetentionDays: 30,
hasDRPlan: false,
rtoHours: null,
rpoHours: null,
hasVulnerabilityManagement: false,
hasPenetrationTests: false,
hasSecurityTraining: false,
})
// Load existing data
useEffect(() => {
if (state.securityProfile) {
setFormData(state.securityProfile)
}
}, [state.securityProfile])
// Sync auth methods with boolean flags
useEffect(() => {
const authTypes = formData.authMethods?.map((m) => m.type) || []
setFormData((prev) => ({
...prev,
hasMFA: authTypes.includes('MFA'),
hasSSO: authTypes.includes('SSO'),
}))
}, [formData.authMethods])
// Handle auth method toggle
const toggleAuthMethod = (method: AuthMethodType) => {
setFormData((prev) => {
const current = prev.authMethods || []
const exists = current.some((m) => m.type === method)
const updated = exists
? current.filter((m) => m.type !== method)
: [...current, { type: method, provider: null }]
return { ...prev, authMethods: updated }
})
}
// Handle submit
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const profile: SecurityProfile = {
authMethods: formData.authMethods || [],
hasMFA: formData.hasMFA || false,
hasSSO: formData.hasSSO || false,
hasIAM: formData.hasIAM || false,
hasPAM: formData.hasPAM || false,
hasEncryptionAtRest: formData.hasEncryptionAtRest || false,
hasEncryptionInTransit: formData.hasEncryptionInTransit || false,
hasLogging: formData.hasLogging || false,
logRetentionDays: formData.logRetentionDays || 90,
hasBackup: formData.hasBackup || false,
backupFrequency: formData.backupFrequency || 'DAILY',
backupRetentionDays: formData.backupRetentionDays || 30,
hasDRPlan: formData.hasDRPlan || false,
rtoHours: formData.rtoHours ?? null,
rpoHours: formData.rpoHours ?? null,
hasVulnerabilityManagement: formData.hasVulnerabilityManagement || false,
hasPenetrationTests: formData.hasPenetrationTests || false,
hasSecurityTraining: formData.hasSecurityTraining || false,
}
setSecurityProfile(profile)
completeCurrentStep(profile)
}
const selectedAuthMethods = formData.authMethods?.map((m) => m.type) || []
return (
<form onSubmit={handleSubmit} className="space-y-8">
{/* Authentication Methods */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Authentifizierungsmethoden</h3>
<p className="text-sm text-gray-600 mb-4">
Welche Authentifizierungsmethoden werden verwendet?
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{AUTH_METHODS.map((method) => (
<label
key={method.value}
className={`relative flex items-start p-3 border rounded-lg cursor-pointer transition-all ${
selectedAuthMethods.includes(method.value)
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="checkbox"
checked={selectedAuthMethods.includes(method.value)}
onChange={() => toggleAuthMethod(method.value)}
className="sr-only"
/>
<div className="flex-1">
<span className="font-medium text-gray-900">{method.label}</span>
<p className="text-xs text-gray-500 mt-0.5">{method.description}</p>
</div>
{selectedAuthMethods.includes(method.value) && (
<svg className="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
)}
</label>
))}
</div>
{/* MFA recommendation */}
{!selectedAuthMethods.includes('MFA') && (
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800">
<strong>Empfehlung:</strong> Multi-Faktor-Authentifizierung (MFA) wird für alle sensiblen
Systeme dringend empfohlen und ist bei besonderen Datenkategorien oft erforderlich.
</p>
</div>
)}
</div>
{/* Identity & Access Management */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Identity & Access Management</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
<input
type="checkbox"
checked={formData.hasIAM || false}
onChange={(e) => setFormData((prev) => ({ ...prev, hasIAM: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-gray-700 font-medium">Identity & Access Management (IAM)</span>
<p className="text-sm text-gray-500">Zentralisierte Benutzer- und Berechtigungsverwaltung</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
<input
type="checkbox"
checked={formData.hasPAM || false}
onChange={(e) => setFormData((prev) => ({ ...prev, hasPAM: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-gray-700 font-medium">Privileged Access Management (PAM)</span>
<p className="text-sm text-gray-500">Kontrolle privilegierter Zugänge (Admin-Konten)</p>
</div>
</label>
</div>
</div>
{/* Encryption */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Verschlüsselung</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
<input
type="checkbox"
checked={formData.hasEncryptionAtRest || false}
onChange={(e) => setFormData((prev) => ({ ...prev, hasEncryptionAtRest: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-gray-700 font-medium">Verschlüsselung ruhender Daten</span>
<p className="text-sm text-gray-500">AES-256 oder vergleichbar für gespeicherte Daten</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
<input
type="checkbox"
checked={formData.hasEncryptionInTransit || false}
onChange={(e) => setFormData((prev) => ({ ...prev, hasEncryptionInTransit: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-gray-700 font-medium">Transportverschlüsselung</span>
<p className="text-sm text-gray-500">TLS 1.2+ für alle Datenübertragungen</p>
</div>
</label>
</div>
</div>
{/* Logging */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Protokollierung</h3>
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50 mb-4">
<input
type="checkbox"
checked={formData.hasLogging || false}
onChange={(e) => setFormData((prev) => ({ ...prev, hasLogging: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-gray-700 font-medium">Audit-Logging aktiviert</span>
<p className="text-sm text-gray-500">Protokollierung aller sicherheitsrelevanten Ereignisse</p>
</div>
</label>
{formData.hasLogging && (
<div className="pl-8">
<label className="block text-sm font-medium text-gray-700 mb-1">
Log-Aufbewahrung (Tage)
</label>
<input
type="number"
min="1"
value={formData.logRetentionDays || 90}
onChange={(e) => setFormData((prev) => ({ ...prev, logRetentionDays: parseInt(e.target.value) || 90 }))}
className="w-32 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
)}
</div>
{/* Backup & DR */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Backup & Disaster Recovery</h3>
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50 mb-4">
<input
type="checkbox"
checked={formData.hasBackup || false}
onChange={(e) => setFormData((prev) => ({ ...prev, hasBackup: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-gray-700 font-medium">Regelmäßige Backups</span>
<p className="text-sm text-gray-500">Automatisierte Datensicherung</p>
</div>
</label>
{formData.hasBackup && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pl-8 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Backup-Frequenz
</label>
<select
value={formData.backupFrequency || 'DAILY'}
onChange={(e) => setFormData((prev) => ({ ...prev, backupFrequency: e.target.value as BackupFrequency }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
{BACKUP_FREQUENCIES.map((freq) => (
<option key={freq.value} value={freq.value}>{freq.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Backup-Aufbewahrung (Tage)
</label>
<input
type="number"
min="1"
value={formData.backupRetentionDays || 30}
onChange={(e) => setFormData((prev) => ({ ...prev, backupRetentionDays: parseInt(e.target.value) || 30 }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
)}
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50 mb-4">
<input
type="checkbox"
checked={formData.hasDRPlan || false}
onChange={(e) => setFormData((prev) => ({ ...prev, hasDRPlan: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-gray-700 font-medium">Disaster Recovery Plan vorhanden</span>
<p className="text-sm text-gray-500">Dokumentierter Wiederherstellungsplan</p>
</div>
</label>
{formData.hasDRPlan && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pl-8">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
RTO (Recovery Time Objective) in Stunden
</label>
<input
type="number"
min="0"
step="0.5"
value={formData.rtoHours ?? ''}
onChange={(e) => setFormData((prev) => ({ ...prev, rtoHours: e.target.value ? parseFloat(e.target.value) : null }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="z.B. 4"
/>
<p className="text-xs text-gray-500 mt-1">Maximale Ausfallzeit</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
RPO (Recovery Point Objective) in Stunden
</label>
<input
type="number"
min="0"
step="0.25"
value={formData.rpoHours ?? ''}
onChange={(e) => setFormData((prev) => ({ ...prev, rpoHours: e.target.value ? parseFloat(e.target.value) : null }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="z.B. 1"
/>
<p className="text-xs text-gray-500 mt-1">Maximaler Datenverlust</p>
</div>
</div>
)}
</div>
{/* Security Testing & Training */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Sicherheitstests & Schulungen</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
<input
type="checkbox"
checked={formData.hasVulnerabilityManagement || false}
onChange={(e) => setFormData((prev) => ({ ...prev, hasVulnerabilityManagement: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-gray-700 font-medium">Schwachstellenmanagement</span>
<p className="text-sm text-gray-500">Regelmäßige Schwachstellenscans und Patch-Management</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
<input
type="checkbox"
checked={formData.hasPenetrationTests || false}
onChange={(e) => setFormData((prev) => ({ ...prev, hasPenetrationTests: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-gray-700 font-medium">Penetrationstests</span>
<p className="text-sm text-gray-500">Regelmäßige Sicherheitstests durch externe Prüfer</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
<input
type="checkbox"
checked={formData.hasSecurityTraining || false}
onChange={(e) => setFormData((prev) => ({ ...prev, hasSecurityTraining: e.target.checked }))}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-gray-700 font-medium">Security Awareness Training</span>
<p className="text-sm text-gray-500">Regelmäßige Schulungen für alle Mitarbeiter</p>
</div>
</label>
</div>
</div>
</form>
)
}
export default SecurityProfileStep

View File

@@ -0,0 +1,100 @@
'use client'
import type { ArchitectureContext as ArchitectureContextType } from './types'
interface ArchitectureContextProps {
context: ArchitectureContextType
currentStep?: string
highlightedComponents?: string[]
}
const LAYER_CONFIG = {
frontend: { label: 'Frontend', color: 'blue', icon: '🖥️' },
api: { label: 'API', color: 'purple', icon: '🔌' },
service: { label: 'Service', color: 'green', icon: '⚙️' },
database: { label: 'Datenbank', color: 'orange', icon: '🗄️' },
}
export function ArchitectureContext({ context, currentStep, highlightedComponents = [] }: ArchitectureContextProps) {
const layerConfig = LAYER_CONFIG[context.layer]
return (
<div className="bg-slate-900 rounded-lg p-6 mb-6">
<h4 className="text-white font-medium mb-4 flex items-center">
<span className="mr-2">🏗</span>
Architektur-Kontext{currentStep && `: ${currentStep}`}
</h4>
{/* Data Flow Visualization */}
<div className="mb-6">
<div className="flex items-center justify-center flex-wrap gap-2">
{context.dataFlow.map((component, index) => {
const isHighlighted = highlightedComponents.includes(component.toLowerCase())
const isCurrentLayer = component.toLowerCase().includes(context.layer)
return (
<div key={index} className="flex items-center">
<div className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
isHighlighted || isCurrentLayer
? 'bg-blue-500 text-white ring-2 ring-blue-300'
: 'bg-slate-700 text-slate-300'
}`}>
{component}
{isCurrentLayer && (
<span className="ml-2 text-xs"> Sie sind hier</span>
)}
</div>
{index < context.dataFlow.length - 1 && (
<span className="mx-2 text-slate-500"></span>
)}
</div>
)
})}
</div>
</div>
{/* Layer Info */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-slate-800 rounded-lg p-4">
<h5 className="text-slate-400 text-xs uppercase tracking-wide mb-2">Aktuelle Schicht</h5>
<div className="flex items-center">
<span className="text-xl mr-2">{layerConfig.icon}</span>
<span className={`text-${layerConfig.color}-400 font-medium`}>
{layerConfig.label}
</span>
</div>
</div>
<div className="bg-slate-800 rounded-lg p-4">
<h5 className="text-slate-400 text-xs uppercase tracking-wide mb-2">Beteiligte Services</h5>
<div className="flex flex-wrap gap-1">
{context.services.map((service) => (
<span
key={service}
className="px-2 py-1 bg-slate-700 text-slate-300 text-xs rounded"
>
{service}
</span>
))}
</div>
</div>
</div>
{/* Dependencies */}
{context.dependencies.length > 0 && (
<div className="pt-4 border-t border-slate-700">
<h5 className="text-slate-400 text-xs uppercase tracking-wide mb-2">Abhaengigkeiten</h5>
<div className="flex flex-wrap gap-2">
{context.dependencies.map((dep) => (
<span
key={dep}
className="px-2 py-1 bg-amber-900/50 text-amber-300 text-xs rounded border border-amber-700"
>
{dep}
</span>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,28 @@
'use client'
import type { EducationContent } from './types'
interface EducationCardProps {
content: EducationContent | undefined
}
export function EducationCard({ content }: EducationCardProps) {
if (!content) return null
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-blue-800 mb-4 flex items-center">
<span className="mr-2">💡</span>
Warum ist das wichtig?
</h3>
<h4 className="text-md font-medium text-blue-700 mb-3">{content.title}</h4>
<div className="space-y-2 text-blue-900">
{content.content.map((line, index) => (
<p key={index} className={line.startsWith('•') ? 'ml-4' : ''}>
{line}
</p>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import type { TestResult } from './types'
interface TestResultCardProps {
result: TestResult
}
export function TestResultCard({ result }: TestResultCardProps) {
const statusColors = {
passed: 'bg-green-100 border-green-300 text-green-800',
failed: 'bg-red-100 border-red-300 text-red-800',
pending: 'bg-yellow-100 border-yellow-300 text-yellow-800',
skipped: 'bg-gray-100 border-gray-300 text-gray-600',
}
const statusIcons = {
passed: '✓',
failed: '✗',
pending: '○',
skipped: '',
}
return (
<div className={`border rounded-lg p-4 mb-3 ${statusColors[result.status]}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium flex items-center">
<span className="mr-2">{statusIcons[result.status]}</span>
{result.name}
</h4>
<p className="text-sm opacity-80 mt-1">{result.description}</p>
</div>
<span className="text-xs opacity-60">{result.duration_ms.toFixed(0)}ms</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Erwartet:</span>
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
{result.expected}
</code>
</div>
<div>
<span className="font-medium">Erhalten:</span>
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
{result.actual}
</code>
</div>
</div>
{result.error_message && (
<div className="mt-2 text-xs text-red-700 bg-red-50 p-2 rounded">
Fehler: {result.error_message}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import { TestResultCard } from './TestResultCard'
import type { TestCategoryResult } from './types'
interface TestRunnerProps {
category: string
categoryResult?: TestCategoryResult
isLoading: boolean
onRunTests: () => void
runButtonLabel?: string
rerunButtonLabel?: string
}
export function TestRunner({
categoryResult,
isLoading,
onRunTests,
runButtonLabel = '▶️ Tests ausfuehren',
rerunButtonLabel = '🔄 Erneut ausfuehren',
}: TestRunnerProps) {
if (!categoryResult) {
return (
<div className="text-center py-6">
<button
onClick={onRunTests}
disabled={isLoading}
className={`px-6 py-3 rounded-lg font-medium transition-colors ${
isLoading
? 'bg-gray-400 cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{isLoading ? '⏳ Tests laufen...' : runButtonLabel}
</button>
</div>
)
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-700">Testergebnisse</h3>
<button
onClick={onRunTests}
disabled={isLoading}
className="text-sm text-blue-600 hover:text-blue-800"
>
{rerunButtonLabel}
</button>
</div>
{categoryResult.tests.map((test, index) => (
<TestResultCard key={index} result={test} />
))}
</div>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import type { FullTestResults } from './types'
interface TestSummaryProps {
results: FullTestResults
}
export function TestSummary({ results }: TestSummaryProps) {
const passRate = results.total_tests > 0
? ((results.total_passed / results.total_tests) * 100).toFixed(1)
: '0'
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-bold mb-4">Test-Ergebnisse</h3>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-gray-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-gray-700">{results.total_tests}</div>
<div className="text-sm text-gray-500">Gesamt</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-green-600">{results.total_passed}</div>
<div className="text-sm text-green-600">Bestanden</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-red-600">{results.total_failed}</div>
<div className="text-sm text-red-600">Fehlgeschlagen</div>
</div>
<div className={`rounded-lg p-4 text-center ${
parseFloat(passRate) >= 80 ? 'bg-green-50' : parseFloat(passRate) >= 50 ? 'bg-yellow-50' : 'bg-red-50'
}`}>
<div className={`text-3xl font-bold ${
parseFloat(passRate) >= 80 ? 'text-green-600' : parseFloat(passRate) >= 50 ? 'text-yellow-600' : 'text-red-600'
}`}>{passRate}%</div>
<div className="text-sm text-gray-500">Erfolgsrate</div>
</div>
</div>
{/* Category Results */}
<div className="space-y-4">
{results.categories.map((category) => (
<div key={category.category} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{category.display_name}</h4>
<span className={`text-sm px-2 py-1 rounded ${
category.failed === 0 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{category.passed}/{category.total} bestanden
</span>
</div>
<p className="text-sm text-gray-600">{category.description}</p>
</div>
))}
</div>
{/* Duration */}
<div className="mt-6 text-sm text-gray-500 text-right">
Gesamtdauer: {(results.duration_ms / 1000).toFixed(2)}s
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
interface WizardBannerProps {
module: string
title: string
description?: string
}
export function WizardBanner({ module, title, description }: WizardBannerProps) {
return (
<div className="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-2xl mr-3">🎓</span>
<div>
<h3 className="font-medium text-blue-800">Lern-Wizard: {title}</h3>
<p className="text-sm text-blue-600">
{description || 'Interaktives Onboarding mit Tests und Architektur-Erklaerungen'}
</p>
</div>
</div>
<a
href={`/admin/${module}/wizard`}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Wizard starten
</a>
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
interface WizardNavigationProps {
currentStep: number
totalSteps: number
onPrev: () => void
onNext: () => void
showNext?: boolean
isLoading?: boolean
nextLabel?: string
prevLabel?: string
}
export function WizardNavigation({
currentStep,
totalSteps,
onPrev,
onNext,
showNext = true,
isLoading = false,
nextLabel = 'Weiter →',
prevLabel = '← Zurueck',
}: WizardNavigationProps) {
return (
<div className="flex justify-between mt-8 pt-6 border-t">
<button
onClick={onPrev}
disabled={currentStep === 0 || isLoading}
className={`px-6 py-2 rounded-lg transition-colors ${
currentStep === 0 || isLoading
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{prevLabel}
</button>
{showNext && currentStep < totalSteps - 1 && (
<button
onClick={onNext}
disabled={isLoading}
className={`px-6 py-2 rounded-lg transition-colors ${
isLoading
? 'bg-blue-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isLoading ? 'Bitte warten...' : nextLabel}
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,72 @@
'use client'
import { createContext, useContext, useState, ReactNode, useCallback } from 'react'
import type { WizardStep, WizardContextValue, TestCategoryResult, FullTestResults } from './types'
const WizardContext = createContext<WizardContextValue | null>(null)
export function useWizard(): WizardContextValue {
const context = useContext(WizardContext)
if (!context) {
throw new Error('useWizard must be used within a WizardProvider')
}
return context
}
interface WizardProviderProps {
children: ReactNode
initialSteps: WizardStep[]
module: string
}
export function WizardProvider({ children, initialSteps, module }: WizardProviderProps) {
const [currentStep, setCurrentStep] = useState(0)
const [steps, setSteps] = useState<WizardStep[]>(initialSteps)
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const goToNext = useCallback(() => {
if (currentStep < steps.length - 1) {
setSteps((prev) =>
prev.map((step, idx) =>
idx === currentStep && step.status === 'pending'
? { ...step, status: 'completed' }
: step
)
)
setCurrentStep((prev) => prev + 1)
}
}, [currentStep, steps.length])
const goToPrev = useCallback(() => {
if (currentStep > 0) {
setCurrentStep((prev) => prev - 1)
}
}, [currentStep])
const value: WizardContextValue = {
currentStep,
steps,
setCurrentStep,
setSteps,
goToNext,
goToPrev,
module,
categoryResults,
setCategoryResults,
fullResults,
setFullResults,
isLoading,
setIsLoading,
error,
setError,
}
return (
<WizardContext.Provider value={value}>
{children}
</WizardContext.Provider>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import type { WizardStep } from './types'
interface WizardStepperProps {
steps: WizardStep[]
currentStep: number
onStepClick: (index: number) => void
}
export function WizardStepper({ steps, currentStep, onStepClick }: WizardStepperProps) {
return (
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<button
onClick={() => onStepClick(index)}
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
index === currentStep
? 'bg-blue-100 text-blue-700'
: step.status === 'completed'
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
: step.status === 'failed'
? 'bg-red-100 text-red-700 cursor-pointer hover:bg-red-200'
: 'text-gray-400'
}`}
disabled={index > currentStep && steps[index - 1]?.status === 'pending'}
>
<span className="text-2xl mb-1">{step.icon}</span>
<span className="text-xs font-medium text-center">{step.name}</span>
{step.status === 'completed' && <span className="text-xs text-green-600"></span>}
{step.status === 'failed' && <span className="text-xs text-red-600"></span>}
</button>
{index < steps.length - 1 && (
<div className={`h-0.5 w-8 mx-1 ${
index < currentStep ? 'bg-green-400' : 'bg-gray-200'
}`} />
)}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,34 @@
// Wizard Framework Components
// ============================
// Wiederverwendbare Komponenten fuer Admin-Modul-Wizards
// Context & Provider
export { WizardProvider, useWizard } from './WizardProvider'
// UI Components
export { WizardStepper } from './WizardStepper'
export { WizardNavigation } from './WizardNavigation'
export { WizardBanner } from './WizardBanner'
// Education Components
export { EducationCard } from './EducationCard'
export { ArchitectureContext } from './ArchitectureContext'
// Test Components
export { TestRunner } from './TestRunner'
export { TestResultCard } from './TestResultCard'
export { TestSummary } from './TestSummary'
// Types
export type {
WizardStep,
StepStatus,
TestResult,
TestCategoryResult,
FullTestResults,
EducationContent,
StepEducation,
ModuleEducation,
ArchitectureContext as ArchitectureContextType,
WizardContextValue,
} from './types'

View File

@@ -0,0 +1,104 @@
// ==============================================
// Wizard Framework Types
// ==============================================
export type StepStatus = 'pending' | 'active' | 'completed' | 'failed'
export interface WizardStep {
id: string
name: string
icon: string
status: StepStatus
category?: string
}
export interface TestResult {
name: string
description: string
expected: string
actual: string
status: 'passed' | 'failed' | 'pending' | 'skipped'
duration_ms: number
error_message?: string
}
export interface ArchitectureContext {
layer: 'frontend' | 'api' | 'service' | 'database'
services: string[]
dependencies: string[]
dataFlow: string[]
}
export interface TestCategoryResult {
category: string
display_name: string
description: string
why_important: string
architecture_context?: ArchitectureContext
tests: TestResult[]
passed: number
failed: number
total: number
duration_ms: number
}
export interface FullTestResults {
timestamp: string
categories: TestCategoryResult[]
total_passed: number
total_failed: number
total_tests: number
duration_ms: number
}
export interface EducationContent {
title: string
content: string[]
}
export interface StepEducation {
stepId: string
title: string
whyImportant: string
whatIsTested: string[]
architectureHighlight?: {
layer: string
components: string[]
dataFlow: string
}
learnMoreLinks?: {
label: string
url: string
}[]
commonMistakes?: string[]
bestPractices?: string[]
}
export interface ModuleEducation {
module: string
overview: {
title: string
description: string
businessValue: string
complianceContext: string
}
steps: StepEducation[]
}
export interface WizardContextValue {
currentStep: number
steps: WizardStep[]
setCurrentStep: (step: number) => void
setSteps: React.Dispatch<React.SetStateAction<WizardStep[]>>
goToNext: () => void
goToPrev: () => void
module: string
categoryResults: Record<string, TestCategoryResult>
setCategoryResults: React.Dispatch<React.SetStateAction<Record<string, TestCategoryResult>>>
fullResults: FullTestResults | null
setFullResults: React.Dispatch<React.SetStateAction<FullTestResults | null>>
isLoading: boolean
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>
error: string | null
setError: React.Dispatch<React.SetStateAction<string | null>>
}