feat(admin-v2): Major SDK/Compliance overhaul and new modules

SDK modules added/enhanced:
- compliance-hub, compliance-scope, consent-management, notfallplan
- audit-report, workflow, source-policy, dsms
- advisory-board documentation section
- TOM dashboard components, TOM generator SDM mapping
- DSFA: mitigation library, risk catalog, threshold analysis, source attribution
- VVT: baseline catalog, profiling engine, types
- Loeschfristen: baseline catalog, compliance engine, export, profiling, types
- Compliance scope: engine, profiling, golden tests, types

Existing SDK pages updated:
- dsfa/[id], tom, vvt, loeschfristen, advisory-board — expanded functionality
- SDKSidebar, StepHeader — new navigation items and layout
- SDK layout, context, types — expanded type system

Other admin-v2 changes:
- AI agents page, RAG pipeline DSFA integration
- GridOverlay component updates
- Companion feature (development + education)
- Compliance advisor SOUL definition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BreakPilot Dev
2026-02-10 00:01:04 +01:00
parent 53219e3eaf
commit dff2ef796b
94 changed files with 29706 additions and 1039 deletions

View File

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

View File

@@ -489,6 +489,30 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
isActive={pathname === '/sdk/security-backlog'}
collapsed={collapsed}
/>
<AdditionalModuleItem
href="/sdk/compliance-hub"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
}
label="Compliance Hub"
isActive={pathname === '/sdk/compliance-hub'}
collapsed={collapsed}
/>
<AdditionalModuleItem
href="/sdk/dsms"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
}
label="DSMS"
isActive={pathname === '/sdk/dsms'}
collapsed={collapsed}
/>
</div>
</nav>

View File

