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:
339
admin-v2/components/common/ArchitectureView.tsx
Normal file
339
admin-v2/components/common/ArchitectureView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
admin-v2/components/common/Breadcrumbs.tsx
Normal file
73
admin-v2/components/common/Breadcrumbs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
510
admin-v2/components/common/DataFlowDiagram.tsx
Normal file
510
admin-v2/components/common/DataFlowDiagram.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
admin-v2/components/common/InfoBox.tsx
Normal file
72
admin-v2/components/common/InfoBox.tsx
Normal 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>
|
||||
}
|
||||
113
admin-v2/components/common/ModuleCard.tsx
Normal file
113
admin-v2/components/common/ModuleCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
169
admin-v2/components/common/PagePurpose.tsx
Normal file
169
admin-v2/components/common/PagePurpose.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
203
admin-v2/components/common/ServiceStatus.tsx
Normal file
203
admin-v2/components/common/ServiceStatus.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
313
admin-v2/components/developers/DevPortalLayout.tsx
Normal file
313
admin-v2/components/developers/DevPortalLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
165
admin-v2/components/developers/SDKDocsSidebar.tsx
Normal file
165
admin-v2/components/developers/SDKDocsSidebar.tsx
Normal 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
|
||||
76
admin-v2/components/layout/Header.tsx
Normal file
76
admin-v2/components/layout/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
admin-v2/components/layout/RoleIndicator.tsx
Normal file
107
admin-v2/components/layout/RoleIndicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
284
admin-v2/components/layout/Sidebar.tsx
Normal file
284
admin-v2/components/layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
464
admin-v2/components/sdk/CommandBar/CommandBar.tsx
Normal file
464
admin-v2/components/sdk/CommandBar/CommandBar.tsx
Normal 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 "{query}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
1
admin-v2/components/sdk/CommandBar/index.ts
Normal file
1
admin-v2/components/sdk/CommandBar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CommandBar } from './CommandBar'
|
||||
@@ -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
|
||||
2
admin-v2/components/sdk/CustomerTypeSelector/index.ts
Normal file
2
admin-v2/components/sdk/CustomerTypeSelector/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CustomerTypeSelector } from './CustomerTypeSelector'
|
||||
export { default } from './CustomerTypeSelector'
|
||||
583
admin-v2/components/sdk/DocumentUpload/DocumentUploadSection.tsx
Normal file
583
admin-v2/components/sdk/DocumentUpload/DocumentUploadSection.tsx
Normal 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
|
||||
1
admin-v2/components/sdk/DocumentUpload/index.ts
Normal file
1
admin-v2/components/sdk/DocumentUpload/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DocumentUploadSection, type DocumentUploadSectionProps, type UploadedDocument, type ExtractedContent, type ExtractedSection } from './DocumentUploadSection'
|
||||
215
admin-v2/components/sdk/Layout/SDKLayout.tsx
Normal file
215
admin-v2/components/sdk/Layout/SDKLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
admin-v2/components/sdk/Layout/index.ts
Normal file
1
admin-v2/components/sdk/Layout/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SDKLayout } from './SDKLayout'
|
||||
@@ -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
|
||||
2
admin-v2/components/sdk/SDKPipelineSidebar/index.ts
Normal file
2
admin-v2/components/sdk/SDKPipelineSidebar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { SDKPipelineSidebar } from './SDKPipelineSidebar'
|
||||
export type { SDKPipelineSidebarProps } from './SDKPipelineSidebar'
|
||||
527
admin-v2/components/sdk/Sidebar/SDKSidebar.tsx
Normal file
527
admin-v2/components/sdk/Sidebar/SDKSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
admin-v2/components/sdk/Sidebar/index.ts
Normal file
1
admin-v2/components/sdk/Sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SDKSidebar } from './SDKSidebar'
|
||||
664
admin-v2/components/sdk/StepHeader/StepHeader.tsx
Normal file
664
admin-v2/components/sdk/StepHeader/StepHeader.tsx
Normal 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
|
||||
2
admin-v2/components/sdk/StepHeader/index.ts
Normal file
2
admin-v2/components/sdk/StepHeader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { StepHeader, STEP_EXPLANATIONS } from './StepHeader'
|
||||
export type { StepTip } from './StepHeader'
|
||||
127
admin-v2/components/sdk/__tests__/StepHeader.test.tsx
Normal file
127
admin-v2/components/sdk/__tests__/StepHeader.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
288
admin-v2/components/sdk/dsr/DSRCommunicationLog.tsx
Normal file
288
admin-v2/components/sdk/dsr/DSRCommunicationLog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
304
admin-v2/components/sdk/dsr/DSRDataExport.tsx
Normal file
304
admin-v2/components/sdk/dsr/DSRDataExport.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
293
admin-v2/components/sdk/dsr/DSRErasureChecklist.tsx
Normal file
293
admin-v2/components/sdk/dsr/DSRErasureChecklist.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
263
admin-v2/components/sdk/dsr/DSRIdentityModal.tsx
Normal file
263
admin-v2/components/sdk/dsr/DSRIdentityModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
176
admin-v2/components/sdk/dsr/DSRWorkflowStepper.tsx
Normal file
176
admin-v2/components/sdk/dsr/DSRWorkflowStepper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
admin-v2/components/sdk/dsr/index.ts
Normal file
9
admin-v2/components/sdk/dsr/index.ts
Normal 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'
|
||||
658
admin-v2/components/sdk/einwilligungen/DataPointCatalog.tsx
Normal file
658
admin-v2/components/sdk/einwilligungen/DataPointCatalog.tsx
Normal 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
|
||||
321
admin-v2/components/sdk/einwilligungen/PrivacyPolicyPreview.tsx
Normal file
321
admin-v2/components/sdk/einwilligungen/PrivacyPolicyPreview.tsx
Normal 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
|
||||
350
admin-v2/components/sdk/einwilligungen/RetentionMatrix.tsx
Normal file
350
admin-v2/components/sdk/einwilligungen/RetentionMatrix.tsx
Normal 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
|
||||
9
admin-v2/components/sdk/einwilligungen/index.ts
Normal file
9
admin-v2/components/sdk/einwilligungen/index.ts
Normal 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'
|
||||
16
admin-v2/components/sdk/index.ts
Normal file
16
admin-v2/components/sdk/index.ts
Normal 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'
|
||||
286
admin-v2/components/sdk/tom-generator/TOMGeneratorWizard.tsx
Normal file
286
admin-v2/components/sdk/tom-generator/TOMGeneratorWizard.tsx
Normal 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 }
|
||||
19
admin-v2/components/sdk/tom-generator/index.ts
Normal file
19
admin-v2/components/sdk/tom-generator/index.ts
Normal 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'
|
||||
460
admin-v2/components/sdk/tom-generator/steps/ArchitectureStep.tsx
Normal file
460
admin-v2/components/sdk/tom-generator/steps/ArchitectureStep.tsx
Normal 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
|
||||
@@ -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
|
||||
593
admin-v2/components/sdk/tom-generator/steps/ReviewExportStep.tsx
Normal file
593
admin-v2/components/sdk/tom-generator/steps/ReviewExportStep.tsx
Normal 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
|
||||
@@ -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
|
||||
403
admin-v2/components/sdk/tom-generator/steps/ScopeRolesStep.tsx
Normal file
403
admin-v2/components/sdk/tom-generator/steps/ScopeRolesStep.tsx
Normal 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
|
||||
@@ -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
|
||||
100
admin-v2/components/wizard/ArchitectureContext.tsx
Normal file
100
admin-v2/components/wizard/ArchitectureContext.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
admin-v2/components/wizard/EducationCard.tsx
Normal file
28
admin-v2/components/wizard/EducationCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
admin-v2/components/wizard/TestResultCard.tsx
Normal file
57
admin-v2/components/wizard/TestResultCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
admin-v2/components/wizard/TestRunner.tsx
Normal file
57
admin-v2/components/wizard/TestRunner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
admin-v2/components/wizard/TestSummary.tsx
Normal file
65
admin-v2/components/wizard/TestSummary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
admin-v2/components/wizard/WizardBanner.tsx
Normal file
31
admin-v2/components/wizard/WizardBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
admin-v2/components/wizard/WizardNavigation.tsx
Normal file
53
admin-v2/components/wizard/WizardNavigation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
admin-v2/components/wizard/WizardProvider.tsx
Normal file
72
admin-v2/components/wizard/WizardProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
admin-v2/components/wizard/WizardStepper.tsx
Normal file
43
admin-v2/components/wizard/WizardStepper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
admin-v2/components/wizard/index.ts
Normal file
34
admin-v2/components/wizard/index.ts
Normal 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'
|
||||
104
admin-v2/components/wizard/types.ts
Normal file
104
admin-v2/components/wizard/types.ts
Normal 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>>
|
||||
}
|
||||
Reference in New Issue
Block a user