merge: phases 1–5 refactor, CI hardening, docs (coolify → main)
Some checks failed
Build + Deploy / build-admin-compliance (push) Failing after 47s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-ai-sdk (push) Successful in 34s
Build + Deploy / build-developer-portal (push) Successful in 56s
Build + Deploy / build-tts (push) Successful in 26s
Build + Deploy / build-document-crawler (push) Successful in 15s
Build + Deploy / build-dsms-gateway (push) Successful in 13s
Build + Deploy / trigger-orca (push) Has been skipped
CI/CD / loc-budget (push) Successful in 22s
CI/CD / guardrail-integrity (push) Has been skipped
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been cancelled
CI/CD / test-go-ai-compliance (push) Has been cancelled
CI/CD / test-python-backend-compliance (push) Has been cancelled
CI/CD / test-python-document-crawler (push) Has been cancelled
CI/CD / test-python-dsms-gateway (push) Successful in 28s
CI/CD / sbom-scan (push) Has been cancelled
CI/CD / validate-canonical-controls (push) Successful in 20s

Phase 1: backend-compliance — partial service-layer extraction
Phase 2: ai-compliance-sdk — full hexagonal split; iace/ucca/training handlers
  and stores split into focused files; cmd/server/main.go → internal/app/
Phase 3: admin-compliance — types.ts, tom-generator loader, and major page
  components split; lib document generators extracted
Phase 4: dsms-gateway, consent-sdk, developer-portal, breakpilot-compliance-sdk
Phase 5 CI hardening:
  - loc-budget job now scans whole repo (blocking, no || true)
  - sbom-scan / grype blocking on high+ CVEs
  - ai-compliance-sdk/.golangci.yml: strict golangci-lint config
  - check-loc.sh: skip test_*.py and *.html; loc-exceptions.txt expanded
  - deleted stray routes.py.backup (2512 LOC)
Docs:
  - root README.md with CI badge, service table, quick start, CI pipeline table
  - CONTRIBUTING.md: setup, pre-commit checklist, guardrail marker reference
  - CLAUDE.md: First-Time Setup & Claude Code Onboarding section
  - all 7 service READMEs updated (stale phase refs, current architecture)
  - AGENTS.go/python/typescript.md enhanced with linting, DI, barrel re-export
  - .gitignore: dist/, .turbo/, pnpm-lock.yaml added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-19 16:11:53 +02:00
1258 changed files with 210195 additions and 145532 deletions

View File

