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
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:
131
admin-compliance/components/sdk/ComplianceAdvisorParts.tsx
Normal file
131
admin-compliance/components/sdk/ComplianceAdvisorParts.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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('.')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
128
admin-compliance/components/sdk/ProjectSelector/ProjectCard.tsx
Normal file
128
admin-compliance/components/sdk/ProjectSelector/ProjectCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
108
admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx
Normal file
108
admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.' },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -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.' },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)',
|
||||
]
|
||||
@@ -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'
|
||||
@@ -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 */}
|
||||
|
||||
149
admin-compliance/components/sdk/dsfa/AIUseCaseTabsPurposeAct.tsx
Normal file
149
admin-compliance/components/sdk/dsfa/AIUseCaseTabsPurposeAct.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
261
admin-compliance/components/sdk/dsfa/AIUseCaseTabsSystemData.tsx
Normal file
261
admin-compliance/components/sdk/dsfa/AIUseCaseTabsSystemData.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
108
admin-compliance/components/sdk/einwilligungen/DataPointRow.tsx
Normal file
108
admin-compliance/components/sdk/einwilligungen/DataPointRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
222
admin-compliance/components/sdk/source-policy/PIIRuleModals.tsx
Normal file
222
admin-compliance/components/sdk/source-policy/PIIRuleModals.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
×
|
||||
</button>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">PII-Test</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Testen Sie, ob ein Text personenbezogene Daten (PII) enthaelt.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={testText}
|
||||
onChange={(e) => setTestText(e.target.value)}
|
||||
placeholder="Geben Sie hier einen Text zum Testen ein...
|
||||
|
||||
Beispiel:
|
||||
Kontaktieren Sie mich unter max.mustermann@example.com oder
|
||||
rufen Sie mich an unter +49 170 1234567.
|
||||
Meine IBAN ist DE89 3704 0044 0532 0130 00."
|
||||
rows={6}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
|
||||
<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>
|
||||
)
|
||||
|
||||
243
admin-compliance/components/sdk/source-policy/SourceModals.tsx
Normal file
243
admin-compliance/components/sdk/source-policy/SourceModals.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
×
|
||||
</button>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}%`}
|
||||
|
||||
Reference in New Issue
Block a user