@@ -3,7 +3,7 @@
import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useSDK, getStepById, getNextStep, getPreviousStep, SDKStep } from '@/lib/sdk'
import { useSDK, getStepById, getNextStep, getPreviousStep, SDKStep, SDK_STEPS } from '@/lib/sdk'
// =============================================================================
// TYPES
@@ -133,7 +133,7 @@ export function StepHeader({
// Calculate step progress within phase
const phaseSteps = currentStep ?
(currentStep.phase === 1 ? 8 : 11) : 0
SDK_STEPS.filter(s => s.phase === currentStep.phase).length : 0
const stepNumber = currentStep?.order || 0
return (
@@ -314,6 +314,28 @@ export const STEP_EXPLANATIONS = {
},
],
},
'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',
@@ -487,34 +509,44 @@ export const STEP_EXPLANATIONS = {
'tom': {
title: 'Technische und Organisatorische Massnahmen',
description: 'Dokumentieren Sie Ihre TOMs nach Art. 32 DSGVO',
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Sie umfassen Zutrittskontrolle, Zugangskontrolle, Zugriffskontrolle und mehr.',
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Das Dashboard zeigt den Status aller aus dem TOM Generator abgeleiteten Massnahmen mit SDM-Mapping und Gap-Analyse.',
tips: [
{
icon: 'info' as const,
title: 'Kategorien',
description: 'TOMs werden in technische (z.B. Verschluesselung) und organisatorische (z.B. Schulungen) Massnahmen unterteilt.',
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: 'success' as const,
title: 'Nachweis',
description: 'Dokumentieren Sie fuer jede TOM einen Nachweis der Umsetzung.',
icon: 'info' as const,
title: 'Generator nutzen',
description: 'Der 6-Schritt-Wizard leitet TOMs systematisch aus Ihrem Risikoprofil ab. Starten Sie dort, um eine vollstaendige Baseline zu erhalten.',
},
{
icon: 'info' as const,
title: 'SDM-Mapping',
description: 'Kontrollen werden den 7 SDM-Gewaehrleistungszielen zugeordnet: Verfuegbarkeit, Integritaet, Vertraulichkeit, Nichtverkettung, Intervenierbarkeit, Transparenz, Datenminimierung.',
},
],
},
'vvt': {
title: 'Verarbeitungsverzeichnis',
description: 'Erstellen Sie Ihr Verzeichnis nach Art. 30 DSGVO',
explanation: 'Das Verarbeitungsverzeichnis dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Es ist fuer die meisten Unternehmen Pflicht.',
description: 'Erstellen und verwalten Sie Ihr Verzeichnis nach Art. 30 DSGVO',
explanation: 'Das Verarbeitungsverzeichnis (VVT) dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Der integrierte Generator-Fragebogen befuellt 70-90% der Pflichtfelder automatisch anhand Ihres Unternehmensprofils.',
tips: [
{
icon: 'warning' as const,
title: 'Vollstaendigkeit',
description: 'Das VVT muss alle Verarbeitungen enthalten. Fehlende Eintraege koennen bei Audits zu Beanstandungen fuehren.',
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: 'Pflichtangaben',
description: 'Jeder Eintrag muss enthalten: Zweck, Datenkategorien, Empfaenger, Loeschfristen, TOMs.',
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).',
},
],
},
@@ -555,17 +587,22 @@ export const STEP_EXPLANATIONS = {
'loeschfristen': {
title: 'Loeschfristen',
description: 'Definieren Sie Aufbewahrungsrichtlinien fuer Ihre Daten',
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Nach Ablauf muessen die Daten geloescht oder anonymisiert werden.',
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Die 3-Stufen-Logik (Zweckende, Aufbewahrungspflicht, Legal Hold) stellt sicher, dass alle gesetzlichen Anforderungen beruecksichtigt werden.',
tips: [
{
icon: 'warning' as const,
title: 'Gesetzliche Fristen',
description: 'Beachten Sie gesetzliche Aufbewahrungspflichten (z.B. Steuerrecht: 10 Jahre, Handelsrecht: 6 Jahre).',
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: 'lightbulb' as const,
title: 'Automatisierung',
description: 'Richten Sie automatische Loeschprozesse ein, um Compliance sicherzustellen.',
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.',
},
],
},
@@ -659,6 +696,91 @@ export const STEP_EXPLANATIONS = {
},
],
},
'source-policy': {
title: 'Source Policy',
description: 'Verwalten Sie Ihre Datenquellen-Governance',
explanation: 'Die Source Policy definiert, welche externen Datenquellen fuer Ihre Anwendung zugelassen sind. Sie umfasst eine Whitelist, Operationsmatrix (Lookup, RAG, Training, Export), PII-Regeln und ein Audit-Trail.',
tips: [
{
icon: 'warning' as const,
title: 'Lizenzierung',
description: 'Pruefen Sie die Lizenzen aller Datenquellen (DL-DE-BY, CC-BY, CC0). Nicht-lizenzierte Quellen koennen rechtliche Risiken bergen.',
},
{
icon: 'info' as const,
title: 'PII-Regeln',
description: 'Definieren Sie klare Regeln fuer den Umgang mit personenbezogenen Daten in externen Quellen.',
},
],
},
'audit-report': {
title: 'Audit Report',
description: 'Erstellen und verwalten Sie Audit-Sitzungen',
explanation: 'Im Audit Report erstellen Sie formelle Audit-Sitzungen mit Pruefer-Informationen, fuehren die Pruefung durch und generieren PDF-Reports. Jede Sitzung dokumentiert den Compliance-Stand zu einem bestimmten Zeitpunkt.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Regelmaessigkeit',
description: 'Fuehren Sie mindestens jaehrlich ein formelles Audit durch. Dokumentieren Sie Abweichungen und Massnahmenplaene.',
},
{
icon: 'success' as const,
title: 'PDF-Export',
description: 'Generieren Sie PDF-Reports in Deutsch oder Englisch fuer externe Pruefer.',
},
],
},
'workflow': {
title: 'Document Workflow',
description: 'Verwalten Sie den Freigabe-Prozess Ihrer rechtlichen Dokumente',
explanation: 'Der Document Workflow bietet einen Split-View-Editor mit synchronisiertem Scrollen. Dokumente durchlaufen den Status Draft → Review → Approved → Published. Aenderungen werden versioniert und der Freigabeprozess wird protokolliert.',
tips: [
{
icon: 'warning' as const,
title: 'Vier-Augen-Prinzip',
description: 'Rechtliche Dokumente sollten immer von mindestens einer weiteren Person geprueft werden, bevor sie veroeffentlicht werden.',
},
{
icon: 'info' as const,
title: 'Versionierung',
description: 'Jede Aenderung wird als neue Version gespeichert. So koennen Sie jederzeit den Stand eines Dokuments nachvollziehen.',
},
],
},
'consent-management': {
title: 'Consent Verwaltung',
description: 'Verwalten Sie Consent-Dokumente, Versionen und DSGVO-Prozesse',
explanation: 'Die Consent Verwaltung umfasst das Lifecycle-Management Ihrer rechtlichen Dokumente (AGB, Datenschutz, Cookie-Richtlinien), die Verwaltung von E-Mail-Templates (16 Lifecycle-E-Mails) und die Steuerung der DSGVO-Prozesse (Art. 15-21).',
tips: [
{
icon: 'info' as const,
title: 'Dokumentversionen',
description: 'Jede Aenderung an einem Consent-Dokument erzeugt eine neue Version. Aktive Nutzer muessen bei Aenderungen erneut zustimmen.',
},
{
icon: 'warning' as const,
title: 'DSGVO-Fristen',
description: 'Betroffenenrechte (Art. 15-21) haben gesetzliche Fristen. Auskunft: 30 Tage, Loeschung: unverzueglich.',
},
],
},
'notfallplan': {
title: 'Notfallplan & Breach Response',
description: 'Verwalten Sie Ihr Datenpannen-Management nach Art. 33/34 DSGVO',
explanation: 'Der Notfallplan definiert Ihren Prozess bei Datenpannen gemaess Art. 33/34 DSGVO. Er umfasst die 72-Stunden-Meldepflicht an die Aufsichtsbehoerde, die Benachrichtigung betroffener Personen bei hohem Risiko, Incident-Klassifizierung, Eskalationswege und Dokumentationspflichten.',
tips: [
{
icon: 'warning' as const,
title: '72-Stunden-Frist',
description: 'Art. 33 DSGVO: Meldung an die Aufsichtsbehoerde innerhalb von 72 Stunden nach Bekanntwerden. Verspaetete Meldungen muessen begruendet werden.',
},
{
icon: 'info' as const,
title: 'Dokumentationspflicht',
description: 'Art. 33 Abs. 5: Alle Datenpannen muessen dokumentiert werden — auch solche, die nicht meldepflichtig sind. Die Dokumentation muss der Aufsichtsbehoerde auf Verlangen vorgelegt werden koennen.',
},
],
},
}
export default StepHeader

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,410 @@
'use client'
import React, { useState, useCallback } from 'react'
import type { ScopeProfilingAnswer, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, getAnswerValue, getAllQuestions } from '@/lib/sdk/compliance-scope-profiling'
import type { CompanyProfile } from '@/lib/sdk/types'
import { prefillFromCompanyProfile } from '@/lib/sdk/compliance-scope-profiling'
import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_COLORS } from '@/lib/sdk/compliance-scope-types'
interface ScopeWizardTabProps {
answers: ScopeProfilingAnswer[]
onAnswersChange: (answers: ScopeProfilingAnswer[]) => void
onComplete: () => void
companyProfile: CompanyProfile | null
currentLevel: ComplianceDepthLevel | null
}
export function ScopeWizardTab({
answers,
onAnswersChange,
onComplete,
companyProfile,
currentLevel,
}: ScopeWizardTabProps) {
const [currentBlockIndex, setCurrentBlockIndex] = useState(0)
const currentBlock = SCOPE_QUESTION_BLOCKS[currentBlockIndex]
const totalProgress = getTotalProgress(answers)
const handleAnswerChange = useCallback(
(questionId: string, value: any) => {
const existingIndex = answers.findIndex((a) => a.questionId === questionId)
if (existingIndex >= 0) {
const newAnswers = [...answers]
newAnswers[existingIndex] = { questionId, value, answeredAt: new Date().toISOString() }
onAnswersChange(newAnswers)
} else {
onAnswersChange([...answers, { questionId, value, answeredAt: new Date().toISOString() }])
}
},
[answers, onAnswersChange]
)
const handlePrefillFromProfile = useCallback(() => {
if (!companyProfile) return
const prefilledAnswers = prefillFromCompanyProfile(companyProfile, answers)
onAnswersChange(prefilledAnswers)
}, [companyProfile, answers, onAnswersChange])
const handleNext = useCallback(() => {
if (currentBlockIndex < SCOPE_QUESTION_BLOCKS.length - 1) {
setCurrentBlockIndex(currentBlockIndex + 1)
} else {
onComplete()
}
}, [currentBlockIndex, onComplete])
const handleBack = useCallback(() => {
if (currentBlockIndex > 0) {
setCurrentBlockIndex(currentBlockIndex - 1)
}
}, [currentBlockIndex])
const renderQuestion = (question: any) => {
const currentValue = getAnswerValue(answers, question.id)
switch (question.type) {
case 'boolean':
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-900">
{question.question}
{question.required && <span className="text-red-500 ml-1">*</span>}
</label>
{question.helpText && (
<button
type="button"
className="text-gray-400 hover:text-gray-600"
title={question.helpText}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
)}
</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 className="text-sm font-medium text-gray-900">
{question.question}
{question.required && <span className="text-red-500 ml-1">*</span>}
{question.helpText && (
<button
type="button"
className="ml-2 text-gray-400 hover:text-gray-600 inline"
title={question.helpText}
>
<svg className="w-4 h-4 inline" 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>
)}
</label>
<div className="space-y-2">
{question.options?.map((option: any) => (
<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 className="text-sm font-medium text-gray-900">
{question.question}
{question.required && <span className="text-red-500 ml-1">*</span>}
{question.helpText && (
<button
type="button"
className="ml-2 text-gray-400 hover:text-gray-600 inline"
title={question.helpText}
>
<svg className="w-4 h-4 inline" 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>
)}
</label>
<div className="space-y-2">
{question.options?.map((option: any) => {
const selectedValues = Array.isArray(currentValue) ? currentValue : []
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 className="text-sm font-medium text-gray-900">
{question.question}
{question.required && <span className="text-red-500 ml-1">*</span>}
{question.helpText && (
<button
type="button"
className="ml-2 text-gray-400 hover:text-gray-600 inline"
title={question.helpText}
>
<svg className="w-4 h-4 inline" 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>
)}
</label>
<input
type="number"
value={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 className="text-sm font-medium text-gray-900">
{question.question}
{question.required && <span className="text-red-500 ml-1">*</span>}
{question.helpText && (
<button
type="button"
className="ml-2 text-gray-400 hover:text-gray-600 inline"
title={question.helpText}
>
<svg className="w-4 h-4 inline" 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>
)}
</label>
<input
type="text"
value={currentValue ?? ''}
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Text eingeben"
/>
</div>
)
default:
return null
}
}
return (
<div className="flex gap-6 h-full">
{/* Left Sidebar - Block Navigation */}
<div className="w-64 flex-shrink-0">
<div className="bg-white rounded-xl border border-gray-200 p-4 sticky top-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Fortschritt</h3>
<div className="space-y-2">
{SCOPE_QUESTION_BLOCKS.map((block, idx) => {
const progress = getBlockProgress(answers, block.id)
const isActive = idx === currentBlockIndex
return (
<button
key={block.id}
type="button"
onClick={() => setCurrentBlockIndex(idx)}
className={`w-full text-left px-3 py-2 rounded-lg transition-all ${
isActive
? 'bg-purple-50 border-2 border-purple-500'
: 'bg-gray-50 border border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className={`text-sm font-medium ${isActive ? 'text-purple-700' : 'text-gray-700'}`}>
{block.title}
</span>
<span className={`text-xs font-semibold ${isActive ? 'text-purple-600' : 'text-gray-500'}`}>
{progress}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full transition-all ${isActive ? 'bg-purple-500' : 'bg-gray-400'}`}
style={{ width: `${progress}%` }}
/>
</div>
</button>
)
})}
</div>
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 space-y-6">
{/* Progress Bar */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Gesamtfortschritt</span>
<div className="flex items-center gap-3">
{currentLevel && (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Vorläufige Einstufung:</span>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold ${DEPTH_LEVEL_COLORS[currentLevel].badge} ${DEPTH_LEVEL_COLORS[currentLevel].text}`}
>
{currentLevel} - {DEPTH_LEVEL_LABELS[currentLevel]}
</span>
</div>
)}
<span className="text-sm font-bold text-gray-900">{totalProgress}%</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-500"
style={{ width: `${totalProgress}%` }}
/>
</div>
</div>
{/* Current Block */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{currentBlock.title}</h2>
<p className="text-gray-600">{currentBlock.description}</p>
</div>
{companyProfile && (
<button
type="button"
onClick={handlePrefillFromProfile}
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 border border-blue-300 rounded-lg hover:bg-blue-100 transition-colors whitespace-nowrap"
>
Aus Unternehmensprofil übernehmen
</button>
)}
</div>
{/* Questions */}
<div className="space-y-6">
{currentBlock.questions.map((question) => (
<div key={question.id} className="border-b border-gray-100 pb-6 last:border-b-0 last:pb-0">
{renderQuestion(question)}
</div>
))}
</div>
</div>
{/* Navigation Buttons */}
<div className="flex items-center justify-between bg-white rounded-xl border border-gray-200 p-4">
<button
type="button"
onClick={handleBack}
disabled={currentBlockIndex === 0}
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Zurück
</button>
<span className="text-sm text-gray-600">
Block {currentBlockIndex + 1} von {SCOPE_QUESTION_BLOCKS.length}
</span>
<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"
>
{currentBlockIndex === SCOPE_QUESTION_BLOCKS.length - 1 ? 'Auswertung starten' : 'Weiter'}
</button>
</div>
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -201,6 +201,62 @@ export function ThresholdAnalysisSection({ dsfa, onUpdate, isSubmitting }: Thres
{wp248Result.reason}
</p>
</div>
{/* Annex-Trigger: Empfehlung bei >= 2 WP248 Kriterien */}
{wp248Selected.length >= 2 && (
<div className="mt-4 p-4 rounded-xl border bg-indigo-50 border-indigo-200">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-4 h-4 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<p className="font-semibold text-indigo-800 text-sm">Annex mit separater Risikobewertung empfohlen</p>
<p className="text-sm text-indigo-700 mt-1">
Bei {wp248Selected.length} erfuellten WP248-Kriterien wird ein Annex mit detaillierter Risikobewertung empfohlen.
</p>
<div className="mt-2">
<p className="text-xs font-medium text-indigo-700 mb-1">Vorgeschlagene Annex-Scopes basierend auf Ihren Kriterien:</p>
<ul className="text-xs text-indigo-600 space-y-1">
{wp248Selected.includes('scoring_profiling') && (
<li>- Annex: Profiling & Scoring Detailanalyse der Bewertungslogik</li>
)}
{wp248Selected.includes('automated_decision') && (
<li>- Annex: Automatisierte Einzelentscheidung Art. 22 Pruefung</li>
)}
{wp248Selected.includes('systematic_monitoring') && (
<li>- Annex: Systematische Ueberwachung Verhaeltnismaessigkeitspruefung</li>
)}
{wp248Selected.includes('sensitive_data') && (
<li>- Annex: Besondere Datenkategorien Schutzbedarfsanalyse Art. 9</li>
)}
{wp248Selected.includes('large_scale') && (
<li>- Annex: Umfangsanalyse Quantitative Bewertung der Verarbeitung</li>
)}
{wp248Selected.includes('matching_combining') && (
<li>- Annex: Datenzusammenfuehrung Zweckbindungspruefung</li>
)}
{wp248Selected.includes('vulnerable_subjects') && (
<li>- Annex: Schutzbeduerftige Betroffene Verstaerkte Schutzmassnahmen</li>
)}
{wp248Selected.includes('innovative_technology') && (
<li>- Annex: Innovative Technologie Technikfolgenabschaetzung</li>
)}
{wp248Selected.includes('preventing_rights') && (
<li>- Annex: Rechteausuebung Barrierefreiheit der Betroffenenrechte</li>
)}
</ul>
</div>
{aiTriggersSelected.length > 0 && (
<p className="text-xs text-indigo-500 mt-2">
+ KI-Trigger aktiv: Zusaetzlicher Annex fuer KI-Risikobewertung empfohlen (AI Act Konformitaet).
</p>
)}
</div>
</div>
</div>
)}
</div>
{/* Step 2: Art. 35 Abs. 3 Cases */}

View File

@@ -11,6 +11,7 @@ export { DSFASidebar } from './DSFASidebar'
export { StakeholderConsultationSection } from './StakeholderConsultationSection'
export { Art36Warning } from './Art36Warning'
export { ReviewScheduleSection } from './ReviewScheduleSection'
export { SourceAttribution, InlineSourceRef, AttributionFooter } from './SourceAttribution'
// =============================================================================
// DSFA Card Component

View File

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

View File

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

View File

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

View File

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