@@ -0,0 +1,131 @@
'use client'
// =============================================================================
// ComplianceAdvisorWidget — shared constants and sub-components
// =============================================================================
// =============================================================================
// EXAMPLE QUESTIONS
// =============================================================================
export 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?',
],
}
// =============================================================================
// TYPES
// =============================================================================
export interface Message {
id: string
role: 'user' | 'agent'
content: string
timestamp: Date
}
// =============================================================================
// EmptyState — shown when no messages yet
// =============================================================================
interface EmptyStateProps {
exampleQuestions: string[]
onExampleClick: (question: string) => void
}
export function AdvisorEmptyState({ exampleQuestions, onExampleClick }: EmptyStateProps) {
return (
<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>
<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={() => onExampleClick(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>
)
}
// =============================================================================
// MessageList — renders messages + typing indicator
// =============================================================================
interface MessageListProps {
messages: Message[]
isTyping: boolean
messagesEndRef: React.RefObject<HTMLDivElement | null>
}
export function AdvisorMessageList({ messages, isTyping, messagesEndRef }: MessageListProps) {
return (
<>
{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} />
</>
)
}

View File

@@ -1,63 +1,16 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { EXAMPLE_QUESTIONS, AdvisorEmptyState, AdvisorMessageList, type Message } from './ComplianceAdvisorParts'
// =============================================================================
// TYPES
// =============================================================================
interface Message {
id: string
role: 'user' | 'agent'
content: string
timestamp: Date
}
interface ComplianceAdvisorWidgetProps {
currentStep?: string
}
// =============================================================================
// 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
// =============================================================================
type Country = 'DE' | 'AT' | 'CH' | 'EU'
const COUNTRIES: { code: Country; label: string }[] = [
@@ -67,6 +20,10 @@ const COUNTRIES: { code: Country; label: string }[] = [
{ code: 'EU', label: 'EU' },
]
// =============================================================================
// COMPONENT
// =============================================================================
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
@@ -77,22 +34,18 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
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
@@ -109,12 +62,9 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
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,
@@ -137,18 +87,11 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
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(),
},
{ id: agentMessageId, role: 'agent', content: '', timestamp: new Date() },
])
// Read streaming response
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let accumulated = ''
@@ -158,14 +101,10 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
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' })
})
@@ -174,32 +113,21 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
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
const errorMessage = error instanceof Error ? error.message : 'Verbindung fehlgeschlagen'
setMessages((prev) => {
const hasAgent = prev.some((m) => m.id === agentMessageId)
if (hasAgent) {
return prev.map((m) =>
m.id === agentMessageId
? { ...m, content: `Fehler: ${errorMessage}` }
: m
m.id === agentMessageId ? { ...m, content: `Fehler: ${errorMessage}` } : m
)
}
return [
...prev,
{
id: agentMessageId,
role: 'agent' as const,
content: `Fehler: ${errorMessage}`,
timestamp: new Date(),
},
{ id: agentMessageId, role: 'agent' as const, content: `Fehler: ${errorMessage}`, timestamp: new Date() },
]
})
setIsTyping(false)
@@ -208,18 +136,11 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
[isTyping, messages, currentStep, selectedCountry]
)
// 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()
@@ -234,18 +155,8 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
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 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>
)
@@ -257,18 +168,8 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
<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 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>
@@ -278,11 +179,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
<button
key={code}
onClick={() => setSelectedCountry(code)}
className={`px-1.5 py-0.5 text-[10px] font-medium rounded transition-colors ${
selectedCountry === code
? 'bg-white text-indigo-700'
: 'bg-white/15 text-white/80 hover:bg-white/25'
}`}
className={`px-1.5 py-0.5 text-[10px] font-medium rounded transition-colors ${selectedCountry === code ? 'bg-white text-indigo-700' : 'bg-white/15 text-white/80 hover:bg-white/25'}`}
>
{label}
</button>
@@ -296,46 +193,17 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
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"
>
<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="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"
/>
<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"
/>
<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>
@@ -344,102 +212,16 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
{/* 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>
<AdvisorEmptyState
exampleQuestions={exampleQuestions}
onExampleClick={(q) => handleSendMessage(q)}
/>
) : (
<>
{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} />
</>
<AdvisorMessageList
messages={messages}
isTyping={isTyping}
messagesEndRef={messagesEndRef}
/>
)}
</div>
@@ -461,18 +243,8 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
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 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>
) : (
@@ -481,18 +253,8 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
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 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>
)}

View File

@@ -0,0 +1,40 @@
'use client'
// =============================================================================
// DocumentUpload icon components — extracted from DocumentUploadSection for LOC compliance
// =============================================================================
export 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>
)
export 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>
)
export 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>
)
export 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>
)
export 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>
)
export 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>
)

View File

@@ -0,0 +1,123 @@
'use client'
// =============================================================================
// QRCodeModal — extracted from DocumentUploadSection for LOC compliance
// =============================================================================
import { useEffect, useState } from 'react'
import { QRIcon, CloseIcon } from './DocumentUploadIcons'
interface QRModalProps {
isOpen: boolean
onClose: () => void
sessionId: string
onFileUploaded?: (file: File) => void
}
export 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>
)
}

View File

@@ -1,257 +1,30 @@
'use client'
import React, { useState, useCallback, useEffect } from 'react'
import React, { useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import {
type UploadedDocument,
type DocumentUploadSectionProps,
formatFileSize,
detectVersionFromFilename,
suggestNextVersion,
} from './DocumentUploadTypes'
import {
UploadIcon,
QRIcon,
DocumentIcon,
CheckIcon,
EditIcon,
CloseIcon,
} from './DocumentUploadIcons'
import { QRCodeModal } from './DocumentUploadQRModal'
// =============================================================================
// 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>
)
}
export type {
UploadedDocument,
ExtractedContent,
ExtractedSection,
DocumentUploadSectionProps,
} from './DocumentUploadTypes'
// =============================================================================
// MAIN COMPONENT

View File

@@ -0,0 +1,89 @@
// =============================================================================
// DocumentUpload shared types — extracted from DocumentUploadSection for LOC compliance
// =============================================================================
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
}
// =============================================================================
// Helper functions
// =============================================================================
export 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]
}
export 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
}
export 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('.')
}

View File

@@ -0,0 +1,169 @@
'use client'
import React, { useState } from 'react'
import { useSDK } from '@/lib/sdk'
import type { ProjectInfo, CustomerType } from '@/lib/sdk/types'
/** Map snake_case backend response to camelCase ProjectInfo */
export function normalizeProject(p: any): ProjectInfo {
return {
id: p.id,
name: p.name,
description: p.description || '',
customerType: p.customerType || p.customer_type || 'new',
status: p.status || 'active',
projectVersion: p.projectVersion ?? p.project_version ?? 1,
completionPercentage: p.completionPercentage ?? p.completion_percentage ?? 0,
createdAt: p.createdAt || p.created_at || '',
updatedAt: p.updatedAt || p.updated_at || '',
}
}
interface CreateProjectDialogProps {
open: boolean
onClose: () => void
onCreated: (project: ProjectInfo) => void
existingProjects: ProjectInfo[]
}
export function CreateProjectDialog({ open, onClose, onCreated, existingProjects }: CreateProjectDialogProps) {
const { createProject } = useSDK()
const [name, setName] = useState('')
const [customerType, setCustomerType] = useState<CustomerType>('new')
const [copyFromId, setCopyFromId] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
if (!open) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError('Projektname ist erforderlich')
return
}
setIsSubmitting(true)
setError('')
try {
const project = await createProject(
name.trim(),
customerType,
copyFromId || undefined
)
onCreated(normalizeProject(project))
setName('')
setCopyFromId('')
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Erstellen des Projekts')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6"
onClick={e => e.stopPropagation()}
>
<h2 className="text-xl font-bold text-gray-900 mb-6">Neues Projekt erstellen</h2>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Projektname *
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="z.B. KI-Produkt X, SaaS API, Tochter GmbH..."
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 outline-none"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Projekttyp
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setCustomerType('new')}
className={`p-3 rounded-lg border-2 text-left transition-all ${
customerType === 'new'
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="font-medium text-sm text-gray-900">Neukunde</div>
<div className="text-xs text-gray-500 mt-0.5">Compliance von Grund auf</div>
</button>
<button
type="button"
onClick={() => setCustomerType('existing')}
className={`p-3 rounded-lg border-2 text-left transition-all ${
customerType === 'existing'
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="font-medium text-sm text-gray-900">Bestandskunde</div>
<div className="text-xs text-gray-500 mt-0.5">Bestehende Dokumente erweitern</div>
</button>
</div>
</div>
{existingProjects.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Stammdaten kopieren von (optional)
</label>
<select
value={copyFromId}
onChange={e => setCopyFromId(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 outline-none bg-white"
>
<option value=""> Keine Kopie (leeres Projekt) </option>
{existingProjects.map(p => (
<option key={p.id} value={p.id}>
{p.name} (V{String(p.projectVersion).padStart(3, '0')})
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">
Firmenprofil wird kopiert und kann dann unabhaengig bearbeitet werden.
</p>
</div>
)}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim()}
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:bg-purple-300 rounded-lg transition-colors"
>
{isSubmitting ? 'Erstelle...' : 'Projekt erstellen'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,140 @@
'use client'
import React, { useState } from 'react'
import type { ProjectInfo } from '@/lib/sdk/types'
type ActionStep = 'choose' | 'confirm-delete'
interface ProjectActionDialogProps {
project: ProjectInfo
onArchive: () => void
onPermanentDelete: () => void
onCancel: () => void
isProcessing: boolean
}
export function ProjectActionDialog({
project,
onArchive,
onPermanentDelete,
onCancel,
isProcessing,
}: ProjectActionDialogProps) {
const [step, setStep] = useState<ActionStep>('choose')
if (step === 'confirm-delete') {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<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>
</div>
<h2 className="text-lg font-bold text-red-700">Endgueltig loeschen</h2>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-sm text-red-800 font-medium mb-2">
Sind Sie sicher, dass Sie <strong>{project.name}</strong> unwiderruflich loeschen moechten?
</p>
<p className="text-sm text-red-700">
Alle Projektdaten, SDK-States und Dokumente werden permanent geloescht. Diese Aktion kann nicht rueckgaengig gemacht werden.
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setStep('choose')}
className="flex-1 px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Zurueck
</button>
<button
onClick={onPermanentDelete}
disabled={isProcessing}
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-red-700 hover:bg-red-800 disabled:bg-red-300 rounded-lg transition-colors"
>
{isProcessing ? 'Loesche...' : 'Endgueltig loeschen'}
</button>
</div>
</div>
</div>
)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<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="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>
</div>
<h2 className="text-lg font-bold text-gray-900">Projekt entfernen</h2>
</div>
<p className="text-gray-600 mb-6">
Was moechten Sie mit dem Projekt <strong>{project.name}</strong> tun?
</p>
<div className="space-y-3 mb-6">
<button
onClick={onArchive}
disabled={isProcessing}
className="w-full flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all text-left disabled:opacity-50"
>
<div className="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</div>
<div>
<div className="font-medium text-gray-900">Archivieren</div>
<div className="text-sm text-gray-500">
Projekt wird ausgeblendet, Daten bleiben erhalten. Kann jederzeit wiederhergestellt werden.
</div>
</div>
</button>
<button
onClick={() => setStep('confirm-delete')}
disabled={isProcessing}
className="w-full flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-red-300 hover:bg-red-50 transition-all text-left disabled:opacity-50"
>
<div className="w-8 h-8 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-4 h-4 text-red-600" 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>
</div>
<div>
<div className="font-medium text-red-700">Endgueltig loeschen</div>
<div className="text-sm text-gray-500">
Alle Daten werden unwiderruflich geloescht. Diese Aktion kann nicht rueckgaengig gemacht werden.
</div>
</div>
</button>
</div>
<button
onClick={onCancel}
className="w-full px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Abbrechen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,128 @@
'use client'
import React from 'react'
import type { ProjectInfo } from '@/lib/sdk/types'
export function formatTimeAgo(dateStr: string): string {
if (!dateStr) return ''
const date = new Date(dateStr)
if (isNaN(date.getTime())) return ''
const now = Date.now()
const diff = now - date.getTime()
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return 'Gerade eben'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `vor ${minutes} Min`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `vor ${hours} Std`
const days = Math.floor(hours / 24)
return `vor ${days} Tag${days > 1 ? 'en' : ''}`
}
interface ProjectCardProps {
project: ProjectInfo
onClick: () => void
onDelete?: () => void
onRestore?: () => void
}
export function ProjectCard({
project,
onClick,
onDelete,
onRestore,
}: ProjectCardProps) {
const timeAgo = formatTimeAgo(project.updatedAt)
const isArchived = project.status === 'archived'
return (
<div className={`relative bg-white rounded-xl border-2 transition-all ${
isArchived
? 'border-gray-200 opacity-75'
: 'border-gray-200 hover:border-purple-300 hover:shadow-lg'
}`}>
{/* Action buttons */}
<div className="absolute top-3 right-3 flex items-center gap-1 z-10">
{isArchived && onRestore && (
<button
onClick={e => {
e.stopPropagation()
onRestore()
}}
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors"
title="Wiederherstellen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
)}
{onDelete && (
<button
onClick={e => {
e.stopPropagation()
onDelete()
}}
className="p-1.5 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="Entfernen"
>
<svg className="w-4 h-4" 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>
{/* Card content */}
<button
onClick={onClick}
className="block w-full text-left p-6"
>
<div className="flex items-start justify-between mb-3 pr-16">
<h3 className={`font-semibold text-lg truncate pr-2 ${isArchived ? 'text-gray-500' : 'text-gray-900'}`}>
{project.name}
</h3>
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${
isArchived
? 'bg-gray-100 text-gray-500'
: 'bg-green-100 text-green-700'
}`}>
{isArchived ? 'Archiviert' : 'Aktiv'}
</span>
</div>
{project.description && (
<p className="text-sm text-gray-500 mb-3 line-clamp-2">{project.description}</p>
)}
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="font-mono">V{String(project.projectVersion).padStart(3, '0')}</span>
<span className="text-gray-300">|</span>
<div className="flex items-center gap-2 flex-1">
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
project.completionPercentage === 100 ? 'bg-green-500' : isArchived ? 'bg-gray-400' : 'bg-purple-600'
}`}
style={{ width: `${project.completionPercentage}%` }}
/>
</div>
<span className="font-medium text-gray-600">{project.completionPercentage}%</span>
</div>
{timeAgo && (
<>
<span className="text-gray-300">|</span>
<span>{timeAgo}</span>
</>
)}
</div>
<div className="mt-2 text-xs text-gray-400">
{project.customerType === 'new' ? 'Neukunde' : 'Bestandskunde'}
</div>
</button>
</div>
)
}

View File

@@ -3,454 +3,10 @@
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import type { ProjectInfo, CustomerType } from '@/lib/sdk/types'
// =============================================================================
// HELPERS
// =============================================================================
/** Map snake_case backend response to camelCase ProjectInfo */
function normalizeProject(p: any): ProjectInfo {
return {
id: p.id,
name: p.name,
description: p.description || '',
customerType: p.customerType || p.customer_type || 'new',
status: p.status || 'active',
projectVersion: p.projectVersion ?? p.project_version ?? 1,
completionPercentage: p.completionPercentage ?? p.completion_percentage ?? 0,
createdAt: p.createdAt || p.created_at || '',
updatedAt: p.updatedAt || p.updated_at || '',
}
}
function formatTimeAgo(dateStr: string): string {
if (!dateStr) return ''
const date = new Date(dateStr)
if (isNaN(date.getTime())) return ''
const now = Date.now()
const diff = now - date.getTime()
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return 'Gerade eben'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `vor ${minutes} Min`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `vor ${hours} Std`
const days = Math.floor(hours / 24)
return `vor ${days} Tag${days > 1 ? 'en' : ''}`
}
// =============================================================================
// CREATE PROJECT DIALOG
// =============================================================================
interface CreateProjectDialogProps {
open: boolean
onClose: () => void
onCreated: (project: ProjectInfo) => void
existingProjects: ProjectInfo[]
}
function CreateProjectDialog({ open, onClose, onCreated, existingProjects }: CreateProjectDialogProps) {
const { createProject } = useSDK()
const [name, setName] = useState('')
const [customerType, setCustomerType] = useState<CustomerType>('new')
const [copyFromId, setCopyFromId] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
if (!open) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError('Projektname ist erforderlich')
return
}
setIsSubmitting(true)
setError('')
try {
const project = await createProject(
name.trim(),
customerType,
copyFromId || undefined
)
onCreated(normalizeProject(project))
setName('')
setCopyFromId('')
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Erstellen des Projekts')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6"
onClick={e => e.stopPropagation()}
>
<h2 className="text-xl font-bold text-gray-900 mb-6">Neues Projekt erstellen</h2>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Project Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Projektname *
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="z.B. KI-Produkt X, SaaS API, Tochter GmbH..."
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 outline-none"
autoFocus
/>
</div>
{/* Customer Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Projekttyp
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setCustomerType('new')}
className={`p-3 rounded-lg border-2 text-left transition-all ${
customerType === 'new'
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="font-medium text-sm text-gray-900">Neukunde</div>
<div className="text-xs text-gray-500 mt-0.5">Compliance von Grund auf</div>
</button>
<button
type="button"
onClick={() => setCustomerType('existing')}
className={`p-3 rounded-lg border-2 text-left transition-all ${
customerType === 'existing'
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="font-medium text-sm text-gray-900">Bestandskunde</div>
<div className="text-xs text-gray-500 mt-0.5">Bestehende Dokumente erweitern</div>
</button>
</div>
</div>
{/* Copy from existing project */}
{existingProjects.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Stammdaten kopieren von (optional)
</label>
<select
value={copyFromId}
onChange={e => setCopyFromId(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 outline-none bg-white"
>
<option value=""> Keine Kopie (leeres Projekt) </option>
{existingProjects.map(p => (
<option key={p.id} value={p.id}>
{p.name} (V{String(p.projectVersion).padStart(3, '0')})
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">
Firmenprofil wird kopiert und kann dann unabhaengig bearbeitet werden.
</p>
</div>
)}
{/* Error */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim()}
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:bg-purple-300 rounded-lg transition-colors"
>
{isSubmitting ? 'Erstelle...' : 'Projekt erstellen'}
</button>
</div>
</form>
</div>
</div>
)
}
// =============================================================================
// PROJECT ACTION DIALOG (Archive / Permanent Delete)
// =============================================================================
type ActionStep = 'choose' | 'confirm-delete'
function ProjectActionDialog({
project,
onArchive,
onPermanentDelete,
onCancel,
isProcessing,
}: {
project: ProjectInfo
onArchive: () => void
onPermanentDelete: () => void
onCancel: () => void
isProcessing: boolean
}) {
const [step, setStep] = useState<ActionStep>('choose')
if (step === 'confirm-delete') {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<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>
</div>
<h2 className="text-lg font-bold text-red-700">Endgueltig loeschen</h2>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-sm text-red-800 font-medium mb-2">
Sind Sie sicher, dass Sie <strong>{project.name}</strong> unwiderruflich loeschen moechten?
</p>
<p className="text-sm text-red-700">
Alle Projektdaten, SDK-States und Dokumente werden permanent geloescht. Diese Aktion kann nicht rueckgaengig gemacht werden.
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setStep('choose')}
className="flex-1 px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Zurueck
</button>
<button
onClick={onPermanentDelete}
disabled={isProcessing}
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-red-700 hover:bg-red-800 disabled:bg-red-300 rounded-lg transition-colors"
>
{isProcessing ? 'Loesche...' : 'Endgueltig loeschen'}
</button>
</div>
</div>
</div>
)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<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="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>
</div>
<h2 className="text-lg font-bold text-gray-900">Projekt entfernen</h2>
</div>
<p className="text-gray-600 mb-6">
Was moechten Sie mit dem Projekt <strong>{project.name}</strong> tun?
</p>
<div className="space-y-3 mb-6">
{/* Archive option */}
<button
onClick={onArchive}
disabled={isProcessing}
className="w-full flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all text-left disabled:opacity-50"
>
<div className="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</div>
<div>
<div className="font-medium text-gray-900">Archivieren</div>
<div className="text-sm text-gray-500">
Projekt wird ausgeblendet, Daten bleiben erhalten. Kann jederzeit wiederhergestellt werden.
</div>
</div>
</button>
{/* Permanent delete option */}
<button
onClick={() => setStep('confirm-delete')}
disabled={isProcessing}
className="w-full flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-red-300 hover:bg-red-50 transition-all text-left disabled:opacity-50"
>
<div className="w-8 h-8 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-4 h-4 text-red-600" 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>
</div>
<div>
<div className="font-medium text-red-700">Endgueltig loeschen</div>
<div className="text-sm text-gray-500">
Alle Daten werden unwiderruflich geloescht. Diese Aktion kann nicht rueckgaengig gemacht werden.
</div>
</div>
</button>
</div>
<button
onClick={onCancel}
className="w-full px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Abbrechen
</button>
</div>
</div>
)
}
// =============================================================================
// PROJECT CARD
// =============================================================================
function ProjectCard({
project,
onClick,
onDelete,
onRestore,
}: {
project: ProjectInfo
onClick: () => void
onDelete?: () => void
onRestore?: () => void
}) {
const timeAgo = formatTimeAgo(project.updatedAt)
const isArchived = project.status === 'archived'
return (
<div className={`relative bg-white rounded-xl border-2 transition-all ${
isArchived
? 'border-gray-200 opacity-75'
: 'border-gray-200 hover:border-purple-300 hover:shadow-lg'
}`}>
{/* Action buttons */}
<div className="absolute top-3 right-3 flex items-center gap-1 z-10">
{isArchived && onRestore && (
<button
onClick={e => {
e.stopPropagation()
onRestore()
}}
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors"
title="Wiederherstellen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
)}
{onDelete && (
<button
onClick={e => {
e.stopPropagation()
onDelete()
}}
className="p-1.5 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="Entfernen"
>
<svg className="w-4 h-4" 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>
{/* Card content */}
<button
onClick={onClick}
className="block w-full text-left p-6"
>
<div className="flex items-start justify-between mb-3 pr-16">
<h3 className={`font-semibold text-lg truncate pr-2 ${isArchived ? 'text-gray-500' : 'text-gray-900'}`}>
{project.name}
</h3>
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${
isArchived
? 'bg-gray-100 text-gray-500'
: 'bg-green-100 text-green-700'
}`}>
{isArchived ? 'Archiviert' : 'Aktiv'}
</span>
</div>
{project.description && (
<p className="text-sm text-gray-500 mb-3 line-clamp-2">{project.description}</p>
)}
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="font-mono">V{String(project.projectVersion).padStart(3, '0')}</span>
<span className="text-gray-300">|</span>
<div className="flex items-center gap-2 flex-1">
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
project.completionPercentage === 100 ? 'bg-green-500' : isArchived ? 'bg-gray-400' : 'bg-purple-600'
}`}
style={{ width: `${project.completionPercentage}%` }}
/>
</div>
<span className="font-medium text-gray-600">{project.completionPercentage}%</span>
</div>
{timeAgo && (
<>
<span className="text-gray-300">|</span>
<span>{timeAgo}</span>
</>
)}
</div>
<div className="mt-2 text-xs text-gray-400">
{project.customerType === 'new' ? 'Neukunde' : 'Bestandskunde'}
</div>
</button>
</div>
)
}
// =============================================================================
// MAIN COMPONENT
// =============================================================================
import type { ProjectInfo } from '@/lib/sdk/types'
import { CreateProjectDialog, normalizeProject } from './CreateProjectDialog'
import { ProjectActionDialog } from './ProjectActionDialog'
import { ProjectCard } from './ProjectCard'
export function ProjectSelector() {
const router = useRouter()
@@ -494,7 +50,6 @@ export function ProjectSelector() {
setIsProcessing(true)
try {
await archiveProject(actionTarget.id)
// Move from active to archived
setProjects(prev => prev.filter(p => p.id !== actionTarget.id))
setArchivedProjects(prev => [...prev, { ...actionTarget, status: 'archived' as const }])
setActionTarget(null)

View File

@@ -11,301 +11,10 @@
* - 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
}
// Filter steps based on visibleWhen conditions
const getVisibleStepsForPackage = (packageId: SDKPackageId): SDKStep[] => {
const steps = getStepsForPackage(packageId)
return steps.filter(step => {
if (step.visibleWhen) return step.visibleWhen(state)
return true
})
}
return (
<div className="space-y-4">
{/* Packages */}
{SDK_PACKAGES.map(pkg => (
<PackageSection
key={pkg.id}
pkg={pkg}
steps={getVisibleStepsForPackage(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>
)
}
import { useSDK } from '@/lib/sdk'
import { CloseIcon, PipelineIcon } from './SDKPipelineSidebarIcons'
import { SidebarContent } from './SDKPipelineSidebarParts'
// =============================================================================
// MAIN COMPONENT - RESPONSIVE
@@ -336,7 +45,7 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
localStorage.setItem('sdk-pipeline-sidebar-collapsed', String(newState))
}
// Close drawer on route change or escape key
// Close drawer on escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
@@ -364,6 +73,17 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
? 'right-4 bottom-6'
: 'left-4 bottom-6'
const progressCircle = (
<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>
)
return (
<>
{/* Desktop: Fixed Sidebar (when expanded) */}
@@ -374,16 +94,10 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
<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>
<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>
<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
@@ -396,7 +110,6 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
</button>
</div>
</div>
{/* Content */}
<div className="p-3 max-h-[calc(100vh-200px)] overflow-y-auto">
<SidebarContent onNavigate={() => {}} />
@@ -409,35 +122,12 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
{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`}
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>
{progressCircle}
</button>
)}
@@ -448,30 +138,7 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
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>
{progressCircle}
</button>
{/* Mobile/Tablet: Drawer Overlay */}
@@ -482,22 +149,15 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
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>
<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>
<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
@@ -508,7 +168,6 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
<CloseIcon />
</button>
</div>
{/* Drawer Content */}
<div className="p-4 overflow-y-auto max-h-[calc(100vh-80px)]">
<SidebarContent onNavigate={() => setIsMobileOpen(false)} />
@@ -520,12 +179,8 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
{/* CSS for slide-in animation */}
<style jsx>{`
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.animate-slide-in-right {
animation: slide-in-right 0.2s ease-out;

View File

@@ -0,0 +1,35 @@
'use client'
// =============================================================================
// SDKPipelineSidebar - Icon Components
// =============================================================================
export 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>
)
export 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>
)
export 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>
)
export 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>
)
export 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>
)

View File

@@ -0,0 +1,264 @@
'use client'
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'
import { CheckIcon, LockIcon, ArrowIcon } from './SDKPipelineSidebarIcons'
// =============================================================================
// STEP ITEM
// =============================================================================
interface StepItemProps {
step: SDKStep
isActive: boolean
isCompleted: boolean
onNavigate: () => void
}
export 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
}
export 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
// =============================================================================
export 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
}
export 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
}
// Filter steps based on visibleWhen conditions
const getVisibleStepsForPackage = (packageId: SDKPackageId): SDKStep[] => {
const steps = getStepsForPackage(packageId)
return steps.filter(step => {
if (step.visibleWhen) return step.visibleWhen(state)
return true
})
}
return (
<div className="space-y-4">
{/* Packages */}
{SDK_PACKAGES.map(pkg => (
<PackageSection
key={pkg.id}
pkg={pkg}
steps={getVisibleStepsForPackage(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>
)
}

View File

@@ -17,8 +17,13 @@ import {
PackageIndicator,
StepItem,
CorpusStalenessInfo,
withProject,
} from './SidebarSubComponents'
import { SidebarModuleNav } from './SidebarModuleNav'
import { SidebarModuleList } from './SidebarModuleList'
// =============================================================================
// MAIN SIDEBAR
// =============================================================================
interface SDKSidebarProps {
collapsed?: boolean
@@ -119,12 +124,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
>
<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"
/>
<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 && (
@@ -138,7 +138,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
</Link>
</div>
{/* Overall Progress - hidden when collapsed */}
{/* Overall Progress */}
{!collapsed && (
<div className="px-4 py-3 border-b border-gray-100">
<div className="flex items-center justify-between text-sm mb-2">
@@ -154,7 +154,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
<CorpusStalenessInfo ragCorpusStatus={state.ragCorpusStatus} />
)}
{/* Navigation - 5 Packages */}
{/* Navigation 5 Packages */}
<nav className="flex-1 overflow-y-auto">
{SDK_PACKAGES.map(pkg => {
const steps = getVisibleStepsForPackage(pkg.id)
@@ -195,8 +195,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
)
})}
<SidebarModuleNav
pathname={pathname}
<SidebarModuleList
collapsed={collapsed}
projectId={projectId}
pendingCRCount={pendingCRCount}
@@ -220,12 +219,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
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"
/>
<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>

View File

@@ -1,4 +1,8 @@
import React from 'react'
'use client'
// =============================================================================
// SIDEBAR ICON COMPONENTS
// Small SVG icons used in SDKSidebar sub-components.
// =============================================================================
export const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -0,0 +1,108 @@
'use client'
// =============================================================================
// SIDEBAR ADDITIONAL MODULE LIST
// The "Zusatzmodule" and "Maschinenrecht / CE" sections rendered in the nav.
// =============================================================================
import React from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { AdditionalModuleItem, withProject } from './SidebarSubComponents'
interface SidebarModuleListProps {
collapsed: boolean
projectId?: string
pendingCRCount: number
}
export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: SidebarModuleListProps) {
const pathname = usePathname()
return (
<>
{/* Maschinenrecht / CE */}
<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">
Maschinenrecht / CE
</div>
)}
<AdditionalModuleItem
href="/sdk/iace"
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 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
}
label="CE-Compliance (IACE)"
isActive={pathname?.startsWith('/sdk/iace') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
</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/training" 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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>} label="Schulung (Admin)" isActive={pathname === '/sdk/training'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/training/learner" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>} label="Schulung (Learner)" isActive={pathname === '/sdk/training/learner'} collapsed={collapsed} projectId={projectId} />
<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} projectId={projectId} />
<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} projectId={projectId} />
<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} projectId={projectId} />
<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} projectId={projectId} />
<AdditionalModuleItem href="/sdk/assertions" 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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" /></svg>} label="Assertions" isActive={pathname === '/sdk/assertions'} collapsed={collapsed} projectId={projectId} />
<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} projectId={projectId} />
<AdditionalModuleItem href="/sdk/sdk-flow" icon={<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>} label="SDK Flow" isActive={pathname === '/sdk/sdk-flow'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/architecture" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>} label="Architektur" isActive={pathname === '/sdk/architecture'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/agents" 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.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="Agenten" isActive={pathname?.startsWith('/sdk/agents') ?? false} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/workshop" icon={<svg className="w-5 h-5" 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 0z" /></svg>} label="Workshop" isActive={pathname === '/sdk/workshop'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/portfolio" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>} label="Portfolio" isActive={pathname === '/sdk/portfolio'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/roadmap" 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 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>} label="Roadmap" isActive={pathname === '/sdk/roadmap'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/isms" 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="ISMS (ISO 27001)" isActive={pathname === '/sdk/isms'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/audit-llm" 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="LLM Audit" isActive={pathname === '/sdk/audit-llm'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/rbac" 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="RBAC Admin" isActive={pathname === '/sdk/rbac'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/catalog-manager" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" /></svg>} label="Kataloge" isActive={pathname === '/sdk/catalog-manager'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/wiki" 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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>} label="Compliance Wiki" isActive={pathname?.startsWith('/sdk/wiki')} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/api-docs" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>} label="API-Referenz" isActive={pathname === '/sdk/api-docs'} collapsed={collapsed} projectId={projectId} />
{/* Change Requests — needs badge so handled directly */}
<Link
href={withProject('/sdk/change-requests', projectId)}
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
collapsed ? 'justify-center' : ''
} ${
pathname === '/sdk/change-requests'
? 'bg-purple-100 text-purple-900 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
title={collapsed ? `Änderungsanfragen (${pendingCRCount})` : undefined}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
{!collapsed && (
<span className="flex items-center gap-2">
Änderungsanfragen
{pendingCRCount > 0 && (
<span className="px-1.5 py-0.5 text-xs font-bold bg-red-500 text-white rounded-full min-w-[1.25rem] text-center">
{pendingCRCount}
</span>
)}
</span>
)}
{collapsed && pendingCRCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</Link>
<AdditionalModuleItem href="https://macmini:3006" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>} label="Developer Portal" isActive={false} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="https://macmini:8011" 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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>} label="SDK Dokumentation" isActive={false} collapsed={collapsed} projectId={projectId} />
</div>
</>
)
}

View File

@@ -1,17 +1,28 @@
'use client'
// =============================================================================
// SIDEBAR SUB-COMPONENTS
// ProgressBar, PackageIndicator, StepItem, AdditionalModuleItem,
// CorpusStalenessInfo — all used internally by SDKSidebar.
// =============================================================================
import React from 'react'
import Link from 'next/link'
import type { SDKStep, RAGCorpusStatus, SDKPackageId } from '@/lib/sdk'
import type { SDKStep, SDKPackageId, RAGCorpusStatus } from '@/lib/sdk'
import { CheckIcon, LockIcon, WarningIcon, ChevronDownIcon } from './SidebarIcons'
function withProject(url: string, projectId?: string): string {
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function withProject(url: string, projectId?: string): string {
if (!projectId) return url
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}project=${projectId}`
}
// =============================================================================
// PROGRESS BAR
// =============================================================================
// ---------------------------------------------------------------------------
// ProgressBar
// ---------------------------------------------------------------------------
interface ProgressBarProps {
value: number
@@ -29,9 +40,9 @@ export function ProgressBar({ value, className = '' }: ProgressBarProps) {
)
}
// =============================================================================
// PACKAGE INDICATOR
// =============================================================================
// ---------------------------------------------------------------------------
// PackageIndicator
// ---------------------------------------------------------------------------
interface PackageIndicatorProps {
packageId: SDKPackageId
@@ -125,9 +136,9 @@ export function PackageIndicator({
)
}
// =============================================================================
// STEP ITEM
// =============================================================================
// ---------------------------------------------------------------------------
// StepItem
// ---------------------------------------------------------------------------
interface StepItemProps {
step: SDKStep
@@ -202,15 +213,15 @@ export function StepItem({ step, isActive, isCompleted, isLocked, checkpointStat
)
}
// =============================================================================
// ADDITIONAL MODULE ITEM
// =============================================================================
// ---------------------------------------------------------------------------
// AdditionalModuleItem
// ---------------------------------------------------------------------------
interface AdditionalModuleItemProps {
href: string
icon: React.ReactNode
label: string
isActive: boolean | undefined
isActive: boolean
collapsed: boolean
projectId?: string
}
@@ -249,9 +260,9 @@ export function AdditionalModuleItem({ href, icon, label, isActive, collapsed, p
)
}
// =============================================================================
// CORPUS STALENESS INFO
// =============================================================================
// ---------------------------------------------------------------------------
// CorpusStalenessInfo
// ---------------------------------------------------------------------------
export function CorpusStalenessInfo({ ragCorpusStatus }: { ragCorpusStatus: RAGCorpusStatus }) {
const collections = ragCorpusStatus.collections
@@ -270,7 +281,9 @@ export function CorpusStalenessInfo({ ragCorpusStatus }: { ragCorpusStatus: RAGC
<div className="px-4 py-2 border-b border-gray-100">
<div className="flex items-center gap-2 text-xs">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${daysSinceUpdate > 30 ? 'bg-amber-400' : 'bg-green-400'}`} />
<span className="text-gray-500 truncate">RAG Corpus: {totalChunks} Chunks</span>
<span className="text-gray-500 truncate">
RAG Corpus: {totalChunks} Chunks
</span>
</div>
{daysSinceUpdate > 30 && (
<div className="mt-1 text-xs text-amber-600 bg-amber-50 rounded px-2 py-1">

View File

@@ -0,0 +1,16 @@
// =============================================================================
// STEP EXPLANATION PRESETS — merged index
// Combines Part1 (company-profile … einwilligungen) and
// Part2 (dsr … use-case-workshop) for backward-compatible re-export.
// =============================================================================
import type { StepTip } from './StepHeader'
import { STEP_EXPLANATIONS_PART1 } from './StepExplanationsPart1'
import { STEP_EXPLANATIONS_PART2 } from './StepExplanationsPart2'
type ExplanationEntry = { title: string; description: string; explanation: string; tips: StepTip[] }
export const STEP_EXPLANATIONS: Record<string, ExplanationEntry> = {
...STEP_EXPLANATIONS_PART1,
...STEP_EXPLANATIONS_PART2,
}

View File

@@ -0,0 +1,197 @@
// =============================================================================
// STEP EXPLANATION PRESETS — Part 1 (company-profile … einwilligungen)
// =============================================================================
import type { StepTip } from './StepHeader'
type ExplanationEntry = { title: string; description: string; explanation: string; tips: StepTip[] }
export const STEP_EXPLANATIONS_PART1: Record<string, ExplanationEntry> = {
'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. Vollstaendige CRUD-Operationen mit Backend-Persistenz.',
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. Bei Backend-Fehler erfolgt automatischer Rollback.' },
{ icon: 'lightbulb' as const, title: 'CRUD-Operationen', description: 'Anforderungen koennen erstellt, bearbeitet und geloescht werden. Alle Aenderungen werden sofort im Backend persistiert.' },
],
},
'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. Evidence-Linking zeigt verknuepfte Nachweise mit Gueltigkeits-Badge.',
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.' },
{ icon: 'success' as const, title: 'Evidence-Linking', description: 'Verknuepfen Sie Nachweise direkt mit Controls. Gueltige, abgelaufene und ausstehende Nachweise werden mit Badges angezeigt.' },
],
},
'evidence': {
title: 'Nachweise',
description: 'Dokumentieren Sie die Umsetzung mit Belegen',
explanation: 'Nachweise sind Dokumente, Screenshots oder Berichte, die belegen, dass Kontrollen implementiert sind. Server-seitige Pagination fuer grosse Nachweis-Sammlungen.',
tips: [
{ icon: 'warning' as const, title: 'Gueltigkeit', description: 'Achten Sie auf das Ablaufdatum von Nachweisen. Abgelaufene Zertifikate oder Berichte muessen erneuert werden. Status: valid, expired, pending, failed.' },
{ icon: 'success' as const, title: 'Verknuepfung', description: 'Verknuepfen Sie Nachweise direkt mit den zugehoerigen Kontrollen fuer eine lueckenlose Dokumentation.' },
{ icon: 'info' as const, title: 'Pagination', description: 'Bei vielen Nachweisen wird automatisch paginiert. Nutzen Sie die Seitennavigation am Ende der Liste.' },
],
},
'audit-checklist': {
title: 'Audit-Checkliste',
description: 'Systematische Pruefung der Compliance-Konformitaet',
explanation: 'Die Audit-Checkliste wird automatisch aus den Anforderungen generiert. Session-Management mit Sign-Off-Workflow und PDF-Export.',
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: 'Sign-Off & PDF', description: 'Zeichnen Sie Pruefpunkte mit digitalem Hash (SHA-256) ab. Exportieren Sie den Report als PDF in Deutsch oder Englisch.' },
{ icon: 'success' as const, title: 'Session-History', description: 'Vergangene Audit-Sitzungen werden mit Status-Badges angezeigt: Draft, In Progress, Completed, Archived.' },
],
},
'risks': {
title: 'Risiko-Matrix',
description: 'Bewerten und priorisieren Sie Ihre Compliance-Risiken',
explanation: 'Die 5x5 Risiko-Matrix visualisiert Ihre Risiken nach Wahrscheinlichkeit und Auswirkung. Inherent Risk vs. Residual Risk mit visuellem Vergleich.',
tips: [
{ icon: 'warning' as const, title: 'Kritische Risiken', description: 'Risiken mit Score >= 20 sind CRITICAL (rot), >= 12 HIGH (orange), >= 6 MEDIUM (gelb), < 6 LOW (gruen).' },
{ icon: 'success' as const, title: 'Mitigation', description: 'Verknuepfen Sie Controls als Mitigationsmassnahmen. Der Residual-Risk wird automatisch anhand verknuepfter Controls berechnet.' },
{ icon: 'info' as const, title: 'Status-Workflow', description: 'Risiken durchlaufen: Identifiziert → Bewertet → Mitigiert → Akzeptiert → Geschlossen.' },
],
},
'ai-act': {
title: 'AI Act Klassifizierung',
description: 'Registrieren und klassifizieren Sie Ihre KI-Systeme',
explanation: 'Der EU AI Act klassifiziert KI-Systeme in Risikostufen: Minimal, Begrenzt, Hoch und Verboten. KI-Systeme werden im Backend persistent gespeichert und koennen automatisch bewertet werden.',
tips: [
{ icon: 'warning' as const, title: 'Hochrisiko-Systeme', description: 'Hochrisiko-KI erfordert 8 Pflichten: Risikomanagement, Daten-Governance, Dokumentation, Transparenz, menschliche Aufsicht, Genauigkeit, Robustheit, Cybersicherheit.' },
{ icon: 'lightbulb' as const, title: 'Automatische Bewertung', description: 'Nutzen Sie die Assess-Funktion: Sie analysiert Zweck und Sektor und leitet die Risikokategorie + Pflichten automatisch ab.' },
{ icon: 'info' as const, title: 'CRUD-Operationen', description: 'KI-Systeme koennen registriert, bearbeitet, bewertet und geloescht werden. Alle Daten werden backend-persistent gespeichert.' },
],
},
'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: 'TOMs nach Art. 32 DSGVO mit Vendor-Controls-Querverweis',
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. Im Uebersicht-Tab werden zusaetzlich Vendor-TOM-Controls (VND-TOM-01 bis VND-TOM-06) aus dem Vendor-Compliance-Modul als Querverweis angezeigt.',
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.' },
{ icon: 'success' as const, title: 'Vendor-Controls', description: 'Im Uebersicht-Tab werden Vendor-TOM-Controls (VND-TOM-01 bis 06) als Read-Only-Querverweis angezeigt: Verschluesselung, Zugriffskontrolle, Verfuegbarkeit und Ueberpruefungsverfahren Ihrer Auftragsverarbeiter.' },
],
},
'vvt': {
title: 'Verarbeitungsverzeichnis',
description: 'Verarbeitungsverzeichnis nach Art. 30 DSGVO mit integriertem Processor-Tab',
explanation: 'Das Verarbeitungsverzeichnis (VVT) dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Der integrierte Generator-Fragebogen befuellt 70-90% der Pflichtfelder automatisch. Der Tab "Auftragsverarbeiter (Abs. 2)" liest Vendors mit role=PROCESSOR/SUB_PROCESSOR direkt aus der Vendor-Compliance-API — keine doppelte Datenhaltung.',
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).' },
{ icon: 'success' as const, title: 'Processor-Tab (Art. 30 Abs. 2)', description: 'Auftragsverarbeiter werden direkt aus dem Vendor Register gelesen (Read-Only). Neue Vendors werden im Vendor-Compliance-Modul angelegt und erscheinen hier automatisch. PDF-Druck fuer Art. 30 Abs. 2 Dokument.' },
],
},
'cookie-banner': {
title: 'Cookie Banner',
description: 'Konfigurieren Sie einen DSGVO-konformen Cookie Banner mit persistenter DB-Speicherung',
explanation: 'Der Cookie Banner Generator erstellt einen rechtssicheren Banner mit Opt-In fuer nicht-essentielle Cookies. Alle Einstellungen — einschliesslich Ueberschrift, Beschreibung und Datenschutz-Link — werden in der Datenbank gespeichert und bleiben auch nach einem Neustart erhalten. Der generierte Embed-Code wird direkt aus der gespeicherten Konfiguration erzeugt.',
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 und Texte', description: 'Passen Sie Ueberschrift, Beschreibung und Farben an Ihr Corporate Design an. Aenderungen werden in der Vorschau sofort sichtbar.' },
{ icon: 'info' as const, title: 'Embed-Code', description: 'Der Code exportiert einen vollstaendigen HTML+CSS+JS-Block aus Ihrer gespeicherten Konfiguration — einfach vor dem schliessenden </body>-Tag einbinden.' },
],
},
'obligations': {
title: 'Pflichtenuebersicht',
description: 'Regulatorische Pflichten mit 12 Compliance-Checks und Vendor-Verknuepfung',
explanation: 'Die Pflichtenuebersicht aggregiert alle Anforderungen aus DSGVO, AI Act, NIS2 und weiteren Regulierungen. 12 automatische Compliance-Checks pruefen Vollstaendigkeit, Fristen, Nachweise und Vendor-Verknuepfungen. Art.-28-Pflichten koennen mit Auftragsverarbeitern aus dem Vendor Register verknuepft werden. Das Pflichtenregister-Dokument (11 Sektionen) kann als auditfaehiges PDF gedruckt werden.',
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: 'Compliance-Checks', description: '12 automatische Checks: Fehlende Verantwortliche, ueberfaellige Fristen, fehlende Nachweise, keine Rechtsreferenz, stagnierende Regulierungen, nicht gestartete High-Priority-Pflichten, fehlende Vendor-Verknuepfung (Art. 28) u.v.m.' },
{ icon: 'success' as const, title: 'Vendor-Verknuepfung', description: 'Art.-28-Pflichten (Auftragsverarbeitung) koennen direkt mit Vendors aus dem Vendor Register verknuepft werden. Check #12 (MISSING_VENDOR_LINK) warnt bei fehlender Verknuepfung.' },
{ icon: 'lightbulb' as const, title: 'Pflichtenregister-Dokument', description: 'Generieren Sie ein auditfaehiges Pflichtenregister mit 11 Sektionen: Ziel, Geltungsbereich, Methodik, Regulatorische Grundlagen, Pflichtenuebersicht, Details, Verantwortlichkeiten, Fristen, Nachweisverzeichnis, Compliance-Status und Aenderungshistorie.' },
],
},
'loeschfristen': {
title: 'Loeschfristen',
description: 'Aufbewahrungsrichtlinien mit VVT-Verknuepfung und Vendor-Zuordnung',
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. Policies koennen mit VVT-Verarbeitungstaetigkeiten und Auftragsverarbeitern aus dem Vendor Register verknuepft 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.' },
{ icon: 'success' as const, title: 'Vendor-Verknuepfung', description: 'Loeschfrist-Policies koennen mit Auftragsverarbeitern verknuepft werden. So ist dokumentiert, welche Vendors Loeschpflichten fuer bestimmte Datenkategorien haben.' },
],
},
'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.' },
],
},
}

