Initial commit: breakpilot-compliance - Compliance SDK Platform

Services: Admin-Compliance, Backend-Compliance,
AI-Compliance-SDK, Consent-SDK, Developer-Portal,
PCA-Platform, DSMS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:28 +01:00
commit 4435e7ea0a
734 changed files with 251369 additions and 0 deletions

View File

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

View File

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

View File

@@ -0,0 +1,485 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
// =============================================================================
// TYPES
// =============================================================================
interface Message {
id: string
role: 'user' | 'agent'
content: string
timestamp: Date
}
interface ComplianceAdvisorWidgetProps {
currentStep?: string
enableDraftingEngine?: boolean
}
// =============================================================================
// EXAMPLE QUESTIONS BY STEP
// =============================================================================
const EXAMPLE_QUESTIONS: Record<string, string[]> = {
vvt: [
'Was ist ein Verarbeitungsverzeichnis?',
'Welche Informationen muss ich erfassen?',
'Wie dokumentiere ich die Rechtsgrundlage?',
],
'compliance-scope': [
'Was bedeutet L3?',
'Wann brauche ich eine DSFA?',
'Was ist der Unterschied zwischen L2 und L3?',
],
tom: [
'Was sind TOM?',
'Welche Massnahmen sind erforderlich?',
'Wie dokumentiere ich Verschluesselung?',
],
dsfa: [
'Was ist eine DSFA?',
'Wann ist eine DSFA verpflichtend?',
'Wie bewerte ich Risiken?',
],
loeschfristen: [
'Wie definiere ich Loeschfristen?',
'Was ist der Unterschied zwischen Loeschpflicht und Aufbewahrungspflicht?',
'Wann muss ich Daten loeschen?',
],
default: [
'Wie starte ich mit dem SDK?',
'Was ist der erste Schritt?',
'Welche Compliance-Anforderungen gelten fuer KI-Systeme?',
],
}
// =============================================================================
// COMPONENT
// =============================================================================
export function ComplianceAdvisorWidget({ currentStep = 'default', enableDraftingEngine = false }: ComplianceAdvisorWidgetProps) {
// Feature-flag: If Drafting Engine enabled, render DraftingEngineWidget instead
if (enableDraftingEngine) {
const { DraftingEngineWidget } = require('./DraftingEngineWidget')
return <DraftingEngineWidget currentStep={currentStep} enableDraftingEngine />
}
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [messages, setMessages] = useState<Message[]>([])
const [inputValue, setInputValue] = useState('')
const [isTyping, setIsTyping] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const abortControllerRef = useRef<AbortController | null>(null)
// Get example questions for current step
const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default
// Auto-scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Cleanup abort controller on unmount
useEffect(() => {
return () => {
abortControllerRef.current?.abort()
}
}, [])
// Handle send message with real LLM + RAG
const handleSendMessage = useCallback(
async (content: string) => {
if (!content.trim() || isTyping) return
const userMessage: Message = {
id: `msg-${Date.now()}`,
role: 'user',
content: content.trim(),
timestamp: new Date(),
}
setMessages((prev) => [...prev, userMessage])
setInputValue('')
setIsTyping(true)
const agentMessageId = `msg-${Date.now()}-agent`
// Create abort controller for this request
abortControllerRef.current = new AbortController()
try {
// Build conversation history for context
const history = messages.map((m) => ({
role: m.role === 'user' ? 'user' : 'assistant',
content: m.content,
}))
const response = await fetch('/api/sdk/compliance-advisor/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: content.trim(),
history,
currentStep,
}),
signal: abortControllerRef.current.signal,
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unbekannter Fehler' }))
throw new Error(errorData.error || `Server-Fehler (${response.status})`)
}
// Add empty agent message for streaming
setMessages((prev) => [
...prev,
{
id: agentMessageId,
role: 'agent',
content: '',
timestamp: new Date(),
},
])
// Read streaming response
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let accumulated = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
accumulated += decoder.decode(value, { stream: true })
// Update agent message with accumulated content
const currentText = accumulated
setMessages((prev) =>
prev.map((m) => (m.id === agentMessageId ? { ...m, content: currentText } : m))
)
// Auto-scroll during streaming
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
})
}
setIsTyping(false)
} catch (error) {
if ((error as Error).name === 'AbortError') {
// User cancelled, keep partial response
setIsTyping(false)
return
}
const errorMessage =
error instanceof Error ? error.message : 'Verbindung fehlgeschlagen'
// Add or update agent message with error
setMessages((prev) => {
const hasAgent = prev.some((m) => m.id === agentMessageId)
if (hasAgent) {
return prev.map((m) =>
m.id === agentMessageId
? { ...m, content: `Fehler: ${errorMessage}` }
: m
)
}
return [
...prev,
{
id: agentMessageId,
role: 'agent' as const,
content: `Fehler: ${errorMessage}`,
timestamp: new Date(),
},
]
})
setIsTyping(false)
}
},
[isTyping, messages, currentStep]
)
// Handle stop generation
const handleStopGeneration = useCallback(() => {
abortControllerRef.current?.abort()
setIsTyping(false)
}, [])
// Handle example question click
const handleExampleClick = (question: string) => {
handleSendMessage(question)
}
// Handle key press
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage(inputValue)
}
}
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-[5.5rem] w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
aria-label="Compliance Advisor oeffnen"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
</button>
)
}
return (
<div className={`fixed bottom-6 right-6 ${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[400px] h-[500px]'} max-h-screen bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200 transition-all duration-200`}>
{/* Header */}
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-4 py-3 rounded-t-2xl flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
<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>
</div>
<div>
<div className="font-semibold text-sm">Compliance Advisor</div>
<div className="text-xs text-white/80">KI-gestuetzter Assistent</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-white/80 hover:text-white transition-colors"
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{isExpanded ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 9L4 4m0 0v4m0-4h4m6 6l5 5m0 0v-4m0 4h-4"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5"
/>
)}
</svg>
</button>
<button
onClick={() => setIsOpen(false)}
className="text-white/80 hover:text-white transition-colors"
aria-label="Schliessen"
>
<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>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
{messages.length === 0 ? (
<div className="text-center py-8">
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-8 h-8 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
</div>
<h3 className="text-sm font-medium text-gray-900 mb-2">
Willkommen beim Compliance Advisor
</h3>
<p className="text-xs text-gray-500 mb-4">
Stellen Sie Fragen zu DSGVO, KI-Verordnung und mehr.
</p>
{/* Example Questions */}
<div className="text-left space-y-2">
<p className="text-xs font-medium text-gray-700 mb-2">
Beispielfragen:
</p>
{exampleQuestions.map((question, idx) => (
<button
key={idx}
onClick={() => handleExampleClick(question)}
className="w-full text-left px-3 py-2 text-xs bg-white hover:bg-purple-50 border border-gray-200 rounded-lg transition-colors text-gray-700"
>
{question}
</button>
))}
</div>
</div>
) : (
<>
{messages.map((message) => (
<div
key={message.id}
className={`flex ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[80%] rounded-lg px-3 py-2 ${
message.role === 'user'
? 'bg-indigo-600 text-white'
: 'bg-white border border-gray-200 text-gray-800'
}`}
>
<p
className={`text-sm ${message.role === 'agent' ? 'whitespace-pre-wrap' : ''}`}
>
{message.content || (message.role === 'agent' && isTyping ? '' : message.content)}
</p>
<p
className={`text-xs mt-1 ${
message.role === 'user'
? 'text-indigo-200'
: 'text-gray-400'
}`}
>
{message.timestamp.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: '0.1s' }}
/>
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: '0.2s' }}
/>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input Area */}
<div className="border-t border-gray-200 p-3 bg-white rounded-b-2xl">
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Frage eingeben..."
disabled={isTyping}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50"
/>
{isTyping ? (
<button
onClick={handleStopGeneration}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
title="Generierung stoppen"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 6h12v12H6z"
/>
</svg>
</button>
) : (
<button
onClick={() => handleSendMessage(inputValue)}
disabled={!inputValue.trim()}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed 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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
</button>
)}
</div>
</div>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,300 @@
'use client'
/**
* DraftEditor - Split-Pane Editor fuer Compliance-Dokument-Entwuerfe
*
* Links (2/3): Gerenderter Draft mit Section-Headern
* Rechts (1/3): Chat-Panel fuer iterative Verfeinerung
* Oben: Document-Type Label, Depth-Level Badge, Constraint-Compliance
*/
import { useState, useRef, useCallback } from 'react'
import { DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
import type {
DraftRevision,
ConstraintCheckResult,
ValidationResult,
} from '@/lib/sdk/drafting-engine/types'
interface DraftEditorProps {
draft: DraftRevision
documentType: ScopeDocumentType | null
constraintCheck: ConstraintCheckResult | null
validationResult: ValidationResult | null
isTyping: boolean
onAccept: () => void
onValidate: () => void
onClose: () => void
onRefine: (instruction: string) => void
}
export function DraftEditor({
draft,
documentType,
constraintCheck,
validationResult,
isTyping,
onAccept,
onValidate,
onClose,
onRefine,
}: DraftEditorProps) {
const [refineInput, setRefineInput] = useState('')
const [activeSection, setActiveSection] = useState<string | null>(null)
const contentRef = useRef<HTMLDivElement>(null)
const handleRefine = useCallback(() => {
if (!refineInput.trim() || isTyping) return
onRefine(refineInput.trim())
setRefineInput('')
}, [refineInput, isTyping, onRefine])
const handleRefineKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleRefine()
}
}
const docLabel = documentType
? DOCUMENT_TYPE_LABELS[documentType]?.split(' (')[0] || documentType
: 'Dokument'
return (
<div className="fixed inset-0 bg-gray-900/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white px-6 py-3 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<svg className="w-5 h-5" 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>
<div>
<div className="font-semibold text-sm">{docLabel} - Entwurf</div>
<div className="text-xs text-white/70">
{draft.sections.length} Sections | Erstellt {new Date(draft.createdAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Constraint Badge */}
{constraintCheck && (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
constraintCheck.allowed
? 'bg-green-500/20 text-green-100'
: 'bg-red-500/20 text-red-100'
}`}>
{constraintCheck.allowed ? 'Constraints OK' : 'Constraint-Verletzung'}
</span>
)}
{/* Validation Badge */}
{validationResult && (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
validationResult.passed
? 'bg-green-500/20 text-green-100'
: 'bg-amber-500/20 text-amber-100'
}`}>
{validationResult.passed ? 'Validiert' : `${validationResult.errors.length} Fehler`}
</span>
)}
<button
onClick={onClose}
className="text-white/80 hover:text-white transition-colors p-1"
aria-label="Editor schliessen"
>
<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>
{/* Adjustment Warnings */}
{constraintCheck && constraintCheck.adjustments.length > 0 && (
<div className="px-6 py-2 bg-amber-50 border-b border-amber-200 shrink-0">
{constraintCheck.adjustments.map((adj, i) => (
<p key={i} className="text-xs text-amber-700 flex items-start gap-1">
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0" 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-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
{adj}
</p>
))}
</div>
)}
{/* Main Content: 2/3 Editor + 1/3 Chat */}
<div className="flex-1 flex overflow-hidden">
{/* Left: Draft Content (2/3) */}
<div className="w-2/3 border-r border-gray-200 overflow-y-auto" ref={contentRef}>
{/* Section Navigation */}
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-2 flex items-center gap-1 overflow-x-auto z-10">
{draft.sections.map((section) => (
<button
key={section.id}
onClick={() => {
setActiveSection(section.id)
document.getElementById(`section-${section.id}`)?.scrollIntoView({ behavior: 'smooth' })
}}
className={`px-2.5 py-1 rounded-md text-xs font-medium whitespace-nowrap transition-colors ${
activeSection === section.id
? 'bg-blue-100 text-blue-700'
: 'text-gray-500 hover:bg-gray-100'
}`}
>
{section.title}
</button>
))}
</div>
{/* Sections */}
<div className="p-6 space-y-6">
{draft.sections.map((section) => (
<div
key={section.id}
id={`section-${section.id}`}
className={`rounded-lg border transition-colors ${
activeSection === section.id
? 'border-blue-300 bg-blue-50/30'
: 'border-gray-200 bg-white'
}`}
>
<div className="px-4 py-2.5 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">{section.title}</h3>
{section.schemaField && (
<span className="text-xs text-gray-400 font-mono">{section.schemaField}</span>
)}
</div>
<div className="px-4 py-3">
<div className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">
{section.content}
</div>
</div>
</div>
))}
</div>
</div>
{/* Right: Refinement Chat (1/3) */}
<div className="w-1/3 flex flex-col bg-gray-50">
<div className="px-4 py-3 border-b border-gray-200 bg-white">
<h3 className="text-sm font-semibold text-gray-900">Verfeinerung</h3>
<p className="text-xs text-gray-500">Geben Sie Anweisungen zur Verbesserung</p>
</div>
{/* Validation Summary (if present) */}
{validationResult && (
<div className="px-4 py-3 border-b border-gray-100">
<div className="space-y-1.5">
{validationResult.errors.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-red-600">
<span className="w-2 h-2 rounded-full bg-red-500" />
{validationResult.errors.length} Fehler
</div>
)}
{validationResult.warnings.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-amber-600">
<span className="w-2 h-2 rounded-full bg-amber-500" />
{validationResult.warnings.length} Warnungen
</div>
)}
{validationResult.suggestions.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-blue-600">
<span className="w-2 h-2 rounded-full bg-blue-500" />
{validationResult.suggestions.length} Vorschlaege
</div>
)}
</div>
</div>
)}
{/* Refinement Area */}
<div className="flex-1 p-4 overflow-y-auto">
<div className="space-y-3">
<p className="text-xs text-gray-500">
Beschreiben Sie, was geaendert werden soll. Der Agent erstellt eine ueberarbeitete Version unter Beachtung der Scope-Constraints.
</p>
{/* Quick Refinement Buttons */}
<div className="space-y-1.5">
{[
'Mehr Details hinzufuegen',
'Platzhalter ausfuellen',
'Rechtliche Referenzen ergaenzen',
'Sprache vereinfachen',
].map((suggestion) => (
<button
key={suggestion}
onClick={() => onRefine(suggestion)}
disabled={isTyping}
className="w-full text-left px-3 py-1.5 text-xs bg-white hover:bg-blue-50 border border-gray-200 rounded-md transition-colors text-gray-600 disabled:opacity-50"
>
{suggestion}
</button>
))}
</div>
</div>
</div>
{/* Refinement Input */}
<div className="border-t border-gray-200 p-3 bg-white">
<div className="flex gap-2">
<input
type="text"
value={refineInput}
onChange={(e) => setRefineInput(e.target.value)}
onKeyDown={handleRefineKeyDown}
placeholder="Anweisung eingeben..."
disabled={isTyping}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
/>
<button
onClick={handleRefine}
disabled={!refineInput.trim() || isTyping}
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed 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 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
</div>
</div>
</div>
{/* Footer Actions */}
<div className="border-t border-gray-200 px-6 py-3 bg-white flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<button
onClick={onValidate}
disabled={isTyping}
className="px-4 py-2 text-sm font-medium text-green-700 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 disabled:opacity-50 transition-colors"
>
Validieren
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors"
>
Abbrechen
</button>
<button
onClick={onAccept}
disabled={isTyping}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
Draft akzeptieren
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,417 @@
'use client'
/**
* DraftingEngineWidget - Erweitert den ComplianceAdvisor um 4 Modi
*
* Mode-Indicator Pills: Explain / Ask / Draft / Validate
* Document-Type Selector aus requiredDocuments der ScopeDecision
* Feature-Flag enableDraftingEngine fuer schrittweises Rollout
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { useSDK } from '@/lib/sdk/context'
import { useDraftingEngine } from '@/lib/sdk/drafting-engine/use-drafting-engine'
import { DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
import type { AgentMode } from '@/lib/sdk/drafting-engine/types'
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
import { DraftEditor } from './DraftEditor'
import { ValidationReport } from './ValidationReport'
interface DraftingEngineWidgetProps {
currentStep?: string
enableDraftingEngine?: boolean
}
const MODE_CONFIG: Record<AgentMode, { label: string; color: string; activeColor: string; icon: string }> = {
explain: { label: 'Explain', color: 'bg-gray-100 text-gray-600', activeColor: 'bg-purple-100 text-purple-700 ring-1 ring-purple-300', icon: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
ask: { label: 'Ask', color: 'bg-gray-100 text-gray-600', activeColor: 'bg-amber-100 text-amber-700 ring-1 ring-amber-300', 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' },
draft: { label: 'Draft', color: 'bg-gray-100 text-gray-600', activeColor: 'bg-blue-100 text-blue-700 ring-1 ring-blue-300', icon: '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' },
validate: { label: 'Validate', color: 'bg-gray-100 text-gray-600', activeColor: 'bg-green-100 text-green-700 ring-1 ring-green-300', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
}
const EXAMPLE_QUESTIONS: Record<AgentMode, string[]> = {
explain: [
'Was ist ein Verarbeitungsverzeichnis?',
'Wann brauche ich eine DSFA?',
'Was sind TOM nach Art. 32 DSGVO?',
],
ask: [
'Welche Luecken hat mein Compliance-Profil?',
'Was fehlt noch fuer die Zertifizierung?',
'Welche Dokumente muss ich noch erstellen?',
],
draft: [
'Erstelle einen VVT-Eintrag fuer unseren Hauptprozess',
'Erstelle TOM fuer unsere Cloud-Infrastruktur',
'Erstelle eine Datenschutzerklaerung',
],
validate: [
'Pruefe die Konsistenz meiner Dokumente',
'Stimmen VVT und TOM ueberein?',
'Gibt es Luecken bei den Loeschfristen?',
],
}
export function DraftingEngineWidget({
currentStep = 'default',
enableDraftingEngine = true,
}: DraftingEngineWidgetProps) {
const { state } = useSDK()
const engine = useDraftingEngine()
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [inputValue, setInputValue] = useState('')
const [showDraftEditor, setShowDraftEditor] = useState(false)
const [showValidationReport, setShowValidationReport] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
// Available document types from scope decision
const availableDocumentTypes: ScopeDocumentType[] =
state.complianceScope?.decision?.requiredDocuments
?.filter(d => d.required)
.map(d => d.documentType as ScopeDocumentType) ?? ['vvt', 'tom', 'lf']
// Auto-scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [engine.messages])
// Open draft editor when a new draft arrives
useEffect(() => {
if (engine.currentDraft) {
setShowDraftEditor(true)
}
}, [engine.currentDraft])
// Open validation report when new results arrive
useEffect(() => {
if (engine.validationResult) {
setShowValidationReport(true)
}
}, [engine.validationResult])
const handleSendMessage = useCallback(
(content: string) => {
if (!content.trim()) return
setInputValue('')
engine.sendMessage(content)
},
[engine]
)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage(inputValue)
}
}
const exampleQuestions = EXAMPLE_QUESTIONS[engine.currentMode]
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-[5.5rem] w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
aria-label="Drafting Engine oeffnen"
>
<svg className="w-6 h-6" 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>
</button>
)
}
// Draft Editor full-screen overlay
if (showDraftEditor && engine.currentDraft) {
return (
<DraftEditor
draft={engine.currentDraft}
documentType={engine.activeDocumentType}
constraintCheck={engine.constraintCheck}
onAccept={() => {
engine.acceptDraft()
setShowDraftEditor(false)
}}
onValidate={() => {
engine.validateDraft()
}}
onClose={() => setShowDraftEditor(false)}
onRefine={(instruction: string) => {
engine.requestDraft(instruction)
}}
validationResult={engine.validationResult}
isTyping={engine.isTyping}
/>
)
}
return (
<div className={`fixed bottom-6 right-6 ${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[420px] h-[560px]'} max-h-screen bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200 transition-all duration-200`}>
{/* Header */}
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-4 py-3 rounded-t-2xl flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
<svg className="w-5 h-5" 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>
</div>
<div>
<div className="font-semibold text-sm">Drafting Engine</div>
<div className="text-xs text-white/80">Compliance-Dokumententwurf</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-white/80 hover:text-white transition-colors p-1"
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isExpanded ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9L4 4m0 0v4m0-4h4m6 6l5 5m0 0v-4m0 4h-4" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
)}
</svg>
</button>
<button
onClick={() => {
engine.clearMessages()
setIsOpen(false)
}}
className="text-white/80 hover:text-white transition-colors p-1"
aria-label="Schliessen"
>
<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>
{/* Mode Pills */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-gray-100 bg-white">
{(Object.keys(MODE_CONFIG) as AgentMode[]).map((mode) => {
const config = MODE_CONFIG[mode]
const isActive = engine.currentMode === mode
return (
<button
key={mode}
onClick={() => engine.setMode(mode)}
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium transition-all ${isActive ? config.activeColor : config.color} hover:opacity-80`}
>
<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={config.icon} />
</svg>
{config.label}
</button>
)
})}
</div>
{/* Document Type Selector (visible in draft/validate mode) */}
{(engine.currentMode === 'draft' || engine.currentMode === 'validate') && (
<div className="px-3 py-2 border-b border-gray-100 bg-gray-50/50">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 shrink-0">Dokument:</span>
<select
value={engine.activeDocumentType || ''}
onChange={(e) => engine.setDocumentType(e.target.value as ScopeDocumentType)}
className="flex-1 text-xs border border-gray-200 rounded-md px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-purple-400"
>
<option value="">Dokumenttyp waehlen...</option>
{availableDocumentTypes.map((dt) => (
<option key={dt} value={dt}>
{DOCUMENT_TYPE_LABELS[dt] || dt}
</option>
))}
</select>
</div>
</div>
)}
{/* Error Banner */}
{engine.error && (
<div className="mx-3 mt-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-700 flex items-center justify-between">
<span>{engine.error}</span>
<button onClick={() => engine.clearMessages()} className="text-red-500 hover:text-red-700 ml-2">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{/* Validation Report Inline */}
{showValidationReport && engine.validationResult && (
<div className="mx-3 mt-2 max-h-48 overflow-y-auto">
<ValidationReport
result={engine.validationResult}
onClose={() => setShowValidationReport(false)}
compact
/>
</div>
)}
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
{engine.messages.length === 0 ? (
<div className="text-center py-6">
<div className="w-14 h-14 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-3">
<svg className="w-7 h-7 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={MODE_CONFIG[engine.currentMode].icon} />
</svg>
</div>
<h3 className="text-sm font-medium text-gray-900 mb-1">
{engine.currentMode === 'explain' && 'Fragen beantworten'}
{engine.currentMode === 'ask' && 'Luecken erkennen'}
{engine.currentMode === 'draft' && 'Dokumente entwerfen'}
{engine.currentMode === 'validate' && 'Konsistenz pruefen'}
</h3>
<p className="text-xs text-gray-500 mb-4">
{engine.currentMode === 'explain' && 'Stellen Sie Fragen zu DSGVO, AI Act und Compliance.'}
{engine.currentMode === 'ask' && 'Identifiziert Luecken in Ihrem Compliance-Profil.'}
{engine.currentMode === 'draft' && 'Erstellt strukturierte Compliance-Dokumente.'}
{engine.currentMode === 'validate' && 'Prueft Cross-Dokument-Konsistenz.'}
</p>
<div className="text-left space-y-2">
<p className="text-xs font-medium text-gray-700 mb-2">Beispiele:</p>
{exampleQuestions.map((q, idx) => (
<button
key={idx}
onClick={() => handleSendMessage(q)}
className="w-full text-left px-3 py-2 text-xs bg-white hover:bg-purple-50 border border-gray-200 rounded-lg transition-colors text-gray-700"
>
{q}
</button>
))}
</div>
{/* Quick Actions for Draft/Validate */}
{engine.currentMode === 'draft' && engine.activeDocumentType && (
<button
onClick={() => engine.requestDraft()}
className="mt-4 px-4 py-2 bg-blue-600 text-white text-xs font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Draft fuer {DOCUMENT_TYPE_LABELS[engine.activeDocumentType]?.split(' (')[0] || engine.activeDocumentType} erstellen
</button>
)}
{engine.currentMode === 'validate' && (
<button
onClick={() => engine.validateDraft()}
className="mt-4 px-4 py-2 bg-green-600 text-white text-xs font-medium rounded-lg hover:bg-green-700 transition-colors"
>
Validierung starten
</button>
)}
</div>
) : (
<>
{engine.messages.map((message, idx) => (
<div
key={idx}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 ${
message.role === 'user'
? 'bg-indigo-600 text-white'
: 'bg-white border border-gray-200 text-gray-800'
}`}
>
<p className={`text-sm ${message.role === 'assistant' ? 'whitespace-pre-wrap' : ''}`}>
{message.content}
</p>
{/* Draft ready indicator */}
{message.metadata?.hasDraft && engine.currentDraft && (
<button
onClick={() => setShowDraftEditor(true)}
className="mt-2 flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 border border-blue-200 rounded-md text-xs text-blue-700 hover:bg-blue-100 transition-colors"
>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Im Editor oeffnen
</button>
)}
{/* Validation ready indicator */}
{message.metadata?.hasValidation && engine.validationResult && (
<button
onClick={() => setShowValidationReport(true)}
className="mt-2 flex items-center gap-1.5 px-3 py-1.5 bg-green-50 border border-green-200 rounded-md text-xs text-green-700 hover:bg-green-100 transition-colors"
>
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Validierungsbericht anzeigen
</button>
)}
</div>
</div>
))}
{engine.isTyping && (
<div className="flex justify-start">
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }} />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input Area */}
<div className="border-t border-gray-200 p-3 bg-white rounded-b-2xl">
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
engine.currentMode === 'draft'
? 'Anweisung fuer den Entwurf...'
: engine.currentMode === 'validate'
? 'Validierungsfrage...'
: 'Frage eingeben...'
}
disabled={engine.isTyping}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50"
/>
{engine.isTyping ? (
<button
onClick={engine.stopGeneration}
className="px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
title="Generierung stoppen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 6h12v12H6z" />
</svg>
</button>
) : (
<button
onClick={() => handleSendMessage(inputValue)}
disabled={!inputValue.trim()}
className="px-3 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed 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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
)}
</div>
</div>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,551 @@
'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}
/>
<AdditionalModuleItem
href="/sdk/compliance-hub"
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 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>
}
label="Compliance Hub"
isActive={pathname === '/sdk/compliance-hub'}
collapsed={collapsed}
/>
<AdditionalModuleItem
href="/sdk/dsms"
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-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>
}
label="DSMS"
isActive={pathname === '/sdk/dsms'}
collapsed={collapsed}
/>
</div>
</nav>
{/* Footer */}
<div className={`${collapsed ? 'p-2' : 'p-4'} border-t border-gray-200 bg-gray-50`}>
{/* Collapse Toggle */}
<button
onClick={() => onCollapsedChange?.(!collapsed)}
className={`w-full flex items-center justify-center gap-2 ${collapsed ? 'p-2' : 'px-4 py-2'} text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors ${collapsed ? '' : 'mb-2'}`}
title={collapsed ? 'Sidebar erweitern' : 'Sidebar einklappen'}
>
<CollapseIcon collapsed={collapsed} />
{!collapsed && <span>Einklappen</span>}
</button>
{/* Export Button */}
{!collapsed && (
<button
onClick={() => {}}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm text-purple-600 hover:text-purple-700 hover:bg-purple-50 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
<span>Exportieren</span>
</button>
)}
</div>
</aside>
)
}

