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:
464
admin-compliance/components/sdk/CommandBar/CommandBar.tsx
Normal file
464
admin-compliance/components/sdk/CommandBar/CommandBar.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK, SDK_STEPS, CommandType, CommandHistory, downloadExport } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface Suggestion {
|
||||
id: string
|
||||
type: CommandType
|
||||
label: string
|
||||
description: string
|
||||
shortcut?: string
|
||||
icon: React.ReactNode
|
||||
action: () => void | Promise<void>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ICONS
|
||||
// =============================================================================
|
||||
|
||||
const icons = {
|
||||
navigation: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
),
|
||||
action: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
search: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
),
|
||||
generate: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
),
|
||||
help: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMMAND BAR
|
||||
// =============================================================================
|
||||
|
||||
interface CommandBarProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CommandBar({ onClose }: CommandBarProps) {
|
||||
const router = useRouter()
|
||||
const { state, dispatch, goToStep } = useSDK()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Generate suggestions based on query
|
||||
const suggestions = useMemo((): Suggestion[] => {
|
||||
const results: Suggestion[] = []
|
||||
const lowerQuery = query.toLowerCase()
|
||||
|
||||
// Navigation suggestions
|
||||
SDK_STEPS.forEach(step => {
|
||||
const matchesName = step.name.toLowerCase().includes(lowerQuery) ||
|
||||
step.nameShort.toLowerCase().includes(lowerQuery)
|
||||
const matchesDescription = step.description.toLowerCase().includes(lowerQuery)
|
||||
|
||||
if (!query || matchesName || matchesDescription) {
|
||||
results.push({
|
||||
id: `nav-${step.id}`,
|
||||
type: 'NAVIGATION',
|
||||
label: `Gehe zu ${step.name}`,
|
||||
description: step.description,
|
||||
icon: icons.navigation,
|
||||
action: () => {
|
||||
goToStep(step.id)
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Action suggestions
|
||||
const actions: Suggestion[] = [
|
||||
{
|
||||
id: 'action-new-usecase',
|
||||
type: 'ACTION',
|
||||
label: 'Neuen Anwendungsfall erstellen',
|
||||
description: 'Startet die Anwendungsfall-Erfassung',
|
||||
icon: icons.action,
|
||||
action: () => {
|
||||
goToStep('use-case-assessment')
|
||||
onClose()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-export-pdf',
|
||||
type: 'ACTION',
|
||||
label: 'Als PDF exportieren',
|
||||
description: 'Exportiert Compliance-Bericht als PDF',
|
||||
icon: icons.action,
|
||||
action: async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await downloadExport(state, 'pdf')
|
||||
} catch (error) {
|
||||
console.error('PDF export failed:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-export-zip',
|
||||
type: 'ACTION',
|
||||
label: 'Als ZIP exportieren',
|
||||
description: 'Exportiert alle Daten und Dokumente als ZIP-Archiv',
|
||||
icon: icons.action,
|
||||
action: async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await downloadExport(state, 'zip')
|
||||
} catch (error) {
|
||||
console.error('ZIP export failed:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-export-json',
|
||||
type: 'ACTION',
|
||||
label: 'Als JSON exportieren',
|
||||
description: 'Exportiert den kompletten State als JSON',
|
||||
icon: icons.action,
|
||||
action: async () => {
|
||||
try {
|
||||
await downloadExport(state, 'json')
|
||||
} catch (error) {
|
||||
console.error('JSON export failed:', error)
|
||||
} finally {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-validate',
|
||||
type: 'ACTION',
|
||||
label: 'Checkpoint validieren',
|
||||
description: 'Validiert den aktuellen Schritt',
|
||||
icon: icons.action,
|
||||
action: () => {
|
||||
// Trigger validation
|
||||
onClose()
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
actions.forEach(action => {
|
||||
if (!query || action.label.toLowerCase().includes(lowerQuery)) {
|
||||
results.push(action)
|
||||
}
|
||||
})
|
||||
|
||||
// Generate suggestions
|
||||
const generateSuggestions: Suggestion[] = [
|
||||
{
|
||||
id: 'gen-dsfa',
|
||||
type: 'GENERATE',
|
||||
label: 'DSFA generieren',
|
||||
description: 'Generiert eine Datenschutz-Folgenabschätzung',
|
||||
icon: icons.generate,
|
||||
action: () => {
|
||||
goToStep('dsfa')
|
||||
onClose()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gen-tom',
|
||||
type: 'GENERATE',
|
||||
label: 'TOMs generieren',
|
||||
description: 'Generiert technische und organisatorische Maßnahmen',
|
||||
icon: icons.generate,
|
||||
action: () => {
|
||||
goToStep('tom')
|
||||
onClose()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gen-vvt',
|
||||
type: 'GENERATE',
|
||||
label: 'VVT generieren',
|
||||
description: 'Generiert das Verarbeitungsverzeichnis',
|
||||
icon: icons.generate,
|
||||
action: () => {
|
||||
goToStep('vvt')
|
||||
onClose()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gen-cookie',
|
||||
type: 'GENERATE',
|
||||
label: 'Cookie Banner generieren',
|
||||
description: 'Generiert Cookie-Consent-Banner Code',
|
||||
icon: icons.generate,
|
||||
action: () => {
|
||||
goToStep('cookie-banner')
|
||||
onClose()
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
generateSuggestions.forEach(suggestion => {
|
||||
if (!query || suggestion.label.toLowerCase().includes(lowerQuery)) {
|
||||
results.push(suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
// Help suggestions
|
||||
const helpSuggestions: Suggestion[] = [
|
||||
{
|
||||
id: 'help-docs',
|
||||
type: 'HELP',
|
||||
label: 'Dokumentation öffnen',
|
||||
description: 'Öffnet die SDK-Dokumentation',
|
||||
icon: icons.help,
|
||||
action: () => {
|
||||
window.open('/docs/sdk', '_blank')
|
||||
onClose()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'help-next',
|
||||
type: 'HELP',
|
||||
label: 'Was muss ich als nächstes tun?',
|
||||
description: 'Zeigt den nächsten empfohlenen Schritt',
|
||||
icon: icons.help,
|
||||
action: () => {
|
||||
// Show contextual help
|
||||
onClose()
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
helpSuggestions.forEach(suggestion => {
|
||||
if (!query || suggestion.label.toLowerCase().includes(lowerQuery)) {
|
||||
results.push(suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
return results.slice(0, 10)
|
||||
}, [query, state, goToStep, onClose])
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (suggestions[selectedIndex]) {
|
||||
executeSuggestion(suggestions[selectedIndex])
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
onClose()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [suggestions, selectedIndex, onClose])
|
||||
|
||||
// Reset selected index when query changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [query])
|
||||
|
||||
const executeSuggestion = async (suggestion: Suggestion) => {
|
||||
// Add to history
|
||||
const historyEntry: CommandHistory = {
|
||||
id: `${Date.now()}`,
|
||||
query: suggestion.label,
|
||||
type: suggestion.type,
|
||||
timestamp: new Date(),
|
||||
success: true,
|
||||
}
|
||||
dispatch({ type: 'ADD_COMMAND_HISTORY', payload: historyEntry })
|
||||
|
||||
try {
|
||||
await suggestion.action()
|
||||
} catch (error) {
|
||||
console.error('Command execution failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: CommandType): string => {
|
||||
switch (type) {
|
||||
case 'NAVIGATION':
|
||||
return 'Navigation'
|
||||
case 'ACTION':
|
||||
return 'Aktion'
|
||||
case 'SEARCH':
|
||||
return 'Suche'
|
||||
case 'GENERATE':
|
||||
return 'Generieren'
|
||||
case 'HELP':
|
||||
return 'Hilfe'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative min-h-screen flex items-start justify-center pt-[15vh] px-4">
|
||||
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Search Input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-200">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Suchen oder Befehl eingeben..."
|
||||
className="flex-1 text-lg bg-transparent border-none outline-none placeholder-gray-400"
|
||||
/>
|
||||
{isLoading && (
|
||||
<svg className="animate-spin w-5 h-5 text-purple-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<kbd className="px-2 py-1 text-xs text-gray-400 bg-gray-100 rounded">ESC</kbd>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
<div className="max-h-96 overflow-y-auto py-2">
|
||||
{suggestions.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-gray-500">
|
||||
Keine Ergebnisse für "{query}"
|
||||
</div>
|
||||
) : (
|
||||
suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
onClick={() => executeSuggestion(suggestion)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-purple-50 text-purple-900'
|
||||
: 'text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
index === selectedIndex
|
||||
? 'bg-purple-100 text-purple-600'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{suggestion.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{suggestion.label}</div>
|
||||
<div className="text-sm text-gray-500 truncate">{suggestion.description}</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
suggestion.type === 'NAVIGATION'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: suggestion.type === 'ACTION'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: suggestion.type === 'GENERATE'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{getTypeLabel(suggestion.type)}
|
||||
</span>
|
||||
{suggestion.shortcut && (
|
||||
<kbd className="px-1.5 py-0.5 text-xs bg-gray-100 rounded">
|
||||
{suggestion.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-t border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-gray-200 rounded">↑</kbd>
|
||||
<kbd className="px-1.5 py-0.5 bg-gray-200 rounded">↓</kbd>
|
||||
navigieren
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-gray-200 rounded">↵</kbd>
|
||||
auswählen
|
||||
</span>
|
||||
</div>
|
||||
<span>AI Compliance SDK v1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
admin-compliance/components/sdk/CommandBar/index.ts
Normal file
1
admin-compliance/components/sdk/CommandBar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CommandBar } from './CommandBar'
|
||||
485
admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx
Normal file
485
admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CustomerTypeSelector } from './CustomerTypeSelector'
|
||||
export { default } from './CustomerTypeSelector'
|
||||
@@ -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
|
||||
1
admin-compliance/components/sdk/DocumentUpload/index.ts
Normal file
1
admin-compliance/components/sdk/DocumentUpload/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DocumentUploadSection, type DocumentUploadSectionProps, type UploadedDocument, type ExtractedContent, type ExtractedSection } from './DocumentUploadSection'
|
||||
300
admin-compliance/components/sdk/DraftEditor.tsx
Normal file
300
admin-compliance/components/sdk/DraftEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
417
admin-compliance/components/sdk/DraftingEngineWidget.tsx
Normal file
417
admin-compliance/components/sdk/DraftingEngineWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
215
admin-compliance/components/sdk/Layout/SDKLayout.tsx
Normal file
215
admin-compliance/components/sdk/Layout/SDKLayout.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { SDKSidebar } from '../Sidebar'
|
||||
import { CommandBar } from '../CommandBar'
|
||||
import { SDKPipelineSidebar } from '../SDKPipelineSidebar'
|
||||
|
||||
// =============================================================================
|
||||
// SDK HEADER
|
||||
// =============================================================================
|
||||
|
||||
function SDKHeader() {
|
||||
const { currentStep, setCommandBarOpen, completionPercentage } = useSDK()
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 bg-white border-b border-gray-200">
|
||||
<div className="flex items-center justify-between px-6 py-3">
|
||||
{/* Breadcrumb / Current Step */}
|
||||
<div className="flex items-center gap-3">
|
||||
<nav className="flex items-center text-sm text-gray-500">
|
||||
<span>SDK</span>
|
||||
<svg className="w-4 h-4 mx-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-gray-900 font-medium">
|
||||
{currentStep?.name || 'Dashboard'}
|
||||
</span>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Command Bar Trigger */}
|
||||
<button
|
||||
onClick={() => setCommandBarOpen(true)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-500 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Suchen...</span>
|
||||
<kbd className="ml-2 px-1.5 py-0.5 text-xs bg-gray-200 rounded">⌘K</kbd>
|
||||
</button>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full transition-all duration-500"
|
||||
style={{ width: `${completionPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-600">{completionPercentage}%</span>
|
||||
</div>
|
||||
|
||||
{/* Help Button */}
|
||||
<button className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NAVIGATION FOOTER
|
||||
// =============================================================================
|
||||
|
||||
function NavigationFooter() {
|
||||
const { goToNextStep, goToPreviousStep, canGoNext, canGoPrevious, currentStep, validateCheckpoint } = useSDK()
|
||||
const [isValidating, setIsValidating] = React.useState(false)
|
||||
|
||||
const handleNext = async () => {
|
||||
if (!currentStep) return
|
||||
|
||||
setIsValidating(true)
|
||||
try {
|
||||
const status = await validateCheckpoint(currentStep.checkpointId)
|
||||
if (status.passed) {
|
||||
goToNextStep()
|
||||
} else {
|
||||
// Show error notification (in production, use toast)
|
||||
console.error('Checkpoint validation failed:', status.errors)
|
||||
}
|
||||
} finally {
|
||||
setIsValidating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="sticky bottom-0 bg-white border-t border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Previous Button */}
|
||||
<button
|
||||
onClick={goToPreviousStep}
|
||||
disabled={!canGoPrevious}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
canGoPrevious
|
||||
? 'text-gray-700 hover:bg-gray-100'
|
||||
: 'text-gray-300 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Zurück</span>
|
||||
</button>
|
||||
|
||||
{/* Current Step Info */}
|
||||
<div className="text-sm text-gray-500">
|
||||
Schritt {currentStep?.order || 1} von {currentStep?.phase === 1 ? 8 : 11}
|
||||
</div>
|
||||
|
||||
{/* Next / Complete Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={goToNextStep}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Überspringen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!canGoNext || isValidating}
|
||||
className={`flex items-center gap-2 px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
canGoNext && !isValidating
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Validiere...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Weiter</span>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN LAYOUT
|
||||
// =============================================================================
|
||||
|
||||
interface SDKLayoutProps {
|
||||
children: React.ReactNode
|
||||
showNavigation?: boolean
|
||||
}
|
||||
|
||||
export function SDKLayout({ children, showNavigation = true }: SDKLayoutProps) {
|
||||
const { isCommandBarOpen, setCommandBarOpen } = useSDK()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Sidebar */}
|
||||
<SDKSidebar />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="ml-64 flex flex-col min-h-screen">
|
||||
{/* Header */}
|
||||
<SDKHeader />
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
|
||||
{/* Navigation Footer */}
|
||||
{showNavigation && <NavigationFooter />}
|
||||
</div>
|
||||
|
||||
{/* Command Bar Modal */}
|
||||
{isCommandBarOpen && <CommandBar onClose={() => setCommandBarOpen(false)} />}
|
||||
|
||||
{/* Pipeline Sidebar (FAB on mobile, fixed on desktop) */}
|
||||
<SDKPipelineSidebar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
admin-compliance/components/sdk/Layout/index.ts
Normal file
1
admin-compliance/components/sdk/Layout/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SDKLayout } from './SDKLayout'
|
||||
@@ -0,0 +1,540 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* SDK Pipeline Sidebar
|
||||
*
|
||||
* Floating Action Button mit Drawer zur Visualisierung der SDK-Pipeline.
|
||||
* Zeigt die 5 Pakete mit Fortschritt und ermoeglicht schnelle Navigation.
|
||||
*
|
||||
* Features:
|
||||
* - Desktop (xl+): Fixierte Sidebar rechts
|
||||
* - Mobile/Tablet: Floating Action Button mit Slide-In Drawer
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useSDK, SDK_STEPS, SDK_PACKAGES, getStepsForPackage, type SDKStep, type SDKPackageId } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
// ICONS
|
||||
// =============================================================================
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const LockIcon = () => (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ArrowIcon = () => (
|
||||
<svg className="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CloseIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PipelineIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// STEP ITEM
|
||||
// =============================================================================
|
||||
|
||||
interface StepItemProps {
|
||||
step: SDKStep
|
||||
isActive: boolean
|
||||
isCompleted: boolean
|
||||
onNavigate: () => void
|
||||
}
|
||||
|
||||
function StepItem({ step, isActive, isCompleted, onNavigate }: StepItemProps) {
|
||||
return (
|
||||
<Link
|
||||
href={step.url}
|
||||
onClick={onNavigate}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-all ${
|
||||
isActive
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 font-medium'
|
||||
: isCompleted
|
||||
? 'text-green-600 dark:text-green-400 hover:bg-slate-100 dark:hover:bg-gray-800'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 text-sm truncate">{step.nameShort}</span>
|
||||
{isCompleted && !isActive && (
|
||||
<span className="flex-shrink-0 w-4 h-4 bg-green-500 text-white rounded-full flex items-center justify-center">
|
||||
<CheckIcon />
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="flex-shrink-0 w-2 h-2 bg-purple-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PACKAGE SECTION
|
||||
// =============================================================================
|
||||
|
||||
interface PackageSectionProps {
|
||||
pkg: (typeof SDK_PACKAGES)[number]
|
||||
steps: SDKStep[]
|
||||
completion: number
|
||||
currentStepId: string
|
||||
completedSteps: string[]
|
||||
isLocked: boolean
|
||||
onNavigate: () => void
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
function PackageSection({
|
||||
pkg,
|
||||
steps,
|
||||
completion,
|
||||
currentStepId,
|
||||
completedSteps,
|
||||
isLocked,
|
||||
onNavigate,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: PackageSectionProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Package Header */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
disabled={isLocked}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${
|
||||
isLocked
|
||||
? 'bg-slate-100 dark:bg-gray-800 opacity-50 cursor-not-allowed'
|
||||
: 'bg-slate-50 dark:bg-gray-800 hover:bg-slate-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center text-sm ${
|
||||
isLocked
|
||||
? 'bg-gray-200 text-gray-400'
|
||||
: completion === 100
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-purple-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{isLocked ? <LockIcon /> : completion === 100 ? <CheckIcon /> : pkg.icon}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className={`text-sm font-medium ${isLocked ? 'text-slate-400' : 'text-slate-700 dark:text-slate-200'}`}>
|
||||
{pkg.order}. {pkg.nameShort}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">{completion}%</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isLocked && (
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{!isLocked && (
|
||||
<div className="px-3">
|
||||
<div className="h-1.5 bg-slate-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
completion === 100 ? 'bg-green-500' : 'bg-purple-600'
|
||||
}`}
|
||||
style={{ width: `${completion}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steps List */}
|
||||
{isExpanded && !isLocked && (
|
||||
<div className="space-y-1 pl-2">
|
||||
{steps.map(step => (
|
||||
<StepItem
|
||||
key={step.id}
|
||||
step={step}
|
||||
isActive={currentStepId === step.id}
|
||||
isCompleted={completedSteps.includes(step.id)}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PIPELINE FLOW VISUALIZATION
|
||||
// =============================================================================
|
||||
|
||||
function PipelineFlow() {
|
||||
return (
|
||||
<div className="pt-3 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
|
||||
Datenfluss
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 dark:bg-gray-900 rounded-lg">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{SDK_PACKAGES.map((pkg, idx) => (
|
||||
<div key={pkg.id} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-5 h-5 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
{pkg.icon}
|
||||
</span>
|
||||
<span className="text-slate-600 dark:text-slate-400 flex-1">{pkg.nameShort}</span>
|
||||
{idx < SDK_PACKAGES.length - 1 && <ArrowIcon />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SIDEBAR CONTENT
|
||||
// =============================================================================
|
||||
|
||||
interface SidebarContentProps {
|
||||
onNavigate: () => void
|
||||
}
|
||||
|
||||
function SidebarContent({ onNavigate }: SidebarContentProps) {
|
||||
const pathname = usePathname()
|
||||
const { state, packageCompletion } = useSDK()
|
||||
const [expandedPackages, setExpandedPackages] = useState<Record<SDKPackageId, boolean>>({
|
||||
'vorbereitung': true,
|
||||
'analyse': false,
|
||||
'dokumentation': false,
|
||||
'rechtliche-texte': false,
|
||||
'betrieb': false,
|
||||
})
|
||||
|
||||
// Find current step
|
||||
const currentStep = SDK_STEPS.find(s => s.url === pathname)
|
||||
const currentStepId = currentStep?.id || ''
|
||||
|
||||
// Auto-expand current package
|
||||
useEffect(() => {
|
||||
if (currentStep) {
|
||||
setExpandedPackages(prev => ({
|
||||
...prev,
|
||||
[currentStep.package]: true,
|
||||
}))
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
const togglePackage = (packageId: SDKPackageId) => {
|
||||
setExpandedPackages(prev => ({ ...prev, [packageId]: !prev[packageId] }))
|
||||
}
|
||||
|
||||
const isPackageLocked = (packageId: SDKPackageId): boolean => {
|
||||
if (state.preferences?.allowParallelWork) return false
|
||||
const pkg = SDK_PACKAGES.find(p => p.id === packageId)
|
||||
if (!pkg || pkg.order === 1) return false
|
||||
|
||||
const prevPkg = SDK_PACKAGES.find(p => p.order === pkg.order - 1)
|
||||
if (!prevPkg) return false
|
||||
|
||||
return packageCompletion[prevPkg.id] < 100
|
||||
}
|
||||
|
||||
// Get visible steps based on customer type
|
||||
const getVisibleSteps = (packageId: SDKPackageId): SDKStep[] => {
|
||||
const steps = getStepsForPackage(packageId)
|
||||
return steps.filter(step => {
|
||||
if (step.id === 'import' && state.customerType === 'new') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Packages */}
|
||||
{SDK_PACKAGES.map(pkg => (
|
||||
<PackageSection
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
steps={getVisibleSteps(pkg.id)}
|
||||
completion={packageCompletion[pkg.id]}
|
||||
currentStepId={currentStepId}
|
||||
completedSteps={state.completedSteps}
|
||||
isLocked={isPackageLocked(pkg.id)}
|
||||
onNavigate={onNavigate}
|
||||
isExpanded={expandedPackages[pkg.id]}
|
||||
onToggle={() => togglePackage(pkg.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Pipeline Flow */}
|
||||
<PipelineFlow />
|
||||
|
||||
{/* Quick Info */}
|
||||
{currentStep && (
|
||||
<div className="pt-3 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-lg">
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong>{' '}
|
||||
{currentStep.description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT - RESPONSIVE
|
||||
// =============================================================================
|
||||
|
||||
export interface SDKPipelineSidebarProps {
|
||||
/** Position des FAB auf Mobile */
|
||||
fabPosition?: 'bottom-right' | 'bottom-left'
|
||||
}
|
||||
|
||||
export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipelineSidebarProps) {
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||
const [isDesktopCollapsed, setIsDesktopCollapsed] = useState(true) // Start collapsed
|
||||
const { completionPercentage } = useSDK()
|
||||
|
||||
// Load collapsed state from localStorage
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('sdk-pipeline-sidebar-collapsed')
|
||||
if (stored !== null) {
|
||||
setIsDesktopCollapsed(stored === 'true')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save collapsed state to localStorage
|
||||
const toggleDesktopSidebar = () => {
|
||||
const newState = !isDesktopCollapsed
|
||||
setIsDesktopCollapsed(newState)
|
||||
localStorage.setItem('sdk-pipeline-sidebar-collapsed', String(newState))
|
||||
}
|
||||
|
||||
// Close drawer on route change or escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsMobileOpen(false)
|
||||
setIsDesktopCollapsed(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [])
|
||||
|
||||
// Prevent body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (isMobileOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isMobileOpen])
|
||||
|
||||
const fabPositionClasses = fabPosition === 'bottom-right'
|
||||
? 'right-4 bottom-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
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SDKPipelineSidebar } from './SDKPipelineSidebar'
|
||||
export type { SDKPipelineSidebarProps } from './SDKPipelineSidebar'
|
||||
551
admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx
Normal file
551
admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
admin-compliance/components/sdk/Sidebar/index.ts
Normal file
1
admin-compliance/components/sdk/Sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SDKSidebar } from './SDKSidebar'
|
||||
786
admin-compliance/components/sdk/StepHeader/StepHeader.tsx
Normal file
786
admin-compliance/components/sdk/StepHeader/StepHeader.tsx
Normal 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
|
||||
2
admin-compliance/components/sdk/StepHeader/index.ts
Normal file
2
admin-compliance/components/sdk/StepHeader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { StepHeader, STEP_EXPLANATIONS } from './StepHeader'
|
||||
export type { StepTip } from './StepHeader'
|
||||
220
admin-compliance/components/sdk/ValidationReport.tsx
Normal file
220
admin-compliance/components/sdk/ValidationReport.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
admin-compliance/components/sdk/__tests__/StepHeader.test.tsx
Normal file
127
admin-compliance/components/sdk/__tests__/StepHeader.test.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { STEP_EXPLANATIONS } from '../StepHeader'
|
||||
|
||||
// Focus on testing the STEP_EXPLANATIONS data structure
|
||||
// Component tests require more complex SDK context mocking
|
||||
|
||||
describe('STEP_EXPLANATIONS', () => {
|
||||
it('should have explanations for all Phase 1 steps', () => {
|
||||
const phase1Steps = [
|
||||
'use-case-workshop',
|
||||
'screening',
|
||||
'modules',
|
||||
'requirements',
|
||||
'controls',
|
||||
'evidence',
|
||||
'audit-checklist',
|
||||
'risks',
|
||||
]
|
||||
|
||||
phase1Steps.forEach(stepId => {
|
||||
expect(STEP_EXPLANATIONS[stepId]).toBeDefined()
|
||||
expect(STEP_EXPLANATIONS[stepId].title).toBeDefined()
|
||||
expect(STEP_EXPLANATIONS[stepId].title.length).toBeGreaterThan(0)
|
||||
expect(STEP_EXPLANATIONS[stepId].description).toBeDefined()
|
||||
expect(STEP_EXPLANATIONS[stepId].description.length).toBeGreaterThan(0)
|
||||
expect(STEP_EXPLANATIONS[stepId].explanation).toBeDefined()
|
||||
expect(STEP_EXPLANATIONS[stepId].explanation.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should have explanations for all Phase 2 steps', () => {
|
||||
const phase2Steps = [
|
||||
'ai-act',
|
||||
'obligations',
|
||||
'dsfa',
|
||||
'tom',
|
||||
'loeschfristen',
|
||||
'vvt',
|
||||
'consent',
|
||||
'cookie-banner',
|
||||
'einwilligungen',
|
||||
'dsr',
|
||||
'escalations',
|
||||
]
|
||||
|
||||
phase2Steps.forEach(stepId => {
|
||||
expect(STEP_EXPLANATIONS[stepId]).toBeDefined()
|
||||
expect(STEP_EXPLANATIONS[stepId].title).toBeDefined()
|
||||
expect(STEP_EXPLANATIONS[stepId].description).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should have tips array for each step explanation', () => {
|
||||
Object.entries(STEP_EXPLANATIONS).forEach(([stepId, explanation]) => {
|
||||
expect(explanation.tips).toBeDefined()
|
||||
expect(Array.isArray(explanation.tips)).toBe(true)
|
||||
expect(explanation.tips.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should have valid tip icons', () => {
|
||||
const validIcons = ['info', 'warning', 'success', 'lightbulb']
|
||||
|
||||
Object.entries(STEP_EXPLANATIONS).forEach(([stepId, explanation]) => {
|
||||
explanation.tips.forEach((tip, tipIndex) => {
|
||||
expect(validIcons).toContain(tip.icon)
|
||||
expect(tip.title).toBeDefined()
|
||||
expect(tip.title.length).toBeGreaterThan(0)
|
||||
expect(tip.description).toBeDefined()
|
||||
expect(tip.description.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should have a use-case-workshop explanation', () => {
|
||||
const ucWorkshop = STEP_EXPLANATIONS['use-case-workshop']
|
||||
|
||||
expect(ucWorkshop.title).toContain('Use Case')
|
||||
expect(ucWorkshop.tips.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should have a risks explanation', () => {
|
||||
const risks = STEP_EXPLANATIONS['risks']
|
||||
|
||||
expect(risks.title).toBeDefined()
|
||||
expect(risks.explanation).toContain('Risiko')
|
||||
})
|
||||
|
||||
it('should have a dsfa explanation', () => {
|
||||
const dsfa = STEP_EXPLANATIONS['dsfa']
|
||||
|
||||
expect(dsfa.title).toContain('Datenschutz')
|
||||
expect(dsfa.explanation.length).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('should cover all 19 SDK steps', () => {
|
||||
const allStepIds = [
|
||||
// Phase 1
|
||||
'use-case-workshop',
|
||||
'screening',
|
||||
'modules',
|
||||
'requirements',
|
||||
'controls',
|
||||
'evidence',
|
||||
'audit-checklist',
|
||||
'risks',
|
||||
// Phase 2
|
||||
'ai-act',
|
||||
'obligations',
|
||||
'dsfa',
|
||||
'tom',
|
||||
'loeschfristen',
|
||||
'vvt',
|
||||
'consent',
|
||||
'cookie-banner',
|
||||
'einwilligungen',
|
||||
'dsr',
|
||||
'escalations',
|
||||
]
|
||||
|
||||
expect(Object.keys(STEP_EXPLANATIONS).length).toBe(allStepIds.length)
|
||||
|
||||
allStepIds.forEach(stepId => {
|
||||
expect(STEP_EXPLANATIONS[stepId]).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ScopeOverviewTab } from './ScopeOverviewTab'
|
||||
export { ScopeWizardTab } from './ScopeWizardTab'
|
||||
export { ScopeDecisionTab } from './ScopeDecisionTab'
|
||||
export { ScopeExportTab } from './ScopeExportTab'
|
||||
372
admin-compliance/components/sdk/dsfa/Art36Warning.tsx
Normal file
372
admin-compliance/components/sdk/dsfa/Art36Warning.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
268
admin-compliance/components/sdk/dsfa/DSFASidebar.tsx
Normal file
268
admin-compliance/components/sdk/dsfa/DSFASidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
323
admin-compliance/components/sdk/dsfa/ReviewScheduleSection.tsx
Normal file
323
admin-compliance/components/sdk/dsfa/ReviewScheduleSection.tsx
Normal 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">
|
||||
"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."
|
||||
</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>
|
||||
)
|
||||
}
|
||||
320
admin-compliance/components/sdk/dsfa/SourceAttribution.tsx
Normal file
320
admin-compliance/components/sdk/dsfa/SourceAttribution.tsx
Normal 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
|
||||
@@ -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">
|
||||
"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."
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
222
admin-compliance/components/sdk/dsfa/index.ts
Normal file
222
admin-compliance/components/sdk/dsfa/index.ts
Normal 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')
|
||||
)
|
||||
)
|
||||
}
|
||||
288
admin-compliance/components/sdk/dsr/DSRCommunicationLog.tsx
Normal file
288
admin-compliance/components/sdk/dsr/DSRCommunicationLog.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
DSRCommunication,
|
||||
CommunicationType,
|
||||
CommunicationChannel,
|
||||
DSRSendCommunicationRequest
|
||||
} from '@/lib/sdk/dsr/types'
|
||||
|
||||
interface DSRCommunicationLogProps {
|
||||
communications: DSRCommunication[]
|
||||
onSendMessage?: (message: DSRSendCommunicationRequest) => Promise<void>
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const CHANNEL_ICONS: Record<CommunicationChannel, string> = {
|
||||
email: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
|
||||
letter: 'M3 19v-8.93a2 2 0 01.89-1.664l7-4.666a2 2 0 012.22 0l7 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5',
|
||||
phone: 'M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z',
|
||||
portal: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||
internal_note: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z'
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<CommunicationType, { bg: string; border: string; icon: string }> = {
|
||||
incoming: { bg: 'bg-blue-50', border: 'border-blue-200', icon: 'text-blue-600' },
|
||||
outgoing: { bg: 'bg-green-50', border: 'border-green-200', icon: 'text-green-600' },
|
||||
internal: { bg: 'bg-gray-50', border: 'border-gray-200', icon: 'text-gray-600' }
|
||||
}
|
||||
|
||||
const CHANNEL_LABELS: Record<CommunicationChannel, string> = {
|
||||
email: 'E-Mail',
|
||||
letter: 'Brief',
|
||||
phone: 'Telefon',
|
||||
portal: 'Portal',
|
||||
internal_note: 'Interne Notiz'
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function DSRCommunicationLog({
|
||||
communications,
|
||||
onSendMessage,
|
||||
isLoading = false
|
||||
}: DSRCommunicationLogProps) {
|
||||
const [showComposeForm, setShowComposeForm] = useState(false)
|
||||
const [newMessage, setNewMessage] = useState<DSRSendCommunicationRequest>({
|
||||
type: 'outgoing',
|
||||
channel: 'email',
|
||||
subject: '',
|
||||
content: ''
|
||||
})
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
|
||||
const sortedCommunications = [...communications].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!onSendMessage || !newMessage.content.trim()) return
|
||||
|
||||
setIsSending(true)
|
||||
try {
|
||||
await onSendMessage(newMessage)
|
||||
setNewMessage({ type: 'outgoing', channel: 'email', subject: '', content: '' })
|
||||
setShowComposeForm(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error)
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Kommunikation</h3>
|
||||
{onSendMessage && (
|
||||
<button
|
||||
onClick={() => setShowComposeForm(!showComposeForm)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Nachricht
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose Form */}
|
||||
{showComposeForm && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={newMessage.type}
|
||||
onChange={(e) => setNewMessage({ ...newMessage, type: e.target.value as CommunicationType })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="outgoing">Ausgehend</option>
|
||||
<option value="incoming">Eingehend</option>
|
||||
<option value="internal">Interne Notiz</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kanal</label>
|
||||
<select
|
||||
value={newMessage.channel}
|
||||
onChange={(e) => setNewMessage({ ...newMessage, channel: e.target.value as CommunicationChannel })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="email">E-Mail</option>
|
||||
<option value="letter">Brief</option>
|
||||
<option value="phone">Telefon</option>
|
||||
<option value="portal">Portal</option>
|
||||
<option value="internal_note">Interne Notiz</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newMessage.type !== 'internal' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betreff</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage.subject || ''}
|
||||
onChange={(e) => setNewMessage({ ...newMessage, subject: e.target.value })}
|
||||
placeholder="Betreff eingeben..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Inhalt</label>
|
||||
<textarea
|
||||
value={newMessage.content}
|
||||
onChange={(e) => setNewMessage({ ...newMessage, content: e.target.value })}
|
||||
placeholder="Nachricht eingeben..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowComposeForm(false)}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!newMessage.content.trim() || isSending}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg font-medium transition-colors
|
||||
${newMessage.content.trim() && !isSending
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSending ? 'Sende...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Communication Timeline */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<svg className="animate-spin w-6 h-6 text-purple-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : sortedCommunications.length === 0 ? (
|
||||
<div className="bg-gray-50 rounded-xl p-8 text-center">
|
||||
<div className="w-12 h-12 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500">Noch keine Kommunikation vorhanden</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
{/* Timeline Line */}
|
||||
<div className="absolute left-5 top-0 bottom-0 w-0.5 bg-gray-200" />
|
||||
|
||||
{/* Timeline Items */}
|
||||
<div className="space-y-4">
|
||||
{sortedCommunications.map((comm) => {
|
||||
const colors = TYPE_COLORS[comm.type]
|
||||
|
||||
return (
|
||||
<div key={comm.id} className="relative pl-12">
|
||||
{/* Timeline Dot */}
|
||||
<div className={`
|
||||
absolute left-3 w-5 h-5 rounded-full border-2 ${colors.border} ${colors.bg}
|
||||
flex items-center justify-center
|
||||
`}>
|
||||
<svg className={`w-3 h-3 ${colors.icon}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={CHANNEL_ICONS[comm.channel]} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Content Card */}
|
||||
<div className={`${colors.bg} border ${colors.border} rounded-xl p-4`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${colors.bg} ${colors.icon} border ${colors.border}`}>
|
||||
{comm.type === 'incoming' ? 'Eingehend' :
|
||||
comm.type === 'outgoing' ? 'Ausgehend' : 'Intern'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{CHANNEL_LABELS[comm.channel]}
|
||||
</span>
|
||||
</div>
|
||||
{comm.subject && (
|
||||
<div className="font-medium text-gray-900 mt-1">
|
||||
{comm.subject}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatDate(comm.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap">
|
||||
{comm.content}
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
{comm.attachments && comm.attachments.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="text-xs text-gray-500 mb-2">Anhaenge:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{comm.attachments.map((attachment, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={attachment.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 px-2 py-1 bg-white rounded border border-gray-200 text-xs text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
{attachment.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{comm.sentBy && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Von: {comm.sentBy}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
304
admin-compliance/components/sdk/dsr/DSRDataExport.tsx
Normal file
304
admin-compliance/components/sdk/dsr/DSRDataExport.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { DSRDataExport, DSRType } from '@/lib/sdk/dsr/types'
|
||||
|
||||
interface DSRDataExportProps {
|
||||
dsrId: string
|
||||
dsrType: DSRType // 'access' or 'portability'
|
||||
existingExport?: DSRDataExport
|
||||
onGenerate?: (format: 'json' | 'csv' | 'xml' | 'pdf') => Promise<void>
|
||||
onDownload?: () => Promise<void>
|
||||
isGenerating?: boolean
|
||||
}
|
||||
|
||||
const FORMAT_OPTIONS: {
|
||||
value: 'json' | 'csv' | 'xml' | 'pdf'
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
recommended?: boolean
|
||||
}[] = [
|
||||
{
|
||||
value: 'json',
|
||||
label: 'JSON',
|
||||
description: 'Maschinenlesbar, ideal fuer technische Uebertragung',
|
||||
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
value: 'csv',
|
||||
label: 'CSV',
|
||||
description: 'Tabellen-Format, mit Excel oeffenbar',
|
||||
icon: 'M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z'
|
||||
},
|
||||
{
|
||||
value: 'xml',
|
||||
label: 'XML',
|
||||
description: 'Strukturiertes Format fuer System-Integration',
|
||||
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4'
|
||||
},
|
||||
{
|
||||
value: 'pdf',
|
||||
label: 'PDF',
|
||||
description: 'Menschenlesbar, ideal fuer direkte Zusendung',
|
||||
icon: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z'
|
||||
}
|
||||
]
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function DSRDataExportComponent({
|
||||
dsrId,
|
||||
dsrType,
|
||||
existingExport,
|
||||
onGenerate,
|
||||
onDownload,
|
||||
isGenerating = false
|
||||
}: DSRDataExportProps) {
|
||||
const [selectedFormat, setSelectedFormat] = useState<'json' | 'csv' | 'xml' | 'pdf'>(
|
||||
dsrType === 'portability' ? 'json' : 'pdf'
|
||||
)
|
||||
const [includeThirdParty, setIncludeThirdParty] = useState(true)
|
||||
const [transferRecipient, setTransferRecipient] = useState('')
|
||||
const [showTransferSection, setShowTransferSection] = useState(false)
|
||||
|
||||
const isPortability = dsrType === 'portability'
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (onGenerate) {
|
||||
await onGenerate(selectedFormat)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{isPortability ? 'Datenexport (Art. 20)' : 'Datenauskunft (Art. 15)'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{isPortability
|
||||
? 'Exportieren Sie die Daten in einem maschinenlesbaren Format zur Uebertragung'
|
||||
: 'Erstellen Sie eine Uebersicht aller gespeicherten personenbezogenen Daten'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Existing Export */}
|
||||
{existingExport && existingExport.generatedAt && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-green-800">Export vorhanden</div>
|
||||
<div className="text-sm text-green-700 mt-1 space-y-1">
|
||||
<div>Format: <span className="font-medium">{existingExport.format.toUpperCase()}</span></div>
|
||||
<div>Erstellt: <span className="font-medium">{formatDate(existingExport.generatedAt)}</span></div>
|
||||
{existingExport.fileName && (
|
||||
<div>Datei: <span className="font-medium">{existingExport.fileName}</span></div>
|
||||
)}
|
||||
{existingExport.fileSize && (
|
||||
<div>Groesse: <span className="font-medium">{formatFileSize(existingExport.fileSize)}</span></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onDownload && (
|
||||
<button
|
||||
onClick={onDownload}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Herunterladen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Format Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Export-Format waehlen
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{FORMAT_OPTIONS.map(format => (
|
||||
<button
|
||||
key={format.value}
|
||||
onClick={() => setSelectedFormat(format.value)}
|
||||
className={`
|
||||
p-4 rounded-xl border-2 text-left transition-all
|
||||
${selectedFormat === format.value
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`
|
||||
w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0
|
||||
${selectedFormat === format.value ? 'bg-purple-100' : 'bg-gray-100'}
|
||||
`}>
|
||||
<svg
|
||||
className={`w-4 h-4 ${selectedFormat === format.value ? 'text-purple-600' : 'text-gray-500'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={format.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-medium ${selectedFormat === format.value ? 'text-purple-700' : 'text-gray-900'}`}>
|
||||
{format.label}
|
||||
</span>
|
||||
{format.recommended && isPortability && (
|
||||
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">
|
||||
Empfohlen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{format.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-4">
|
||||
{/* Include Third-Party Data */}
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeThirdParty}
|
||||
onChange={(e) => setIncludeThirdParty(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Drittanbieter-Daten einbeziehen</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Daten von externen Diensten (Google Analytics, etc.) mit exportieren
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Data Transfer (Art. 20 only) */}
|
||||
{isPortability && (
|
||||
<div className="pt-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTransferSection}
|
||||
onChange={(e) => setShowTransferSection(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Direkte Uebertragung an Dritten</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Daten direkt an einen anderen Verantwortlichen uebertragen
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{showTransferSection && (
|
||||
<div className="mt-3 ml-8 p-4 bg-gray-50 rounded-xl">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Empfaenger der Daten
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={transferRecipient}
|
||||
onChange={(e) => setTransferRecipient(e.target.value)}
|
||||
placeholder="Name des Unternehmens oder E-Mail-Adresse"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Die Daten werden an den angegebenen Empfaenger uebermittelt,
|
||||
sofern dies technisch machbar ist.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-blue-700">
|
||||
<p className="font-medium">Enthaltene Datenkategorien</p>
|
||||
<ul className="mt-2 space-y-1 list-disc list-inside">
|
||||
<li>Stammdaten (Name, E-Mail, Adresse)</li>
|
||||
<li>Nutzungsdaten (Login-Historie, Aktivitaeten)</li>
|
||||
<li>Kommunikationsdaten (E-Mails, Support-Anfragen)</li>
|
||||
{includeThirdParty && <li>Tracking-Daten (Analytics, Cookies)</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
{onGenerate && (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className={`
|
||||
px-6 py-2.5 rounded-lg font-medium transition-colors flex items-center gap-2
|
||||
${!isGenerating
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Export wird erstellt...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
{existingExport ? 'Neuen Export erstellen' : 'Export generieren'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
293
admin-compliance/components/sdk/dsr/DSRErasureChecklist.tsx
Normal file
293
admin-compliance/components/sdk/dsr/DSRErasureChecklist.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
DSRErasureChecklist,
|
||||
DSRErasureChecklistItem,
|
||||
ERASURE_EXCEPTIONS
|
||||
} from '@/lib/sdk/dsr/types'
|
||||
|
||||
interface DSRErasureChecklistProps {
|
||||
checklist?: DSRErasureChecklist
|
||||
onChange?: (checklist: DSRErasureChecklist) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export function DSRErasureChecklistComponent({
|
||||
checklist,
|
||||
onChange,
|
||||
readOnly = false
|
||||
}: DSRErasureChecklistProps) {
|
||||
const [localChecklist, setLocalChecklist] = useState<DSRErasureChecklist>(() => {
|
||||
if (checklist) return checklist
|
||||
return {
|
||||
items: ERASURE_EXCEPTIONS.map(exc => ({
|
||||
...exc,
|
||||
checked: false,
|
||||
applies: false
|
||||
})),
|
||||
canProceedWithErasure: true
|
||||
}
|
||||
})
|
||||
|
||||
const handleItemChange = (
|
||||
itemId: string,
|
||||
field: 'checked' | 'applies' | 'notes',
|
||||
value: boolean | string
|
||||
) => {
|
||||
const updatedItems = localChecklist.items.map(item => {
|
||||
if (item.id !== itemId) return item
|
||||
return { ...item, [field]: value }
|
||||
})
|
||||
|
||||
// Calculate if erasure can proceed (no exceptions apply)
|
||||
const canProceedWithErasure = !updatedItems.some(item => item.checked && item.applies)
|
||||
|
||||
const updatedChecklist: DSRErasureChecklist = {
|
||||
...localChecklist,
|
||||
items: updatedItems,
|
||||
canProceedWithErasure
|
||||
}
|
||||
|
||||
setLocalChecklist(updatedChecklist)
|
||||
onChange?.(updatedChecklist)
|
||||
}
|
||||
|
||||
const appliedExceptions = localChecklist.items.filter(item => item.checked && item.applies)
|
||||
const allChecked = localChecklist.items.every(item => item.checked)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Art. 17(3) Ausnahmen-Pruefung
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Pruefen Sie, ob eine der Ausnahmen zur Loeschung zutrifft
|
||||
</p>
|
||||
</div>
|
||||
{/* Status Badge */}
|
||||
<div className={`
|
||||
px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
${localChecklist.canProceedWithErasure
|
||||
? 'bg-green-100 text-green-700 border border-green-200'
|
||||
: 'bg-red-100 text-red-700 border border-red-200'
|
||||
}
|
||||
`}>
|
||||
{localChecklist.canProceedWithErasure
|
||||
? 'Loeschung moeglich'
|
||||
: `${appliedExceptions.length} Ausnahme(n)`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-blue-700">
|
||||
<p className="font-medium">Hinweis</p>
|
||||
<p className="mt-1">
|
||||
Nach Art. 17(3) DSGVO bestehen Ausnahmen vom Loeschungsanspruch.
|
||||
Pruefen Sie jeden Punkt und dokumentieren Sie, ob eine Ausnahme greift.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checklist Items */}
|
||||
<div className="space-y-3">
|
||||
{localChecklist.items.map((item, index) => (
|
||||
<ChecklistItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
readOnly={readOnly}
|
||||
onChange={(field, value) => handleItemChange(item.id, field, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{allChecked && (
|
||||
<div className={`
|
||||
rounded-xl p-4 border
|
||||
${localChecklist.canProceedWithErasure
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}
|
||||
`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0
|
||||
${localChecklist.canProceedWithErasure ? 'bg-green-100' : 'bg-red-100'}
|
||||
`}>
|
||||
{localChecklist.canProceedWithErasure ? (
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className={`font-medium ${localChecklist.canProceedWithErasure ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{localChecklist.canProceedWithErasure
|
||||
? 'Alle Ausnahmen geprueft - Loeschung kann durchgefuehrt werden'
|
||||
: 'Ausnahme(n) greifen - Loeschung nicht oder nur teilweise moeglich'
|
||||
}
|
||||
</div>
|
||||
{!localChecklist.canProceedWithErasure && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{appliedExceptions.map(exc => (
|
||||
<li key={exc.id} className="text-sm text-red-700">
|
||||
- {exc.article}: {exc.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Indicator */}
|
||||
{!allChecked && (
|
||||
<div className="text-sm text-gray-500 text-center">
|
||||
{localChecklist.items.filter(i => i.checked).length} von {localChecklist.items.length} Ausnahmen geprueft
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Individual Checklist Item Component
|
||||
function ChecklistItem({
|
||||
item,
|
||||
index,
|
||||
readOnly,
|
||||
onChange
|
||||
}: {
|
||||
item: DSRErasureChecklistItem
|
||||
index: number
|
||||
readOnly: boolean
|
||||
onChange: (field: 'checked' | 'applies' | 'notes', value: boolean | string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
rounded-xl border transition-all
|
||||
${item.checked
|
||||
? item.applies
|
||||
? 'border-red-200 bg-red-50'
|
||||
: 'border-green-200 bg-green-50'
|
||||
: 'border-gray-200 bg-white'
|
||||
}
|
||||
`}>
|
||||
{/* Main Row */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Checkbox */}
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onChange={(e) => onChange('checked', e.target.checked)}
|
||||
disabled={readOnly}
|
||||
className="w-5 h-5 rounded border-gray-300 text-purple-600 focus:ring-purple-500 disabled:opacity-50"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.article}
|
||||
</span>
|
||||
<span className="font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{item.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle Expand */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded"
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Applies Toggle - Show when checked */}
|
||||
{item.checked && (
|
||||
<div className="mt-3 ml-9 flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">Trifft diese Ausnahme zu?</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onChange('applies', false)}
|
||||
disabled={readOnly}
|
||||
className={`
|
||||
px-3 py-1 text-sm rounded-lg transition-colors
|
||||
${!item.applies
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}
|
||||
${readOnly ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
Nein
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChange('applies', true)}
|
||||
disabled={readOnly}
|
||||
className={`
|
||||
px-3 py-1 text-sm rounded-lg transition-colors
|
||||
${item.applies
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}
|
||||
${readOnly ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
Ja
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded Notes Section */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 pt-0 border-t border-gray-100">
|
||||
<div className="ml-9 mt-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notizen / Begruendung
|
||||
</label>
|
||||
<textarea
|
||||
value={item.notes || ''}
|
||||
onChange={(e) => onChange('notes', e.target.value)}
|
||||
disabled={readOnly}
|
||||
placeholder="Dokumentieren Sie Ihre Pruefung..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none disabled:bg-gray-50 disabled:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
263
admin-compliance/components/sdk/dsr/DSRIdentityModal.tsx
Normal file
263
admin-compliance/components/sdk/dsr/DSRIdentityModal.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
IdentityVerificationMethod,
|
||||
DSRVerifyIdentityRequest
|
||||
} from '@/lib/sdk/dsr/types'
|
||||
|
||||
interface DSRIdentityModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onVerify: (verification: DSRVerifyIdentityRequest) => Promise<void>
|
||||
requesterName: string
|
||||
requesterEmail: string
|
||||
}
|
||||
|
||||
const VERIFICATION_METHODS: {
|
||||
value: IdentityVerificationMethod
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
}[] = [
|
||||
{
|
||||
value: 'id_document',
|
||||
label: 'Ausweisdokument',
|
||||
description: 'Kopie von Personalausweis oder Reisepass',
|
||||
icon: 'M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2'
|
||||
},
|
||||
{
|
||||
value: 'email',
|
||||
label: 'E-Mail-Bestaetigung',
|
||||
description: 'Bestaetigung ueber verifizierte E-Mail-Adresse',
|
||||
icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z'
|
||||
},
|
||||
{
|
||||
value: 'existing_account',
|
||||
label: 'Bestehendes Konto',
|
||||
description: 'Anmeldung ueber bestehendes Kundenkonto',
|
||||
icon: 'M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
},
|
||||
{
|
||||
value: 'phone',
|
||||
label: 'Telefonische Bestaetigung',
|
||||
description: 'Verifizierung per Telefonanruf',
|
||||
icon: 'M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z'
|
||||
},
|
||||
{
|
||||
value: 'postal',
|
||||
label: 'Postalische Bestaetigung',
|
||||
description: 'Bestaetigung per Brief',
|
||||
icon: 'M3 19v-8.93a2 2 0 01.89-1.664l7-4.666a2 2 0 012.22 0l7 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5m0 0l-1.14.76a2 2 0 01-2.22 0l-1.14-.76'
|
||||
},
|
||||
{
|
||||
value: 'other',
|
||||
label: 'Sonstige Methode',
|
||||
description: 'Andere Verifizierungsmethode',
|
||||
icon: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
}
|
||||
]
|
||||
|
||||
export function DSRIdentityModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onVerify,
|
||||
requesterName,
|
||||
requesterEmail
|
||||
}: DSRIdentityModalProps) {
|
||||
const [selectedMethod, setSelectedMethod] = useState<IdentityVerificationMethod | null>(null)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [documentRef, setDocumentRef] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!selectedMethod) {
|
||||
setError('Bitte waehlen Sie eine Verifizierungsmethode')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onVerify({
|
||||
method: selectedMethod,
|
||||
notes: notes || undefined,
|
||||
documentRef: documentRef || undefined
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verifizierung fehlgeschlagen')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedMethod(null)
|
||||
setNotes('')
|
||||
setDocumentRef('')
|
||||
setError(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-2xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Identitaet verifizieren
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4 overflow-y-auto max-h-[60vh]">
|
||||
{/* Requester Info */}
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Antragsteller</div>
|
||||
<div className="font-medium text-gray-900">{requesterName}</div>
|
||||
<div className="text-sm text-gray-600">{requesterEmail}</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verification Methods */}
|
||||
<div className="space-y-2 mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Verifizierungsmethode
|
||||
</label>
|
||||
{VERIFICATION_METHODS.map(method => (
|
||||
<button
|
||||
key={method.value}
|
||||
onClick={() => setSelectedMethod(method.value)}
|
||||
className={`
|
||||
w-full flex items-start gap-3 p-3 rounded-xl border-2 text-left transition-all
|
||||
${selectedMethod === method.value
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`
|
||||
w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0
|
||||
${selectedMethod === method.value ? 'bg-purple-100' : 'bg-gray-100'}
|
||||
`}>
|
||||
<svg
|
||||
className={`w-5 h-5 ${selectedMethod === method.value ? 'text-purple-600' : 'text-gray-500'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={method.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-medium ${selectedMethod === method.value ? 'text-purple-700' : 'text-gray-900'}`}>
|
||||
{method.label}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{method.description}
|
||||
</div>
|
||||
</div>
|
||||
{selectedMethod === method.value && (
|
||||
<svg className="w-5 h-5 text-purple-600 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Document Reference */}
|
||||
{selectedMethod === 'id_document' && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Dokumentenreferenz (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={documentRef}
|
||||
onChange={(e) => setDocumentRef(e.target.value)}
|
||||
placeholder="z.B. Datei-ID oder Speicherort"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notizen (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Weitere Informationen zur Verifizierung..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleVerify}
|
||||
disabled={!selectedMethod || isLoading}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg font-medium transition-colors
|
||||
${selectedMethod && !isLoading
|
||||
? 'bg-green-600 text-white hover:bg-green-700'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Verifiziere...
|
||||
</span>
|
||||
) : (
|
||||
'Identitaet bestaetigen'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
176
admin-compliance/components/sdk/dsr/DSRWorkflowStepper.tsx
Normal file
176
admin-compliance/components/sdk/dsr/DSRWorkflowStepper.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { DSRStatus, DSR_STATUS_INFO } from '@/lib/sdk/dsr/types'
|
||||
|
||||
interface WorkflowStep {
|
||||
id: DSRStatus
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const WORKFLOW_STEPS: WorkflowStep[] = [
|
||||
{ id: 'intake', label: 'Eingang', description: 'Anfrage dokumentiert' },
|
||||
{ id: 'identity_verification', label: 'ID-Pruefung', description: 'Identitaet verifizieren' },
|
||||
{ id: 'processing', label: 'Bearbeitung', description: 'Anfrage bearbeiten' },
|
||||
{ id: 'completed', label: 'Abschluss', description: 'Antwort versenden' }
|
||||
]
|
||||
|
||||
interface DSRWorkflowStepperProps {
|
||||
currentStatus: DSRStatus
|
||||
onStepClick?: (status: DSRStatus) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DSRWorkflowStepper({
|
||||
currentStatus,
|
||||
onStepClick,
|
||||
className = ''
|
||||
}: DSRWorkflowStepperProps) {
|
||||
const currentIndex = WORKFLOW_STEPS.findIndex(s => s.id === currentStatus)
|
||||
const isRejectedOrCancelled = currentStatus === 'rejected' || currentStatus === 'cancelled'
|
||||
|
||||
const getStepState = (index: number): 'completed' | 'current' | 'upcoming' => {
|
||||
if (isRejectedOrCancelled) {
|
||||
return index <= currentIndex ? 'completed' : 'upcoming'
|
||||
}
|
||||
if (index < currentIndex) return 'completed'
|
||||
if (index === currentIndex) return 'current'
|
||||
return 'upcoming'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
{WORKFLOW_STEPS.map((step, index) => {
|
||||
const state = getStepState(index)
|
||||
const isLast = index === WORKFLOW_STEPS.length - 1
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.id}>
|
||||
{/* Step */}
|
||||
<div
|
||||
className={`flex flex-col items-center ${
|
||||
onStepClick && state !== 'upcoming' ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={() => onStepClick && state !== 'upcoming' && onStepClick(step.id)}
|
||||
>
|
||||
{/* Circle */}
|
||||
<div
|
||||
className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center font-medium text-sm
|
||||
transition-all duration-200
|
||||
${state === 'completed'
|
||||
? 'bg-green-500 text-white'
|
||||
: state === 'current'
|
||||
? 'bg-purple-600 text-white ring-4 ring-purple-100'
|
||||
: 'bg-gray-200 text-gray-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{state === 'completed' ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div className="mt-2 text-center">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
state === 'current' ? 'text-purple-600' :
|
||||
state === 'completed' ? 'text-green-600' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</div>
|
||||
{step.description && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 hidden sm:block">
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connector Line */}
|
||||
{!isLast && (
|
||||
<div
|
||||
className={`
|
||||
flex-1 h-1 mx-2 rounded-full
|
||||
${state === 'completed' || getStepState(index + 1) === 'completed' || getStepState(index + 1) === 'current'
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-200'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Rejected/Cancelled Badge */}
|
||||
{isRejectedOrCancelled && (
|
||||
<div className={`
|
||||
mt-4 px-4 py-2 rounded-lg text-center text-sm font-medium
|
||||
${currentStatus === 'rejected'
|
||||
? 'bg-red-100 text-red-700 border border-red-200'
|
||||
: 'bg-gray-100 text-gray-700 border border-gray-200'
|
||||
}
|
||||
`}>
|
||||
{currentStatus === 'rejected' ? 'Anfrage wurde abgelehnt' : 'Anfrage wurde storniert'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact version for list views
|
||||
export function DSRWorkflowStepperCompact({
|
||||
currentStatus,
|
||||
className = ''
|
||||
}: {
|
||||
currentStatus: DSRStatus
|
||||
className?: string
|
||||
}) {
|
||||
const statusInfo = DSR_STATUS_INFO[currentStatus]
|
||||
const currentIndex = WORKFLOW_STEPS.findIndex(s => s.id === currentStatus)
|
||||
const totalSteps = WORKFLOW_STEPS.length
|
||||
const isTerminal = currentStatus === 'rejected' || currentStatus === 'cancelled' || currentStatus === 'completed'
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{/* Mini progress dots */}
|
||||
<div className="flex items-center gap-1">
|
||||
{WORKFLOW_STEPS.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`
|
||||
w-2 h-2 rounded-full transition-all
|
||||
${index < currentIndex
|
||||
? 'bg-green-500'
|
||||
: index === currentIndex
|
||||
? isTerminal
|
||||
? currentStatus === 'completed'
|
||||
? 'bg-green-500'
|
||||
: currentStatus === 'rejected'
|
||||
? 'bg-red-500'
|
||||
: 'bg-gray-500'
|
||||
: 'bg-purple-500'
|
||||
: 'bg-gray-200'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status label */}
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
admin-compliance/components/sdk/dsr/index.ts
Normal file
9
admin-compliance/components/sdk/dsr/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* DSR Components Exports
|
||||
*/
|
||||
|
||||
export { DSRWorkflowStepper, DSRWorkflowStepperCompact } from './DSRWorkflowStepper'
|
||||
export { DSRIdentityModal } from './DSRIdentityModal'
|
||||
export { DSRCommunicationLog } from './DSRCommunicationLog'
|
||||
export { DSRErasureChecklistComponent } from './DSRErasureChecklist'
|
||||
export { DSRDataExportComponent } from './DSRDataExport'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,350 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* RetentionMatrix Component
|
||||
*
|
||||
* Visualisiert die Loeschfristen-Matrix nach Kategorien.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
Clock,
|
||||
Calendar,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
RetentionMatrixEntry,
|
||||
RetentionPeriod,
|
||||
DataPointCategory,
|
||||
SupportedLanguage,
|
||||
CATEGORY_METADATA,
|
||||
RETENTION_PERIOD_INFO,
|
||||
DataPoint,
|
||||
} from '@/lib/sdk/einwilligungen/types'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface RetentionMatrixProps {
|
||||
matrix: RetentionMatrixEntry[]
|
||||
dataPoints: DataPoint[]
|
||||
language?: SupportedLanguage
|
||||
showDetails?: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function getRetentionColor(period: RetentionPeriod): string {
|
||||
const days = RETENTION_PERIOD_INFO[period].days
|
||||
if (days === null) return 'bg-purple-100 text-purple-700'
|
||||
if (days <= 30) return 'bg-green-100 text-green-700'
|
||||
if (days <= 365) return 'bg-blue-100 text-blue-700'
|
||||
if (days <= 1095) return 'bg-amber-100 text-amber-700'
|
||||
return 'bg-red-100 text-red-700'
|
||||
}
|
||||
|
||||
function getRetentionBarWidth(period: RetentionPeriod): number {
|
||||
const days = RETENTION_PERIOD_INFO[period].days
|
||||
if (days === null) return 100
|
||||
const maxDays = 3650 // 10 Jahre
|
||||
return Math.min(100, (days / maxDays) * 100)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export function RetentionMatrix({
|
||||
matrix,
|
||||
dataPoints,
|
||||
language = 'de',
|
||||
showDetails = true,
|
||||
}: RetentionMatrixProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<DataPointCategory>>(new Set())
|
||||
|
||||
const toggleCategory = (category: DataPointCategory) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(category)) {
|
||||
next.delete(category)
|
||||
} else {
|
||||
next.add(category)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Group data points by category
|
||||
const dataPointsByCategory = useMemo(() => {
|
||||
const grouped = new Map<DataPointCategory, DataPoint[]>()
|
||||
for (const dp of dataPoints) {
|
||||
const existing = grouped.get(dp.category) || []
|
||||
grouped.set(dp.category, [...existing, dp])
|
||||
}
|
||||
return grouped
|
||||
}, [dataPoints])
|
||||
|
||||
// Stats
|
||||
const stats = useMemo(() => {
|
||||
const periodCounts: Record<string, number> = {}
|
||||
for (const dp of dataPoints) {
|
||||
periodCounts[dp.retentionPeriod] = (periodCounts[dp.retentionPeriod] || 0) + 1
|
||||
}
|
||||
return periodCounts
|
||||
}, [dataPoints])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-green-50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-green-600 mb-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Kurzfristig</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-700">
|
||||
{(stats['24_HOURS'] || 0) + (stats['30_DAYS'] || 0)}
|
||||
</div>
|
||||
<div className="text-xs text-green-600">≤ 30 Tage</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-blue-600 mb-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Mittelfristig</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-700">
|
||||
{(stats['90_DAYS'] || 0) + (stats['12_MONTHS'] || 0)}
|
||||
</div>
|
||||
<div className="text-xs text-blue-600">90 Tage - 12 Monate</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-amber-600 mb-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Langfristig</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-amber-700">
|
||||
{(stats['24_MONTHS'] || 0) + (stats['36_MONTHS'] || 0)}
|
||||
</div>
|
||||
<div className="text-xs text-amber-600">2-3 Jahre</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-red-600 mb-1">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Gesetzlich</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-red-700">
|
||||
{(stats['6_YEARS'] || 0) + (stats['10_YEARS'] || 0)}
|
||||
</div>
|
||||
<div className="text-xs text-red-600">6-10 Jahre (AO/HGB)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Matrix Table */}
|
||||
<div className="border border-slate-200 rounded-xl overflow-hidden bg-white">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-200">
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold text-slate-700">
|
||||
Kategorie
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold text-slate-700">
|
||||
Standard-Loeschfrist
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold text-slate-700 hidden md:table-cell">
|
||||
Rechtsgrundlage
|
||||
</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-semibold text-slate-700 w-24">
|
||||
Datenpunkte
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{matrix.map((entry) => {
|
||||
const meta = CATEGORY_METADATA[entry.category]
|
||||
const categoryDataPoints = dataPointsByCategory.get(entry.category) || []
|
||||
const isExpanded = expandedCategories.has(entry.category)
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
key={entry.category}
|
||||
className="hover:bg-slate-50 cursor-pointer transition-colors"
|
||||
onClick={() => showDetails && toggleCategory(entry.category)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{showDetails && (
|
||||
<div className="text-slate-400">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{meta.code}. {entry.categoryName[language]}
|
||||
</div>
|
||||
{entry.exceptions.length > 0 && (
|
||||
<div className="text-xs text-slate-500">
|
||||
{entry.exceptions.length} Ausnahme(n)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={`inline-flex px-2.5 py-1 text-xs font-medium rounded-full w-fit ${getRetentionColor(
|
||||
entry.standardPeriod
|
||||
)}`}
|
||||
>
|
||||
{RETENTION_PERIOD_INFO[entry.standardPeriod].label[language]}
|
||||
</span>
|
||||
<div className="h-1.5 bg-slate-100 rounded-full w-full max-w-[200px]">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
getRetentionColor(entry.standardPeriod).includes('green')
|
||||
? 'bg-green-400'
|
||||
: getRetentionColor(entry.standardPeriod).includes('blue')
|
||||
? 'bg-blue-400'
|
||||
: getRetentionColor(entry.standardPeriod).includes('amber')
|
||||
? 'bg-amber-400'
|
||||
: getRetentionColor(entry.standardPeriod).includes('red')
|
||||
? 'bg-red-400'
|
||||
: 'bg-purple-400'
|
||||
}`}
|
||||
style={{ width: `${getRetentionBarWidth(entry.standardPeriod)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600 hidden md:table-cell">
|
||||
{entry.legalBasis}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-slate-100 text-sm font-medium text-slate-700">
|
||||
{categoryDataPoints.length}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{showDetails && isExpanded && (
|
||||
<tr key={`${entry.category}-details`}>
|
||||
<td colSpan={4} className="bg-slate-50 px-4 py-4">
|
||||
<div className="space-y-4">
|
||||
{/* Exceptions */}
|
||||
{entry.exceptions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2 flex items-center gap-1">
|
||||
<Info className="w-4 h-4" />
|
||||
Ausnahmen von der Standardfrist
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{entry.exceptions.map((exc, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-3 p-3 bg-white rounded-lg border border-slate-200"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900">
|
||||
{exc.condition[language]}
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
Loeschfrist:{' '}
|
||||
<span className="font-medium">
|
||||
{RETENTION_PERIOD_INFO[exc.period].label[language]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
{exc.reason[language]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data Points in Category */}
|
||||
{categoryDataPoints.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">
|
||||
Datenpunkte in dieser Kategorie
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{categoryDataPoints.map((dp) => (
|
||||
<div
|
||||
key={dp.id}
|
||||
className="flex items-center justify-between p-2 bg-white rounded-lg border border-slate-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
|
||||
{dp.code}
|
||||
</span>
|
||||
<span className="text-sm text-slate-700">
|
||||
{dp.name[language]}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${getRetentionColor(
|
||||
dp.retentionPeriod
|
||||
)}`}
|
||||
>
|
||||
{RETENTION_PERIOD_INFO[dp.retentionPeriod].label[language]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-600">
|
||||
<span className="font-medium">Legende:</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-green-400" />
|
||||
≤ 30 Tage
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-400" />
|
||||
90 Tage - 12 Monate
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-amber-400" />
|
||||
2-3 Jahre
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||
6-10 Jahre
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-purple-400" />
|
||||
Variabel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RetentionMatrix
|
||||
9
admin-compliance/components/sdk/einwilligungen/index.ts
Normal file
9
admin-compliance/components/sdk/einwilligungen/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Einwilligungen Components
|
||||
*
|
||||
* UI-Komponenten fuer das Datenpunktkatalog & DSI-Generator Modul.
|
||||
*/
|
||||
|
||||
export { DataPointCatalog } from './DataPointCatalog'
|
||||
export { PrivacyPolicyPreview } from './PrivacyPolicyPreview'
|
||||
export { RetentionMatrix } from './RetentionMatrix'
|
||||
16
admin-compliance/components/sdk/index.ts
Normal file
16
admin-compliance/components/sdk/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* AI Compliance SDK - Components
|
||||
*/
|
||||
|
||||
// Layout
|
||||
export { SDKLayout } from './Layout'
|
||||
|
||||
// Sidebar
|
||||
export { SDKSidebar } from './Sidebar'
|
||||
|
||||
// Command Bar
|
||||
export { CommandBar } from './CommandBar'
|
||||
|
||||
// Document Upload
|
||||
export { DocumentUploadSection } from './DocumentUpload'
|
||||
export type { DocumentUploadSectionProps, UploadedDocument, ExtractedContent, ExtractedSection } from './DocumentUpload'
|
||||
413
admin-compliance/components/sdk/source-policy/AuditTab.tsx
Normal file
413
admin-compliance/components/sdk/source-policy/AuditTab.tsx
Normal 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">
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
562
admin-compliance/components/sdk/source-policy/PIIRulesTab.tsx
Normal file
562
admin-compliance/components/sdk/source-policy/PIIRulesTab.tsx
Normal 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">
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
525
admin-compliance/components/sdk/source-policy/SourcesTab.tsx
Normal file
525
admin-compliance/components/sdk/source-policy/SourcesTab.tsx
Normal 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">
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
376
admin-compliance/components/sdk/tom-dashboard/TOMEditorTab.tsx
Normal file
376
admin-compliance/components/sdk/tom-dashboard/TOMEditorTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
267
admin-compliance/components/sdk/tom-dashboard/TOMOverviewTab.tsx
Normal file
267
admin-compliance/components/sdk/tom-dashboard/TOMOverviewTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
admin-compliance/components/sdk/tom-dashboard/index.ts
Normal file
3
admin-compliance/components/sdk/tom-dashboard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { TOMOverviewTab } from './TOMOverviewTab'
|
||||
export { TOMEditorTab } from './TOMEditorTab'
|
||||
export { TOMGapExportTab } from './TOMGapExportTab'
|
||||
@@ -0,0 +1,286 @@
|
||||
'use client'
|
||||
|
||||
// =============================================================================
|
||||
// TOM Generator Wizard Component
|
||||
// Main wizard container with step navigation
|
||||
// =============================================================================
|
||||
|
||||
import React from 'react'
|
||||
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
|
||||
import { TOM_GENERATOR_STEPS, TOMGeneratorStepId } from '@/lib/sdk/tom-generator/types'
|
||||
|
||||
// =============================================================================
|
||||
// STEP INDICATOR
|
||||
// =============================================================================
|
||||
|
||||
interface StepIndicatorProps {
|
||||
stepId: TOMGeneratorStepId
|
||||
stepNumber: number
|
||||
title: string
|
||||
isActive: boolean
|
||||
isCompleted: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function StepIndicator({
|
||||
stepNumber,
|
||||
title,
|
||||
isActive,
|
||||
isCompleted,
|
||||
onClick,
|
||||
}: StepIndicatorProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg transition-all w-full text-left ${
|
||||
isActive
|
||||
? 'bg-blue-50 border-2 border-blue-500'
|
||||
: isCompleted
|
||||
? 'bg-green-50 border border-green-300 hover:bg-green-100'
|
||||
: 'bg-gray-50 border border-gray-200 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
isActive
|
||||
? 'bg-blue-500 text-white'
|
||||
: isCompleted
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-300 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
stepNumber
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isActive ? 'text-blue-700' : isCompleted ? 'text-green-700' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// WIZARD NAVIGATION
|
||||
// =============================================================================
|
||||
|
||||
interface WizardNavigationProps {
|
||||
onPrevious: () => void
|
||||
onNext: () => void
|
||||
onSave: () => void
|
||||
canGoPrevious: boolean
|
||||
canGoNext: boolean
|
||||
isLastStep: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
function WizardNavigation({
|
||||
onPrevious,
|
||||
onNext,
|
||||
onSave,
|
||||
canGoPrevious,
|
||||
canGoNext,
|
||||
isLastStep,
|
||||
isSaving,
|
||||
}: WizardNavigationProps) {
|
||||
return (
|
||||
<div className="flex justify-between items-center mt-8 pt-6 border-t">
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
disabled={!canGoPrevious}
|
||||
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 ${
|
||||
canGoPrevious
|
||||
? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
: 'bg-gray-50 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurück
|
||||
</button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 rounded-lg font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
)}
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!canGoNext && !isLastStep}
|
||||
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 ${
|
||||
canGoNext || isLastStep
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-blue-300 text-white cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isLastStep ? 'Abschließen' : 'Weiter'}
|
||||
{!isLastStep && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROGRESS BAR
|
||||
// =============================================================================
|
||||
|
||||
interface ProgressBarProps {
|
||||
percentage: number
|
||||
}
|
||||
|
||||
function ProgressBar({ percentage }: ProgressBarProps) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||
<span>Fortschritt</span>
|
||||
<span>{percentage}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-300"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN WIZARD COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
interface TOMGeneratorWizardProps {
|
||||
children: React.ReactNode
|
||||
showSidebar?: boolean
|
||||
showProgress?: boolean
|
||||
}
|
||||
|
||||
export function TOMGeneratorWizard({
|
||||
children,
|
||||
showSidebar = true,
|
||||
showProgress = true,
|
||||
}: TOMGeneratorWizardProps) {
|
||||
const {
|
||||
state,
|
||||
currentStepIndex,
|
||||
canGoNext,
|
||||
canGoPrevious,
|
||||
goToStep,
|
||||
goToNextStep,
|
||||
goToPreviousStep,
|
||||
isStepCompleted,
|
||||
getCompletionPercentage,
|
||||
saveState,
|
||||
isLoading,
|
||||
} = useTOMGenerator()
|
||||
|
||||
const [isSaving, setIsSaving] = React.useState(false)
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await saveState()
|
||||
} catch (error) {
|
||||
console.error('Failed to save:', error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isLastStep = currentStepIndex === TOM_GENERATOR_STEPS.length - 1
|
||||
|
||||
return (
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar */}
|
||||
{showSidebar && (
|
||||
<div className="w-72 flex-shrink-0">
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4 sticky top-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Wizard-Schritte</h3>
|
||||
|
||||
{showProgress && <ProgressBar percentage={getCompletionPercentage()} />}
|
||||
|
||||
<div className="space-y-2">
|
||||
{TOM_GENERATOR_STEPS.map((step, index) => (
|
||||
<StepIndicator
|
||||
key={step.id}
|
||||
stepId={step.id}
|
||||
stepNumber={index + 1}
|
||||
title={step.title.de}
|
||||
isActive={state.currentStep === step.id}
|
||||
isCompleted={isStepCompleted(step.id)}
|
||||
onClick={() => goToStep(step.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
{/* Step Header */}
|
||||
<div className="mb-6">
|
||||
<div className="text-sm text-blue-600 font-medium mb-1">
|
||||
Schritt {currentStepIndex + 1} von {TOM_GENERATOR_STEPS.length}
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{TOM_GENERATOR_STEPS[currentStepIndex].title.de}
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{TOM_GENERATOR_STEPS[currentStepIndex].description.de}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="min-h-[400px]">{children}</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<WizardNavigation
|
||||
onPrevious={goToPreviousStep}
|
||||
onNext={goToNextStep}
|
||||
onSave={handleSave}
|
||||
canGoPrevious={canGoPrevious}
|
||||
canGoNext={canGoNext}
|
||||
isLastStep={isLastStep}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXPORTS
|
||||
// =============================================================================
|
||||
|
||||
export { StepIndicator, WizardNavigation, ProgressBar }
|
||||
19
admin-compliance/components/sdk/tom-generator/index.ts
Normal file
19
admin-compliance/components/sdk/tom-generator/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// =============================================================================
|
||||
// TOM Generator Components - Public API
|
||||
// =============================================================================
|
||||
|
||||
// Main Wizard
|
||||
export {
|
||||
TOMGeneratorWizard,
|
||||
StepIndicator,
|
||||
WizardNavigation,
|
||||
ProgressBar,
|
||||
} from './TOMGeneratorWizard'
|
||||
|
||||
// Step Components
|
||||
export { ScopeRolesStep } from './steps/ScopeRolesStep'
|
||||
export { DataCategoriesStep } from './steps/DataCategoriesStep'
|
||||
export { ArchitectureStep } from './steps/ArchitectureStep'
|
||||
export { SecurityProfileStep } from './steps/SecurityProfileStep'
|
||||
export { RiskProtectionStep } from './steps/RiskProtectionStep'
|
||||
export { ReviewExportStep } from './steps/ReviewExportStep'
|
||||
@@ -0,0 +1,460 @@
|
||||
'use client'
|
||||
|
||||
// =============================================================================
|
||||
// Step 3: Architecture & Hosting
|
||||
// Hosting model, location, and provider configuration
|
||||
// =============================================================================
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
|
||||
import {
|
||||
ArchitectureProfile,
|
||||
HostingModel,
|
||||
HostingLocation,
|
||||
MultiTenancy,
|
||||
CloudProvider,
|
||||
} from '@/lib/sdk/tom-generator/types'
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const HOSTING_MODELS: { value: HostingModel; label: string; description: string; icon: string }[] = [
|
||||
{
|
||||
value: 'ON_PREMISE',
|
||||
label: 'On-Premise',
|
||||
description: 'Eigenes Rechenzentrum oder Co-Location',
|
||||
icon: '🏢',
|
||||
},
|
||||
{
|
||||
value: 'PRIVATE_CLOUD',
|
||||
label: 'Private Cloud',
|
||||
description: 'Dedizierte Cloud-Infrastruktur',
|
||||
icon: '☁️',
|
||||
},
|
||||
{
|
||||
value: 'PUBLIC_CLOUD',
|
||||
label: 'Public Cloud',
|
||||
description: 'AWS, Azure, GCP oder andere',
|
||||
icon: '🌐',
|
||||
},
|
||||
{
|
||||
value: 'HYBRID',
|
||||
label: 'Hybrid',
|
||||
description: 'Kombination aus On-Premise und Cloud',
|
||||
icon: '🔄',
|
||||
},
|
||||
]
|
||||
|
||||
const HOSTING_LOCATIONS: { value: HostingLocation; label: string; description: string }[] = [
|
||||
{ value: 'DE', label: 'Deutschland', description: 'Rechenzentrum in Deutschland' },
|
||||
{ value: 'EU', label: 'EU (nicht DE)', description: 'Innerhalb der EU, aber nicht in Deutschland' },
|
||||
{ value: 'EEA', label: 'EWR', description: 'Europäischer Wirtschaftsraum' },
|
||||
{ value: 'THIRD_COUNTRY_ADEQUATE', label: 'Drittland (Angemessenheit)', description: 'Mit Angemessenheitsbeschluss' },
|
||||
{ value: 'THIRD_COUNTRY', label: 'Drittland (andere)', description: 'Ohne Angemessenheitsbeschluss' },
|
||||
]
|
||||
|
||||
const MULTI_TENANCY_OPTIONS: { value: MultiTenancy; label: string; description: string }[] = [
|
||||
{ value: 'SINGLE_TENANT', label: 'Single-Tenant', description: 'Dedizierte Instanz pro Kunde' },
|
||||
{ value: 'MULTI_TENANT', label: 'Multi-Tenant', description: 'Geteilte Infrastruktur mit logischer Trennung' },
|
||||
{ value: 'DEDICATED', label: 'Dedicated', description: 'Dedizierte Hardware, aber gemeinsame Software' },
|
||||
]
|
||||
|
||||
const COMMON_CERTIFICATIONS = [
|
||||
'ISO 27001',
|
||||
'SOC 2 Type II',
|
||||
'C5',
|
||||
'TISAX',
|
||||
'PCI DSS',
|
||||
'HIPAA',
|
||||
'FedRAMP',
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export function ArchitectureStep() {
|
||||
const { state, setArchitectureProfile, completeCurrentStep } = useTOMGenerator()
|
||||
|
||||
const [formData, setFormData] = useState<Partial<ArchitectureProfile>>({
|
||||
hostingModel: 'PUBLIC_CLOUD',
|
||||
hostingLocation: 'EU',
|
||||
providers: [],
|
||||
multiTenancy: 'MULTI_TENANT',
|
||||
hasSubprocessors: false,
|
||||
subprocessorCount: 0,
|
||||
encryptionAtRest: false,
|
||||
encryptionInTransit: false,
|
||||
})
|
||||
|
||||
const [newProvider, setNewProvider] = useState<Partial<CloudProvider>>({
|
||||
name: '',
|
||||
location: 'EU',
|
||||
certifications: [],
|
||||
})
|
||||
const [certificationInput, setCertificationInput] = useState('')
|
||||
|
||||
// Load existing data
|
||||
useEffect(() => {
|
||||
if (state.architectureProfile) {
|
||||
setFormData(state.architectureProfile)
|
||||
}
|
||||
}, [state.architectureProfile])
|
||||
|
||||
// Handle provider addition
|
||||
const addProvider = () => {
|
||||
if (newProvider.name?.trim()) {
|
||||
const provider: CloudProvider = {
|
||||
name: newProvider.name.trim(),
|
||||
location: newProvider.location || 'EU',
|
||||
certifications: newProvider.certifications || [],
|
||||
}
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
providers: [...(prev.providers || []), provider],
|
||||
}))
|
||||
setNewProvider({ name: '', location: 'EU', certifications: [] })
|
||||
}
|
||||
}
|
||||
|
||||
const removeProvider = (index: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
providers: (prev.providers || []).filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle certification toggle
|
||||
const toggleCertification = (cert: string) => {
|
||||
setNewProvider((prev) => {
|
||||
const current = prev.certifications || []
|
||||
const updated = current.includes(cert)
|
||||
? current.filter((c) => c !== cert)
|
||||
: [...current, cert]
|
||||
return { ...prev, certifications: updated }
|
||||
})
|
||||
}
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const profile: ArchitectureProfile = {
|
||||
hostingModel: formData.hostingModel || 'PUBLIC_CLOUD',
|
||||
hostingLocation: formData.hostingLocation || 'EU',
|
||||
providers: formData.providers || [],
|
||||
multiTenancy: formData.multiTenancy || 'MULTI_TENANT',
|
||||
hasSubprocessors: formData.hasSubprocessors || false,
|
||||
subprocessorCount: formData.subprocessorCount || 0,
|
||||
encryptionAtRest: formData.encryptionAtRest || false,
|
||||
encryptionInTransit: formData.encryptionInTransit || false,
|
||||
}
|
||||
|
||||
setArchitectureProfile(profile)
|
||||
completeCurrentStep(profile)
|
||||
}
|
||||
|
||||
const showProviderSection = formData.hostingModel !== 'ON_PREMISE'
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Hosting Model */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Hosting-Modell</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Wie wird Ihre Infrastruktur betrieben?
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{HOSTING_MODELS.map((model) => (
|
||||
<label
|
||||
key={model.value}
|
||||
className={`relative flex items-start p-4 border rounded-lg cursor-pointer transition-all ${
|
||||
formData.hostingModel === model.value
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="hostingModel"
|
||||
value={model.value}
|
||||
checked={formData.hostingModel === model.value}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hostingModel: e.target.value as HostingModel }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-2xl mr-3">{model.icon}</span>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium text-gray-900">{model.label}</span>
|
||||
<p className="text-sm text-gray-500 mt-1">{model.description}</p>
|
||||
</div>
|
||||
{formData.hostingModel === model.value && (
|
||||
<svg className="w-5 h-5 text-blue-500 absolute top-3 right-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hosting Location */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Primärer Hosting-Standort</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Wo werden die Daten primär gespeichert?
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{HOSTING_LOCATIONS.map((location) => (
|
||||
<label
|
||||
key={location.value}
|
||||
className={`relative flex items-start p-3 border rounded-lg cursor-pointer transition-all ${
|
||||
formData.hostingLocation === location.value
|
||||
? location.value.startsWith('THIRD_COUNTRY')
|
||||
? 'border-amber-500 bg-amber-50'
|
||||
: 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="hostingLocation"
|
||||
value={location.value}
|
||||
checked={formData.hostingLocation === location.value}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hostingLocation: e.target.value as HostingLocation }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium text-gray-900">{location.label}</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{location.description}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{formData.hostingLocation?.startsWith('THIRD_COUNTRY') && (
|
||||
<div className="mt-4 bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Hinweis:</strong> Bei Hosting in Drittländern sind zusätzliche Garantien nach Art. 46 DSGVO
|
||||
erforderlich (z.B. Standardvertragsklauseln).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cloud Providers */}
|
||||
{showProviderSection && (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Cloud-Provider / Rechenzentren</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Fügen Sie Ihre genutzten Provider hinzu.
|
||||
</p>
|
||||
|
||||
{/* Existing providers */}
|
||||
{formData.providers && formData.providers.length > 0 && (
|
||||
<div className="space-y-2 mb-4">
|
||||
{formData.providers.map((provider, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 border rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900">{provider.name}</span>
|
||||
<span className="text-sm text-gray-500 ml-2">({provider.location})</span>
|
||||
{provider.certifications.length > 0 && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{provider.certifications.map((cert) => (
|
||||
<span
|
||||
key={cert}
|
||||
className="px-2 py-0.5 text-xs bg-green-100 text-green-800 rounded"
|
||||
>
|
||||
{cert}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeProvider(index)}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new provider */}
|
||||
<div className="border rounded-lg p-4 bg-gray-50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Provider-Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newProvider.name || ''}
|
||||
onChange={(e) => setNewProvider((prev) => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="z.B. AWS, Azure, Hetzner"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Standort
|
||||
</label>
|
||||
<select
|
||||
value={newProvider.location || 'EU'}
|
||||
onChange={(e) => setNewProvider((prev) => ({ ...prev, location: e.target.value as HostingLocation }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{HOSTING_LOCATIONS.map((loc) => (
|
||||
<option key={loc.value} value={loc.value}>{loc.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Zertifizierungen
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COMMON_CERTIFICATIONS.map((cert) => (
|
||||
<button
|
||||
key={cert}
|
||||
type="button"
|
||||
onClick={() => toggleCertification(cert)}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-all ${
|
||||
newProvider.certifications?.includes(cert)
|
||||
? 'bg-green-100 border-green-300 text-green-800'
|
||||
: 'bg-white border-gray-300 text-gray-600 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{cert}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addProvider}
|
||||
disabled={!newProvider.name?.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
Provider hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi-Tenancy */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Mandantentrennung</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Wie ist die Trennung zwischen verschiedenen Mandanten/Kunden umgesetzt?
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{MULTI_TENANCY_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`relative flex items-start p-4 border rounded-lg cursor-pointer transition-all ${
|
||||
formData.multiTenancy === option.value
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="multiTenancy"
|
||||
value={option.value}
|
||||
checked={formData.multiTenancy === option.value}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, multiTenancy: e.target.value as MultiTenancy }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium text-gray-900">{option.label}</span>
|
||||
<p className="text-sm text-gray-500 mt-1">{option.description}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subprocessors */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Unterauftragsverarbeiter</h3>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasSubprocessors || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hasSubprocessors: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-gray-700">
|
||||
Wir setzen Unterauftragsverarbeiter ein
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{formData.hasSubprocessors && (
|
||||
<div className="pl-8">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Anzahl der Unterauftragsverarbeiter
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.subprocessorCount || 0}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, subprocessorCount: parseInt(e.target.value) || 0 }))}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Encryption */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Verschlüsselung</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.encryptionAtRest || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, encryptionAtRest: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Verschlüsselung ruhender Daten (at rest)</span>
|
||||
<p className="text-sm text-gray-500">Daten werden verschlüsselt gespeichert</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.encryptionInTransit || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, encryptionInTransit: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Transportverschlüsselung (in transit)</span>
|
||||
<p className="text-sm text-gray-500">TLS/SSL für alle Datenübertragungen</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArchitectureStep
|
||||
@@ -0,0 +1,374 @@
|
||||
'use client'
|
||||
|
||||
// =============================================================================
|
||||
// Step 2: Data Categories
|
||||
// Data categories and data subjects selection
|
||||
// =============================================================================
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
|
||||
import {
|
||||
DataProfile,
|
||||
DataCategory,
|
||||
DataSubject,
|
||||
DataVolume,
|
||||
DATA_CATEGORIES_METADATA,
|
||||
DATA_SUBJECTS_METADATA,
|
||||
} from '@/lib/sdk/tom-generator/types'
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const DATA_VOLUMES: { value: DataVolume; label: string; description: string }[] = [
|
||||
{ value: 'LOW', label: 'Niedrig', description: '< 1.000 Datensätze' },
|
||||
{ value: 'MEDIUM', label: 'Mittel', description: '1.000 - 100.000 Datensätze' },
|
||||
{ value: 'HIGH', label: 'Hoch', description: '100.000 - 1 Mio. Datensätze' },
|
||||
{ value: 'VERY_HIGH', label: 'Sehr hoch', description: '> 1 Mio. Datensätze' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export function DataCategoriesStep() {
|
||||
const { state, setDataProfile, completeCurrentStep } = useTOMGenerator()
|
||||
|
||||
const [formData, setFormData] = useState<Partial<DataProfile>>({
|
||||
categories: [],
|
||||
subjects: [],
|
||||
hasSpecialCategories: false,
|
||||
processesMinors: false,
|
||||
dataVolume: 'MEDIUM',
|
||||
thirdCountryTransfers: false,
|
||||
thirdCountryList: [],
|
||||
})
|
||||
|
||||
const [thirdCountryInput, setThirdCountryInput] = useState('')
|
||||
|
||||
// Load existing data
|
||||
useEffect(() => {
|
||||
if (state.dataProfile) {
|
||||
setFormData(state.dataProfile)
|
||||
}
|
||||
}, [state.dataProfile])
|
||||
|
||||
// Check for special categories
|
||||
useEffect(() => {
|
||||
const hasSpecial = formData.categories?.some((cat) => {
|
||||
const meta = DATA_CATEGORIES_METADATA.find((m) => m.id === cat)
|
||||
return meta?.isSpecialCategory
|
||||
})
|
||||
setFormData((prev) => ({ ...prev, hasSpecialCategories: hasSpecial || false }))
|
||||
}, [formData.categories])
|
||||
|
||||
// Check for minors
|
||||
useEffect(() => {
|
||||
const hasMinors = formData.subjects?.includes('MINORS')
|
||||
setFormData((prev) => ({ ...prev, processesMinors: hasMinors || false }))
|
||||
}, [formData.subjects])
|
||||
|
||||
// Handle category toggle
|
||||
const toggleCategory = (category: DataCategory) => {
|
||||
setFormData((prev) => {
|
||||
const current = prev.categories || []
|
||||
const updated = current.includes(category)
|
||||
? current.filter((c) => c !== category)
|
||||
: [...current, category]
|
||||
return { ...prev, categories: updated }
|
||||
})
|
||||
}
|
||||
|
||||
// Handle subject toggle
|
||||
const toggleSubject = (subject: DataSubject) => {
|
||||
setFormData((prev) => {
|
||||
const current = prev.subjects || []
|
||||
const updated = current.includes(subject)
|
||||
? current.filter((s) => s !== subject)
|
||||
: [...current, subject]
|
||||
return { ...prev, subjects: updated }
|
||||
})
|
||||
}
|
||||
|
||||
// Handle third country addition
|
||||
const addThirdCountry = () => {
|
||||
if (thirdCountryInput.trim()) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
thirdCountryList: [...(prev.thirdCountryList || []), thirdCountryInput.trim()],
|
||||
}))
|
||||
setThirdCountryInput('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeThirdCountry = (index: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
thirdCountryList: (prev.thirdCountryList || []).filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const profile: DataProfile = {
|
||||
categories: formData.categories || [],
|
||||
subjects: formData.subjects || [],
|
||||
hasSpecialCategories: formData.hasSpecialCategories || false,
|
||||
processesMinors: formData.processesMinors || false,
|
||||
dataVolume: formData.dataVolume || 'MEDIUM',
|
||||
thirdCountryTransfers: formData.thirdCountryTransfers || false,
|
||||
thirdCountryList: formData.thirdCountryList || [],
|
||||
}
|
||||
|
||||
setDataProfile(profile)
|
||||
completeCurrentStep(profile)
|
||||
}
|
||||
|
||||
const selectedSpecialCategories = (formData.categories || []).filter((cat) => {
|
||||
const meta = DATA_CATEGORIES_METADATA.find((m) => m.id === cat)
|
||||
return meta?.isSpecialCategory
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Data Categories */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Datenkategorien</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Wählen Sie alle Kategorien personenbezogener Daten, die Sie verarbeiten.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{DATA_CATEGORIES_METADATA.map((category) => (
|
||||
<label
|
||||
key={category.id}
|
||||
className={`relative flex items-start p-3 border rounded-lg cursor-pointer transition-all ${
|
||||
formData.categories?.includes(category.id)
|
||||
? category.isSpecialCategory
|
||||
? 'border-amber-500 bg-amber-50'
|
||||
: 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.categories?.includes(category.id) || false}
|
||||
onChange={() => toggleCategory(category.id)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{category.name.de}</span>
|
||||
{category.isSpecialCategory && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 rounded">
|
||||
Art. 9
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{formData.categories?.includes(category.id) && (
|
||||
<svg className="w-5 h-5 text-blue-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Special Categories Warning */}
|
||||
{selectedSpecialCategories.length > 0 && (
|
||||
<div className="mt-4 bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-amber-900">Besondere Kategorien nach Art. 9 DSGVO</h4>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Sie verarbeiten besonders schützenswerte Daten. Dies erfordert zusätzliche Schutzmaßnahmen
|
||||
und möglicherweise eine Datenschutz-Folgenabschätzung (DSFA).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Data Subjects */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Betroffene Personen</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Wählen Sie alle Personengruppen, deren Daten Sie verarbeiten.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{DATA_SUBJECTS_METADATA.map((subject) => (
|
||||
<label
|
||||
key={subject.id}
|
||||
className={`relative flex items-start p-3 border rounded-lg cursor-pointer transition-all ${
|
||||
formData.subjects?.includes(subject.id)
|
||||
? subject.isVulnerable
|
||||
? 'border-red-500 bg-red-50'
|
||||
: 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.subjects?.includes(subject.id) || false}
|
||||
onChange={() => toggleSubject(subject.id)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{subject.name.de}</span>
|
||||
{subject.isVulnerable && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 rounded">
|
||||
Schutzbedürftig
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{formData.subjects?.includes(subject.id) && (
|
||||
<svg className="w-5 h-5 text-blue-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Minors Warning */}
|
||||
{formData.subjects?.includes('MINORS') && (
|
||||
<div className="mt-4 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-red-900">Verarbeitung von Minderjährigen-Daten</h4>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
Die Verarbeitung von Daten Minderjähriger erfordert besondere Schutzmaßnahmen nach Art. 8 DSGVO
|
||||
und erhöhte Sorgfaltspflichten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Data Volume */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Datenvolumen</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Geschätzte Anzahl der Datensätze, die Sie verarbeiten.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{DATA_VOLUMES.map((volume) => (
|
||||
<label
|
||||
key={volume.value}
|
||||
className={`relative flex flex-col items-center p-4 border rounded-lg cursor-pointer transition-all text-center ${
|
||||
formData.dataVolume === volume.value
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="dataVolume"
|
||||
value={volume.value}
|
||||
checked={formData.dataVolume === volume.value}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, dataVolume: e.target.value as DataVolume }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="font-medium text-gray-900">{volume.label}</span>
|
||||
<span className="text-xs text-gray-500 mt-1">{volume.description}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Third Country Transfers */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Drittlandübermittlungen</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Werden Daten in Länder außerhalb der EU/EWR übermittelt?
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.thirdCountryTransfers || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, thirdCountryTransfers: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-gray-700">
|
||||
Ja, wir übermitteln Daten in Drittländer
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{formData.thirdCountryTransfers && (
|
||||
<div className="pl-8">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Zielländer
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={thirdCountryInput}
|
||||
onChange={(e) => setThirdCountryInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addThirdCountry())}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="z.B. USA, Schweiz, UK"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addThirdCountry}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{formData.thirdCountryList && formData.thirdCountryList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formData.thirdCountryList.map((country, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 text-gray-800 rounded-full text-sm"
|
||||
>
|
||||
{country}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeThirdCountry(index)}
|
||||
className="hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.thirdCountryList?.includes('USA') && (
|
||||
<div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Hinweis:</strong> Für Übermittlungen in die USA ist seit dem EU-US Data Privacy Framework
|
||||
ein Angemessenheitsbeschluss vorhanden. Prüfen Sie, ob Ihr US-Partner zertifiziert ist.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataCategoriesStep
|
||||
@@ -0,0 +1,593 @@
|
||||
'use client'
|
||||
|
||||
// =============================================================================
|
||||
// Step 6: Review & Export
|
||||
// Summary, derived TOMs table, gap analysis, and export
|
||||
// =============================================================================
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
|
||||
import { CONTROL_CATEGORIES } from '@/lib/sdk/tom-generator/types'
|
||||
import { getControlById } from '@/lib/sdk/tom-generator/controls/loader'
|
||||
import { generateDOCXBlob } from '@/lib/sdk/tom-generator/export/docx'
|
||||
import { generatePDFBlob } from '@/lib/sdk/tom-generator/export/pdf'
|
||||
import { generateZIPBlob } from '@/lib/sdk/tom-generator/export/zip'
|
||||
|
||||
// =============================================================================
|
||||
// SUMMARY CARD COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
interface SummaryCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
description?: string
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger'
|
||||
}
|
||||
|
||||
function SummaryCard({ title, value, description, variant = 'default' }: SummaryCardProps) {
|
||||
const colors = {
|
||||
default: 'bg-gray-100 text-gray-800',
|
||||
success: 'bg-green-100 text-green-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
danger: 'bg-red-100 text-red-800',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg p-4 ${colors[variant]}`}>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<div className="font-medium">{title}</div>
|
||||
{description && <div className="text-sm opacity-75 mt-1">{description}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TOMS TABLE COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
function TOMsTable() {
|
||||
const { state } = useTOMGenerator()
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
||||
const [selectedApplicability, setSelectedApplicability] = useState<string>('all')
|
||||
|
||||
const filteredTOMs = state.derivedTOMs.filter((tom) => {
|
||||
const control = getControlById(tom.controlId)
|
||||
const categoryMatch = selectedCategory === 'all' || control?.category === selectedCategory
|
||||
const applicabilityMatch = selectedApplicability === 'all' || tom.applicability === selectedApplicability
|
||||
return categoryMatch && applicabilityMatch
|
||||
})
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { bg: string; text: string }> = {
|
||||
IMPLEMENTED: { bg: 'bg-green-100', text: 'text-green-800' },
|
||||
PARTIAL: { bg: 'bg-yellow-100', text: 'text-yellow-800' },
|
||||
NOT_IMPLEMENTED: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
IMPLEMENTED: 'Umgesetzt',
|
||||
PARTIAL: 'Teilweise',
|
||||
NOT_IMPLEMENTED: 'Offen',
|
||||
}
|
||||
const config = badges[status] || badges.NOT_IMPLEMENTED
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const getApplicabilityBadge = (applicability: string) => {
|
||||
const badges: Record<string, { bg: string; text: string }> = {
|
||||
REQUIRED: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||
RECOMMENDED: { bg: 'bg-blue-100', text: 'text-blue-800' },
|
||||
OPTIONAL: { bg: 'bg-gray-100', text: 'text-gray-800' },
|
||||
NOT_APPLICABLE: { bg: 'bg-gray-50', text: 'text-gray-500' },
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
REQUIRED: 'Erforderlich',
|
||||
RECOMMENDED: 'Empfohlen',
|
||||
OPTIONAL: 'Optional',
|
||||
NOT_APPLICABLE: 'N/A',
|
||||
}
|
||||
const config = badges[applicability] || badges.OPTIONAL
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>
|
||||
{labels[applicability] || applicability}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{CONTROL_CATEGORIES.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name.de}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anwendbarkeit</label>
|
||||
<select
|
||||
value={selectedApplicability}
|
||||
onChange={(e) => setSelectedApplicability(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
<option value="REQUIRED">Erforderlich</option>
|
||||
<option value="RECOMMENDED">Empfohlen</option>
|
||||
<option value="OPTIONAL">Optional</option>
|
||||
<option value="NOT_APPLICABLE">Nicht anwendbar</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Maßnahme
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Anwendbarkeit
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Nachweise
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredTOMs.map((tom) => (
|
||||
<tr key={tom.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-mono text-gray-900">
|
||||
{tom.controlId}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm font-medium text-gray-900">{tom.name}</div>
|
||||
<div className="text-xs text-gray-500 max-w-md truncate">{tom.applicabilityReason}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{getApplicabilityBadge(tom.applicability)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{getStatusBadge(tom.implementationStatus)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{tom.linkedEvidence.length > 0 ? (
|
||||
<span className="text-green-600">{tom.linkedEvidence.length} Dok.</span>
|
||||
) : tom.evidenceGaps.length > 0 ? (
|
||||
<span className="text-red-600">{tom.evidenceGaps.length} fehlen</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{filteredTOMs.length} von {state.derivedTOMs.length} Maßnahmen angezeigt
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GAP ANALYSIS PANEL
|
||||
// =============================================================================
|
||||
|
||||
function GapAnalysisPanel() {
|
||||
const { state, runGapAnalysis } = useTOMGenerator()
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.gapAnalysis && state.derivedTOMs.length > 0) {
|
||||
runGapAnalysis()
|
||||
}
|
||||
}, [state.derivedTOMs, state.gapAnalysis, runGapAnalysis])
|
||||
|
||||
if (!state.gapAnalysis) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2" />
|
||||
Lückenanalyse wird durchgeführt...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { overallScore, missingControls, partialControls, recommendations } = state.gapAnalysis
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'text-green-600'
|
||||
if (score >= 50) return 'text-yellow-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Score */}
|
||||
<div className="text-center">
|
||||
<div className={`text-5xl font-bold ${getScoreColor(overallScore)}`}>
|
||||
{overallScore}%
|
||||
</div>
|
||||
<div className="text-gray-600 mt-1">Compliance Score</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-4 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${
|
||||
overallScore >= 80 ? 'bg-green-500' : overallScore >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${overallScore}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Missing Controls */}
|
||||
{missingControls.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">
|
||||
Fehlende Maßnahmen ({missingControls.length})
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{missingControls.map((mc) => {
|
||||
const control = getControlById(mc.controlId)
|
||||
return (
|
||||
<div key={mc.controlId} className="flex items-center justify-between p-2 bg-red-50 rounded-lg">
|
||||
<div>
|
||||
<span className="font-mono text-sm text-gray-600">{mc.controlId}</span>
|
||||
<span className="ml-2 text-sm text-gray-900">{control?.name.de}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${
|
||||
mc.priority === 'CRITICAL' ? 'bg-red-200 text-red-800' :
|
||||
mc.priority === 'HIGH' ? 'bg-orange-200 text-orange-800' :
|
||||
'bg-gray-200 text-gray-800'
|
||||
}`}>
|
||||
{mc.priority}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Partial Controls */}
|
||||
{partialControls.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">
|
||||
Teilweise umgesetzt ({partialControls.length})
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{partialControls.map((pc) => {
|
||||
const control = getControlById(pc.controlId)
|
||||
return (
|
||||
<div key={pc.controlId} className="p-2 bg-yellow-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-gray-600">{pc.controlId}</span>
|
||||
<span className="text-sm text-gray-900">{control?.name.de}</span>
|
||||
</div>
|
||||
<div className="text-xs text-yellow-700 mt-1">
|
||||
Fehlend: {pc.missingAspects.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{recommendations.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">Empfehlungen</h4>
|
||||
<ul className="space-y-2">
|
||||
{recommendations.map((rec, index) => (
|
||||
<li key={index} className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXPORT PANEL
|
||||
// =============================================================================
|
||||
|
||||
function ExportPanel() {
|
||||
const { state, addExport } = useTOMGenerator()
|
||||
const [isExporting, setIsExporting] = useState<string | null>(null)
|
||||
|
||||
const handleExport = async (format: 'docx' | 'pdf' | 'json' | 'zip') => {
|
||||
setIsExporting(format)
|
||||
try {
|
||||
let blob: Blob
|
||||
let filename: string
|
||||
|
||||
switch (format) {
|
||||
case 'docx':
|
||||
blob = await generateDOCXBlob(state, { language: 'de' })
|
||||
filename = `TOM-Dokumentation-${new Date().toISOString().split('T')[0]}.docx`
|
||||
break
|
||||
case 'pdf':
|
||||
blob = await generatePDFBlob(state, { language: 'de' })
|
||||
filename = `TOM-Dokumentation-${new Date().toISOString().split('T')[0]}.pdf`
|
||||
break
|
||||
case 'json':
|
||||
blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||
filename = `TOM-Export-${new Date().toISOString().split('T')[0]}.json`
|
||||
break
|
||||
case 'zip':
|
||||
blob = await generateZIPBlob(state, { language: 'de' })
|
||||
filename = `TOM-Package-${new Date().toISOString().split('T')[0]}.zip`
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
// Download
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
// Record export
|
||||
addExport({
|
||||
id: `export-${Date.now()}`,
|
||||
format: format.toUpperCase() as 'DOCX' | 'PDF' | 'JSON' | 'ZIP',
|
||||
generatedAt: new Date(),
|
||||
filename,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error)
|
||||
} finally {
|
||||
setIsExporting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const exportFormats = [
|
||||
{ id: 'docx', label: 'Word (.docx)', icon: '📄', description: 'Bearbeitbares Dokument' },
|
||||
{ id: 'pdf', label: 'PDF', icon: '📕', description: 'Druckversion' },
|
||||
{ id: 'json', label: 'JSON', icon: '💾', description: 'Maschinelles Format' },
|
||||
{ id: 'zip', label: 'ZIP-Paket', icon: '📦', description: 'Vollständiges Paket' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{exportFormats.map((format) => (
|
||||
<button
|
||||
key={format.id}
|
||||
onClick={() => handleExport(format.id as 'docx' | 'pdf' | 'json' | 'zip')}
|
||||
disabled={isExporting !== null}
|
||||
className={`p-4 border rounded-lg text-center transition-all ${
|
||||
isExporting === format.id
|
||||
? 'bg-blue-50 border-blue-300'
|
||||
: 'hover:bg-gray-50 hover:border-gray-300'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<div className="text-3xl mb-2">{format.icon}</div>
|
||||
<div className="font-medium text-gray-900">{format.label}</div>
|
||||
<div className="text-xs text-gray-500">{format.description}</div>
|
||||
{isExporting === format.id && (
|
||||
<div className="mt-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mx-auto" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Export History */}
|
||||
{state.exports.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Letzte Exporte</h4>
|
||||
<div className="space-y-2">
|
||||
{state.exports.slice(-5).reverse().map((exp) => (
|
||||
<div key={exp.id} className="flex items-center justify-between p-2 bg-gray-50 rounded-lg text-sm">
|
||||
<span className="font-medium">{exp.filename}</span>
|
||||
<span className="text-gray-500">
|
||||
{new Date(exp.generatedAt).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export function ReviewExportStep() {
|
||||
const { state, deriveTOMs, completeCurrentStep } = useTOMGenerator()
|
||||
const [activeTab, setActiveTab] = useState<'summary' | 'toms' | 'gaps' | 'export'>('summary')
|
||||
|
||||
// Derive TOMs if not already done
|
||||
useEffect(() => {
|
||||
if (state.derivedTOMs.length === 0 && state.companyProfile && state.dataProfile) {
|
||||
deriveTOMs()
|
||||
}
|
||||
}, [state, deriveTOMs])
|
||||
|
||||
// Mark step as complete when viewing
|
||||
useEffect(() => {
|
||||
completeCurrentStep({ reviewed: true })
|
||||
}, [completeCurrentStep])
|
||||
|
||||
// Statistics
|
||||
const stats = {
|
||||
totalTOMs: state.derivedTOMs.length,
|
||||
required: state.derivedTOMs.filter((t) => t.applicability === 'REQUIRED').length,
|
||||
implemented: state.derivedTOMs.filter((t) => t.implementationStatus === 'IMPLEMENTED').length,
|
||||
partial: state.derivedTOMs.filter((t) => t.implementationStatus === 'PARTIAL').length,
|
||||
documents: state.documents.length,
|
||||
score: state.gapAnalysis?.overallScore ?? 0,
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'summary', label: 'Zusammenfassung' },
|
||||
{ id: 'toms', label: 'TOMs-Tabelle' },
|
||||
{ id: 'gaps', label: 'Lückenanalyse' },
|
||||
{ id: 'export', label: 'Export' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tabs */}
|
||||
<div className="border-b">
|
||||
<nav className="flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-all ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="min-h-[400px]">
|
||||
{activeTab === 'summary' && (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<SummaryCard
|
||||
title="Gesamt TOMs"
|
||||
value={stats.totalTOMs}
|
||||
variant="default"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Erforderlich"
|
||||
value={stats.required}
|
||||
variant="danger"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Umgesetzt"
|
||||
value={stats.implemented}
|
||||
variant="success"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Teilweise"
|
||||
value={stats.partial}
|
||||
variant="warning"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Dokumente"
|
||||
value={stats.documents}
|
||||
variant="default"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Score"
|
||||
value={`${stats.score}%`}
|
||||
variant={stats.score >= 80 ? 'success' : stats.score >= 50 ? 'warning' : 'danger'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Profile Summaries */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Company */}
|
||||
{state.companyProfile && (
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Unternehmen</h4>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Name:</dt>
|
||||
<dd className="text-gray-900">{state.companyProfile.name}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Branche:</dt>
|
||||
<dd className="text-gray-900">{state.companyProfile.industry}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Rolle:</dt>
|
||||
<dd className="text-gray-900">{state.companyProfile.role}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Risk */}
|
||||
{state.riskProfile && (
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Schutzbedarf</h4>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Level:</dt>
|
||||
<dd className={`font-medium ${
|
||||
state.riskProfile.protectionLevel === 'VERY_HIGH' ? 'text-red-600' :
|
||||
state.riskProfile.protectionLevel === 'HIGH' ? 'text-yellow-600' : 'text-green-600'
|
||||
}`}>
|
||||
{state.riskProfile.protectionLevel}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">DSFA erforderlich:</dt>
|
||||
<dd className={state.riskProfile.dsfaRequired ? 'text-red-600 font-medium' : 'text-gray-900'}>
|
||||
{state.riskProfile.dsfaRequired ? 'Ja' : 'Nein'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">CIA (V/I/V):</dt>
|
||||
<dd className="text-gray-900">
|
||||
{state.riskProfile.ciaAssessment.confidentiality}/
|
||||
{state.riskProfile.ciaAssessment.integrity}/
|
||||
{state.riskProfile.ciaAssessment.availability}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'toms' && <TOMsTable />}
|
||||
|
||||
{activeTab === 'gaps' && <GapAnalysisPanel />}
|
||||
|
||||
{activeTab === 'export' && <ExportPanel />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReviewExportStep
|
||||
@@ -0,0 +1,422 @@
|
||||
'use client'
|
||||
|
||||
// =============================================================================
|
||||
// Step 5: Risk & Protection Level
|
||||
// CIA assessment and protection level determination
|
||||
// =============================================================================
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
|
||||
import {
|
||||
RiskProfile,
|
||||
CIARating,
|
||||
ProtectionLevel,
|
||||
calculateProtectionLevel,
|
||||
isDSFARequired,
|
||||
} from '@/lib/sdk/tom-generator/types'
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const CIA_LEVELS: { value: CIARating; label: string; description: string }[] = [
|
||||
{ value: 1, label: 'Sehr gering', description: 'Kein nennenswerter Schaden bei Verletzung' },
|
||||
{ value: 2, label: 'Gering', description: 'Begrenzter, beherrschbarer Schaden' },
|
||||
{ value: 3, label: 'Mittel', description: 'Erheblicher Schaden, aber kompensierbar' },
|
||||
{ value: 4, label: 'Hoch', description: 'Schwerwiegender Schaden, schwer kompensierbar' },
|
||||
{ value: 5, label: 'Sehr hoch', description: 'Existenzbedrohender oder irreversibler Schaden' },
|
||||
]
|
||||
|
||||
const REGULATORY_REQUIREMENTS = [
|
||||
'DSGVO',
|
||||
'BDSG',
|
||||
'MaRisk (Finanz)',
|
||||
'BAIT (Finanz)',
|
||||
'PSD2 (Zahlungsdienste)',
|
||||
'SGB (Gesundheit)',
|
||||
'MDR (Medizinprodukte)',
|
||||
'TISAX (Automotive)',
|
||||
'KRITIS (Kritische Infrastruktur)',
|
||||
'NIS2',
|
||||
'ISO 27001',
|
||||
'SOC 2',
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// CIA SLIDER COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
interface CIASliderProps {
|
||||
label: string
|
||||
description: string
|
||||
value: CIARating
|
||||
onChange: (value: CIARating) => void
|
||||
}
|
||||
|
||||
function CIASlider({ label, description, value, onChange }: CIASliderProps) {
|
||||
const level = CIA_LEVELS.find((l) => l.value === value)
|
||||
|
||||
const getColor = (v: CIARating) => {
|
||||
if (v <= 2) return 'bg-green-500'
|
||||
if (v === 3) return 'bg-yellow-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{label}</h4>
|
||||
<p className="text-sm text-gray-500">{description}</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium text-white ${getColor(value)}`}>
|
||||
{level?.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseInt(e.target.value) as CIARating)}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||
/>
|
||||
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||
<span>1</span>
|
||||
<span>2</span>
|
||||
<span>3</span>
|
||||
<span>4</span>
|
||||
<span>5</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mt-2 italic">{level?.description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROTECTION LEVEL DISPLAY
|
||||
// =============================================================================
|
||||
|
||||
interface ProtectionLevelDisplayProps {
|
||||
level: ProtectionLevel
|
||||
}
|
||||
|
||||
function ProtectionLevelDisplay({ level }: ProtectionLevelDisplayProps) {
|
||||
const config: Record<ProtectionLevel, { label: string; color: string; bg: string; description: string }> = {
|
||||
NORMAL: {
|
||||
label: 'Normal',
|
||||
color: 'text-green-800',
|
||||
bg: 'bg-green-100',
|
||||
description: 'Standard-Schutzmaßnahmen ausreichend',
|
||||
},
|
||||
HIGH: {
|
||||
label: 'Hoch',
|
||||
color: 'text-yellow-800',
|
||||
bg: 'bg-yellow-100',
|
||||
description: 'Erweiterte Schutzmaßnahmen erforderlich',
|
||||
},
|
||||
VERY_HIGH: {
|
||||
label: 'Sehr hoch',
|
||||
color: 'text-red-800',
|
||||
bg: 'bg-red-100',
|
||||
description: 'Höchste Schutzmaßnahmen erforderlich',
|
||||
},
|
||||
}
|
||||
|
||||
const { label, color, bg, description } = config[level]
|
||||
|
||||
return (
|
||||
<div className={`${bg} rounded-lg p-4 border`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`text-2xl font-bold ${color}`}>{label}</div>
|
||||
</div>
|
||||
<p className={`text-sm ${color} mt-1`}>{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export function RiskProtectionStep() {
|
||||
const { state, setRiskProfile, completeCurrentStep } = useTOMGenerator()
|
||||
|
||||
const [formData, setFormData] = useState<Partial<RiskProfile>>({
|
||||
ciaAssessment: {
|
||||
confidentiality: 3,
|
||||
integrity: 3,
|
||||
availability: 3,
|
||||
justification: '',
|
||||
},
|
||||
protectionLevel: 'HIGH',
|
||||
specialRisks: [],
|
||||
regulatoryRequirements: ['DSGVO'],
|
||||
hasHighRiskProcessing: false,
|
||||
dsfaRequired: false,
|
||||
})
|
||||
|
||||
const [specialRiskInput, setSpecialRiskInput] = useState('')
|
||||
|
||||
// Load existing data
|
||||
useEffect(() => {
|
||||
if (state.riskProfile) {
|
||||
setFormData(state.riskProfile)
|
||||
}
|
||||
}, [state.riskProfile])
|
||||
|
||||
// Calculate protection level when CIA changes
|
||||
useEffect(() => {
|
||||
if (formData.ciaAssessment) {
|
||||
const level = calculateProtectionLevel(formData.ciaAssessment)
|
||||
const dsfaReq = isDSFARequired(state.dataProfile, {
|
||||
...formData,
|
||||
protectionLevel: level,
|
||||
} as RiskProfile)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
protectionLevel: level,
|
||||
dsfaRequired: dsfaReq,
|
||||
}))
|
||||
}
|
||||
}, [formData.ciaAssessment, state.dataProfile])
|
||||
|
||||
// Handle CIA changes
|
||||
const handleCIAChange = (field: 'confidentiality' | 'integrity' | 'availability', value: CIARating) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
ciaAssessment: {
|
||||
...prev.ciaAssessment!,
|
||||
[field]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle regulatory requirements toggle
|
||||
const toggleRequirement = (req: string) => {
|
||||
setFormData((prev) => {
|
||||
const current = prev.regulatoryRequirements || []
|
||||
const updated = current.includes(req)
|
||||
? current.filter((r) => r !== req)
|
||||
: [...current, req]
|
||||
return { ...prev, regulatoryRequirements: updated }
|
||||
})
|
||||
}
|
||||
|
||||
// Handle special risk addition
|
||||
const addSpecialRisk = () => {
|
||||
if (specialRiskInput.trim()) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
specialRisks: [...(prev.specialRisks || []), specialRiskInput.trim()],
|
||||
}))
|
||||
setSpecialRiskInput('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeSpecialRisk = (index: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
specialRisks: (prev.specialRisks || []).filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const profile: RiskProfile = {
|
||||
ciaAssessment: formData.ciaAssessment!,
|
||||
protectionLevel: formData.protectionLevel || 'HIGH',
|
||||
specialRisks: formData.specialRisks || [],
|
||||
regulatoryRequirements: formData.regulatoryRequirements || [],
|
||||
hasHighRiskProcessing: formData.hasHighRiskProcessing || false,
|
||||
dsfaRequired: formData.dsfaRequired || false,
|
||||
}
|
||||
|
||||
setRiskProfile(profile)
|
||||
completeCurrentStep(profile)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* CIA Assessment */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">CIA-Bewertung</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Bewerten Sie die Schutzziele für Ihre Datenverarbeitung. Was passiert, wenn die Vertraulichkeit,
|
||||
Integrität oder Verfügbarkeit der Daten beeinträchtigt wird?
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<CIASlider
|
||||
label="Vertraulichkeit (Confidentiality)"
|
||||
description="Schutz vor unbefugtem Zugriff auf Daten"
|
||||
value={formData.ciaAssessment?.confidentiality || 3}
|
||||
onChange={(v) => handleCIAChange('confidentiality', v)}
|
||||
/>
|
||||
|
||||
<CIASlider
|
||||
label="Integrität (Integrity)"
|
||||
description="Schutz vor unbefugter Änderung von Daten"
|
||||
value={formData.ciaAssessment?.integrity || 3}
|
||||
onChange={(v) => handleCIAChange('integrity', v)}
|
||||
/>
|
||||
|
||||
<CIASlider
|
||||
label="Verfügbarkeit (Availability)"
|
||||
description="Sicherstellung des Zugriffs auf Daten"
|
||||
value={formData.ciaAssessment?.availability || 3}
|
||||
onChange={(v) => handleCIAChange('availability', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Justification */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Begründung der Bewertung
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.ciaAssessment?.justification || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
ciaAssessment: {
|
||||
...prev.ciaAssessment!,
|
||||
justification: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Beschreiben Sie kurz, warum Sie diese Bewertung gewählt haben..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calculated Protection Level */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Ermittelter Schutzbedarf</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Basierend auf Ihrer CIA-Bewertung ergibt sich folgender Schutzbedarf:
|
||||
</p>
|
||||
|
||||
<ProtectionLevelDisplay level={formData.protectionLevel || 'HIGH'} />
|
||||
</div>
|
||||
|
||||
{/* DSFA Indicator */}
|
||||
{formData.dsfaRequired && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-6 h-6 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-red-900">DSFA erforderlich</h4>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
Aufgrund Ihrer Datenverarbeitung (besondere Kategorien, Minderjährige oder sehr hoher Schutzbedarf)
|
||||
ist eine Datenschutz-Folgenabschätzung nach Art. 35 DSGVO erforderlich.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* High Risk Processing */}
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasHighRiskProcessing || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hasHighRiskProcessing: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Hochrisiko-Verarbeitung</span>
|
||||
<p className="text-sm text-gray-500">
|
||||
z.B. Profiling, automatisierte Entscheidungen, systematische Überwachung
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Special Risks */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Besondere Risiken</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Identifizieren Sie spezifische Risiken Ihrer Datenverarbeitung.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={specialRiskInput}
|
||||
onChange={(e) => setSpecialRiskInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addSpecialRisk())}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="z.B. Cloud-Abhängigkeit, Insider-Bedrohungen"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSpecialRisk}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.specialRisks && formData.specialRisks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.specialRisks.map((risk, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 bg-red-100 text-red-800 rounded-full text-sm"
|
||||
>
|
||||
{risk}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSpecialRisk(index)}
|
||||
className="hover:text-red-600"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Regulatory Requirements */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Regulatorische Anforderungen</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Welche regulatorischen Anforderungen gelten für Ihre Datenverarbeitung?
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{REGULATORY_REQUIREMENTS.map((req) => (
|
||||
<button
|
||||
key={req}
|
||||
type="button"
|
||||
onClick={() => toggleRequirement(req)}
|
||||
className={`px-4 py-2 rounded-full border text-sm font-medium transition-all ${
|
||||
formData.regulatoryRequirements?.includes(req)
|
||||
? 'bg-blue-100 border-blue-300 text-blue-800'
|
||||
: 'bg-white border-gray-300 text-gray-600 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{req}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default RiskProtectionStep
|
||||
@@ -0,0 +1,403 @@
|
||||
'use client'
|
||||
|
||||
// =============================================================================
|
||||
// Step 1: Scope & Roles
|
||||
// Company profile and role definition
|
||||
// =============================================================================
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
|
||||
import {
|
||||
CompanyProfile,
|
||||
CompanyRole,
|
||||
CompanySize,
|
||||
} from '@/lib/sdk/tom-generator/types'
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const COMPANY_SIZES: { value: CompanySize; label: string; description: string }[] = [
|
||||
{ value: 'MICRO', label: 'Kleinstunternehmen', description: '< 10 Mitarbeiter' },
|
||||
{ value: 'SMALL', label: 'Kleinunternehmen', description: '10-49 Mitarbeiter' },
|
||||
{ value: 'MEDIUM', label: 'Mittelunternehmen', description: '50-249 Mitarbeiter' },
|
||||
{ value: 'LARGE', label: 'Großunternehmen', description: '250-999 Mitarbeiter' },
|
||||
{ value: 'ENTERPRISE', label: 'Konzern', description: '1000+ Mitarbeiter' },
|
||||
]
|
||||
|
||||
const COMPANY_ROLES: { value: CompanyRole; label: string; description: string }[] = [
|
||||
{
|
||||
value: 'CONTROLLER',
|
||||
label: 'Verantwortlicher',
|
||||
description: 'Sie bestimmen Zweck und Mittel der Datenverarbeitung',
|
||||
},
|
||||
{
|
||||
value: 'PROCESSOR',
|
||||
label: 'Auftragsverarbeiter',
|
||||
description: 'Sie verarbeiten Daten im Auftrag eines Verantwortlichen',
|
||||
},
|
||||
{
|
||||
value: 'JOINT_CONTROLLER',
|
||||
label: 'Gemeinsam Verantwortlicher',
|
||||
description: 'Sie bestimmen gemeinsam mit anderen Zweck und Mittel',
|
||||
},
|
||||
]
|
||||
|
||||
const INDUSTRIES = [
|
||||
'Software / IT',
|
||||
'Finanzdienstleistungen',
|
||||
'Gesundheitswesen',
|
||||
'E-Commerce / Handel',
|
||||
'Beratung / Professional Services',
|
||||
'Produktion / Industrie',
|
||||
'Bildung / Forschung',
|
||||
'Öffentlicher Sektor',
|
||||
'Medien / Kommunikation',
|
||||
'Transport / Logistik',
|
||||
'Sonstige',
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export function ScopeRolesStep() {
|
||||
const { state, setCompanyProfile, completeCurrentStep } = useTOMGenerator()
|
||||
|
||||
const [formData, setFormData] = useState<Partial<CompanyProfile>>({
|
||||
id: '',
|
||||
name: '',
|
||||
industry: '',
|
||||
size: 'MEDIUM',
|
||||
role: 'CONTROLLER',
|
||||
products: [],
|
||||
dpoPerson: '',
|
||||
dpoEmail: '',
|
||||
itSecurityContact: '',
|
||||
})
|
||||
|
||||
const [productInput, setProductInput] = useState('')
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
// Load existing data
|
||||
useEffect(() => {
|
||||
if (state.companyProfile) {
|
||||
setFormData(state.companyProfile)
|
||||
}
|
||||
}, [state.companyProfile])
|
||||
|
||||
// Validation
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!formData.name?.trim()) {
|
||||
newErrors.name = 'Unternehmensname ist erforderlich'
|
||||
}
|
||||
|
||||
if (!formData.industry) {
|
||||
newErrors.industry = 'Bitte wählen Sie eine Branche'
|
||||
}
|
||||
|
||||
if (!formData.role) {
|
||||
newErrors.role = 'Bitte wählen Sie eine Rolle'
|
||||
}
|
||||
|
||||
if (formData.dpoEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.dpoEmail)) {
|
||||
newErrors.dpoEmail = 'Bitte geben Sie eine gültige E-Mail-Adresse ein'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
// Handle form changes
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = e.target
|
||||
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||
// Clear error when field is edited
|
||||
if (errors[name]) {
|
||||
setErrors((prev) => ({ ...prev, [name]: '' }))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle product addition
|
||||
const addProduct = () => {
|
||||
if (productInput.trim()) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
products: [...(prev.products || []), productInput.trim()],
|
||||
}))
|
||||
setProductInput('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeProduct = (index: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
products: (prev.products || []).filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validate()) return
|
||||
|
||||
const profile: CompanyProfile = {
|
||||
id: formData.id || `company-${Date.now()}`,
|
||||
name: formData.name!,
|
||||
industry: formData.industry!,
|
||||
size: formData.size!,
|
||||
role: formData.role!,
|
||||
products: formData.products || [],
|
||||
dpoPerson: formData.dpoPerson || null,
|
||||
dpoEmail: formData.dpoEmail || null,
|
||||
itSecurityContact: formData.itSecurityContact || null,
|
||||
}
|
||||
|
||||
setCompanyProfile(profile)
|
||||
completeCurrentStep(profile)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Company Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Unternehmensname <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.name ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="z.B. Muster GmbH"
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* Industry */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Branche <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="industry"
|
||||
value={formData.industry || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.industry ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{INDUSTRIES.map((industry) => (
|
||||
<option key={industry} value={industry}>
|
||||
{industry}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.industry && <p className="mt-1 text-sm text-red-500">{errors.industry}</p>}
|
||||
</div>
|
||||
|
||||
{/* Company Size */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Unternehmensgröße <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{COMPANY_SIZES.map((size) => (
|
||||
<label
|
||||
key={size.value}
|
||||
className={`relative flex items-start p-3 border rounded-lg cursor-pointer transition-all ${
|
||||
formData.size === size.value
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="size"
|
||||
value={size.value}
|
||||
checked={formData.size === size.value}
|
||||
onChange={handleChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900">{size.label}</span>
|
||||
<p className="text-sm text-gray-500">{size.description}</p>
|
||||
</div>
|
||||
{formData.size === size.value && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<svg className="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Role */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Ihre Rolle nach DSGVO <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{COMPANY_ROLES.map((role) => (
|
||||
<label
|
||||
key={role.value}
|
||||
className={`relative flex items-start p-4 border rounded-lg cursor-pointer transition-all ${
|
||||
formData.role === role.value
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value={role.value}
|
||||
checked={formData.role === role.value}
|
||||
onChange={handleChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium text-gray-900">{role.label}</span>
|
||||
<p className="text-sm text-gray-500 mt-1">{role.description}</p>
|
||||
</div>
|
||||
{formData.role === role.value && (
|
||||
<div className="flex-shrink-0 ml-3">
|
||||
<svg className="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{errors.role && <p className="mt-1 text-sm text-red-500">{errors.role}</p>}
|
||||
</div>
|
||||
|
||||
{/* Products/Services */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Produkte / Services
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={productInput}
|
||||
onChange={(e) => setProductInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addProduct())}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="z.B. Cloud CRM, API Services"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addProduct}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{formData.products && formData.products.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formData.products.map((product, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
|
||||
>
|
||||
{product}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeProduct(index)}
|
||||
className="hover:text-blue-600"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Kontaktinformationen</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Datenschutzbeauftragter
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="dpoPerson"
|
||||
value={formData.dpoPerson || ''}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Name des DSB"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
E-Mail des DSB
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="dpoEmail"
|
||||
value={formData.dpoEmail || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.dpoEmail ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="dpo@example.de"
|
||||
/>
|
||||
{errors.dpoEmail && <p className="mt-1 text-sm text-red-500">{errors.dpoEmail}</p>}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
IT-Security Ansprechpartner
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="itSecurityContact"
|
||||
value={formData.itSecurityContact || ''}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Name oder Team"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900">Hinweis zur Rollenwahl</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Die Wahl Ihrer DSGVO-Rolle beeinflusst, welche TOMs für Sie relevant sind.
|
||||
Als <strong>Auftragsverarbeiter</strong> gelten zusätzliche Anforderungen nach Art. 28 DSGVO.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScopeRolesStep
|
||||
@@ -0,0 +1,417 @@
|
||||
'use client'
|
||||
|
||||
// =============================================================================
|
||||
// Step 4: Security Profile
|
||||
// Authentication, encryption, and security configuration
|
||||
// =============================================================================
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTOMGenerator } from '@/lib/sdk/tom-generator'
|
||||
import {
|
||||
SecurityProfile,
|
||||
AuthMethodType,
|
||||
BackupFrequency,
|
||||
} from '@/lib/sdk/tom-generator/types'
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const AUTH_METHODS: { value: AuthMethodType; label: string; description: string }[] = [
|
||||
{ value: 'PASSWORD', label: 'Passwort', description: 'Standard-Passwortauthentifizierung' },
|
||||
{ value: 'MFA', label: 'Multi-Faktor (MFA)', description: 'Zweiter Faktor erforderlich' },
|
||||
{ value: 'SSO', label: 'Single Sign-On', description: 'Zentralisierte Anmeldung' },
|
||||
{ value: 'CERTIFICATE', label: 'Zertifikat', description: 'Client-Zertifikate' },
|
||||
{ value: 'BIOMETRIC', label: 'Biometrisch', description: 'Fingerabdruck, Gesicht, etc.' },
|
||||
]
|
||||
|
||||
const BACKUP_FREQUENCIES: { value: BackupFrequency; label: string }[] = [
|
||||
{ value: 'HOURLY', label: 'Stündlich' },
|
||||
{ value: 'DAILY', label: 'Täglich' },
|
||||
{ value: 'WEEKLY', label: 'Wöchentlich' },
|
||||
{ value: 'MONTHLY', label: 'Monatlich' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export function SecurityProfileStep() {
|
||||
const { state, setSecurityProfile, completeCurrentStep } = useTOMGenerator()
|
||||
|
||||
const [formData, setFormData] = useState<Partial<SecurityProfile>>({
|
||||
authMethods: [],
|
||||
hasMFA: false,
|
||||
hasSSO: false,
|
||||
hasIAM: false,
|
||||
hasPAM: false,
|
||||
hasEncryptionAtRest: false,
|
||||
hasEncryptionInTransit: false,
|
||||
hasLogging: false,
|
||||
logRetentionDays: 90,
|
||||
hasBackup: false,
|
||||
backupFrequency: 'DAILY',
|
||||
backupRetentionDays: 30,
|
||||
hasDRPlan: false,
|
||||
rtoHours: null,
|
||||
rpoHours: null,
|
||||
hasVulnerabilityManagement: false,
|
||||
hasPenetrationTests: false,
|
||||
hasSecurityTraining: false,
|
||||
})
|
||||
|
||||
// Load existing data
|
||||
useEffect(() => {
|
||||
if (state.securityProfile) {
|
||||
setFormData(state.securityProfile)
|
||||
}
|
||||
}, [state.securityProfile])
|
||||
|
||||
// Sync auth methods with boolean flags
|
||||
useEffect(() => {
|
||||
const authTypes = formData.authMethods?.map((m) => m.type) || []
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
hasMFA: authTypes.includes('MFA'),
|
||||
hasSSO: authTypes.includes('SSO'),
|
||||
}))
|
||||
}, [formData.authMethods])
|
||||
|
||||
// Handle auth method toggle
|
||||
const toggleAuthMethod = (method: AuthMethodType) => {
|
||||
setFormData((prev) => {
|
||||
const current = prev.authMethods || []
|
||||
const exists = current.some((m) => m.type === method)
|
||||
const updated = exists
|
||||
? current.filter((m) => m.type !== method)
|
||||
: [...current, { type: method, provider: null }]
|
||||
return { ...prev, authMethods: updated }
|
||||
})
|
||||
}
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const profile: SecurityProfile = {
|
||||
authMethods: formData.authMethods || [],
|
||||
hasMFA: formData.hasMFA || false,
|
||||
hasSSO: formData.hasSSO || false,
|
||||
hasIAM: formData.hasIAM || false,
|
||||
hasPAM: formData.hasPAM || false,
|
||||
hasEncryptionAtRest: formData.hasEncryptionAtRest || false,
|
||||
hasEncryptionInTransit: formData.hasEncryptionInTransit || false,
|
||||
hasLogging: formData.hasLogging || false,
|
||||
logRetentionDays: formData.logRetentionDays || 90,
|
||||
hasBackup: formData.hasBackup || false,
|
||||
backupFrequency: formData.backupFrequency || 'DAILY',
|
||||
backupRetentionDays: formData.backupRetentionDays || 30,
|
||||
hasDRPlan: formData.hasDRPlan || false,
|
||||
rtoHours: formData.rtoHours ?? null,
|
||||
rpoHours: formData.rpoHours ?? null,
|
||||
hasVulnerabilityManagement: formData.hasVulnerabilityManagement || false,
|
||||
hasPenetrationTests: formData.hasPenetrationTests || false,
|
||||
hasSecurityTraining: formData.hasSecurityTraining || false,
|
||||
}
|
||||
|
||||
setSecurityProfile(profile)
|
||||
completeCurrentStep(profile)
|
||||
}
|
||||
|
||||
const selectedAuthMethods = formData.authMethods?.map((m) => m.type) || []
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Authentication Methods */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Authentifizierungsmethoden</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Welche Authentifizierungsmethoden werden verwendet?
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{AUTH_METHODS.map((method) => (
|
||||
<label
|
||||
key={method.value}
|
||||
className={`relative flex items-start p-3 border rounded-lg cursor-pointer transition-all ${
|
||||
selectedAuthMethods.includes(method.value)
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAuthMethods.includes(method.value)}
|
||||
onChange={() => toggleAuthMethod(method.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium text-gray-900">{method.label}</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{method.description}</p>
|
||||
</div>
|
||||
{selectedAuthMethods.includes(method.value) && (
|
||||
<svg className="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* MFA recommendation */}
|
||||
{!selectedAuthMethods.includes('MFA') && (
|
||||
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Empfehlung:</strong> Multi-Faktor-Authentifizierung (MFA) wird für alle sensiblen
|
||||
Systeme dringend empfohlen und ist bei besonderen Datenkategorien oft erforderlich.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Identity & Access Management */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Identity & Access Management</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasIAM || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hasIAM: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Identity & Access Management (IAM)</span>
|
||||
<p className="text-sm text-gray-500">Zentralisierte Benutzer- und Berechtigungsverwaltung</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasPAM || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hasPAM: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Privileged Access Management (PAM)</span>
|
||||
<p className="text-sm text-gray-500">Kontrolle privilegierter Zugänge (Admin-Konten)</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Encryption */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Verschlüsselung</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasEncryptionAtRest || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hasEncryptionAtRest: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Verschlüsselung ruhender Daten</span>
|
||||
<p className="text-sm text-gray-500">AES-256 oder vergleichbar für gespeicherte Daten</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasEncryptionInTransit || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hasEncryptionInTransit: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Transportverschlüsselung</span>
|
||||
<p className="text-sm text-gray-500">TLS 1.2+ für alle Datenübertragungen</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logging */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Protokollierung</h3>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasLogging || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hasLogging: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Audit-Logging aktiviert</span>
|
||||
<p className="text-sm text-gray-500">Protokollierung aller sicherheitsrelevanten Ereignisse</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{formData.hasLogging && (
|
||||
<div className="pl-8">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Log-Aufbewahrung (Tage)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.logRetentionDays || 90}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, logRetentionDays: parseInt(e.target.value) || 90 }))}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backup & DR */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Backup & Disaster Recovery</h3>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasBackup || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hasBackup: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Regelmäßige Backups</span>
|
||||
<p className="text-sm text-gray-500">Automatisierte Datensicherung</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{formData.hasBackup && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pl-8 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Backup-Frequenz
|
||||
</label>
|
||||
<select
|
||||
value={formData.backupFrequency || 'DAILY'}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, backupFrequency: e.target.value as BackupFrequency }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{BACKUP_FREQUENCIES.map((freq) => (
|
||||
<option key={freq.value} value={freq.value}>{freq.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Backup-Aufbewahrung (Tage)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.backupRetentionDays || 30}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, backupRetentionDays: parseInt(e.target.value) || 30 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasDRPlan || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hasDRPlan: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Disaster Recovery Plan vorhanden</span>
|
||||
<p className="text-sm text-gray-500">Dokumentierter Wiederherstellungsplan</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{formData.hasDRPlan && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pl-8">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
RTO (Recovery Time Objective) in Stunden
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
value={formData.rtoHours ?? ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, rtoHours: e.target.value ? parseFloat(e.target.value) : null }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="z.B. 4"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Maximale Ausfallzeit</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
RPO (Recovery Point Objective) in Stunden
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.25"
|
||||
value={formData.rpoHours ?? ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, rpoHours: e.target.value ? parseFloat(e.target.value) : null }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="z.B. 1"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Maximaler Datenverlust</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Testing & Training */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Sicherheitstests & Schulungen</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasVulnerabilityManagement || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hasVulnerabilityManagement: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Schwachstellenmanagement</span>
|
||||
<p className="text-sm text-gray-500">Regelmäßige Schwachstellenscans und Patch-Management</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasPenetrationTests || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hasPenetrationTests: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Penetrationstests</span>
|
||||
<p className="text-sm text-gray-500">Regelmäßige Sicherheitstests durch externe Prüfer</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasSecurityTraining || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hasSecurityTraining: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Security Awareness Training</span>
|
||||
<p className="text-sm text-gray-500">Regelmäßige Schulungen für alle Mitarbeiter</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default SecurityProfileStep
|
||||
Reference in New Issue
Block a user