View File

@@ -0,0 +1,208 @@
// =============================================================================
// STEP EXPLANATION PRESETS — Part 2 (dsr … use-case-workshop)
// =============================================================================
import type { StepTip } from './StepHeader'
type ExplanationEntry = { title: string; description: string; explanation: string; tips: StepTip[] }
export const STEP_EXPLANATIONS_PART2: Record<string, ExplanationEntry> = {
'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.' },
],
},
'vendor-compliance': {
title: 'Vendor Compliance',
description: 'Auftragsverarbeiter-Management mit Cross-Modul-Integration',
explanation: 'Vendor Compliance verwaltet alle Auftragsverarbeiter (Art. 28 DSGVO) und Drittanbieter. Fuer jeden Vendor werden AVVs, Drittlandtransfers, TOMs und Subunternehmer geprueft. Das Modul ist zentral mit vier weiteren Modulen integriert: VVT-Processor-Tab liest Vendors direkt aus der API, Obligations und Loeschfristen verknuepfen Vendors ueber linked_vendor_ids, TOM zeigt Vendor-Controls als Querverweis.',
tips: [
{ icon: 'warning' as const, title: 'Art. 28 DSGVO', description: 'Jede Auftragsverarbeitung erfordert einen schriftlichen Vertrag (AVV). Pruefen Sie: Weisungsgebundenheit, TOMs, Subunternehmer-Genehmigung, Loeschpflicht und Audit-Recht.' },
{ icon: 'info' as const, title: 'Cross-Modul-Integration', description: 'Vendors erscheinen automatisch im VVT-Processor-Tab, koennen in Obligations und Loeschfristen verknuepft werden, und ihre TOM-Controls werden im TOM-Modul als Querverweis angezeigt.' },
{ icon: 'lightbulb' as const, title: 'Drittlandtransfer', description: 'Bei Datenverarbeitung ausserhalb der EU/EWR sind Standardvertragsklauseln (SCCs) oder andere Garantien nach Art. 44-49 DSGVO erforderlich.' },
{ icon: 'success' as const, title: 'Controls Library', description: '6 TOM-Domain Controls (VND-TOM-01 bis VND-TOM-06) pruefen Verschluesselung, Zugriffskontrolle, Verfuegbarkeit und Ueberpruefungsverfahren bei Ihren Auftragsverarbeitern.' },
],
},
'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. Uebersicht mit Status-Badges, Detail-Seite pro Sitzung mit Fortschrittsbalken und interaktiven Checklist-Items.',
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: 'Detail-Ansicht', description: 'Klicken Sie auf eine Sitzung fuer die Detail-Seite: Metadaten, Fortschrittsbalken, Checklist-Items mit Sign-Off und Notizen.' },
{ icon: 'info' as const, title: 'PDF-Export', description: 'Generieren Sie PDF-Reports in Deutsch oder Englisch fuer externe Pruefer und Aufsichtsbehoerden.' },
],
},
'workflow': {
title: 'Document Workflow',
description: 'Freigabe-Workflow mit Split-View-Editor und DB-persistenter Versionierung',
explanation: 'Der Document Workflow bietet einen Split-View-Editor: links die veroffentlichte Version, rechts der aktuelle Entwurf. Dokumente durchlaufen den Status Draft → Review → Approved → Published. Alle Versionen werden in der Datenbank gespeichert. Word-Dokumente koennen direkt als neue Version importiert werden.',
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. Veroeffentlichte Versionen sind unveraenderlich — Aenderungen erzeugen stets eine neue Version.' },
{ icon: 'lightbulb' as const, title: 'DOCX-Import', description: 'Bestehende Word-Dokumente koennen direkt hochgeladen und als Basis fuer neue Versionen verwendet werden.' },
],
},
'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.' },
],
},
'academy': {
title: 'Compliance Academy',
description: 'E-Learning-Plattform fuer Mitarbeiterschulungen',
explanation: 'Die Compliance Academy ermoeglicht KI-generierte Schulungsvideos mit interaktiven Quizfragen und PDF-Zertifikaten. Unternehmen muessen Mitarbeiter regelmaessig in Datenschutz, IT-Sicherheit und KI-Kompetenz schulen (DSGVO Art. 39 Abs. 1 lit. b, EU AI Act Art. 4).',
tips: [
{ icon: 'info' as const, title: 'Schulungspflicht', description: 'DSGVO Art. 39 Abs. 1 lit. b verpflichtet den DSB zur Sensibilisierung und Schulung aller Mitarbeiter.' },
{ icon: 'lightbulb' as const, title: 'Zertifikate', description: 'Schulungszertifikate dienen als Audit-Nachweis. Sie dokumentieren Teilnahme, Testergebnis und Gueltigkeit.' },
],
},
'whistleblower': {
title: 'Hinweisgebersystem',
description: 'Interne Meldestelle gemaess Hinweisgeberschutzgesetz (HinSchG) — seit 17. Dezember 2023 Pflicht fuer alle Unternehmen ab 50 Beschaeftigten',
explanation: 'Das Hinweisgebersystem implementiert eine HinSchG-konforme interne Meldestelle fuer die sichere, auch anonyme Meldung von Rechtsverstoessen. Es setzt die EU-Whistleblowing-Richtlinie (2019/1937) in deutsches Recht um. Beschaeftigungsgeber mit mindestens 50 Beschaeftigten sind zur Einrichtung verpflichtet (§ 12 HinSchG). Das System unterstuetzt den gesamten Meldeprozess: Einreichung, Eingangsbestaetigung (7-Tage-Frist), Sachverhaltspruefung, Folgemaßnahmen und Rueckmeldung (3-Monate-Frist).',
tips: [
{ icon: 'warning' as const, title: 'Pflicht ab 50 Beschaeftigten', description: 'Seit 17.12.2023 gilt die Pflicht fuer ALLE Unternehmen ab 50 Beschaeftigten (§ 12 HinSchG). Verstoesse koennen mit Bussgeldern bis zu 50.000 EUR geahndet werden (§ 40 HinSchG).' },
{ icon: 'info' as const, title: 'Anonymitaet & Vertraulichkeit', description: 'Die Identitaet des Hinweisgebers ist streng vertraulich zu behandeln (§ 8 HinSchG). Anonyme Meldungen sollen bearbeitet werden. Repressalien sind verboten und loesen Schadensersatzpflicht aus (§ 36, § 37 HinSchG).' },
{ icon: 'lightbulb' as const, title: 'Gesetzliche Fristen', description: 'Eingangsbestaetigung innerhalb von 7 Tagen (§ 17 Abs. 1 S. 2). Rueckmeldung ueber ergriffene Folgemaßnahmen innerhalb von 3 Monaten nach Eingangsbestaetigung (§ 17 Abs. 2). Die Dokumentation muss 3 Jahre aufbewahrt werden (§ 11 HinSchG).' },
{ icon: 'warning' as const, title: 'Sachlicher Anwendungsbereich', description: 'Erfasst werden Verstoesse gegen EU-Recht und nationales Recht, u.a. Strafrecht, Datenschutz (DSGVO/BDSG), Arbeitsschutz, Umweltschutz, Geldwaesche, Produktsicherheit und Verbraucherschutz (§ 2 HinSchG).' },
],
},
'incidents': {
title: 'Vorfallmanagement',
description: 'Erfassung und Nachverfolgung von Compliance-Vorfaellen',
explanation: 'Das Vorfallmanagement dokumentiert Compliance-Vorfaelle, Datenpannen und Sicherheitsereignisse. Es unterstuetzt die Meldepflicht nach Art. 33/34 DSGVO und die systematische Ursachenanalyse.',
tips: [
{ icon: 'warning' as const, title: '72-Stunden-Frist', description: 'Datenpannen muessen innerhalb von 72 Stunden an die Aufsichtsbehoerde gemeldet werden (Art. 33 DSGVO).' },
{ icon: 'info' as const, title: 'Klassifizierung', description: 'Vorfaelle werden nach Schweregrad klassifiziert: Niedrig, Mittel, Hoch, Kritisch. Die Klassifizierung bestimmt die Eskalationswege.' },
],
},
'dsb-portal': {
title: 'DSB Portal',
description: 'Arbeitsbereich fuer den Datenschutzbeauftragten',
explanation: 'Das DSB Portal bietet dem Datenschutzbeauftragten einen zentralen Arbeitsbereich mit Aufgabenuebersicht, Beratungsprotokollen und Taetigkeitsberichten. Es unterstuetzt die Aufgaben nach Art. 39 DSGVO.',
tips: [
{ icon: 'info' as const, title: 'Taetigkeitsbericht', description: 'Der DSB muss regelmaessig ueber seine Taetigkeiten berichten. Das Portal generiert strukturierte Berichte.' },
{ icon: 'lightbulb' as const, title: 'Beratungsprotokolle', description: 'Dokumentieren Sie alle Beratungen, um die Rechenschaftspflicht zu erfuellen.' },
],
},
'industry-templates': {
title: 'Branchenvorlagen',
description: 'Branchenspezifische Compliance-Vorlagen und Best Practices',
explanation: 'Branchenvorlagen bieten vorkonfigurierte Compliance-Pakete fuer verschiedene Branchen (Gesundheitswesen, Finanzwesen, E-Commerce etc.). Sie enthalten typische Verarbeitungen, Risiken und Massnahmen.',
tips: [
{ icon: 'lightbulb' as const, title: 'Schnellstart', description: 'Branchenvorlagen beschleunigen die Ersteinrichtung erheblich. Sie koennen spaeter individuell angepasst werden.' },
{ icon: 'info' as const, title: 'Branchenstandards', description: 'Templates beruecksichtigen branchenspezifische Regulierungen wie PCI-DSS (Finanzen) oder Patientendatenschutz (Gesundheit).' },
],
},
'multi-tenant': {
title: 'Multi-Tenant Verwaltung',
description: 'Mandantenverwaltung fuer mehrere Unternehmen oder Standorte',
explanation: 'Die Multi-Tenant Verwaltung ermoeglicht die zentrale Steuerung mehrerer Mandanten (Tochtergesellschaften, Standorte, Kunden). Jeder Mandant hat eigene Compliance-Daten, kann aber zentral verwaltet werden.',
tips: [
{ icon: 'info' as const, title: 'Datentrennung', description: 'Mandantendaten sind strikt getrennt. Nur der uebergeordnete Administrator kann mandantenuebergreifend auswerten.' },
{ icon: 'lightbulb' as const, title: 'Template-Vererbung', description: 'Richtlinien und Vorlagen koennen zentral erstellt und an Mandanten vererbt werden.' },
],
},
'sso': {
title: 'Single Sign-On',
description: 'SSO-Integration und Authentifizierung verwalten',
explanation: 'Die SSO-Konfiguration ermoeglicht die Integration mit Ihrem Identity Provider (SAML, OIDC). Mitarbeiter koennen sich mit ihren bestehenden Unternehmens-Credentials anmelden.',
tips: [
{ icon: 'info' as const, title: 'Unterstuetzte Protokolle', description: 'SAML 2.0 und OpenID Connect (OIDC) werden unterstuetzt. Die gaengigsten IdPs (Azure AD, Okta, Google) sind vorkonfiguriert.' },
{ icon: 'warning' as const, title: 'Sicherheit', description: 'SSO reduziert das Risiko schwacher Passwoerter und ermoeglicht zentrale Zugriffskontrolle.' },
],
},
'document-crawler': {
title: 'Dokumenten-Crawler',
description: 'Automatische Erfassung und Analyse von Compliance-Dokumenten',
explanation: 'Der Dokumenten-Crawler durchsucht Ihre Systeme automatisch nach relevanten Compliance-Dokumenten (Datenschutzerklaerungen, Vertraege, Richtlinien) und analysiert deren Aktualitaet und Vollstaendigkeit.',
tips: [
{ icon: 'lightbulb' as const, title: 'Automatisierung', description: 'Der Crawler erkennt veraltete Dokumente und fehlende Pflichtangaben automatisch.' },
{ icon: 'info' as const, title: 'Quellen', description: 'Unterstuetzt Webseiten, SharePoint, Confluence und lokale Dateisysteme als Datenquellen.' },
],
},
'advisory-board': {
title: 'Compliance-Beirat',
description: 'Virtueller Compliance-Beirat mit KI-Experten',
explanation: 'Der Compliance-Beirat simuliert ein Expertengremium aus verschiedenen Fachrichtungen (Datenschutzrecht, IT-Sicherheit, KI-Ethik). Holen Sie sich Einschaetzungen zu komplexen Compliance-Fragen.',
tips: [
{ icon: 'lightbulb' as const, title: 'Zweitmeinung', description: 'Nutzen Sie den Beirat fuer eine zweite Einschaetzung bei schwierigen Compliance-Entscheidungen.' },
{ icon: 'warning' as const, title: 'Kein Rechtsersatz', description: 'Der KI-Beirat ersetzt keine professionelle Rechtsberatung. Bei kritischen Entscheidungen ziehen Sie einen Anwalt hinzu.' },
],
},
'reporting': {
title: 'Management Reporting',
description: 'Compliance-Berichte und KPIs fuer das Top Management',
explanation: 'Das Executive Reporting Dashboard bietet einen umfassenden Ueberblick ueber den Compliance-Status Ihres Unternehmens. Es aggregiert Daten aus allen Modulen (DSGVO, Lieferanten, Vorfaelle, Schulungen) zu einem Gesamt-Compliance-Score mit Risikobewertung und Fristenuebersicht.',
tips: [
{ icon: 'lightbulb' as const, title: 'Regelmaessig pruefen', description: 'Praesentieren Sie den Compliance-Bericht regelmaessig der Geschaeftsleitung (empfohlen: monatlich oder quartalsweise).' },
{ icon: 'warning' as const, title: 'Rechenschaftspflicht', description: 'Art. 5 Abs. 2 DSGVO verlangt den Nachweis der Compliance. Dieser Bericht dient als Dokumentation gegenueber Aufsichtsbehoerden.' },
],
},
'email-templates': {
title: 'E-Mail-Templates',
description: 'Verwalten Sie Vorlagen fuer alle DSGVO-relevanten Benachrichtigungen',
explanation: 'E-Mail-Templates definieren die Texte und das Layout fuer automatisierte DSGVO-Benachrichtigungen: Einwilligungsbestaetigung, Widerrufsbestaetigung, Auskunftsantwort, Loeschbestaetigung und weitere Lifecycle-E-Mails. Alle 16 Template-Typen koennen individuell angepasst und mit Variablen personalisiert werden.',
tips: [
{ icon: 'info' as const, title: '16 Lifecycle-E-Mails', description: 'Von der Registrierungsbestaetigung bis zur Kontoloeschung — alle relevanten Touchpoints sind mit Vorlagen abgedeckt.' },
{ icon: 'warning' as const, title: 'Pflichtangaben', description: 'Stellen Sie sicher, dass jede E-Mail die gesetzlich vorgeschriebenen Angaben enthaelt: Impressum, Datenschutzhinweis und Widerrufsmoeglichkeit.' },
{ icon: 'lightbulb' as const, title: 'Variablen', description: 'Nutzen Sie Platzhalter wie {{name}}, {{email}} und {{company}} fuer automatische Personalisierung.' },
],
},
'use-case-workshop': {
title: 'Use Case Workshop',
description: 'Erfassen und bewerten Sie Ihre KI-Anwendungsfaelle im Workshop-Format',
explanation: 'Im Use Case Workshop erfassen Sie Ihre KI-Anwendungsfaelle strukturiert in einem gefuehrten Prozess. Der Workshop leitet Sie durch Identifikation, Beschreibung, Datenkategorien, Risikobewertung und Stakeholder-Analyse. Die Ergebnisse fliessen direkt in die Compliance-Bewertung ein.',
tips: [
{ icon: 'lightbulb' as const, title: 'Vollstaendigkeit', description: 'Erfassen Sie alle KI-Anwendungsfaelle — auch solche, die nur intern genutzt werden oder sich noch in der Planungsphase befinden.' },
{ icon: 'info' as const, title: 'Stakeholder einbeziehen', description: 'Beziehen Sie Fachbereiche und IT in den Workshop ein, um alle Anwendungsfaelle zu identifizieren.' },
{ icon: 'warning' as const, title: 'Risikobewertung', description: 'Jeder Anwendungsfall wird nach EU AI Act Risikostufen klassifiziert. Hochrisiko-Systeme erfordern zusaetzliche Dokumentation.' },
],
},
}

View File