View File

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

View File

@@ -0,0 +1,786 @@
'use client'
import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useSDK, getStepById, getNextStep, getPreviousStep, SDKStep, SDK_STEPS } 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 ?
SDK_STEPS.filter(s => s.phase === currentStep.phase).length : 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.',
},
],
},
'compliance-scope': {
title: 'Compliance Scope',
description: 'Umfang und Tiefe Ihrer Compliance-Dokumentation bestimmen',
explanation: 'Die Compliance Scope Engine bestimmt deterministisch, welche Dokumente Sie in welcher Tiefe benoetigen. Basierend auf 35 Fragen in 6 Bloecken werden Risiko-, Komplexitaets- und Assurance-Scores berechnet, die in ein 4-Level-Modell (L1 Lean bis L4 Zertifizierungsbereit) muenden.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Deterministisch',
description: 'Alle Entscheidungen sind nachvollziehbar — keine KI, keine Black Box. Jede Einstufung wird mit Rechtsgrundlage und Audit-Trail begruendet.',
},
{
icon: 'info' as const,
title: '4-Level-Modell',
description: 'L1 (Lean Startup) bis L4 (Zertifizierungsbereit). Hard Triggers (Art. 9, Minderjaehrige, Zertifizierungsziele) heben das Level automatisch an.',
},
{
icon: 'warning' as const,
title: 'Hard Triggers',
description: '50 deterministische Regeln pruefen besondere Kategorien (Art. 9), Minderjaehrige, KI-Einsatz, Drittlandtransfers und Zertifizierungsziele.',
},
],
},
'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. Das Dashboard zeigt den Status aller aus dem TOM Generator abgeleiteten Massnahmen mit SDM-Mapping und Gap-Analyse.',
tips: [
{
icon: 'warning' as const,
title: 'Nachweispflicht',
description: 'TOMs muessen nachweisbar real sein. Verknuepfen Sie Evidence-Dokumente (Policies, Zertifikate, Screenshots) mit jeder Massnahme, um die Rechenschaftspflicht (Art. 5 Abs. 2 DSGVO) zu erfuellen.',
},
{
icon: 'info' as const,
title: 'Generator nutzen',
description: 'Der 6-Schritt-Wizard leitet TOMs systematisch aus Ihrem Risikoprofil ab. Starten Sie dort, um eine vollstaendige Baseline zu erhalten.',
},
{
icon: 'info' as const,
title: 'SDM-Mapping',
description: 'Kontrollen werden den 7 SDM-Gewaehrleistungszielen zugeordnet: Verfuegbarkeit, Integritaet, Vertraulichkeit, Nichtverkettung, Intervenierbarkeit, Transparenz, Datenminimierung.',
},
],
},
'vvt': {
title: 'Verarbeitungsverzeichnis',
description: 'Erstellen und verwalten Sie Ihr Verzeichnis nach Art. 30 DSGVO',
explanation: 'Das Verarbeitungsverzeichnis (VVT) dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Der integrierte Generator-Fragebogen befuellt 70-90% der Pflichtfelder automatisch anhand Ihres Unternehmensprofils.',
tips: [
{
icon: 'warning' as const,
title: 'Pflicht fuer alle',
description: 'Die Ausnahme fuer Unternehmen <250 Mitarbeiter greift nur bei gelegentlicher, risikoarmer Verarbeitung ohne besondere Kategorien (Art. 30 Abs. 5).',
},
{
icon: 'info' as const,
title: 'Zweck-zuerst',
description: 'Definieren Sie Verarbeitungen nach Geschaeftszweck, nicht nach Tool. Ein Tool kann mehrere Verarbeitungen abdecken, eine Verarbeitung kann mehrere Tools nutzen.',
},
{
icon: 'info' as const,
title: 'Kein oeffentliches Dokument',
description: 'Das VVT ist ein internes Dokument. Es muss der Aufsichtsbehoerde nur auf Verlangen vorgelegt werden (Art. 30 Abs. 4).',
},
],
},
'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. Die 3-Stufen-Logik (Zweckende, Aufbewahrungspflicht, Legal Hold) stellt sicher, dass alle gesetzlichen Anforderungen beruecksichtigt werden.',
tips: [
{
icon: 'warning' as const,
title: '3-Stufen-Logik',
description: 'Jede Loeschfrist folgt einer 3-Stufen-Logik: 1. Zweckende (Daten werden nach Zweckwegfall geloescht), 2. Aufbewahrungspflicht (gesetzliche Fristen verhindern Loeschung), 3. Legal Hold (laufende Verfahren blockieren Loeschung).',
},
{
icon: 'info' as const,
title: 'Deutsche Rechtsgrundlagen',
description: 'Der Generator kennt die wichtigsten Aufbewahrungstreiber: AO (10 J. Steuer), HGB (10/6 J. Handel), UStG (10 J. Rechnungen), BGB (3 J. Verjaehrung), ArbZG (2 J. Zeiterfassung), AGG (6 Mon. Bewerbungen).',
},
{
icon: 'info' as const,
title: 'Backup-Behandlung',
description: 'Auch Backups muessen ins Loeschkonzept einbezogen werden. Daten koennen nach primaerer Loeschung noch in Backup-Systemen existieren.',
},
],
},
'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.',
},
],
},
'source-policy': {
title: 'Source Policy',
description: 'Verwalten Sie Ihre Datenquellen-Governance',
explanation: 'Die Source Policy definiert, welche externen Datenquellen fuer Ihre Anwendung zugelassen sind. Sie umfasst eine Whitelist, Operationsmatrix (Lookup, RAG, Training, Export), PII-Regeln und ein Audit-Trail.',
tips: [
{
icon: 'warning' as const,
title: 'Lizenzierung',
description: 'Pruefen Sie die Lizenzen aller Datenquellen (DL-DE-BY, CC-BY, CC0). Nicht-lizenzierte Quellen koennen rechtliche Risiken bergen.',
},
{
icon: 'info' as const,
title: 'PII-Regeln',
description: 'Definieren Sie klare Regeln fuer den Umgang mit personenbezogenen Daten in externen Quellen.',
},
],
},
'audit-report': {
title: 'Audit Report',
description: 'Erstellen und verwalten Sie Audit-Sitzungen',
explanation: 'Im Audit Report erstellen Sie formelle Audit-Sitzungen mit Pruefer-Informationen, fuehren die Pruefung durch und generieren PDF-Reports. Jede Sitzung dokumentiert den Compliance-Stand zu einem bestimmten Zeitpunkt.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Regelmaessigkeit',
description: 'Fuehren Sie mindestens jaehrlich ein formelles Audit durch. Dokumentieren Sie Abweichungen und Massnahmenplaene.',
},
{
icon: 'success' as const,
title: 'PDF-Export',
description: 'Generieren Sie PDF-Reports in Deutsch oder Englisch fuer externe Pruefer.',
},
],
},
'workflow': {
title: 'Document Workflow',
description: 'Verwalten Sie den Freigabe-Prozess Ihrer rechtlichen Dokumente',
explanation: 'Der Document Workflow bietet einen Split-View-Editor mit synchronisiertem Scrollen. Dokumente durchlaufen den Status Draft → Review → Approved → Published. Aenderungen werden versioniert und der Freigabeprozess wird protokolliert.',
tips: [
{
icon: 'warning' as const,
title: 'Vier-Augen-Prinzip',
description: 'Rechtliche Dokumente sollten immer von mindestens einer weiteren Person geprueft werden, bevor sie veroeffentlicht werden.',
},
{
icon: 'info' as const,
title: 'Versionierung',
description: 'Jede Aenderung wird als neue Version gespeichert. So koennen Sie jederzeit den Stand eines Dokuments nachvollziehen.',
},
],
},
'consent-management': {
title: 'Consent Verwaltung',
description: 'Verwalten Sie Consent-Dokumente, Versionen und DSGVO-Prozesse',
explanation: 'Die Consent Verwaltung umfasst das Lifecycle-Management Ihrer rechtlichen Dokumente (AGB, Datenschutz, Cookie-Richtlinien), die Verwaltung von E-Mail-Templates (16 Lifecycle-E-Mails) und die Steuerung der DSGVO-Prozesse (Art. 15-21).',
tips: [
{
icon: 'info' as const,
title: 'Dokumentversionen',
description: 'Jede Aenderung an einem Consent-Dokument erzeugt eine neue Version. Aktive Nutzer muessen bei Aenderungen erneut zustimmen.',
},
{
icon: 'warning' as const,
title: 'DSGVO-Fristen',
description: 'Betroffenenrechte (Art. 15-21) haben gesetzliche Fristen. Auskunft: 30 Tage, Loeschung: unverzueglich.',
},
],
},
'notfallplan': {
title: 'Notfallplan & Breach Response',
description: 'Verwalten Sie Ihr Datenpannen-Management nach Art. 33/34 DSGVO',
explanation: 'Der Notfallplan definiert Ihren Prozess bei Datenpannen gemaess Art. 33/34 DSGVO. Er umfasst die 72-Stunden-Meldepflicht an die Aufsichtsbehoerde, die Benachrichtigung betroffener Personen bei hohem Risiko, Incident-Klassifizierung, Eskalationswege und Dokumentationspflichten.',
tips: [
{
icon: 'warning' as const,
title: '72-Stunden-Frist',
description: 'Art. 33 DSGVO: Meldung an die Aufsichtsbehoerde innerhalb von 72 Stunden nach Bekanntwerden. Verspaetete Meldungen muessen begruendet werden.',
},
{
icon: 'info' as const,
title: 'Dokumentationspflicht',
description: 'Art. 33 Abs. 5: Alle Datenpannen muessen dokumentiert werden — auch solche, die nicht meldepflichtig sind. Die Dokumentation muss der Aufsichtsbehoerde auf Verlangen vorgelegt werden koennen.',
},
],
},
}
export default StepHeader

View File

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

View File

