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:
442
admin-v2/components/sdk/ComplianceAdvisorWidget.tsx
Normal file
442
admin-v2/components/sdk/ComplianceAdvisorWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
362
admin-v2/components/sdk/compliance-scope/ScopeDecisionTab.tsx
Normal file
362
admin-v2/components/sdk/compliance-scope/ScopeDecisionTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
334
admin-v2/components/sdk/compliance-scope/ScopeExportTab.tsx
Normal file
334
admin-v2/components/sdk/compliance-scope/ScopeExportTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
267
admin-v2/components/sdk/compliance-scope/ScopeOverviewTab.tsx
Normal file
267
admin-v2/components/sdk/compliance-scope/ScopeOverviewTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
410
admin-v2/components/sdk/compliance-scope/ScopeWizardTab.tsx
Normal file
410
admin-v2/components/sdk/compliance-scope/ScopeWizardTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
admin-v2/components/sdk/compliance-scope/index.ts
Normal file
4
admin-v2/components/sdk/compliance-scope/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ScopeOverviewTab } from './ScopeOverviewTab'
|
||||
export { ScopeWizardTab } from './ScopeWizardTab'
|
||||
export { ScopeDecisionTab } from './ScopeDecisionTab'
|
||||
export { ScopeExportTab } from './ScopeExportTab'
|
||||
320
admin-v2/components/sdk/dsfa/SourceAttribution.tsx
Normal file
320
admin-v2/components/sdk/dsfa/SourceAttribution.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { BookOpen, ExternalLink, Scale, ChevronDown, ChevronUp, Info } from 'lucide-react'
|
||||
import {
|
||||
DSFALicenseCode,
|
||||
DSFA_LICENSE_LABELS,
|
||||
SourceAttributionProps
|
||||
} from '@/lib/sdk/types'
|
||||
|
||||
/**
|
||||
* Get license badge color based on license type
|
||||
*/
|
||||
function getLicenseBadgeColor(licenseCode: DSFALicenseCode): string {
|
||||
switch (licenseCode) {
|
||||
case 'DL-DE-BY-2.0':
|
||||
case 'DL-DE-ZERO-2.0':
|
||||
return 'bg-blue-100 text-blue-700 border-blue-200'
|
||||
case 'CC-BY-4.0':
|
||||
return 'bg-green-100 text-green-700 border-green-200'
|
||||
case 'EDPB-LICENSE':
|
||||
return 'bg-purple-100 text-purple-700 border-purple-200'
|
||||
case 'PUBLIC_DOMAIN':
|
||||
return 'bg-gray-100 text-gray-700 border-gray-200'
|
||||
case 'PROPRIETARY':
|
||||
return 'bg-amber-100 text-amber-700 border-amber-200'
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-700 border-slate-200'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license URL based on license code
|
||||
*/
|
||||
function getLicenseUrl(licenseCode: DSFALicenseCode): string | null {
|
||||
switch (licenseCode) {
|
||||
case 'DL-DE-BY-2.0':
|
||||
return 'https://www.govdata.de/dl-de/by-2-0'
|
||||
case 'DL-DE-ZERO-2.0':
|
||||
return 'https://www.govdata.de/dl-de/zero-2-0'
|
||||
case 'CC-BY-4.0':
|
||||
return 'https://creativecommons.org/licenses/by/4.0/'
|
||||
case 'EDPB-LICENSE':
|
||||
return 'https://edpb.europa.eu/about-edpb/legal-notice_en'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* License badge component
|
||||
*/
|
||||
function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
|
||||
const licenseUrl = getLicenseUrl(licenseCode)
|
||||
const colorClass = getLicenseBadgeColor(licenseCode)
|
||||
const label = DSFA_LICENSE_LABELS[licenseCode] || licenseCode
|
||||
|
||||
if (licenseUrl) {
|
||||
return (
|
||||
<a
|
||||
href={licenseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorClass} hover:opacity-80 transition-opacity`}
|
||||
>
|
||||
<Scale className="w-3 h-3" />
|
||||
{label}
|
||||
<ExternalLink className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorClass}`}>
|
||||
<Scale className="w-3 h-3" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source item in the attribution list
|
||||
*/
|
||||
function SourceItem({
|
||||
source,
|
||||
index,
|
||||
showScore
|
||||
}: {
|
||||
source: SourceAttributionProps['sources'][0]
|
||||
index: number
|
||||
showScore: boolean
|
||||
}) {
|
||||
return (
|
||||
<li className="text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-slate-400 font-mono text-xs mt-0.5 min-w-[1.5rem]">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{source.sourceUrl ? (
|
||||
<a
|
||||
href={source.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline font-medium truncate"
|
||||
>
|
||||
{source.sourceName}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-slate-700 font-medium truncate">
|
||||
{source.sourceName}
|
||||
</span>
|
||||
)}
|
||||
{showScore && source.score !== undefined && (
|
||||
<span className="text-xs text-slate-400 font-mono">
|
||||
({(source.score * 100).toFixed(0)}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-0.5 leading-relaxed">
|
||||
{source.attributionText}
|
||||
</p>
|
||||
<div className="mt-1.5">
|
||||
<LicenseBadge licenseCode={source.licenseCode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact source badge for inline display
|
||||
*/
|
||||
function CompactSourceBadge({
|
||||
source
|
||||
}: {
|
||||
source: SourceAttributionProps['sources'][0]
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-600 border border-slate-200">
|
||||
<BookOpen className="w-3 h-3" />
|
||||
{source.sourceCode}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SourceAttribution component - displays source/license information for DSFA RAG results
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SourceAttribution
|
||||
* sources={[
|
||||
* {
|
||||
* sourceCode: "WP248",
|
||||
* sourceName: "WP248 rev.01 - Leitlinien zur DSFA",
|
||||
* attributionText: "Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)",
|
||||
* licenseCode: "EDPB-LICENSE",
|
||||
* sourceUrl: "https://ec.europa.eu/newsroom/article29/items/611236/en",
|
||||
* score: 0.87
|
||||
* }
|
||||
* ]}
|
||||
* showScores
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function SourceAttribution({
|
||||
sources,
|
||||
compact = false,
|
||||
showScores = false
|
||||
}: SourceAttributionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact)
|
||||
|
||||
if (!sources || sources.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Compact mode - just show badges
|
||||
if (compact && !isExpanded) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
<Info className="w-3 h-3" />
|
||||
Quellen ({sources.length})
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
{sources.slice(0, 3).map((source, i) => (
|
||||
<CompactSourceBadge key={i} source={source} />
|
||||
))}
|
||||
{sources.length > 3 && (
|
||||
<span className="text-xs text-slate-400">+{sources.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-slate-700 flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Quellen & Lizenzen
|
||||
</h4>
|
||||
{compact && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="text-xs text-slate-400 hover:text-slate-600 flex items-center gap-1"
|
||||
>
|
||||
Einklappen
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-3 space-y-3">
|
||||
{sources.map((source, i) => (
|
||||
<SourceItem
|
||||
key={i}
|
||||
source={source}
|
||||
index={i}
|
||||
showScore={showScores}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Aggregated license notice */}
|
||||
{sources.length > 1 && (
|
||||
<div className="mt-4 pt-3 border-t border-slate-200">
|
||||
<p className="text-xs text-slate-500">
|
||||
<strong>Hinweis:</strong> Die angezeigten Inhalte stammen aus {sources.length} verschiedenen Quellen
|
||||
mit unterschiedlichen Lizenzen. Bitte beachten Sie die jeweiligen Attributionsanforderungen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline source reference for use within text
|
||||
*/
|
||||
export function InlineSourceRef({
|
||||
sourceCode,
|
||||
sourceName,
|
||||
sourceUrl
|
||||
}: {
|
||||
sourceCode: string
|
||||
sourceName: string
|
||||
sourceUrl?: string
|
||||
}) {
|
||||
if (sourceUrl) {
|
||||
return (
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-blue-600 hover:text-blue-800 text-sm"
|
||||
title={sourceName}
|
||||
>
|
||||
[{sourceCode}]
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-slate-600 text-sm" title={sourceName}>
|
||||
[{sourceCode}]
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribution footer for generated documents
|
||||
*/
|
||||
export function AttributionFooter({
|
||||
sources,
|
||||
generatedAt
|
||||
}: {
|
||||
sources: SourceAttributionProps['sources']
|
||||
generatedAt?: Date
|
||||
}) {
|
||||
if (!sources || sources.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Group by license
|
||||
const byLicense = sources.reduce((acc, source) => {
|
||||
const key = source.licenseCode
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(source)
|
||||
return acc
|
||||
}, {} as Record<string, typeof sources>)
|
||||
|
||||
return (
|
||||
<footer className="mt-8 pt-4 border-t border-slate-200 text-xs text-slate-500">
|
||||
<h5 className="font-medium text-slate-600 mb-2">Quellennachweis</h5>
|
||||
<ul className="space-y-1">
|
||||
{Object.entries(byLicense).map(([licenseCode, licenseSources]) => (
|
||||
<li key={licenseCode}>
|
||||
<span className="font-medium">{DSFA_LICENSE_LABELS[licenseCode as DSFALicenseCode]}:</span>{' '}
|
||||
{licenseSources.map(s => s.sourceName).join(', ')}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{generatedAt && (
|
||||
<p className="mt-2 text-slate-400">
|
||||
Generiert am {generatedAt.toLocaleDateString('de-DE')} um {generatedAt.toLocaleTimeString('de-DE')}
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export default SourceAttribution
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
376
admin-v2/components/sdk/tom-dashboard/TOMEditorTab.tsx
Normal file
376
admin-v2/components/sdk/tom-dashboard/TOMEditorTab.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
||||
import { getControlById } from '@/lib/sdk/tom-generator/controls/loader'
|
||||
|
||||
interface TOMEditorTabProps {
|
||||
state: TOMGeneratorState
|
||||
selectedTOMId: string | null
|
||||
onUpdateTOM: (tomId: string, updates: Partial<DerivedTOM>) => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: { value: DerivedTOM['implementationStatus']; label: string; className: string }[] = [
|
||||
{ value: 'IMPLEMENTED', label: 'Implementiert', className: 'border-green-300 bg-green-50 text-green-700' },
|
||||
{ value: 'PARTIAL', label: 'Teilweise implementiert', className: 'border-yellow-300 bg-yellow-50 text-yellow-700' },
|
||||
{ value: 'NOT_IMPLEMENTED', label: 'Nicht implementiert', className: 'border-red-300 bg-red-50 text-red-700' },
|
||||
]
|
||||
|
||||
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
|
||||
TECHNICAL: { label: 'Technisch', className: 'bg-blue-100 text-blue-700' },
|
||||
ORGANIZATIONAL: { label: 'Organisatorisch', className: 'bg-indigo-100 text-indigo-700' },
|
||||
}
|
||||
|
||||
interface VVTActivity {
|
||||
id: string
|
||||
name?: string
|
||||
title?: string
|
||||
structuredToms?: { category?: string }[]
|
||||
}
|
||||
|
||||
export function TOMEditorTab({ state, selectedTOMId, onUpdateTOM, onBack }: TOMEditorTabProps) {
|
||||
const tom = useMemo(() => {
|
||||
if (!selectedTOMId) return null
|
||||
return state.derivedTOMs.find(t => t.id === selectedTOMId) || null
|
||||
}, [state.derivedTOMs, selectedTOMId])
|
||||
|
||||
const control = useMemo(() => {
|
||||
if (!tom) return null
|
||||
return getControlById(tom.controlId)
|
||||
}, [tom])
|
||||
|
||||
const [implementationStatus, setImplementationStatus] = useState<DerivedTOM['implementationStatus']>('NOT_IMPLEMENTED')
|
||||
const [responsiblePerson, setResponsiblePerson] = useState('')
|
||||
const [implementationDate, setImplementationDate] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [linkedEvidence, setLinkedEvidence] = useState<string[]>([])
|
||||
const [selectedEvidenceId, setSelectedEvidenceId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (tom) {
|
||||
setImplementationStatus(tom.implementationStatus)
|
||||
setResponsiblePerson(tom.responsiblePerson || '')
|
||||
setImplementationDate(tom.implementationDate ? new Date(tom.implementationDate).toISOString().slice(0, 10) : '')
|
||||
setNotes(tom.aiGeneratedDescription || '')
|
||||
setLinkedEvidence(tom.linkedEvidence || [])
|
||||
}
|
||||
}, [tom])
|
||||
|
||||
const vvtActivities = useMemo(() => {
|
||||
if (!control) return []
|
||||
try {
|
||||
const raw = localStorage.getItem('bp_vvt')
|
||||
if (!raw) return []
|
||||
const activities: VVTActivity[] = JSON.parse(raw)
|
||||
return activities.filter(a =>
|
||||
a.structuredToms?.some(t => t.category === control.category)
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, [control])
|
||||
|
||||
const availableDocuments = useMemo(() => {
|
||||
return (state.documents || []).filter(
|
||||
doc => !linkedEvidence.includes(doc.id)
|
||||
)
|
||||
}, [state.documents, linkedEvidence])
|
||||
|
||||
const linkedDocuments = useMemo(() => {
|
||||
return linkedEvidence
|
||||
.map(id => (state.documents || []).find(d => d.id === id))
|
||||
.filter(Boolean)
|
||||
}, [state.documents, linkedEvidence])
|
||||
|
||||
const evidenceGaps = useMemo(() => {
|
||||
if (!control?.evidenceRequirements) return []
|
||||
return control.evidenceRequirements.map(req => {
|
||||
const hasMatch = (state.documents || []).some(doc =>
|
||||
linkedEvidence.includes(doc.id) &&
|
||||
(doc.filename?.toLowerCase().includes(req.toLowerCase()) ||
|
||||
doc.documentType?.toLowerCase().includes(req.toLowerCase()))
|
||||
)
|
||||
return { requirement: req, fulfilled: hasMatch }
|
||||
})
|
||||
}, [control, state.documents, linkedEvidence])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!tom) return
|
||||
onUpdateTOM(tom.id, {
|
||||
implementationStatus,
|
||||
responsiblePerson: responsiblePerson || null,
|
||||
implementationDate: implementationDate ? new Date(implementationDate) : null,
|
||||
aiGeneratedDescription: notes || null,
|
||||
linkedEvidence,
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddEvidence = () => {
|
||||
if (!selectedEvidenceId) return
|
||||
setLinkedEvidence(prev => [...prev, selectedEvidenceId])
|
||||
setSelectedEvidenceId('')
|
||||
}
|
||||
|
||||
const handleRemoveEvidence = (docId: string) => {
|
||||
setLinkedEvidence(prev => prev.filter(id => id !== docId))
|
||||
}
|
||||
|
||||
if (!selectedTOMId || !tom) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">Keine TOM ausgewaehlt</h3>
|
||||
<p className="text-gray-500">Waehlen Sie eine TOM aus der Uebersicht, um sie zu bearbeiten.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const typeBadge = TYPE_BADGES[control?.type || 'TECHNICAL'] || TYPE_BADGES.TECHNICAL
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-purple-600 hover:text-purple-800 font-medium flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurueck zur Uebersicht
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Aenderungen speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* TOM Header Card */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="text-xs font-mono bg-gray-100 text-gray-600 px-2 py-1 rounded">{control?.code || tom.controlId}</span>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium ${typeBadge.className}`}>
|
||||
{typeBadge.label}
|
||||
</span>
|
||||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full font-medium">
|
||||
{control?.category || 'Unbekannt'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">{control?.name?.de || tom.controlId}</h2>
|
||||
{control?.description?.de && (
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{control.description.de}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Implementation Status */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Implementierungsstatus</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{STATUS_OPTIONS.map(opt => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex items-center gap-3 border rounded-lg p-3 cursor-pointer transition-all ${
|
||||
implementationStatus === opt.value
|
||||
? opt.className + ' ring-2 ring-offset-1 ring-current'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="implementationStatus"
|
||||
value={opt.value}
|
||||
checked={implementationStatus === opt.value}
|
||||
onChange={() => setImplementationStatus(opt.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
||||
implementationStatus === opt.value ? 'border-current' : 'border-gray-300'
|
||||
}`}>
|
||||
{implementationStatus === opt.value && (
|
||||
<div className="w-2 h-2 rounded-full bg-current" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Responsible Person */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Verantwortliche Person</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Umgesetzt von</label>
|
||||
<input
|
||||
type="text"
|
||||
value={responsiblePerson}
|
||||
onChange={e => setResponsiblePerson(e.target.value)}
|
||||
placeholder="Name der verantwortlichen Person"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Umsetzungsdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={implementationDate}
|
||||
onChange={e => setImplementationDate(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Anmerkungen</h3>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Anmerkungen zur Umsetzung, Besonderheiten, etc."
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Evidence Section */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Nachweisdokumente</h3>
|
||||
|
||||
{linkedDocuments.length > 0 ? (
|
||||
<div className="space-y-2 mb-4">
|
||||
{linkedDocuments.map(doc => doc && (
|
||||
<div key={doc.id} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-700">{doc.originalName || doc.filename || doc.id}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveEvidence(doc.id)}
|
||||
className="text-red-500 hover:text-red-700 text-xs font-medium"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 mb-4">Keine Nachweisdokumente verknuepft.</p>
|
||||
)}
|
||||
|
||||
{availableDocuments.length > 0 && (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Dokument hinzufuegen</label>
|
||||
<select
|
||||
value={selectedEvidenceId}
|
||||
onChange={e => setSelectedEvidenceId(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="">Dokument auswaehlen...</option>
|
||||
{availableDocuments.map(doc => (
|
||||
<option key={doc.id} value={doc.id}>{doc.originalName || doc.filename || doc.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddEvidence}
|
||||
disabled={!selectedEvidenceId}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Evidence Gaps */}
|
||||
{evidenceGaps.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Nachweis-Anforderungen</h3>
|
||||
<div className="space-y-2">
|
||||
{evidenceGaps.map((gap, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3">
|
||||
<div className={`w-5 h-5 rounded flex items-center justify-center flex-shrink-0 ${
|
||||
gap.fulfilled ? 'bg-green-100 text-green-600' : 'bg-red-50 text-red-400'
|
||||
}`}>
|
||||
{gap.fulfilled ? (
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-sm ${gap.fulfilled ? 'text-gray-700' : 'text-gray-500'}`}>
|
||||
{gap.requirement}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VVT Cross-References */}
|
||||
{vvtActivities.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">VVT-Querverweise</h3>
|
||||
<div className="space-y-2">
|
||||
{vvtActivities.map(activity => (
|
||||
<div key={activity.id} className="flex items-center gap-2 bg-purple-50 rounded-lg px-3 py-2">
|
||||
<svg className="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span className="text-sm text-purple-700">{activity.name || activity.title || activity.id}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Framework Mappings */}
|
||||
{control?.mappings && control.mappings.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Framework-Zuordnungen</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{control.mappings.map((mapping, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase">{mapping.framework}</span>
|
||||
<span className="text-sm text-gray-700">{mapping.reference}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Save */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 font-medium"
|
||||
>
|
||||
Zurueck zur Uebersicht
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-6 py-2.5 font-medium transition-colors"
|
||||
>
|
||||
Aenderungen speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
328
admin-v2/components/sdk/tom-dashboard/TOMGapExportTab.tsx
Normal file
328
admin-v2/components/sdk/tom-dashboard/TOMGapExportTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
267
admin-v2/components/sdk/tom-dashboard/TOMOverviewTab.tsx
Normal file
267
admin-v2/components/sdk/tom-dashboard/TOMOverviewTab.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
||||
import { getControlById, getControlsByCategory, getAllCategories } from '@/lib/sdk/tom-generator/controls/loader'
|
||||
import { SDM_GOAL_LABELS, getSDMCoverageStats, SDMGewaehrleistungsziel } from '@/lib/sdk/tom-generator/sdm-mapping'
|
||||
|
||||
interface TOMOverviewTabProps {
|
||||
state: TOMGeneratorState
|
||||
onSelectTOM: (tomId: string) => void
|
||||
onStartGenerator: () => void
|
||||
}
|
||||
|
||||
const STATUS_BADGES: Record<string, { label: string; className: string }> = {
|
||||
IMPLEMENTED: { label: 'Implementiert', className: 'bg-green-100 text-green-700' },
|
||||
PARTIAL: { label: 'Teilweise', className: 'bg-yellow-100 text-yellow-700' },
|
||||
NOT_IMPLEMENTED: { label: 'Fehlend', className: 'bg-red-100 text-red-700' },
|
||||
}
|
||||
|
||||
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
|
||||
TECHNICAL: { label: 'Technisch', className: 'bg-blue-100 text-blue-700' },
|
||||
ORGANIZATIONAL: { label: 'Organisatorisch', className: 'bg-indigo-100 text-indigo-700' },
|
||||
}
|
||||
|
||||
const SCHUTZZIELE: { key: SDMGewaehrleistungsziel; label: string }[] = [
|
||||
{ key: 'Vertraulichkeit', label: 'Vertraulichkeit' },
|
||||
{ key: 'Integritaet', label: 'Integritaet' },
|
||||
{ key: 'Verfuegbarkeit', label: 'Verfuegbarkeit' },
|
||||
{ key: 'Nichtverkettung', label: 'Nichtverkettung' },
|
||||
]
|
||||
|
||||
export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOverviewTabProps) {
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('ALL')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('ALL')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('ALL')
|
||||
const [applicabilityFilter, setApplicabilityFilter] = useState<string>('ALL')
|
||||
|
||||
const categories = useMemo(() => getAllCategories(), [])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const toms = state.derivedTOMs
|
||||
return {
|
||||
total: toms.length,
|
||||
implemented: toms.filter(t => t.implementationStatus === 'IMPLEMENTED').length,
|
||||
partial: toms.filter(t => t.implementationStatus === 'PARTIAL').length,
|
||||
missing: toms.filter(t => t.implementationStatus === 'NOT_IMPLEMENTED').length,
|
||||
}
|
||||
}, [state.derivedTOMs])
|
||||
|
||||
const sdmStats = useMemo(() => {
|
||||
const allStats = getSDMCoverageStats(state.derivedTOMs)
|
||||
return SCHUTZZIELE.map(sz => ({
|
||||
...sz,
|
||||
stats: allStats[sz.key] || { total: 0, implemented: 0, partial: 0, missing: 0 },
|
||||
}))
|
||||
}, [state.derivedTOMs])
|
||||
|
||||
const filteredTOMs = useMemo(() => {
|
||||
let toms = state.derivedTOMs
|
||||
|
||||
if (categoryFilter !== 'ALL') {
|
||||
const categoryControlIds = getControlsByCategory(categoryFilter).map(c => c.id)
|
||||
toms = toms.filter(t => categoryControlIds.includes(t.controlId))
|
||||
}
|
||||
|
||||
if (typeFilter !== 'ALL') {
|
||||
toms = toms.filter(t => {
|
||||
const ctrl = getControlById(t.controlId)
|
||||
return ctrl?.type === typeFilter
|
||||
})
|
||||
}
|
||||
|
||||
if (statusFilter !== 'ALL') {
|
||||
toms = toms.filter(t => t.implementationStatus === statusFilter)
|
||||
}
|
||||
|
||||
if (applicabilityFilter !== 'ALL') {
|
||||
toms = toms.filter(t => t.applicability === applicabilityFilter)
|
||||
}
|
||||
|
||||
return toms
|
||||
}, [state.derivedTOMs, categoryFilter, typeFilter, statusFilter, applicabilityFilter])
|
||||
|
||||
if (state.derivedTOMs.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">Keine TOMs vorhanden</h3>
|
||||
<p className="text-gray-500 mb-6 max-w-md">
|
||||
Starten Sie den TOM Generator, um technische und organisatorische Massnahmen basierend auf Ihrem Verarbeitungsverzeichnis abzuleiten.
|
||||
</p>
|
||||
<button
|
||||
onClick={onStartGenerator}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-6 py-3 font-medium transition-colors"
|
||||
>
|
||||
TOM Generator starten
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
||||
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Gesamt TOMs</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{stats.implemented}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Implementiert</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
||||
<div className="text-3xl font-bold text-yellow-600">{stats.partial}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Teilweise</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
||||
<div className="text-3xl font-bold text-red-600">{stats.missing}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Fehlend</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Art. 32 Schutzziele */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Art. 32 DSGVO Schutzziele</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{sdmStats.map(sz => {
|
||||
const total = sz.stats.total || 1
|
||||
const implPercent = Math.round((sz.stats.implemented / total) * 100)
|
||||
const partialPercent = Math.round((sz.stats.partial / total) * 100)
|
||||
return (
|
||||
<div key={sz.key} className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">{sz.label}</div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full flex">
|
||||
<div
|
||||
className="bg-green-500 h-full"
|
||||
style={{ width: `${implPercent}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-yellow-400 h-full"
|
||||
style={{ width: `${partialPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{sz.stats.implemented}/{sz.stats.total} implementiert
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Controls */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="ALL">Alle Kategorien</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Typ</label>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="ALL">Alle</option>
|
||||
<option value="TECHNICAL">Technisch</option>
|
||||
<option value="ORGANIZATIONAL">Organisatorisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="ALL">Alle</option>
|
||||
<option value="IMPLEMENTED">Implementiert</option>
|
||||
<option value="PARTIAL">Teilweise</option>
|
||||
<option value="NOT_IMPLEMENTED">Fehlend</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Anwendbarkeit</label>
|
||||
<select
|
||||
value={applicabilityFilter}
|
||||
onChange={e => setApplicabilityFilter(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="ALL">Alle</option>
|
||||
<option value="REQUIRED">Erforderlich</option>
|
||||
<option value="RECOMMENDED">Empfohlen</option>
|
||||
<option value="OPTIONAL">Optional</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TOM Card Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{filteredTOMs.map(tom => {
|
||||
const control = getControlById(tom.controlId)
|
||||
const statusBadge = STATUS_BADGES[tom.implementationStatus] || STATUS_BADGES.NOT_IMPLEMENTED
|
||||
const typeBadge = TYPE_BADGES[control?.type || 'TECHNICAL'] || TYPE_BADGES.TECHNICAL
|
||||
const evidenceCount = tom.linkedEvidence?.length || 0
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tom.id}
|
||||
onClick={() => onSelectTOM(tom.id)}
|
||||
className="bg-white rounded-xl border border-gray-200 p-5 text-left hover:border-purple-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono text-gray-400">{control?.code || tom.controlId}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadge.className}`}>
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeBadge.className}`}>
|
||||
{typeBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
{evidenceCount > 0 && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||
{evidenceCount} Nachweise
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-gray-800 group-hover:text-purple-700 transition-colors mb-1">
|
||||
{control?.name?.de || tom.controlId}
|
||||
</h4>
|
||||
<div className="text-xs text-gray-400">
|
||||
{control?.category || 'Unbekannte Kategorie'}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredTOMs.length === 0 && state.derivedTOMs.length > 0 && (
|
||||
<div className="text-center py-10 text-gray-500">
|
||||
<p>Keine TOMs entsprechen den aktuellen Filterkriterien.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
admin-v2/components/sdk/tom-dashboard/index.ts
Normal file
3
admin-v2/components/sdk/tom-dashboard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { TOMOverviewTab } from './TOMOverviewTab'
|
||||
export { TOMEditorTab } from './TOMEditorTab'
|
||||
export { TOMGapExportTab } from './TOMGapExportTab'
|
||||
Reference in New Issue
Block a user