@@ -3,7 +3,8 @@
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'
import { useSDK, getStepById, getNextStep, getPreviousStep, SDK_STEPS } from '@/lib/sdk'
import { STEP_EXPLANATIONS } from './StepExplanations'
// =============================================================================
// TYPES
@@ -299,816 +300,4 @@ export function StepHeader({
)
}
// =============================================================================
// 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. Vollstaendige CRUD-Operationen mit Backend-Persistenz.',
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. Bei Backend-Fehler erfolgt automatischer Rollback.',
},
{
icon: 'lightbulb' as const,
title: 'CRUD-Operationen',
description: 'Anforderungen koennen erstellt, bearbeitet und geloescht werden. Alle Aenderungen werden sofort im Backend persistiert.',
},
],
},
'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. Evidence-Linking zeigt verknuepfte Nachweise mit Gueltigkeits-Badge.',
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.',
},
{
icon: 'success' as const,
title: 'Evidence-Linking',
description: 'Verknuepfen Sie Nachweise direkt mit Controls. Gueltige, abgelaufene und ausstehende Nachweise werden mit Badges angezeigt.',
},
],
},
'evidence': {
title: 'Nachweise',
description: 'Dokumentieren Sie die Umsetzung mit Belegen',
explanation: 'Nachweise sind Dokumente, Screenshots oder Berichte, die belegen, dass Kontrollen implementiert sind. Server-seitige Pagination fuer grosse Nachweis-Sammlungen.',
tips: [
{
icon: 'warning' as const,
title: 'Gueltigkeit',
description: 'Achten Sie auf das Ablaufdatum von Nachweisen. Abgelaufene Zertifikate oder Berichte muessen erneuert werden. Status: valid, expired, pending, failed.',
},
{
icon: 'success' as const,
title: 'Verknuepfung',
description: 'Verknuepfen Sie Nachweise direkt mit den zugehoerigen Kontrollen fuer eine lueckenlose Dokumentation.',
},
{
icon: 'info' as const,
title: 'Pagination',
description: 'Bei vielen Nachweisen wird automatisch paginiert. Nutzen Sie die Seitennavigation am Ende der Liste.',
},
],
},
'audit-checklist': {
title: 'Audit-Checkliste',
description: 'Systematische Pruefung der Compliance-Konformitaet',
explanation: 'Die Audit-Checkliste wird automatisch aus den Anforderungen generiert. Session-Management mit Sign-Off-Workflow und PDF-Export.',
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: 'Sign-Off & PDF',
description: 'Zeichnen Sie Pruefpunkte mit digitalem Hash (SHA-256) ab. Exportieren Sie den Report als PDF in Deutsch oder Englisch.',
},
{
icon: 'success' as const,
title: 'Session-History',
description: 'Vergangene Audit-Sitzungen werden mit Status-Badges angezeigt: Draft, In Progress, Completed, Archived.',
},
],
},
'risks': {
title: 'Risiko-Matrix',
description: 'Bewerten und priorisieren Sie Ihre Compliance-Risiken',
explanation: 'Die 5x5 Risiko-Matrix visualisiert Ihre Risiken nach Wahrscheinlichkeit und Auswirkung. Inherent Risk vs. Residual Risk mit visuellem Vergleich.',
tips: [
{
icon: 'warning' as const,
title: 'Kritische Risiken',
description: 'Risiken mit Score >= 20 sind CRITICAL (rot), >= 12 HIGH (orange), >= 6 MEDIUM (gelb), < 6 LOW (gruen).',
},
{
icon: 'success' as const,
title: 'Mitigation',
description: 'Verknuepfen Sie Controls als Mitigationsmassnahmen. Der Residual-Risk wird automatisch anhand verknuepfter Controls berechnet.',
},
{
icon: 'info' as const,
title: 'Status-Workflow',
description: 'Risiken durchlaufen: Identifiziert → Bewertet → Mitigiert → Akzeptiert → Geschlossen.',
},
],
},
'ai-act': {
title: 'AI Act Klassifizierung',
description: 'Registrieren und klassifizieren Sie Ihre KI-Systeme',
explanation: 'Der EU AI Act klassifiziert KI-Systeme in Risikostufen: Minimal, Begrenzt, Hoch und Verboten. KI-Systeme werden im Backend persistent gespeichert und koennen automatisch bewertet werden.',
tips: [
{
icon: 'warning' as const,
title: 'Hochrisiko-Systeme',
description: 'Hochrisiko-KI erfordert 8 Pflichten: Risikomanagement, Daten-Governance, Dokumentation, Transparenz, menschliche Aufsicht, Genauigkeit, Robustheit, Cybersicherheit.',
},
{
icon: 'lightbulb' as const,
title: 'Automatische Bewertung',
description: 'Nutzen Sie die Assess-Funktion: Sie analysiert Zweck und Sektor und leitet die Risikokategorie + Pflichten automatisch ab.',
},
{
icon: 'info' as const,
title: 'CRUD-Operationen',
description: 'KI-Systeme koennen registriert, bearbeitet, bewertet und geloescht werden. Alle Daten werden backend-persistent gespeichert.',
},
],
},
'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: 'TOMs nach Art. 32 DSGVO mit Vendor-Controls-Querverweis',
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. Im Uebersicht-Tab werden zusaetzlich Vendor-TOM-Controls (VND-TOM-01 bis VND-TOM-06) aus dem Vendor-Compliance-Modul als Querverweis angezeigt.',
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.',
},
{
icon: 'success' as const,
title: 'Vendor-Controls',
description: 'Im Uebersicht-Tab werden Vendor-TOM-Controls (VND-TOM-01 bis 06) als Read-Only-Querverweis angezeigt: Verschluesselung, Zugriffskontrolle, Verfuegbarkeit und Ueberpruefungsverfahren Ihrer Auftragsverarbeiter.',
},
],
},
'vvt': {
title: 'Verarbeitungsverzeichnis',
description: 'Verarbeitungsverzeichnis nach Art. 30 DSGVO mit integriertem Processor-Tab',
explanation: 'Das Verarbeitungsverzeichnis (VVT) dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Der integrierte Generator-Fragebogen befuellt 70-90% der Pflichtfelder automatisch. Der Tab "Auftragsverarbeiter (Abs. 2)" liest Vendors mit role=PROCESSOR/SUB_PROCESSOR direkt aus der Vendor-Compliance-API — keine doppelte Datenhaltung.',
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).',
},
{
icon: 'success' as const,
title: 'Processor-Tab (Art. 30 Abs. 2)',
description: 'Auftragsverarbeiter werden direkt aus dem Vendor Register gelesen (Read-Only). Neue Vendors werden im Vendor-Compliance-Modul angelegt und erscheinen hier automatisch. PDF-Druck fuer Art. 30 Abs. 2 Dokument.',
},
],
},
'cookie-banner': {
title: 'Cookie Banner',
description: 'Konfigurieren Sie einen DSGVO-konformen Cookie Banner mit persistenter DB-Speicherung',
explanation: 'Der Cookie Banner Generator erstellt einen rechtssicheren Banner mit Opt-In fuer nicht-essentielle Cookies. Alle Einstellungen — einschliesslich Ueberschrift, Beschreibung und Datenschutz-Link — werden in der Datenbank gespeichert und bleiben auch nach einem Neustart erhalten. Der generierte Embed-Code wird direkt aus der gespeicherten Konfiguration erzeugt.',
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 und Texte',
description: 'Passen Sie Ueberschrift, Beschreibung und Farben an Ihr Corporate Design an. Aenderungen werden in der Vorschau sofort sichtbar.',
},
{
icon: 'info' as const,
title: 'Embed-Code',
description: 'Der Code exportiert einen vollstaendigen HTML+CSS+JS-Block aus Ihrer gespeicherten Konfiguration — einfach vor dem schliessenden </body>-Tag einbinden.',
},
],
},
'obligations': {
title: 'Pflichtenuebersicht',
description: 'Regulatorische Pflichten mit 12 Compliance-Checks und Vendor-Verknuepfung',
explanation: 'Die Pflichtenuebersicht aggregiert alle Anforderungen aus DSGVO, AI Act, NIS2 und weiteren Regulierungen. 12 automatische Compliance-Checks pruefen Vollstaendigkeit, Fristen, Nachweise und Vendor-Verknuepfungen. Art.-28-Pflichten koennen mit Auftragsverarbeitern aus dem Vendor Register verknuepft werden. Das Pflichtenregister-Dokument (11 Sektionen) kann als auditfaehiges PDF gedruckt werden.',
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: 'Compliance-Checks',
description: '12 automatische Checks: Fehlende Verantwortliche, ueberfaellige Fristen, fehlende Nachweise, keine Rechtsreferenz, stagnierende Regulierungen, nicht gestartete High-Priority-Pflichten, fehlende Vendor-Verknuepfung (Art. 28) u.v.m.',
},
{
icon: 'success' as const,
title: 'Vendor-Verknuepfung',
description: 'Art.-28-Pflichten (Auftragsverarbeitung) koennen direkt mit Vendors aus dem Vendor Register verknuepft werden. Check #12 (MISSING_VENDOR_LINK) warnt bei fehlender Verknuepfung.',
},
{
icon: 'lightbulb' as const,
title: 'Pflichtenregister-Dokument',
description: 'Generieren Sie ein auditfaehiges Pflichtenregister mit 11 Sektionen: Ziel, Geltungsbereich, Methodik, Regulatorische Grundlagen, Pflichtenuebersicht, Details, Verantwortlichkeiten, Fristen, Nachweisverzeichnis, Compliance-Status und Aenderungshistorie.',
},
],
},
'loeschfristen': {
title: 'Loeschfristen',
description: 'Aufbewahrungsrichtlinien mit VVT-Verknuepfung und Vendor-Zuordnung',
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. Policies koennen mit VVT-Verarbeitungstaetigkeiten und Auftragsverarbeitern aus dem Vendor Register verknuepft 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.',
},
{
icon: 'success' as const,
title: 'Vendor-Verknuepfung',
description: 'Loeschfrist-Policies koennen mit Auftragsverarbeitern verknuepft werden. So ist dokumentiert, welche Vendors Loeschpflichten fuer bestimmte Datenkategorien haben.',
},
],
},
'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.',
},
],
},
'vendor-compliance': {
title: 'Vendor Compliance',
description: 'Auftragsverarbeiter-Management mit Cross-Modul-Integration',
explanation: 'Vendor Compliance verwaltet alle Auftragsverarbeiter (Art. 28 DSGVO) und Drittanbieter. Fuer jeden Vendor werden AVVs, Drittlandtransfers, TOMs und Subunternehmer geprueft. Das Modul ist zentral mit vier weiteren Modulen integriert: VVT-Processor-Tab liest Vendors direkt aus der API, Obligations und Loeschfristen verknuepfen Vendors ueber linked_vendor_ids, TOM zeigt Vendor-Controls als Querverweis.',
tips: [
{
icon: 'warning' as const,
title: 'Art. 28 DSGVO',
description: 'Jede Auftragsverarbeitung erfordert einen schriftlichen Vertrag (AVV). Pruefen Sie: Weisungsgebundenheit, TOMs, Subunternehmer-Genehmigung, Loeschpflicht und Audit-Recht.',
},
{
icon: 'info' as const,
title: 'Cross-Modul-Integration',
description: 'Vendors erscheinen automatisch im VVT-Processor-Tab, koennen in Obligations und Loeschfristen verknuepft werden, und ihre TOM-Controls werden im TOM-Modul als Querverweis angezeigt.',
},
{
icon: 'lightbulb' as const,
title: 'Drittlandtransfer',
description: 'Bei Datenverarbeitung ausserhalb der EU/EWR sind Standardvertragsklauseln (SCCs) oder andere Garantien nach Art. 44-49 DSGVO erforderlich.',
},
{
icon: 'success' as const,
title: 'Controls Library',
description: '6 TOM-Domain Controls (VND-TOM-01 bis VND-TOM-06) pruefen Verschluesselung, Zugriffskontrolle, Verfuegbarkeit und Ueberpruefungsverfahren bei Ihren Auftragsverarbeitern.',
},
],
},
'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. Uebersicht mit Status-Badges, Detail-Seite pro Sitzung mit Fortschrittsbalken und interaktiven Checklist-Items.',
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: 'Detail-Ansicht',
description: 'Klicken Sie auf eine Sitzung fuer die Detail-Seite: Metadaten, Fortschrittsbalken, Checklist-Items mit Sign-Off und Notizen.',
},
{
icon: 'info' as const,
title: 'PDF-Export',
description: 'Generieren Sie PDF-Reports in Deutsch oder Englisch fuer externe Pruefer und Aufsichtsbehoerden.',
},
],
},
'workflow': {
title: 'Document Workflow',
description: 'Freigabe-Workflow mit Split-View-Editor und DB-persistenter Versionierung',
explanation: 'Der Document Workflow bietet einen Split-View-Editor: links die veroffentlichte Version, rechts der aktuelle Entwurf. Dokumente durchlaufen den Status Draft → Review → Approved → Published. Alle Versionen werden in der Datenbank gespeichert. Word-Dokumente koennen direkt als neue Version importiert werden.',
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. Veroeffentlichte Versionen sind unveraenderlich — Aenderungen erzeugen stets eine neue Version.',
},
{
icon: 'lightbulb' as const,
title: 'DOCX-Import',
description: 'Bestehende Word-Dokumente koennen direkt hochgeladen und als Basis fuer neue Versionen verwendet werden.',
},
],
},
'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.',
},
],
},
'academy': {
title: 'Compliance Academy',
description: 'E-Learning-Plattform fuer Mitarbeiterschulungen',
explanation: 'Die Compliance Academy ermoeglicht KI-generierte Schulungsvideos mit interaktiven Quizfragen und PDF-Zertifikaten. Unternehmen muessen Mitarbeiter regelmaessig in Datenschutz, IT-Sicherheit und KI-Kompetenz schulen (DSGVO Art. 39 Abs. 1 lit. b, EU AI Act Art. 4).',
tips: [
{
icon: 'info' as const,
title: 'Schulungspflicht',
description: 'DSGVO Art. 39 Abs. 1 lit. b verpflichtet den DSB zur Sensibilisierung und Schulung aller Mitarbeiter.',
},
{
icon: 'lightbulb' as const,
title: 'Zertifikate',
description: 'Schulungszertifikate dienen als Audit-Nachweis. Sie dokumentieren Teilnahme, Testergebnis und Gueltigkeit.',
},
],
},
'whistleblower': {
title: 'Hinweisgebersystem',
description: 'Interne Meldestelle gemaess Hinweisgeberschutzgesetz (HinSchG) — seit 17. Dezember 2023 Pflicht fuer alle Unternehmen ab 50 Beschaeftigten',
explanation: 'Das Hinweisgebersystem implementiert eine HinSchG-konforme interne Meldestelle fuer die sichere, auch anonyme Meldung von Rechtsverstoessen. Es setzt die EU-Whistleblowing-Richtlinie (2019/1937) in deutsches Recht um. Beschaeftigungsgeber mit mindestens 50 Beschaeftigten sind zur Einrichtung verpflichtet (§ 12 HinSchG). Das System unterstuetzt den gesamten Meldeprozess: Einreichung, Eingangsbestaetigung (7-Tage-Frist), Sachverhaltspruefung, Folgemaßnahmen und Rueckmeldung (3-Monate-Frist).',
tips: [
{
icon: 'warning' as const,
title: 'Pflicht ab 50 Beschaeftigten',
description: 'Seit 17.12.2023 gilt die Pflicht fuer ALLE Unternehmen ab 50 Beschaeftigten (§ 12 HinSchG). Verstoesse koennen mit Bussgeldern bis zu 50.000 EUR geahndet werden (§ 40 HinSchG).',
},
{
icon: 'info' as const,
title: 'Anonymitaet & Vertraulichkeit',
description: 'Die Identitaet des Hinweisgebers ist streng vertraulich zu behandeln (§ 8 HinSchG). Anonyme Meldungen sollen bearbeitet werden. Repressalien sind verboten und loesen Schadensersatzpflicht aus (§ 36, § 37 HinSchG).',
},
{
icon: 'lightbulb' as const,
title: 'Gesetzliche Fristen',
description: 'Eingangsbestaetigung innerhalb von 7 Tagen (§ 17 Abs. 1 S. 2). Rueckmeldung ueber ergriffene Folgemaßnahmen innerhalb von 3 Monaten nach Eingangsbestaetigung (§ 17 Abs. 2). Die Dokumentation muss 3 Jahre aufbewahrt werden (§ 11 HinSchG).',
},
{
icon: 'warning' as const,
title: 'Sachlicher Anwendungsbereich',
description: 'Erfasst werden Verstoesse gegen EU-Recht und nationales Recht, u.a. Strafrecht, Datenschutz (DSGVO/BDSG), Arbeitsschutz, Umweltschutz, Geldwaesche, Produktsicherheit und Verbraucherschutz (§ 2 HinSchG).',
},
],
},
'incidents': {
title: 'Vorfallmanagement',
description: 'Erfassung und Nachverfolgung von Compliance-Vorfaellen',
explanation: 'Das Vorfallmanagement dokumentiert Compliance-Vorfaelle, Datenpannen und Sicherheitsereignisse. Es unterstuetzt die Meldepflicht nach Art. 33/34 DSGVO und die systematische Ursachenanalyse.',
tips: [
{
icon: 'warning' as const,
title: '72-Stunden-Frist',
description: 'Datenpannen muessen innerhalb von 72 Stunden an die Aufsichtsbehoerde gemeldet werden (Art. 33 DSGVO).',
},
{
icon: 'info' as const,
title: 'Klassifizierung',
description: 'Vorfaelle werden nach Schweregrad klassifiziert: Niedrig, Mittel, Hoch, Kritisch. Die Klassifizierung bestimmt die Eskalationswege.',
},
],
},
'dsb-portal': {
title: 'DSB Portal',
description: 'Arbeitsbereich fuer den Datenschutzbeauftragten',
explanation: 'Das DSB Portal bietet dem Datenschutzbeauftragten einen zentralen Arbeitsbereich mit Aufgabenuebersicht, Beratungsprotokollen und Taetigkeitsberichten. Es unterstuetzt die Aufgaben nach Art. 39 DSGVO.',
tips: [
{
icon: 'info' as const,
title: 'Taetigkeitsbericht',
description: 'Der DSB muss regelmaessig ueber seine Taetigkeiten berichten. Das Portal generiert strukturierte Berichte.',
},
{
icon: 'lightbulb' as const,
title: 'Beratungsprotokolle',
description: 'Dokumentieren Sie alle Beratungen, um die Rechenschaftspflicht zu erfuellen.',
},
],
},
'industry-templates': {
title: 'Branchenvorlagen',
description: 'Branchenspezifische Compliance-Vorlagen und Best Practices',
explanation: 'Branchenvorlagen bieten vorkonfigurierte Compliance-Pakete fuer verschiedene Branchen (Gesundheitswesen, Finanzwesen, E-Commerce etc.). Sie enthalten typische Verarbeitungen, Risiken und Massnahmen.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Schnellstart',
description: 'Branchenvorlagen beschleunigen die Ersteinrichtung erheblich. Sie koennen spaeter individuell angepasst werden.',
},
{
icon: 'info' as const,
title: 'Branchenstandards',
description: 'Templates beruecksichtigen branchenspezifische Regulierungen wie PCI-DSS (Finanzen) oder Patientendatenschutz (Gesundheit).',
},
],
},
'multi-tenant': {
title: 'Multi-Tenant Verwaltung',
description: 'Mandantenverwaltung fuer mehrere Unternehmen oder Standorte',
explanation: 'Die Multi-Tenant Verwaltung ermoeglicht die zentrale Steuerung mehrerer Mandanten (Tochtergesellschaften, Standorte, Kunden). Jeder Mandant hat eigene Compliance-Daten, kann aber zentral verwaltet werden.',
tips: [
{
icon: 'info' as const,
title: 'Datentrennung',
description: 'Mandantendaten sind strikt getrennt. Nur der uebergeordnete Administrator kann mandantenuebergreifend auswerten.',
},
{
icon: 'lightbulb' as const,
title: 'Template-Vererbung',
description: 'Richtlinien und Vorlagen koennen zentral erstellt und an Mandanten vererbt werden.',
},
],
},
'sso': {
title: 'Single Sign-On',
description: 'SSO-Integration und Authentifizierung verwalten',
explanation: 'Die SSO-Konfiguration ermoeglicht die Integration mit Ihrem Identity Provider (SAML, OIDC). Mitarbeiter koennen sich mit ihren bestehenden Unternehmens-Credentials anmelden.',
tips: [
{
icon: 'info' as const,
title: 'Unterstuetzte Protokolle',
description: 'SAML 2.0 und OpenID Connect (OIDC) werden unterstuetzt. Die gaengigsten IdPs (Azure AD, Okta, Google) sind vorkonfiguriert.',
},
{
icon: 'warning' as const,
title: 'Sicherheit',
description: 'SSO reduziert das Risiko schwacher Passwoerter und ermoeglicht zentrale Zugriffskontrolle.',
},
],
},
'document-crawler': {
title: 'Dokumenten-Crawler',
description: 'Automatische Erfassung und Analyse von Compliance-Dokumenten',
explanation: 'Der Dokumenten-Crawler durchsucht Ihre Systeme automatisch nach relevanten Compliance-Dokumenten (Datenschutzerklaerungen, Vertraege, Richtlinien) und analysiert deren Aktualitaet und Vollstaendigkeit.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Automatisierung',
description: 'Der Crawler erkennt veraltete Dokumente und fehlende Pflichtangaben automatisch.',
},
{
icon: 'info' as const,
title: 'Quellen',
description: 'Unterstuetzt Webseiten, SharePoint, Confluence und lokale Dateisysteme als Datenquellen.',
},
],
},
'advisory-board': {
title: 'Compliance-Beirat',
description: 'Virtueller Compliance-Beirat mit KI-Experten',
explanation: 'Der Compliance-Beirat simuliert ein Expertengremium aus verschiedenen Fachrichtungen (Datenschutzrecht, IT-Sicherheit, KI-Ethik). Holen Sie sich Einschaetzungen zu komplexen Compliance-Fragen.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Zweitmeinung',
description: 'Nutzen Sie den Beirat fuer eine zweite Einschaetzung bei schwierigen Compliance-Entscheidungen.',
},
{
icon: 'warning' as const,
title: 'Kein Rechtsersatz',
description: 'Der KI-Beirat ersetzt keine professionelle Rechtsberatung. Bei kritischen Entscheidungen ziehen Sie einen Anwalt hinzu.',
},
],
},
'reporting': {
title: 'Management Reporting',
description: 'Compliance-Berichte und KPIs fuer das Top Management',
explanation: 'Das Executive Reporting Dashboard bietet einen umfassenden Ueberblick ueber den Compliance-Status Ihres Unternehmens. Es aggregiert Daten aus allen Modulen (DSGVO, Lieferanten, Vorfaelle, Schulungen) zu einem Gesamt-Compliance-Score mit Risikobewertung und Fristenuebersicht.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Regelmaessig pruefen',
description: 'Praesentieren Sie den Compliance-Bericht regelmaessig der Geschaeftsleitung (empfohlen: monatlich oder quartalsweise).',
},
{
icon: 'warning' as const,
title: 'Rechenschaftspflicht',
description: 'Art. 5 Abs. 2 DSGVO verlangt den Nachweis der Compliance. Dieser Bericht dient als Dokumentation gegenueber Aufsichtsbehoerden.',
},
],
},
'email-templates': {
title: 'E-Mail-Templates',
description: 'Verwalten Sie Vorlagen fuer alle DSGVO-relevanten Benachrichtigungen',
explanation: 'E-Mail-Templates definieren die Texte und das Layout fuer automatisierte DSGVO-Benachrichtigungen: Einwilligungsbestaetigung, Widerrufsbestaetigung, Auskunftsantwort, Loeschbestaetigung und weitere Lifecycle-E-Mails. Alle 16 Template-Typen koennen individuell angepasst und mit Variablen personalisiert werden.',
tips: [
{
icon: 'info' as const,
title: '16 Lifecycle-E-Mails',
description: 'Von der Registrierungsbestaetigung bis zur Kontoloeschung — alle relevanten Touchpoints sind mit Vorlagen abgedeckt.',
},
{
icon: 'warning' as const,
title: 'Pflichtangaben',
description: 'Stellen Sie sicher, dass jede E-Mail die gesetzlich vorgeschriebenen Angaben enthaelt: Impressum, Datenschutzhinweis und Widerrufsmoeglichkeit.',
},
{
icon: 'lightbulb' as const,
title: 'Variablen',
description: 'Nutzen Sie Platzhalter wie {{name}}, {{email}} und {{company}} fuer automatische Personalisierung.',
},
],
},
'use-case-workshop': {
title: 'Use Case Workshop',
description: 'Erfassen und bewerten Sie Ihre KI-Anwendungsfaelle im Workshop-Format',
explanation: 'Im Use Case Workshop erfassen Sie Ihre KI-Anwendungsfaelle strukturiert in einem gefuehrten Prozess. Der Workshop leitet Sie durch Identifikation, Beschreibung, Datenkategorien, Risikobewertung und Stakeholder-Analyse. Die Ergebnisse fliessen direkt in die Compliance-Bewertung ein.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Vollstaendigkeit',
description: 'Erfassen Sie alle KI-Anwendungsfaelle — auch solche, die nur intern genutzt werden oder sich noch in der Planungsphase befinden.',
},
{
icon: 'info' as const,
title: 'Stakeholder einbeziehen',
description: 'Beziehen Sie Fachbereiche und IT in den Workshop ein, um alle Anwendungsfaelle zu identifizieren.',
},
{
icon: 'warning' as const,
title: 'Risikobewertung',
description: 'Jeder Anwendungsfall wird nach EU AI Act Risikostufen klassifiziert. Hochrisiko-Systeme erfordern zusaetzliche Dokumentation.',
},
],
},
} satisfies Record<string, { title: string; description: string; explanation: string; tips: StepTip[] }>
export default StepHeader