@@ -0,0 +1,220 @@
'use client'
/**
* ValidationReport - Strukturierte Anzeige von Validierungsergebnissen
*
* Errors (Scope-Violations) in Rot
* Warnings (Inkonsistenzen) in Amber
* Suggestions in Blau
*/
import { DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
import type { ValidationResult, ValidationFinding } from '@/lib/sdk/drafting-engine/types'
interface ValidationReportProps {
result: ValidationResult
onClose: () => void
/** Compact mode for inline display in widget */
compact?: boolean
}
const SEVERITY_CONFIG = {
error: {
bg: 'bg-red-50',
border: 'border-red-200',
text: 'text-red-700',
icon: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
label: 'Fehler',
dotColor: 'bg-red-500',
},
warning: {
bg: 'bg-amber-50',
border: 'border-amber-200',
text: 'text-amber-700',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z',
label: 'Warnungen',
dotColor: 'bg-amber-500',
},
suggestion: {
bg: 'bg-blue-50',
border: 'border-blue-200',
text: 'text-blue-700',
icon: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
label: 'Vorschlaege',
dotColor: 'bg-blue-500',
},
}
function FindingCard({ finding, compact }: { finding: ValidationFinding; compact?: boolean }) {
const config = SEVERITY_CONFIG[finding.severity]
const docLabel = DOCUMENT_TYPE_LABELS[finding.documentType]?.split(' (')[0] || finding.documentType
if (compact) {
return (
<div className={`flex items-start gap-2 px-2.5 py-1.5 ${config.bg} rounded-md border ${config.border}`}>
<span className={`w-1.5 h-1.5 rounded-full mt-1.5 shrink-0 ${config.dotColor}`} />
<div className="min-w-0">
<p className={`text-xs font-medium ${config.text}`}>{finding.title}</p>
<p className="text-xs text-gray-500 truncate">{finding.description}</p>
</div>
</div>
)
}
return (
<div className={`${config.bg} rounded-lg border ${config.border} p-3`}>
<div className="flex items-start gap-2">
<svg className={`w-4 h-4 mt-0.5 shrink-0 ${config.text}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={config.icon} />
</svg>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className={`text-sm font-medium ${config.text}`}>{finding.title}</h4>
<span className="text-xs text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">{docLabel}</span>
</div>
<p className="text-xs text-gray-600 mt-1">{finding.description}</p>
{finding.crossReferenceType && (
<p className="text-xs text-gray-500 mt-1">
Cross-Referenz: {DOCUMENT_TYPE_LABELS[finding.crossReferenceType]?.split(' (')[0] || finding.crossReferenceType}
</p>
)}
{finding.legalReference && (
<p className="text-xs text-gray-500 mt-1 font-mono">{finding.legalReference}</p>
)}
{finding.suggestion && (
<div className="mt-2 flex items-start gap-1.5 px-2.5 py-1.5 bg-white/60 rounded border border-gray-100">
<svg className="w-3.5 h-3.5 mt-0.5 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<p className="text-xs text-gray-600">{finding.suggestion}</p>
</div>
)}
</div>
</div>
</div>
)
}
export function ValidationReport({ result, onClose, compact }: ValidationReportProps) {
const totalFindings = result.errors.length + result.warnings.length + result.suggestions.length
if (compact) {
return (
<div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b border-gray-100">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${result.passed ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-xs font-medium text-gray-700">
{result.passed ? 'Validierung bestanden' : 'Validierung fehlgeschlagen'}
</span>
<span className="text-xs text-gray-400">
({totalFindings} {totalFindings === 1 ? 'Fund' : 'Funde'})
</span>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-2 space-y-1.5 max-h-36 overflow-y-auto">
{result.errors.map((f) => <FindingCard key={f.id} finding={f} compact />)}
{result.warnings.map((f) => <FindingCard key={f.id} finding={f} compact />)}
{result.suggestions.map((f) => <FindingCard key={f.id} finding={f} compact />)}
</div>
</div>
)
}
return (
<div className="space-y-4">
{/* Summary Header */}
<div className={`rounded-lg border p-4 ${result.passed ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${result.passed ? 'bg-green-100' : 'bg-red-100'}`}>
<svg className={`w-5 h-5 ${result.passed ? 'text-green-600' : 'text-red-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={result.passed ? 'M5 13l4 4L19 7' : 'M6 18L18 6M6 6l12 12'} />
</svg>
</div>
<div>
<h3 className={`text-sm font-semibold ${result.passed ? 'text-green-800' : 'text-red-800'}`}>
{result.passed ? 'Validierung bestanden' : 'Validierung fehlgeschlagen'}
</h3>
<p className="text-xs text-gray-500">
Level {result.scopeLevel} | {new Date(result.timestamp).toLocaleString('de-DE')}
</p>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-3">
{result.errors.length > 0 && (
<div className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-xs font-medium text-red-700">{result.errors.length}</span>
</div>
)}
{result.warnings.length > 0 && (
<div className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-amber-500" />
<span className="text-xs font-medium text-amber-700">{result.warnings.length}</span>
</div>
)}
{result.suggestions.length > 0 && (
<div className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-xs font-medium text-blue-700">{result.suggestions.length}</span>
</div>
)}
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 ml-2">
<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>
</div>
</div>
</div>
{/* Errors */}
{result.errors.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-red-700 uppercase tracking-wide mb-2">
Fehler ({result.errors.length})
</h4>
<div className="space-y-2">
{result.errors.map((f) => <FindingCard key={f.id} finding={f} />)}
</div>
</div>
)}
{/* Warnings */}
{result.warnings.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-amber-700 uppercase tracking-wide mb-2">
Warnungen ({result.warnings.length})
</h4>
<div className="space-y-2">
{result.warnings.map((f) => <FindingCard key={f.id} finding={f} />)}
</div>
</div>
)}
{/* Suggestions */}
{result.suggestions.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-blue-700 uppercase tracking-wide mb-2">
Vorschlaege ({result.suggestions.length})
</h4>
<div className="space-y-2">
{result.suggestions.map((f) => <FindingCard key={f.id} finding={f} />)}
</div>
</div>
)}
</div>
)
}

View File

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

View File

@@ -0,0 +1,362 @@
'use client'
import React, { useState } from 'react'
import type { ScopeDecision, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_DESCRIPTIONS, DEPTH_LEVEL_COLORS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
interface ScopeDecisionTabProps {
decision: ScopeDecision | null
}
export function ScopeDecisionTab({ decision }: ScopeDecisionTabProps) {
const [expandedTrigger, setExpandedTrigger] = useState<number | null>(null)
const [showAuditTrail, setShowAuditTrail] = useState(false)
if (!decision) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 rounded-full mb-4">
<svg className="w-8 h-8 text-gray-400" 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>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Keine Entscheidung vorhanden</h3>
<p className="text-gray-600">Bitte führen Sie zuerst das Scope-Profiling durch.</p>
</div>
)
}
const getScoreColor = (score: number): string => {
if (score >= 80) return 'from-red-500 to-red-600'
if (score >= 60) return 'from-orange-500 to-orange-600'
if (score >= 40) return 'from-yellow-500 to-yellow-600'
return 'from-green-500 to-green-600'
}
const getSeverityBadge = (severity: 'low' | 'medium' | 'high' | 'critical') => {
const colors = {
low: 'bg-gray-100 text-gray-800',
medium: 'bg-yellow-100 text-yellow-800',
high: 'bg-orange-100 text-orange-800',
critical: 'bg-red-100 text-red-800',
}
const labels = {
low: 'Niedrig',
medium: 'Mittel',
high: 'Hoch',
critical: 'Kritisch',
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[severity]}`}>
{labels[severity]}
</span>
)
}
const renderScoreBar = (label: string, score: number | undefined) => {
const value = score ?? 0
return (
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-700">{label}</span>
<span className="text-sm font-bold text-gray-900">{value}/100</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className={`h-full bg-gradient-to-r ${getScoreColor(value)} transition-all duration-500`}
style={{ width: `${value}%` }}
/>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Level Determination */}
<div className={`${DEPTH_LEVEL_COLORS[decision.level].bg} border-2 ${DEPTH_LEVEL_COLORS[decision.level].border} rounded-xl p-6`}>
<div className="flex items-start gap-6">
<div className={`flex-shrink-0 w-20 h-20 ${DEPTH_LEVEL_COLORS[decision.level].badge} rounded-xl flex items-center justify-center`}>
<span className={`text-3xl font-bold ${DEPTH_LEVEL_COLORS[decision.level].text}`}>
{decision.level}
</span>
</div>
<div className="flex-1">
<h2 className={`text-2xl font-bold ${DEPTH_LEVEL_COLORS[decision.level].text} mb-2`}>
{DEPTH_LEVEL_LABELS[decision.level]}
</h2>
<p className="text-gray-700 mb-3">{DEPTH_LEVEL_DESCRIPTIONS[decision.level]}</p>
{decision.reasoning && (
<p className="text-sm text-gray-600 italic">{decision.reasoning}</p>
)}
</div>
</div>
</div>
{/* Score Breakdown */}
{decision.scores && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Score-Analyse</h3>
<div className="space-y-4">
{renderScoreBar('Risiko-Score', decision.scores.riskScore)}
{renderScoreBar('Komplexitäts-Score', decision.scores.complexityScore)}
{renderScoreBar('Assurance-Score', decision.scores.assuranceScore)}
<div className="pt-4 border-t border-gray-200">
{renderScoreBar('Gesamt-Score', decision.scores.compositeScore)}
</div>
</div>
</div>
)}
{/* Hard Triggers */}
{decision.hardTriggers && decision.hardTriggers.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Hard-Trigger</h3>
<div className="space-y-3">
{decision.hardTriggers.map((trigger, idx) => (
<div
key={idx}
className={`border rounded-lg overflow-hidden ${
trigger.matched ? 'border-red-300 bg-red-50' : 'border-gray-200 bg-gray-50'
}`}
>
<button
type="button"
onClick={() => setExpandedTrigger(expandedTrigger === idx ? null : idx)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-opacity-80 transition-colors"
>
<div className="flex items-center gap-3">
{trigger.matched && (
<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="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>
)}
<span className="font-medium text-gray-900">{trigger.label}</span>
</div>
<svg
className={`w-5 h-5 text-gray-500 transition-transform ${
expandedTrigger === idx ? '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>
{expandedTrigger === idx && (
<div className="px-4 pb-4 pt-2 border-t border-gray-200">
<p className="text-sm text-gray-700 mb-2">{trigger.description}</p>
{trigger.legalReference && (
<p className="text-xs text-gray-600 mb-2">
<span className="font-medium">Rechtsgrundlage:</span> {trigger.legalReference}
</p>
)}
{trigger.matchedValue && (
<p className="text-xs text-gray-700">
<span className="font-medium">Erfasster Wert:</span> {trigger.matchedValue}
</p>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Required Documents */}
{decision.requiredDocuments && decision.requiredDocuments.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Erforderliche Dokumente</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Typ</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Tiefe</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aufwand</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Status</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aktion</th>
</tr>
</thead>
<tbody>
{decision.requiredDocuments.map((doc, idx) => (
<tr key={idx} className="border-b border-gray-100 last:border-b-0 hover:bg-gray-50">
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">
{DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType}
</span>
{doc.isMandatory && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
Pflicht
</span>
)}
</div>
</td>
<td className="py-3 px-4 text-sm text-gray-700">{doc.depthDescription}</td>
<td className="py-3 px-4 text-sm text-gray-700">
{doc.effortEstimate ? `${doc.effortEstimate.days} Tage` : '-'}
</td>
<td className="py-3 px-4">
{doc.triggeredByHardTrigger && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
Hard-Trigger
</span>
)}
</td>
<td className="py-3 px-4">
{doc.sdkStepUrl && (
<a
href={doc.sdkStepUrl}
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
>
Zum SDK-Schritt
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Risk Flags */}
{decision.riskFlags && decision.riskFlags.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Risiko-Flags</h3>
<div className="space-y-4">
{decision.riskFlags.map((flag, idx) => (
<div key={idx} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-gray-900">{flag.title}</h4>
{getSeverityBadge(flag.severity)}
</div>
<p className="text-sm text-gray-700 mb-2">{flag.description}</p>
<p className="text-sm text-gray-600">
<span className="font-medium">Empfehlung:</span> {flag.recommendation}
</p>
</div>
))}
</div>
</div>
)}
{/* Gap Analysis */}
{decision.gapAnalysis && decision.gapAnalysis.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Gap-Analyse</h3>
<div className="space-y-4">
{decision.gapAnalysis.map((gap, idx) => (
<div key={idx} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-gray-900">{gap.title}</h4>
{getSeverityBadge(gap.severity)}
</div>
<p className="text-sm text-gray-700 mb-2">{gap.description}</p>
<p className="text-sm text-gray-600 mb-2">
<span className="font-medium">Empfehlung:</span> {gap.recommendation}
</p>
{gap.relatedDocuments && gap.relatedDocuments.length > 0 && (
<div className="mt-2">
<span className="text-xs text-gray-500">Betroffene Dokumente: </span>
{gap.relatedDocuments.map((doc, docIdx) => (
<span
key={docIdx}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 mr-1"
>
{DOCUMENT_TYPE_LABELS[doc] || doc}
</span>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Next Actions */}
{decision.nextActions && decision.nextActions.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Nächste Schritte</h3>
<div className="space-y-4">
{decision.nextActions.map((action, idx) => (
<div key={idx} className="flex gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
<span className="text-sm font-bold text-purple-700">{action.priority}</span>
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900 mb-1">{action.title}</h4>
<p className="text-sm text-gray-700 mb-2">{action.description}</p>
<div className="flex items-center gap-3">
{action.effortDays && (
<span className="text-xs text-gray-600">
<span className="font-medium">Aufwand:</span> {action.effortDays} Tage
</span>
)}
{action.relatedDocuments && action.relatedDocuments.length > 0 && (
<span className="text-xs text-gray-600">
<span className="font-medium">Dokumente:</span> {action.relatedDocuments.length}
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Audit Trail */}
{decision.auditTrail && decision.auditTrail.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<button
type="button"
onClick={() => setShowAuditTrail(!showAuditTrail)}
className="w-full flex items-center justify-between mb-4"
>
<h3 className="text-lg font-semibold text-gray-900">Audit-Trail</h3>
<svg
className={`w-5 h-5 text-gray-500 transition-transform ${showAuditTrail ? '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>
{showAuditTrail && (
<div className="space-y-3">
{decision.auditTrail.map((entry, idx) => (
<div key={idx} className="border-l-2 border-purple-300 pl-4 py-2">
<h4 className="font-medium text-gray-900 mb-1">{entry.step}</h4>
<p className="text-sm text-gray-700 mb-2">{entry.description}</p>
{entry.details && entry.details.length > 0 && (
<ul className="text-xs text-gray-600 space-y-1">
{entry.details.map((detail, detailIdx) => (
<li key={detailIdx}> {detail}</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,334 @@
'use client'
import React, { useState, useCallback } from 'react'
import type { ScopeDecision, ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types'
import { DEPTH_LEVEL_LABELS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
interface ScopeExportTabProps {
decision: ScopeDecision | null
answers: ScopeProfilingAnswer[]
}
export function ScopeExportTab({ decision, answers }: ScopeExportTabProps) {
const [copiedMarkdown, setCopiedMarkdown] = useState(false)
const handleDownloadJSON = useCallback(() => {
if (!decision) return
const dataStr = JSON.stringify(decision, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = `compliance-scope-decision-${new Date().toISOString().split('T')[0]}.json`
link.click()
URL.revokeObjectURL(url)
}, [decision])
const handleDownloadCSV = useCallback(() => {
if (!decision || !decision.requiredDocuments) return
const headers = ['Typ', 'Tiefe', 'Aufwand (Tage)', 'Pflicht', 'Hard-Trigger']
const rows = decision.requiredDocuments.map((doc) => [
DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType,
doc.depthDescription,
doc.effortEstimate?.days?.toString() || '0',
doc.isMandatory ? 'Ja' : 'Nein',
doc.triggeredByHardTrigger ? 'Ja' : 'Nein',
])
const csvContent = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n')
const dataBlob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = `compliance-scope-documents-${new Date().toISOString().split('T')[0]}.csv`
link.click()
URL.revokeObjectURL(url)
}, [decision])
const generateMarkdownSummary = useCallback(() => {
if (!decision) return ''
let markdown = `# Compliance Scope Entscheidung\n\n`
markdown += `**Datum:** ${new Date().toLocaleDateString('de-DE')}\n\n`
markdown += `## Einstufung\n\n`
markdown += `**Level:** ${decision.level} - ${DEPTH_LEVEL_LABELS[decision.level]}\n\n`
if (decision.reasoning) {
markdown += `**Begründung:** ${decision.reasoning}\n\n`
}
if (decision.scores) {
markdown += `## Scores\n\n`
markdown += `- **Risiko-Score:** ${decision.scores.riskScore}/100\n`
markdown += `- **Komplexitäts-Score:** ${decision.scores.complexityScore}/100\n`
markdown += `- **Assurance-Score:** ${decision.scores.assuranceScore}/100\n`
markdown += `- **Gesamt-Score:** ${decision.scores.compositeScore}/100\n\n`
}
if (decision.hardTriggers && decision.hardTriggers.length > 0) {
const matchedTriggers = decision.hardTriggers.filter((ht) => ht.matched)
if (matchedTriggers.length > 0) {
markdown += `## Aktive Hard-Trigger\n\n`
matchedTriggers.forEach((trigger) => {
markdown += `- **${trigger.label}**\n`
markdown += ` - ${trigger.description}\n`
if (trigger.legalReference) {
markdown += ` - Rechtsgrundlage: ${trigger.legalReference}\n`
}
})
markdown += `\n`
}
}
if (decision.requiredDocuments && decision.requiredDocuments.length > 0) {
markdown += `## Erforderliche Dokumente\n\n`
markdown += `| Typ | Tiefe | Aufwand | Pflicht | Hard-Trigger |\n`
markdown += `|-----|-------|---------|---------|-------------|\n`
decision.requiredDocuments.forEach((doc) => {
markdown += `| ${DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType} | ${doc.depthDescription} | ${
doc.effortEstimate?.days || 0
} Tage | ${doc.isMandatory ? 'Ja' : 'Nein'} | ${doc.triggeredByHardTrigger ? 'Ja' : 'Nein'} |\n`
})
markdown += `\n`
}
if (decision.riskFlags && decision.riskFlags.length > 0) {
markdown += `## Risiko-Flags\n\n`
decision.riskFlags.forEach((flag) => {
markdown += `### ${flag.title} (${flag.severity})\n\n`
markdown += `${flag.description}\n\n`
markdown += `**Empfehlung:** ${flag.recommendation}\n\n`
})
}
if (decision.nextActions && decision.nextActions.length > 0) {
markdown += `## Nächste Schritte\n\n`
decision.nextActions.forEach((action) => {
markdown += `${action.priority}. **${action.title}**\n`
markdown += ` ${action.description}\n`
if (action.effortDays) {
markdown += ` Aufwand: ${action.effortDays} Tage\n`
}
markdown += `\n`
})
}
return markdown
}, [decision])
const handleCopyMarkdown = useCallback(() => {
const markdown = generateMarkdownSummary()
navigator.clipboard.writeText(markdown).then(() => {
setCopiedMarkdown(true)
setTimeout(() => setCopiedMarkdown(false), 2000)
})
}, [generateMarkdownSummary])
const handlePrintView = useCallback(() => {
if (!decision) return
const markdown = generateMarkdownSummary()
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Compliance Scope Entscheidung</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
max-width: 900px;
margin: 40px auto;
padding: 20px;
line-height: 1.6;
}
h1 { color: #7c3aed; border-bottom: 3px solid #7c3aed; padding-bottom: 10px; }
h2 { color: #5b21b6; margin-top: 30px; border-bottom: 2px solid #e5e7eb; padding-bottom: 8px; }
h3 { color: #4c1d95; margin-top: 20px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #d1d5db; padding: 12px; text-align: left; }
th { background-color: #f3f4f6; font-weight: 600; }
ul { list-style-type: disc; padding-left: 20px; }
li { margin: 8px 0; }
@media print {
body { margin: 20px; }
h1, h2, h3 { page-break-after: avoid; }
table { page-break-inside: avoid; }
}
</style>
</head>
<body>
<pre style="white-space: pre-wrap; font-family: inherit;">${markdown}</pre>
</body>
</html>
`
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(htmlContent)
printWindow.document.close()
printWindow.focus()
setTimeout(() => printWindow.print(), 250)
}
}, [decision, generateMarkdownSummary])
if (!decision) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 rounded-full mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Keine Daten zum Export</h3>
<p className="text-gray-600">Bitte führen Sie zuerst das Scope-Profiling durch.</p>
</div>
)
}
return (
<div className="space-y-6">
{/* JSON Export */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-blue-600" 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>
<h3 className="text-lg font-semibold text-gray-900">JSON Export</h3>
</div>
<p className="text-sm text-gray-600 mb-3">
Exportieren Sie die vollständige Entscheidung als strukturierte JSON-Datei für weitere Verarbeitung oder
Archivierung.
</p>
<button
onClick={handleDownloadJSON}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
>
JSON herunterladen
</button>
</div>
</div>
</div>
{/* CSV Export */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<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 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>
<h3 className="text-lg font-semibold text-gray-900">CSV Export</h3>
</div>
<p className="text-sm text-gray-600 mb-3">
Exportieren Sie die Liste der erforderlichen Dokumente als CSV-Datei für Excel, Google Sheets oder andere
Tabellenkalkulationen.
</p>
<button
onClick={handleDownloadCSV}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium text-sm"
>
CSV herunterladen
</button>
</div>
</div>
</div>
{/* Markdown Summary */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-2 mb-3">
<svg className="w-5 h-5 text-purple-600" 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>
<h3 className="text-lg font-semibold text-gray-900">Markdown-Zusammenfassung</h3>
</div>
<p className="text-sm text-gray-600 mb-3">
Strukturierte Zusammenfassung im Markdown-Format für Dokumentation oder Berichte.
</p>
<textarea
readOnly
value={generateMarkdownSummary()}
className="w-full h-64 px-4 py-3 border border-gray-300 rounded-lg font-mono text-sm text-gray-700 resize-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<button
onClick={handleCopyMarkdown}
className="mt-3 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium text-sm"
>
{copiedMarkdown ? 'Kopiert!' : 'Kopieren'}
</button>
</div>
{/* Print View */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"
/>
</svg>
<h3 className="text-lg font-semibold text-gray-900">Druckansicht</h3>
</div>
<p className="text-sm text-gray-600 mb-3">
Öffnen Sie eine druckfreundliche HTML-Ansicht der Entscheidung in einem neuen Fenster.
</p>
<button
onClick={handlePrintView}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-medium text-sm"
>
Druckansicht öffnen
</button>
</div>
</div>
</div>
{/* Export Info */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.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>
<div>
<h4 className="text-sm font-semibold text-blue-900 mb-1">Export-Hinweise</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li> JSON-Exporte enthalten alle Daten und können wieder importiert werden</li>
<li> CSV-Exporte sind ideal für Tabellenkalkulation und Aufwandsschätzungen</li>
<li> Markdown eignet sich für Dokumentation und Berichte</li>
<li> Die Druckansicht ist optimiert für PDF-Export über den Browser</li>
</ul>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,267 @@
'use client'
import React from 'react'
import type { ComplianceScopeState, ScopeDecision, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_DESCRIPTIONS, DEPTH_LEVEL_COLORS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
interface ScopeOverviewTabProps {
scopeState: ComplianceScopeState
onStartProfiling: () => void
onRefreshDecision: () => void
}
export function ScopeOverviewTab({ scopeState, onStartProfiling, onRefreshDecision }: ScopeOverviewTabProps) {
const { decision, answers } = scopeState
const hasAnswers = answers && answers.length > 0
const getScoreColor = (score: number): string => {
if (score >= 80) return 'from-red-500 to-red-600'
if (score >= 60) return 'from-orange-500 to-orange-600'
if (score >= 40) return 'from-yellow-500 to-yellow-600'
return 'from-green-500 to-green-600'
}
const getScoreColorBg = (score: number): string => {
if (score >= 80) return 'bg-red-100'
if (score >= 60) return 'bg-orange-100'
if (score >= 40) return 'bg-yellow-100'
return 'bg-green-100'
}
const renderScoreGauge = (label: string, score: number | undefined) => {
const value = score ?? 0
return (
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-700">{label}</span>
<span className="text-sm font-bold text-gray-900">{value}/100</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className={`h-full bg-gradient-to-r ${getScoreColor(value)} transition-all duration-500`}
style={{ width: `${value}%` }}
/>
</div>
</div>
)
}
const renderLevelBadge = () => {
if (!decision?.level) {
return (
<div className="bg-gray-100 border border-gray-300 rounded-xl p-8 text-center">
<div className="inline-flex items-center justify-center w-24 h-24 bg-gray-200 rounded-full mb-4">
<span className="text-4xl font-bold text-gray-400">?</span>
</div>
<h3 className="text-xl font-semibold text-gray-600 mb-2">Noch nicht bewertet</h3>
<p className="text-gray-500">
Führen Sie das Scope-Profiling durch, um Ihre Compliance-Tiefe zu bestimmen.
</p>
</div>
)
}
const levelColors = DEPTH_LEVEL_COLORS[decision.level]
return (
<div className={`${levelColors.bg} border-2 ${levelColors.border} rounded-xl p-8 text-center`}>
<div className={`inline-flex items-center justify-center w-24 h-24 ${levelColors.badge} rounded-full mb-4`}>
<span className={`text-4xl font-bold ${levelColors.text}`}>{decision.level}</span>
</div>
<h3 className={`text-xl font-semibold ${levelColors.text} mb-2`}>
{DEPTH_LEVEL_LABELS[decision.level]}
</h3>
<p className="text-gray-600">{DEPTH_LEVEL_DESCRIPTIONS[decision.level]}</p>
</div>
)
}
const renderActiveHardTriggers = () => {
if (!decision?.hardTriggers || decision.hardTriggers.length === 0) {
return null
}
const activeHardTriggers = decision.hardTriggers.filter((ht) => ht.matched)
if (activeHardTriggers.length === 0) {
return null
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-2 mb-4">
<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="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>
<h3 className="text-lg font-semibold text-gray-900">Aktive Hard-Trigger</h3>
</div>
<div className="space-y-3">
{activeHardTriggers.map((trigger, idx) => (
<div
key={idx}
className="border-l-4 border-red-500 bg-red-50 rounded-r-lg p-4"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-semibold text-gray-900">{trigger.label}</h4>
<p className="text-sm text-gray-600 mt-1">{trigger.description}</p>
{trigger.legalReference && (
<p className="text-xs text-gray-500 mt-2">
<span className="font-medium">Rechtsgrundlage:</span> {trigger.legalReference}
</p>
)}
{trigger.matchedValue && (
<p className="text-xs text-gray-700 mt-1">
<span className="font-medium">Erfasster Wert:</span> {trigger.matchedValue}
</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)
}
const renderDocumentSummary = () => {
if (!decision?.requiredDocuments) {
return null
}
const mandatoryDocs = decision.requiredDocuments.filter((doc) => doc.isMandatory)
const optionalDocs = decision.requiredDocuments.filter((doc) => !doc.isMandatory)
const totalEffortDays = decision.requiredDocuments.reduce(
(sum, doc) => sum + (doc.effortEstimate?.days ?? 0),
0
)
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Dokumenten-Übersicht</h3>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-3xl font-bold text-purple-600">{mandatoryDocs.length}</div>
<div className="text-sm text-gray-600 mt-1">Pflichtdokumente</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">{optionalDocs.length}</div>
<div className="text-sm text-gray-600 mt-1">Optional</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-gray-900">{totalEffortDays}</div>
<div className="text-sm text-gray-600 mt-1">Tage Aufwand (geschätzt)</div>
</div>
</div>
</div>
)
}
const renderRiskFlagsSummary = () => {
if (!decision?.riskFlags || decision.riskFlags.length === 0) {
return null
}
const critical = decision.riskFlags.filter((rf) => rf.severity === 'critical').length
const high = decision.riskFlags.filter((rf) => rf.severity === 'high').length
const medium = decision.riskFlags.filter((rf) => rf.severity === 'medium').length
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-2 mb-4">
<svg className="w-5 h-5 text-orange-600" 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>
<h3 className="text-lg font-semibold text-gray-900">Risiko-Flags</h3>
</div>
<div className="flex gap-6">
{critical > 0 && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Kritisch
</span>
<span className="text-lg font-bold text-red-600">{critical}</span>
</div>
)}
{high > 0 && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
Hoch
</span>
<span className="text-lg font-bold text-orange-600">{high}</span>
</div>
)}
{medium > 0 && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Mittel
</span>
<span className="text-lg font-bold text-yellow-600">{medium}</span>
</div>
)}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Level Badge */}
{renderLevelBadge()}
{/* Scores Section */}
{decision && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Score-Übersicht</h3>
<div className="space-y-4">
{renderScoreGauge('Risiko-Score', decision.scores?.riskScore)}
{renderScoreGauge('Komplexitäts-Score', decision.scores?.complexityScore)}
{renderScoreGauge('Assurance-Score', decision.scores?.assuranceScore)}
<div className="pt-4 border-t border-gray-200">
{renderScoreGauge('Gesamt-Score', decision.scores?.compositeScore)}
</div>
</div>
</div>
)}
{/* Active Hard Triggers */}
{renderActiveHardTriggers()}
{/* Document Summary */}
{renderDocumentSummary()}
{/* Risk Flags Summary */}
{renderRiskFlagsSummary()}
{/* CTA Section */}
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{!hasAnswers ? 'Bereit für das Scope-Profiling?' : 'Ergebnis aktualisieren'}
</h3>
<p className="text-gray-600">
{!hasAnswers
? 'Beantworten Sie einige Fragen zu Ihrem Unternehmen und erhalten Sie eine präzise Compliance-Bewertung.'
: 'Haben sich Ihre Unternehmensparameter geändert? Aktualisieren Sie Ihre Bewertung.'}
</p>
</div>
<button
onClick={!hasAnswers ? onStartProfiling : onRefreshDecision}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium whitespace-nowrap"
>
{!hasAnswers ? 'Scope-Profiling starten' : 'Ergebnis aktualisieren'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,459 @@
'use client'
import React, { useState, useCallback, useEffect, useMemo } from 'react'
import type { ScopeProfilingAnswer, ScopeProfilingQuestion } from '@/lib/sdk/compliance-scope-types'
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, getAnswerValue, prefillFromCompanyProfile } from '@/lib/sdk/compliance-scope-profiling'
import { useSDK } from '@/lib/sdk'
interface ScopeWizardTabProps {
answers: ScopeProfilingAnswer[]
onAnswersChange: (answers: ScopeProfilingAnswer[]) => void
onEvaluate: () => void
canEvaluate: boolean
isEvaluating: boolean
completionStats: { total: number; answered: number; percentage: number; isComplete: boolean }
}
export function ScopeWizardTab({
answers,
onAnswersChange,
onEvaluate,
canEvaluate,
isEvaluating,
completionStats,
}: ScopeWizardTabProps) {
const [currentBlockIndex, setCurrentBlockIndex] = useState(0)
const [expandedHelp, setExpandedHelp] = useState<Set<string>>(new Set())
const currentBlock = SCOPE_QUESTION_BLOCKS[currentBlockIndex]
const totalProgress = getTotalProgress(answers)
// Load companyProfile from SDK context
const { state: sdkState } = useSDK()
const companyProfile = sdkState.companyProfile
// Track which question IDs were prefilled from profile
const [prefilledIds, setPrefilledIds] = useState<Set<string>>(new Set())
// Auto-prefill from company profile on mount if answers are empty
useEffect(() => {
if (companyProfile && answers.length === 0) {
const prefilled = prefillFromCompanyProfile(companyProfile)
if (prefilled.length > 0) {
onAnswersChange(prefilled)
setPrefilledIds(new Set(prefilled.map(a => a.questionId)))
}
}
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleAnswerChange = useCallback(
(questionId: string, value: string | string[] | boolean | number) => {
const existingIndex = answers.findIndex((a) => a.questionId === questionId)
if (existingIndex >= 0) {
const newAnswers = [...answers]
newAnswers[existingIndex] = { questionId, value }
onAnswersChange(newAnswers)
} else {
onAnswersChange([...answers, { questionId, value }])
}
// Remove from prefilled set when user manually changes
if (prefilledIds.has(questionId)) {
setPrefilledIds(prev => {
const next = new Set(prev)
next.delete(questionId)
return next
})
}
},
[answers, onAnswersChange, prefilledIds]
)
const handlePrefillFromProfile = useCallback(() => {
if (!companyProfile) return
const prefilled = prefillFromCompanyProfile(companyProfile)
// Merge with existing answers: prefilled values for questions not yet answered
const existingIds = new Set(answers.map(a => a.questionId))
const newAnswers = [...answers]
const newPrefilledIds = new Set(prefilledIds)
for (const pa of prefilled) {
if (!existingIds.has(pa.questionId)) {
newAnswers.push(pa)
newPrefilledIds.add(pa.questionId)
}
}
onAnswersChange(newAnswers)
setPrefilledIds(newPrefilledIds)
}, [companyProfile, answers, onAnswersChange, prefilledIds])
const handleNext = useCallback(() => {
if (currentBlockIndex < SCOPE_QUESTION_BLOCKS.length - 1) {
setCurrentBlockIndex(currentBlockIndex + 1)
} else if (canEvaluate) {
onEvaluate()
}
}, [currentBlockIndex, canEvaluate, onEvaluate])
const handleBack = useCallback(() => {
if (currentBlockIndex > 0) {
setCurrentBlockIndex(currentBlockIndex - 1)
}
}, [currentBlockIndex])
const toggleHelp = useCallback((questionId: string) => {
setExpandedHelp(prev => {
const next = new Set(prev)
if (next.has(questionId)) {
next.delete(questionId)
} else {
next.add(questionId)
}
return next
})
}, [])
// Check if a question was prefilled from company profile
const isPrefilledFromProfile = useCallback((questionId: string) => {
return prefilledIds.has(questionId)
}, [prefilledIds])
const renderHelpText = (question: ScopeProfilingQuestion) => {
if (!question.helpText) return null
return (
<>
<button
type="button"
className="ml-2 text-blue-400 hover:text-blue-600 inline-flex items-center"
onClick={(e) => {
e.preventDefault()
toggleHelp(question.id)
}}
>
<svg className="w-4 h-4" 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>
</button>
{expandedHelp.has(question.id) && (
<div className="flex items-start gap-2 mt-2 p-2.5 bg-blue-50 rounded-lg text-xs text-blue-700 leading-relaxed">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0 text-blue-500" 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>
<span>{question.helpText}</span>
</div>
)}
</>
)
}
const renderPrefilledBadge = (questionId: string) => {
if (!isPrefilledFromProfile(questionId)) return null
return (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
Aus Profil
</span>
)
}
const renderQuestion = (question: ScopeProfilingQuestion) => {
const currentValue = getAnswerValue(answers, question.id)
switch (question.type) {
case 'boolean':
return (
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => handleAnswerChange(question.id, true)}
className={`flex-1 py-2 px-4 rounded-lg border-2 transition-all ${
currentValue === true
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
Ja
</button>
<button
type="button"
onClick={() => handleAnswerChange(question.id, false)}
className={`flex-1 py-2 px-4 rounded-lg border-2 transition-all ${
currentValue === false
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
Nein
</button>
</div>
</div>
)
case 'single':
return (
<div className="space-y-2">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
<div className="space-y-2">
{question.options?.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleAnswerChange(question.id, option.value)}
className={`w-full text-left py-3 px-4 rounded-lg border-2 transition-all ${
currentValue === option.value
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
{option.label}
</button>
))}
</div>
</div>
)
case 'multi':
return (
<div className="space-y-2">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
<div className="space-y-2">
{question.options?.map((option) => {
const selectedValues = Array.isArray(currentValue) ? currentValue as string[] : []
const isChecked = selectedValues.includes(option.value)
return (
<label
key={option.value}
className={`flex items-center gap-3 py-3 px-4 rounded-lg border-2 cursor-pointer transition-all ${
isChecked
? 'border-purple-500 bg-purple-50'
: 'border-gray-300 bg-white hover:border-gray-400'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
const newValues = e.target.checked
? [...selectedValues, option.value]
: selectedValues.filter((v) => v !== option.value)
handleAnswerChange(question.id, newValues)
}}
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span className={isChecked ? 'text-purple-700 font-medium' : 'text-gray-700'}>
{option.label}
</span>
</label>
)
})}
</div>
</div>
)
case 'number':
return (
<div className="space-y-2">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
<input
type="number"
value={currentValue != null ? String(currentValue) : ''}
onChange={(e) => handleAnswerChange(question.id, parseInt(e.target.value, 10))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Zahl eingeben"
/>
</div>
)
case 'text':
return (
<div className="space-y-2">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
<input
type="text"
value={currentValue != null ? String(currentValue) : ''}
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Text eingeben"
/>
</div>
)
default:
return null
}
}
return (
<div className="flex gap-6 h-full">
{/* Left Sidebar - Block Navigation */}
<div className="w-64 flex-shrink-0">
<div className="bg-white rounded-xl border border-gray-200 p-4 sticky top-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Fortschritt</h3>
<div className="space-y-2">
{SCOPE_QUESTION_BLOCKS.map((block, idx) => {
const progress = getBlockProgress(answers, block.id)
const isActive = idx === currentBlockIndex
return (
<button
key={block.id}
type="button"
onClick={() => setCurrentBlockIndex(idx)}
className={`w-full text-left px-3 py-2 rounded-lg transition-all ${
isActive
? 'bg-purple-50 border-2 border-purple-500'
: 'bg-gray-50 border border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className={`text-sm font-medium ${isActive ? 'text-purple-700' : 'text-gray-700'}`}>
{block.title}
</span>
<span className={`text-xs font-semibold ${isActive ? 'text-purple-600' : 'text-gray-500'}`}>
{progress}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full transition-all ${isActive ? 'bg-purple-500' : 'bg-gray-400'}`}
style={{ width: `${progress}%` }}
/>
</div>
</button>
)
})}
</div>
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 space-y-6">
{/* Progress Bar */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Gesamtfortschritt</span>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">
{completionStats.answered} / {completionStats.total} Fragen
</span>
<span className="text-sm font-bold text-gray-900">{totalProgress}%</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-500"
style={{ width: `${totalProgress}%` }}
/>
</div>
</div>
{/* Current Block */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{currentBlock.title}</h2>
<p className="text-gray-600">{currentBlock.description}</p>
</div>
{companyProfile && (
<button
type="button"
onClick={handlePrefillFromProfile}
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 border border-blue-300 rounded-lg hover:bg-blue-100 transition-colors whitespace-nowrap"
>
Aus Profil uebernehmen
</button>
)}
</div>
{/* Questions */}
<div className="space-y-6">
{currentBlock.questions.map((question) => (
<div key={question.id} className="border-b border-gray-100 pb-6 last:border-b-0 last:pb-0">
{renderQuestion(question)}
</div>
))}
</div>
</div>
{/* Navigation Buttons */}
<div className="flex items-center justify-between bg-white rounded-xl border border-gray-200 p-4">
<button
type="button"
onClick={handleBack}
disabled={currentBlockIndex === 0}
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Zurueck
</button>
<span className="text-sm text-gray-600">
Block {currentBlockIndex + 1} von {SCOPE_QUESTION_BLOCKS.length}
</span>
{currentBlockIndex === SCOPE_QUESTION_BLOCKS.length - 1 ? (
<button
type="button"
onClick={onEvaluate}
disabled={!canEvaluate || isEvaluating}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isEvaluating ? 'Evaluiere...' : 'Auswertung starten'}
</button>
) : (
<button
type="button"
onClick={handleNext}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
>
Weiter
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { ScopeOverviewTab } from './ScopeOverviewTab'
export { ScopeWizardTab } from './ScopeWizardTab'
export { ScopeDecisionTab } from './ScopeDecisionTab'
export { ScopeExportTab } from './ScopeExportTab'

View File

@@ -0,0 +1,372 @@
'use client'
import React, { useState } from 'react'
import {
DSFA,
DSFAConsultationRequirement,
DSFA_AUTHORITY_RESOURCES,
getFederalStateOptions,
getAuthorityResource,
} from '@/lib/sdk/dsfa/types'
interface Art36WarningProps {
dsfa: DSFA
onUpdate: (data: Record<string, unknown>) => Promise<void>
isSubmitting: boolean
}
export function Art36Warning({ dsfa, onUpdate, isSubmitting }: Art36WarningProps) {
const isHighResidualRisk = dsfa.residual_risk_level === 'high' || dsfa.residual_risk_level === 'very_high'
const consultationReq = dsfa.consultation_requirement
const [federalState, setFederalState] = useState(dsfa.federal_state || '')
const [authorityNotified, setAuthorityNotified] = useState(consultationReq?.authority_notified || false)
const [notificationDate, setNotificationDate] = useState(consultationReq?.notification_date || '')
const [waitingPeriodObserved, setWaitingPeriodObserved] = useState(consultationReq?.waiting_period_observed || false)
const [authorityResponse, setAuthorityResponse] = useState(consultationReq?.authority_response || '')
const [recommendations, setRecommendations] = useState<string[]>(consultationReq?.authority_recommendations || [])
const [newRecommendation, setNewRecommendation] = useState('')
const federalStateOptions = getFederalStateOptions()
const selectedAuthority = federalState ? getAuthorityResource(federalState) : null
const handleSave = async () => {
const requirement: DSFAConsultationRequirement = {
high_residual_risk: isHighResidualRisk,
consultation_required: isHighResidualRisk,
consultation_reason: isHighResidualRisk
? 'Trotz geplanter Massnahmen verbleibt ein hohes Restrisiko. Gem. Art. 36 Abs. 1 DSGVO ist vor der Verarbeitung die Aufsichtsbehoerde zu konsultieren.'
: undefined,
authority_notified: authorityNotified,
notification_date: notificationDate || undefined,
authority_response: authorityResponse || undefined,
authority_recommendations: recommendations.length > 0 ? recommendations : undefined,
waiting_period_observed: waitingPeriodObserved,
}
await onUpdate({
consultation_requirement: requirement,
federal_state: federalState,
authority_resource_id: federalState,
})
}
const addRecommendation = () => {
if (newRecommendation.trim()) {
setRecommendations([...recommendations, newRecommendation.trim()])
setNewRecommendation('')
}
}
const removeRecommendation = (index: number) => {
setRecommendations(recommendations.filter((_, i) => i !== index))
}
// Don't show if residual risk is not high
if (!isHighResidualRisk) {
return (
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 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>
</div>
<div>
<h3 className="font-semibold text-green-800">Keine Behoerdenkonsultation erforderlich</h3>
<p className="text-sm text-green-600 mt-1">
Das Restrisiko nach Umsetzung der geplanten Massnahmen ist nicht hoch.
Eine vorherige Konsultation der Aufsichtsbehoerde gem. Art. 36 DSGVO ist nicht erforderlich.
</p>
</div>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Warning Banner */}
<div className="bg-red-50 border-2 border-red-300 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-red-600" 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>
</div>
<div>
<h3 className="text-lg font-semibold text-red-800">
Behoerdenkonsultation erforderlich (Art. 36 DSGVO)
</h3>
<p className="text-sm text-red-700 mt-2">
Das Restrisiko nach Umsetzung aller geplanten Massnahmen wurde als
<span className="font-bold"> {dsfa.residual_risk_level === 'very_high' ? 'SEHR HOCH' : 'HOCH'} </span>
eingestuft.
</p>
<p className="text-sm text-red-600 mt-2">
Gemaess Art. 36 Abs. 1 DSGVO muessen Sie <strong>vor Beginn der Verarbeitung</strong> die
zustaendige Aufsichtsbehoerde konsultieren. Die Behoerde hat eine Frist von 8 Wochen
zur Stellungnahme (Art. 36 Abs. 2 DSGVO).
</p>
</div>
</div>
</div>
{/* Federal State Selection */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-4">Zustaendige Aufsichtsbehoerde</h4>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Bundesland / Zustaendigkeit *
</label>
<select
value={federalState}
onChange={(e) => setFederalState(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="">Bitte waehlen...</option>
{federalStateOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Authority Details */}
{selectedAuthority && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h5 className="font-medium text-blue-900">{selectedAuthority.name}</h5>
<p className="text-sm text-blue-700 mt-1">({selectedAuthority.shortName})</p>
<div className="mt-4 flex flex-wrap gap-2">
<a
href={selectedAuthority.overviewUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg text-sm hover:bg-blue-200 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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
DSFA-Informationen
</a>
{selectedAuthority.publicSectorListUrl && (
<a
href={selectedAuthority.publicSectorListUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-green-100 text-green-700 rounded-lg text-sm hover:bg-green-200 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="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>
Muss-Liste (oeffentlich)
</a>
)}
{selectedAuthority.privateSectorListUrl && (
<a
href={selectedAuthority.privateSectorListUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-orange-100 text-orange-700 rounded-lg text-sm hover:bg-orange-200 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="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>
Muss-Liste (nicht-oeffentlich)
</a>
)}
{selectedAuthority.templateUrl && (
<a
href={selectedAuthority.templateUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-purple-100 text-purple-700 rounded-lg text-sm hover:bg-purple-200 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-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
DSFA-Vorlage
</a>
)}
</div>
{selectedAuthority.additionalResources && selectedAuthority.additionalResources.length > 0 && (
<div className="mt-4 pt-4 border-t border-blue-200">
<p className="text-xs font-medium text-blue-800 mb-2">Weitere Ressourcen:</p>
<div className="flex flex-wrap gap-2">
{selectedAuthority.additionalResources.map((resource, idx) => (
<a
key={idx}
href={resource.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 underline"
>
{resource.title}
</a>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Consultation Documentation */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-4">Konsultation dokumentieren</h4>
<div className="space-y-4">
{/* Authority Notified Checkbox */}
<label className={`flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-all ${
authorityNotified
? 'bg-green-50 border-green-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}>
<input
type="checkbox"
checked={authorityNotified}
onChange={(e) => setAuthorityNotified(e.target.checked)}
className="mt-1 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<div>
<span className="font-medium text-gray-900">Aufsichtsbehoerde wurde konsultiert</span>
<p className="text-sm text-gray-500 mt-1">
Die DSFA wurde der zustaendigen Aufsichtsbehoerde vorgelegt.
</p>
</div>
</label>
{authorityNotified && (
<>
{/* Notification Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Datum der Konsultation
</label>
<input
type="date"
value={notificationDate}
onChange={(e) => setNotificationDate(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
{/* 8-Week Waiting Period */}
<label className={`flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-all ${
waitingPeriodObserved
? 'bg-green-50 border-green-300'
: 'bg-yellow-50 border-yellow-300'
}`}>
<input
type="checkbox"
checked={waitingPeriodObserved}
onChange={(e) => setWaitingPeriodObserved(e.target.checked)}
className="mt-1 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<div>
<span className="font-medium text-gray-900">
8-Wochen-Frist eingehalten (Art. 36 Abs. 2 DSGVO)
</span>
<p className="text-sm text-gray-500 mt-1">
Die Aufsichtsbehoerde hat innerhalb von 8 Wochen nach Eingang der Konsultation
schriftlich Stellung genommen, oder die Frist ist abgelaufen.
</p>
{notificationDate && (
<p className="text-xs text-blue-600 mt-2">
8-Wochen-Frist endet am:{' '}
{new Date(new Date(notificationDate).getTime() + 8 * 7 * 24 * 60 * 60 * 1000).toLocaleDateString('de-DE')}
</p>
)}
</div>
</label>
{/* Authority Response */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Stellungnahme / Entscheidung der Behoerde
</label>
<textarea
value={authorityResponse}
onChange={(e) => setAuthorityResponse(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
rows={4}
placeholder="Zusammenfassung der behoerdlichen Stellungnahme..."
/>
</div>
{/* Authority Recommendations */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Auflagen / Empfehlungen der Behoerde
</label>
<div className="flex flex-wrap gap-2 mb-2">
{recommendations.map((rec, idx) => (
<span key={idx} className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm flex items-center gap-2">
{rec}
<button onClick={() => removeRecommendation(idx)} className="hover:text-blue-900">
<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 className="flex gap-2">
<input
type="text"
value={newRecommendation}
onChange={(e) => setNewRecommendation(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addRecommendation())}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Auflage oder Empfehlung hinzufuegen..."
/>
<button
onClick={addRecommendation}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
+
</button>
</div>
</div>
</>
)}
</div>
</div>
{/* Important Note */}
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-yellow-500 mt-0.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>
<div className="text-sm text-yellow-800">
<p className="font-medium mb-1">Wichtiger Hinweis</p>
<p>
Die Verarbeitung darf erst beginnen, nachdem die Aufsichtsbehoerde konsultiert wurde
und entweder ihre Zustimmung erteilt hat oder die 8-Wochen-Frist abgelaufen ist.
Die Behoerde kann diese Frist um weitere 6 Wochen verlaengern.
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
onClick={handleSave}
disabled={isSubmitting || !federalState}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{isSubmitting ? 'Speichern...' : 'Dokumentation speichern'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,268 @@
'use client'
import React from 'react'
import { DSFA, DSFA_SECTIONS, DSFASectionProgress } from '@/lib/sdk/dsfa/types'
interface DSFASidebarProps {
dsfa: DSFA
activeSection: number
onSectionChange: (section: number) => void
}
// Calculate completion percentage for a section
function calculateSectionProgress(dsfa: DSFA, sectionNumber: number): number {
switch (sectionNumber) {
case 0: // Threshold Analysis
if (!dsfa.threshold_analysis) return 0
const ta = dsfa.threshold_analysis
if (ta.dsfa_required !== undefined && ta.decision_justification) return 100
if (ta.criteria_assessment?.some(c => c.applies)) return 50
return 0
case 1: // Processing Description
const s1Fields = [
dsfa.processing_purpose,
dsfa.processing_description,
dsfa.data_categories?.length,
dsfa.legal_basis,
]
return Math.round((s1Fields.filter(Boolean).length / s1Fields.length) * 100)
case 2: // Necessity & Proportionality
const s2Fields = [
dsfa.necessity_assessment,
dsfa.proportionality_assessment,
]
return Math.round((s2Fields.filter(Boolean).length / s2Fields.length) * 100)
case 3: // Risk Assessment
if (!dsfa.risks?.length) return 0
if (dsfa.overall_risk_level) return 100
return 50
case 4: // Mitigation Measures
if (!dsfa.mitigations?.length) return 0
if (dsfa.residual_risk_level) return 100
return 50
case 5: // Stakeholder Consultation (optional)
if (dsfa.stakeholder_consultation_not_appropriate && dsfa.stakeholder_consultation_not_appropriate_reason) return 100
if (dsfa.stakeholder_consultations?.length) return 100
return 0
case 6: // DPO & Authority Consultation
const s6Fields = [
dsfa.dpo_consulted,
dsfa.dpo_opinion,
]
const s6Progress = Math.round((s6Fields.filter(Boolean).length / s6Fields.length) * 100)
// Add extra progress if authority consultation is documented when required
if (dsfa.consultation_requirement?.consultation_required) {
if (dsfa.authority_consulted) return s6Progress
return Math.min(s6Progress, 75)
}
return s6Progress
case 7: // Review & Maintenance
if (!dsfa.review_schedule) return 0
const rs = dsfa.review_schedule
if (rs.next_review_date && rs.review_frequency_months && rs.review_responsible) return 100
if (rs.next_review_date) return 50
return 25
default:
return 0
}
}
// Check if a section is complete
function isSectionComplete(dsfa: DSFA, sectionNumber: number): boolean {
const progress = dsfa.section_progress
switch (sectionNumber) {
case 0: return progress.section_0_complete ?? false
case 1: return progress.section_1_complete ?? false
case 2: return progress.section_2_complete ?? false
case 3: return progress.section_3_complete ?? false
case 4: return progress.section_4_complete ?? false
case 5: return progress.section_5_complete ?? false
case 6: return progress.section_6_complete ?? false
case 7: return progress.section_7_complete ?? false
default: return false
}
}
// Calculate overall DSFA progress
function calculateOverallProgress(dsfa: DSFA): number {
const requiredSections = DSFA_SECTIONS.filter(s => s.required)
let totalProgress = 0
for (const section of requiredSections) {
totalProgress += calculateSectionProgress(dsfa, section.number)
}
return Math.round(totalProgress / requiredSections.length)
}
export function DSFASidebar({ dsfa, activeSection, onSectionChange }: DSFASidebarProps) {
const overallProgress = calculateOverallProgress(dsfa)
// Group sections by category
const thresholdSection = DSFA_SECTIONS.find(s => s.number === 0)
const art35Sections = DSFA_SECTIONS.filter(s => s.number >= 1 && s.number <= 4)
const stakeholderSection = DSFA_SECTIONS.find(s => s.number === 5)
const consultationSection = DSFA_SECTIONS.find(s => s.number === 6)
const reviewSection = DSFA_SECTIONS.find(s => s.number === 7)
const renderSectionItem = (section: typeof DSFA_SECTIONS[0]) => {
const progress = calculateSectionProgress(dsfa, section.number)
const isComplete = isSectionComplete(dsfa, section.number) || progress === 100
const isActive = activeSection === section.number
return (
<button
key={section.number}
onClick={() => onSectionChange(section.number)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all ${
isActive
? 'bg-purple-100 text-purple-700'
: 'hover:bg-gray-100 text-gray-700'
}`}
>
{/* Status indicator */}
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${
isComplete
? 'bg-green-500 text-white'
: isActive
? 'bg-purple-500 text-white'
: 'bg-gray-200 text-gray-500'
}`}>
{isComplete ? (
<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>
) : (
<span className="text-xs font-medium">{section.number}</span>
)}
</div>
{/* Section info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium truncate ${isActive ? 'text-purple-700' : ''}`}>
{section.titleDE}
</span>
{!section.required && (
<span className="text-[10px] px-1.5 py-0.5 bg-gray-200 text-gray-500 rounded">
optional
</span>
)}
</div>
{/* Progress bar */}
<div className="mt-1 h-1 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
isComplete ? 'bg-green-500' : 'bg-purple-500'
}`}
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Progress percentage */}
<span className={`text-xs font-medium ${
isComplete ? 'text-green-600' : 'text-gray-400'
}`}>
{progress}%
</span>
</button>
)
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-4">
{/* Overall Progress */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-gray-900">DSFA Fortschritt</h3>
<span className="text-lg font-bold text-purple-600">{overallProgress}%</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-purple-600 transition-all duration-500"
style={{ width: `${overallProgress}%` }}
/>
</div>
</div>
{/* Section 0: Threshold Analysis */}
<div className="mb-4">
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-3">
Vorabpruefung
</div>
{thresholdSection && renderSectionItem(thresholdSection)}
</div>
{/* Sections 1-4: Art. 35 Abs. 7 */}
<div className="mb-4">
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-3">
Art. 35 Abs. 7 DSGVO
</div>
<div className="space-y-1">
{art35Sections.map(section => renderSectionItem(section))}
</div>
</div>
{/* Section 5: Stakeholder Consultation */}
<div className="mb-4">
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-3">
Betroffene
</div>
{stakeholderSection && renderSectionItem(stakeholderSection)}
</div>
{/* Section 6: DPO & Authority */}
<div className="mb-4">
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-3">
Konsultation
</div>
{consultationSection && renderSectionItem(consultationSection)}
</div>
{/* Section 7: Review */}
<div>
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-3">
Fortschreibung
</div>
{reviewSection && renderSectionItem(reviewSection)}
</div>
{/* Status Footer */}
<div className="mt-6 pt-4 border-t border-gray-200">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">Status</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${
dsfa.status === 'draft' ? 'bg-gray-100 text-gray-600' :
dsfa.status === 'in_review' ? 'bg-yellow-100 text-yellow-700' :
dsfa.status === 'approved' ? 'bg-green-100 text-green-700' :
dsfa.status === 'rejected' ? 'bg-red-100 text-red-700' :
'bg-orange-100 text-orange-700'
}`}>
{dsfa.status === 'draft' ? 'Entwurf' :
dsfa.status === 'in_review' ? 'In Pruefung' :
dsfa.status === 'approved' ? 'Genehmigt' :
dsfa.status === 'rejected' ? 'Abgelehnt' :
'Ueberarbeitung'}
</span>
</div>
{dsfa.version && (
<div className="flex items-center justify-between text-sm mt-2">
<span className="text-gray-500">Version</span>
<span className="text-gray-700">{dsfa.version}</span>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,323 @@
'use client'
import React, { useState } from 'react'
import { DSFA, DSFAReviewSchedule, DSFAReviewTrigger } from '@/lib/sdk/dsfa/types'
interface ReviewScheduleSectionProps {
dsfa: DSFA
onUpdate: (data: Record<string, unknown>) => Promise<void>
isSubmitting: boolean
}
const REVIEW_FREQUENCIES = [
{ value: 6, label: '6 Monate', description: 'Empfohlen bei hohem Risiko oder dynamischer Verarbeitung' },
{ value: 12, label: '12 Monate (jaehrlich)', description: 'Standardempfehlung fuer die meisten Verarbeitungen' },
{ value: 24, label: '24 Monate (alle 2 Jahre)', description: 'Bei stabilem, niedrigem Risiko' },
{ value: 36, label: '36 Monate (alle 3 Jahre)', description: 'Bei sehr stabiler Verarbeitung mit minimalem Risiko' },
]
const TRIGGER_TYPES = [
{ value: 'scheduled', label: 'Planmaessig', description: 'Regelmaessige Ueberpruefung nach Zeitplan', icon: '📅' },
{ value: 'risk_change', label: 'Risiko-Aenderung', description: 'Aenderung der Risikobewertung', icon: '⚠️' },
{ value: 'new_technology', label: 'Neue Technologie', description: 'Einfuehrung neuer technischer Systeme', icon: '🔧' },
{ value: 'new_purpose', label: 'Neuer Zweck', description: 'Aenderung oder Erweiterung des Verarbeitungszwecks', icon: '🎯' },
{ value: 'incident', label: 'Sicherheitsvorfall', description: 'Datenschutzvorfall oder Sicherheitsproblem', icon: '🚨' },
{ value: 'regulatory', label: 'Regulatorisch', description: 'Gesetzes- oder Behoerden-Aenderung', icon: '📜' },
{ value: 'other', label: 'Sonstiges', description: 'Anderer Ausloser', icon: '📋' },
]
export function ReviewScheduleSection({ dsfa, onUpdate, isSubmitting }: ReviewScheduleSectionProps) {
const existingSchedule = dsfa.review_schedule
const existingTriggers = dsfa.review_triggers || []
const [nextReviewDate, setNextReviewDate] = useState(existingSchedule?.next_review_date || '')
const [reviewFrequency, setReviewFrequency] = useState(existingSchedule?.review_frequency_months || 12)
const [reviewResponsible, setReviewResponsible] = useState(existingSchedule?.review_responsible || '')
const [triggers, setTriggers] = useState<DSFAReviewTrigger[]>(existingTriggers)
const [selectedTriggerTypes, setSelectedTriggerTypes] = useState<string[]>(
[...new Set(existingTriggers.map(t => t.trigger_type))]
)
// Calculate suggested next review date based on frequency
const suggestNextReviewDate = () => {
const date = new Date()
date.setMonth(date.getMonth() + reviewFrequency)
return date.toISOString().split('T')[0]
}
const toggleTriggerType = (type: string) => {
setSelectedTriggerTypes(prev =>
prev.includes(type)
? prev.filter(t => t !== type)
: [...prev, type]
)
}
const handleSave = async () => {
const schedule: DSFAReviewSchedule = {
next_review_date: nextReviewDate,
review_frequency_months: reviewFrequency,
last_review_date: existingSchedule?.last_review_date,
review_responsible: reviewResponsible,
}
// Generate triggers from selected types
const newTriggers: DSFAReviewTrigger[] = selectedTriggerTypes.map(type => {
const existingTrigger = triggers.find(t => t.trigger_type === type)
if (existingTrigger) return existingTrigger
const triggerInfo = TRIGGER_TYPES.find(t => t.value === type)
return {
id: crypto.randomUUID(),
trigger_type: type as DSFAReviewTrigger['trigger_type'],
description: triggerInfo?.description || '',
detected_at: new Date().toISOString(),
detected_by: 'system',
review_required: true,
review_completed: false,
changes_made: [],
}
})
await onUpdate({
review_schedule: schedule,
review_triggers: newTriggers,
})
}
// Check if review is overdue
const isOverdue = existingSchedule?.next_review_date
? new Date(existingSchedule.next_review_date) < new Date()
: false
return (
<div className="space-y-6">
{/* Info Banner */}
<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-500 mt-0.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>
<div>
<p className="text-sm font-medium text-blue-800">Art. 35 Abs. 11 DSGVO</p>
<p className="text-sm text-blue-600 mt-1">
&quot;Der Verantwortliche ueberprueft erforderlichenfalls, ob die Verarbeitung gemaess
der Datenschutz-Folgenabschaetzung durchgefuehrt wird, zumindest wenn hinsichtlich
des mit den Verarbeitungsvorgaengen verbundenen Risikos Aenderungen eingetreten sind.&quot;
</p>
</div>
</div>
</div>
{/* Overdue Warning */}
{isOverdue && (
<div className="bg-red-50 border-2 border-red-300 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p className="font-semibold text-red-800">Review ueberfaellig!</p>
<p className="text-sm text-red-600">
Das naechste Review war fuer den{' '}
{new Date(existingSchedule!.next_review_date).toLocaleDateString('de-DE')} geplant.
Bitte aktualisieren Sie die DSFA.
</p>
</div>
</div>
</div>
)}
{/* Version Info */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-4">Versionsinformation</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="bg-gray-50 rounded-lg p-3">
<span className="text-gray-500">Aktuelle Version</span>
<p className="text-xl font-bold text-gray-900">{dsfa.version || 1}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<span className="text-gray-500">Erstellt am</span>
<p className="font-medium text-gray-900">
{new Date(dsfa.created_at).toLocaleDateString('de-DE')}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<span className="text-gray-500">Letzte Aenderung</span>
<p className="font-medium text-gray-900">
{new Date(dsfa.updated_at).toLocaleDateString('de-DE')}
</p>
</div>
{existingSchedule?.last_review_date && (
<div className="bg-gray-50 rounded-lg p-3">
<span className="text-gray-500">Letztes Review</span>
<p className="font-medium text-gray-900">
{new Date(existingSchedule.last_review_date).toLocaleDateString('de-DE')}
</p>
</div>
)}
</div>
</div>
{/* Review Schedule */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-4">Review-Planung</h4>
<div className="space-y-4">
{/* Review Frequency */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Review-Frequenz *
</label>
<div className="grid grid-cols-2 gap-3">
{REVIEW_FREQUENCIES.map((freq) => (
<label
key={freq.value}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
reviewFrequency === freq.value
? 'bg-purple-50 border-purple-300 ring-1 ring-purple-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="radio"
name="reviewFrequency"
value={freq.value}
checked={reviewFrequency === freq.value}
onChange={(e) => setReviewFrequency(Number(e.target.value))}
className="mt-1 text-purple-600 focus:ring-purple-500"
/>
<div>
<span className="font-medium text-gray-900 text-sm">{freq.label}</span>
<p className="text-xs text-gray-500">{freq.description}</p>
</div>
</label>
))}
</div>
</div>
{/* Next Review Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Naechstes Review-Datum *
</label>
<div className="flex gap-2">
<input
type="date"
value={nextReviewDate}
onChange={(e) => setNextReviewDate(e.target.value)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
<button
onClick={() => setNextReviewDate(suggestNextReviewDate())}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm"
>
Vorschlag: +{reviewFrequency} Monate
</button>
</div>
{nextReviewDate && (
<p className="text-xs text-gray-500 mt-1">
Das naechste Review ist in{' '}
{Math.ceil((new Date(nextReviewDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))} Tagen faellig.
</p>
)}
</div>
{/* Review Responsible */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Verantwortlich fuer Review *
</label>
<input
type="text"
value={reviewResponsible}
onChange={(e) => setReviewResponsible(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Name oder Rolle der verantwortlichen Person/Abteilung..."
/>
</div>
</div>
</div>
{/* Review Triggers */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-2">Review-Ausloser definieren</h4>
<p className="text-sm text-gray-500 mb-4">
Waehlen Sie die Ereignisse aus, bei denen eine Ueberpruefung der DSFA ausgeloest werden soll.
</p>
<div className="space-y-3">
{TRIGGER_TYPES.map((trigger) => (
<label
key={trigger.value}
className={`flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-all ${
selectedTriggerTypes.includes(trigger.value)
? 'bg-green-50 border-green-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={selectedTriggerTypes.includes(trigger.value)}
onChange={() => toggleTriggerType(trigger.value)}
className="mt-1 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-lg">{trigger.icon}</span>
<span className="font-medium text-gray-900">{trigger.label}</span>
</div>
<p className="text-sm text-gray-500 mt-1">{trigger.description}</p>
</div>
</label>
))}
</div>
</div>
{/* Existing Pending Triggers */}
{triggers.filter(t => t.review_required && !t.review_completed).length > 0 && (
<div className="bg-orange-50 rounded-xl border border-orange-200 p-6">
<h4 className="font-semibold text-orange-900 mb-4">
Offene Review-Trigger ({triggers.filter(t => t.review_required && !t.review_completed).length})
</h4>
<div className="space-y-3">
{triggers
.filter(t => t.review_required && !t.review_completed)
.map((trigger) => {
const triggerInfo = TRIGGER_TYPES.find(t => t.value === trigger.trigger_type)
return (
<div
key={trigger.id}
className="bg-white rounded-lg border border-orange-200 p-3"
>
<div className="flex items-center gap-2">
<span className="text-lg">{triggerInfo?.icon || '📋'}</span>
<span className="font-medium text-gray-900">{triggerInfo?.label || trigger.trigger_type}</span>
</div>
<p className="text-sm text-gray-600 mt-1">{trigger.description}</p>
<p className="text-xs text-gray-400 mt-2">
Erkannt am {new Date(trigger.detected_at).toLocaleDateString('de-DE')} von {trigger.detected_by}
</p>
</div>
)
})}
</div>
</div>
)}
{/* Save Button */}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
onClick={handleSave}
disabled={isSubmitting || !nextReviewDate || !reviewResponsible}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{isSubmitting ? 'Speichern...' : 'Review-Plan speichern'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,320 @@
'use client'
import { useState } from 'react'
import { BookOpen, ExternalLink, Scale, ChevronDown, ChevronUp, Info } from 'lucide-react'
import {
DSFALicenseCode,
DSFA_LICENSE_LABELS,
SourceAttributionProps
} from '@/lib/sdk/types'
/**
* Get license badge color based on license type
*/
function getLicenseBadgeColor(licenseCode: DSFALicenseCode): string {
switch (licenseCode) {
case 'DL-DE-BY-2.0':
case 'DL-DE-ZERO-2.0':
return 'bg-blue-100 text-blue-700 border-blue-200'
case 'CC-BY-4.0':
return 'bg-green-100 text-green-700 border-green-200'
case 'EDPB-LICENSE':
return 'bg-purple-100 text-purple-700 border-purple-200'
case 'PUBLIC_DOMAIN':
return 'bg-gray-100 text-gray-700 border-gray-200'
case 'PROPRIETARY':
return 'bg-amber-100 text-amber-700 border-amber-200'
default:
return 'bg-slate-100 text-slate-700 border-slate-200'
}
}
/**
* Get license URL based on license code
*/
function getLicenseUrl(licenseCode: DSFALicenseCode): string | null {
switch (licenseCode) {
case 'DL-DE-BY-2.0':
return 'https://www.govdata.de/dl-de/by-2-0'
case 'DL-DE-ZERO-2.0':
return 'https://www.govdata.de/dl-de/zero-2-0'
case 'CC-BY-4.0':
return 'https://creativecommons.org/licenses/by/4.0/'
case 'EDPB-LICENSE':
return 'https://edpb.europa.eu/about-edpb/legal-notice_en'
default:
return null
}
}
/**
* License badge component
*/
function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
const licenseUrl = getLicenseUrl(licenseCode)
const colorClass = getLicenseBadgeColor(licenseCode)
const label = DSFA_LICENSE_LABELS[licenseCode] || licenseCode
if (licenseUrl) {
return (
<a
href={licenseUrl}
target="_blank"
rel="noopener noreferrer"
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorClass} hover:opacity-80 transition-opacity`}
>
<Scale className="w-3 h-3" />
{label}
<ExternalLink className="w-2.5 h-2.5" />
</a>
)
}
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorClass}`}>
<Scale className="w-3 h-3" />
{label}
</span>
)
}
/**
* Single source item in the attribution list
*/
function SourceItem({
source,
index,
showScore
}: {
source: SourceAttributionProps['sources'][0]
index: number
showScore: boolean
}) {
return (
<li className="text-sm">
<div className="flex items-start gap-2">
<span className="text-slate-400 font-mono text-xs mt-0.5 min-w-[1.5rem]">
{index + 1}.
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{source.sourceUrl ? (
<a
href={source.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline font-medium truncate"
>
{source.sourceName}
</a>
) : (
<span className="text-slate-700 font-medium truncate">
{source.sourceName}
</span>
)}
{showScore && source.score !== undefined && (
<span className="text-xs text-slate-400 font-mono">
({(source.score * 100).toFixed(0)}%)
</span>
)}
</div>
<p className="text-xs text-slate-500 mt-0.5 leading-relaxed">
{source.attributionText}
</p>
<div className="mt-1.5">
<LicenseBadge licenseCode={source.licenseCode} />
</div>
</div>
</div>
</li>
)
}
/**
* Compact source badge for inline display
*/
function CompactSourceBadge({
source
}: {
source: SourceAttributionProps['sources'][0]
}) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-600 border border-slate-200">
<BookOpen className="w-3 h-3" />
{source.sourceCode}
</span>
)
}
/**
* SourceAttribution component - displays source/license information for DSFA RAG results
*
* @example
* ```tsx
* <SourceAttribution
* sources={[
* {
* sourceCode: "WP248",
* sourceName: "WP248 rev.01 - Leitlinien zur DSFA",
* attributionText: "Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)",
* licenseCode: "EDPB-LICENSE",
* sourceUrl: "https://ec.europa.eu/newsroom/article29/items/611236/en",
* score: 0.87
* }
* ]}
* showScores
* />
* ```
*/
export function SourceAttribution({
sources,
compact = false,
showScores = false
}: SourceAttributionProps) {
const [isExpanded, setIsExpanded] = useState(!compact)
if (!sources || sources.length === 0) {
return null
}
// Compact mode - just show badges
if (compact && !isExpanded) {
return (
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setIsExpanded(true)}
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700"
>
<Info className="w-3 h-3" />
Quellen ({sources.length})
<ChevronDown className="w-3 h-3" />
</button>
{sources.slice(0, 3).map((source, i) => (
<CompactSourceBadge key={i} source={source} />
))}
{sources.length > 3 && (
<span className="text-xs text-slate-400">+{sources.length - 3}</span>
)}
</div>
)
}
return (
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 mt-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-slate-700 flex items-center gap-2">
<BookOpen className="w-4 h-4" />
Quellen & Lizenzen
</h4>
{compact && (
<button
onClick={() => setIsExpanded(false)}
className="text-xs text-slate-400 hover:text-slate-600 flex items-center gap-1"
>
Einklappen
<ChevronUp className="w-3 h-3" />
</button>
)}
</div>
<ul className="mt-3 space-y-3">
{sources.map((source, i) => (
<SourceItem
key={i}
source={source}
index={i}
showScore={showScores}
/>
))}
</ul>
{/* Aggregated license notice */}
{sources.length > 1 && (
<div className="mt-4 pt-3 border-t border-slate-200">
<p className="text-xs text-slate-500">
<strong>Hinweis:</strong> Die angezeigten Inhalte stammen aus {sources.length} verschiedenen Quellen
mit unterschiedlichen Lizenzen. Bitte beachten Sie die jeweiligen Attributionsanforderungen.
</p>
</div>
)}
</div>
)
}
/**
* Inline source reference for use within text
*/
export function InlineSourceRef({
sourceCode,
sourceName,
sourceUrl
}: {
sourceCode: string
sourceName: string
sourceUrl?: string
}) {
if (sourceUrl) {
return (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-blue-600 hover:text-blue-800 text-sm"
title={sourceName}
>
[{sourceCode}]
<ExternalLink className="w-3 h-3" />
</a>
)
}
return (
<span className="text-slate-600 text-sm" title={sourceName}>
[{sourceCode}]
</span>
)
}
/**
* Attribution footer for generated documents
*/
export function AttributionFooter({
sources,
generatedAt
}: {
sources: SourceAttributionProps['sources']
generatedAt?: Date
}) {
if (!sources || sources.length === 0) {
return null
}
// Group by license
const byLicense = sources.reduce((acc, source) => {
const key = source.licenseCode
if (!acc[key]) acc[key] = []
acc[key].push(source)
return acc
}, {} as Record<string, typeof sources>)
return (
<footer className="mt-8 pt-4 border-t border-slate-200 text-xs text-slate-500">
<h5 className="font-medium text-slate-600 mb-2">Quellennachweis</h5>
<ul className="space-y-1">
{Object.entries(byLicense).map(([licenseCode, licenseSources]) => (
<li key={licenseCode}>
<span className="font-medium">{DSFA_LICENSE_LABELS[licenseCode as DSFALicenseCode]}:</span>{' '}
{licenseSources.map(s => s.sourceName).join(', ')}
</li>
))}
</ul>
{generatedAt && (
<p className="mt-2 text-slate-400">
Generiert am {generatedAt.toLocaleDateString('de-DE')} um {generatedAt.toLocaleTimeString('de-DE')}
</p>
)}
</footer>
)
}
export default SourceAttribution

View File

@@ -0,0 +1,458 @@
'use client'
import React, { useState } from 'react'
import { DSFA, DSFAStakeholderConsultation } from '@/lib/sdk/dsfa/types'
interface StakeholderConsultationSectionProps {
dsfa: DSFA
onUpdate: (data: Record<string, unknown>) => Promise<void>
isSubmitting: boolean
}
const STAKEHOLDER_TYPES = [
{ value: 'data_subjects', label: 'Betroffene Personen', description: 'Direkt von der Verarbeitung betroffene Personen' },
{ value: 'representatives', label: 'Vertreter der Betroffenen', description: 'Z.B. Verbraucherorganisationen, Patientenvertreter' },
{ value: 'works_council', label: 'Betriebsrat / Personalrat', description: 'Bei Arbeitnehmer-Datenverarbeitung' },
{ value: 'other', label: 'Sonstige Stakeholder', description: 'Andere relevante Interessengruppen' },
]
const CONSULTATION_METHODS = [
{ value: 'survey', label: 'Umfrage', description: 'Schriftliche oder Online-Befragung' },
{ value: 'interview', label: 'Interview', description: 'Persoenliche oder telefonische Gespraeche' },
{ value: 'workshop', label: 'Workshop', description: 'Gemeinsame Erarbeitung in Gruppensitzung' },
{ value: 'written', label: 'Schriftliche Stellungnahme', description: 'Formelle schriftliche Anfrage' },
{ value: 'other', label: 'Andere Methode', description: 'Sonstige Konsultationsform' },
]
export function StakeholderConsultationSection({ dsfa, onUpdate, isSubmitting }: StakeholderConsultationSectionProps) {
const [consultations, setConsultations] = useState<DSFAStakeholderConsultation[]>(
dsfa.stakeholder_consultations || []
)
const [notAppropriate, setNotAppropriate] = useState(
dsfa.stakeholder_consultation_not_appropriate || false
)
const [notAppropriateReason, setNotAppropriateReason] = useState(
dsfa.stakeholder_consultation_not_appropriate_reason || ''
)
const [showAddForm, setShowAddForm] = useState(false)
// New consultation form state
const [newConsultation, setNewConsultation] = useState<Partial<DSFAStakeholderConsultation>>({
stakeholder_type: 'data_subjects',
stakeholder_description: '',
consultation_method: 'survey',
consultation_date: '',
summary: '',
concerns_raised: [],
addressed_in_dsfa: false,
})
const [newConcern, setNewConcern] = useState('')
const addConsultation = () => {
if (!newConsultation.stakeholder_description || !newConsultation.summary) return
const consultation: DSFAStakeholderConsultation = {
id: crypto.randomUUID(),
stakeholder_type: newConsultation.stakeholder_type as DSFAStakeholderConsultation['stakeholder_type'],
stakeholder_description: newConsultation.stakeholder_description || '',
consultation_method: newConsultation.consultation_method as DSFAStakeholderConsultation['consultation_method'],
consultation_date: newConsultation.consultation_date || undefined,
summary: newConsultation.summary || '',
concerns_raised: newConsultation.concerns_raised || [],
addressed_in_dsfa: newConsultation.addressed_in_dsfa || false,
}
setConsultations([...consultations, consultation])
setNewConsultation({
stakeholder_type: 'data_subjects',
stakeholder_description: '',
consultation_method: 'survey',
consultation_date: '',
summary: '',
concerns_raised: [],
addressed_in_dsfa: false,
})
setShowAddForm(false)
}
const removeConsultation = (id: string) => {
setConsultations(consultations.filter(c => c.id !== id))
}
const addConcern = () => {
if (newConcern.trim()) {
setNewConsultation({
...newConsultation,
concerns_raised: [...(newConsultation.concerns_raised || []), newConcern.trim()],
})
setNewConcern('')
}
}
const removeConcern = (index: number) => {
setNewConsultation({
...newConsultation,
concerns_raised: (newConsultation.concerns_raised || []).filter((_, i) => i !== index),
})
}
const handleSave = async () => {
await onUpdate({
stakeholder_consultations: notAppropriate ? [] : consultations,
stakeholder_consultation_not_appropriate: notAppropriate,
stakeholder_consultation_not_appropriate_reason: notAppropriate ? notAppropriateReason : undefined,
})
}
return (
<div className="space-y-6">
{/* Info Banner */}
<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-500 mt-0.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>
<div>
<p className="text-sm font-medium text-blue-800">Art. 35 Abs. 9 DSGVO</p>
<p className="text-sm text-blue-600 mt-1">
&quot;Der Verantwortliche holt gegebenenfalls den Standpunkt der betroffenen Personen oder
ihrer Vertreter zu der beabsichtigten Verarbeitung ein, ohne dass dadurch der Schutz
gewerblicher oder oeffentlicher Interessen oder die Sicherheit der Verarbeitungsvorgaenge
beeintraechtigt wird.&quot;
</p>
</div>
</div>
</div>
{/* Not Appropriate Option */}
<div className={`p-4 rounded-xl border transition-all ${
notAppropriate
? 'bg-orange-50 border-orange-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={notAppropriate}
onChange={(e) => setNotAppropriate(e.target.checked)}
className="mt-1 rounded border-gray-300 text-orange-600 focus:ring-orange-500"
/>
<div>
<span className="font-medium text-gray-900">
Konsultation nicht angemessen
</span>
<p className="text-sm text-gray-500 mt-1">
Die Einholung des Standpunkts der Betroffenen ist in diesem Fall nicht angemessen
(z.B. bei Gefaehrdung der Sicherheit oder gewerblicher Interessen).
</p>
</div>
</label>
{notAppropriate && (
<div className="mt-4 ml-7">
<label className="block text-sm font-medium text-gray-700 mb-2">
Begruendung *
</label>
<textarea
value={notAppropriateReason}
onChange={(e) => setNotAppropriateReason(e.target.value)}
className="w-full px-4 py-3 border border-orange-300 rounded-lg focus:ring-2 focus:ring-orange-500 bg-white"
rows={3}
placeholder="Begruenden Sie, warum eine Konsultation nicht angemessen ist..."
/>
</div>
)}
</div>
{/* Consultations List */}
{!notAppropriate && (
<>
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">
Durchgefuehrte Konsultationen ({consultations.length})
</h3>
<button
onClick={() => setShowAddForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 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="M12 4v16m8-8H4" />
</svg>
Konsultation hinzufuegen
</button>
</div>
{consultations.length === 0 && !showAddForm ? (
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-xl border border-dashed border-gray-300">
<svg className="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<p>Noch keine Konsultationen dokumentiert</p>
<p className="text-sm mt-1">Fuegen Sie Ihre Stakeholder-Konsultationen hinzu.</p>
</div>
) : (
<div className="space-y-4">
{consultations.map((consultation) => {
const typeInfo = STAKEHOLDER_TYPES.find(t => t.value === consultation.stakeholder_type)
const methodInfo = CONSULTATION_METHODS.find(m => m.value === consultation.consultation_method)
return (
<div
key={consultation.id}
className="bg-white rounded-xl border border-gray-200 p-4"
>
<div className="flex items-start justify-between mb-3">
<div>
<span className="inline-block px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs font-medium mb-2">
{typeInfo?.label}
</span>
<h4 className="font-medium text-gray-900">
{consultation.stakeholder_description}
</h4>
</div>
<button
onClick={() => removeConsultation(consultation.id)}
className="p-1 text-gray-400 hover:text-red-500 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="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 className="grid grid-cols-2 gap-4 text-sm mb-3">
<div>
<span className="text-gray-500">Methode:</span>
<span className="ml-2 text-gray-700">{methodInfo?.label}</span>
</div>
{consultation.consultation_date && (
<div>
<span className="text-gray-500">Datum:</span>
<span className="ml-2 text-gray-700">
{new Date(consultation.consultation_date).toLocaleDateString('de-DE')}
</span>
</div>
)}
</div>
<div className="text-sm text-gray-600 mb-3">
<p className="font-medium text-gray-700">Zusammenfassung:</p>
<p>{consultation.summary}</p>
</div>
{consultation.concerns_raised.length > 0 && (
<div className="mb-3">
<p className="text-sm font-medium text-gray-700 mb-2">Geaeusserte Bedenken:</p>
<ul className="space-y-1">
{consultation.concerns_raised.map((concern, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm text-gray-600">
<span className="text-orange-500 mt-0.5">-</span>
{concern}
</li>
))}
</ul>
</div>
)}
<div className="flex items-center gap-2">
{consultation.addressed_in_dsfa ? (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
In DSFA beruecksichtigt
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">
Noch nicht beruecksichtigt
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
{/* Add Consultation Form */}
{showAddForm && (
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-4">Neue Konsultation hinzufuegen</h4>
<div className="space-y-4">
{/* Stakeholder Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Stakeholder-Typ *
</label>
<div className="grid grid-cols-2 gap-3">
{STAKEHOLDER_TYPES.map((type) => (
<label
key={type.value}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
newConsultation.stakeholder_type === type.value
? 'bg-purple-50 border-purple-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="radio"
name="stakeholderType"
value={type.value}
checked={newConsultation.stakeholder_type === type.value}
onChange={(e) => setNewConsultation({ ...newConsultation, stakeholder_type: e.target.value as DSFAStakeholderConsultation['stakeholder_type'] })}
className="mt-1 text-purple-600 focus:ring-purple-500"
/>
<div>
<span className="font-medium text-gray-900 text-sm">{type.label}</span>
<p className="text-xs text-gray-500">{type.description}</p>
</div>
</label>
))}
</div>
</div>
{/* Stakeholder Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Beschreibung der Stakeholder *
</label>
<input
type="text"
value={newConsultation.stakeholder_description}
onChange={(e) => setNewConsultation({ ...newConsultation, stakeholder_description: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Z.B. Mitarbeiter der IT-Abteilung, Kunden des Online-Shops..."
/>
</div>
{/* Consultation Method */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Konsultationsmethode *
</label>
<select
value={newConsultation.consultation_method}
onChange={(e) => setNewConsultation({ ...newConsultation, consultation_method: e.target.value as DSFAStakeholderConsultation['consultation_method'] })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
{CONSULTATION_METHODS.map((method) => (
<option key={method.value} value={method.value}>
{method.label} - {method.description}
</option>
))}
</select>
</div>
{/* Consultation Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Datum der Konsultation
</label>
<input
type="date"
value={newConsultation.consultation_date}
onChange={(e) => setNewConsultation({ ...newConsultation, consultation_date: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
{/* Summary */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Zusammenfassung der Ergebnisse *
</label>
<textarea
value={newConsultation.summary}
onChange={(e) => setNewConsultation({ ...newConsultation, summary: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
rows={3}
placeholder="Fassen Sie die wichtigsten Ergebnisse der Konsultation zusammen..."
/>
</div>
{/* Concerns */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Geaeusserte Bedenken
</label>
<div className="flex flex-wrap gap-2 mb-2">
{(newConsultation.concerns_raised || []).map((concern, idx) => (
<span key={idx} className="px-3 py-1 bg-orange-100 text-orange-700 rounded-full text-sm flex items-center gap-2">
{concern}
<button onClick={() => removeConcern(idx)} className="hover:text-orange-900">
<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 className="flex gap-2">
<input
type="text"
value={newConcern}
onChange={(e) => setNewConcern(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addConcern())}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Bedenken hinzufuegen..."
/>
<button
onClick={addConcern}
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700"
>
+
</button>
</div>
</div>
{/* Addressed in DSFA */}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={newConsultation.addressed_in_dsfa}
onChange={(e) => setNewConsultation({ ...newConsultation, addressed_in_dsfa: e.target.checked })}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span className="text-sm text-gray-700">
Bedenken wurden in der DSFA beruecksichtigt
</span>
</label>
{/* Form Actions */}
<div className="flex gap-3 pt-4">
<button
onClick={() => setShowAddForm(false)}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
>
Abbrechen
</button>
<button
onClick={addConsultation}
disabled={!newConsultation.stakeholder_description || !newConsultation.summary}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
Konsultation hinzufuegen
</button>
</div>
</div>
</div>
)}
</>
)}
{/* Save Button */}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
onClick={handleSave}
disabled={isSubmitting || (notAppropriate && !notAppropriateReason)}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{isSubmitting ? 'Speichern...' : 'Abschnitt speichern'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,474 @@
'use client'
import React, { useState, useEffect } from 'react'
import {
DSFA,
WP248_CRITERIA,
ART35_ABS3_CASES,
AI_DSFA_TRIGGERS,
checkDSFARequiredByWP248,
DSFAThresholdAnalysis,
} from '@/lib/sdk/dsfa/types'
interface ThresholdAnalysisSectionProps {
dsfa: DSFA
onUpdate: (data: Record<string, unknown>) => Promise<void>
isSubmitting: boolean
}
export function ThresholdAnalysisSection({ dsfa, onUpdate, isSubmitting }: ThresholdAnalysisSectionProps) {
// Initialize state from existing data
const existingAnalysis = dsfa.threshold_analysis
const [wp248Selected, setWp248Selected] = useState<string[]>(
dsfa.wp248_criteria_met ||
existingAnalysis?.criteria_assessment?.filter(c => c.applies).map(c => c.criterion_id) ||
[]
)
const [art35Selected, setArt35Selected] = useState<string[]>(
dsfa.art35_abs3_triggered ||
existingAnalysis?.art35_abs3_assessment?.filter(c => c.applies).map(c => c.case_id) ||
[]
)
const [aiTriggersSelected, setAiTriggersSelected] = useState<string[]>(
dsfa.ai_trigger_ids || []
)
const [dsfaRequired, setDsfaRequired] = useState<boolean | null>(
existingAnalysis?.dsfa_required ?? null
)
const [justification, setJustification] = useState(
existingAnalysis?.decision_justification || ''
)
// Calculate recommendation based on selections
const wp248Result = checkDSFARequiredByWP248(wp248Selected)
const hasArt35Trigger = art35Selected.length > 0
const hasAITrigger = aiTriggersSelected.length > 0
const recommendation = wp248Result.required || hasArt35Trigger || hasAITrigger
? 'required'
: wp248Selected.length === 1
? 'possible'
: 'not_required'
// Auto-generate justification when selections change
useEffect(() => {
if (dsfaRequired === null && !justification) {
const parts: string[] = []
if (wp248Selected.length > 0) {
const criteriaNames = wp248Selected.map(id =>
WP248_CRITERIA.find(c => c.id === id)?.code
).filter(Boolean).join(', ')
parts.push(`${wp248Selected.length} WP248-Kriterien erfuellt (${criteriaNames})`)
}
if (art35Selected.length > 0) {
parts.push(`Art. 35 Abs. 3 Regelbeispiel${art35Selected.length > 1 ? 'e' : ''} erfuellt`)
}
if (aiTriggersSelected.length > 0) {
parts.push(`${aiTriggersSelected.length} KI-spezifische${aiTriggersSelected.length > 1 ? '' : 'r'} Trigger erfuellt`)
}
if (parts.length > 0) {
setJustification(parts.join('. ') + '.')
}
}
}, [wp248Selected, art35Selected, aiTriggersSelected, dsfaRequired, justification])
const toggleWp248 = (criterionId: string) => {
setWp248Selected(prev =>
prev.includes(criterionId)
? prev.filter(id => id !== criterionId)
: [...prev, criterionId]
)
}
const toggleArt35 = (caseId: string) => {
setArt35Selected(prev =>
prev.includes(caseId)
? prev.filter(id => id !== caseId)
: [...prev, caseId]
)
}
const toggleAITrigger = (triggerId: string) => {
setAiTriggersSelected(prev =>
prev.includes(triggerId)
? prev.filter(id => id !== triggerId)
: [...prev, triggerId]
)
}
const handleSave = async () => {
const thresholdAnalysis: DSFAThresholdAnalysis = {
id: existingAnalysis?.id || crypto.randomUUID(),
dsfa_id: dsfa.id,
performed_at: new Date().toISOString(),
performed_by: 'current_user', // Would come from auth context
criteria_assessment: WP248_CRITERIA.map(c => ({
criterion_id: c.id,
applies: wp248Selected.includes(c.id),
justification: '',
})),
art35_abs3_assessment: ART35_ABS3_CASES.map(c => ({
case_id: c.id,
applies: art35Selected.includes(c.id),
justification: '',
})),
dsfa_required: dsfaRequired ?? recommendation === 'required',
decision_justification: justification,
documented: true,
}
await onUpdate({
threshold_analysis: thresholdAnalysis,
wp248_criteria_met: wp248Selected,
art35_abs3_triggered: art35Selected,
ai_trigger_ids: aiTriggersSelected,
involves_ai: aiTriggersSelected.length > 0,
})
}
return (
<div className="space-y-8">
{/* Step 1: WP248 Criteria */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Schritt 1: WP248 Kriterien pruefen
</h3>
<p className="text-sm text-gray-500 mb-4">
Pruefen Sie, welche der 9 Kriterien der Artikel-29-Datenschutzgruppe auf Ihre Verarbeitung zutreffen.
Bei 2 oder mehr erfuellten Kriterien ist eine DSFA in den meisten Faellen erforderlich.
</p>
<div className="space-y-3">
{WP248_CRITERIA.map((criterion) => (
<label
key={criterion.id}
className={`flex items-start gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
wp248Selected.includes(criterion.id)
? 'bg-purple-50 border-purple-300 ring-1 ring-purple-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={wp248Selected.includes(criterion.id)}
onChange={() => toggleWp248(criterion.id)}
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{criterion.code}:</span>
<span className="text-gray-900">{criterion.title}</span>
</div>
<p className="text-sm text-gray-500 mt-1">{criterion.description}</p>
{criterion.examples.length > 0 && (
<p className="text-xs text-gray-400 mt-1">
Beispiele: {criterion.examples.join(', ')}
</p>
)}
</div>
</label>
))}
</div>
{/* WP248 Summary */}
<div className={`mt-4 p-4 rounded-xl border ${
wp248Selected.length >= 2
? 'bg-orange-50 border-orange-200'
: wp248Selected.length === 1
? 'bg-yellow-50 border-yellow-200'
: 'bg-green-50 border-green-200'
}`}>
<div className="flex items-center gap-2">
{wp248Selected.length >= 2 ? (
<svg className="w-5 h-5 text-orange-500" 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>
) : (
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
<span className="font-medium">
{wp248Selected.length} von 9 Kriterien erfuellt
</span>
</div>
<p className="text-sm mt-1 text-gray-600">
{wp248Result.reason}
</p>
</div>
{/* Annex-Trigger: Empfehlung bei >= 2 WP248 Kriterien */}
{wp248Selected.length >= 2 && (
<div className="mt-4 p-4 rounded-xl border bg-indigo-50 border-indigo-200">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-4 h-4 text-indigo-600" 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>
</div>
<div>
<p className="font-semibold text-indigo-800 text-sm">Annex mit separater Risikobewertung empfohlen</p>
<p className="text-sm text-indigo-700 mt-1">
Bei {wp248Selected.length} erfuellten WP248-Kriterien wird ein Annex mit detaillierter Risikobewertung empfohlen.
</p>
<div className="mt-2">
<p className="text-xs font-medium text-indigo-700 mb-1">Vorgeschlagene Annex-Scopes basierend auf Ihren Kriterien:</p>
<ul className="text-xs text-indigo-600 space-y-1">
{wp248Selected.includes('scoring_profiling') && (
<li>- Annex: Profiling & Scoring Detailanalyse der Bewertungslogik</li>
)}
{wp248Selected.includes('automated_decision') && (
<li>- Annex: Automatisierte Einzelentscheidung Art. 22 Pruefung</li>
)}
{wp248Selected.includes('systematic_monitoring') && (
<li>- Annex: Systematische Ueberwachung Verhaeltnismaessigkeitspruefung</li>
)}
{wp248Selected.includes('sensitive_data') && (
<li>- Annex: Besondere Datenkategorien Schutzbedarfsanalyse Art. 9</li>
)}
{wp248Selected.includes('large_scale') && (
<li>- Annex: Umfangsanalyse Quantitative Bewertung der Verarbeitung</li>
)}
{wp248Selected.includes('matching_combining') && (
<li>- Annex: Datenzusammenfuehrung Zweckbindungspruefung</li>
)}
{wp248Selected.includes('vulnerable_subjects') && (
<li>- Annex: Schutzbeduerftige Betroffene Verstaerkte Schutzmassnahmen</li>
)}
{wp248Selected.includes('innovative_technology') && (
<li>- Annex: Innovative Technologie Technikfolgenabschaetzung</li>
)}
{wp248Selected.includes('preventing_rights') && (
<li>- Annex: Rechteausuebung Barrierefreiheit der Betroffenenrechte</li>
)}
</ul>
</div>
{aiTriggersSelected.length > 0 && (
<p className="text-xs text-indigo-500 mt-2">
+ KI-Trigger aktiv: Zusaetzlicher Annex fuer KI-Risikobewertung empfohlen (AI Act Konformitaet).
</p>
)}
</div>
</div>
</div>
)}
</div>
{/* Step 2: Art. 35 Abs. 3 Cases */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Schritt 2: Art. 35 Abs. 3 Regelbeispiele
</h3>
<p className="text-sm text-gray-500 mb-4">
Bei Erfuellung eines Regelbeispiels ist eine DSFA zwingend erforderlich.
</p>
<div className="space-y-3">
{ART35_ABS3_CASES.map((caseItem) => (
<label
key={caseItem.id}
className={`flex items-start gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
art35Selected.includes(caseItem.id)
? 'bg-red-50 border-red-300 ring-1 ring-red-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={art35Selected.includes(caseItem.id)}
onChange={() => toggleArt35(caseItem.id)}
className="mt-1 rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">lit. {caseItem.lit}:</span>
<span className="text-gray-900">{caseItem.title}</span>
</div>
<p className="text-sm text-gray-500 mt-1">{caseItem.description}</p>
<span className="text-xs text-blue-600 mt-1 inline-block">{caseItem.gdprRef}</span>
</div>
</label>
))}
</div>
</div>
{/* Step 3: AI-specific Triggers */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Schritt 3: KI-spezifische Trigger
</h3>
<p className="text-sm text-gray-500 mb-4">
Wird kuenstliche Intelligenz eingesetzt? Diese Trigger sind in der deutschen DSFA-Muss-Liste enthalten.
</p>
<div className="space-y-3">
{AI_DSFA_TRIGGERS.map((trigger) => (
<label
key={trigger.id}
className={`flex items-start gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
aiTriggersSelected.includes(trigger.id)
? 'bg-blue-50 border-blue-300 ring-1 ring-blue-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={aiTriggersSelected.includes(trigger.id)}
onChange={() => toggleAITrigger(trigger.id)}
className="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<span className="font-medium text-gray-900">{trigger.title}</span>
<p className="text-sm text-gray-500 mt-1">{trigger.description}</p>
{trigger.examples.length > 0 && (
<p className="text-xs text-gray-400 mt-1">
Beispiele: {trigger.examples.join(', ')}
</p>
)}
</div>
</label>
))}
</div>
</div>
{/* Step 4: Decision */}
<div className="border-t border-gray-200 pt-8">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Schritt 4: Entscheidung
</h3>
<p className="text-sm text-gray-500 mb-4">
Dokumentieren Sie Ihre Entscheidung, ob eine DSFA erforderlich ist.
</p>
{/* Recommendation Banner */}
<div className={`mb-6 p-4 rounded-xl border ${
recommendation === 'required'
? 'bg-red-50 border-red-200'
: recommendation === 'possible'
? 'bg-yellow-50 border-yellow-200'
: 'bg-green-50 border-green-200'
}`}>
<div className="flex items-center gap-3">
{recommendation === 'required' ? (
<>
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" 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>
</div>
<div>
<p className="font-semibold text-red-800">DSFA erforderlich</p>
<p className="text-sm text-red-600">
Basierend auf Ihrer Auswahl ist eine DSFA in den meisten Faellen Pflicht.
</p>
</div>
</>
) : recommendation === 'possible' ? (
<>
<div className="w-10 h-10 rounded-full bg-yellow-100 flex items-center justify-center">
<svg className="w-6 h-6 text-yellow-600" 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>
<div>
<p className="font-semibold text-yellow-800">DSFA moeglicherweise erforderlich</p>
<p className="text-sm text-yellow-600">
Einzelfallpruefung empfohlen. Bei Unsicherheit DSFA durchfuehren.
</p>
</div>
</>
) : (
<>
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center">
<svg className="w-6 h-6 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>
</div>
<div>
<p className="font-semibold text-green-800">DSFA wahrscheinlich nicht erforderlich</p>
<p className="text-sm text-green-600">
Keine Pflichtkriterien erfuellt. Dokumentieren Sie diese Entscheidung.
</p>
</div>
</>
)}
</div>
</div>
{/* Decision Radio Buttons */}
<div className="space-y-3 mb-6">
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
dsfaRequired === true
? 'bg-purple-50 border-purple-300 ring-1 ring-purple-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}>
<input
type="radio"
name="dsfaRequired"
checked={dsfaRequired === true}
onChange={() => setDsfaRequired(true)}
className="text-purple-600 focus:ring-purple-500"
/>
<div>
<span className="font-medium text-gray-900">DSFA erforderlich</span>
<p className="text-sm text-gray-500">Ich fuehre eine vollstaendige DSFA durch.</p>
</div>
</label>
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
dsfaRequired === false
? 'bg-purple-50 border-purple-300 ring-1 ring-purple-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}>
<input
type="radio"
name="dsfaRequired"
checked={dsfaRequired === false}
onChange={() => setDsfaRequired(false)}
className="text-purple-600 focus:ring-purple-500"
/>
<div>
<span className="font-medium text-gray-900">DSFA nicht erforderlich</span>
<p className="text-sm text-gray-500">
Die Verarbeitung erfordert keine DSFA. Die Entscheidung wird dokumentiert.
</p>
</div>
</label>
</div>
{/* Justification */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Begruendung der Entscheidung *
</label>
<p className="text-xs text-gray-500 mb-2">
Gem. DSK Kurzpapier Nr. 5 ist die Entscheidung und ihre Begruendung zu dokumentieren.
</p>
<textarea
value={justification}
onChange={(e) => setJustification(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
rows={4}
placeholder="Begruenden Sie, warum eine DSFA erforderlich/nicht erforderlich ist..."
/>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
onClick={handleSave}
disabled={isSubmitting || dsfaRequired === null || !justification.trim()}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{isSubmitting ? 'Speichern...' : 'Entscheidung speichern & fortfahren'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,222 @@
'use client'
import React from 'react'
// =============================================================================
// RE-EXPORTS FROM SEPARATE FILES
// =============================================================================
export { ThresholdAnalysisSection } from './ThresholdAnalysisSection'
export { DSFASidebar } from './DSFASidebar'
export { StakeholderConsultationSection } from './StakeholderConsultationSection'
export { Art36Warning } from './Art36Warning'
export { ReviewScheduleSection } from './ReviewScheduleSection'
export { SourceAttribution, InlineSourceRef, AttributionFooter } from './SourceAttribution'
// =============================================================================
// DSFA Card Component
// =============================================================================
interface DSFACardProps {
dsfa: {
id: string
title: string
status: string
risk_level?: string
created_at?: string
updated_at?: string
processing_description?: string
}
onDelete?: (id: string) => void
onExport?: (id: string) => void
}
export function DSFACard({ dsfa, onDelete, onExport }: DSFACardProps) {
const statusColors: Record<string, string> = {
draft: 'bg-slate-100 text-slate-700',
in_progress: 'bg-blue-100 text-blue-700',
review: 'bg-amber-100 text-amber-700',
approved: 'bg-green-100 text-green-700',
rejected: 'bg-red-100 text-red-700',
}
return React.createElement('div', {
className: 'bg-white rounded-xl border border-slate-200 p-5 hover:shadow-md transition-shadow'
},
React.createElement('div', { className: 'flex items-start justify-between mb-3' },
React.createElement('h3', { className: 'font-semibold text-slate-900 text-lg' }, dsfa.title),
React.createElement('span', {
className: `px-2.5 py-1 rounded-full text-xs font-medium ${statusColors[dsfa.status] || statusColors.draft}`
}, dsfa.status)
),
dsfa.processing_description && React.createElement('p', {
className: 'text-sm text-slate-500 mb-4 line-clamp-2'
}, dsfa.processing_description),
React.createElement('div', { className: 'flex items-center gap-2' },
React.createElement('a', {
href: `/sdk/dsfa/${dsfa.id}`,
className: 'px-3 py-1.5 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 transition-colors'
}, 'Bearbeiten'),
onExport && React.createElement('button', {
onClick: () => onExport(dsfa.id),
className: 'px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-sm hover:bg-slate-200 transition-colors'
}, 'Export'),
onDelete && React.createElement('button', {
onClick: () => onDelete(dsfa.id),
className: 'px-3 py-1.5 text-red-600 rounded-lg text-sm hover:bg-red-50 transition-colors'
}, 'Loeschen')
)
)
}
// =============================================================================
// Risk Matrix Component
// =============================================================================
// DSFARisk type matching lib/sdk/dsfa/types.ts
interface DSFARiskInput {
id: string
category?: string
description: string
likelihood: 'low' | 'medium' | 'high'
impact: 'low' | 'medium' | 'high'
risk_level?: string
affected_data?: string[]
}
interface RiskMatrixProps {
risks: DSFARiskInput[]
onRiskSelect?: (risk: DSFARiskInput) => void
onRiskClick?: (riskId: string) => void
onAddRisk?: (likelihood: 'low' | 'medium' | 'high', impact: 'low' | 'medium' | 'high') => void
selectedRiskId?: string
readOnly?: boolean
}
export function RiskMatrix({ risks, onRiskSelect, onRiskClick, onAddRisk, selectedRiskId, readOnly }: RiskMatrixProps) {
const likelihoodLevels: Array<'low' | 'medium' | 'high'> = ['high', 'medium', 'low']
const impactLevels: Array<'low' | 'medium' | 'high'> = ['low', 'medium', 'high']
const levelLabels = { low: 'Niedrig', medium: 'Mittel', high: 'Hoch' }
const cellColors: Record<string, string> = {
low: 'bg-green-100 hover:bg-green-200',
medium: 'bg-yellow-100 hover:bg-yellow-200',
high: 'bg-orange-100 hover:bg-orange-200',
very_high: 'bg-red-100 hover:bg-red-200',
}
const getRiskColor = (likelihood: 'low' | 'medium' | 'high', impact: 'low' | 'medium' | 'high') => {
const matrix: Record<string, Record<string, string>> = {
low: { low: 'low', medium: 'low', high: 'medium' },
medium: { low: 'low', medium: 'medium', high: 'high' },
high: { low: 'medium', medium: 'high', high: 'very_high' },
}
return cellColors[matrix[likelihood]?.[impact] || 'medium']
}
const handleCellClick = (likelihood: 'low' | 'medium' | 'high', impact: 'low' | 'medium' | 'high') => {
const cellRisks = risks.filter(r => r.likelihood === likelihood && r.impact === impact)
if (cellRisks.length > 0 && onRiskSelect) {
onRiskSelect(cellRisks[0])
} else if (cellRisks.length > 0 && onRiskClick) {
onRiskClick(cellRisks[0].id)
} else if (!readOnly && onAddRisk) {
onAddRisk(likelihood, impact)
}
}
return React.createElement('div', { className: 'bg-white rounded-xl border border-slate-200 p-5' },
React.createElement('h3', { className: 'font-semibold text-slate-900 mb-4' }, 'Risikomatrix'),
React.createElement('div', { className: 'text-xs text-slate-500 mb-2' }, 'Eintrittswahrscheinlichkeit ↑ | Schwere →'),
React.createElement('div', { className: 'grid grid-cols-4 gap-1' },
// Header row
React.createElement('div'),
...impactLevels.map(i => React.createElement('div', {
key: `h-${i}`,
className: 'text-center text-xs text-slate-500 py-1'
}, levelLabels[i])),
// Grid rows
...likelihoodLevels.map(likelihood =>
[
React.createElement('div', {
key: `l-${likelihood}`,
className: 'text-right text-xs text-slate-500 pr-2 flex items-center justify-end'
}, levelLabels[likelihood]),
...impactLevels.map(impact => {
const cellRisks = risks.filter(r => r.likelihood === likelihood && r.impact === impact)
const isSelected = cellRisks.some(r => r.id === selectedRiskId)
return React.createElement('div', {
key: `${likelihood}-${impact}`,
className: `aspect-square rounded ${getRiskColor(likelihood, impact)} flex items-center justify-center text-xs font-medium cursor-pointer ${isSelected ? 'ring-2 ring-purple-500' : ''}`,
onClick: () => handleCellClick(likelihood, impact)
}, cellRisks.length > 0 ? String(cellRisks.length) : (readOnly ? '' : '+'))
})
]
).flat()
)
)
}
// =============================================================================
// Approval Panel Component
// =============================================================================
interface ApprovalPanelProps {
dsfa: {
id: string
status: string
approved_by?: string
approved_at?: string
rejection_reason?: string
}
onApprove?: () => void
onReject?: (reason: string) => void
}
export function ApprovalPanel({ dsfa, onApprove, onReject }: ApprovalPanelProps) {
const [rejectionReason, setRejectionReason] = React.useState('')
const [showRejectForm, setShowRejectForm] = React.useState(false)
if (dsfa.status === 'approved') {
return React.createElement('div', {
className: 'bg-green-50 border border-green-200 rounded-xl p-5'
},
React.createElement('div', { className: 'flex items-center gap-2 mb-2' },
React.createElement('span', { className: 'text-green-600 text-lg' }, '\u2713'),
React.createElement('h3', { className: 'font-semibold text-green-800' }, 'DSFA genehmigt')
),
dsfa.approved_by && React.createElement('p', { className: 'text-sm text-green-700' },
`Genehmigt von: ${dsfa.approved_by}`
)
)
}
return React.createElement('div', {
className: 'bg-white rounded-xl border border-slate-200 p-5'
},
React.createElement('h3', { className: 'font-semibold text-slate-900 mb-4' }, 'Freigabe'),
React.createElement('div', { className: 'flex gap-3' },
onApprove && React.createElement('button', {
onClick: onApprove,
className: 'px-4 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 transition-colors'
}, 'Genehmigen'),
onReject && React.createElement('button', {
onClick: () => setShowRejectForm(true),
className: 'px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700 transition-colors'
}, 'Ablehnen')
),
showRejectForm && React.createElement('div', { className: 'mt-4' },
React.createElement('textarea', {
value: rejectionReason,
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => setRejectionReason(e.target.value),
placeholder: 'Ablehnungsgrund...',
className: 'w-full p-3 border border-slate-200 rounded-lg text-sm resize-none',
rows: 3
}),
React.createElement('button', {
onClick: () => { onReject?.(rejectionReason); setShowRejectForm(false) },
className: 'mt-2 px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700'
}, 'Ablehnung senden')
)
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,413 @@
'use client'
import { useState, useEffect } from 'react'
interface AuditLogEntry {
id: string
action: string
entity_type: string
entity_id?: string
old_value?: any
new_value?: any
user_email?: string
created_at: string
}
interface BlockedContentEntry {
id: string
url: string
domain: string
block_reason: string
rule_id?: string
details?: any
created_at: string
}
interface AuditTabProps {
apiBase: string
}
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
create: { label: 'Erstellt', color: 'bg-green-100 text-green-700' },
update: { label: 'Aktualisiert', color: 'bg-blue-100 text-blue-700' },
delete: { label: 'Geloescht', color: 'bg-red-100 text-red-700' },
}
const ENTITY_LABELS: Record<string, string> = {
source_policy: 'Policy',
allowed_source: 'Quelle',
operation_permission: 'Operation',
pii_rule: 'PII-Regel',
}
const BLOCK_REASON_LABELS: Record<string, { label: string; color: string }> = {
not_whitelisted: { label: 'Nicht in Whitelist', color: 'bg-amber-100 text-amber-700' },
pii_detected: { label: 'PII erkannt', color: 'bg-red-100 text-red-700' },
license_violation: { label: 'Lizenzverletzung', color: 'bg-orange-100 text-orange-700' },
training_forbidden: { label: 'Training verboten', color: 'bg-slate-800 text-white' },
}
export function AuditTab({ apiBase }: AuditTabProps) {
const [activeView, setActiveView] = useState<'changes' | 'blocked'>('changes')
// Audit logs
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([])
const [auditLoading, setAuditLoading] = useState(true)
const [auditTotal, setAuditTotal] = useState(0)
// Blocked content
const [blockedContent, setBlockedContent] = useState<BlockedContentEntry[]>([])
const [blockedLoading, setBlockedLoading] = useState(true)
const [blockedTotal, setBlockedTotal] = useState(0)
// Filters
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [entityFilter, setEntityFilter] = useState('')
// Export
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (activeView === 'changes') {
fetchAuditLogs()
} else {
fetchBlockedContent()
}
}, [activeView, dateFrom, dateTo, entityFilter])
const fetchAuditLogs = async () => {
try {
setAuditLoading(true)
const params = new URLSearchParams()
if (dateFrom) params.append('from', dateFrom)
if (dateTo) params.append('to', dateTo)
if (entityFilter) params.append('entity_type', entityFilter)
params.append('limit', '100')
const res = await fetch(`${apiBase}/v1/admin/policy-audit?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setAuditLogs(data.logs || [])
setAuditTotal(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setAuditLoading(false)
}
}
const fetchBlockedContent = async () => {
try {
setBlockedLoading(true)
const params = new URLSearchParams()
if (dateFrom) params.append('from', dateFrom)
if (dateTo) params.append('to', dateTo)
params.append('limit', '100')
const res = await fetch(`${apiBase}/v1/admin/blocked-content?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setBlockedContent(data.blocked || [])
setBlockedTotal(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setBlockedLoading(false)
}
}
const exportReport = async () => {
try {
setExporting(true)
const params = new URLSearchParams()
if (dateFrom) params.append('from', dateFrom)
if (dateTo) params.append('to', dateTo)
params.append('format', 'download')
const res = await fetch(`${apiBase}/v1/admin/compliance-report?${params}`)
if (!res.ok) throw new Error('Fehler beim Export')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `compliance-report-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setExporting(false)
}
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* View Toggle & Filters */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex gap-2">
<button
onClick={() => setActiveView('changes')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeView === 'changes'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Aenderungshistorie
</button>
<button
onClick={() => setActiveView('blocked')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeView === 'blocked'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Blockierte URLs
</button>
</div>
<div className="flex-1 flex flex-wrap gap-4 items-center">
<div className="flex items-center gap-2">
<label className="text-sm text-slate-600">Von:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-slate-600">Bis:</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
{activeView === 'changes' && (
<select
value={entityFilter}
onChange={(e) => setEntityFilter(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
>
<option value="">Alle Typen</option>
<option value="source_policy">Policies</option>
<option value="allowed_source">Quellen</option>
<option value="operation_permission">Operations</option>
<option value="pii_rule">PII-Regeln</option>
</select>
)}
<button
onClick={exportReport}
disabled={exporting}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 flex items-center gap-2 ml-auto"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
{exporting ? 'Exportiere...' : 'JSON Export'}
</button>
</div>
</div>
{/* Changes View */}
{activeView === 'changes' && (
<>
{auditLoading ? (
<div className="text-center py-12 text-slate-500">Lade Audit-Log...</div>
) : auditLogs.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-4" 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>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Eintraege vorhanden</h3>
<p className="text-sm text-slate-500">
Aenderungen werden hier protokolliert.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200 text-sm text-slate-600">
{auditTotal} Eintraege gesamt
</div>
<div className="divide-y divide-slate-100">
{auditLogs.map((log) => {
const actionConfig = ACTION_LABELS[log.action] || { label: log.action, color: 'bg-slate-100 text-slate-700' }
return (
<div key={log.id} className="px-4 py-4 hover:bg-slate-50">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${actionConfig.color}`}>
{actionConfig.label}
</span>
<span className="text-sm text-slate-700">
{ENTITY_LABELS[log.entity_type] || log.entity_type}
</span>
{log.entity_id && (
<code className="text-xs bg-slate-100 px-2 py-1 rounded text-slate-500">
{log.entity_id.substring(0, 8)}...
</code>
)}
</div>
<div className="text-xs text-slate-500">
{formatDate(log.created_at)}
</div>
</div>
{log.user_email && (
<div className="mt-2 text-xs text-slate-500">
Benutzer: {log.user_email}
</div>
)}
{(log.old_value || log.new_value) && (
<div className="mt-2 flex gap-4 text-xs">
{log.old_value && (
<div className="flex-1 p-2 bg-red-50 rounded">
<div className="text-red-600 font-medium mb-1">Vorher:</div>
<pre className="text-red-700 overflow-x-auto">
{typeof log.old_value === 'string' ? log.old_value : JSON.stringify(log.old_value, null, 2)}
</pre>
</div>
)}
{log.new_value && (
<div className="flex-1 p-2 bg-green-50 rounded">
<div className="text-green-600 font-medium mb-1">Nachher:</div>
<pre className="text-green-700 overflow-x-auto">
{typeof log.new_value === 'string' ? log.new_value : JSON.stringify(log.new_value, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
</>
)}
{/* Blocked Content View */}
{activeView === 'blocked' && (
<>
{blockedLoading ? (
<div className="text-center py-12 text-slate-500">Lade blockierte URLs...</div>
) : blockedContent.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-green-300 mb-4" 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>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine blockierten URLs</h3>
<p className="text-sm text-slate-500">
Alle gecrawlten URLs waren in der Whitelist.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200 text-sm text-slate-600">
{blockedTotal} blockierte URLs gesamt
</div>
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">URL</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Domain</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Grund</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Zeitpunkt</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{blockedContent.map((entry) => {
const reasonConfig = BLOCK_REASON_LABELS[entry.block_reason] || {
label: entry.block_reason,
color: 'bg-slate-100 text-slate-700',
}
return (
<tr key={entry.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<div className="text-sm text-slate-700 truncate max-w-md" title={entry.url}>
{entry.url}
</div>
</td>
<td className="px-4 py-3">
<code className="text-xs bg-slate-100 px-2 py-1 rounded">{entry.domain}</code>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${reasonConfig.color}`}>
{reasonConfig.label}
</span>
</td>
<td className="px-4 py-3 text-xs text-slate-500">
{formatDate(entry.created_at)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
{/* Auditor Info Box */}
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-blue-600" 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>
<div>
<h3 className="font-semibold text-blue-800">Fuer Auditoren</h3>
<p className="text-sm text-blue-700 mt-1">
Dieses Audit-Log ist unveraenderlich und protokolliert alle Aenderungen an der Quellen-Policy.
Jeder Eintrag enthaelt:
</p>
<ul className="text-sm text-blue-700 mt-2 list-disc list-inside">
<li>Zeitstempel der Aenderung</li>
<li>Art der Aenderung (Erstellen/Aendern/Loeschen)</li>
<li>Betroffene Entitaet und ID</li>
<li>Vorheriger und neuer Wert</li>
<li>E-Mail des Benutzers (falls angemeldet)</li>
</ul>
<p className="text-sm text-blue-600 mt-2 font-medium">
Der JSON-Export ist fuer die externe Pruefung und Archivierung geeignet.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,271 @@
'use client'
import { useState, useEffect } from 'react'
interface OperationPermission {
id: string
source_id: string
operation: string
is_allowed: boolean
requires_citation: boolean
notes?: string
}
interface SourceWithOperations {
id: string
domain: string
name: string
license: string
is_active: boolean
operations: OperationPermission[]
}
interface OperationsMatrixTabProps {
apiBase: string
}
const OPERATIONS = [
{ id: 'lookup', name: 'Lookup', description: 'Inhalt anzeigen/durchsuchen', icon: '🔍' },
{ id: 'rag', name: 'RAG', description: 'Retrieval Augmented Generation', icon: '🤖' },
{ id: 'training', name: 'Training', description: 'KI-Training (VERBOTEN)', icon: '🚫' },
{ id: 'export', name: 'Export', description: 'Daten exportieren', icon: '📤' },
]
export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
const [sources, setSources] = useState<SourceWithOperations[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [updating, setUpdating] = useState<string | null>(null)
useEffect(() => {
fetchMatrix()
}, [])
const fetchMatrix = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/operations-matrix`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setSources(data.sources || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const togglePermission = async (
source: SourceWithOperations,
operationId: string,
field: 'is_allowed' | 'requires_citation'
) => {
// Find the permission
const permission = source.operations.find((op) => op.operation === operationId)
if (!permission) return
// Block enabling training
if (operationId === 'training' && field === 'is_allowed' && !permission.is_allowed) {
setError('Training mit externen Daten ist VERBOTEN und kann nicht aktiviert werden.')
return
}
const updateId = `${permission.id}-${field}`
setUpdating(updateId)
try {
const newValue = !permission[field]
const res = await fetch(`${apiBase}/v1/admin/operations/${permission.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: newValue }),
})
if (!res.ok) {
const errData = await res.json()
throw new Error(errData.message || errData.error || 'Fehler beim Aktualisieren')
}
fetchMatrix()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setUpdating(null)
}
}
if (loading) {
return <div className="text-center py-12 text-slate-500">Lade Operations-Matrix...</div>
}
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Legend */}
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
<h3 className="font-medium text-slate-900 mb-3">Legende</h3>
<div className="flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-green-100 text-green-700 rounded">
<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>
</span>
<span className="text-slate-600">Erlaubt</span>
</div>
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-red-100 text-red-700 rounded">
<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>
</span>
<span className="text-slate-600">Verboten</span>
</div>
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-amber-100 text-amber-700 rounded text-xs">
Cite
</span>
<span className="text-slate-600">Zitation erforderlich</span>
</div>
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-slate-800 text-white rounded">
<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>
</span>
<span className="text-slate-600">System-gesperrt (Training)</span>
</div>
</div>
</div>
{/* Matrix Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-x-auto">
<table className="w-full min-w-[800px]">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Quelle</th>
{OPERATIONS.map((op) => (
<th key={op.id} className="text-center px-4 py-3">
<div className="flex flex-col items-center gap-1">
<span className="text-lg">{op.icon}</span>
<span className="text-xs font-medium text-slate-500 uppercase">{op.name}</span>
<span className="text-xs text-slate-400 font-normal">{op.description}</span>
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sources.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
Keine Quellen vorhanden
</td>
</tr>
) : (
sources.map((source) => (
<tr key={source.id} className={`hover:bg-slate-50 ${!source.is_active ? 'opacity-50' : ''}`}>
<td className="px-4 py-3">
<div>
<div className="font-medium text-slate-800">{source.name}</div>
<code className="text-xs text-slate-500">{source.domain}</code>
</div>
</td>
{OPERATIONS.map((op) => {
const permission = source.operations.find((p) => p.operation === op.id)
const isTraining = op.id === 'training'
const isAllowed = permission?.is_allowed ?? false
const requiresCitation = permission?.requires_citation ?? false
const isUpdating = updating === `${permission?.id}-is_allowed` || updating === `${permission?.id}-requires_citation`
return (
<td key={op.id} className="px-4 py-3 text-center">
<div className="flex flex-col items-center gap-2">
{/* Is Allowed Toggle */}
<button
onClick={() => togglePermission(source, op.id, 'is_allowed')}
disabled={isTraining || isUpdating || !source.is_active}
className={`w-10 h-10 flex items-center justify-center rounded transition-colors ${
isTraining
? 'bg-slate-800 text-white cursor-not-allowed'
: isAllowed
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-red-100 text-red-700 hover:bg-red-200'
} ${isUpdating ? 'opacity-50' : ''}`}
title={isTraining ? 'Training ist system-weit gesperrt' : isAllowed ? 'Klicken zum Deaktivieren' : 'Klicken zum Aktivieren'}
>
{isTraining ? (
<svg className="w-5 h-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>
) : isAllowed ? (
<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>
) : (
<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>
{/* Citation Required Toggle (only for allowed non-training ops) */}
{isAllowed && !isTraining && (
<button
onClick={() => togglePermission(source, op.id, 'requires_citation')}
disabled={isUpdating || !source.is_active}
className={`px-2 py-1 text-xs rounded transition-colors ${
requiresCitation
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
} ${isUpdating ? 'opacity-50' : ''}`}
title={requiresCitation ? 'Zitation erforderlich - Klicken zum Aendern' : 'Klicken um Zitation zu erfordern'}
>
{requiresCitation ? 'Cite ✓' : 'Cite'}
</button>
)}
</div>
</td>
)
})}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Training Warning */}
<div className="mt-6 bg-red-50 border border-red-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<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="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>
</div>
<div>
<h3 className="font-semibold text-red-800">Training-Operation: System-gesperrt</h3>
<p className="text-sm text-red-700 mt-1">
Das Training von KI-Modellen mit gecrawlten externen Daten ist aufgrund von Urheberrechts- und
Datenschutzbestimmungen grundsaetzlich verboten. Diese Einschraenkung ist im System hart kodiert
und kann nicht ueber diese Oberflaeche geaendert werden.
</p>
<p className="text-sm text-red-600 mt-2 font-medium">
Ausnahmen erfordern eine schriftliche Genehmigung des DSB und eine rechtliche Pruefung.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,562 @@
'use client'
import { useState, useEffect } from 'react'
interface PIIRule {
id: string
name: string
rule_type: string
pattern: string
severity: string
is_active: boolean
created_at: string
updated_at: string
}
interface PIIMatch {
rule_id: string
rule_name: string
rule_type: string
severity: string
match: string
start_index: number
end_index: number
}
interface PIITestResult {
has_pii: boolean
matches: PIIMatch[]
should_block: boolean
block_level: string
}
interface PIIRulesTabProps {
apiBase: string
onUpdate?: () => void
}
const RULE_TYPES = [
{ value: 'regex', label: 'Regex (Muster)' },
{ value: 'keyword', label: 'Keyword (Stichwort)' },
]
const SEVERITIES = [
{ value: 'warn', label: 'Warnung', color: 'bg-amber-100 text-amber-700' },
{ value: 'redact', label: 'Schwärzen', color: 'bg-orange-100 text-orange-700' },
{ value: 'block', label: 'Blockieren', color: 'bg-red-100 text-red-700' },
]
export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const [rules, setRules] = useState<PIIRule[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Test panel
const [testText, setTestText] = useState('')
const [testResult, setTestResult] = useState<PIITestResult | null>(null)
const [testing, setTesting] = useState(false)
// Edit modal
const [editingRule, setEditingRule] = useState<PIIRule | null>(null)
const [isNewRule, setIsNewRule] = useState(false)
const [saving, setSaving] = useState(false)
// New rule form
const [newRule, setNewRule] = useState({
name: '',
rule_type: 'regex',
pattern: '',
severity: 'block',
is_active: true,
})
useEffect(() => {
fetchRules()
}, [])
const fetchRules = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setRules(data.rules || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const createRule = async () => {
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newRule),
})
if (!res.ok) throw new Error('Fehler beim Erstellen')
setNewRule({
name: '',
rule_type: 'regex',
pattern: '',
severity: 'block',
is_active: true,
})
setIsNewRule(false)
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const updateRule = async () => {
if (!editingRule) return
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${editingRule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingRule),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren')
setEditingRule(null)
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const deleteRule = async (id: string) => {
if (!confirm('Regel wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
try {
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${id}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Fehler beim Loeschen')
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const toggleRuleStatus = async (rule: PIIRule) => {
try {
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${rule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !rule.is_active }),
})
if (!res.ok) throw new Error('Fehler beim Aendern des Status')
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const runTest = async () => {
if (!testText) return
try {
setTesting(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: testText }),
})
if (!res.ok) throw new Error('Fehler beim Testen')
const data = await res.json()
setTestResult(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setTesting(false)
}
}
const getSeverityBadge = (severity: string) => {
const config = SEVERITIES.find((s) => s.value === severity)
return (
<span className={`px-2 py-1 rounded text-xs font-medium ${config?.color || 'bg-slate-100 text-slate-700'}`}>
{config?.label || severity}
</span>
)
}
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Test Panel */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<h3 className="font-semibold text-slate-900 mb-4">PII-Test</h3>
<p className="text-sm text-slate-600 mb-4">
Testen Sie, ob ein Text personenbezogene Daten (PII) enthaelt.
</p>
<textarea
value={testText}
onChange={(e) => setTestText(e.target.value)}
placeholder="Geben Sie hier einen Text zum Testen ein...
Beispiel:
Kontaktieren Sie mich unter max.mustermann@example.com oder
rufen Sie mich an unter +49 170 1234567.
Meine IBAN ist DE89 3704 0044 0532 0130 00."
rows={6}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
/>
<div className="flex justify-between items-center mt-4">
<button
onClick={() => {
setTestText('')
setTestResult(null)
}}
className="text-sm text-slate-500 hover:text-slate-700"
>
Zuruecksetzen
</button>
<button
onClick={runTest}
disabled={testing || !testText}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{testing ? 'Teste...' : 'Testen'}
</button>
</div>
{/* Test Results */}
{testResult && (
<div className={`mt-4 p-4 rounded-lg ${testResult.should_block ? 'bg-red-50 border border-red-200' : testResult.has_pii ? 'bg-amber-50 border border-amber-200' : 'bg-green-50 border border-green-200'}`}>
<div className="flex items-center gap-2 mb-3">
{testResult.should_block ? (
<>
<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="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>
<span className="font-medium text-red-800">Blockiert - Kritische PII gefunden</span>
</>
) : testResult.has_pii ? (
<>
<svg className="w-5 h-5 text-amber-600" 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>
<span className="font-medium text-amber-800">Warnung - PII gefunden ({testResult.matches.length} Treffer)</span>
</>
) : (
<>
<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>
<span className="font-medium text-green-800">Keine PII gefunden</span>
</>
)}
</div>
{testResult.matches.length > 0 && (
<div className="space-y-2">
{testResult.matches.map((match, idx) => (
<div key={idx} className="flex items-center gap-3 text-sm bg-white bg-opacity-50 rounded px-3 py-2">
{getSeverityBadge(match.severity)}
<span className="text-slate-700 font-medium">{match.rule_name}</span>
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
{match.match.length > 30 ? match.match.substring(0, 30) + '...' : match.match}
</code>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Rules List Header */}
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-slate-900">PII-Erkennungsregeln</h3>
<button
onClick={() => setIsNewRule(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 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="M12 4v16m8-8H4" />
</svg>
Neue Regel
</button>
</div>
{/* Rules Table */}
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Regeln...</div>
) : rules.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-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>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Regeln vorhanden</h3>
<p className="text-sm text-slate-500">
Fuegen Sie PII-Erkennungsregeln hinzu.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Muster</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Severity</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rules.map((rule) => (
<tr key={rule.id} className="hover:bg-slate-50">
<td className="px-4 py-3 text-sm font-medium text-slate-800">{rule.name}</td>
<td className="px-4 py-3">
<span className="text-xs bg-slate-100 text-slate-700 px-2 py-1 rounded">
{rule.rule_type}
</span>
</td>
<td className="px-4 py-3">
<code className="text-xs bg-slate-100 px-2 py-1 rounded max-w-xs truncate block">
{rule.pattern.length > 40 ? rule.pattern.substring(0, 40) + '...' : rule.pattern}
</code>
</td>
<td className="px-4 py-3">{getSeverityBadge(rule.severity)}</td>
<td className="px-4 py-3">
<button
onClick={() => toggleRuleStatus(rule)}
className={`text-xs px-2 py-1 rounded ${
rule.is_active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{rule.is_active ? 'Aktiv' : 'Inaktiv'}
</button>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setEditingRule(rule)}
className="text-purple-600 hover:text-purple-700 mr-3"
>
Bearbeiten
</button>
<button
onClick={() => deleteRule(rule.id)}
className="text-red-600 hover:text-red-700"
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* New Rule Modal */}
{isNewRule && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neue PII-Regel</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newRule.name}
onChange={(e) => setNewRule({ ...newRule, name: e.target.value })}
placeholder="z.B. Deutsche Telefonnummern"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ *</label>
<select
value={newRule.rule_type}
onChange={(e) => setNewRule({ ...newRule, rule_type: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{RULE_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster *</label>
<textarea
value={newRule.pattern}
onChange={(e) => setNewRule({ ...newRule, pattern: e.target.value })}
placeholder={newRule.rule_type === 'regex' ? 'Regex-Muster, z.B. (?:\\+49|0)[\\s.-]?\\d{2,4}...' : 'Keywords getrennt durch Komma, z.B. password,secret,api_key'}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Severity *</label>
<select
value={newRule.severity}
onChange={(e) => setNewRule({ ...newRule, severity: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{SEVERITIES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setIsNewRule(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={createRule}
disabled={saving || !newRule.name || !newRule.pattern}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Erstellen'}
</button>
</div>
</div>
</div>
)}
{/* Edit Rule Modal */}
{editingRule && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">PII-Regel bearbeiten</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={editingRule.name}
onChange={(e) => setEditingRule({ ...editingRule, name: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={editingRule.rule_type}
onChange={(e) => setEditingRule({ ...editingRule, rule_type: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{RULE_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster *</label>
<textarea
value={editingRule.pattern}
onChange={(e) => setEditingRule({ ...editingRule, pattern: e.target.value })}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Severity</label>
<select
value={editingRule.severity}
onChange={(e) => setEditingRule({ ...editingRule, severity: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{SEVERITIES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="edit_is_active"
checked={editingRule.is_active}
onChange={(e) => setEditingRule({ ...editingRule, is_active: e.target.checked })}
className="w-4 h-4 text-purple-600"
/>
<label htmlFor="edit_is_active" className="text-sm text-slate-700">
Aktiv
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setEditingRule(null)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={updateRule}
disabled={saving}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,525 @@
'use client'
import { useState, useEffect } from 'react'
interface AllowedSource {
id: string
policy_id: string
domain: string
name: string
license: string
legal_basis?: string
citation_template?: string
trust_boost: number
is_active: boolean
created_at: string
updated_at: string
}
interface SourcesTabProps {
apiBase: string
onUpdate?: () => void
}
const LICENSES = [
{ value: 'DL-DE-BY-2.0', label: 'Datenlizenz Deutschland' },
{ value: 'CC-BY', label: 'Creative Commons BY' },
{ value: 'CC-BY-SA', label: 'Creative Commons BY-SA' },
{ value: 'CC0', label: 'Public Domain' },
{ value: '§5 UrhG', label: 'Amtliche Werke (§5 UrhG)' },
]
const BUNDESLAENDER = [
{ value: '', label: 'Bundesebene' },
{ value: 'NI', label: 'Niedersachsen' },
{ value: 'BY', label: 'Bayern' },
{ value: 'BW', label: 'Baden-Wuerttemberg' },
{ value: 'NW', label: 'Nordrhein-Westfalen' },
{ value: 'HE', label: 'Hessen' },
{ value: 'SN', label: 'Sachsen' },
{ value: 'BE', label: 'Berlin' },
{ value: 'HH', label: 'Hamburg' },
]
export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
const [sources, setSources] = useState<AllowedSource[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filters
const [searchTerm, setSearchTerm] = useState('')
const [licenseFilter, setLicenseFilter] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
// Edit modal
const [editingSource, setEditingSource] = useState<AllowedSource | null>(null)
const [isNewSource, setIsNewSource] = useState(false)
const [saving, setSaving] = useState(false)
// New source form
const [newSource, setNewSource] = useState({
domain: '',
name: '',
license: 'DL-DE-BY-2.0',
legal_basis: '',
citation_template: '',
trust_boost: 0.5,
is_active: true,
policy_id: '', // Will be set from policies
})
useEffect(() => {
fetchSources()
}, [licenseFilter, statusFilter])
const fetchSources = async () => {
try {
setLoading(true)
const params = new URLSearchParams()
if (licenseFilter) params.append('license', licenseFilter)
if (statusFilter !== 'all') params.append('active_only', statusFilter === 'active' ? 'true' : 'false')
const res = await fetch(`${apiBase}/v1/admin/sources?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setSources(data.sources || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const createSource = async () => {
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/sources`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSource),
})
if (!res.ok) throw new Error('Fehler beim Erstellen')
setNewSource({
domain: '',
name: '',
license: 'DL-DE-BY-2.0',
legal_basis: '',
citation_template: '',
trust_boost: 0.5,
is_active: true,
policy_id: '',
})
setIsNewSource(false)
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const updateSource = async () => {
if (!editingSource) return
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/sources/${editingSource.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingSource),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren')
setEditingSource(null)
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const deleteSource = async (id: string) => {
if (!confirm('Quelle wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
try {
const res = await fetch(`${apiBase}/v1/admin/sources/${id}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Fehler beim Loeschen')
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const toggleSourceStatus = async (source: AllowedSource) => {
try {
const res = await fetch(`${apiBase}/v1/admin/sources/${source.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !source.is_active }),
})
if (!res.ok) throw new Error('Fehler beim Aendern des Status')
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const filteredSources = sources.filter((source) => {
if (searchTerm) {
const term = searchTerm.toLowerCase()
if (!source.domain.toLowerCase().includes(term) && !source.name.toLowerCase().includes(term)) {
return false
}
}
return true
})
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Filters & Actions */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex-1">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Domain oder Name suchen..."
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<select
value={licenseFilter}
onChange={(e) => setLicenseFilter(e.target.value)}
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Alle Lizenzen</option>
{LICENSES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="all">Alle Status</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
<button
onClick={() => setIsNewSource(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 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="M12 4v16m8-8H4" />
</svg>
Neue Quelle
</button>
</div>
{/* Sources Table */}
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Quellen...</div>
) : filteredSources.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Quellen gefunden</h3>
<p className="text-sm text-slate-500">
Fuegen Sie neue Quellen zur Whitelist hinzu.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Domain</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Lizenz</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Trust</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredSources.map((source) => (
<tr key={source.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<code className="text-sm bg-slate-100 px-2 py-1 rounded">{source.domain}</code>
</td>
<td className="px-4 py-3 text-sm text-slate-700">{source.name}</td>
<td className="px-4 py-3">
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">
{source.license}
</span>
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{(source.trust_boost * 100).toFixed(0)}%
</td>
<td className="px-4 py-3">
<button
onClick={() => toggleSourceStatus(source)}
className={`text-xs px-2 py-1 rounded ${
source.is_active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{source.is_active ? 'Aktiv' : 'Inaktiv'}
</button>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setEditingSource(source)}
className="text-purple-600 hover:text-purple-700 mr-3"
>
Bearbeiten
</button>
<button
onClick={() => deleteSource(source.id)}
className="text-red-600 hover:text-red-700"
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* New Source Modal */}
{isNewSource && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neue Quelle hinzufuegen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Domain *</label>
<input
type="text"
value={newSource.domain}
onChange={(e) => setNewSource({ ...newSource, domain: e.target.value })}
placeholder="z.B. nibis.de"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newSource.name}
onChange={(e) => setNewSource({ ...newSource, name: e.target.value })}
placeholder="z.B. NiBiS Bildungsserver"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Lizenz *</label>
<select
value={newSource.license}
onChange={(e) => setNewSource({ ...newSource, license: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{LICENSES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage</label>
<input
type="text"
value={newSource.legal_basis}
onChange={(e) => setNewSource({ ...newSource, legal_basis: e.target.value })}
placeholder="z.B. §5 UrhG (Amtliche Werke)"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Trust Boost</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={newSource.trust_boost}
onChange={(e) => setNewSource({ ...newSource, trust_boost: parseFloat(e.target.value) })}
className="w-full"
/>
<div className="text-xs text-slate-500 text-right">
{(newSource.trust_boost * 100).toFixed(0)}%
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setIsNewSource(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={createSource}
disabled={saving || !newSource.domain || !newSource.name}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Erstellen'}
</button>
</div>
</div>
</div>
)}
{/* Edit Source Modal */}
{editingSource && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Quelle bearbeiten</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Domain</label>
<input
type="text"
value={editingSource.domain}
disabled
className="w-full px-4 py-2 border border-slate-200 rounded-lg bg-slate-50 text-slate-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={editingSource.name}
onChange={(e) => setEditingSource({ ...editingSource, name: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Lizenz *</label>
<select
value={editingSource.license}
onChange={(e) => setEditingSource({ ...editingSource, license: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{LICENSES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage</label>
<input
type="text"
value={editingSource.legal_basis || ''}
onChange={(e) => setEditingSource({ ...editingSource, legal_basis: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Zitiervorlage</label>
<input
type="text"
value={editingSource.citation_template || ''}
onChange={(e) => setEditingSource({ ...editingSource, citation_template: e.target.value })}
placeholder="Quelle: {source}, {title}, {date}"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Trust Boost</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={editingSource.trust_boost}
onChange={(e) => setEditingSource({ ...editingSource, trust_boost: parseFloat(e.target.value) })}
className="w-full"
/>
<div className="text-xs text-slate-500 text-right">
{(editingSource.trust_boost * 100).toFixed(0)}%
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_active"
checked={editingSource.is_active}
onChange={(e) => setEditingSource({ ...editingSource, is_active: e.target.checked })}
className="w-4 h-4 text-purple-600"
/>
<label htmlFor="is_active" className="text-sm text-slate-700">
Aktiv
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setEditingSource(null)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={updateSource}
disabled={saving}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,376 @@
'use client'
import { useMemo, useState, useEffect } from 'react'
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
import { getControlById } from '@/lib/sdk/tom-generator/controls/loader'
interface TOMEditorTabProps {
state: TOMGeneratorState
selectedTOMId: string | null
onUpdateTOM: (tomId: string, updates: Partial<DerivedTOM>) => void
onBack: () => void
}
const STATUS_OPTIONS: { value: DerivedTOM['implementationStatus']; label: string; className: string }[] = [
{ value: 'IMPLEMENTED', label: 'Implementiert', className: 'border-green-300 bg-green-50 text-green-700' },
{ value: 'PARTIAL', label: 'Teilweise implementiert', className: 'border-yellow-300 bg-yellow-50 text-yellow-700' },
{ value: 'NOT_IMPLEMENTED', label: 'Nicht implementiert', className: 'border-red-300 bg-red-50 text-red-700' },
]
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
TECHNICAL: { label: 'Technisch', className: 'bg-blue-100 text-blue-700' },
ORGANIZATIONAL: { label: 'Organisatorisch', className: 'bg-indigo-100 text-indigo-700' },
}
interface VVTActivity {
id: string
name?: string
title?: string
structuredToms?: { category?: string }[]
}
export function TOMEditorTab({ state, selectedTOMId, onUpdateTOM, onBack }: TOMEditorTabProps) {
const tom = useMemo(() => {
if (!selectedTOMId) return null
return state.derivedTOMs.find(t => t.id === selectedTOMId) || null
}, [state.derivedTOMs, selectedTOMId])
const control = useMemo(() => {
if (!tom) return null
return getControlById(tom.controlId)
}, [tom])
const [implementationStatus, setImplementationStatus] = useState<DerivedTOM['implementationStatus']>('NOT_IMPLEMENTED')
const [responsiblePerson, setResponsiblePerson] = useState('')
const [implementationDate, setImplementationDate] = useState('')
const [notes, setNotes] = useState('')
const [linkedEvidence, setLinkedEvidence] = useState<string[]>([])
const [selectedEvidenceId, setSelectedEvidenceId] = useState('')
useEffect(() => {
if (tom) {
setImplementationStatus(tom.implementationStatus)
setResponsiblePerson(tom.responsiblePerson || '')
setImplementationDate(tom.implementationDate ? new Date(tom.implementationDate).toISOString().slice(0, 10) : '')
setNotes(tom.aiGeneratedDescription || '')
setLinkedEvidence(tom.linkedEvidence || [])
}
}, [tom])
const vvtActivities = useMemo(() => {
if (!control) return []
try {
const raw = localStorage.getItem('bp_vvt')
if (!raw) return []
const activities: VVTActivity[] = JSON.parse(raw)
return activities.filter(a =>
a.structuredToms?.some(t => t.category === control.category)
)
} catch {
return []
}
}, [control])
const availableDocuments = useMemo(() => {
return (state.documents || []).filter(
doc => !linkedEvidence.includes(doc.id)
)
}, [state.documents, linkedEvidence])
const linkedDocuments = useMemo(() => {
return linkedEvidence
.map(id => (state.documents || []).find(d => d.id === id))
.filter(Boolean)
}, [state.documents, linkedEvidence])
const evidenceGaps = useMemo(() => {
if (!control?.evidenceRequirements) return []
return control.evidenceRequirements.map(req => {
const hasMatch = (state.documents || []).some(doc =>
linkedEvidence.includes(doc.id) &&
(doc.filename?.toLowerCase().includes(req.toLowerCase()) ||
doc.documentType?.toLowerCase().includes(req.toLowerCase()))
)
return { requirement: req, fulfilled: hasMatch }
})
}, [control, state.documents, linkedEvidence])
const handleSave = () => {
if (!tom) return
onUpdateTOM(tom.id, {
implementationStatus,
responsiblePerson: responsiblePerson || null,
implementationDate: implementationDate ? new Date(implementationDate) : null,
aiGeneratedDescription: notes || null,
linkedEvidence,
})
}
const handleAddEvidence = () => {
if (!selectedEvidenceId) return
setLinkedEvidence(prev => [...prev, selectedEvidenceId])
setSelectedEvidenceId('')
}
const handleRemoveEvidence = (docId: string) => {
setLinkedEvidence(prev => prev.filter(id => id !== docId))
}
if (!selectedTOMId || !tom) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="text-gray-400 mb-4">
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-700 mb-2">Keine TOM ausgewaehlt</h3>
<p className="text-gray-500">Waehlen Sie eine TOM aus der Uebersicht, um sie zu bearbeiten.</p>
</div>
)
}
const typeBadge = TYPE_BADGES[control?.type || 'TECHNICAL'] || TYPE_BADGES.TECHNICAL
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<button
onClick={onBack}
className="text-sm text-purple-600 hover:text-purple-800 font-medium flex items-center gap-1"
>
<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>
Zurueck zur Uebersicht
</button>
<button
onClick={handleSave}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
>
Aenderungen speichern
</button>
</div>
{/* TOM Header Card */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start gap-3 mb-3">
<span className="text-xs font-mono bg-gray-100 text-gray-600 px-2 py-1 rounded">{control?.code || tom.controlId}</span>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${typeBadge.className}`}>
{typeBadge.label}
</span>
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full font-medium">
{control?.category || 'Unbekannt'}
</span>
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">{control?.name?.de || tom.controlId}</h2>
{control?.description?.de && (
<p className="text-sm text-gray-600 leading-relaxed">{control.description.de}</p>
)}
</div>
{/* Implementation Status */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">Implementierungsstatus</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{STATUS_OPTIONS.map(opt => (
<label
key={opt.value}
className={`flex items-center gap-3 border rounded-lg p-3 cursor-pointer transition-all ${
implementationStatus === opt.value
? opt.className + ' ring-2 ring-offset-1 ring-current'
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
}`}
>
<input
type="radio"
name="implementationStatus"
value={opt.value}
checked={implementationStatus === opt.value}
onChange={() => setImplementationStatus(opt.value)}
className="sr-only"
/>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
implementationStatus === opt.value ? 'border-current' : 'border-gray-300'
}`}>
{implementationStatus === opt.value && (
<div className="w-2 h-2 rounded-full bg-current" />
)}
</div>
<span className="text-sm font-medium">{opt.label}</span>
</label>
))}
</div>
</div>
{/* Responsible Person */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">Verantwortliche Person</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Umgesetzt von</label>
<input
type="text"
value={responsiblePerson}
onChange={e => setResponsiblePerson(e.target.value)}
placeholder="Name der verantwortlichen Person"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Umsetzungsdatum</label>
<input
type="date"
value={implementationDate}
onChange={e => setImplementationDate(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
</div>
{/* Notes */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">Anmerkungen</h3>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={4}
placeholder="Anmerkungen zur Umsetzung, Besonderheiten, etc."
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-y"
/>
</div>
{/* Evidence Section */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">Nachweisdokumente</h3>
{linkedDocuments.length > 0 ? (
<div className="space-y-2 mb-4">
{linkedDocuments.map(doc => doc && (
<div key={doc.id} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-gray-400" 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>
<span className="text-sm text-gray-700">{doc.originalName || doc.filename || doc.id}</span>
</div>
<button
onClick={() => handleRemoveEvidence(doc.id)}
className="text-red-500 hover:text-red-700 text-xs font-medium"
>
Entfernen
</button>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400 mb-4">Keine Nachweisdokumente verknuepft.</p>
)}
{availableDocuments.length > 0 && (
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-600 mb-1">Dokument hinzufuegen</label>
<select
value={selectedEvidenceId}
onChange={e => setSelectedEvidenceId(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="">Dokument auswaehlen...</option>
{availableDocuments.map(doc => (
<option key={doc.id} value={doc.id}>{doc.originalName || doc.filename || doc.id}</option>
))}
</select>
</div>
<button
onClick={handleAddEvidence}
disabled={!selectedEvidenceId}
className="bg-purple-600 text-white hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition-colors"
>
Hinzufuegen
</button>
</div>
)}
</div>
{/* Evidence Gaps */}
{evidenceGaps.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">Nachweis-Anforderungen</h3>
<div className="space-y-2">
{evidenceGaps.map((gap, idx) => (
<div key={idx} className="flex items-center gap-3">
<div className={`w-5 h-5 rounded flex items-center justify-center flex-shrink-0 ${
gap.fulfilled ? 'bg-green-100 text-green-600' : 'bg-red-50 text-red-400'
}`}>
{gap.fulfilled ? (
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
)}
</div>
<span className={`text-sm ${gap.fulfilled ? 'text-gray-700' : 'text-gray-500'}`}>
{gap.requirement}
</span>
</div>
))}
</div>
</div>
)}
{/* VVT Cross-References */}
{vvtActivities.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">VVT-Querverweise</h3>
<div className="space-y-2">
{vvtActivities.map(activity => (
<div key={activity.id} className="flex items-center gap-2 bg-purple-50 rounded-lg px-3 py-2">
<svg className="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span className="text-sm text-purple-700">{activity.name || activity.title || activity.id}</span>
</div>
))}
</div>
</div>
)}
{/* Framework Mappings */}
{control?.mappings && control.mappings.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">Framework-Zuordnungen</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{control.mappings.map((mapping, idx) => (
<div key={idx} className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2">
<span className="text-xs font-semibold text-gray-500 uppercase">{mapping.framework}</span>
<span className="text-sm text-gray-700">{mapping.reference}</span>
</div>
))}
</div>
</div>
)}
{/* Bottom Save */}
<div className="flex items-center justify-between pt-2">
<button
onClick={onBack}
className="text-sm text-gray-500 hover:text-gray-700 font-medium"
>
Zurueck zur Uebersicht
</button>
<button
onClick={handleSave}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-6 py-2.5 font-medium transition-colors"
>
Aenderungen speichern
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,328 @@
'use client'
import { useMemo } from 'react'
import { TOMGeneratorState, GapAnalysisResult, DerivedTOM } from '@/lib/sdk/tom-generator/types'
import { getControlById, getAllControls } from '@/lib/sdk/tom-generator/controls/loader'
import {
SDM_GOAL_LABELS,
SDM_GOAL_DESCRIPTIONS,
getSDMCoverageStats,
MODULE_LABELS,
getModuleCoverageStats,
SDMGewaehrleistungsziel,
TOMModuleCategory,
} from '@/lib/sdk/tom-generator/sdm-mapping'
interface TOMGapExportTabProps {
state: TOMGeneratorState
onRunGapAnalysis: () => void
}
function getScoreColor(score: number): string {
if (score >= 75) return 'text-green-600'
if (score >= 50) return 'text-yellow-600'
return 'text-red-600'
}
function getScoreBgColor(score: number): string {
if (score >= 75) return 'bg-green-50 border-green-200'
if (score >= 50) return 'bg-yellow-50 border-yellow-200'
return 'bg-red-50 border-red-200'
}
function getBarColor(score: number): string {
if (score >= 75) return 'bg-green-500'
if (score >= 50) return 'bg-yellow-500'
return 'bg-red-500'
}
function downloadJSON(data: unknown, filename: string) {
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json' })
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)
}
export function TOMGapExportTab({ state, onRunGapAnalysis }: TOMGapExportTabProps) {
const gap = state.gapAnalysis as GapAnalysisResult | null | undefined
const sdmGoals = useMemo(() => {
const goals = Object.keys(SDM_GOAL_LABELS) as SDMGewaehrleistungsziel[]
const allStats = getSDMCoverageStats(state.derivedTOMs)
return goals.map(key => {
const stats = allStats[key] || { total: 0, implemented: 0, partial: 0, missing: 0 }
const total = stats.total || 1
const percent = Math.round((stats.implemented / total) * 100)
return {
key,
label: SDM_GOAL_LABELS[key],
description: SDM_GOAL_DESCRIPTIONS[key],
stats,
percent,
}
})
}, [state.derivedTOMs])
const modules = useMemo(() => {
const moduleKeys = Object.keys(MODULE_LABELS) as TOMModuleCategory[]
const allStats = getModuleCoverageStats(state.derivedTOMs)
return moduleKeys.map(key => {
const stats = allStats[key] || { total: 0, implemented: 0 }
const total = stats.total || 1
const percent = Math.round((stats.implemented / total) * 100)
return {
key,
label: MODULE_LABELS[key],
stats: { ...stats, partial: 0, missing: total - stats.implemented },
percent,
}
})
}, [state.derivedTOMs])
const handleExportTOMs = () => {
downloadJSON(state.derivedTOMs, `tom-export-${new Date().toISOString().slice(0, 10)}.json`)
}
const handleExportGap = () => {
if (!gap) return
downloadJSON(gap, `gap-analyse-${new Date().toISOString().slice(0, 10)}.json`)
}
return (
<div className="space-y-6">
{/* Gap Analysis */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900">Gap-Analyse</h3>
<button
onClick={onRunGapAnalysis}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
>
Analyse ausfuehren
</button>
</div>
{gap ? (
<div className="space-y-6">
{/* Score Gauge */}
<div className="flex justify-center">
<div className={`rounded-xl border-2 p-8 text-center ${getScoreBgColor(gap.overallScore)}`}>
<div className={`text-5xl font-bold ${getScoreColor(gap.overallScore)}`}>
{gap.overallScore}
</div>
<div className="text-sm text-gray-600 mt-1">von 100 Punkten</div>
</div>
</div>
{/* Missing Controls */}
{gap.missingControls && gap.missingControls.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-red-700 mb-2">
Fehlende Kontrollen ({gap.missingControls.length})
</h4>
<div className="space-y-1">
{gap.missingControls.map((mc, idx) => {
const control = getControlById(mc.controlId)
return (
<div key={idx} className="flex items-center gap-2 bg-red-50 rounded-lg px-3 py-2">
<span className="text-xs font-mono text-red-400">{control?.code || mc.controlId}</span>
<span className="text-sm text-red-700">{control?.name?.de || mc.controlId}</span>
{mc.reason && <span className="text-xs text-red-400 ml-auto">{mc.reason}</span>}
</div>
)
})}
</div>
</div>
)}
{/* Partial Controls */}
{gap.partialControls && gap.partialControls.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-yellow-700 mb-2">
Teilweise implementierte Kontrollen ({gap.partialControls.length})
</h4>
<div className="space-y-1">
{gap.partialControls.map((pc, idx) => {
const control = getControlById(pc.controlId)
return (
<div key={idx} className="flex items-center gap-2 bg-yellow-50 rounded-lg px-3 py-2">
<span className="text-xs font-mono text-yellow-500">{control?.code || pc.controlId}</span>
<span className="text-sm text-yellow-700">{control?.name?.de || pc.controlId}</span>
</div>
)
})}
</div>
</div>
)}
{/* Missing Evidence */}
{gap.missingEvidence && gap.missingEvidence.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-orange-700 mb-2">
Fehlende Nachweise ({gap.missingEvidence.length})
</h4>
<div className="space-y-1">
{gap.missingEvidence.map((item, idx) => {
const control = getControlById(item.controlId)
return (
<div key={idx} className="flex items-center gap-2 bg-orange-50 rounded-lg px-3 py-2">
<svg className="w-4 h-4 text-orange-400 flex-shrink-0" 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-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<span className="text-sm text-orange-700">
{control?.name?.de || item.controlId}: {item.requiredEvidence.join(', ')}
</span>
</div>
)
})}
</div>
</div>
)}
{/* Recommendations */}
{gap.recommendations && gap.recommendations.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-blue-700 mb-2">
Empfehlungen ({gap.recommendations.length})
</h4>
<div className="space-y-1">
{gap.recommendations.map((rec, idx) => (
<div key={idx} className="flex items-start gap-2 bg-blue-50 rounded-lg px-3 py-2">
<svg className="w-4 h-4 text-blue-400 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>
<span className="text-sm text-blue-700">
{typeof rec === 'string' ? rec : (rec as { text?: string; message?: string }).text || (rec as { text?: string; message?: string }).message || JSON.stringify(rec)}
</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="text-center py-8 text-gray-400">
<svg className="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
<p className="text-sm">Fuehren Sie die Gap-Analyse aus, um Luecken in Ihren TOMs zu identifizieren.</p>
</div>
)}
</div>
{/* SDM Gewaehrleistungsziele */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">SDM Gewaehrleistungsziele</h3>
<div className="space-y-4">
{sdmGoals.map(goal => (
<div key={goal.key}>
<div className="flex items-center justify-between mb-1">
<div>
<span className="text-sm font-medium text-gray-700">{goal.label}</span>
{goal.description && (
<span className="text-xs text-gray-400 ml-2">{goal.description}</span>
)}
</div>
<div className="text-xs text-gray-500">
{goal.stats.implemented}/{goal.stats.total} implementiert
{goal.stats.partial > 0 && ` | ${goal.stats.partial} teilweise`}
{goal.stats.missing > 0 && ` | ${goal.stats.missing} fehlend`}
</div>
</div>
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full flex">
<div
className="bg-green-500 h-full transition-all"
style={{ width: `${goal.percent}%` }}
/>
<div
className="bg-yellow-400 h-full transition-all"
style={{ width: `${goal.stats.total ? Math.round((goal.stats.partial / goal.stats.total) * 100) : 0}%` }}
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Module Coverage */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Modul-Abdeckung</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{modules.map(mod => (
<div key={mod.key} className="border border-gray-200 rounded-lg p-4">
<div className="text-sm font-medium text-gray-700 mb-2">{mod.label}</div>
<div className="flex items-end gap-2 mb-2">
<span className={`text-2xl font-bold ${getScoreColor(mod.percent)}`}>
{mod.percent}%
</span>
<span className="text-xs text-gray-400 mb-1">
({mod.stats.implemented}/{mod.stats.total})
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${getBarColor(mod.percent)}`}
style={{ width: `${mod.percent}%` }}
/>
</div>
{mod.stats.partial > 0 && (
<div className="text-xs text-yellow-600 mt-1">{mod.stats.partial} teilweise</div>
)}
{mod.stats.missing > 0 && (
<div className="text-xs text-red-500 mt-0.5">{mod.stats.missing} fehlend</div>
)}
</div>
))}
</div>
</div>
{/* Export Section */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Export</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<button
onClick={handleExportTOMs}
disabled={state.derivedTOMs.length === 0}
className="flex flex-col items-center gap-2 border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-8 h-8 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
<span className="text-sm font-medium text-gray-700">JSON Export</span>
<span className="text-xs text-gray-400">Alle TOMs als JSON</span>
</button>
<button
onClick={handleExportGap}
disabled={!gap}
className="flex flex-col items-center gap-2 border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-8 h-8 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
<span className="text-sm font-medium text-gray-700">Gap-Analyse Export</span>
<span className="text-xs text-gray-400">Analyseergebnis als JSON</span>
</button>
<div className="flex flex-col items-center gap-2 border border-dashed border-gray-300 rounded-lg p-4 bg-gray-50">
<svg className="w-8 h-8 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
<span className="text-sm font-medium text-gray-500">Vollstaendiger Export (ZIP)</span>
<span className="text-xs text-gray-400 text-center">
Nutzen Sie den TOM Generator fuer den vollstaendigen Export mit DOCX/PDF
</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,267 @@
'use client'
import { useMemo, useState } from 'react'
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
import { getControlById, getControlsByCategory, getAllCategories } from '@/lib/sdk/tom-generator/controls/loader'
import { SDM_GOAL_LABELS, getSDMCoverageStats, SDMGewaehrleistungsziel } from '@/lib/sdk/tom-generator/sdm-mapping'
interface TOMOverviewTabProps {
state: TOMGeneratorState
onSelectTOM: (tomId: string) => void
onStartGenerator: () => void
}
const STATUS_BADGES: Record<string, { label: string; className: string }> = {
IMPLEMENTED: { label: 'Implementiert', className: 'bg-green-100 text-green-700' },
PARTIAL: { label: 'Teilweise', className: 'bg-yellow-100 text-yellow-700' },
NOT_IMPLEMENTED: { label: 'Fehlend', className: 'bg-red-100 text-red-700' },
}
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
TECHNICAL: { label: 'Technisch', className: 'bg-blue-100 text-blue-700' },
ORGANIZATIONAL: { label: 'Organisatorisch', className: 'bg-indigo-100 text-indigo-700' },
}
const SCHUTZZIELE: { key: SDMGewaehrleistungsziel; label: string }[] = [
{ key: 'Vertraulichkeit', label: 'Vertraulichkeit' },
{ key: 'Integritaet', label: 'Integritaet' },
{ key: 'Verfuegbarkeit', label: 'Verfuegbarkeit' },
{ key: 'Nichtverkettung', label: 'Nichtverkettung' },
]
export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOverviewTabProps) {
const [categoryFilter, setCategoryFilter] = useState<string>('ALL')
const [typeFilter, setTypeFilter] = useState<string>('ALL')
const [statusFilter, setStatusFilter] = useState<string>('ALL')
const [applicabilityFilter, setApplicabilityFilter] = useState<string>('ALL')
const categories = useMemo(() => getAllCategories(), [])
const stats = useMemo(() => {
const toms = state.derivedTOMs
return {
total: toms.length,
implemented: toms.filter(t => t.implementationStatus === 'IMPLEMENTED').length,
partial: toms.filter(t => t.implementationStatus === 'PARTIAL').length,
missing: toms.filter(t => t.implementationStatus === 'NOT_IMPLEMENTED').length,
}
}, [state.derivedTOMs])
const sdmStats = useMemo(() => {
const allStats = getSDMCoverageStats(state.derivedTOMs)
return SCHUTZZIELE.map(sz => ({
...sz,
stats: allStats[sz.key] || { total: 0, implemented: 0, partial: 0, missing: 0 },
}))
}, [state.derivedTOMs])
const filteredTOMs = useMemo(() => {
let toms = state.derivedTOMs
if (categoryFilter !== 'ALL') {
const categoryControlIds = getControlsByCategory(categoryFilter).map(c => c.id)
toms = toms.filter(t => categoryControlIds.includes(t.controlId))
}
if (typeFilter !== 'ALL') {
toms = toms.filter(t => {
const ctrl = getControlById(t.controlId)
return ctrl?.type === typeFilter
})
}
if (statusFilter !== 'ALL') {
toms = toms.filter(t => t.implementationStatus === statusFilter)
}
if (applicabilityFilter !== 'ALL') {
toms = toms.filter(t => t.applicability === applicabilityFilter)
}
return toms
}, [state.derivedTOMs, categoryFilter, typeFilter, statusFilter, applicabilityFilter])
if (state.derivedTOMs.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="text-gray-400 mb-4">
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
</div>
<h3 className="text-lg font-semibold text-gray-700 mb-2">Keine TOMs vorhanden</h3>
<p className="text-gray-500 mb-6 max-w-md">
Starten Sie den TOM Generator, um technische und organisatorische Massnahmen basierend auf Ihrem Verarbeitungsverzeichnis abzuleiten.
</p>
<button
onClick={onStartGenerator}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-6 py-3 font-medium transition-colors"
>
TOM Generator starten
</button>
</div>
)
}
return (
<div className="space-y-6">
{/* Stats Row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
<div className="text-sm text-gray-500 mt-1">Gesamt TOMs</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-3xl font-bold text-green-600">{stats.implemented}</div>
<div className="text-sm text-gray-500 mt-1">Implementiert</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-3xl font-bold text-yellow-600">{stats.partial}</div>
<div className="text-sm text-gray-500 mt-1">Teilweise</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-3xl font-bold text-red-600">{stats.missing}</div>
<div className="text-sm text-gray-500 mt-1">Fehlend</div>
</div>
</div>
{/* Art. 32 Schutzziele */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-3">Art. 32 DSGVO Schutzziele</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{sdmStats.map(sz => {
const total = sz.stats.total || 1
const implPercent = Math.round((sz.stats.implemented / total) * 100)
const partialPercent = Math.round((sz.stats.partial / total) * 100)
return (
<div key={sz.key} className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm font-medium text-gray-700 mb-2">{sz.label}</div>
<div className="flex items-center gap-2 mb-1">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full flex">
<div
className="bg-green-500 h-full"
style={{ width: `${implPercent}%` }}
/>
<div
className="bg-yellow-400 h-full"
style={{ width: `${partialPercent}%` }}
/>
</div>
</div>
</div>
<div className="text-xs text-gray-500">
{sz.stats.implemented}/{sz.stats.total} implementiert
</div>
</div>
)
})}
</div>
</div>
{/* Filter Controls */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Kategorie</label>
<select
value={categoryFilter}
onChange={e => setCategoryFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="ALL">Alle Kategorien</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Typ</label>
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="ALL">Alle</option>
<option value="TECHNICAL">Technisch</option>
<option value="ORGANIZATIONAL">Organisatorisch</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="ALL">Alle</option>
<option value="IMPLEMENTED">Implementiert</option>
<option value="PARTIAL">Teilweise</option>
<option value="NOT_IMPLEMENTED">Fehlend</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Anwendbarkeit</label>
<select
value={applicabilityFilter}
onChange={e => setApplicabilityFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="ALL">Alle</option>
<option value="REQUIRED">Erforderlich</option>
<option value="RECOMMENDED">Empfohlen</option>
<option value="OPTIONAL">Optional</option>
</select>
</div>
</div>
</div>
{/* TOM Card Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredTOMs.map(tom => {
const control = getControlById(tom.controlId)
const statusBadge = STATUS_BADGES[tom.implementationStatus] || STATUS_BADGES.NOT_IMPLEMENTED
const typeBadge = TYPE_BADGES[control?.type || 'TECHNICAL'] || TYPE_BADGES.TECHNICAL
const evidenceCount = tom.linkedEvidence?.length || 0
return (
<button
key={tom.id}
onClick={() => onSelectTOM(tom.id)}
className="bg-white rounded-xl border border-gray-200 p-5 text-left hover:border-purple-300 hover:shadow-md transition-all group"
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono text-gray-400">{control?.code || tom.controlId}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadge.className}`}>
{statusBadge.label}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeBadge.className}`}>
{typeBadge.label}
</span>
</div>
{evidenceCount > 0 && (
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
{evidenceCount} Nachweise
</span>
)}
</div>
<h4 className="text-sm font-semibold text-gray-800 group-hover:text-purple-700 transition-colors mb-1">
{control?.name?.de || tom.controlId}
</h4>
<div className="text-xs text-gray-400">
{control?.category || 'Unbekannte Kategorie'}
</div>
</button>
)
})}
</div>
{filteredTOMs.length === 0 && state.derivedTOMs.length > 0 && (
<div className="text-center py-10 text-gray-500">
<p>Keine TOMs entsprechen den aktuellen Filterkriterien.</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,3 @@
export { TOMOverviewTab } from './TOMOverviewTab'
export { TOMEditorTab } from './TOMEditorTab'
export { TOMGapExportTab } from './TOMGapExportTab'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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