View File

@@ -1,12 +1,13 @@
'use client'
import React, { useState } from 'react'
// =============================================================================
// BLOCK 9: Datenkategorien pro Abteilung (aufklappbare Kacheln)
// Extracted from ScopeWizardTab for LOC compliance.
// =============================================================================
import { useState } from 'react'
import type { ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types'
import { DEPARTMENT_DATA_CATEGORIES } from '@/lib/sdk/vvt-profiling'
// =============================================================================
// CONSTANTS
// =============================================================================
/** Mapping Block 8 vvt_departments values → DEPARTMENT_DATA_CATEGORIES keys */
export const DEPT_VALUE_TO_KEY: Record<string, string[]> = {
personal: ['dept_hr', 'dept_recruiting'],
@@ -38,16 +39,15 @@ export const DEPT_KEY_TO_QUESTION: Record<string, string> = {
dept_facility: 'dk_dept_facility',
}
// =============================================================================
// DATENKATEGORIEN BLOCK 9
// =============================================================================
interface DatenkategorienBlock9Props {
answers: ScopeProfilingAnswer[]
onAnswerChange: (questionId: string, value: string | string[] | boolean | number) => void
}
export function DatenkategorienBlock9({ answers, onAnswerChange }: DatenkategorienBlock9Props) {
export function DatenkategorienBlock9({
answers,
onAnswerChange,
}: DatenkategorienBlock9Props) {
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set())
const [initializedDepts, setInitializedDepts] = useState<Set<string>>(new Set())

View File

@@ -0,0 +1,444 @@
'use client'
import type { ScopeDecision, ApplicableRegulation, SupervisoryAuthorityInfo } 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'
// =============================================================================
// Helpers
// =============================================================================
export 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'
}
export const getSeverityBadge = (severity: string) => {
const s = severity.toLowerCase()
const colors: Record<string, string> = {
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: Record<string, string> = {
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[s] || colors.medium}`}>
{labels[s] || severity}
</span>
)
}
export const ScoreBar = ({ label, score }: { 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>
)
}
// =============================================================================
// LevelCard
// =============================================================================
export function LevelCard({ decision }: { decision: ScopeDecision }) {
return (
<div className={`${DEPTH_LEVEL_COLORS[decision.determinedLevel].bg} border-2 ${DEPTH_LEVEL_COLORS[decision.determinedLevel].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.determinedLevel].badge} rounded-xl flex items-center justify-center`}>
<span className={`text-3xl font-bold ${DEPTH_LEVEL_COLORS[decision.determinedLevel].text}`}>
{decision.determinedLevel}
</span>
</div>
<div className="flex-1">
<h2 className={`text-2xl font-bold ${DEPTH_LEVEL_COLORS[decision.determinedLevel].text} mb-2`}>
{DEPTH_LEVEL_LABELS[decision.determinedLevel]}
</h2>
<p className="text-gray-700 mb-3">{DEPTH_LEVEL_DESCRIPTIONS[decision.determinedLevel]}</p>
{decision.reasoning && decision.reasoning.length > 0 && (
<p className="text-sm text-gray-600 italic">{decision.reasoning.map(r => r.description).filter(Boolean).join('. ')}</p>
)}
</div>
</div>
</div>
)
}
// =============================================================================
// ScoreBreakdown
// =============================================================================
export function ScoreBreakdown({ decision }: { decision: ScopeDecision }) {
if (!decision.scores) return null
return (
<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">
<ScoreBar label="Risiko-Score" score={decision.scores.risk_score} />
<ScoreBar label="Komplexitäts-Score" score={decision.scores.complexity_score} />
<ScoreBar label="Assurance-Score" score={decision.scores.assurance_need} />
<div className="pt-4 border-t border-gray-200">
<ScoreBar label="Gesamt-Score" score={decision.scores.composite_score} />
</div>
</div>
</div>
)
}
// =============================================================================
// RegulationsPanel
// =============================================================================
interface RegulationsPanelProps {
applicableRegulations?: ApplicableRegulation[]
supervisoryAuthorities?: SupervisoryAuthorityInfo[]
regulationAssessmentLoading?: boolean
onGoToObligations?: () => void
}
export function RegulationsPanel({
applicableRegulations,
supervisoryAuthorities,
regulationAssessmentLoading,
onGoToObligations,
}: RegulationsPanelProps) {
if (!applicableRegulations && !regulationAssessmentLoading) return null
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Anwendbare Regulierungen</h3>
{regulationAssessmentLoading ? (
<div className="flex items-center gap-3 text-gray-500">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<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>Regulierungen werden geprueft...</span>
</div>
) : applicableRegulations && applicableRegulations.length > 0 ? (
<div className="space-y-3">
{applicableRegulations.map((reg) => (
<div key={reg.id} className="flex items-center justify-between border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center gap-3">
<div className="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<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>
</div>
<div>
<span className="font-semibold text-gray-900">{reg.name}</span>
{reg.classification && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
{reg.classification}
</span>
)}
</div>
</div>
<div className="text-right text-sm text-gray-600">
<span>{reg.obligation_count} Pflichten</span>
{reg.control_count > 0 && <span className="ml-2">{reg.control_count} Controls</span>}
</div>
</div>
))}
{supervisoryAuthorities && supervisoryAuthorities.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200">
<h4 className="text-sm font-semibold text-gray-700 mb-3">Zustaendige Aufsichtsbehoerden</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{supervisoryAuthorities.map((sa, idx) => (
<div key={idx} className="flex items-start gap-3 bg-gray-50 rounded-lg p-3">
<div className="flex-shrink-0 w-6 h-6 bg-blue-100 rounded flex items-center justify-center mt-0.5">
<svg className="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<div>
<span className="text-sm font-medium text-gray-900">{sa.authority.abbreviation}</span>
<span className="text-xs text-gray-500 ml-1">({sa.domain})</span>
<p className="text-xs text-gray-600 mt-0.5">{sa.authority.name}</p>
{sa.authority.url && (
<a href={sa.authority.url} target="_blank" rel="noopener noreferrer" className="text-xs text-purple-600 hover:text-purple-700">
Website
</a>
)}
</div>
</div>
))}
</div>
</div>
)}
{onGoToObligations && (
<div className="mt-4 pt-4 border-t border-gray-200">
<button
onClick={onGoToObligations}
className="inline-flex items-center gap-2 px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Pflichten anzeigen
<svg className="w-4 h-4" 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>
</button>
</div>
)}
</div>
) : (
<p className="text-gray-500 text-sm">Keine anwendbaren Regulierungen ermittelt.</p>
)}
</div>
)
}
// =============================================================================
// HardTriggersPanel
// =============================================================================
export function HardTriggersPanel({
decision,
expandedTrigger,
onToggle,
}: {
decision: ScopeDecision
expandedTrigger: number | null
onToggle: (idx: number) => void
}) {
if (!decision.triggeredHardTriggers || decision.triggeredHardTriggers.length === 0) return null
return (
<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.triggeredHardTriggers.map((trigger, idx) => (
<div key={idx} className="border rounded-lg overflow-hidden border-red-300 bg-red-50">
<button
type="button"
onClick={() => onToggle(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">
<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.description}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-red-200 text-red-800 font-medium">Min. {trigger.minimumLevel}</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.mandatoryDocuments && trigger.mandatoryDocuments.length > 0 && (
<p className="text-xs text-gray-700"><span className="font-medium">Pflichtdokumente:</span> {trigger.mandatoryDocuments.join(', ')}</p>
)}
{trigger.requiresDSFA && (
<p className="text-xs text-orange-700 font-medium mt-1">DSFA erforderlich</p>
)}
</div>
)}
</div>
))}
</div>
</div>
)
}
// =============================================================================
// RequiredDocumentsPanel
// =============================================================================
export function RequiredDocumentsPanel({ decision }: { decision: ScopeDecision }) {
if (!decision.requiredDocuments || decision.requiredDocuments.length === 0) return null
return (
<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">Dokument</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Priorität</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">Trigger</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.requirement === 'mandatory' && (
<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 capitalize">{doc.priority}</td>
<td className="py-3 px-4 text-sm text-gray-700">{doc.estimatedEffort ? `${doc.estimatedEffort}h` : '-'}</td>
<td className="py-3 px-4">
{doc.triggeredBy && doc.triggeredBy.length > 0 && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">{doc.triggeredBy.join(', ')}</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>
)
}
// =============================================================================
// RiskFlagsPanel
// =============================================================================
export function RiskFlagsPanel({ decision }: { decision: ScopeDecision }) {
if (!decision.riskFlags || decision.riskFlags.length === 0) return null
return (
<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.message}</h4>
{getSeverityBadge(flag.severity)}
</div>
{flag.legalReference && <p className="text-xs text-gray-500 mb-2">{flag.legalReference}</p>}
<p className="text-sm text-gray-600"><span className="font-medium">Empfehlung:</span> {flag.recommendation}</p>
</div>
))}
</div>
</div>
)
}
// =============================================================================
// GapAnalysisPanel
// =============================================================================
export function GapAnalysisPanel({ decision }: { decision: ScopeDecision }) {
if (!decision.gaps || decision.gaps.length === 0) return null
return (
<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.gaps.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.description}</h4>
{getSeverityBadge(gap.severity)}
</div>
<p className="text-sm text-gray-700 mb-2"><span className="font-medium">Ist:</span> {gap.currentState}</p>
<p className="text-sm text-gray-600 mb-2"><span className="font-medium">Soll:</span> {gap.targetState}</p>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>Aufwand: ~{gap.effort}h</span>
<span>Level: {gap.requiredFor}</span>
</div>
</div>
))}
</div>
</div>
)
}
// =============================================================================
// NextActionsPanel
// =============================================================================
export function NextActionsPanel({ decision }: { decision: ScopeDecision }) {
if (!decision.nextActions || decision.nextActions.length === 0) return null
return (
<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">{idx + 1}</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.estimatedEffort > 0 && (
<span className="text-xs text-gray-600"><span className="font-medium">Aufwand:</span> ~{action.estimatedEffort}h</span>
)}
{action.sdkStepUrl && (
<a href={action.sdkStepUrl} className="text-xs text-purple-600 hover:text-purple-700">Zum SDK-Schritt </a>
)}
</div>
</div>
</div>
))}
</div>
</div>
)
}
// =============================================================================
// AuditTrailPanel
// =============================================================================
export function AuditTrailPanel({
decision,
showAuditTrail,
onToggle,
}: {
decision: ScopeDecision
showAuditTrail: boolean
onToggle: () => void
}) {
if (!decision.reasoning || decision.reasoning.length === 0) return null
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<button type="button" onClick={onToggle} 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.reasoning.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.factors && entry.factors.length > 0 && (
<ul className="text-xs text-gray-600 space-y-1">
{entry.factors.map((factor, factorIdx) => <li key={factorIdx}> {factor}</li>)}
</ul>
)}
{entry.impact && <p className="text-xs text-purple-700 font-medium mt-1">{entry.impact}</p>}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -1,7 +1,17 @@
'use client'
import React, { useState } from 'react'
import type { ScopeDecision, ComplianceDepthLevel, ApplicableRegulation, SupervisoryAuthorityInfo } 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'
import type { ScopeDecision, ApplicableRegulation, SupervisoryAuthorityInfo } from '@/lib/sdk/compliance-scope-types'
import {
LevelCard,
ScoreBreakdown,
RegulationsPanel,
HardTriggersPanel,
RequiredDocumentsPanel,
RiskFlagsPanel,
GapAnalysisPanel,
NextActionsPanel,
AuditTrailPanel,
} from './ScopeDecisionSections'
interface ScopeDecisionTabProps {
decision: ScopeDecision | null
@@ -51,390 +61,32 @@ export function ScopeDecisionTab({
)
}
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: string) => {
const s = severity.toLowerCase()
const colors: Record<string, string> = {
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: Record<string, string> = {
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[s] || colors.medium}`}>
{labels[s] || 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.determinedLevel].bg} border-2 ${DEPTH_LEVEL_COLORS[decision.determinedLevel].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.determinedLevel].badge} rounded-xl flex items-center justify-center`}>
<span className={`text-3xl font-bold ${DEPTH_LEVEL_COLORS[decision.determinedLevel].text}`}>
{decision.determinedLevel}
</span>
</div>
<div className="flex-1">
<h2 className={`text-2xl font-bold ${DEPTH_LEVEL_COLORS[decision.determinedLevel].text} mb-2`}>
{DEPTH_LEVEL_LABELS[decision.determinedLevel]}
</h2>
<p className="text-gray-700 mb-3">{DEPTH_LEVEL_DESCRIPTIONS[decision.determinedLevel]}</p>
{decision.reasoning && decision.reasoning.length > 0 && (
<p className="text-sm text-gray-600 italic">{decision.reasoning.map(r => r.description).filter(Boolean).join('. ')}</p>
)}
</div>
</div>
</div>
<LevelCard decision={decision} />
{/* 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.risk_score)}
{renderScoreBar('Komplexitäts-Score', decision.scores.complexity_score)}
{renderScoreBar('Assurance-Score', decision.scores.assurance_need)}
<div className="pt-4 border-t border-gray-200">
{renderScoreBar('Gesamt-Score', decision.scores.composite_score)}
</div>
</div>
</div>
)}
<ScoreBreakdown decision={decision} />
{/* Applicable Regulations */}
{(applicableRegulations || regulationAssessmentLoading) && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Anwendbare Regulierungen</h3>
{regulationAssessmentLoading ? (
<div className="flex items-center gap-3 text-gray-500">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<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>Regulierungen werden geprueft...</span>
</div>
) : applicableRegulations && applicableRegulations.length > 0 ? (
<div className="space-y-3">
{applicableRegulations.map((reg) => (
<div
key={reg.id}
className="flex items-center justify-between border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<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>
</div>
<div>
<span className="font-semibold text-gray-900">{reg.name}</span>
{reg.classification && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
{reg.classification}
</span>
)}
</div>
</div>
<div className="text-right text-sm text-gray-600">
<span>{reg.obligation_count} Pflichten</span>
{reg.control_count > 0 && (
<span className="ml-2">{reg.control_count} Controls</span>
)}
</div>
</div>
))}
<RegulationsPanel
applicableRegulations={applicableRegulations}
supervisoryAuthorities={supervisoryAuthorities}
regulationAssessmentLoading={regulationAssessmentLoading}
onGoToObligations={onGoToObligations}
/>
{/* Supervisory Authorities */}
{supervisoryAuthorities && supervisoryAuthorities.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200">
<h4 className="text-sm font-semibold text-gray-700 mb-3">Zustaendige Aufsichtsbehoerden</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{supervisoryAuthorities.map((sa, idx) => (
<div key={idx} className="flex items-start gap-3 bg-gray-50 rounded-lg p-3">
<div className="flex-shrink-0 w-6 h-6 bg-blue-100 rounded flex items-center justify-center mt-0.5">
<svg className="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<div>
<span className="text-sm font-medium text-gray-900">{sa.authority.abbreviation}</span>
<span className="text-xs text-gray-500 ml-1">({sa.domain})</span>
<p className="text-xs text-gray-600 mt-0.5">{sa.authority.name}</p>
{sa.authority.url && (
<a
href={sa.authority.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-purple-600 hover:text-purple-700"
>
Website
</a>
)}
</div>
</div>
))}
</div>
</div>
)}
<HardTriggersPanel
decision={decision}
expandedTrigger={expandedTrigger}
onToggle={(idx) => setExpandedTrigger(expandedTrigger === idx ? null : idx)}
/>
{/* Link to Obligations */}
{onGoToObligations && (
<div className="mt-4 pt-4 border-t border-gray-200">
<button
onClick={onGoToObligations}
className="inline-flex items-center gap-2 px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Pflichten anzeigen
<svg className="w-4 h-4" 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>
</button>
</div>
)}
</div>
) : (
<p className="text-gray-500 text-sm">Keine anwendbaren Regulierungen ermittelt.</p>
)}
</div>
)}
<RequiredDocumentsPanel decision={decision} />
{/* Hard Triggers */}
{decision.triggeredHardTriggers && decision.triggeredHardTriggers.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.triggeredHardTriggers.map((trigger, idx) => (
<div
key={idx}
className="border rounded-lg overflow-hidden border-red-300 bg-red-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">
<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.description}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-red-200 text-red-800 font-medium">
Min. {trigger.minimumLevel}
</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.mandatoryDocuments && trigger.mandatoryDocuments.length > 0 && (
<p className="text-xs text-gray-700">
<span className="font-medium">Pflichtdokumente:</span> {trigger.mandatoryDocuments.join(', ')}
</p>
)}
{trigger.requiresDSFA && (
<p className="text-xs text-orange-700 font-medium mt-1">DSFA erforderlich</p>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
<RiskFlagsPanel decision={decision} />
{/* 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">Dokument</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Priorität</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">Trigger</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.requirement === 'mandatory' && (
<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 capitalize">{doc.priority}</td>
<td className="py-3 px-4 text-sm text-gray-700">
{doc.estimatedEffort ? `${doc.estimatedEffort}h` : '-'}
</td>
<td className="py-3 px-4">
{doc.triggeredBy && doc.triggeredBy.length > 0 && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
{doc.triggeredBy.join(', ')}
</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>
)}
<GapAnalysisPanel decision={decision} />
{/* 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.message}</h4>
{getSeverityBadge(flag.severity)}
</div>
{flag.legalReference && (
<p className="text-xs text-gray-500 mb-2">{flag.legalReference}</p>
)}
<p className="text-sm text-gray-600">
<span className="font-medium">Empfehlung:</span> {flag.recommendation}
</p>
</div>
))}
</div>
</div>
)}
{/* Gap Analysis */}
{decision.gaps && decision.gaps.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.gaps.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.description}</h4>
{getSeverityBadge(gap.severity)}
</div>
<p className="text-sm text-gray-700 mb-2">
<span className="font-medium">Ist:</span> {gap.currentState}
</p>
<p className="text-sm text-gray-600 mb-2">
<span className="font-medium">Soll:</span> {gap.targetState}
</p>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>Aufwand: ~{gap.effort}h</span>
<span>Level: {gap.requiredFor}</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">{idx + 1}</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.estimatedEffort > 0 && (
<span className="text-xs text-gray-600">
<span className="font-medium">Aufwand:</span> ~{action.estimatedEffort}h
</span>
)}
{action.sdkStepUrl && (
<a href={action.sdkStepUrl} className="text-xs text-purple-600 hover:text-purple-700">
Zum SDK-Schritt
</a>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
<NextActionsPanel decision={decision} />
{/* Action Buttons */}
<div className="flex items-center gap-3">
@@ -465,46 +117,11 @@ export function ScopeDecisionTab({
)}
</div>
{/* Audit Trail (from reasoning) */}
{decision.reasoning && decision.reasoning.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.reasoning.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.factors && entry.factors.length > 0 && (
<ul className="text-xs text-gray-600 space-y-1">
{entry.factors.map((factor, factorIdx) => (
<li key={factorIdx}> {factor}</li>
))}
</ul>
)}
{entry.impact && (
<p className="text-xs text-purple-700 font-medium mt-1">{entry.impact}</p>
)}
</div>
))}
</div>
)}
</div>
)}
<AuditTrailPanel
decision={decision}
showAuditTrail={showAuditTrail}
onToggle={() => setShowAuditTrail(!showAuditTrail)}
/>
</div>
)
}

View File

@@ -1,11 +1,10 @@
'use client'
import React, { useState, useCallback, useEffect } from 'react'
import type { ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types'
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, prefillFromCompanyProfile, getProfileInfoForBlock, getAutoFilledScoringAnswers, getUnansweredRequiredQuestions } from '@/lib/sdk/compliance-scope-profiling'
import type { ScopeProfilingAnswer, ScopeProfilingQuestion } from '@/lib/sdk/compliance-scope-types'
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, getAnswerValue, prefillFromCompanyProfile, getProfileInfoForBlock, getAutoFilledScoringAnswers, getUnansweredRequiredQuestions } from '@/lib/sdk/compliance-scope-profiling'
import type { ScopeQuestionBlockId } from '@/lib/sdk/compliance-scope-types'
import { useSDK } from '@/lib/sdk'
import { DatenkategorienBlock9 } from './DatenkategorienBlock'
import { ScopeQuestionRenderer } from './ScopeQuestionRenderer'
interface ScopeWizardTabProps {
answers: ScopeProfilingAnswer[]
@@ -102,12 +101,107 @@ export function ScopeWizardTab({
const toggleHelp = useCallback((questionId: string) => {
setExpandedHelp(prev => {
const next = new Set(prev)
if (next.has(questionId)) next.delete(questionId)
else next.add(questionId)
if (next.has(questionId)) { next.delete(questionId) } else { next.add(questionId) }
return next
})
}, [])
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)
const label = (
<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>
)
switch (question.type) {
case 'boolean':
return (
<div className="space-y-2">
<div className="flex items-start justify-between">{label}</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">
{label}
<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">
{label}
<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">
{label}
<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">
{label}
<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 */}
@@ -126,25 +220,12 @@ export function ScopeWizardTab({
const optionalDone = !hasRequired && hasAnyAnswer
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'
}`}
>
<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-sm font-medium ${isActive ? 'text-purple-700' : 'text-gray-700'}`}>{block.title}</span>
{allRequiredDone || optionalDone ? (
<span className="flex items-center gap-1 text-xs font-semibold text-green-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.5} d="M5 13l4 4L19 7" />
</svg>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" /></svg>
{!hasRequired && <span>(optional)</span>}
</span>
) : !hasRequired ? (
@@ -154,12 +235,7 @@ export function ScopeWizardTab({
)}
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full transition-all ${
allRequiredDone || optionalDone ? 'bg-green-500' : !hasRequired ? 'bg-gray-300' : 'bg-orange-400'
}`}
style={{ width: `${progress}%` }}
/>
<div className={`h-full transition-all ${allRequiredDone || optionalDone ? 'bg-green-500' : !hasRequired ? 'bg-gray-300' : 'bg-orange-400'}`} style={{ width: `${progress}%` }} />
</div>
</button>
)
@@ -175,20 +251,15 @@ export function ScopeWizardTab({
<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 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 className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-500" style={{ width: `${totalProgress}%` }} />
</div>
{/* Clickable unanswered required questions summary */}
{/* Unanswered required questions summary */}
{(() => {
const allUnanswered = getUnansweredRequiredQuestions(answers)
if (allUnanswered.length === 0) return null
@@ -206,11 +277,7 @@ export function ScopeWizardTab({
{Array.from(byBlock.entries()).map(([blockId, info], i) => (
<React.Fragment key={blockId}>
{i > 0 && <span className="text-gray-300">·</span>}
<button
type="button"
onClick={() => setCurrentBlockIndex(info.blockIndex)}
className="text-orange-700 hover:text-orange-900 hover:underline font-medium"
>
<button type="button" onClick={() => setCurrentBlockIndex(info.blockIndex)} className="text-orange-700 hover:text-orange-900 hover:underline font-medium">
{info.blockTitle} ({info.count})
</button>
</React.Fragment>
@@ -228,17 +295,13 @@ export function ScopeWizardTab({
<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"
>
<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>
{/* "Aus Profil" Info Box */}
{/* Profile Info Box */}
{companyProfile && (() => {
const profileItems = getProfileInfoForBlock(companyProfile, currentBlock.id as ScopeQuestionBlockId)
if (profileItems.length === 0) return null
@@ -247,27 +310,16 @@ export function ScopeWizardTab({
<div className="flex items-start justify-between">
<div>
<h4 className="text-sm font-medium text-blue-900 mb-2 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="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>
<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>
Aus Unternehmensprofil
</h4>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-blue-800">
{profileItems.map(item => (
<span key={item.label}>
<span className="font-medium">{item.label}:</span> {item.value}
</span>
))}
{profileItems.map(item => (<span key={item.label}><span className="font-medium">{item.label}:</span> {item.value}</span>))}
</div>
</div>
<a
href="/sdk/company-profile"
className="text-sm text-blue-600 hover:text-blue-800 font-medium whitespace-nowrap flex items-center gap-1"
>
<a href="/sdk/company-profile" className="text-sm text-blue-600 hover:text-blue-800 font-medium whitespace-nowrap flex items-center gap-1">
Profil bearbeiten
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
</a>
</div>
</div>
@@ -281,9 +333,7 @@ export function ScopeWizardTab({
) : (
currentBlock.questions.map((question) => {
const isAnswered = answers.some(a => a.questionId === question.id)
const borderClass = question.required
? isAnswered ? 'border-l-4 border-l-green-400 pl-4' : 'border-l-4 border-l-orange-400 pl-4'
: ''
const borderClass = question.required ? (isAnswered ? 'border-l-4 border-l-green-400 pl-4' : 'border-l-4 border-l-orange-400 pl-4') : ''
return (
<div key={question.id} className={`border-b border-gray-100 pb-6 last:border-b-0 last:pb-0 ${borderClass}`}>
<ScopeQuestionRenderer
@@ -303,32 +353,16 @@ export function ScopeWizardTab({
{/* 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"
>
<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>
<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"
>
<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"
>
<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>
)}

View File

@@ -0,0 +1,29 @@
import { AIModuleReviewTriggerType } from '@/lib/sdk/dsfa/ai-use-case-types'
export const TABS = [
{ id: 1, label: 'System', icon: '🖥️' },
{ id: 2, label: 'Daten', icon: '📊' },
{ id: 3, label: 'Zweck & Art. 22', icon: '⚖️' },
{ id: 4, label: 'KI-Kriterien', icon: '🔍' },
{ id: 5, label: 'Risiken', icon: '⚠️' },
{ id: 6, label: 'Maßnahmen', icon: '🛡️' },
{ id: 7, label: 'Review', icon: '🔄' },
]
export const REVIEW_TRIGGER_TYPES: { value: AIModuleReviewTriggerType; label: string; icon: string }[] = [
{ value: 'model_update', label: 'Modell-Update', icon: '🔄' },
{ value: 'data_drift', label: 'Datendrift', icon: '📉' },
{ value: 'accuracy_drop', label: 'Genauigkeitsabfall', icon: '📊' },
{ value: 'new_use_case', label: 'Neuer Anwendungsfall', icon: '🎯' },
{ value: 'regulatory_change', label: 'Regulatorische Änderung', icon: '📜' },
{ value: 'incident', label: 'Sicherheitsvorfall', icon: '🚨' },
{ value: 'periodic', label: 'Regelmäßig (zeitbasiert)', icon: '📅' },
]
export const LEGAL_BASES = [
'Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)',
'Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung)',
'Art. 6 Abs. 1 lit. c DSGVO (Rechtliche Verpflichtung)',
'Art. 6 Abs. 1 lit. f DSGVO (Berechtigtes Interesse)',
'Art. 9 Abs. 2 lit. a DSGVO (Ausdrückliche Einwilligung)',
]

View File

@@ -0,0 +1,4 @@
// Barrel re-export — implementation split into focused files
export { Tab1System, Tab2Data } from './AIUseCaseTabsSystemData'
export { Tab3Purpose, Tab4AIAct } from './AIUseCaseTabsPurposeAct'
export { Tab5Risks, Tab6PrivacyByDesign, Tab7Review } from './AIUseCaseTabsRisksReview'

View File

@@ -3,10 +3,7 @@
import React, { useState } from 'react'
import {
AIUseCaseModule,
AIUseCaseType,
AIActRiskClass,
AI_USE_CASE_TYPES,
AI_ACT_RISK_CLASSES,
PRIVACY_BY_DESIGN_CATEGORIES,
PrivacyByDesignCategory,
PrivacyByDesignMeasure,
@@ -14,8 +11,16 @@ import {
AIModuleReviewTriggerType,
checkArt22Applicability,
} from '@/lib/sdk/dsfa/ai-use-case-types'
import { Art22AssessmentPanel } from './Art22AssessmentPanel'
import { AIRiskCriteriaChecklist } from './AIRiskCriteriaChecklist'
import { TABS, REVIEW_TRIGGER_TYPES } from './AIUseCaseEditorConstants'
import {
Tab1System,
Tab2Data,
Tab3Purpose,
Tab4AIAct,
Tab5Risks,
Tab6PrivacyByDesign,
Tab7Review,
} from './AIUseCaseEditorTabs'
interface AIUseCaseModuleEditorProps {
module: AIUseCaseModule
@@ -24,34 +29,6 @@ interface AIUseCaseModuleEditorProps {
isSaving?: boolean
}
const TABS = [
{ id: 1, label: 'System', icon: '🖥️' },
{ id: 2, label: 'Daten', icon: '📊' },
{ id: 3, label: 'Zweck & Art. 22', icon: '⚖️' },
{ id: 4, label: 'KI-Kriterien', icon: '🔍' },
{ id: 5, label: 'Risiken', icon: '⚠️' },
{ id: 6, label: 'Maßnahmen', icon: '🛡️' },
{ id: 7, label: 'Review', icon: '🔄' },
]
const REVIEW_TRIGGER_TYPES: { value: AIModuleReviewTriggerType; label: string; icon: string }[] = [
{ value: 'model_update', label: 'Modell-Update', icon: '🔄' },
{ value: 'data_drift', label: 'Datendrift', icon: '📉' },
{ value: 'accuracy_drop', label: 'Genauigkeitsabfall', icon: '📊' },
{ value: 'new_use_case', label: 'Neuer Anwendungsfall', icon: '🎯' },
{ value: 'regulatory_change', label: 'Regulatorische Änderung', icon: '📜' },
{ value: 'incident', label: 'Sicherheitsvorfall', icon: '🚨' },
{ value: 'periodic', label: 'Regelmäßig (zeitbasiert)', icon: '📅' },
]
const LEGAL_BASES = [
'Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)',
'Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung)',
'Art. 6 Abs. 1 lit. c DSGVO (Rechtliche Verpflichtung)',
'Art. 6 Abs. 1 lit. f DSGVO (Berechtigtes Interesse)',
'Art. 9 Abs. 2 lit. a DSGVO (Ausdrückliche Einwilligung)',
]
export function AIUseCaseModuleEditor({ module: initialModule, onSave, onCancel, isSaving }: AIUseCaseModuleEditorProps) {
const [activeTab, setActiveTab] = useState(1)
const [module, setModule] = useState<AIUseCaseModule>(initialModule)
@@ -146,514 +123,26 @@ export function AIUseCaseModuleEditor({ module: initialModule, onSave, onCancel,
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{/* Tab 1: Systembeschreibung */}
{activeTab === 1 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name des KI-Anwendungsfalls *</label>
<input
type="text"
value={module.name}
onChange={e => update({ name: e.target.value })}
placeholder={`z.B. ${typeInfo.label} für Kundenservice`}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung *</label>
<textarea
value={module.model_description}
onChange={e => update({ model_description: e.target.value })}
rows={4}
placeholder="Beschreiben Sie das KI-System: Funktionsweise, Input/Output, eingesetzte Algorithmen..."
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Modell-Typ</label>
<input
type="text"
value={module.model_type || ''}
onChange={e => update({ model_type: e.target.value })}
placeholder="z.B. Random Forest, GPT-4, CNN"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Anbieter / Provider</label>
<input
type="text"
value={module.provider || ''}
onChange={e => update({ provider: e.target.value })}
placeholder="z.B. Anthropic, OpenAI, intern"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Datenfluss-Beschreibung</label>
<textarea
value={module.data_flow_description || ''}
onChange={e => update({ data_flow_description: e.target.value })}
rows={3}
placeholder="Wie fließen Daten in das KI-System ein und aus? Gibt es Drittland-Transfers?"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
/>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="third_country"
checked={module.third_country_transfer}
onChange={e => update({ third_country_transfer: e.target.checked })}
className="h-4 w-4 rounded border-gray-300 text-purple-600"
/>
<label htmlFor="third_country" className="text-sm text-gray-700">
Drittland-Transfer (außerhalb EU/EWR)
</label>
{module.third_country_transfer && (
<input
type="text"
value={module.provider_country || ''}
onChange={e => update({ provider_country: e.target.value })}
placeholder="Land (z.B. USA)"
className="ml-2 px-2 py-1 text-sm border border-orange-300 rounded focus:ring-2 focus:ring-orange-500"
/>
)}
</div>
</div>
)}
{/* Tab 2: Daten & Betroffene */}
{activeTab === 1 && <Tab1System module={module} update={update} typeInfo={typeInfo} />}
{activeTab === 2 && (
<div className="space-y-4">
{/* Input Data Categories */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Input-Datenkategorien *</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newCategory}
onChange={e => setNewCategory(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addToList('input_data_categories', newCategory, setNewCategory)}
placeholder="Datenkategorie hinzufügen..."
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
<button
onClick={() => addToList('input_data_categories', newCategory, setNewCategory)}
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
>
+
</button>
</div>
<div className="flex flex-wrap gap-2">
{(module.input_data_categories || []).map((cat, i) => (
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs">
{cat}
<button onClick={() => removeFromList('input_data_categories', i)} className="hover:text-purple-900">×</button>
</span>
))}
</div>
</div>
{/* Output Data Categories */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Output-Datenkategorien</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newOutputCategory}
onChange={e => setNewOutputCategory(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addToList('output_data_categories', newOutputCategory, setNewOutputCategory)}
placeholder="Output-Kategorie (z.B. Bewertung, Empfehlung)..."
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
<button
onClick={() => addToList('output_data_categories', newOutputCategory, setNewOutputCategory)}
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
>
+
</button>
</div>
<div className="flex flex-wrap gap-2">
{(module.output_data_categories || []).map((cat, i) => (
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
{cat}
<button onClick={() => removeFromList('output_data_categories', i)} className="hover:text-blue-900">×</button>
</span>
))}
</div>
</div>
{/* Special Categories */}
<div className="flex items-start gap-3 p-3 rounded-lg border border-gray-200">
<input
type="checkbox"
id="special_cats"
checked={module.involves_special_categories}
onChange={e => update({ involves_special_categories: e.target.checked })}
className="mt-1 h-4 w-4 rounded border-gray-300 text-purple-600"
/>
<label htmlFor="special_cats" className="flex-1">
<div className="text-sm font-medium text-gray-900">Besondere Kategorien (Art. 9 DSGVO)</div>
<p className="text-xs text-gray-500">Gesundheit, Biometrie, Religion, politische Meinung etc.</p>
{module.involves_special_categories && (
<textarea
value={module.special_categories_justification || ''}
onChange={e => update({ special_categories_justification: e.target.value })}
rows={2}
placeholder="Begründung nach Art. 9 Abs. 2 DSGVO..."
className="mt-2 w-full px-3 py-2 text-xs border border-orange-300 rounded focus:ring-2 focus:ring-orange-400 resize-none"
/>
)}
</label>
</div>
{/* Data Subjects */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Betroffenengruppen *</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newSubject}
onChange={e => setNewSubject(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addToList('data_subjects', newSubject, setNewSubject)}
placeholder="z.B. Kunden, Mitarbeiter, Nutzer..."
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
<button
onClick={() => addToList('data_subjects', newSubject, setNewSubject)}
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
>
+
</button>
</div>
<div className="flex flex-wrap gap-2">
{(module.data_subjects || []).map((s, i) => (
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
{s}
<button onClick={() => removeFromList('data_subjects', i)} className="hover:text-green-900">×</button>
</span>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Geschätztes Volumen</label>
<input
type="text"
value={module.estimated_volume || ''}
onChange={e => update({ estimated_volume: e.target.value })}
placeholder="z.B. >10.000 Personen/Monat"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Aufbewahrungsdauer (Monate)</label>
<input
type="number"
value={module.data_retention_months || ''}
onChange={e => update({ data_retention_months: parseInt(e.target.value) || undefined })}
min={1}
placeholder="z.B. 24"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
</div>
)}
{/* Tab 3: Zweck & Art. 22 */}
{activeTab === 3 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungszweck *</label>
<textarea
value={module.processing_purpose}
onChange={e => update({ processing_purpose: e.target.value })}
rows={3}
placeholder="Welchem Zweck dient dieses KI-System? Was soll erreicht werden?"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsgrundlage *</label>
<select
value={module.legal_basis}
onChange={e => update({ legal_basis: e.target.value })}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="">Bitte wählen...</option>
{LEGAL_BASES.map(lb => (
<option key={lb} value={lb}>{lb}</option>
))}
</select>
</div>
{module.legal_basis && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Details zur Rechtsgrundlage</label>
<textarea
value={module.legal_basis_details || ''}
onChange={e => update({ legal_basis_details: e.target.value })}
rows={2}
placeholder="Ergänzende Erläuterung zur Rechtsgrundlage..."
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
/>
</div>
)}
{/* Art. 22 Panel */}
<div className="border-t border-gray-200 pt-4">
<h4 className="text-sm font-semibold text-gray-900 mb-3">
Art. 22 DSGVO Automatisierte Einzelentscheidung
{art22Required && (
<span className="ml-2 text-xs px-1.5 py-0.5 bg-red-100 text-red-700 rounded">
Wahrscheinlich anwendbar
</span>
)}
</h4>
<Art22AssessmentPanel
assessment={module.art22_assessment}
onChange={a22 => update({ art22_assessment: a22 })}
/>
</div>
</div>
)}
{/* Tab 4: KI-Kriterien & AI Act */}
{activeTab === 4 && (
<div className="space-y-6">
<div>
<h4 className="text-sm font-semibold text-gray-900 mb-3">WP248-Risikokriterien</h4>
<AIRiskCriteriaChecklist
criteria={module.risk_criteria}
onChange={criteria => update({ risk_criteria: criteria })}
/>
</div>
<div className="border-t border-gray-200 pt-4">
<h4 className="text-sm font-semibold text-gray-900 mb-3">EU AI Act Risikoklasse</h4>
<div className="grid grid-cols-2 gap-3">
{(Object.entries(AI_ACT_RISK_CLASSES) as [AIActRiskClass, typeof AI_ACT_RISK_CLASSES[AIActRiskClass]][]).map(([cls, info]) => (
<button
key={cls}
onClick={() => update({ ai_act_risk_class: cls })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
module.ai_act_risk_class === cls
? cls === 'unacceptable' ? 'border-red-500 bg-red-50'
: cls === 'high_risk' ? 'border-orange-500 bg-orange-50'
: cls === 'limited' ? 'border-yellow-500 bg-yellow-50'
: 'border-green-500 bg-green-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="font-medium text-sm text-gray-900">{info.labelDE}</div>
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{info.description}</p>
</button>
))}
</div>
{module.ai_act_risk_class && (
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Begründung der Klassifizierung</label>
<textarea
value={module.ai_act_justification || ''}
onChange={e => update({ ai_act_justification: e.target.value })}
rows={2}
placeholder="Warum wurde diese Risikoklasse gewählt?"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
/>
</div>
)}
{module.ai_act_risk_class && AI_ACT_RISK_CLASSES[module.ai_act_risk_class].requirements.length > 0 && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
<div className="text-xs font-medium text-gray-700 mb-2">Anforderungen dieser Klasse:</div>
<ul className="space-y-1">
{AI_ACT_RISK_CLASSES[module.ai_act_risk_class].requirements.map((req, i) => (
<li key={i} className="text-xs text-gray-600 flex items-start gap-1.5">
<span className="text-purple-500 flex-shrink-0"></span>
{req}
</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{/* Tab 5: Risikoanalyse */}
{activeTab === 5 && (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Spezifische Risiken für diesen KI-Anwendungsfall. Typische Risiken basierend auf dem gewählten Typ:
</p>
{typeInfo.typical_risks.length > 0 && (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="text-xs font-medium text-yellow-800 mb-1">Typische Risiken für {typeInfo.label}:</div>
<ul className="space-y-0.5">
{typeInfo.typical_risks.map((r, i) => (
<li key={i} className="text-xs text-yellow-700 flex items-center gap-1.5">
<span></span> {r}
</li>
))}
</ul>
</div>
)}
<div className="space-y-2">
{(module.risks || []).map((risk, idx) => (
<div key={idx} className="p-3 border border-gray-200 rounded-lg bg-gray-50">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-gray-900">{risk.description}</p>
<div className="flex gap-2 mt-1">
<span className="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-600 rounded">W: {risk.likelihood}</span>
<span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-600 rounded">S: {risk.impact}</span>
</div>
</div>
<button
onClick={() => update({ risks: module.risks.filter((_, i) => i !== idx) })}
className="text-gray-400 hover:text-red-500 ml-2"
>
×
</button>
</div>
</div>
))}
{(module.risks || []).length === 0 && (
<p className="text-sm text-gray-400 text-center py-4">Noch keine Risiken dokumentiert</p>
)}
</div>
{/* Add Risk */}
<button
onClick={() => {
const desc = prompt('Risiko-Beschreibung:')
if (desc) {
update({
risks: [...(module.risks || []), {
risk_id: crypto.randomUUID(),
description: desc,
likelihood: 'medium',
impact: 'medium',
mitigation_ids: [],
}]
})
}
}}
className="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-purple-400 hover:text-purple-600 transition-colors"
>
+ Risiko hinzufügen
</button>
</div>
)}
{/* Tab 6: Maßnahmen & Privacy by Design */}
{activeTab === 6 && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold text-gray-900 mb-3">Privacy by Design Maßnahmen</h4>
<div className="grid grid-cols-2 gap-2">
{(Object.entries(PRIVACY_BY_DESIGN_CATEGORIES) as [PrivacyByDesignCategory, typeof PRIVACY_BY_DESIGN_CATEGORIES[PrivacyByDesignCategory]][]).map(([cat, info]) => {
const measure = module.privacy_by_design_measures?.find(m => m.category === cat)
return (
<button
key={cat}
onClick={() => togglePbdMeasure(cat)}
className={`flex items-start gap-2 p-3 rounded-lg border text-left transition-all ${
measure?.implemented
? 'border-green-400 bg-green-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<span className="text-lg flex-shrink-0">{info.icon}</span>
<div>
<div className={`text-xs font-medium ${measure?.implemented ? 'text-green-800' : 'text-gray-700'}`}>
{info.label}
</div>
<p className="text-[10px] text-gray-500 mt-0.5">{info.description}</p>
</div>
</button>
)
})}
</div>
</div>
</div>
)}
{/* Tab 7: Review-Trigger */}
{activeTab === 7 && (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Wählen Sie die Ereignisse, die eine erneute Bewertung dieses KI-Anwendungsfalls auslösen sollen.
</p>
<div className="space-y-2">
{REVIEW_TRIGGER_TYPES.map(rt => {
const active = module.review_triggers?.some(t => t.type === rt.value)
const trigger = module.review_triggers?.find(t => t.type === rt.value)
return (
<div key={rt.value} className={`rounded-lg border p-3 transition-all ${active ? 'border-purple-300 bg-purple-50' : 'border-gray-200'}`}>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={active || false}
onChange={() => toggleReviewTrigger(rt.value)}
className="h-4 w-4 rounded border-gray-300 text-purple-600"
/>
<span className="text-base">{rt.icon}</span>
<span className="text-sm font-medium text-gray-900">{rt.label}</span>
</div>
{active && (
<div className="mt-2 ml-7 space-y-2">
<input
type="text"
value={trigger?.threshold || ''}
onChange={e => {
const updated = (module.review_triggers || []).map(t =>
t.type === rt.value ? { ...t, threshold: e.target.value } : t
)
update({ review_triggers: updated })
}}
placeholder="Schwellwert (z.B. Genauigkeit < 80%)"
className="w-full px-2 py-1 text-xs border border-purple-200 rounded focus:ring-2 focus:ring-purple-400"
/>
<input
type="text"
value={trigger?.monitoring_interval || ''}
onChange={e => {
const updated = (module.review_triggers || []).map(t =>
t.type === rt.value ? { ...t, monitoring_interval: e.target.value } : t
)
update({ review_triggers: updated })
}}
placeholder="Monitoring-Intervall (z.B. wöchentlich)"
className="w-full px-2 py-1 text-xs border border-purple-200 rounded focus:ring-2 focus:ring-purple-400"
/>
</div>
)}
</div>
)
})}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Monitoring-Beschreibung</label>
<textarea
value={module.monitoring_description || ''}
onChange={e => update({ monitoring_description: e.target.value })}
rows={3}
placeholder="Wie wird das KI-System kontinuierlich überwacht? Welche Metriken werden erfasst?"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nächstes Review-Datum</label>
<input
type="date"
value={module.next_review_date || ''}
onChange={e => update({ next_review_date: e.target.value })}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
<Tab2Data
module={module}
update={update}
newCategory={newCategory}
setNewCategory={setNewCategory}
newOutputCategory={newOutputCategory}
setNewOutputCategory={setNewOutputCategory}
newSubject={newSubject}
setNewSubject={setNewSubject}
addToList={addToList}
removeFromList={removeFromList}
/>
)}
{activeTab === 3 && <Tab3Purpose module={module} update={update} art22Required={art22Required} />}
{activeTab === 4 && <Tab4AIAct module={module} update={update} />}
{activeTab === 5 && <Tab5Risks module={module} update={update} typeInfo={typeInfo} />}
{activeTab === 6 && <Tab6PrivacyByDesign module={module} update={update} togglePbdMeasure={togglePbdMeasure} />}
{activeTab === 7 && <Tab7Review module={module} update={update} toggleReviewTrigger={toggleReviewTrigger} />}
</div>
{/* Footer */}

View File

@@ -0,0 +1,149 @@
'use client'
import React from 'react'
import {
AIUseCaseModule,
AIActRiskClass,
AI_ACT_RISK_CLASSES,
} from '@/lib/sdk/dsfa/ai-use-case-types'
import { Art22AssessmentPanel } from './Art22AssessmentPanel'
import { AIRiskCriteriaChecklist } from './AIRiskCriteriaChecklist'
import { LEGAL_BASES } from './AIUseCaseEditorConstants'
type UpdateFn = (updates: Partial<AIUseCaseModule>) => void
// =============================================================================
// TAB 3: Zweck & Art. 22
// =============================================================================
interface Tab3PurposeProps {
module: AIUseCaseModule
update: UpdateFn
art22Required: boolean
}
export function Tab3Purpose({ module, update, art22Required }: Tab3PurposeProps) {
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungszweck *</label>
<textarea
value={module.processing_purpose}
onChange={e => update({ processing_purpose: e.target.value })}
rows={3}
placeholder="Welchem Zweck dient dieses KI-System? Was soll erreicht werden?"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsgrundlage *</label>
<select
value={module.legal_basis}
onChange={e => update({ legal_basis: e.target.value })}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="">Bitte wählen...</option>
{LEGAL_BASES.map(lb => (
<option key={lb} value={lb}>{lb}</option>
))}
</select>
</div>
{module.legal_basis && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Details zur Rechtsgrundlage</label>
<textarea
value={module.legal_basis_details || ''}
onChange={e => update({ legal_basis_details: e.target.value })}
rows={2}
placeholder="Ergänzende Erläuterung zur Rechtsgrundlage..."
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
/>
</div>
)}
<div className="border-t border-gray-200 pt-4">
<h4 className="text-sm font-semibold text-gray-900 mb-3">
Art. 22 DSGVO Automatisierte Einzelentscheidung
{art22Required && (
<span className="ml-2 text-xs px-1.5 py-0.5 bg-red-100 text-red-700 rounded">
Wahrscheinlich anwendbar
</span>
)}
</h4>
<Art22AssessmentPanel
assessment={module.art22_assessment}
onChange={a22 => update({ art22_assessment: a22 })}
/>
</div>
</div>
)
}
// =============================================================================
// TAB 4: KI-Kriterien & AI Act
// =============================================================================
interface Tab4AIActProps {
module: AIUseCaseModule
update: UpdateFn
}
export function Tab4AIAct({ module, update }: Tab4AIActProps) {
return (
<div className="space-y-6">
<div>
<h4 className="text-sm font-semibold text-gray-900 mb-3">WP248-Risikokriterien</h4>
<AIRiskCriteriaChecklist
criteria={module.risk_criteria}
onChange={criteria => update({ risk_criteria: criteria })}
/>
</div>
<div className="border-t border-gray-200 pt-4">
<h4 className="text-sm font-semibold text-gray-900 mb-3">EU AI Act Risikoklasse</h4>
<div className="grid grid-cols-2 gap-3">
{(Object.entries(AI_ACT_RISK_CLASSES) as [AIActRiskClass, typeof AI_ACT_RISK_CLASSES[AIActRiskClass]][]).map(([cls, info]) => (
<button
key={cls}
onClick={() => update({ ai_act_risk_class: cls })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
module.ai_act_risk_class === cls
? cls === 'unacceptable' ? 'border-red-500 bg-red-50'
: cls === 'high_risk' ? 'border-orange-500 bg-orange-50'
: cls === 'limited' ? 'border-yellow-500 bg-yellow-50'
: 'border-green-500 bg-green-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="font-medium text-sm text-gray-900">{info.labelDE}</div>
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{info.description}</p>
</button>
))}
</div>
{module.ai_act_risk_class && (
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Begründung der Klassifizierung</label>
<textarea
value={module.ai_act_justification || ''}
onChange={e => update({ ai_act_justification: e.target.value })}
rows={2}
placeholder="Warum wurde diese Risikoklasse gewählt?"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
/>
</div>
)}
{module.ai_act_risk_class && AI_ACT_RISK_CLASSES[module.ai_act_risk_class].requirements.length > 0 && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
<div className="text-xs font-medium text-gray-700 mb-2">Anforderungen dieser Klasse:</div>
<ul className="space-y-1">
{AI_ACT_RISK_CLASSES[module.ai_act_risk_class].requirements.map((req, i) => (
<li key={i} className="text-xs text-gray-600 flex items-start gap-1.5">
<span className="text-purple-500 flex-shrink-0"></span>
{req}
</li>
))}
</ul>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,219 @@
'use client'
import React from 'react'
import {
AIUseCaseModule,
AI_USE_CASE_TYPES,
PRIVACY_BY_DESIGN_CATEGORIES,
PrivacyByDesignCategory,
AIModuleReviewTriggerType,
} from '@/lib/sdk/dsfa/ai-use-case-types'
import { REVIEW_TRIGGER_TYPES } from './AIUseCaseEditorConstants'
type UpdateFn = (updates: Partial<AIUseCaseModule>) => void
// =============================================================================
// TAB 5: Risikoanalyse
// =============================================================================
interface Tab5RisksProps {
module: AIUseCaseModule
update: UpdateFn
typeInfo: typeof AI_USE_CASE_TYPES[keyof typeof AI_USE_CASE_TYPES]
}
export function Tab5Risks({ module, update, typeInfo }: Tab5RisksProps) {
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Spezifische Risiken für diesen KI-Anwendungsfall. Typische Risiken basierend auf dem gewählten Typ:
</p>
{typeInfo.typical_risks.length > 0 && (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="text-xs font-medium text-yellow-800 mb-1">Typische Risiken für {typeInfo.label}:</div>
<ul className="space-y-0.5">
{typeInfo.typical_risks.map((r, i) => (
<li key={i} className="text-xs text-yellow-700 flex items-center gap-1.5">
<span></span> {r}
</li>
))}
</ul>
</div>
)}
<div className="space-y-2">
{(module.risks || []).map((risk, idx) => (
<div key={idx} className="p-3 border border-gray-200 rounded-lg bg-gray-50">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-gray-900">{risk.description}</p>
<div className="flex gap-2 mt-1">
<span className="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-600 rounded">W: {risk.likelihood}</span>
<span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-600 rounded">S: {risk.impact}</span>
</div>
</div>
<button
onClick={() => update({ risks: module.risks.filter((_, i) => i !== idx) })}
className="text-gray-400 hover:text-red-500 ml-2"
>
×
</button>
</div>
</div>
))}
{(module.risks || []).length === 0 && (
<p className="text-sm text-gray-400 text-center py-4">Noch keine Risiken dokumentiert</p>
)}
</div>
<button
onClick={() => {
const desc = prompt('Risiko-Beschreibung:')
if (desc) {
update({
risks: [...(module.risks || []), {
risk_id: crypto.randomUUID(),
description: desc,
likelihood: 'medium',
impact: 'medium',
mitigation_ids: [],
}]
})
}
}}
className="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-purple-400 hover:text-purple-600 transition-colors"
>
+ Risiko hinzufügen
</button>
</div>
)
}
// =============================================================================
// TAB 6: Maßnahmen & Privacy by Design
// =============================================================================
interface Tab6PrivacyByDesignProps {
module: AIUseCaseModule
update: UpdateFn
togglePbdMeasure: (category: PrivacyByDesignCategory) => void
}
export function Tab6PrivacyByDesign({ module, togglePbdMeasure }: Tab6PrivacyByDesignProps) {
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold text-gray-900 mb-3">Privacy by Design Maßnahmen</h4>
<div className="grid grid-cols-2 gap-2">
{(Object.entries(PRIVACY_BY_DESIGN_CATEGORIES) as [PrivacyByDesignCategory, typeof PRIVACY_BY_DESIGN_CATEGORIES[PrivacyByDesignCategory]][]).map(([cat, info]) => {
const measure = module.privacy_by_design_measures?.find(m => m.category === cat)
return (
<button
key={cat}
onClick={() => togglePbdMeasure(cat)}
className={`flex items-start gap-2 p-3 rounded-lg border text-left transition-all ${
measure?.implemented
? 'border-green-400 bg-green-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<span className="text-lg flex-shrink-0">{info.icon}</span>
<div>
<div className={`text-xs font-medium ${measure?.implemented ? 'text-green-800' : 'text-gray-700'}`}>
{info.label}
</div>
<p className="text-[10px] text-gray-500 mt-0.5">{info.description}</p>
</div>
</button>
)
})}
</div>
</div>
</div>
)
}
// =============================================================================
// TAB 7: Review-Trigger
// =============================================================================
interface Tab7ReviewProps {
module: AIUseCaseModule
update: UpdateFn
toggleReviewTrigger: (type: AIModuleReviewTriggerType) => void
}
export function Tab7Review({ module, update, toggleReviewTrigger }: Tab7ReviewProps) {
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Wählen Sie die Ereignisse, die eine erneute Bewertung dieses KI-Anwendungsfalls auslösen sollen.
</p>
<div className="space-y-2">
{REVIEW_TRIGGER_TYPES.map(rt => {
const active = module.review_triggers?.some(t => t.type === rt.value)
const trigger = module.review_triggers?.find(t => t.type === rt.value)
return (
<div key={rt.value} className={`rounded-lg border p-3 transition-all ${active ? 'border-purple-300 bg-purple-50' : 'border-gray-200'}`}>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={active || false}
onChange={() => toggleReviewTrigger(rt.value)}
className="h-4 w-4 rounded border-gray-300 text-purple-600"
/>
<span className="text-base">{rt.icon}</span>
<span className="text-sm font-medium text-gray-900">{rt.label}</span>
</div>
{active && (
<div className="mt-2 ml-7 space-y-2">
<input
type="text"
value={trigger?.threshold || ''}
onChange={e => {
const updated = (module.review_triggers || []).map(t =>
t.type === rt.value ? { ...t, threshold: e.target.value } : t
)
update({ review_triggers: updated })
}}
placeholder="Schwellwert (z.B. Genauigkeit < 80%)"
className="w-full px-2 py-1 text-xs border border-purple-200 rounded focus:ring-2 focus:ring-purple-400"
/>
<input
type="text"
value={trigger?.monitoring_interval || ''}
onChange={e => {
const updated = (module.review_triggers || []).map(t =>
t.type === rt.value ? { ...t, monitoring_interval: e.target.value } : t
)
update({ review_triggers: updated })
}}
placeholder="Monitoring-Intervall (z.B. wöchentlich)"
className="w-full px-2 py-1 text-xs border border-purple-200 rounded focus:ring-2 focus:ring-purple-400"
/>
</div>
)}
</div>
)
})}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Monitoring-Beschreibung</label>
<textarea
value={module.monitoring_description || ''}
onChange={e => update({ monitoring_description: e.target.value })}
rows={3}
placeholder="Wie wird das KI-System kontinuierlich überwacht? Welche Metriken werden erfasst?"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nächstes Review-Datum</label>
<input
type="date"
value={module.next_review_date || ''}
onChange={e => update({ next_review_date: e.target.value })}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,261 @@
'use client'
import React from 'react'
import {
AIUseCaseModule,
AI_USE_CASE_TYPES,
} from '@/lib/sdk/dsfa/ai-use-case-types'
type UpdateFn = (updates: Partial<AIUseCaseModule>) => void
type AddToListFn = (field: keyof AIUseCaseModule, value: string, setter: (v: string) => void) => void
type RemoveFromListFn = (field: keyof AIUseCaseModule, idx: number) => void
// =============================================================================
// TAB 1: System
// =============================================================================
interface Tab1SystemProps {
module: AIUseCaseModule
update: UpdateFn
typeInfo: typeof AI_USE_CASE_TYPES[keyof typeof AI_USE_CASE_TYPES]
}
export function Tab1System({ module, update, typeInfo }: Tab1SystemProps) {
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name des KI-Anwendungsfalls *</label>
<input
type="text"
value={module.name}
onChange={e => update({ name: e.target.value })}
placeholder={`z.B. ${typeInfo.label} für Kundenservice`}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung *</label>
<textarea
value={module.model_description}
onChange={e => update({ model_description: e.target.value })}
rows={4}
placeholder="Beschreiben Sie das KI-System: Funktionsweise, Input/Output, eingesetzte Algorithmen..."
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Modell-Typ</label>
<input
type="text"
value={module.model_type || ''}
onChange={e => update({ model_type: e.target.value })}
placeholder="z.B. Random Forest, GPT-4, CNN"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Anbieter / Provider</label>
<input
type="text"
value={module.provider || ''}
onChange={e => update({ provider: e.target.value })}
placeholder="z.B. Anthropic, OpenAI, intern"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Datenfluss-Beschreibung</label>
<textarea
value={module.data_flow_description || ''}
onChange={e => update({ data_flow_description: e.target.value })}
rows={3}
placeholder="Wie fließen Daten in das KI-System ein und aus? Gibt es Drittland-Transfers?"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
/>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="third_country"
checked={module.third_country_transfer}
onChange={e => update({ third_country_transfer: e.target.checked })}
className="h-4 w-4 rounded border-gray-300 text-purple-600"
/>
<label htmlFor="third_country" className="text-sm text-gray-700">
Drittland-Transfer (außerhalb EU/EWR)
</label>
{module.third_country_transfer && (
<input
type="text"
value={module.provider_country || ''}
onChange={e => update({ provider_country: e.target.value })}
placeholder="Land (z.B. USA)"
className="ml-2 px-2 py-1 text-sm border border-orange-300 rounded focus:ring-2 focus:ring-orange-500"
/>
)}
</div>
</div>
)
}
// =============================================================================
// TAB 2: Daten & Betroffene
// =============================================================================
interface Tab2DataProps {
module: AIUseCaseModule
update: UpdateFn
newCategory: string
setNewCategory: (v: string) => void
newOutputCategory: string
setNewOutputCategory: (v: string) => void
newSubject: string
setNewSubject: (v: string) => void
addToList: AddToListFn
removeFromList: RemoveFromListFn
}
export function Tab2Data({
module, update,
newCategory, setNewCategory,
newOutputCategory, setNewOutputCategory,
newSubject, setNewSubject,
addToList, removeFromList,
}: Tab2DataProps) {
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Input-Datenkategorien *</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newCategory}
onChange={e => setNewCategory(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addToList('input_data_categories', newCategory, setNewCategory)}
placeholder="Datenkategorie hinzufügen..."
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
<button
onClick={() => addToList('input_data_categories', newCategory, setNewCategory)}
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
>
+
</button>
</div>
<div className="flex flex-wrap gap-2">
{(module.input_data_categories || []).map((cat, i) => (
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs">
{cat}
<button onClick={() => removeFromList('input_data_categories', i)} className="hover:text-purple-900">×</button>
</span>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Output-Datenkategorien</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newOutputCategory}
onChange={e => setNewOutputCategory(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addToList('output_data_categories', newOutputCategory, setNewOutputCategory)}
placeholder="Output-Kategorie (z.B. Bewertung, Empfehlung)..."
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
<button
onClick={() => addToList('output_data_categories', newOutputCategory, setNewOutputCategory)}
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
>
+
</button>
</div>
<div className="flex flex-wrap gap-2">
{(module.output_data_categories || []).map((cat, i) => (
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
{cat}
<button onClick={() => removeFromList('output_data_categories', i)} className="hover:text-blue-900">×</button>
</span>
))}
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg border border-gray-200">
<input
type="checkbox"
id="special_cats"
checked={module.involves_special_categories}
onChange={e => update({ involves_special_categories: e.target.checked })}
className="mt-1 h-4 w-4 rounded border-gray-300 text-purple-600"
/>
<label htmlFor="special_cats" className="flex-1">
<div className="text-sm font-medium text-gray-900">Besondere Kategorien (Art. 9 DSGVO)</div>
<p className="text-xs text-gray-500">Gesundheit, Biometrie, Religion, politische Meinung etc.</p>
{module.involves_special_categories && (
<textarea
value={module.special_categories_justification || ''}
onChange={e => update({ special_categories_justification: e.target.value })}
rows={2}
placeholder="Begründung nach Art. 9 Abs. 2 DSGVO..."
className="mt-2 w-full px-3 py-2 text-xs border border-orange-300 rounded focus:ring-2 focus:ring-orange-400 resize-none"
/>
)}
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Betroffenengruppen *</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newSubject}
onChange={e => setNewSubject(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addToList('data_subjects', newSubject, setNewSubject)}
placeholder="z.B. Kunden, Mitarbeiter, Nutzer..."
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
<button
onClick={() => addToList('data_subjects', newSubject, setNewSubject)}
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
>
+
</button>
</div>
<div className="flex flex-wrap gap-2">
{(module.data_subjects || []).map((s, i) => (
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
{s}
<button onClick={() => removeFromList('data_subjects', i)} className="hover:text-green-900">×</button>
</span>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Geschätztes Volumen</label>
<input
type="text"
value={module.estimated_volume || ''}
onChange={e => update({ estimated_volume: e.target.value })}
placeholder="z.B. >10.000 Personen/Monat"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Aufbewahrungsdauer (Monate)</label>
<input
type="number"
value={module.data_retention_months || ''}
onChange={e => update({ data_retention_months: parseInt(e.target.value) || undefined })}
min={1}
placeholder="z.B. 24"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
</div>
)
}

View File

@@ -9,36 +9,7 @@
*/
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 { Search } from 'lucide-react'
import {
DataPoint,
DataPointCategory,
@@ -48,12 +19,10 @@ import {
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'
import { SpecialCategoryWarning } from './DataPointCatalogHelpers'
import { DataPointCategoryGroup } from './DataPointCategoryGroup'
// =============================================================================
// TYPES
@@ -71,149 +40,29 @@ interface DataPointCatalogProps {
}
// =============================================================================
// HELPER COMPONENTS
// CATEGORY ORDER
// =============================================================================
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>
)
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
]
// =============================================================================
// MAIN COMPONENT
@@ -229,29 +78,6 @@ export function DataPointCatalog({
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)
@@ -261,34 +87,23 @@ export function DataPointCatalog({
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) {
@@ -301,7 +116,6 @@ export function DataPointCatalog({
return grouped
}, [filteredDataPoints])
// Zaehle ausgewaehlte spezielle Kategorien fuer Warnungen
const selectedSpecialCategories = useMemo(() => {
const special = new Set<DataPointCategory>()
for (const id of selectedIds) {
@@ -321,7 +135,6 @@ export function DataPointCatalog({
return special
}, [selectedIds, dataPoints])
// Toggle category expansion
const toggleCategory = (category: DataPointCategory) => {
setExpandedCategories((prev) => {
const next = new Set(prev)
@@ -334,7 +147,6 @@ export function DataPointCatalog({
})
}
// Stats
const totalSelected = selectedIds.length
const totalDataPoints = dataPoints.length
@@ -367,7 +179,7 @@ export function DataPointCatalog({
)}
</div>
{/* Art. 9 DSGVO / BDSG § 26 / AI Act Warnungen */}
{/* Art. 9 / BDSG § 26 / AI Act Warnungen */}
{selectedSpecialCategories.size > 0 && (
<div className="space-y-3">
{selectedSpecialCategories.has('HEALTH_DATA') && !dismissedWarnings.has('HEALTH_DATA') && (
@@ -397,7 +209,6 @@ export function DataPointCatalog({
{/* 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
@@ -408,8 +219,6 @@ export function DataPointCatalog({
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')}
@@ -422,8 +231,6 @@ export function DataPointCatalog({
</option>
))}
</select>
{/* Risk Filter */}
<select
value={filterRisk}
onChange={(e) => setFilterRisk(e.target.value as RiskLevel | 'ALL')}
@@ -434,8 +241,6 @@ export function DataPointCatalog({
<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')}
@@ -455,190 +260,18 @@ export function DataPointCatalog({
<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
<DataPointCategoryGroup
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>
category={category}
categoryDataPoints={categoryDataPoints}
selectedIds={selectedIds}
isExpanded={expandedCategories.has(category)}
readOnly={readOnly}
language={language}
onToggleCategory={toggleCategory}
onToggleDataPoint={onToggle}
/>
)
})}
</div>

View File

@@ -0,0 +1,190 @@
'use client'
import React from 'react'
import {
CheckCircle,
Circle,
Shield,
AlertTriangle,
Key,
Megaphone,
MessageSquare,
CreditCard,
Bot,
User,
Mail,
Activity,
MapPin,
Smartphone,
BarChart3,
Share2,
Heart,
Briefcase,
FileText,
FileCode,
X,
} from 'lucide-react'
import {
DataPointCategory,
RiskLevel,
LegalBasis,
SupportedLanguage,
RISK_LEVEL_STYLING,
LEGAL_BASIS_INFO,
ARTICLE_9_WARNING,
EMPLOYEE_DATA_WARNING,
AI_DATA_WARNING,
} from '@/lib/sdk/einwilligungen/types'
// =============================================================================
// CategoryIcon
// =============================================================================
export const CategoryIcon: React.FC<{ category: DataPointCategory; className?: string }> = ({
category,
className = 'w-5 h-5',
}) => {
const icons: Record<DataPointCategory, React.ReactNode> = {
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} />}</>
}
// =============================================================================
// RiskBadge
// =============================================================================
export 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>
)
}
// =============================================================================
// LegalBasisBadge
// =============================================================================
export 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>
)
}
// =============================================================================
// SpecialCategoryWarning
// =============================================================================
export const SpecialCategoryWarning: React.FC<{
category: DataPointCategory
language: SupportedLanguage
onClose?: () => void
}> = ({ category, language, onClose }) => {
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>
)
}
// =============================================================================
// Article9Badge
// =============================================================================
export 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>
)

View File

@@ -0,0 +1,124 @@
'use client'
import { ChevronDown, ChevronRight } from 'lucide-react'
import {
DataPoint,
DataPointCategory,
SupportedLanguage,
CATEGORY_METADATA,
} from '@/lib/sdk/einwilligungen/types'
import { CategoryIcon } from './DataPointCatalogHelpers'
import { DataPointRow } from './DataPointRow'
interface DataPointCategoryGroupProps {
category: DataPointCategory
categoryDataPoints: DataPoint[]
selectedIds: string[]
isExpanded: boolean
readOnly: boolean
language: SupportedLanguage
onToggleCategory: (category: DataPointCategory) => void
onToggleDataPoint: (id: string) => void
}
export function DataPointCategoryGroup({
category,
categoryDataPoints,
selectedIds,
isExpanded,
readOnly,
language,
onToggleCategory,
onToggleDataPoint,
}: DataPointCategoryGroupProps) {
const meta = CATEGORY_METADATA[category]
const selectedInCategory = categoryDataPoints.filter((dp) =>
selectedIds.includes(dp.id)
).length
return (
<div className="border border-slate-200 rounded-xl overflow-hidden bg-white">
{/* Category Header */}
<button
onClick={() => onToggleCategory(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) => (
<DataPointRow
key={dp.id}
dp={dp}
isSelected={selectedIds.includes(dp.id)}
readOnly={readOnly}
language={language}
onToggle={onToggleDataPoint}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,108 @@
'use client'
import { CheckCircle, Circle, AlertTriangle } from 'lucide-react'
import {
DataPoint,
SupportedLanguage,
RETENTION_PERIOD_INFO,
} from '@/lib/sdk/einwilligungen/types'
import { RiskBadge, LegalBasisBadge, Article9Badge } from './DataPointCatalogHelpers'
interface DataPointRowProps {
dp: DataPoint
isSelected: boolean
readOnly: boolean
language: SupportedLanguage
onToggle: (id: string) => void
}
export function DataPointRow({ dp, isSelected, readOnly, language, onToggle }: DataPointRowProps) {
return (
<div
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)}
>
{!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>
)}
<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>
<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>
{(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>
)}
{dp.thirdPartyRecipients.length > 0 && (
<div className="mt-2 text-xs text-slate-500">
<strong>Drittanbieter:</strong>{' '}
{dp.thirdPartyRecipients.join(', ')}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,222 @@
'use client'
// =============================================================================
// PII RULE MODALS
// NewRuleModal and EditRuleModal extracted from PIIRulesTab for LOC compliance.
// =============================================================================
interface PIIRule {
id: string
name: string
description?: string
pattern?: string
category: string
action: string
active: boolean
created_at: string
}
export const CATEGORIES = [
{ value: 'email', label: 'E-Mail-Adressen' },
{ value: 'phone', label: 'Telefonnummern' },
{ value: 'iban', label: 'IBAN/Bankdaten' },
{ value: 'name', label: 'Personennamen' },
{ value: 'address', label: 'Adressen' },
{ value: 'id_number', label: 'Ausweisnummern' },
{ value: 'health', label: 'Gesundheitsdaten' },
{ value: 'other', label: 'Sonstige' },
]
export const ACTIONS = [
{ value: 'warn', label: 'Warnung', color: 'bg-amber-100 text-amber-700' },
{ value: 'mask', label: 'Maskieren', color: 'bg-orange-100 text-orange-700' },
{ value: 'block', label: 'Blockieren', color: 'bg-red-100 text-red-700' },
]
// ---------------------------------------------------------------------------
// NewRuleModal
// ---------------------------------------------------------------------------
export interface NewRuleState {
name: string
pattern: string
category: string
action: string
active: boolean
}
interface NewRuleModalProps {
newRule: NewRuleState
onChange: (rule: NewRuleState) => void
onSubmit: () => void
onClose: () => void
saving: boolean
}
export function NewRuleModal({ newRule, onChange, onSubmit, onClose, saving }: NewRuleModalProps) {
return (
<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) => onChange({ ...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">Kategorie *</label>
<select
value={newRule.category}
onChange={(e) => onChange({ ...newRule, category: 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"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster (Regex)</label>
<textarea
value={newRule.pattern}
onChange={(e) => onChange({ ...newRule, pattern: e.target.value })}
placeholder={'Regex-Muster, z.B. (?:\\+49|0)[\\s.-]?\\d{2,4}...'}
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">Aktion *</label>
<select
value={newRule.action}
onChange={(e) => onChange({ ...newRule, action: 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"
>
{ACTIONS.map((a) => (
<option key={a.value} value={a.value}>{a.label}</option>
))}
</select>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button onClick={onClose} className="px-4 py-2 text-slate-600 hover:text-slate-700">
Abbrechen
</button>
<button
onClick={onSubmit}
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>
)
}
// ---------------------------------------------------------------------------
// EditRuleModal
// ---------------------------------------------------------------------------
interface EditRuleModalProps {
editingRule: PIIRule
onChange: (rule: PIIRule) => void
onSubmit: () => void
onClose: () => void
saving: boolean
}
export function EditRuleModal({ editingRule, onChange, onSubmit, onClose, saving }: EditRuleModalProps) {
return (
<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) => onChange({ ...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">Kategorie</label>
<select
value={editingRule.category}
onChange={(e) => onChange({ ...editingRule, category: 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"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster (Regex)</label>
<textarea
value={editingRule.pattern || ''}
onChange={(e) => onChange({ ...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">Aktion</label>
<select
value={editingRule.action}
onChange={(e) => onChange({ ...editingRule, action: 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"
>
{ACTIONS.map((a) => (
<option key={a.value} value={a.value}>{a.label}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="edit_active"
checked={editingRule.active}
onChange={(e) => onChange({ ...editingRule, active: e.target.checked })}
className="w-4 h-4 text-purple-600"
/>
<label htmlFor="edit_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={onClose} className="px-4 py-2 text-slate-600 hover:text-slate-700">
Abbrechen
</button>
<button
onClick={onSubmit}
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>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { NewRuleModal, EditRuleModal, CATEGORIES, ACTIONS, type NewRuleState } from './PIIRuleModals'
interface PIIRule {
id: string
@@ -34,53 +35,22 @@ interface PIIRulesTabProps {
onUpdate?: () => void
}
const CATEGORIES = [
{ value: 'email', label: 'E-Mail-Adressen' },
{ value: 'phone', label: 'Telefonnummern' },
{ value: 'iban', label: 'IBAN/Bankdaten' },
{ value: 'name', label: 'Personennamen' },
{ value: 'address', label: 'Adressen' },
{ value: 'id_number', label: 'Ausweisnummern' },
{ value: 'health', label: 'Gesundheitsdaten' },
{ value: 'other', label: 'Sonstige' },
]
const ACTIONS = [
{ value: 'warn', label: 'Warnung', color: 'bg-amber-100 text-amber-700' },
{ value: 'mask', label: 'Maskieren', 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)
// Category filter
const [categoryFilter, setCategoryFilter] = useState('')
// 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: '',
pattern: '',
category: 'email',
action: 'block',
active: true,
const [newRule, setNewRule] = useState<NewRuleState>({
name: '', pattern: '', category: 'email', action: 'block', active: true,
})
useEffect(() => {
fetchRules()
}, [categoryFilter])
useEffect(() => { fetchRules() }, [categoryFilter]) // eslint-disable-line react-hooks/exhaustive-deps
const fetchRules = async () => {
try {
@@ -89,7 +59,6 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
if (categoryFilter) params.append('category', categoryFilter)
const res = await fetch(`${apiBase}/pii-rules?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setRules(data.rules || [])
} catch (err) {
@@ -107,16 +76,8 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newRule),
})
if (!res.ok) throw new Error('Fehler beim Erstellen')
setNewRule({
name: '',
pattern: '',
category: 'email',
action: 'block',
active: true,
})
setNewRule({ name: '', pattern: '', category: 'email', action: 'block', active: true })
setIsNewRule(false)
fetchRules()
onUpdate?.()
@@ -129,7 +90,6 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const updateRule = async () => {
if (!editingRule) return
try {
setSaving(true)
const res = await fetch(`${apiBase}/pii-rules/${editingRule.id}`, {
@@ -137,9 +97,7 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingRule),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren')
setEditingRule(null)
fetchRules()
onUpdate?.()
@@ -152,14 +110,9 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const deleteRule = async (id: string) => {
if (!confirm('Regel wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
try {
const res = await fetch(`${apiBase}/pii-rules/${id}`, {
method: 'DELETE',
})
const res = await fetch(`${apiBase}/pii-rules/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Fehler beim Loeschen')
fetchRules()
onUpdate?.()
} catch (err) {
@@ -174,9 +127,7 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active: !rule.active }),
})
if (!res.ok) throw new Error('Fehler beim Aendern des Status')
fetchRules()
onUpdate?.()
} catch (err) {
@@ -186,47 +137,25 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const runTest = () => {
if (!testText) return
setTesting(true)
const matches: PIIMatch[] = []
const activeRules = rules.filter((r) => r.active && r.pattern)
for (const rule of activeRules) {
try {
const regex = new RegExp(rule.pattern!, 'gi')
let m: RegExpExecArray | null
while ((m = regex.exec(testText)) !== null) {
matches.push({
rule_id: rule.id,
rule_name: rule.name,
category: rule.category,
action: rule.action,
match: m[0],
start_index: m.index,
end_index: m.index + m[0].length,
})
matches.push({ rule_id: rule.id, rule_name: rule.name, category: rule.category, action: rule.action, match: m[0], start_index: m.index, end_index: m.index + m[0].length })
}
} catch {
// Invalid regex — skip
}
} catch { /* Invalid regex — skip */ }
}
const shouldBlock = matches.some((m) => m.action === 'block')
setTestResult({
has_pii: matches.length > 0,
matches,
should_block: shouldBlock,
})
setTestResult({ has_pii: matches.length > 0, matches, should_block: matches.some(m => m.action === 'block') })
setTesting(false)
}
const getActionBadge = (action: string) => {
const config = ACTIONS.find((a) => a.value === action)
return (
<span className={`px-2 py-1 rounded text-xs font-medium ${config?.color || 'bg-slate-100 text-slate-700'}`}>
{config?.label || action}
</span>
)
return <span className={`px-2 py-1 rounded text-xs font-medium ${config?.color || 'bg-slate-100 text-slate-700'}`}>{config?.label || action}</span>
}
return (
@@ -235,88 +164,38 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">&times;</button>
</div>
)}
{/* Test Panel */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<h3 className="font-semibold text-slate-900 mb-4">PII-Test</h3>
<p className="text-sm text-slate-600 mb-4">
Testen Sie, ob ein Text personenbezogene Daten (PII) enthaelt.
</p>
<textarea
value={testText}
onChange={(e) => setTestText(e.target.value)}
placeholder="Geben Sie hier einen Text zum Testen ein...
Beispiel:
Kontaktieren Sie mich unter max.mustermann@example.com oder
rufen Sie mich an unter +49 170 1234567.
Meine IBAN ist DE89 3704 0044 0532 0130 00."
rows={6}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
/>
<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...\n\nBeispiel:\nKontaktieren Sie mich unter max.mustermann@example.com oder\nrufen Sie mich an unter +49 170 1234567.\nMeine 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>
<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>
</>
<><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-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>
</>
<><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">
{getActionBadge(match.action)}
<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>
<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>
@@ -329,23 +208,12 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
<div className="flex flex-wrap justify-between items-center gap-3 mb-4">
<h3 className="font-semibold text-slate-900">PII-Erkennungsregeln</h3>
<div className="flex gap-3 items-center">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} className="px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<option value="">Alle Kategorien</option>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
{CATEGORIES.map((c) => (<option key={c.value} value={c.value}>{c.label}</option>))}
</select>
<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>
<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>
@@ -356,13 +224,9 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
<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>
<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>
<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">
@@ -381,42 +245,17 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
{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">
{CATEGORIES.find((c) => c.value === rule.category)?.label || rule.category}
</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 && rule.pattern.length > 40 ? rule.pattern.substring(0, 40) + '...' : rule.pattern || '-'}
</code>
</td>
<td className="px-4 py-3"><span className="text-xs bg-slate-100 text-slate-700 px-2 py-1 rounded">{CATEGORIES.find((c) => c.value === rule.category)?.label || rule.category}</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 && rule.pattern.length > 40 ? rule.pattern.substring(0, 40) + '...' : rule.pattern || '-'}</code></td>
<td className="px-4 py-3">{getActionBadge(rule.action)}</td>
<td className="px-4 py-3">
<button
onClick={() => toggleRuleStatus(rule)}
className={`text-xs px-2 py-1 rounded ${
rule.active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
<button onClick={() => toggleRuleStatus(rule)} className={`text-xs px-2 py-1 rounded ${rule.active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{rule.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>
<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>
))}
@@ -425,173 +264,25 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
</div>
)}
{/* New Rule Modal */}
{/* Modals */}
{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">Kategorie *</label>
<select
value={newRule.category}
onChange={(e) => setNewRule({ ...newRule, category: 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"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster (Regex)</label>
<textarea
value={newRule.pattern}
onChange={(e) => setNewRule({ ...newRule, pattern: e.target.value })}
placeholder={'Regex-Muster, z.B. (?:\\+49|0)[\\s.-]?\\d{2,4}...'}
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">Aktion *</label>
<select
value={newRule.action}
onChange={(e) => setNewRule({ ...newRule, action: 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"
>
{ACTIONS.map((a) => (
<option key={a.value} value={a.value}>
{a.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>
<NewRuleModal
newRule={newRule}
onChange={setNewRule}
onSubmit={createRule}
onClose={() => setIsNewRule(false)}
saving={saving}
/>
)}
{/* 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">Kategorie</label>
<select
value={editingRule.category}
onChange={(e) => setEditingRule({ ...editingRule, category: 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"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster (Regex)</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">Aktion</label>
<select
value={editingRule.action}
onChange={(e) => setEditingRule({ ...editingRule, action: 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"
>
{ACTIONS.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="edit_active"
checked={editingRule.active}
onChange={(e) => setEditingRule({ ...editingRule, active: e.target.checked })}
className="w-4 h-4 text-purple-600"
/>
<label htmlFor="edit_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>
<EditRuleModal
editingRule={editingRule}
onChange={setEditingRule}
onSubmit={updateRule}
onClose={() => setEditingRule(null)}
saving={saving}
/>
)}
</div>
)

View File

@@ -0,0 +1,243 @@
'use client'
// =============================================================================
// Source Policy - New/Edit Source Modals
// =============================================================================
interface AllowedSource {
id: string
domain: string
name: string
description?: string
license?: string
legal_basis?: string
trust_boost: number
source_type: string
active: boolean
metadata?: Record<string, unknown>
created_at: string
updated_at?: string
}
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)' },
]
// =============================================================================
// NEW SOURCE MODAL
// =============================================================================
interface NewSourceFormState {
domain: string
name: string
license: string
legal_basis: string
trust_boost: number
active: boolean
}
interface NewSourceModalProps {
newSource: NewSourceFormState
saving: boolean
onClose: () => void
onCreate: () => void
onChange: (update: Partial<NewSourceFormState>) => void
}
export function NewSourceModal({ newSource, saving, onClose, onCreate, onChange }: NewSourceModalProps) {
return (
<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) => onChange({ 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) => onChange({ 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) => onChange({ 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) => onChange({ 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) => onChange({ 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={onClose} className="px-4 py-2 text-slate-600 hover:text-slate-700">
Abbrechen
</button>
<button
onClick={onCreate}
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
// =============================================================================
interface EditSourceModalProps {
source: AllowedSource
saving: boolean
onClose: () => void
onSave: () => void
onChange: (update: Partial<AllowedSource>) => void
}
export function EditSourceModal({ source, saving, onClose, onSave, onChange }: EditSourceModalProps) {
return (
<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={source.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={source.name}
onChange={(e) => onChange({ 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={source.license}
onChange={(e) => onChange({ 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={source.legal_basis || ''}
onChange={(e) => onChange({ 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">Trust Boost</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={source.trust_boost}
onChange={(e) => onChange({ trust_boost: parseFloat(e.target.value) })}
className="w-full"
/>
<div className="text-xs text-slate-500 text-right">
{(source.trust_boost * 100).toFixed(0)}%
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="active"
checked={source.active}
onChange={(e) => onChange({ active: e.target.checked })}
className="w-4 h-4 text-purple-600"
/>
<label htmlFor="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={onClose} className="px-4 py-2 text-slate-600 hover:text-slate-700">
Abbrechen
</button>
<button
onClick={onSave}
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>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { NewSourceModal, EditSourceModal } from './SourceModals'
interface AllowedSource {
id: string
@@ -30,18 +31,6 @@ const LICENSES = [
{ 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)
@@ -103,14 +92,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
if (!res.ok) throw new Error('Fehler beim Erstellen')
setNewSource({
domain: '',
name: '',
license: 'DL-DE-BY-2.0',
legal_basis: '',
trust_boost: 0.5,
active: true,
})
setNewSource({ domain: '', name: '', license: 'DL-DE-BY-2.0', legal_basis: '', trust_boost: 0.5, active: true })
setIsNewSource(false)
fetchSources()
onUpdate?.()
@@ -148,12 +130,8 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
if (!confirm('Quelle wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
try {
const res = await fetch(`${apiBase}/sources/${id}`, {
method: 'DELETE',
})
const res = await fetch(`${apiBase}/sources/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Fehler beim Loeschen')
fetchSources()
onUpdate?.()
} catch (err) {
@@ -194,9 +172,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">&times;</button>
</div>
)}
@@ -217,15 +193,11 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
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>
))}
{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)}
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'active' | 'inactive')}
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>
@@ -264,9 +236,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
<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>
<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">
@@ -289,36 +259,22 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
</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)}%
<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.active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
className={`text-xs px-2 py-1 rounded ${source.active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}
>
{source.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"
>
<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"
>
<button onClick={() => deleteSource(source.id)} className="text-red-600 hover:text-red-700">
Loeschen
</button>
</td>
@@ -331,194 +287,24 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
{/* 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>
<NewSourceModal
newSource={newSource}
saving={saving}
onClose={() => setIsNewSource(false)}
onCreate={createSource}
onChange={(update) => setNewSource(prev => ({ ...prev, ...update }))}
/>
)}
{/* 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">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="active"
checked={editingSource.active}
onChange={(e) => setEditingSource({ ...editingSource, active: e.target.checked })}
className="w-4 h-4 text-purple-600"
/>
<label htmlFor="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>
<EditSourceModal
source={editingSource}
saving={saving}
onClose={() => setEditingSource(null)}
onSave={updateSource}
onChange={(update) => setEditingSource(prev => prev ? { ...prev, ...update } : prev)}
/>
)}
</div>
)

View File

@@ -0,0 +1,427 @@
'use client'
// =============================================================================
// ReviewExportPanels
// SummaryCard, TOMsTable, GapAnalysisPanel, ExportPanel extracted from
// ReviewExportStep for LOC compliance.
// =============================================================================
import { 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'
}
export 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
// =============================================================================
export 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
// =============================================================================
export 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
// =============================================================================
export 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>
)
}

View File

@@ -5,430 +5,9 @@
// Summary, derived TOMs table, gap analysis, and export
// =============================================================================
import React, { useEffect, useState } from 'react'
import { 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
// =============================================================================
import { SummaryCard, TOMsTable, GapAnalysisPanel, ExportPanel } from './ReviewExportPanels'
export function ReviewExportStep() {
const { state, deriveTOMs, completeCurrentStep } = useTOMGenerator()
@@ -490,31 +69,11 @@ export function ReviewExportStep() {
<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="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}%`}