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

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

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

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

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

View File

@@ -273,6 +273,52 @@ Dein Ziel ist die rechtzeitige Erkennung und Kommunikation relevanter Ereignisse
createdAt: '2024-12-01T00:00:00Z',
updatedAt: '2025-01-12T02:00:00Z'
},
'compliance-advisor': {
id: 'compliance-advisor',
name: 'Compliance Advisor',
description: 'DSGVO/Compliance-Berater fuer SDK-Nutzer',
soulFile: 'compliance-advisor.soul.md',
soulContent: `# Compliance Advisor Agent
## Identitaet
Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance SDK,
Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
Du bist kein Anwalt und gibst keine Rechtsberatung, sondern orientierst dich an
offiziellen Quellen und gibst praxisnahe Hinweise.
## Kernprinzipien
- **Quellenbasiert**: Verweise immer auf konkrete Rechtsgrundlagen (DSGVO-Artikel, BDSG-Paragraphen)
- **Verstaendlich**: Erklaere rechtliche Konzepte in einfacher, praxisnaher Sprache
- **Ehrlich**: Bei Unsicherheit empfehle professionelle Rechtsberatung
- **Kontextbewusst**: Nutze das RAG-System fuer aktuelle Rechtstexte und Leitfaeden
- **Scope-bewusst**: Nutze alle verfuegbaren RAG-Quellen AUSSER NIBIS-Dokumenten
## Kompetenzbereich
- DSGVO Art. 1-99 + Erwaegsgruende
- BDSG (Bundesdatenschutzgesetz)
- AI Act (EU KI-Verordnung)
- TTDSG, ePrivacy-Richtlinie
- DSK-Kurzpapiere (Nr. 1-20)
- SDM V3.0, BSI-Grundschutz, BSI-TR-03161
- EDPB Guidelines, Bundes-/Laender-Muss-Listen
- ISO 27001/27701 (Ueberblick)
## Kommunikationsstil
- Sachlich, aber verstaendlich
- Deutsch als Hauptsprache
- Strukturierte Antworten mit Quellenangabe
- Praxisbeispiele wo hilfreich`,
color: '#6366f1',
status: 'running',
activeSessions: 0,
totalProcessed: 0,
avgResponseTime: 0,
errorRate: 0,
lastRestart: new Date().toISOString(),
version: '1.0.0',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
'orchestrator': {
id: 'orchestrator',
name: 'Orchestrator',

View File

@@ -94,6 +94,19 @@ const mockAgents: AgentConfig[] = [
totalProcessed: 8934,
avgResponseTime: 12,
lastActivity: 'just now'
},
{
id: 'compliance-advisor',
name: 'Compliance Advisor',
description: 'DSGVO/Compliance-Berater fuer SDK-Nutzer',
soulFile: 'compliance-advisor.soul.md',
color: '#6366f1',
icon: 'message',
status: 'running',
activeSessions: 0,
totalProcessed: 0,
avgResponseTime: 0,
lastActivity: new Date().toISOString()
}
]

View File

@@ -0,0 +1,674 @@
'use client'
/**
* DSFA Document Manager
*
* Manages DSFA-related sources and documents for the RAG pipeline.
* Features:
* - View all registered DSFA sources with license info
* - Upload new documents
* - Trigger re-indexing
* - View corpus statistics
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import {
ArrowLeft,
RefreshCw,
Upload,
FileText,
Database,
Scale,
ExternalLink,
ChevronDown,
ChevronUp,
Search,
Filter,
CheckCircle,
Clock,
AlertCircle,
BookOpen
} from 'lucide-react'
import {
DSFASource,
DSFACorpusStats,
DSFASourceStats,
DSFALicenseCode,
DSFA_LICENSE_LABELS,
DSFA_DOCUMENT_TYPE_LABELS
} from '@/lib/sdk/types'
// ============================================================================
// TYPES
// ============================================================================
interface APIError {
message: string
status?: number
}
// ============================================================================
// API FUNCTIONS
// ============================================================================
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
async function fetchSources(): Promise<DSFASource[]> {
try {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources`)
if (!response.ok) throw new Error('Failed to fetch sources')
return await response.json()
} catch {
// Return mock data for demo
return MOCK_SOURCES
}
}
async function fetchStats(): Promise<DSFACorpusStats> {
try {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/stats`)
if (!response.ok) throw new Error('Failed to fetch stats')
return await response.json()
} catch {
return MOCK_STATS
}
}
async function initializeCorpus(): Promise<{ sources_registered: number }> {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/init`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to initialize corpus')
return await response.json()
}
async function triggerIngestion(sourceCode: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources/${sourceCode}/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (!response.ok) throw new Error('Failed to trigger ingestion')
}
// ============================================================================
// MOCK DATA
// ============================================================================
const MOCK_SOURCES: DSFASource[] = [
{
id: '1',
sourceCode: 'WP248',
name: 'WP248 rev.01 - Leitlinien zur DSFA',
fullName: 'Leitlinien zur Datenschutz-Folgenabschaetzung',
organization: 'Artikel-29-Datenschutzgruppe / EDPB',
sourceUrl: 'https://ec.europa.eu/newsroom/article29/items/611236/en',
licenseCode: 'EDPB-LICENSE',
licenseName: 'EDPB Document License',
attributionRequired: true,
attributionText: 'Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)',
documentType: 'guideline',
language: 'de',
},
{
id: '2',
sourceCode: 'DSK_KP5',
name: 'Kurzpapier Nr. 5 - DSFA nach Art. 35 DS-GVO',
organization: 'Datenschutzkonferenz (DSK)',
sourceUrl: 'https://www.datenschutzkonferenz-online.de/media/kp/dsk_kpnr_5.pdf',
licenseCode: 'DL-DE-BY-2.0',
licenseName: 'Datenlizenz DE Namensnennung 2.0',
licenseUrl: 'https://www.govdata.de/dl-de/by-2-0',
attributionRequired: true,
attributionText: 'Quelle: DSK Kurzpapier Nr. 5 (Stand: 2018)',
documentType: 'guideline',
language: 'de',
},
{
id: '3',
sourceCode: 'BFDI_MUSS_PUBLIC',
name: 'BfDI DSFA-Liste (oeffentlicher Bereich)',
organization: 'BfDI',
sourceUrl: 'https://www.bfdi.bund.de',
licenseCode: 'DL-DE-ZERO-2.0',
licenseName: 'Datenlizenz DE Zero 2.0',
attributionRequired: false,
attributionText: 'Quelle: BfDI, Liste gem. Art. 35 Abs. 4 DSGVO',
documentType: 'checklist',
language: 'de',
},
{
id: '4',
sourceCode: 'NI_MUSS_PRIVATE',
name: 'LfD NI DSFA-Liste (nicht-oeffentlich)',
organization: 'LfD Niedersachsen',
sourceUrl: 'https://www.lfd.niedersachsen.de/download/131098',
licenseCode: 'DL-DE-BY-2.0',
licenseName: 'Datenlizenz DE Namensnennung 2.0',
attributionRequired: true,
attributionText: 'Quelle: LfD Niedersachsen, DSFA-Muss-Liste',
documentType: 'checklist',
language: 'de',
},
]
const MOCK_STATS: DSFACorpusStats = {
sources: [
{
sourceId: '1',
sourceCode: 'WP248',
name: 'WP248 rev.01',
organization: 'EDPB',
licenseCode: 'EDPB-LICENSE',
documentType: 'guideline',
documentCount: 1,
chunkCount: 50,
lastIndexedAt: '2026-02-09T10:00:00Z',
},
{
sourceId: '2',
sourceCode: 'DSK_KP5',
name: 'DSK Kurzpapier Nr. 5',
organization: 'DSK',
licenseCode: 'DL-DE-BY-2.0',
documentType: 'guideline',
documentCount: 1,
chunkCount: 35,
lastIndexedAt: '2026-02-09T10:00:00Z',
},
],
totalSources: 45,
totalDocuments: 45,
totalChunks: 850,
qdrantCollection: 'bp_dsfa_corpus',
qdrantPointsCount: 850,
qdrantStatus: 'green',
}
// ============================================================================
// COMPONENTS
// ============================================================================
function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
const colorMap: Record<DSFALicenseCode, string> = {
'DL-DE-BY-2.0': 'bg-blue-100 text-blue-700 border-blue-200',
'DL-DE-ZERO-2.0': 'bg-gray-100 text-gray-700 border-gray-200',
'CC-BY-4.0': 'bg-green-100 text-green-700 border-green-200',
'EDPB-LICENSE': 'bg-purple-100 text-purple-700 border-purple-200',
'PUBLIC_DOMAIN': 'bg-gray-100 text-gray-600 border-gray-200',
'PROPRIETARY': 'bg-amber-100 text-amber-700 border-amber-200',
}
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorMap[licenseCode] || 'bg-gray-100 text-gray-700 border-gray-200'}`}>
<Scale className="w-3 h-3" />
{DSFA_LICENSE_LABELS[licenseCode] || licenseCode}
</span>
)
}
function DocumentTypeBadge({ type }: { type?: string }) {
if (!type) return null
const colorMap: Record<string, string> = {
guideline: 'bg-indigo-100 text-indigo-700',
checklist: 'bg-emerald-100 text-emerald-700',
regulation: 'bg-red-100 text-red-700',
template: 'bg-orange-100 text-orange-700',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs ${colorMap[type] || 'bg-gray-100 text-gray-700'}`}>
{DSFA_DOCUMENT_TYPE_LABELS[type as keyof typeof DSFA_DOCUMENT_TYPE_LABELS] || type}
</span>
)
}
function StatusIndicator({ status }: { status: string }) {
const statusConfig: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
green: { color: 'text-green-500', icon: <CheckCircle className="w-4 h-4" />, label: 'Aktiv' },
yellow: { color: 'text-yellow-500', icon: <Clock className="w-4 h-4" />, label: 'Ausstehend' },
red: { color: 'text-red-500', icon: <AlertCircle className="w-4 h-4" />, label: 'Fehler' },
}
const config = statusConfig[status] || statusConfig.yellow
return (
<span className={`inline-flex items-center gap-1 ${config.color}`}>
{config.icon}
<span className="text-sm">{config.label}</span>
</span>
)
}
function SourceCard({
source,
stats,
onIngest,
isIngesting
}: {
source: DSFASource
stats?: DSFASourceStats
onIngest: () => void
isIngesting: boolean
}) {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{source.sourceCode}
</span>
<DocumentTypeBadge type={source.documentType} />
</div>
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
{source.name}
</h3>
{source.organization && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{source.organization}
</p>
)}
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
</div>
<div className="flex items-center gap-4 mt-3">
<LicenseBadge licenseCode={source.licenseCode} />
{stats && (
<>
<span className="text-sm text-gray-500">
{stats.documentCount} Dok.
</span>
<span className="text-sm text-gray-500">
{stats.chunkCount} Chunks
</span>
</>
)}
</div>
{source.attributionRequired && (
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-700 dark:text-amber-300">
<strong>Attribution:</strong> {source.attributionText}
</div>
)}
</div>
{isExpanded && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
<dl className="grid grid-cols-2 gap-3 text-sm">
{source.sourceUrl && (
<>
<dt className="text-gray-500">Quelle:</dt>
<dd>
<a
href={source.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
>
Link <ExternalLink className="w-3 h-3" />
</a>
</dd>
</>
)}
{source.licenseUrl && (
<>
<dt className="text-gray-500">Lizenz-URL:</dt>
<dd>
<a
href={source.licenseUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
>
{source.licenseName} <ExternalLink className="w-3 h-3" />
</a>
</dd>
</>
)}
<dt className="text-gray-500">Sprache:</dt>
<dd className="uppercase">{source.language}</dd>
{stats?.lastIndexedAt && (
<>
<dt className="text-gray-500">Zuletzt indexiert:</dt>
<dd>{new Date(stats.lastIndexedAt).toLocaleString('de-DE')}</dd>
</>
)}
</dl>
<div className="mt-4 flex gap-2">
<button
onClick={onIngest}
disabled={isIngesting}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
>
{isIngesting ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Neu indexieren
</button>
</div>
</div>
)}
</div>
)
}
function StatsOverview({ stats }: { stats: DSFACorpusStats }) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Database className="w-5 h-5" />
Corpus-Statistik
</h2>
<StatusIndicator status={stats.qdrantStatus} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{stats.totalSources}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Quellen</p>
</div>
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
{stats.totalDocuments}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
</div>
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">
{stats.totalChunks.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
</div>
<div className="text-center p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<p className="text-2xl font-bold text-orange-600 dark:text-orange-400">
{stats.qdrantPointsCount.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Vektoren</p>
</div>
</div>
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Collection:</strong>{' '}
<code className="font-mono bg-gray-200 dark:bg-gray-700 px-1 rounded">
{stats.qdrantCollection}
</code>
</p>
</div>
</div>
)
}
// ============================================================================
// MAIN PAGE
// ============================================================================
export default function DSFADocumentManagerPage() {
const [sources, setSources] = useState<DSFASource[]>([])
const [stats, setStats] = useState<DSFACorpusStats | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [filterType, setFilterType] = useState<string>('all')
const [ingestingSource, setIngestingSource] = useState<string | null>(null)
const [isInitializing, setIsInitializing] = useState(false)
useEffect(() => {
async function loadData() {
setIsLoading(true)
try {
const [sourcesData, statsData] = await Promise.all([
fetchSources(),
fetchStats(),
])
setSources(sourcesData)
setStats(statsData)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data')
setSources(MOCK_SOURCES)
setStats(MOCK_STATS)
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
const handleInitialize = async () => {
setIsInitializing(true)
try {
await initializeCorpus()
// Reload data
const [sourcesData, statsData] = await Promise.all([
fetchSources(),
fetchStats(),
])
setSources(sourcesData)
setStats(statsData)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to initialize')
} finally {
setIsInitializing(false)
}
}
const handleIngest = async (sourceCode: string) => {
setIngestingSource(sourceCode)
try {
await triggerIngestion(sourceCode)
// Reload stats
const statsData = await fetchStats()
setStats(statsData)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to ingest')
} finally {
setIngestingSource(null)
}
}
// Filter sources
const filteredSources = sources.filter(source => {
const matchesSearch = searchQuery === '' ||
source.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
source.sourceCode.toLowerCase().includes(searchQuery.toLowerCase()) ||
source.organization?.toLowerCase().includes(searchQuery.toLowerCase())
const matchesType = filterType === 'all' || source.documentType === filterType
return matchesSearch && matchesType
})
// Get stats by source code
const getStatsForSource = (sourceCode: string): DSFASourceStats | undefined => {
return stats?.sources.find(s => s.sourceCode === sourceCode)
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<Link
href="/ai/rag-pipeline"
className="inline-flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-4"
>
<ArrowLeft className="w-4 h-4" />
Zurueck zur RAG-Pipeline
</Link>
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<BookOpen className="w-8 h-8 text-blue-600" />
DSFA-Quellen Manager
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Verwalten Sie DSFA-Guidance Dokumente mit vollstaendiger Lizenzattribution
</p>
</div>
<div className="flex gap-2">
<button
onClick={handleInitialize}
disabled={isInitializing}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
>
{isInitializing ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Database className="w-4 h-4" />
)}
Initialisieren
</button>
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2">
<Upload className="w-4 h-4" />
Dokument hochladen
</button>
</div>
</div>
</div>
{/* Error Banner */}
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-xl">
<div className="flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-500" />
<span className="text-red-800 dark:text-red-200">{error}</span>
<button
onClick={() => setError(null)}
className="ml-auto text-red-600 hover:text-red-800"
>
&times;
</button>
</div>
</div>
)}
{/* Stats Overview */}
{stats && <StatsOverview stats={stats} />}
{/* Search & Filter */}
<div className="mt-6 flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Quellen durchsuchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800"
/>
</div>
<div className="relative">
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="pl-9 pr-8 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 appearance-none"
>
<option value="all">Alle Typen</option>
<option value="guideline">Leitlinien</option>
<option value="checklist">Prueflisten</option>
<option value="regulation">Verordnungen</option>
</select>
</div>
</div>
{/* Sources List */}
<div className="mt-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Registrierte Quellen ({filteredSources.length})
</h2>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400">Lade Quellen...</p>
</div>
</div>
) : filteredSources.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400">
{searchQuery || filterType !== 'all'
? 'Keine Quellen gefunden'
: 'Noch keine Quellen registriert'}
</p>
{!searchQuery && filterType === 'all' && (
<button
onClick={handleInitialize}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Quellen initialisieren
</button>
)}
</div>
) : (
<div className="grid gap-4">
{filteredSources.map(source => (
<SourceCard
key={source.id}
source={source}
stats={getStatsForSource(source.sourceCode)}
onIngest={() => handleIngest(source.sourceCode)}
isIngesting={ingestingSource === source.sourceCode}
/>
))}
</div>
)}
</div>
{/* Info Box */}
<div className="mt-8 p-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl">
<h3 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">
Ueber die Lizenzattribution
</h3>
<p className="text-sm text-blue-800 dark:text-blue-200 mb-4">
Alle DSFA-Quellen werden mit vollstaendiger Lizenzinformation gespeichert.
Bei der Nutzung der RAG-Suche werden automatisch die korrekten Attributionen angezeigt.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
<div className="flex items-center gap-2">
<LicenseBadge licenseCode="DL-DE-BY-2.0" />
<span className="text-blue-700 dark:text-blue-300">Namensnennung</span>
</div>
<div className="flex items-center gap-2">
<LicenseBadge licenseCode="DL-DE-ZERO-2.0" />
<span className="text-blue-700 dark:text-blue-300">Keine Attribution</span>
</div>
<div className="flex items-center gap-2">
<LicenseBadge licenseCode="CC-BY-4.0" />
<span className="text-blue-700 dark:text-blue-300">CC Attribution</span>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -128,6 +128,16 @@ const MOCK_DATA_SOURCES: DataSource[] = [
last_updated: '2025-01-10T08:00:00Z',
status: 'active',
},
{
id: 'dsfa',
name: 'DSFA-Guidance',
description: 'WP248, DSK Kurzpapiere, Muss-Listen aller Bundeslaender mit Quellenattribution',
collection: 'bp_dsfa_corpus',
document_count: 45,
chunk_count: 850,
last_updated: '2026-02-09T10:00:00Z',
status: 'active',
},
{
id: 'schulordnungen',
name: 'Schulordnungen',
@@ -899,6 +909,21 @@ function DataSourcesTab({ sources }: { sources: DataSource[] }) {
Regelwerk hinzufuegen
</button>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
<div className="text-2xl mb-2">📋</div>
<h3 className="font-medium text-gray-900 dark:text-white mb-2">
DSFA-Quellen verwalten
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
WP248, DSK, Muss-Listen mit Lizenzattribution
</p>
<a
href="/ai/rag-pipeline/dsfa"
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
DSFA-Manager oeffnen
</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,39 @@
'use client'
import { PagePurpose } from '@/components/common/PagePurpose'
import { getModuleByHref } from '@/lib/navigation'
import { GraduationCap, Construction } from 'lucide-react'
export default function CompanionPage() {
const moduleInfo = getModuleByHref('/development/companion')
return (
<div className="space-y-6">
{moduleInfo && (
<PagePurpose
title={moduleInfo.module.name}
purpose={moduleInfo.module.purpose}
audience={moduleInfo.module.audience}
collapsible={true}
defaultCollapsed={true}
/>
)}
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
<div className="flex justify-center mb-4">
<div className="p-4 bg-slate-100 rounded-full">
<GraduationCap className="w-12 h-12 text-slate-400" />
</div>
</div>
<h2 className="text-xl font-semibold text-slate-800 mb-2">Companion Dev</h2>
<p className="text-slate-600 mb-4">
Lesson-Modus Entwicklung fuer strukturiertes Lernen.
</p>
<div className="inline-flex items-center gap-2 px-4 py-2 bg-amber-50 border border-amber-200 rounded-lg text-amber-700">
<Construction className="w-4 h-4" />
<span className="text-sm font-medium">In Entwicklung</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { Suspense } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { getModuleByHref } from '@/lib/navigation'
import { CompanionDashboard } from '@/components/companion/CompanionDashboard'
import { GraduationCap } from 'lucide-react'
function LoadingFallback() {
return (
<div className="space-y-6">
{/* Header Skeleton */}
<div className="flex items-center justify-between">
<div className="h-12 w-80 bg-slate-200 rounded-xl animate-pulse" />
<div className="flex gap-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-10 w-10 bg-slate-200 rounded-lg animate-pulse" />
))}
</div>
</div>
{/* Phase Timeline Skeleton */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="h-4 w-24 bg-slate-200 rounded mb-4 animate-pulse" />
<div className="flex gap-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-10 h-10 bg-slate-200 rounded-full animate-pulse" />
{i < 5 && <div className="w-8 h-1 bg-slate-200 animate-pulse" />}
</div>
))}
</div>
</div>
{/* Stats Skeleton */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white border border-slate-200 rounded-xl p-4">
<div className="h-4 w-16 bg-slate-200 rounded mb-2 animate-pulse" />
<div className="h-8 w-12 bg-slate-200 rounded animate-pulse" />
</div>
))}
</div>
{/* Content Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white border border-slate-200 rounded-xl p-6 h-64 animate-pulse" />
<div className="bg-white border border-slate-200 rounded-xl p-6 h-64 animate-pulse" />
</div>
</div>
)
}
export default function CompanionPage() {
const moduleInfo = getModuleByHref('/education/companion')
return (
<div className="space-y-6">
{/* Page Purpose Header */}
{moduleInfo && (
<PagePurpose
title={moduleInfo.module.name}
purpose={moduleInfo.module.purpose}
audience={moduleInfo.module.audience}
collapsible={true}
defaultCollapsed={true}
/>
)}
{/* Main Companion Dashboard */}
<Suspense fallback={<LoadingFallback />}>
<CompanionDashboard />
</Suspense>
</div>
)
}

View File

@@ -1,11 +1,12 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useRouter, usePathname } from 'next/navigation'
import { SDKProvider } from '@/lib/sdk'
import { SDKSidebar } from '@/components/sdk/Sidebar/SDKSidebar'
import { CommandBar } from '@/components/sdk/CommandBar'
import { SDKPipelineSidebar } from '@/components/sdk/SDKPipelineSidebar'
import { ComplianceAdvisorWidget } from '@/components/sdk/ComplianceAdvisorWidget'
import { useSDK } from '@/lib/sdk'
import { getStoredRole } from '@/lib/roles'
@@ -86,6 +87,10 @@ function SDKHeader({ sidebarCollapsed }: { sidebarCollapsed: boolean }) {
function SDKInnerLayout({ children }: { children: React.ReactNode }) {
const { isCommandBarOpen, setCommandBarOpen } = useSDK()
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const pathname = usePathname()
// Extract current step from pathname (e.g., /sdk/vvt -> vvt)
const currentStep = pathname?.split('/').pop() || 'default'
// Load collapsed state from localStorage
useEffect(() => {
@@ -123,6 +128,9 @@ function SDKInnerLayout({ children }: { children: React.ReactNode }) {
{/* Pipeline Sidebar (FAB on mobile/tablet, fixed on desktop xl+) */}
<SDKPipelineSidebar />
{/* Compliance Advisor Widget */}
<ComplianceAdvisorWidget currentStep={currentStep} />
</div>
)
}

View File

@@ -0,0 +1,596 @@
'use client'
/**
* UCCA System Documentation Page (SDK Version)
*
* Displays architecture documentation, auditor information,
* and transparency data for the UCCA compliance system.
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
// ============================================================================
// Types
// ============================================================================
type DocTab = 'overview' | 'architecture' | 'auditor' | 'rules' | 'legal-corpus'
interface Rule {
code: string
category: string
title: string
description: string
severity: string
gdpr_ref: string
rationale?: string
risk_add?: number
}
interface Pattern {
id: string
title: string
description: string
benefit?: string
effort?: string
risk_reduction?: number
}
interface Control {
id: string
title: string
description: string
gdpr_ref?: string
effort?: string
}
// ============================================================================
// API Configuration
// ============================================================================
const API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'https://macmini:8090'
// ============================================================================
// Main Component
// ============================================================================
export default function DocumentationPage() {
const [activeTab, setActiveTab] = useState<DocTab>('overview')
const [rules, setRules] = useState<Rule[]>([])
const [patterns, setPatterns] = useState<Pattern[]>([])
const [controls, setControls] = useState<Control[]>([])
const [policyVersion, setPolicyVersion] = useState<string>('')
const [loading, setLoading] = useState(false)
useEffect(() => {
const fetchData = async () => {
setLoading(true)
try {
const rulesRes = await fetch(`${API_BASE}/sdk/v1/ucca/rules`, {
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000000' }
})
if (rulesRes.ok) {
const rulesData = await rulesRes.json()
setRules(rulesData.rules || [])
setPolicyVersion(rulesData.policy_version || '')
}
const patternsRes = await fetch(`${API_BASE}/sdk/v1/ucca/patterns`, {
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000000' }
})
if (patternsRes.ok) {
const patternsData = await patternsRes.json()
setPatterns(patternsData.patterns || [])
}
const controlsRes = await fetch(`${API_BASE}/sdk/v1/ucca/controls`, {
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000000' }
})
if (controlsRes.ok) {
const controlsData = await controlsRes.json()
setControls(controlsData.controls || [])
}
} catch (error) {
console.error('Failed to fetch documentation data:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
// ============================================================================
// Tab Content Renderers
// ============================================================================
const renderOverview = () => (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="font-semibold text-slate-800 mb-2">Deterministische Regeln</h3>
<div className="text-3xl font-bold text-purple-600">{rules.length}</div>
<p className="text-sm text-slate-500 mt-2">
Alle Entscheidungen basieren auf transparenten, nachvollziehbaren Regeln.
</p>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="font-semibold text-slate-800 mb-2">Architektur-Patterns</h3>
<div className="text-3xl font-bold text-green-600">{patterns.length}</div>
<p className="text-sm text-slate-500 mt-2">
Best-Practice-Loesungen fuer datenschutzkonforme KI-Systeme.
</p>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="font-semibold text-slate-800 mb-2">Compliance-Kontrollen</h3>
<div className="text-3xl font-bold text-blue-600">{controls.length}</div>
<p className="text-sm text-slate-500 mt-2">
Technische und organisatorische Massnahmen.
</p>
</div>
</div>
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-6">
<h3 className="font-semibold text-purple-800 text-lg mb-4">Was ist UCCA?</h3>
<div className="prose prose-sm max-w-none text-slate-700">
<p>
<strong>UCCA (Use-Case Compliance & Feasibility Advisor)</strong> ist ein deterministisches
Compliance-Pruefwerkzeug, das Organisationen bei der Bewertung geplanter KI-Anwendungsfaelle
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit unterstuetzt.
</p>
<h4 className="text-purple-700 mt-4">Kernprinzipien</h4>
<ul className="space-y-2">
<li>
<strong>Determinismus:</strong> Alle Entscheidungen basieren auf transparenten Regeln.
Die KI trifft KEINE autonomen Entscheidungen.
</li>
<li>
<strong>Transparenz:</strong> Alle Regeln, Kontrollen und Patterns sind einsehbar.
</li>
<li>
<strong>Human-in-the-Loop:</strong> Kritische Entscheidungen erfordern immer
menschliche Pruefung durch DSB oder Legal.
</li>
<li>
<strong>Rechtsgrundlage:</strong> Jede Regel referenziert konkrete DSGVO-Artikel.
</li>
</ul>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-6">
<h3 className="font-semibold text-amber-800 mb-3">
Wichtiger Hinweis zur KI-Nutzung
</h3>
<p className="text-amber-700">
Das System verwendet KI (LLM) <strong>ausschliesslich zur Erklaerung</strong> bereits
getroffener Regelentscheidungen. Die eigentliche Compliance-Bewertung erfolgt
<strong> rein deterministisch</strong> durch die Policy Engine. BLOCK-Entscheidungen
koennen NICHT durch KI ueberschrieben werden.
</p>
</div>
</div>
)
const renderArchitecture = () => (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-800 text-lg mb-4">Systemarchitektur</h3>
<div className="bg-slate-900 text-green-400 p-6 rounded-lg font-mono text-sm overflow-x-auto">
<pre>{`
┌─────────────────────────────────────────────────────────────────────┐
│ Frontend (Next.js) │
│ admin-v2:3000/sdk/advisory-board │
└───────────────────────────────────┬─────────────────────────────────┘
│ HTTPS
┌─────────────────────────────────────────────────────────────────────┐
│ AI Compliance SDK (Go) │
│ Port 8090 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Policy Engine │ │
│ │ ┌───────────────────────────────────────────────────────┐ │ │
│ │ │ YAML-basierte Regeln (ucca_policy_v1.yaml) │ │ │
│ │ │ ~45 Regeln in 7 Kategorien │ │ │
│ │ │ Deterministisch - Kein LLM in Entscheidungslogik │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │ Controls │ │ Patterns │ │ Examples │ │ │
│ │ │ Library │ │ Library │ │ Library │ │ │
│ │ └────────────────┘ └────────────────┘ └────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ LLM Integration │ │ Legal RAG │──────┐ │
│ │ (nur Explain) │ │ Client │ │ │
│ └──────────────────┘ └──────────────────┘ │ │
└─────────────────────────────┬────────────────────┼──────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Datenschicht │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ PostgreSQL │ │ Qdrant │ │
│ │ (Assessments, │ │ (Legal Corpus, │ │
│ │ Escalations) │ │ 2,274 Chunks) │ │
│ └────────────────────┘ └────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
`}</pre>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<h4 className="font-medium text-blue-800 mb-2">Datenfluss</h4>
<ol className="text-sm text-blue-700 list-decimal list-inside space-y-1">
<li>Benutzer beschreibt Use Case im Frontend</li>
<li>Policy Engine evaluiert gegen alle Regeln</li>
<li>Ergebnis mit Controls + Patterns zurueck</li>
<li>Optional: LLM erklaert das Ergebnis</li>
<li>Bei Risiko: Automatische Eskalation</li>
</ol>
</div>
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<h4 className="font-medium text-green-800 mb-2">Sicherheitsmerkmale</h4>
<ul className="text-sm text-green-700 list-disc list-inside space-y-1">
<li>TLS 1.3 Verschluesselung</li>
<li>RBAC mit Tenant-Isolation</li>
<li>JWT-basierte Authentifizierung</li>
<li>Audit-Trail aller Aktionen</li>
<li>Keine Rohtext-Speicherung (nur Hash)</li>
</ul>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-800 text-lg mb-4">Eskalations-Workflow</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-medium text-slate-600">Level</th>
<th className="text-left py-2 px-3 font-medium text-slate-600">Ausloeser</th>
<th className="text-left py-2 px-3 font-medium text-slate-600">Pruefer</th>
<th className="text-left py-2 px-3 font-medium text-slate-600">SLA</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-slate-100 bg-green-50">
<td className="py-2 px-3 font-medium text-green-700">E0</td>
<td className="py-2 px-3 text-slate-600">Nur INFO-Regeln, Risiko &lt; 20</td>
<td className="py-2 px-3 text-slate-600">Automatisch</td>
<td className="py-2 px-3 text-slate-600">-</td>
</tr>
<tr className="border-b border-slate-100 bg-yellow-50">
<td className="py-2 px-3 font-medium text-yellow-700">E1</td>
<td className="py-2 px-3 text-slate-600">WARN-Regeln, Risiko 20-40</td>
<td className="py-2 px-3 text-slate-600">Team-Lead</td>
<td className="py-2 px-3 text-slate-600">24h / 72h</td>
</tr>
<tr className="border-b border-slate-100 bg-orange-50">
<td className="py-2 px-3 font-medium text-orange-700">E2</td>
<td className="py-2 px-3 text-slate-600">Art. 9 Daten, DSFA empfohlen, Risiko 40-60</td>
<td className="py-2 px-3 text-slate-600">DSB</td>
<td className="py-2 px-3 text-slate-600">8h / 48h</td>
</tr>
<tr className="bg-red-50">
<td className="py-2 px-3 font-medium text-red-700">E3</td>
<td className="py-2 px-3 text-slate-600">BLOCK-Regeln, Art. 22, Risiko &gt; 60</td>
<td className="py-2 px-3 text-slate-600">DSB + Legal</td>
<td className="py-2 px-3 text-slate-600">4h / 24h</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
)
const renderAuditorInfo = () => (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-800 text-lg mb-4">
Dokumentation fuer externe Auditoren
</h3>
<p className="text-slate-600 mb-4">
Diese Dokumentation erfuellt die Anforderungen nach Art. 30 DSGVO (Verzeichnis von
Verarbeitungstaetigkeiten) und dient als Grundlage fuer Audits nach Art. 32 DSGVO.
</p>
<div className="space-y-4">
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<h4 className="font-medium text-slate-800 mb-2">1. Zweck des Systems</h4>
<p className="text-sm text-slate-600">
UCCA ist ein Compliance-Pruefwerkzeug zur Bewertung geplanter KI-Anwendungsfaelle
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit.
</p>
</div>
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<h4 className="font-medium text-slate-800 mb-2">2. Rechtsgrundlage</h4>
<ul className="text-sm text-slate-600 list-disc list-inside space-y-1">
<li><strong>Art. 6 Abs. 1 lit. c DSGVO</strong> - Erfuellung rechtlicher Verpflichtungen</li>
<li><strong>Art. 6 Abs. 1 lit. f DSGVO</strong> - Berechtigte Interessen (Compliance-Management)</li>
</ul>
</div>
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<h4 className="font-medium text-slate-800 mb-2">3. Verarbeitete Datenkategorien</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm mt-2">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-2 font-medium text-slate-600">Kategorie</th>
<th className="text-left py-2 px-2 font-medium text-slate-600">Speicherung</th>
<th className="text-left py-2 px-2 font-medium text-slate-600">Aufbewahrung</th>
</tr>
</thead>
<tbody className="text-slate-600">
<tr className="border-b border-slate-100">
<td className="py-2 px-2">Use-Case-Beschreibung</td>
<td className="py-2 px-2">Nur Hash (SHA-256)</td>
<td className="py-2 px-2">10 Jahre</td>
</tr>
<tr className="border-b border-slate-100">
<td className="py-2 px-2">Bewertungsergebnis</td>
<td className="py-2 px-2">Vollstaendig</td>
<td className="py-2 px-2">10 Jahre</td>
</tr>
<tr className="border-b border-slate-100">
<td className="py-2 px-2">Audit-Trail</td>
<td className="py-2 px-2">Vollstaendig</td>
<td className="py-2 px-2">10 Jahre</td>
</tr>
<tr>
<td className="py-2 px-2">Eskalations-Historie</td>
<td className="py-2 px-2">Vollstaendig</td>
<td className="py-2 px-2">10 Jahre</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<h4 className="font-medium text-slate-800 mb-2">4. Keine autonomen KI-Entscheidungen</h4>
<p className="text-sm text-slate-600">
Das System trifft <strong>KEINE automatisierten Einzelentscheidungen</strong> im Sinne
von Art. 22 DSGVO, da:
</p>
<ul className="text-sm text-slate-600 list-disc list-inside mt-2 space-y-1">
<li>Regelauswertung ist keine rechtlich bindende Entscheidung</li>
<li>Alle kritischen Faelle werden menschlich geprueft (E1-E3)</li>
<li>BLOCK-Entscheidungen erfordern immer menschliche Freigabe</li>
<li>Betroffene haben Anfechtungsmoeglichkeit ueber Eskalation</li>
</ul>
</div>
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<h4 className="font-medium text-green-800 mb-2">5. Technische und Organisatorische Massnahmen</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<strong className="text-green-700">Vertraulichkeit</strong>
<ul className="text-green-700 list-disc list-inside mt-1">
<li>RBAC mit Tenant-Isolation</li>
<li>TLS 1.3 Verschluesselung</li>
<li>AES-256 at rest</li>
</ul>
</div>
<div>
<strong className="text-green-700">Integritaet</strong>
<ul className="text-green-700 list-disc list-inside mt-1">
<li>Unveraenderlicher Audit-Trail</li>
<li>Policy-Versionierung</li>
<li>Input-Validierung</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
)
const renderRulesTab = () => (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-800 text-lg">Regel-Katalog</h3>
<p className="text-sm text-slate-500">Policy Version: {policyVersion}</p>
</div>
<div className="text-sm text-slate-500">
{rules.length} Regeln insgesamt
</div>
</div>
{loading ? (
<div className="text-center py-8 text-slate-500">Lade Regeln...</div>
) : (
<div className="space-y-4">
{Array.from(new Set(rules.map(r => r.category))).map(category => (
<div key={category} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200">
<h4 className="font-medium text-slate-800">{category}</h4>
<p className="text-xs text-slate-500">
{rules.filter(r => r.category === category).length} Regeln
</p>
</div>
<div className="divide-y divide-slate-100">
{rules.filter(r => r.category === category).map(rule => (
<div key={rule.code} className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-slate-500">{rule.code}</span>
<span className={`text-xs px-2 py-0.5 rounded ${
rule.severity === 'BLOCK' ? 'bg-red-100 text-red-700' :
rule.severity === 'WARN' ? 'bg-yellow-100 text-yellow-700' :
'bg-blue-100 text-blue-700'
}`}>
{rule.severity}
</span>
</div>
<div className="font-medium text-slate-800 mt-1">{rule.title}</div>
<div className="text-sm text-slate-600 mt-1">{rule.description}</div>
{rule.gdpr_ref && (
<div className="text-xs text-slate-500 mt-2">{rule.gdpr_ref}</div>
)}
</div>
{rule.risk_add && (
<div className="text-sm font-medium text-red-600">
+{rule.risk_add}
</div>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)
const renderLegalCorpus = () => (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-800 text-lg mb-4">Legal RAG Corpus</h3>
<p className="text-slate-600 mb-4">
Das System verwendet einen semantischen Suchindex mit 2.274 Chunks aus 19 EU-Regulierungen
fuer rechtsgrundlagenbasierte Erklaerungen.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<h4 className="font-medium text-blue-800 mb-2">Indexierte Regulierungen</h4>
<ul className="text-sm text-blue-700 space-y-1">
<li>DSGVO - Datenschutz-Grundverordnung</li>
<li>AI Act - EU KI-Verordnung</li>
<li>NIS2 - Cybersicherheits-Richtlinie</li>
<li>CRA - Cyber Resilience Act</li>
<li>Data Act - Datengesetz</li>
<li>DSA/DMA - Digital Services/Markets Act</li>
<li>DPF - EU-US Data Privacy Framework</li>
<li>BSI-TR-03161 - Digitale Identitaeten</li>
</ul>
</div>
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<h4 className="font-medium text-green-800 mb-2">RAG-Funktionalitaet</h4>
<ul className="text-sm text-green-700 space-y-1">
<li>Hybride Suche (Dense + BM25)</li>
<li>Semantisches Chunking</li>
<li>Cross-Encoder Reranking</li>
<li>Artikel-Referenz-Extraktion</li>
<li>Mehrsprachig (DE/EN)</li>
</ul>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-800 mb-4">Verwendung im System</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
1
</div>
<div>
<div className="font-medium text-slate-800">Benutzer fordert Erklaerung an</div>
<div className="text-sm text-slate-600">
Nach der Bewertung kann eine LLM-basierte Erklaerung generiert werden.
</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
2
</div>
<div>
<div className="font-medium text-slate-800">Legal RAG Client sucht relevante Artikel</div>
<div className="text-sm text-slate-600">
Basierend auf den ausgeloesten Regeln werden passende Gesetzestexte gefunden.
</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
3
</div>
<div>
<div className="font-medium text-slate-800">LLM generiert Erklaerung mit Rechtsgrundlage</div>
<div className="text-sm text-slate-600">
Die Erklaerung referenziert konkrete Artikel aus DSGVO, AI Act etc.
</div>
</div>
</div>
</div>
</div>
</div>
)
// ============================================================================
// Tabs Configuration
// ============================================================================
const tabs: { id: DocTab; label: string }[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'architecture', label: 'Architektur' },
{ id: 'auditor', label: 'Fuer Auditoren' },
{ id: 'rules', label: 'Regel-Katalog' },
{ id: 'legal-corpus', label: 'Legal RAG' },
]
// ============================================================================
// Main Render
// ============================================================================
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="bg-white rounded-xl shadow-sm border p-6 flex-1">
<h1 className="text-2xl font-bold text-slate-900">UCCA System-Dokumentation</h1>
<p className="text-slate-500 mt-1">
Transparente Dokumentation des UCCA-Systems fuer Entwickler, Auditoren und Datenschutzbeauftragte.
</p>
</div>
<Link
href="/sdk/advisory-board"
className="ml-4 px-4 py-2 text-sm text-slate-600 hover:text-slate-800 border border-slate-200 rounded-lg hover:bg-slate-50"
>
Zurueck zum Advisory Board
</Link>
</div>
{/* Tab Navigation */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="flex border-b border-slate-200 overflow-x-auto">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors ${
activeTab === tab.id
? 'text-purple-600 border-b-2 border-purple-600 bg-purple-50'
: 'text-slate-600 hover:text-slate-800 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="p-6">
{activeTab === 'overview' && renderOverview()}
{activeTab === 'architecture' && renderArchitecture()}
{activeTab === 'auditor' && renderAuditorInfo()}
{activeTab === 'rules' && renderRulesTab()}
{activeTab === 'legal-corpus' && renderLegalCorpus()}
</div>
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import React, { useState } from 'react'
import Link from 'next/link'
import { useSDK, UseCaseAssessment } from '@/lib/sdk'
// =============================================================================
@@ -601,17 +602,25 @@ export default function AdvisoryBoardPage() {
Erfassen Sie Ihre KI-Anwendungsfälle und erhalten Sie eine erste Compliance-Bewertung
</p>
</div>
{!showWizard && (
<button
onClick={() => setShowWizard(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
<div className="flex items-center gap-3">
<Link
href="/sdk/advisory-board/documentation"
className="inline-flex items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:text-purple-700 hover:bg-purple-50 border border-purple-300 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Neuer Use Case
</button>
)}
UCCA-System Dokumentation ansehen
</Link>
{!showWizard && (
<button
onClick={() => setShowWizard(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Neuer Use Case
</button>
)}
</div>
</div>
{/* Wizard or List */}

View File

@@ -0,0 +1,343 @@
'use client'
/**
* Audit Report Management Page (SDK Version)
*
* Create and manage GDPR audit sessions with PDF report generation.
*/
import { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
import StepHeader from '@/components/sdk/StepHeader/StepHeader'
interface AuditSession {
id: string
name: string
description?: string
auditor_name: string
auditor_email?: string
auditor_organization?: string
status: 'draft' | 'in_progress' | 'completed' | 'archived'
regulation_ids?: string[]
total_items: number
completed_items: number
compliant_count: number
non_compliant_count: number
completion_percentage: number
created_at: string
started_at?: string
completed_at?: string
}
const REGULATIONS = [
{ code: 'GDPR', name: 'DSGVO / GDPR', description: 'EU-Datenschutzgrundverordnung' },
{ code: 'BDSG', name: 'BDSG', description: 'Bundesdatenschutzgesetz' },
{ code: 'TTDSG', name: 'TTDSG', description: 'Telekommunikation-Telemedien-Datenschutz' },
]
export default function AuditReportPage() {
const { state } = useSDK()
const [sessions, setSessions] = useState<AuditSession[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'sessions' | 'new' | 'export'>('sessions')
const [newSession, setNewSession] = useState({
name: '',
description: '',
auditor_name: '',
auditor_email: '',
auditor_organization: '',
regulation_codes: [] as string[],
})
const [creating, setCreating] = useState(false)
const [generatingPdf, setGeneratingPdf] = useState<string | null>(null)
const [pdfLanguage, setPdfLanguage] = useState<'de' | 'en'>('de')
const [statusFilter, setStatusFilter] = useState<string>('all')
useEffect(() => {
fetchSessions()
}, [statusFilter])
const fetchSessions = async () => {
try {
setLoading(true)
const params = statusFilter !== 'all' ? `?status=${statusFilter}` : ''
const res = await fetch(`/api/admin/audit/sessions${params}`)
if (!res.ok) throw new Error('Fehler beim Laden der Audit-Sessions')
const data = await res.json()
setSessions(data.sessions || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const createSession = async () => {
if (!newSession.name || !newSession.auditor_name) {
setError('Name und Auditor-Name sind Pflichtfelder')
return
}
try {
setCreating(true)
const res = await fetch('/api/admin/audit/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSession),
})
if (!res.ok) throw new Error('Fehler beim Erstellen der Session')
setNewSession({ name: '', description: '', auditor_name: '', auditor_email: '', auditor_organization: '', regulation_codes: [] })
setActiveTab('sessions')
fetchSessions()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setCreating(false)
}
}
const startSession = async (sessionId: string) => {
try {
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/start`, { method: 'PUT' })
if (!res.ok) throw new Error('Fehler beim Starten der Session')
fetchSessions()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const completeSession = async (sessionId: string) => {
try {
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/complete`, { method: 'PUT' })
if (!res.ok) throw new Error('Fehler beim Abschliessen der Session')
fetchSessions()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const deleteSession = async (sessionId: string) => {
if (!confirm('Session wirklich loeschen?')) return
try {
const res = await fetch(`/api/admin/audit/sessions/${sessionId}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Fehler beim Loeschen der Session')
fetchSessions()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const downloadPdf = async (sessionId: string) => {
try {
setGeneratingPdf(sessionId)
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/pdf?language=${pdfLanguage}`)
if (!res.ok) throw new Error('Fehler bei der PDF-Generierung')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-report-${sessionId}.pdf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setGeneratingPdf(null)
}
}
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
draft: 'bg-slate-100 text-slate-700',
in_progress: 'bg-blue-100 text-blue-700',
completed: 'bg-green-100 text-green-700',
archived: 'bg-purple-100 text-purple-700',
}
const labels: Record<string, string> = {
draft: 'Entwurf',
in_progress: 'In Bearbeitung',
completed: 'Abgeschlossen',
archived: 'Archiviert',
}
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[status] || ''}`}>
{labels[status] || status}
</span>
)
}
const getComplianceColor = (percentage: number) => {
if (percentage >= 80) return 'text-green-600'
if (percentage >= 50) return 'text-yellow-600'
return 'text-red-600'
}
return (
<div className="space-y-6">
<StepHeader stepId="audit-report" showProgress={true} />
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">&times;</button>
</div>
)}
{/* Tabs */}
<div className="flex gap-2">
{(['sessions', 'new', 'export'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeTab === tab ? 'bg-purple-600 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{tab === 'sessions' ? 'Audit-Sessions' : tab === 'new' ? '+ Neues Audit' : 'Export-Optionen'}
</button>
))}
</div>
{/* Sessions Tab */}
{activeTab === 'sessions' && (
<div>
<div className="flex items-center gap-4 mb-4">
<label className="text-sm text-slate-600">Status:</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="px-3 py-2 border border-slate-200 rounded-lg text-sm">
<option value="all">Alle</option>
<option value="draft">Entwurf</option>
<option value="in_progress">In Bearbeitung</option>
<option value="completed">Abgeschlossen</option>
<option value="archived">Archiviert</option>
</select>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Audit-Sessions...</div>
) : sessions.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Audit-Sessions vorhanden</h3>
<p className="text-sm text-slate-500 mb-4">Erstellen Sie ein neues Audit, um mit der DSGVO-Pruefung zu beginnen.</p>
<button onClick={() => setActiveTab('new')} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">Neues Audit erstellen</button>
</div>
) : (
<div className="space-y-4">
{sessions.map((session) => (
<div key={session.id} className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-3">
<h3 className="font-semibold text-slate-900">{session.name}</h3>
{getStatusBadge(session.status)}
</div>
{session.description && <p className="text-sm text-slate-500 mt-1">{session.description}</p>}
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span>Auditor: {session.auditor_name}</span>
{session.auditor_organization && <span>| {session.auditor_organization}</span>}
<span>| Erstellt: {new Date(session.created_at).toLocaleDateString('de-DE')}</span>
</div>
</div>
<div className="text-right">
<div className={`text-2xl font-bold ${getComplianceColor(session.completion_percentage)}`}>{session.completion_percentage}%</div>
<div className="text-xs text-slate-500">{session.completed_items} / {session.total_items} Punkte</div>
</div>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden mb-4">
<div className={`h-full transition-all ${session.completion_percentage >= 80 ? 'bg-green-500' : session.completion_percentage >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`} style={{ width: `${session.completion_percentage}%` }} />
</div>
<div className="grid grid-cols-3 gap-4 mb-4 text-sm">
<div className="text-center p-3 bg-green-50 rounded-lg"><div className="font-semibold text-green-700">{session.compliant_count}</div><div className="text-xs text-green-600">Konform</div></div>
<div className="text-center p-3 bg-red-50 rounded-lg"><div className="font-semibold text-red-700">{session.non_compliant_count}</div><div className="text-xs text-red-600">Nicht Konform</div></div>
<div className="text-center p-3 bg-slate-50 rounded-lg"><div className="font-semibold text-slate-700">{session.total_items - session.completed_items}</div><div className="text-xs text-slate-600">Ausstehend</div></div>
</div>
<div className="flex items-center gap-2 pt-4 border-t border-slate-100">
{session.status === 'draft' && <button onClick={() => startSession(session.id)} className="px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700">Audit starten</button>}
{session.status === 'in_progress' && <button onClick={() => completeSession(session.id)} className="px-3 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700">Abschliessen</button>}
{(session.status === 'completed' || session.status === 'in_progress') && (
<button onClick={() => downloadPdf(session.id)} disabled={generatingPdf === session.id} className="px-3 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 disabled:opacity-50">
{generatingPdf === session.id ? 'Generiere PDF...' : 'PDF-Report'}
</button>
)}
{(session.status === 'draft' || session.status === 'archived') && <button onClick={() => deleteSession(session.id)} className="px-3 py-2 text-red-600 text-sm hover:text-red-700">Loeschen</button>}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* New Session Tab */}
{activeTab === 'new' && (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Neues Audit erstellen</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Audit-Name *</label>
<input type="text" value={newSession.name} onChange={(e) => setNewSession({ ...newSession, name: e.target.value })} placeholder="z.B. DSGVO Jahresaudit 2026" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea value={newSession.description} onChange={(e) => setNewSession({ ...newSession, description: e.target.value })} rows={3} placeholder="Optionale Beschreibung" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Auditor Name *</label>
<input type="text" value={newSession.auditor_name} onChange={(e) => setNewSession({ ...newSession, auditor_name: e.target.value })} placeholder="Name des Auditors" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail</label>
<input type="email" value={newSession.auditor_email} onChange={(e) => setNewSession({ ...newSession, auditor_email: e.target.value })} placeholder="auditor@example.com" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Organisation</label>
<input type="text" value={newSession.auditor_organization} onChange={(e) => setNewSession({ ...newSession, auditor_organization: e.target.value })} placeholder="z.B. TUeV" className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Zu pruefende Regelwerke</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{REGULATIONS.map((reg) => (
<label key={reg.code} className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${newSession.regulation_codes.includes(reg.code) ? 'border-purple-500 bg-purple-50' : 'border-slate-200 hover:border-slate-300'}`}>
<input type="checkbox" checked={newSession.regulation_codes.includes(reg.code)} onChange={(e) => { if (e.target.checked) { setNewSession({ ...newSession, regulation_codes: [...newSession.regulation_codes, reg.code] }) } else { setNewSession({ ...newSession, regulation_codes: newSession.regulation_codes.filter((c) => c !== reg.code) }) } }} className="w-4 h-4 text-purple-600" />
<div><div className="font-medium text-slate-800">{reg.name}</div><div className="text-xs text-slate-500">{reg.description}</div></div>
</label>
))}
</div>
</div>
<div className="pt-4 border-t border-slate-100">
<button onClick={createSession} disabled={creating} className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{creating ? 'Erstelle...' : 'Audit-Session erstellen'}
</button>
</div>
</div>
</div>
)}
{/* Export Tab */}
{activeTab === 'export' && (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">PDF-Export Einstellungen</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Sprache</label>
<div className="flex gap-3">
<label className={`flex items-center gap-2 px-4 py-2 border rounded-lg cursor-pointer ${pdfLanguage === 'de' ? 'border-purple-500 bg-purple-50' : 'border-slate-200'}`}>
<input type="radio" checked={pdfLanguage === 'de'} onChange={() => setPdfLanguage('de')} className="w-4 h-4 text-purple-600" />
<span>Deutsch</span>
</label>
<label className={`flex items-center gap-2 px-4 py-2 border rounded-lg cursor-pointer ${pdfLanguage === 'en' ? 'border-purple-500 bg-purple-50' : 'border-slate-200'}`}>
<input type="radio" checked={pdfLanguage === 'en'} onChange={() => setPdfLanguage('en')} className="w-4 h-4 text-purple-600" />
<span>English</span>
</label>
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,521 @@
'use client'
/**
* Compliance Hub Page (SDK Version - Zusatzmodul)
*
* Central compliance management dashboard with:
* - Compliance Score Overview
* - Quick Access to all compliance modules (SDK paths)
* - Control-Mappings with statistics
* - Audit Findings
* - Regulations overview
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
// Types
interface DashboardData {
compliance_score: number
total_regulations: number
total_requirements: number
total_controls: number
controls_by_status: Record<string, number>
controls_by_domain: Record<string, Record<string, number>>
total_evidence: number
evidence_by_status: Record<string, number>
total_risks: number
risks_by_level: Record<string, number>
}
interface Regulation {
id: string
code: string
name: string
full_name: string
regulation_type: string
effective_date: string | null
description: string
requirement_count: number
}
interface MappingsData {
total: number
by_regulation: Record<string, number>
}
interface FindingsData {
major_count: number
minor_count: number
ofi_count: number
total: number
open_majors: number
open_minors: number
}
const DOMAIN_LABELS: Record<string, string> = {
gov: 'Governance',
priv: 'Datenschutz',
iam: 'Identity & Access',
crypto: 'Kryptografie',
sdlc: 'Secure Dev',
ops: 'Operations',
ai: 'KI-spezifisch',
cra: 'Supply Chain',
aud: 'Audit',
}
export default function ComplianceHubPage() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [regulations, setRegulations] = useState<Regulation[]>([])
const [mappings, setMappings] = useState<MappingsData | null>(null)
const [findings, setFindings] = useState<FindingsData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [seeding, setSeeding] = useState(false)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
setError(null)
try {
const [dashboardRes, regulationsRes, mappingsRes, findingsRes] = await Promise.all([
fetch('/api/admin/compliance/dashboard'),
fetch('/api/admin/compliance/regulations'),
fetch('/api/admin/compliance/mappings'),
fetch('/api/admin/compliance/isms/findings/summary'),
])
if (dashboardRes.ok) {
setDashboard(await dashboardRes.json())
}
if (regulationsRes.ok) {
const data = await regulationsRes.json()
setRegulations(data.regulations || [])
}
if (mappingsRes.ok) {
const data = await mappingsRes.json()
setMappings(data)
}
if (findingsRes.ok) {
const data = await findingsRes.json()
setFindings(data)
}
} catch (err) {
console.error('Failed to load compliance data:', err)
setError('Verbindung zum Backend fehlgeschlagen')
} finally {
setLoading(false)
}
}
const seedDatabase = async () => {
setSeeding(true)
try {
const res = await fetch('/api/admin/compliance/seed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: false }),
})
if (res.ok) {
const result = await res.json()
alert(`Datenbank erfolgreich initialisiert!\n\nRegulations: ${result.counts?.regulations || 0}\nControls: ${result.counts?.controls || 0}\nRequirements: ${result.counts?.requirements || 0}`)
loadData()
} else {
const error = await res.text()
alert(`Fehler beim Seeding: ${error}`)
}
} catch (err) {
console.error('Seeding failed:', err)
alert('Fehler beim Initialisieren der Datenbank')
} finally {
setSeeding(false)
}
}
const score = dashboard?.compliance_score || 0
const scoreColor = score >= 80 ? 'text-green-600' : score >= 60 ? 'text-yellow-600' : 'text-red-600'
const scoreBgColor = score >= 80 ? 'bg-green-500' : score >= 60 ? 'bg-yellow-500' : 'bg-red-500'
return (
<div className="space-y-6">
{/* Title Card (Zusatzmodul - no StepHeader) */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h1 className="text-2xl font-bold text-slate-900">Compliance Hub</h1>
<p className="text-slate-500 mt-1">
Zentrale Verwaltung aller Compliance-Anforderungen nach DSGVO, AI Act, BSI TR-03161 und weiteren Regulierungen.
</p>
</div>
{/* Error Banner */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<svg className="w-5 h-5 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-red-700">{error}</span>
<button onClick={loadData} className="ml-auto text-red-600 hover:text-red-800 text-sm font-medium">
Erneut versuchen
</button>
</div>
)}
{/* Seed Button if no data */}
{!loading && (dashboard?.total_controls || 0) === 0 && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-yellow-800">Keine Compliance-Daten vorhanden</p>
<p className="text-sm text-yellow-700">Initialisieren Sie die Datenbank mit den Seed-Daten.</p>
</div>
<button
onClick={seedDatabase}
disabled={seeding}
className="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 disabled:opacity-50"
>
{seeding ? 'Initialisiere...' : 'Datenbank initialisieren'}
</button>
</div>
</div>
)}
{/* Quick Actions */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schnellzugriff</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
<Link
href="/sdk/audit-checklist"
className="p-4 rounded-lg border border-slate-200 hover:border-purple-500 hover:bg-purple-50 transition-colors text-center"
>
<div className="text-purple-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Audit Checkliste</p>
<p className="text-xs text-slate-500 mt-1">{dashboard?.total_requirements || '...'} Anforderungen</p>
</Link>
<Link
href="/sdk/controls"
className="p-4 rounded-lg border border-slate-200 hover:border-green-500 hover:bg-green-50 transition-colors text-center"
>
<div className="text-green-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Controls</p>
<p className="text-xs text-slate-500 mt-1">{dashboard?.total_controls || '...'} Massnahmen</p>
</Link>
<Link
href="/sdk/evidence"
className="p-4 rounded-lg border border-slate-200 hover:border-blue-500 hover:bg-blue-50 transition-colors text-center"
>
<div className="text-blue-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Evidence</p>
<p className="text-xs text-slate-500 mt-1">Nachweise</p>
</Link>
<Link
href="/sdk/risks"
className="p-4 rounded-lg border border-slate-200 hover:border-red-500 hover:bg-red-50 transition-colors text-center"
>
<div className="text-red-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Risk Matrix</p>
<p className="text-xs text-slate-500 mt-1">5x5 Risiken</p>
</Link>
<Link
href="/sdk/modules"
className="p-4 rounded-lg border border-slate-200 hover:border-pink-500 hover:bg-pink-50 transition-colors text-center"
>
<div className="text-pink-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Service Registry</p>
<p className="text-xs text-slate-500 mt-1">Module</p>
</Link>
<Link
href="/sdk/audit-report"
className="p-4 rounded-lg border border-slate-200 hover:border-orange-500 hover:bg-orange-50 transition-colors text-center"
>
<div className="text-orange-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 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>
<p className="font-medium text-slate-900 text-sm">Audit Report</p>
<p className="text-xs text-slate-500 mt-1">PDF Export</p>
</Link>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600" />
</div>
) : (
<>
{/* Score and Stats Row */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Compliance Score</h3>
<div className={`text-5xl font-bold ${scoreColor}`}>
{score.toFixed(0)}%
</div>
<div className="mt-4 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${scoreBgColor}`}
style={{ width: `${score}%` }}
/>
</div>
<p className="mt-2 text-sm text-slate-500">
{dashboard?.controls_by_status?.pass || 0} von {dashboard?.total_controls || 0} Controls bestanden
</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Verordnungen</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_regulations || 0}</p>
</div>
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<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="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="mt-2 text-sm text-slate-500">{dashboard?.total_requirements || 0} Anforderungen</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Controls</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_controls || 0}</p>
</div>
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.controls_by_status?.pass || 0} bestanden</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Nachweise</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_evidence || 0}</p>
</div>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<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="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.evidence_by_status?.valid || 0} aktiv</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Risiken</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_risks || 0}</p>
</div>
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">
{(dashboard?.risks_by_level?.high || 0) + (dashboard?.risks_by_level?.critical || 0)} kritisch
</p>
</div>
</div>
{/* Control-Mappings & Findings Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Control-Mappings</h3>
<Link href="/sdk/controls" className="text-sm text-purple-600 hover:text-purple-700">
Alle anzeigen
</Link>
</div>
<div className="flex items-center gap-6 mb-4">
<div>
<p className="text-4xl font-bold text-purple-600">{mappings?.total || 474}</p>
<p className="text-sm text-slate-500">Mappings gesamt</p>
</div>
<div className="flex-1 h-16 bg-slate-50 rounded-lg p-3">
<p className="text-xs text-slate-500 mb-1">Nach Verordnung</p>
<div className="flex gap-1 flex-wrap">
{mappings?.by_regulation && Object.entries(mappings.by_regulation).slice(0, 5).map(([reg, count]) => (
<span key={reg} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
{reg}: {count}
</span>
))}
{!mappings?.by_regulation && (
<>
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">GDPR: 180</span>
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">AI Act: 95</span>
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">BSI: 120</span>
<span className="px-2 py-0.5 bg-orange-100 text-orange-700 rounded text-xs">CRA: 79</span>
</>
)}
</div>
</div>
</div>
<p className="text-sm text-slate-600">
Automatisch generierte Verknuepfungen zwischen {dashboard?.total_controls || 44} Controls
und {dashboard?.total_requirements || 558} Anforderungen aus {dashboard?.total_regulations || 19} Verordnungen.
</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Audit Findings</h3>
<Link href="/sdk/audit-checklist" className="text-sm text-purple-600 hover:text-purple-700">
Audit Checkliste
</Link>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-sm font-medium text-red-800">Hauptabweichungen</span>
</div>
<p className="text-3xl font-bold text-red-600">{findings?.open_majors || 0}</p>
<p className="text-xs text-red-600">offen (blockiert Zertifizierung)</p>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
<span className="text-sm font-medium text-yellow-800">Nebenabweichungen</span>
</div>
<p className="text-3xl font-bold text-yellow-600">{findings?.open_minors || 0}</p>
<p className="text-xs text-yellow-600">offen (erfordert CAPA)</p>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">
Gesamt: {findings?.total || 0} Findings ({findings?.major_count || 0} Major, {findings?.minor_count || 0} Minor, {findings?.ofi_count || 0} OFI)
</span>
{(findings?.open_majors || 0) === 0 ? (
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">
Zertifizierung moeglich
</span>
) : (
<span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
Zertifizierung blockiert
</span>
)}
</div>
</div>
</div>
{/* Domain Chart */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Controls nach Domain</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(dashboard?.controls_by_domain || {}).map(([domain, stats]) => {
const total = stats.total || 0
const pass = stats.pass || 0
const partial = stats.partial || 0
const passPercent = total > 0 ? ((pass + partial * 0.5) / total) * 100 : 0
return (
<div key={domain} className="p-3 rounded-lg bg-slate-50">
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-slate-700">
{DOMAIN_LABELS[domain] || domain.toUpperCase()}
</span>
<span className="text-slate-500">
{pass}/{total} ({passPercent.toFixed(0)}%)
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden flex">
<div className="bg-green-500 h-full" style={{ width: `${(pass / total) * 100}%` }} />
<div className="bg-yellow-500 h-full" style={{ width: `${(partial / total) * 100}%` }} />
</div>
</div>
)
})}
</div>
</div>
{/* Regulations Table */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Verordnungen & Standards ({regulations.length})</h3>
<button onClick={loadData} className="text-sm text-purple-600 hover:text-purple-700">
Aktualisieren
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Code</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Anforderungen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{regulations.slice(0, 15).map((reg) => (
<tr key={reg.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-purple-600">{reg.code}</span>
</td>
<td className="px-4 py-3">
<p className="font-medium text-slate-900">{reg.name}</p>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${
reg.regulation_type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' :
reg.regulation_type === 'eu_directive' ? 'bg-purple-100 text-purple-700' :
reg.regulation_type === 'bsi_standard' ? 'bg-green-100 text-green-700' :
'bg-slate-100 text-slate-700'
}`}>
{reg.regulation_type === 'eu_regulation' ? 'EU-VO' :
reg.regulation_type === 'eu_directive' ? 'EU-RL' :
reg.regulation_type === 'bsi_standard' ? 'BSI' :
reg.regulation_type === 'de_law' ? 'DE' : reg.regulation_type}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-medium">{reg.requirement_count}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,385 @@
'use client'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader/StepHeader'
import {
ScopeOverviewTab,
ScopeWizardTab,
ScopeDecisionTab,
ScopeExportTab
} from '@/components/sdk/compliance-scope'
import type {
ComplianceScopeState,
ScopeProfilingAnswer,
ScopeDecision
} from '@/lib/sdk/compliance-scope-types'
import {
createEmptyScopeState,
STORAGE_KEY
} from '@/lib/sdk/compliance-scope-types'
import { complianceScopeEngine } from '@/lib/sdk/compliance-scope-engine'
type TabId = 'overview' | 'wizard' | 'decision' | 'export'
const TABS: { id: TabId; label: string; icon: string }[] = [
{ id: 'overview', label: 'Uebersicht', icon: '📊' },
{ id: 'wizard', label: 'Scope-Profiling', icon: '📋' },
{ id: 'decision', label: 'Scope-Entscheidung', icon: '⚖️' },
{ id: 'export', label: 'Export', icon: '📤' },
]
export default function ComplianceScopePage() {
const { state: sdkState, dispatch } = useSDK()
// Active tab state
const [activeTab, setActiveTab] = useState<TabId>('overview')
// Local scope state
const [scopeState, setScopeState] = useState<ComplianceScopeState>(() => {
// Try to load from SDK context first
if (sdkState.complianceScope) {
return sdkState.complianceScope
}
return createEmptyScopeState()
})
// Loading state
const [isLoading, setIsLoading] = useState(true)
const [isEvaluating, setIsEvaluating] = useState(false)
// Load from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as ComplianceScopeState
setScopeState(parsed)
// Also sync to SDK context
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: parsed })
}
} catch (error) {
console.error('Failed to load compliance scope state from localStorage:', error)
} finally {
setIsLoading(false)
}
}, [dispatch])
// Save to localStorage and SDK context whenever state changes
useEffect(() => {
if (!isLoading) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(scopeState))
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: scopeState })
} catch (error) {
console.error('Failed to save compliance scope state:', error)
}
}
}, [scopeState, isLoading, dispatch])
// Handle answers change from wizard
const handleAnswersChange = useCallback((answers: Record<string, ScopeProfilingAnswer>) => {
setScopeState(prev => ({
...prev,
answers,
lastModified: new Date().toISOString(),
}))
}, [])
// Handle evaluate button click
const handleEvaluate = useCallback(async () => {
setIsEvaluating(true)
try {
// Run the compliance scope engine
const decision = complianceScopeEngine.evaluate(scopeState.answers)
// Update state with decision
setScopeState(prev => ({
...prev,
decision,
lastModified: new Date().toISOString(),
}))
// Switch to decision tab to show results
setActiveTab('decision')
} catch (error) {
console.error('Failed to evaluate compliance scope:', error)
// Optionally show error toast/notification
} finally {
setIsEvaluating(false)
}
}, [scopeState.answers])
// Handle start profiling from overview
const handleStartProfiling = useCallback(() => {
setActiveTab('wizard')
}, [])
// Handle reset
const handleReset = useCallback(() => {
const emptyState = createEmptyScopeState()
setScopeState(emptyState)
setActiveTab('overview')
localStorage.removeItem(STORAGE_KEY)
}, [])
// Calculate completion statistics
const completionStats = useMemo(() => {
const answers = scopeState.answers
const totalQuestions = Object.keys(answers).length
const answeredQuestions = Object.values(answers).filter(
answer => answer.value !== null && answer.value !== undefined
).length
const completionPercentage = totalQuestions > 0
? Math.round((answeredQuestions / totalQuestions) * 100)
: 0
const isComplete = answeredQuestions === totalQuestions
return {
total: totalQuestions,
answered: answeredQuestions,
percentage: completionPercentage,
isComplete,
}
}, [scopeState.answers])
// Auto-enable evaluation when all questions are answered
const canEvaluate = useMemo(() => {
return completionStats.isComplete
}, [completionStats.isComplete])
if (isLoading) {
return (
<div className="max-w-6xl mx-auto p-6">
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
</div>
)
}
return (
<div className="max-w-6xl mx-auto space-y-6 p-6">
{/* Step Header */}
<StepHeader
stepId="compliance-scope"
title="Compliance Scope Engine"
description="Umfang und Tiefe Ihrer Compliance-Dokumentation bestimmen"
explanation="Die Scope Engine analysiert Ihr Unternehmen anhand von 35 Fragen in 6 Bereichen und bestimmt deterministisch, welche Dokumente in welcher Tiefe benoetigt werden. Das 4-Level-Modell reicht von L1 (Lean Startup) bis L4 (Zertifizierungsbereit). Hard Triggers wie Art. 9 Daten oder Minderjährige heben das Level automatisch an."
tips={[
{
icon: 'lightbulb',
title: 'Deterministisch',
description: 'Alle Entscheidungen sind nachvollziehbar — keine KI, keine Black Box. Jede Empfehlung hat eine auditfähige Begründung.'
},
{
icon: 'info',
title: '4-Level-Modell',
description: 'L1 (Lean Startup) bis L4 (Zertifizierungsbereit). Das Level bestimmt Dokumentationstiefe und -umfang.'
},
{
icon: 'warning',
title: 'Hard Triggers',
description: 'Besondere Datenkategorien (Art. 9), Minderjährige oder Zertifizierungsziele heben das Level automatisch an.'
},
]}
/>
{/* Progress Indicator */}
{completionStats.answered > 0 && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium text-purple-900">
Fortschritt: {completionStats.answered} von {completionStats.total} Fragen beantwortet
</div>
<div className="text-sm font-semibold text-purple-700">
{completionStats.percentage}%
</div>
</div>
<div className="w-full bg-purple-200 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${completionStats.percentage}%` }}
/>
</div>
</div>
)}
{/* Main Content Card */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
{/* Tab Navigation */}
<div className="border-b border-gray-200 px-6">
<nav className="flex gap-6 -mb-px">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-700'
: 'border-transparent text-gray-600 hover:text-gray-800 hover:border-gray-300'
}
`}
>
<span className="text-lg">{tab.icon}</span>
<span>{tab.label}</span>
{tab.id === 'wizard' && completionStats.answered > 0 && (
<span className="ml-1 px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-700">
{completionStats.percentage}%
</span>
)}
{tab.id === 'decision' && scopeState.decision && (
<span className="ml-1 w-2 h-2 rounded-full bg-green-500" />
)}
</button>
))}
</nav>
</div>
{/* Tab Content */}
<div className="p-6">
{activeTab === 'overview' && (
<ScopeOverviewTab
scopeState={scopeState}
completionStats={completionStats}
onStartProfiling={handleStartProfiling}
onReset={handleReset}
onGoToWizard={() => setActiveTab('wizard')}
onGoToDecision={() => setActiveTab('decision')}
onGoToExport={() => setActiveTab('export')}
/>
)}
{activeTab === 'wizard' && (
<ScopeWizardTab
answers={scopeState.answers}
onAnswersChange={handleAnswersChange}
onEvaluate={handleEvaluate}
canEvaluate={canEvaluate}
isEvaluating={isEvaluating}
completionStats={completionStats}
/>
)}
{activeTab === 'decision' && (
<ScopeDecisionTab
decision={scopeState.decision}
answers={scopeState.answers}
onBackToWizard={() => setActiveTab('wizard')}
onGoToExport={() => setActiveTab('export')}
canEvaluate={canEvaluate}
onEvaluate={handleEvaluate}
isEvaluating={isEvaluating}
/>
)}
{activeTab === 'export' && (
<ScopeExportTab
scopeState={scopeState}
onBackToDecision={() => setActiveTab('decision')}
/>
)}
</div>
</div>
{/* Quick Action Buttons (Fixed at bottom on mobile) */}
<div className="sticky bottom-6 flex justify-between items-center gap-4 bg-white rounded-lg border border-gray-200 p-4 shadow-lg">
<div className="flex items-center gap-3">
<div className="text-sm text-gray-600">
{completionStats.isComplete ? (
<span className="flex items-center gap-2 text-green-700">
<span className="text-lg"></span>
<span className="font-medium">Profiling abgeschlossen</span>
</span>
) : (
<span className="flex items-center gap-2">
<span className="text-lg">📋</span>
<span>
{completionStats.answered === 0
? 'Starten Sie mit dem Profiling'
: `Noch ${completionStats.total - completionStats.answered} Fragen offen`
}
</span>
</span>
)}
</div>
</div>
<div className="flex items-center gap-3">
{activeTab !== 'wizard' && completionStats.answered > 0 && (
<button
onClick={() => setActiveTab('wizard')}
className="px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors"
>
Zum Fragebogen
</button>
)}
{canEvaluate && activeTab !== 'decision' && (
<button
onClick={handleEvaluate}
disabled={isEvaluating}
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isEvaluating ? 'Evaluiere...' : 'Scope evaluieren'}
</button>
)}
{scopeState.decision && activeTab !== 'export' && (
<button
onClick={() => setActiveTab('export')}
className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
>
Exportieren
</button>
)}
</div>
</div>
{/* Debug Info (only in development) */}
{process.env.NODE_ENV === 'development' && (
<details className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<summary className="cursor-pointer text-sm font-medium text-gray-700">
Debug Information
</summary>
<div className="mt-3 space-y-2 text-xs font-mono">
<div>
<span className="font-semibold">Active Tab:</span> {activeTab}
</div>
<div>
<span className="font-semibold">Total Answers:</span> {Object.keys(scopeState.answers).length}
</div>
<div>
<span className="font-semibold">Answered:</span> {completionStats.answered} ({completionStats.percentage}%)
</div>
<div>
<span className="font-semibold">Has Decision:</span> {scopeState.decision ? 'Yes' : 'No'}
</div>
{scopeState.decision && (
<>
<div>
<span className="font-semibold">Level:</span> {scopeState.decision.level}
</div>
<div>
<span className="font-semibold">Score:</span> {scopeState.decision.score}
</div>
<div>
<span className="font-semibold">Hard Triggers:</span> {scopeState.decision.hardTriggers.length}
</div>
</>
)}
<div>
<span className="font-semibold">Last Modified:</span> {scopeState.lastModified || 'Never'}
</div>
<div>
<span className="font-semibold">Can Evaluate:</span> {canEvaluate ? 'Yes' : 'No'}
</div>
</div>
</details>
)}
</div>
)
}

View File

@@ -0,0 +1,615 @@
'use client'
/**
* Consent Management Page (SDK Version)
*
* Admin interface for managing:
* - Documents (AGB, Privacy, etc.)
* - Document Versions
* - Email Templates
* - GDPR Processes (Art. 15-21)
* - Statistics
*/
import { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
import StepHeader from '@/components/sdk/StepHeader/StepHeader'
// API Proxy URL (avoids CORS issues)
const API_BASE = '/api/admin/consent'
type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats'
interface Document {
id: string
type: string
name: string
description: string
mandatory: boolean
created_at: string
updated_at: string
}
interface Version {
id: string
document_id: string
version: string
language: string
title: string
content: string
status: string
created_at: string
}
export default function ConsentManagementPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<Tab>('documents')
const [documents, setDocuments] = useState<Document[]>([])
const [versions, setVersions] = useState<Version[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedDocument, setSelectedDocument] = useState<string>('')
// Auth token (in production, get from auth context)
const [authToken, setAuthToken] = useState<string>('')
useEffect(() => {
const token = localStorage.getItem('bp_admin_token')
if (token) {
setAuthToken(token)
}
}, [])
useEffect(() => {
if (activeTab === 'documents') {
loadDocuments()
} else if (activeTab === 'versions' && selectedDocument) {
loadVersions(selectedDocument)
}
}, [activeTab, selectedDocument, authToken])
async function loadDocuments() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/documents`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setDocuments(data.documents || [])
} else {
const errorData = await res.json().catch(() => ({}))
setError(errorData.error || 'Fehler beim Laden der Dokumente')
}
} catch {
setError('Verbindungsfehler zum Server')
} finally {
setLoading(false)
}
}
async function loadVersions(docId: string) {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/documents/${docId}/versions`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setVersions(data.versions || [])
} else {
const errorData = await res.json().catch(() => ({}))
setError(errorData.error || 'Fehler beim Laden der Versionen')
}
} catch {
setError('Verbindungsfehler zum Server')
} finally {
setLoading(false)
}
}
const tabs: { id: Tab; label: string }[] = [
{ id: 'documents', label: 'Dokumente' },
{ id: 'versions', label: 'Versionen' },
{ id: 'emails', label: 'E-Mail Vorlagen' },
{ id: 'gdpr', label: 'DSGVO Prozesse' },
{ id: 'stats', label: 'Statistiken' },
]
// 16 Lifecycle Email Templates
const emailTemplates = [
{ name: 'Willkommens-E-Mail', key: 'welcome', category: 'onboarding', description: 'Wird bei Registrierung versendet' },
{ name: 'E-Mail Bestaetigung', key: 'email_verification', category: 'onboarding', description: 'Bestaetigungslink fuer E-Mail-Adresse' },
{ name: 'Konto aktiviert', key: 'account_activated', category: 'onboarding', description: 'Bestaetigung der Kontoaktivierung' },
{ name: 'Passwort zuruecksetzen', key: 'password_reset', category: 'security', description: 'Link zum Zuruecksetzen des Passworts' },
{ name: 'Passwort geaendert', key: 'password_changed', category: 'security', description: 'Bestaetigung der Passwortaenderung' },
{ name: 'Neue Anmeldung', key: 'new_login', category: 'security', description: 'Benachrichtigung ueber Anmeldung von neuem Geraet' },
{ name: '2FA aktiviert', key: '2fa_enabled', category: 'security', description: 'Bestaetigung der 2FA-Aktivierung' },
{ name: 'Neue Dokumentversion', key: 'new_document_version', category: 'consent', description: 'Info ueber neue Dokumentversion zur Zustimmung' },
{ name: 'Zustimmung erteilt', key: 'consent_given', category: 'consent', description: 'Bestaetigung der erteilten Zustimmung' },
{ name: 'Zustimmung widerrufen', key: 'consent_withdrawn', category: 'consent', description: 'Bestaetigung des Widerrufs' },
{ name: 'DSR Anfrage erhalten', key: 'dsr_received', category: 'gdpr', description: 'Bestaetigung des Eingangs einer DSGVO-Anfrage' },
{ name: 'Datenexport bereit', key: 'export_ready', category: 'gdpr', description: 'Benachrichtigung ueber fertigen Datenexport' },
{ name: 'Daten geloescht', key: 'data_deleted', category: 'gdpr', description: 'Bestaetigung der Datenloeschung' },
{ name: 'Daten berichtigt', key: 'data_rectified', category: 'gdpr', description: 'Bestaetigung der Datenberichtigung' },
{ name: 'Konto deaktiviert', key: 'account_deactivated', category: 'lifecycle', description: 'Konto wurde deaktiviert' },
{ name: 'Konto geloescht', key: 'account_deleted', category: 'lifecycle', description: 'Bestaetigung der Kontoloeschung' },
]
// GDPR Article 15-21 Processes
const gdprProcesses = [
{
article: '15',
title: 'Auskunftsrecht',
description: 'Recht auf Bestaetigung und Auskunft ueber verarbeitete personenbezogene Daten',
actions: ['Datenexport generieren', 'Verarbeitungszwecke auflisten', 'Empfaenger auflisten'],
sla: '30 Tage',
},
{
article: '16',
title: 'Recht auf Berichtigung',
description: 'Recht auf Berichtigung unrichtiger personenbezogener Daten',
actions: ['Daten bearbeiten', 'Aenderungshistorie fuehren', 'Benachrichtigung senden'],
sla: '30 Tage',
},
{
article: '17',
title: 'Recht auf Loeschung ("Vergessenwerden")',
description: 'Recht auf Loeschung personenbezogener Daten unter bestimmten Voraussetzungen',
actions: ['Loeschantrag pruefen', 'Daten loeschen', 'Aufbewahrungsfristen pruefen', 'Loeschbestaetigung senden'],
sla: '30 Tage',
},
{
article: '18',
title: 'Recht auf Einschraenkung der Verarbeitung',
description: 'Recht auf Markierung von Daten zur eingeschraenkten Verarbeitung',
actions: ['Daten markieren', 'Verarbeitung einschraenken', 'Benachrichtigung bei Aufhebung'],
sla: '30 Tage',
},
{
article: '19',
title: 'Mitteilungspflicht',
description: 'Pflicht zur Mitteilung von Berichtigung, Loeschung oder Einschraenkung an Empfaenger',
actions: ['Empfaenger ermitteln', 'Mitteilungen versenden', 'Protokollierung'],
sla: 'Unverzueglich',
},
{
article: '20',
title: 'Recht auf Datenuebertragbarkeit',
description: 'Recht auf Erhalt der Daten in strukturiertem, maschinenlesbarem Format',
actions: ['JSON/CSV Export', 'API-Schnittstelle', 'Direkte Uebertragung'],
sla: '30 Tage',
},
{
article: '21',
title: 'Widerspruchsrecht',
description: 'Recht auf Widerspruch gegen Verarbeitung aus berechtigtem Interesse oder Direktwerbung',
actions: ['Widerspruch erfassen', 'Verarbeitung stoppen', 'Marketing-Opt-out'],
sla: 'Unverzueglich',
},
]
const emailCategories = [
{ key: 'onboarding', label: 'Onboarding', color: 'bg-blue-100 text-blue-700' },
{ key: 'security', label: 'Sicherheit', color: 'bg-red-100 text-red-700' },
{ key: 'consent', label: 'Zustimmung', color: 'bg-green-100 text-green-700' },
{ key: 'gdpr', label: 'DSGVO', color: 'bg-purple-100 text-purple-700' },
{ key: 'lifecycle', label: 'Lifecycle', color: 'bg-orange-100 text-orange-700' },
]
return (
<div className="space-y-6">
<StepHeader stepId="consent-management" showProgress={true} />
{/* Token Input */}
{!authToken && (
<div className="bg-white rounded-xl border border-slate-200 p-4">
<label className="block text-sm font-medium text-slate-700 mb-2">
Admin Token
</label>
<input
type="password"
placeholder="JWT Token eingeben..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
onChange={(e) => {
setAuthToken(e.target.value)
localStorage.setItem('bp_admin_token', e.target.value)
}}
/>
</div>
)}
{/* Tabs */}
<div>
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab.id
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Content */}
<div>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error}
<button
onClick={() => setError(null)}
className="ml-4 text-red-500 hover:text-red-700"
>
X
</button>
</div>
)}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
{/* Documents Tab */}
{activeTab === 'documents' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Dokumente verwalten</h2>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ Neues Dokument
</button>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Dokumente...</div>
) : documents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Dokumente vorhanden
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Typ</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Name</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Beschreibung</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Pflicht</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Erstellt</th>
<th className="text-right py-3 px-4 text-sm font-medium text-slate-500">Aktionen</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4">
<span className="px-2 py-1 bg-slate-100 text-slate-700 rounded text-xs font-medium">
{doc.type}
</span>
</td>
<td className="py-3 px-4 font-medium text-slate-900">{doc.name}</td>
<td className="py-3 px-4 text-slate-600 text-sm">{doc.description}</td>
<td className="py-3 px-4">
{doc.mandatory ? (
<span className="text-green-600">Ja</span>
) : (
<span className="text-slate-400">Nein</span>
)}
</td>
<td className="py-3 px-4 text-sm text-slate-500">
{new Date(doc.created_at).toLocaleDateString('de-DE')}
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => {
setSelectedDocument(doc.id)
setActiveTab('versions')
}}
className="text-purple-600 hover:text-purple-700 text-sm font-medium mr-3"
>
Versionen
</button>
<button className="text-slate-500 hover:text-slate-700 text-sm">
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Versions Tab */}
{activeTab === 'versions' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h2 className="text-lg font-semibold text-slate-900">Versionen</h2>
<select
value={selectedDocument}
onChange={(e) => setSelectedDocument(e.target.value)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Dokument auswaehlen...</option>
{documents.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.name}
</option>
))}
</select>
</div>
{selectedDocument && (
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ Neue Version
</button>
)}
</div>
{!selectedDocument ? (
<div className="text-center py-12 text-slate-500">
Bitte waehlen Sie ein Dokument aus
</div>
) : loading ? (
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
) : versions.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Versionen vorhanden
</div>
) : (
<div className="space-y-4">
{versions.map((version) => (
<div
key={version.id}
className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-slate-900">v{version.version}</span>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
{version.language.toUpperCase()}
</span>
<span
className={`px-2 py-0.5 rounded text-xs ${
version.status === 'published'
? 'bg-green-100 text-green-700'
: version.status === 'draft'
? 'bg-yellow-100 text-yellow-700'
: 'bg-slate-100 text-slate-600'
}`}
>
{version.status}
</span>
</div>
<h3 className="text-slate-700">{version.title}</h3>
<p className="text-sm text-slate-500 mt-1">
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
</p>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
{version.status === 'draft' && (
<button className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
Veroeffentlichen
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Emails Tab - 16 Lifecycle Templates */}
{activeTab === 'emails' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail Vorlagen</h2>
<p className="text-sm text-slate-500 mt-1">16 Lifecycle-Vorlagen fuer automatisierte Kommunikation</p>
</div>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ Neue Vorlage
</button>
</div>
{/* Category Filter */}
<div className="flex flex-wrap gap-2 mb-6">
<span className="text-sm text-slate-500 py-1">Filter:</span>
{emailCategories.map((cat) => (
<span key={cat.key} className={`px-3 py-1 rounded-full text-xs font-medium ${cat.color}`}>
{cat.label}
</span>
))}
</div>
{/* Templates grouped by category */}
{emailCategories.map((category) => (
<div key={category.key} className="mb-8">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${category.color.split(' ')[0]}`}></span>
{category.label}
</h3>
<div className="grid gap-3">
{emailTemplates
.filter((t) => t.category === category.key)
.map((template) => (
<div
key={template.key}
className="border border-slate-200 rounded-lg p-4 flex items-center justify-between hover:border-purple-300 transition-colors bg-white"
>
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-lg ${category.color} flex items-center justify-center text-lg`}>
</div>
<div>
<h4 className="font-medium text-slate-900">{template.name}</h4>
<p className="text-sm text-slate-500">{template.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorschau
</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* GDPR Processes Tab - Articles 15-21 */}
{activeTab === 'gdpr' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">DSGVO Betroffenenrechte</h2>
<p className="text-sm text-slate-500 mt-1">Artikel 15-21 Prozesse und Vorlagen</p>
</div>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ DSR Anfrage erstellen
</button>
</div>
{/* Info Banner */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<span className="text-2xl">*</span>
<div>
<h4 className="font-medium text-purple-900">Data Subject Rights (DSR)</h4>
<p className="text-sm text-purple-700 mt-1">
Hier verwalten Sie alle DSGVO-Anfragen. Jeder Artikel hat definierte Prozesse, SLAs und automatisierte Workflows.
</p>
</div>
</div>
</div>
{/* GDPR Process Cards */}
<div className="space-y-4">
{gdprProcesses.map((process) => (
<div
key={process.article}
className="border border-slate-200 rounded-xl p-5 hover:border-purple-300 transition-colors bg-white"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center font-bold text-lg">
{process.article}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-slate-900">{process.title}</h3>
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
</div>
<p className="text-sm text-slate-600 mb-3">{process.description}</p>
{/* Actions */}
<div className="flex flex-wrap gap-2 mb-3">
{process.actions.map((action, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
{action}
</span>
))}
</div>
{/* SLA */}
<div className="flex items-center gap-4 text-sm">
<span className="text-slate-500">
SLA: <span className="font-medium text-slate-700">{process.sla}</span>
</span>
<span className="text-slate-300">|</span>
<span className="text-slate-500">
Offene Anfragen: <span className="font-medium text-slate-700">0</span>
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<button className="px-3 py-1.5 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg">
Anfragen
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorlage
</button>
</div>
</div>
</div>
))}
</div>
{/* DSR Request Statistics */}
<div className="mt-8 pt-6 border-t border-slate-200">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-4">DSR Uebersicht</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-slate-900">0</div>
<div className="text-xs text-slate-500 mt-1">Offen</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-700">0</div>
<div className="text-xs text-slate-500 mt-1">Erledigt</div>
</div>
<div className="bg-yellow-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-yellow-700">0</div>
<div className="text-xs text-slate-500 mt-1">In Bearbeitung</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-red-700">0</div>
<div className="text-xs text-slate-500 mt-1">Ueberfaellig</div>
</div>
</div>
</div>
</div>
)}
{/* Stats Tab */}
{activeTab === 'stats' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Statistiken</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Aktive Zustimmungen</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Dokumente</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Offene DSR-Anfragen</div>
</div>
</div>
<div className="border border-slate-200 rounded-lg p-6">
<h3 className="font-semibold text-slate-900 mb-4">Zustimmungsrate nach Dokument</h3>
<div className="text-center py-8 text-slate-500">
Noch keine Daten verfuegbar
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -13,7 +13,27 @@ import {
DSFA_LEGAL_BASES,
DSFA_AFFECTED_RIGHTS,
calculateRiskLevel,
SDM_GOALS,
} from '@/lib/sdk/dsfa/types'
import type { SDMGoal, DSFARiskCategory } from '@/lib/sdk/dsfa/types'
import {
RISK_CATALOG,
RISK_CATEGORY_LABELS,
COMPONENT_FAMILY_LABELS,
getRisksByCategory,
getRisksBySDMGoal,
} from '@/lib/sdk/dsfa/risk-catalog'
import type { CatalogRisk } from '@/lib/sdk/dsfa/risk-catalog'
import {
MITIGATION_LIBRARY,
MITIGATION_TYPE_LABELS,
SDM_GOAL_LABELS,
EFFECTIVENESS_LABELS,
getMitigationsBySDMGoal,
getMitigationsByType,
getMitigationsForRisk,
} from '@/lib/sdk/dsfa/mitigation-library'
import type { CatalogMitigation } from '@/lib/sdk/dsfa/mitigation-library'
import {
getDSFA,
updateDSFASection,
@@ -32,6 +52,8 @@ import {
Art36Warning,
ReviewScheduleSection,
} from '@/components/sdk/dsfa'
import { SourceAttribution } from '@/components/sdk/dsfa/SourceAttribution'
import type { DSFALicenseCode, SourceAttributionProps } from '@/lib/sdk/types'
// =============================================================================
// SECTION EDITORS
@@ -483,6 +505,12 @@ function Section3Editor({ dsfa, onUpdate, isSubmitting }: SectionProps) {
)}
</div>
{/* RAG Search for Risks */}
<RAGSearchPanel
context={`Risiken Datenschutz-Folgenabschaetzung ${dsfa.processing_description || ''} ${dsfa.processing_purpose || ''}`}
categories={['risk_assessment', 'threshold_analysis']}
/>
{/* Affected Rights */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Betroffene Rechte & Freiheiten</h4>
@@ -648,6 +676,12 @@ function Section4Editor({ dsfa, onUpdate, isSubmitting }: SectionProps) {
'bg-purple-50'
)}
{/* RAG Search for Mitigations */}
<RAGSearchPanel
context={`Massnahmen Datenschutz-Folgenabschaetzung ${dsfa.processing_description || ''} ${dsfa.processing_purpose || ''}`}
categories={['mitigation', 'risk_assessment']}
/>
{/* TOM References */}
{dsfa.tom_references && dsfa.tom_references.length > 0 && (
<div className="bg-gray-50 rounded-xl p-4">
@@ -837,6 +871,305 @@ function Section5Editor({ dsfa, onUpdate, isSubmitting }: SectionProps) {
)
}
// =============================================================================
// SDM COVERAGE OVERVIEW
// =============================================================================
function SDMCoverageOverview({ dsfa }: { dsfa: DSFA }) {
const goals = Object.keys(SDM_GOALS) as SDMGoal[]
const riskCount = dsfa.risks?.length || 0
const mitigationCount = dsfa.mitigations?.length || 0
// Count catalog risks and mitigations per SDM goal by matching descriptions
const goalCoverage = goals.map(goal => {
const catalogRisks = RISK_CATALOG.filter(r => r.sdmGoal === goal)
const catalogMitigations = MITIGATION_LIBRARY.filter(m => m.sdmGoals.includes(goal))
// Check if any DSFA risk descriptions contain catalog risk titles for this goal
const matchedRisks = catalogRisks.filter(cr =>
dsfa.risks?.some(r => r.description?.includes(cr.title))
).length
// Check if any DSFA mitigation descriptions contain catalog mitigation titles for this goal
const matchedMitigations = catalogMitigations.filter(cm =>
dsfa.mitigations?.some(m => m.description?.includes(cm.title))
).length
const totalCatalogRisks = catalogRisks.length
const totalCatalogMitigations = catalogMitigations.length
// Coverage: simple heuristic based on whether there are mitigations for risks in this area
const hasRisks = matchedRisks > 0 || dsfa.risks?.some(r => {
const cat = r.category
if (goal === 'vertraulichkeit' && cat === 'confidentiality') return true
if (goal === 'integritaet' && cat === 'integrity') return true
if (goal === 'verfuegbarkeit' && cat === 'availability') return true
if (goal === 'nichtverkettung' && cat === 'rights_freedoms') return true
return false
})
const coverage = matchedMitigations > 0 ? 'covered' :
hasRisks ? 'gaps' : 'no_data'
return {
goal,
info: SDM_GOALS[goal],
matchedRisks,
matchedMitigations,
totalCatalogRisks,
totalCatalogMitigations,
coverage,
}
})
return (
<div className="mt-6 bg-white rounded-xl border p-6">
<h3 className="text-md font-semibold text-gray-900 mb-1">SDM-Abdeckung (Gewaehrleistungsziele)</h3>
<p className="text-xs text-gray-500 mb-4">Uebersicht ueber die Abdeckung der 7 Gewaehrleistungsziele des Standard-Datenschutzmodells.</p>
<div className="grid grid-cols-7 gap-2">
{goalCoverage.map(({ goal, info, matchedRisks, matchedMitigations, coverage }) => (
<div
key={goal}
className={`p-3 rounded-lg text-center border ${
coverage === 'covered' ? 'bg-green-50 border-green-200' :
coverage === 'gaps' ? 'bg-yellow-50 border-yellow-200' :
'bg-gray-50 border-gray-200'
}`}
>
<div className={`text-lg mb-1 ${
coverage === 'covered' ? 'text-green-600' :
coverage === 'gaps' ? 'text-yellow-600' :
'text-gray-400'
}`}>
{coverage === 'covered' ? '\u2713' : coverage === 'gaps' ? '!' : '\u2013'}
</div>
<div className="text-xs font-medium text-gray-900 leading-tight">{info.name}</div>
<div className="text-[10px] text-gray-500 mt-1">
{matchedRisks}R / {matchedMitigations}M
</div>
</div>
))}
</div>
<div className="flex items-center gap-4 mt-3 text-xs text-gray-500">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-500"></span> Abgedeckt</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-yellow-500"></span> Luecken</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-gray-300"></span> Keine Daten</span>
<span className="ml-auto">R = Risiken, M = Massnahmen</span>
</div>
</div>
)
}
// =============================================================================
// RAG SEARCH PANEL
// =============================================================================
const RAG_API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
interface RAGSearchResult {
chunk_id: string
content: string
score: number
source_code: string
source_name: string
attribution_text: string
license_code: string
license_name: string
license_url?: string
source_url?: string
document_type?: string
category?: string
section_title?: string
}
interface RAGSearchResponse {
query: string
results: RAGSearchResult[]
total_results: number
licenses_used: string[]
attribution_notice: string
}
function RAGSearchPanel({
context,
categories,
onInsertText,
}: {
context: string
categories?: string[]
onInsertText?: (text: string) => void
}) {
const [isOpen, setIsOpen] = useState(false)
const [query, setQuery] = useState('')
const [isSearching, setIsSearching] = useState(false)
const [results, setResults] = useState<RAGSearchResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [copiedId, setCopiedId] = useState<string | null>(null)
const buildQuery = () => {
if (query.trim()) return query.trim()
// Auto-generate query from context
return context.substring(0, 200)
}
const handleSearch = async () => {
const searchQuery = buildQuery()
if (!searchQuery || searchQuery.length < 3) return
setIsSearching(true)
setError(null)
try {
const params = new URLSearchParams({ query: searchQuery, limit: '5' })
if (categories?.length) {
categories.forEach(c => params.append('categories', c))
}
const response = await fetch(`${RAG_API_BASE}/api/v1/dsfa-rag/search?${params}`)
if (!response.ok) throw new Error(`Suche fehlgeschlagen (${response.status})`)
const data: RAGSearchResponse = await response.json()
setResults(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Suche fehlgeschlagen')
setResults(null)
} finally {
setIsSearching(false)
}
}
const handleInsert = (text: string, chunkId: string) => {
if (onInsertText) {
onInsertText(text)
} else {
navigator.clipboard.writeText(text)
}
setCopiedId(chunkId)
setTimeout(() => setCopiedId(null), 2000)
}
const sourcesForAttribution: SourceAttributionProps['sources'] = (results?.results || []).map(r => ({
sourceCode: r.source_code,
sourceName: r.source_name,
attributionText: r.attribution_text,
licenseCode: r.license_code as DSFALicenseCode,
sourceUrl: r.source_url,
score: r.score,
}))
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-2 px-4 py-2 text-sm bg-indigo-50 text-indigo-700 rounded-lg border border-indigo-200 hover:bg-indigo-100 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Empfehlung suchen (RAG)
</button>
)
}
return (
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-4 space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-indigo-800 flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
DSFA-Wissenssuche (RAG)
</h4>
<button
onClick={() => { setIsOpen(false); setResults(null); setError(null) }}
className="text-indigo-400 hover:text-indigo-600"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Search Input */}
<div className="flex gap-2">
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
placeholder={`Suchbegriff (oder leer fuer automatische Kontextsuche)...`}
className="flex-1 px-3 py-2 text-sm border border-indigo-300 rounded-lg bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
<button
onClick={handleSearch}
disabled={isSearching}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{isSearching ? 'Suche...' : 'Suchen'}
</button>
</div>
{/* Error */}
{error && (
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg p-3">
{error}
</div>
)}
{/* Results */}
{results && results.results.length > 0 && (
<div className="space-y-3">
<p className="text-xs text-indigo-600">{results.total_results} Ergebnis(se) gefunden</p>
{results.results.map(r => (
<div key={r.chunk_id} className="bg-white rounded-lg border border-indigo-100 p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
{r.section_title && (
<div className="text-xs font-medium text-indigo-600 mb-1">{r.section_title}</div>
)}
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">
{r.content.length > 400 ? r.content.substring(0, 400) + '...' : r.content}
</p>
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-gray-400 font-mono">
{r.source_code} ({(r.score * 100).toFixed(0)}%)
</span>
{r.category && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">{r.category}</span>
)}
</div>
</div>
<button
onClick={() => handleInsert(r.content, r.chunk_id)}
className={`flex-shrink-0 px-3 py-1.5 text-xs rounded-lg transition-colors ${
copiedId === r.chunk_id
? 'bg-green-100 text-green-700'
: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200'
}`}
title="In Beschreibung uebernehmen"
>
{copiedId === r.chunk_id ? 'Kopiert!' : 'Uebernehmen'}
</button>
</div>
</div>
))}
{/* Source Attribution */}
<SourceAttribution sources={sourcesForAttribution} compact showScores />
</div>
)}
{results && results.results.length === 0 && (
<div className="text-sm text-indigo-600 text-center py-4">
Keine Ergebnisse gefunden. Versuchen Sie einen anderen Suchbegriff.
</div>
)}
</div>
)
}
// =============================================================================
// MODALS
// =============================================================================
@@ -852,14 +1185,29 @@ function AddRiskModal({
onClose: () => void
onAdd: (data: { category: string; description: string }) => void
}) {
const [mode, setMode] = useState<'catalog' | 'manual'>('catalog')
const [category, setCategory] = useState('confidentiality')
const [description, setDescription] = useState('')
const [catalogFilter, setCatalogFilter] = useState<DSFARiskCategory | 'all'>('all')
const [sdmFilter, setSdmFilter] = useState<SDMGoal | 'all'>('all')
const { level } = calculateRiskLevel(likelihood, impact)
const filteredCatalog = RISK_CATALOG.filter(r => {
if (catalogFilter !== 'all' && r.category !== catalogFilter) return false
if (sdmFilter !== 'all' && r.sdmGoal !== sdmFilter) return false
return true
})
function selectCatalogRisk(risk: CatalogRisk) {
setCategory(risk.category)
setDescription(`${risk.title}\n\n${risk.description}`)
setMode('manual')
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 max-w-lg w-full mx-4">
<div className="bg-white rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[85vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Risiko hinzufuegen</h3>
<div className="mb-4 p-3 rounded-lg bg-gray-50">
@@ -872,33 +1220,107 @@ function AddRiskModal({
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Kategorie</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="confidentiality">Vertraulichkeit</option>
<option value="integrity">Integritaet</option>
<option value="availability">Verfuegbarkeit</option>
<option value="rights_freedoms">Rechte & Freiheiten</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
rows={4}
placeholder="Beschreiben Sie das Risiko..."
/>
</div>
{/* Tab Toggle */}
<div className="flex border-b mb-4">
<button
onClick={() => setMode('catalog')}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
mode === 'catalog' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Aus Katalog waehlen ({RISK_CATALOG.length})
</button>
<button
onClick={() => setMode('manual')}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
mode === 'manual' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Manuell eingeben
</button>
</div>
{mode === 'catalog' ? (
<div className="space-y-3">
{/* Filters */}
<div className="flex gap-2">
<select
value={catalogFilter}
onChange={e => setCatalogFilter(e.target.value as DSFARiskCategory | 'all')}
className="text-sm border rounded px-2 py-1"
>
<option value="all">Alle Kategorien</option>
{Object.entries(RISK_CATEGORY_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<select
value={sdmFilter}
onChange={e => setSdmFilter(e.target.value as SDMGoal | 'all')}
className="text-sm border rounded px-2 py-1"
>
<option value="all">Alle SDM-Ziele</option>
{Object.entries(SDM_GOAL_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
{/* Catalog List */}
<div className="max-h-[40vh] overflow-y-auto space-y-2">
{filteredCatalog.map(risk => (
<button
key={risk.id}
onClick={() => selectCatalogRisk(risk)}
className="w-full text-left p-3 rounded-lg border hover:border-purple-300 hover:bg-purple-50 transition-colors"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{risk.id}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
{RISK_CATEGORY_LABELS[risk.category]}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600">
{SDM_GOAL_LABELS[risk.sdmGoal]}
</span>
</div>
<div className="text-sm font-medium text-gray-900">{risk.title}</div>
<div className="text-xs text-gray-500 mt-1 line-clamp-2">{risk.description}</div>
</button>
))}
</div>
{filteredCatalog.length === 0 && (
<p className="text-sm text-gray-500 text-center py-4">Keine Risiken fuer die gewaehlten Filter.</p>
)}
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Kategorie</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="confidentiality">Vertraulichkeit</option>
<option value="integrity">Integritaet</option>
<option value="availability">Verfuegbarkeit</option>
<option value="rights_freedoms">Rechte & Freiheiten</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
rows={4}
placeholder="Beschreiben Sie das Risiko..."
/>
</div>
</div>
)}
<div className="flex gap-3 mt-6">
<button
onClick={onClose}
@@ -908,7 +1330,7 @@ function AddRiskModal({
</button>
<button
onClick={() => onAdd({ category, description })}
disabled={!description.trim()}
disabled={!description.trim() || mode === 'catalog'}
className="flex-1 py-2 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50"
>
Hinzufuegen
@@ -928,68 +1350,170 @@ function AddMitigationModal({
onClose: () => void
onAdd: (data: { risk_id: string; description: string; type: string; responsible_party: string }) => void
}) {
const [mode, setMode] = useState<'library' | 'manual'>('library')
const [riskId, setRiskId] = useState(risks[0]?.id || '')
const [type, setType] = useState('technical')
const [description, setDescription] = useState('')
const [responsibleParty, setResponsibleParty] = useState('')
const [typeFilter, setTypeFilter] = useState<'all' | 'technical' | 'organizational' | 'legal'>('all')
const [sdmFilter, setSdmFilter] = useState<SDMGoal | 'all'>('all')
const filteredLibrary = MITIGATION_LIBRARY.filter(m => {
if (typeFilter !== 'all' && m.type !== typeFilter) return false
if (sdmFilter !== 'all' && !m.sdmGoals.includes(sdmFilter)) return false
return true
})
function selectCatalogMitigation(m: CatalogMitigation) {
setType(m.type)
setDescription(`${m.title}\n\n${m.description}\n\nRechtsgrundlage: ${m.legalBasis}`)
setMode('manual')
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 max-w-lg w-full mx-4">
<div className="bg-white rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[85vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Massnahme hinzufuegen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Zugehoeriges Risiko</label>
<select
value={riskId}
onChange={(e) => setRiskId(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
{risks.map(risk => (
<option key={risk.id} value={risk.id}>
{risk.description.substring(0, 50)}...
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Typ</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="technical">Technisch</option>
<option value="organizational">Organisatorisch</option>
<option value="legal">Rechtlich</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
rows={3}
placeholder="Beschreiben Sie die Massnahme..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Verantwortlich</label>
<input
type="text"
value={responsibleParty}
onChange={(e) => setResponsibleParty(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Name oder Rolle..."
/>
</div>
{/* Tab Toggle */}
<div className="flex border-b mb-4">
<button
onClick={() => setMode('library')}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
mode === 'library' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Aus Bibliothek waehlen ({MITIGATION_LIBRARY.length})
</button>
<button
onClick={() => setMode('manual')}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
mode === 'manual' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Manuell eingeben
</button>
</div>
{mode === 'library' ? (
<div className="space-y-3">
{/* Filters */}
<div className="flex gap-2">
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value as typeof typeFilter)}
className="text-sm border rounded px-2 py-1"
>
<option value="all">Alle Typen</option>
{Object.entries(MITIGATION_TYPE_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<select
value={sdmFilter}
onChange={e => setSdmFilter(e.target.value as SDMGoal | 'all')}
className="text-sm border rounded px-2 py-1"
>
<option value="all">Alle SDM-Ziele</option>
{Object.entries(SDM_GOAL_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
{/* Library List */}
<div className="max-h-[40vh] overflow-y-auto space-y-2">
{filteredLibrary.map(m => (
<button
key={m.id}
onClick={() => selectCatalogMitigation(m)}
className="w-full text-left p-3 rounded-lg border hover:border-purple-300 hover:bg-purple-50 transition-colors"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.id}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
m.type === 'technical' ? 'bg-blue-50 text-blue-600' :
m.type === 'organizational' ? 'bg-green-50 text-green-600' :
'bg-purple-50 text-purple-600'
}`}>
{MITIGATION_TYPE_LABELS[m.type]}
</span>
{m.sdmGoals.map(g => (
<span key={g} className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
{SDM_GOAL_LABELS[g]}
</span>
))}
<span className={`text-xs px-2 py-0.5 rounded-full ${
m.effectiveness === 'high' ? 'bg-green-50 text-green-700' :
m.effectiveness === 'medium' ? 'bg-yellow-50 text-yellow-700' :
'bg-gray-50 text-gray-500'
}`}>
{EFFECTIVENESS_LABELS[m.effectiveness]}
</span>
</div>
<div className="text-sm font-medium text-gray-900">{m.title}</div>
<div className="text-xs text-gray-500 mt-1 line-clamp-2">{m.description}</div>
</button>
))}
</div>
{filteredLibrary.length === 0 && (
<p className="text-sm text-gray-500 text-center py-4">Keine Massnahmen fuer die gewaehlten Filter.</p>
)}
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Zugehoeriges Risiko</label>
<select
value={riskId}
onChange={(e) => setRiskId(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
{risks.map(risk => (
<option key={risk.id} value={risk.id}>
{risk.description.substring(0, 50)}...
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Typ</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="technical">Technisch</option>
<option value="organizational">Organisatorisch</option>
<option value="legal">Rechtlich</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
rows={3}
placeholder="Beschreiben Sie die Massnahme..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Verantwortlich</label>
<input
type="text"
value={responsibleParty}
onChange={(e) => setResponsibleParty(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Name oder Rolle..."
/>
</div>
</div>
)}
<div className="flex gap-3 mt-6">
<button
onClick={onClose}
@@ -999,7 +1523,7 @@ function AddMitigationModal({
</button>
<button
onClick={() => onAdd({ risk_id: riskId, description, type, responsible_party: responsibleParty })}
disabled={!description.trim()}
disabled={!description.trim() || mode === 'library'}
className="flex-1 py-2 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50"
>
Hinzufuegen
@@ -1264,6 +1788,11 @@ export default function DSFAEditorPage() {
/>
)}
{/* SDM Coverage Overview (shown in Section 3 and 4) */}
{(activeSection === 3 || activeSection === 4) && (dsfa.risks?.length > 0 || dsfa.mitigations?.length > 0) && (
<SDMCoverageOverview dsfa={dsfa} />
)}
{/* Section 5: Stakeholder Consultation (NEW) */}
{activeSection === 5 && (
<StakeholderConsultationSection

View File

@@ -0,0 +1,357 @@
'use client'
/**
* DSMS Page (SDK Version - Zusatzmodul)
*
* Data Protection Management System overview with:
* - DSGVO Compliance Score
* - Quick access to compliance modules (SDK paths)
* - 6 Module cards with status
* - GDPR Rights overview
*/
import Link from 'next/link'
interface ComplianceModule {
id: string
title: string
description: string
status: 'active' | 'pending' | 'inactive'
href?: string
items: {
name: string
status: 'complete' | 'in_progress' | 'pending'
lastUpdated?: string
}[]
}
export default function DSMSPage() {
const modules: ComplianceModule[] = [
{
id: 'legal-docs',
title: 'Rechtliche Dokumente',
description: 'AGB, Datenschutzerklaerung, Cookie-Richtlinie',
status: 'active',
href: '/sdk/consent-management',
items: [
{ name: 'AGB', status: 'complete', lastUpdated: '2024-12-01' },
{ name: 'Datenschutzerklaerung', status: 'complete', lastUpdated: '2024-12-01' },
{ name: 'Cookie-Richtlinie', status: 'complete', lastUpdated: '2024-12-01' },
{ name: 'Impressum', status: 'complete', lastUpdated: '2024-12-01' },
],
},
{
id: 'dsr',
title: 'Betroffenenanfragen (DSR)',
description: 'Art. 15-21 DSGVO Anfragen-Management',
status: 'active',
href: '/sdk/dsr',
items: [
{ name: 'Auskunftsprozess (Art. 15)', status: 'complete' },
{ name: 'Berichtigung (Art. 16)', status: 'complete' },
{ name: 'Loeschung (Art. 17)', status: 'complete' },
{ name: 'Datenuebertragbarkeit (Art. 20)', status: 'complete' },
],
},
{
id: 'consent',
title: 'Einwilligungsverwaltung',
description: 'Consent-Tracking und -Nachweis',
status: 'active',
href: '/sdk/consent',
items: [
{ name: 'Consent-Datenbank', status: 'complete' },
{ name: 'Widerrufsprozess', status: 'complete' },
{ name: 'Audit-Trail', status: 'complete' },
{ name: 'Export-Funktion', status: 'complete' },
],
},
{
id: 'tom',
title: 'Technische & Organisatorische Massnahmen',
description: 'Art. 32 DSGVO Sicherheitsmassnahmen',
status: 'active',
href: '/sdk/tom',
items: [
{ name: 'Verschluesselung (TLS/Ruhe)', status: 'complete' },
{ name: 'Zugriffskontrolle', status: 'complete' },
{ name: 'Backup & Recovery', status: 'in_progress' },
{ name: 'Logging & Monitoring', status: 'complete' },
],
},
{
id: 'vvt',
title: 'Verarbeitungsverzeichnis',
description: 'Art. 30 DSGVO Dokumentation',
status: 'active',
href: '/sdk/vvt',
items: [
{ name: 'Verarbeitungstaetigkeiten', status: 'complete' },
{ name: 'Rechtsgrundlagen', status: 'complete' },
{ name: 'Loeschfristen', status: 'complete' },
{ name: 'Auftragsverarbeiter', status: 'complete' },
],
},
{
id: 'dpia',
title: 'Datenschutz-Folgenabschaetzung',
description: 'Art. 35 DSGVO Risikoanalyse',
status: 'active',
href: '/sdk/dsfa',
items: [
{ name: 'KI-Verarbeitung', status: 'in_progress' },
{ name: 'Profiling-Risiken', status: 'complete' },
{ name: 'Automatisierte Entscheidungen', status: 'in_progress' },
],
},
]
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
case 'complete':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Aktiv</span>
case 'in_progress':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">In Arbeit</span>
case 'pending':
case 'inactive':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">Ausstehend</span>
default:
return null
}
}
const calculateScore = () => {
let complete = 0
let total = 0
modules.forEach((m) => {
m.items.forEach((item) => {
total++
if (item.status === 'complete') complete++
})
})
return Math.round((complete / total) * 100)
}
const complianceScore = calculateScore()
return (
<div className="space-y-6">
{/* Title Card (Zusatzmodul - no StepHeader) */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h1 className="text-2xl font-bold text-slate-900">Datenschutz-Management-System (DSMS)</h1>
<p className="text-slate-500 mt-1">
Zentrale Uebersicht aller Datenschutz-Massnahmen und deren Status. Verfolgen Sie den Compliance-Fortschritt und identifizieren Sie offene Aufgaben.
</p>
</div>
{/* Compliance Score */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">DSGVO-Compliance Score</h2>
<p className="text-sm text-slate-500 mt-1">Gesamtfortschritt der Datenschutz-Massnahmen</p>
</div>
<div className="text-right">
<div className={`text-4xl font-bold ${complianceScore >= 80 ? 'text-green-600' : complianceScore >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
{complianceScore}%
</div>
<div className="text-sm text-slate-500">Compliance</div>
</div>
</div>
<div className="mt-4 h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${complianceScore >= 80 ? 'bg-green-500' : complianceScore >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${complianceScore}%` }}
/>
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Link
href="/sdk/dsr"
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-purple-300 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-orange-100 flex items-center justify-center">
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<div className="font-medium text-slate-900">DSR bearbeiten</div>
<div className="text-xs text-slate-500">Anfragen verwalten</div>
</div>
</div>
</Link>
<Link
href="/sdk/consent"
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-purple-300 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="font-medium text-slate-900">Consents</div>
<div className="text-xs text-slate-500">Einwilligungen pruefen</div>
</div>
</div>
</Link>
<Link
href="/sdk/einwilligungen"
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-purple-300 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="font-medium text-slate-900">Einwilligungen</div>
<div className="text-xs text-slate-500">User Consents pruefen</div>
</div>
</div>
</Link>
<Link
href="/sdk/loeschfristen"
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-purple-300 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="font-medium text-slate-900">Loeschfristen</div>
<div className="text-xs text-slate-500">Pruefen & durchfuehren</div>
</div>
</div>
</Link>
</div>
{/* Audit Report Quick Action */}
<Link
href="/sdk/audit-report"
className="block bg-gradient-to-r from-purple-500 to-indigo-600 rounded-xl p-6 text-white hover:from-purple-600 hover:to-indigo-700 transition-all"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-white/20 flex items-center justify-center">
<svg className="w-6 h-6" 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>
<h3 className="text-lg font-semibold">Audit Report erstellen</h3>
<p className="text-sm text-white/80">PDF-Berichte fuer Auditoren und Aufsichtsbehoerden generieren</p>
</div>
</div>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
{/* Compliance Modules */}
<h2 className="text-lg font-semibold text-slate-900">Compliance-Module</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{modules.map((module) => (
<div key={module.id} className="bg-white rounded-xl border border-slate-200">
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-900">{module.title}</h3>
<p className="text-xs text-slate-500">{module.description}</p>
</div>
{getStatusBadge(module.status)}
</div>
<div className="p-4">
<ul className="space-y-2">
{module.items.map((item, idx) => (
<li key={idx} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
{item.status === 'complete' ? (
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : item.status === 'in_progress' ? (
<svg className="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-4 h-4 text-slate-300" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM7 9a1 1 0 000 2h6a1 1 0 100-2H7z" clipRule="evenodd" />
</svg>
)}
<span className={item.status === 'pending' ? 'text-slate-400' : 'text-slate-700'}>
{item.name}
</span>
</div>
{item.lastUpdated && (
<span className="text-xs text-slate-400">{item.lastUpdated}</span>
)}
</li>
))}
</ul>
{module.href && (
<Link
href={module.href}
className="mt-3 block text-center py-2 text-sm text-purple-600 hover:text-purple-700 font-medium"
>
Verwalten
</Link>
)}
</div>
</div>
))}
</div>
{/* GDPR Rights Overview */}
<div className="bg-purple-50 border border-purple-200 rounded-xl p-6">
<h3 className="font-semibold text-purple-900 mb-4">DSGVO Betroffenenrechte (Art. 12-22)</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="font-medium text-purple-700">Art. 15</div>
<div className="text-purple-600">Auskunftsrecht</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 16</div>
<div className="text-purple-600">Recht auf Berichtigung</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 17</div>
<div className="text-purple-600">Recht auf Loeschung</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 18</div>
<div className="text-purple-600">Recht auf Einschraenkung</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 19</div>
<div className="text-purple-600">Mitteilungspflicht</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 20</div>
<div className="text-purple-600">Datenuebertragbarkeit</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 21</div>
<div className="text-purple-600">Widerspruchsrecht</div>
</div>
<div>
<div className="font-medium text-purple-700">Art. 22</div>
<div className="text-purple-600">Automatisierte Entscheidungen</div>
</div>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,202 @@
'use client'
/**
* Source Policy Management Page (SDK Version)
*
* Whitelist-based data source management for edu-search-service.
* For auditors: Full audit trail for all changes.
*/
import { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
import StepHeader from '@/components/sdk/StepHeader/StepHeader'
import { SourcesTab } from '@/app/(admin)/compliance/source-policy/components/SourcesTab'
import { OperationsMatrixTab } from '@/app/(admin)/compliance/source-policy/components/OperationsMatrixTab'
import { PIIRulesTab } from '@/app/(admin)/compliance/source-policy/components/PIIRulesTab'
import { AuditTab } from '@/app/(admin)/compliance/source-policy/components/AuditTab'
// API base URL for edu-search-service
const getApiBase = () => {
if (typeof window === 'undefined') return 'http://localhost:8088'
const hostname = window.location.hostname
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return 'http://localhost:8088'
}
return `https://${hostname}:8089`
}
interface PolicyStats {
active_policies: number
allowed_sources: number
pii_rules: number
blocked_today: number
blocked_total: number
}
type TabId = 'dashboard' | 'sources' | 'operations' | 'pii' | 'audit'
export default function SourcePolicyPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('dashboard')
const [stats, setStats] = useState<PolicyStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [apiBase, setApiBase] = useState<string | null>(null)
useEffect(() => {
const base = getApiBase()
setApiBase(base)
}, [])
useEffect(() => {
if (apiBase !== null) {
fetchStats()
}
}, [apiBase])
const fetchStats = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/policy-stats`)
if (!res.ok) {
throw new Error('Fehler beim Laden der Statistiken')
}
const data = await res.json()
setStats(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
setStats({
active_policies: 0,
allowed_sources: 0,
pii_rules: 0,
blocked_today: 0,
blocked_total: 0,
})
} finally {
setLoading(false)
}
}
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'dashboard',
name: 'Dashboard',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
),
},
{
id: 'sources',
name: 'Quellen',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
),
},
{
id: 'operations',
name: 'Operations',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
),
},
{
id: 'pii',
name: 'PII-Regeln',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
),
},
{
id: 'audit',
name: 'Audit',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
},
]
return (
<div className="space-y-6">
<StepHeader stepId="source-policy" showProgress={true} />
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-purple-600">{stats.active_policies}</div>
<div className="text-sm text-slate-500">Aktive Policies</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats.allowed_sources}</div>
<div className="text-sm text-slate-500">Zugelassene Quellen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-red-600">{stats.blocked_today}</div>
<div className="text-sm text-slate-500">Blockiert (heute)</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-blue-600">{stats.pii_rules}</div>
<div className="text-sm text-slate-500">PII-Regeln</div>
</div>
</div>
)}
{/* Tabs */}
<div className="flex gap-2 flex-wrap">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
activeTab === tab.id
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{tab.icon}
{tab.name}
</button>
))}
</div>
{/* Tab Content */}
{apiBase === null ? (
<div className="text-center py-12 text-slate-500">Initialisiere...</div>
) : (
<>
{activeTab === 'dashboard' && (
<div className="text-center py-12 text-slate-500">
{loading ? 'Lade Dashboard...' : 'Dashboard-Ansicht - Wechseln Sie zu einem Tab fuer Details.'}
</div>
)}
{activeTab === 'sources' && <SourcesTab apiBase={apiBase} onUpdate={fetchStats} />}
{activeTab === 'operations' && <OperationsMatrixTab apiBase={apiBase} />}
{activeTab === 'pii' && <PIIRulesTab apiBase={apiBase} onUpdate={fetchStats} />}
{activeTab === 'audit' && <AuditTab apiBase={apiBase} />}
</>
)}
</div>
)
}

View File

@@ -90,6 +90,21 @@ export default function ScopePage() {
</div>
</div>
{/* Scope Prefill Hint */}
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4 mb-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-purple-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>
<p className="text-sm text-purple-800">
<span className="font-semibold">Tipp:</span> Wenn Sie bereits die Scope-Analyse ausgefuellt haben, werden relevante Felder
(Branche, Groesse, Hosting, Verschluesselung) automatisch vorausgefuellt. Anpassungen sind jederzeit moeglich.
</p>
</div>
</div>
</div>
{/* Step Content */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<ScopeRolesStep />

View File

@@ -0,0 +1,15 @@
'use client'
import { TOMGeneratorProvider } from '@/lib/sdk/tom-generator/context'
import { useSDK } from '@/lib/sdk'
export default function TOMLayout({ children }: { children: React.ReactNode }) {
const { state } = useSDK()
const tenantId = state?.tenantId || 'default'
return (
<TOMGeneratorProvider tenantId={tenantId}>
{children}
</TOMGeneratorProvider>
)
}

View File

@@ -1,377 +1,356 @@
'use client'
import React, { useState, useCallback } from 'react'
import React, { useState, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
import { useTOMGenerator } from '@/lib/sdk/tom-generator/context'
import { DerivedTOM } from '@/lib/sdk/tom-generator/types'
import { TOMOverviewTab, TOMEditorTab, TOMGapExportTab } from '@/components/sdk/tom-dashboard'
// =============================================================================
// TYPES
// =============================================================================
interface TOM {
id: string
title: string
description: string
category: 'confidentiality' | 'integrity' | 'availability' | 'resilience'
type: 'technical' | 'organizational'
status: 'implemented' | 'partial' | 'planned' | 'not-implemented'
article32Reference: string
lastReview: Date
nextReview: Date
responsible: string
documentation: string | null
type Tab = 'uebersicht' | 'editor' | 'generator' | 'gap-export'
interface TabDefinition {
key: Tab
label: string
}
// =============================================================================
// MOCK DATA
// =============================================================================
const mockTOMs: TOM[] = [
{
id: 'tom-1',
title: 'Zutrittskontrolle',
description: 'Physische Zugangskontrolle zu Serverraeumen und Rechenzentren',
category: 'confidentiality',
type: 'technical',
status: 'implemented',
article32Reference: 'Art. 32 Abs. 1 lit. b',
lastReview: new Date('2024-01-01'),
nextReview: new Date('2024-07-01'),
responsible: 'Facility Management',
documentation: 'TOM-001-Zutrittskontrolle.pdf',
},
{
id: 'tom-2',
title: 'Zugangskontrolle',
description: 'Authentifizierung und Autorisierung fuer IT-Systeme',
category: 'confidentiality',
type: 'technical',
status: 'implemented',
article32Reference: 'Art. 32 Abs. 1 lit. b',
lastReview: new Date('2024-01-15'),
nextReview: new Date('2024-07-15'),
responsible: 'IT Security',
documentation: 'TOM-002-Zugangskontrolle.pdf',
},
{
id: 'tom-3',
title: 'Verschluesselung',
description: 'Verschluesselung von Daten bei Speicherung und Uebertragung',
category: 'confidentiality',
type: 'technical',
status: 'implemented',
article32Reference: 'Art. 32 Abs. 1 lit. a',
lastReview: new Date('2024-01-10'),
nextReview: new Date('2024-07-10'),
responsible: 'IT Security',
documentation: 'TOM-003-Verschluesselung.pdf',
},
{
id: 'tom-4',
title: 'Datensicherung',
description: 'Regelmaessige Backups und Wiederherstellungstests',
category: 'availability',
type: 'technical',
status: 'implemented',
article32Reference: 'Art. 32 Abs. 1 lit. c',
lastReview: new Date('2023-12-01'),
nextReview: new Date('2024-06-01'),
responsible: 'IT Operations',
documentation: 'TOM-004-Backup.pdf',
},
{
id: 'tom-5',
title: 'Datenschutzschulung',
description: 'Regelmaessige Schulungen fuer alle Mitarbeiter',
category: 'confidentiality',
type: 'organizational',
status: 'partial',
article32Reference: 'Art. 32 Abs. 1 lit. b',
lastReview: new Date('2023-11-01'),
nextReview: new Date('2024-02-01'),
responsible: 'HR / Datenschutz',
documentation: null,
},
{
id: 'tom-6',
title: 'Incident Response Plan',
description: 'Prozess zur Behandlung von Sicherheitsvorfaellen',
category: 'resilience',
type: 'organizational',
status: 'planned',
article32Reference: 'Art. 32 Abs. 1 lit. c',
lastReview: new Date('2024-01-20'),
nextReview: new Date('2024-04-20'),
responsible: 'CISO',
documentation: null,
},
{
id: 'tom-7',
title: 'Protokollierung',
description: 'Logging aller sicherheitsrelevanten Ereignisse',
category: 'integrity',
type: 'technical',
status: 'implemented',
article32Reference: 'Art. 32 Abs. 1 lit. b',
lastReview: new Date('2024-01-05'),
nextReview: new Date('2024-07-05'),
responsible: 'IT Security',
documentation: 'TOM-007-Logging.pdf',
},
const TABS: TabDefinition[] = [
{ key: 'uebersicht', label: 'Uebersicht' },
{ key: 'editor', label: 'Detail-Editor' },
{ key: 'generator', label: 'Generator' },
{ key: 'gap-export', label: 'Gap-Analyse & Export' },
]
// =============================================================================
// COMPONENTS
// =============================================================================
function TOMCard({ tom }: { tom: TOM }) {
const categoryColors = {
confidentiality: 'bg-blue-100 text-blue-700',
integrity: 'bg-green-100 text-green-700',
availability: 'bg-purple-100 text-purple-700',
resilience: 'bg-orange-100 text-orange-700',
}
const categoryLabels = {
confidentiality: 'Vertraulichkeit',
integrity: 'Integritaet',
availability: 'Verfuegbarkeit',
resilience: 'Belastbarkeit',
}
const statusColors = {
implemented: 'bg-green-100 text-green-700 border-green-200',
partial: 'bg-yellow-100 text-yellow-700 border-yellow-200',
planned: 'bg-blue-100 text-blue-700 border-blue-200',
'not-implemented': 'bg-red-100 text-red-700 border-red-200',
}
const statusLabels = {
implemented: 'Implementiert',
partial: 'Teilweise',
planned: 'Geplant',
'not-implemented': 'Nicht implementiert',
}
const isReviewDue = tom.nextReview <= new Date()
return (
<div className={`bg-white rounded-xl border-2 p-6 ${
isReviewDue ? 'border-orange-200' :
tom.status === 'implemented' ? 'border-green-200' : 'border-gray-200'
}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 text-xs rounded-full ${categoryColors[tom.category]}`}>
{categoryLabels[tom.category]}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${
tom.type === 'technical' ? 'bg-gray-100 text-gray-700' : 'bg-purple-50 text-purple-700'
}`}>
{tom.type === 'technical' ? 'Technisch' : 'Organisatorisch'}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[tom.status]}`}>
{statusLabels[tom.status]}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-900">{tom.title}</h3>
<p className="text-sm text-gray-500 mt-1">{tom.description}</p>
<p className="text-xs text-gray-400 mt-2">Rechtsgrundlage: {tom.article32Reference}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Verantwortlich: </span>
<span className="font-medium text-gray-700">{tom.responsible}</span>
</div>
<div className={isReviewDue ? 'text-orange-600' : ''}>
<span className="text-gray-500">Naechste Pruefung: </span>
<span className="font-medium">
{tom.nextReview.toLocaleDateString('de-DE')}
{isReviewDue && ' (faellig)'}
</span>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
{tom.documentation ? (
<span className="text-sm text-green-600 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="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>
Dokumentiert
</span>
) : (
<span className="text-sm text-gray-400">Keine Dokumentation</span>
)}
<div className="flex items-center gap-2">
<button className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Bearbeiten
</button>
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Pruefung starten
</button>
</div>
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// PAGE COMPONENT
// =============================================================================
export default function TOMPage() {
const router = useRouter()
const { state } = useSDK()
const [toms] = useState<TOM[]>(mockTOMs)
const [filter, setFilter] = useState<string>('all')
const sdk = useSDK()
const { state, dispatch, bulkUpdateTOMs, runGapAnalysis } = useTOMGenerator()
// Handle uploaded document - import into SDK state
const handleDocumentProcessed = useCallback((doc: UploadedDocument) => {
console.log('[TOM Page] Document processed:', doc)
// In production: Parse document content and add to state.toms
// ---------------------------------------------------------------------------
// Local state
// ---------------------------------------------------------------------------
const [tab, setTab] = useState<Tab>('uebersicht')
const [selectedTOMId, setSelectedTOMId] = useState<string | null>(null)
// ---------------------------------------------------------------------------
// Computed / memoised values
// ---------------------------------------------------------------------------
const tomCount = useMemo(() => {
if (!state?.derivedTOMs) return 0
return Array.isArray(state.derivedTOMs)
? state.derivedTOMs.length
: Object.keys(state.derivedTOMs).length
}, [state?.derivedTOMs])
const lastModifiedFormatted = useMemo(() => {
if (!state?.metadata?.lastModified) return null
try {
const date = new Date(state.metadata.lastModified)
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return null
}
}, [state?.metadata?.lastModified])
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const handleSelectTOM = useCallback((tomId: string) => {
setSelectedTOMId(tomId)
setTab('editor')
}, [])
// Open document in workflow editor
const handleOpenInEditor = useCallback((doc: UploadedDocument) => {
router.push(`/compliance/workflow?documentType=tom&documentId=${doc.id}&mode=change`)
const handleUpdateTOM = useCallback(
(tomId: string, updates: Partial<DerivedTOM>) => {
dispatch({
type: 'UPDATE_DERIVED_TOM',
payload: { id: tomId, data: updates },
})
},
[dispatch],
)
const handleStartGenerator = useCallback(() => {
router.push('/sdk/tom-generator')
}, [router])
const filteredTOMs = filter === 'all'
? toms
: toms.filter(t => t.category === filter || t.type === filter || t.status === filter)
const handleBackToOverview = useCallback(() => {
setSelectedTOMId(null)
setTab('uebersicht')
}, [])
const implementedCount = toms.filter(t => t.status === 'implemented').length
const technicalCount = toms.filter(t => t.type === 'technical').length
const organizationalCount = toms.filter(t => t.type === 'organizational').length
const reviewDueCount = toms.filter(t => t.nextReview <= new Date()).length
const handleRunGapAnalysis = useCallback(() => {
if (typeof runGapAnalysis === 'function') {
runGapAnalysis()
}
}, [runGapAnalysis])
const stepInfo = STEP_EXPLANATIONS['tom']
const handleTabChange = useCallback((newTab: Tab) => {
setTab(newTab)
}, [])
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="tom"
title={stepInfo.title}
description={stepInfo.description}
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
TOM hinzufuegen
</button>
</StepHeader>
// ---------------------------------------------------------------------------
// Render helpers
// ---------------------------------------------------------------------------
{/* Document Upload Section */}
<DocumentUploadSection
documentType="tom"
onDocumentProcessed={handleDocumentProcessed}
onOpenInEditor={handleOpenInEditor}
/>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Gesamt</div>
<div className="text-3xl font-bold text-gray-900">{toms.length}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Implementiert</div>
<div className="text-3xl font-bold text-green-600">{implementedCount}</div>
</div>
<div className="bg-white rounded-xl border border-blue-200 p-6">
<div className="text-sm text-blue-600">Technisch / Organisatorisch</div>
<div className="text-3xl font-bold text-blue-600">{technicalCount} / {organizationalCount}</div>
</div>
<div className="bg-white rounded-xl border border-orange-200 p-6">
<div className="text-sm text-orange-600">Pruefung faellig</div>
<div className="text-3xl font-bold text-orange-600">{reviewDueCount}</div>
</div>
</div>
{/* Article 32 Overview */}
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-xl border border-blue-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">Art. 32 DSGVO - Schutzziele</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-blue-600">
{toms.filter(t => t.category === 'confidentiality').length}
</div>
<div className="text-sm text-gray-500">Vertraulichkeit</div>
</div>
<div className="bg-white rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-600">
{toms.filter(t => t.category === 'integrity').length}
</div>
<div className="text-sm text-gray-500">Integritaet</div>
</div>
<div className="bg-white rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-purple-600">
{toms.filter(t => t.category === 'availability').length}
</div>
<div className="text-sm text-gray-500">Verfuegbarkeit</div>
</div>
<div className="bg-white rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-orange-600">
{toms.filter(t => t.category === 'resilience').length}
</div>
<div className="text-sm text-gray-500">Belastbarkeit</div>
</div>
</div>
</div>
{/* Filter */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{['all', 'confidentiality', 'integrity', 'availability', 'resilience', 'technical', 'organizational', 'implemented', 'partial'].map(f => (
const renderTabBar = () => (
<div className="flex flex-wrap gap-2">
{TABS.map((t) => {
const isActive = tab === t.key
return (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filter === f
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
key={t.key}
onClick={() => handleTabChange(t.key)}
className={`
rounded-lg px-4 py-2 text-sm font-medium transition-colors
${
isActive
? 'bg-purple-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}
`}
>
{f === 'all' ? 'Alle' :
f === 'confidentiality' ? 'Vertraulichkeit' :
f === 'integrity' ? 'Integritaet' :
f === 'availability' ? 'Verfuegbarkeit' :
f === 'resilience' ? 'Belastbarkeit' :
f === 'technical' ? 'Technisch' :
f === 'organizational' ? 'Organisatorisch' :
f === 'implemented' ? 'Implementiert' : 'Teilweise'}
{t.label}
</button>
))}
</div>
)
})}
</div>
)
{/* TOM List */}
<div className="space-y-4">
{filteredTOMs.map(tom => (
<TOMCard key={tom.id} tom={tom} />
))}
</div>
// ---------------------------------------------------------------------------
// Tab 1 Uebersicht
// ---------------------------------------------------------------------------
{filteredTOMs.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center 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 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" />
const renderUebersicht = () => (
<TOMOverviewTab
state={state}
onSelectTOM={handleSelectTOM}
onStartGenerator={handleStartGenerator}
/>
)
// ---------------------------------------------------------------------------
// Tab 2 Detail-Editor
// ---------------------------------------------------------------------------
const renderEditor = () => (
<TOMEditorTab
state={state}
selectedTOMId={selectedTOMId}
onUpdateTOM={handleUpdateTOM}
onBack={handleBackToOverview}
/>
)
// ---------------------------------------------------------------------------
// Tab 3 Generator
// ---------------------------------------------------------------------------
const renderGenerator = () => (
<div className="space-y-6">
{/* Info card */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start gap-4">
{/* Icon */}
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-purple-100 flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
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>
<h3 className="text-lg font-semibold text-gray-900">Keine TOMs gefunden</h3>
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder fuegen Sie neue TOMs hinzu.</p>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
TOM Generator &ndash; 6-Schritte-Assistent
</h3>
<p className="text-gray-600 leading-relaxed mb-4">
Der TOM Generator fuehrt Sie in 6 Schritten durch die systematische
Ableitung Ihrer technischen und organisatorischen Massnahmen. Sie
beantworten gezielte Fragen zu Ihrem Unternehmen, Ihrer
IT-Infrastruktur und Ihren Verarbeitungstaetigkeiten. Daraus werden
passende TOMs automatisch abgeleitet und priorisiert.
</p>
<div className="bg-purple-50 rounded-lg p-4 mb-4">
<h4 className="text-sm font-semibold text-purple-800 mb-2">
Die 6 Schritte im Ueberblick:
</h4>
<ol className="list-decimal list-inside text-sm text-purple-700 space-y-1">
<li>Unternehmenskontext erfassen</li>
<li>IT-Infrastruktur beschreiben</li>
<li>Verarbeitungstaetigkeiten zuordnen</li>
<li>Risikobewertung durchfuehren</li>
<li>TOM-Ableitung und Priorisierung</li>
<li>Ergebnis pruefen und uebernehmen</li>
</ol>
</div>
<button
onClick={handleStartGenerator}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-6 py-3 text-sm font-medium transition-colors shadow-sm"
>
TOM Generator starten
</button>
</div>
</div>
</div>
{/* Quick stats only rendered when derivedTOMs exist */}
{tomCount > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">
Aktueller Stand
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* TOM count */}
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500 mb-1">Abgeleitete TOMs</p>
<p className="text-2xl font-bold text-gray-900">{tomCount}</p>
</div>
{/* Last generated date */}
{lastModifiedFormatted && (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500 mb-1">Zuletzt generiert</p>
<p className="text-lg font-semibold text-gray-900">
{lastModifiedFormatted}
</p>
</div>
)}
{/* Status */}
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500 mb-1">Status</p>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-green-500" />
<p className="text-sm font-medium text-gray-900">
TOMs vorhanden
</p>
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100">
<p className="text-xs text-gray-400">
Sie koennen den Generator jederzeit erneut ausfuehren, um Ihre
TOMs zu aktualisieren oder zu erweitern.
</p>
</div>
</div>
)}
{/* Empty state when no TOMs exist yet */}
{tomCount === 0 && (
<div className="bg-white rounded-xl border border-dashed border-gray-300 p-8 text-center">
<div className="mx-auto w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</div>
<h4 className="text-base font-medium text-gray-700 mb-1">
Noch keine TOMs vorhanden
</h4>
<p className="text-sm text-gray-500 mb-4">
Starten Sie den Generator, um Ihre ersten technischen und
organisatorischen Massnahmen abzuleiten.
</p>
<button
onClick={handleStartGenerator}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
>
Jetzt starten
</button>
</div>
)}
</div>
)
// ---------------------------------------------------------------------------
// Tab 4 Gap-Analyse & Export
// ---------------------------------------------------------------------------
const renderGapExport = () => (
<TOMGapExportTab
state={state}
onRunGapAnalysis={handleRunGapAnalysis}
/>
)
// ---------------------------------------------------------------------------
// Tab content router
// ---------------------------------------------------------------------------
const renderActiveTab = () => {
switch (tab) {
case 'uebersicht':
return renderUebersicht()
case 'editor':
return renderEditor()
case 'generator':
return renderGenerator()
case 'gap-export':
return renderGapExport()
default:
return renderUebersicht()
}
}
// ---------------------------------------------------------------------------
// Main render
// ---------------------------------------------------------------------------
return (
<div className="space-y-6">
{/* Step header */}
<StepHeader stepId="tom" {...STEP_EXPLANATIONS['tom']} />
{/* Tab bar */}
<div className="bg-white rounded-xl border border-gray-200 p-3">
{renderTabBar()}
</div>
{/* Active tab content */}
<div>{renderActiveTab()}</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* POST /api/admin/companion/feedback
* Submit feedback (bug report, feature request, general feedback)
* Proxy to backend /api/feedback
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Validate required fields
if (!body.type || !body.title || !body.description) {
return NextResponse.json(
{
success: false,
error: 'Missing required fields: type, title, description',
},
{ status: 400 }
)
}
// Validate feedback type
const validTypes = ['bug', 'feature', 'feedback']
if (!validTypes.includes(body.type)) {
return NextResponse.json(
{
success: false,
error: 'Invalid feedback type. Must be: bug, feature, or feedback',
},
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/feedback`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// body: JSON.stringify({
// type: body.type,
// title: body.title,
// description: body.description,
// screenshot: body.screenshot,
// sessionId: body.sessionId,
// metadata: {
// ...body.metadata,
// source: 'companion',
// timestamp: new Date().toISOString(),
// userAgent: request.headers.get('user-agent'),
// },
// }),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the submission
const feedbackId = `fb-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
console.log('Feedback received:', {
id: feedbackId,
type: body.type,
title: body.title,
description: body.description.substring(0, 100) + '...',
hasScreenshot: !!body.screenshot,
sessionId: body.sessionId,
})
return NextResponse.json({
success: true,
message: 'Feedback submitted successfully',
data: {
feedbackId,
submittedAt: new Date().toISOString(),
},
})
} catch (error) {
console.error('Submit feedback error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* GET /api/admin/companion/feedback
* Get feedback history (admin only)
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type')
const limit = parseInt(searchParams.get('limit') || '10')
// TODO: Replace with actual backend call
// Mock response - empty list for now
return NextResponse.json({
success: true,
data: {
feedback: [],
total: 0,
page: 1,
limit,
},
})
} catch (error) {
console.error('Get feedback error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,194 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* POST /api/admin/companion/lesson
* Start a new lesson session
* Proxy to backend /api/classroom/sessions
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// TODO: Replace with actual backend call
// const response = await fetch(`${backendUrl}/api/classroom/sessions`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify(body),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - create a new session
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const mockSession = {
success: true,
data: {
sessionId,
classId: body.classId,
className: body.className || body.classId,
subject: body.subject,
topic: body.topic,
startTime: new Date().toISOString(),
phases: [
{ phase: 'einstieg', duration: 8, status: 'active', actualTime: 0 },
{ phase: 'erarbeitung', duration: 20, status: 'planned', actualTime: 0 },
{ phase: 'sicherung', duration: 10, status: 'planned', actualTime: 0 },
{ phase: 'transfer', duration: 7, status: 'planned', actualTime: 0 },
{ phase: 'reflexion', duration: 5, status: 'planned', actualTime: 0 },
],
totalPlannedDuration: 50,
currentPhaseIndex: 0,
elapsedTime: 0,
isPaused: false,
pauseDuration: 0,
overtimeMinutes: 0,
status: 'in_progress',
homeworkList: [],
materials: [],
},
}
return NextResponse.json(mockSession)
} catch (error) {
console.error('Start lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* GET /api/admin/companion/lesson
* Get current lesson session or list of recent sessions
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId')
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const url = sessionId
// ? `${backendUrl}/api/classroom/sessions/${sessionId}`
// : `${backendUrl}/api/classroom/sessions`
//
// const response = await fetch(url)
// const data = await response.json()
// return NextResponse.json(data)
// Mock response
if (sessionId) {
return NextResponse.json({
success: true,
data: null, // No active session stored on server in mock
})
}
return NextResponse.json({
success: true,
data: {
sessions: [], // Empty list for now
},
})
} catch (error) {
console.error('Get lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PATCH /api/admin/companion/lesson
* Update lesson session (timer state, phase changes, etc.)
*/
export async function PATCH(request: NextRequest) {
try {
const body = await request.json()
const { sessionId, ...updates } = body
if (!sessionId) {
return NextResponse.json(
{ success: false, error: 'Session ID required' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/classroom/sessions/${sessionId}`, {
// method: 'PATCH',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(updates),
// })
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the update
return NextResponse.json({
success: true,
message: 'Session updated',
})
} catch (error) {
console.error('Update lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* DELETE /api/admin/companion/lesson
* End/delete a lesson session
*/
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId')
if (!sessionId) {
return NextResponse.json(
{ success: false, error: 'Session ID required' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
return NextResponse.json({
success: true,
message: 'Session ended',
})
} catch (error) {
console.error('End lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,102 @@
import { NextResponse } from 'next/server'
/**
* GET /api/admin/companion
* Proxy to backend /api/state/dashboard for companion dashboard data
*/
export async function GET() {
try {
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// TODO: Replace with actual backend call when endpoint is available
// const response = await fetch(`${backendUrl}/api/state/dashboard`, {
// method: 'GET',
// headers: {
// 'Content-Type': 'application/json',
// },
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response for development
const mockData = {
success: true,
data: {
context: {
currentPhase: 'erarbeitung',
phaseDisplayName: 'Erarbeitung',
},
stats: {
classesCount: 4,
studentsCount: 96,
learningUnitsCreated: 23,
gradesEntered: 156,
},
phases: [
{ id: 'einstieg', shortName: 'E', displayName: 'Einstieg', duration: 8, status: 'completed', color: '#4A90E2' },
{ id: 'erarbeitung', shortName: 'A', displayName: 'Erarbeitung', duration: 20, status: 'active', color: '#F5A623' },
{ id: 'sicherung', shortName: 'S', displayName: 'Sicherung', duration: 10, status: 'planned', color: '#7ED321' },
{ id: 'transfer', shortName: 'T', displayName: 'Transfer', duration: 7, status: 'planned', color: '#9013FE' },
{ id: 'reflexion', shortName: 'R', displayName: 'Reflexion', duration: 5, status: 'planned', color: '#6B7280' },
],
progress: {
percentage: 65,
completed: 13,
total: 20,
},
suggestions: [
{
id: '1',
title: 'Klausuren korrigieren',
description: 'Deutsch LK - 12 unkorrigierte Arbeiten warten',
priority: 'urgent',
icon: 'ClipboardCheck',
actionTarget: '/ai/klausur-korrektur',
estimatedTime: 120,
},
{
id: '2',
title: 'Elternsprechtag vorbereiten',
description: 'Notenuebersicht fuer 8b erstellen',
priority: 'high',
icon: 'Users',
actionTarget: '/education/grades',
estimatedTime: 30,
},
],
upcomingEvents: [
{
id: 'e1',
title: 'Mathe-Test 9b',
date: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
type: 'exam',
inDays: 2,
},
{
id: 'e2',
title: 'Elternsprechtag',
date: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
type: 'parent_meeting',
inDays: 5,
},
],
},
}
return NextResponse.json(mockData)
} catch (error) {
console.error('Companion dashboard error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server'
const DEFAULT_SETTINGS = {
defaultPhaseDurations: {
einstieg: 8,
erarbeitung: 20,
sicherung: 10,
transfer: 7,
reflexion: 5,
},
preferredLessonLength: 45,
autoAdvancePhases: true,
soundNotifications: true,
showKeyboardShortcuts: true,
highContrastMode: false,
onboardingCompleted: false,
}
/**
* GET /api/admin/companion/settings
* Get teacher settings
* Proxy to backend /api/teacher/settings
*/
export async function GET() {
try {
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
// method: 'GET',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - return default settings
return NextResponse.json({
success: true,
data: DEFAULT_SETTINGS,
})
} catch (error) {
console.error('Get settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PUT /api/admin/companion/settings
* Update teacher settings
*/
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
// Validate the settings structure
if (!body || typeof body !== 'object') {
return NextResponse.json(
{ success: false, error: 'Invalid settings data' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
// method: 'PUT',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// body: JSON.stringify(body),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the save
return NextResponse.json({
success: true,
message: 'Settings saved',
data: body,
})
} catch (error) {
console.error('Save settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PATCH /api/admin/companion/settings
* Partially update teacher settings
*/
export async function PATCH(request: NextRequest) {
try {
const body = await request.json()
// TODO: Replace with actual backend call
return NextResponse.json({
success: true,
message: 'Settings updated',
data: body,
})
} catch (error) {
console.error('Update settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,217 @@
/**
* Compliance Advisor Chat API
*
* Connects the ComplianceAdvisorWidget to:
* 1. RAG legal corpus search (klausur-service) for context
* 2. Ollama LLM (32B) for generating answers
*
* Streams the LLM response back as plain text.
*/
import { NextRequest, NextResponse } from 'next/server'
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://klausur-service:8086'
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
// SOUL system prompt (from agent-core/soul/compliance-advisor.soul.md)
const SYSTEM_PROMPT = `# Compliance Advisor Agent
## Identitaet
Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance SDK,
Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
Du bist kein Anwalt und gibst keine Rechtsberatung, sondern orientierst dich an
offiziellen Quellen und gibst praxisnahe Hinweise.
## Kernprinzipien
- **Quellenbasiert**: Verweise immer auf konkrete Rechtsgrundlagen (DSGVO-Artikel, BDSG-Paragraphen)
- **Verstaendlich**: Erklaere rechtliche Konzepte in einfacher, praxisnaher Sprache
- **Ehrlich**: Bei Unsicherheit empfehle professionelle Rechtsberatung
- **Kontextbewusst**: Nutze das RAG-System fuer aktuelle Rechtstexte und Leitfaeden
- **Scope-bewusst**: Nutze alle verfuegbaren RAG-Quellen (DSGVO, BDSG, AI Act, TTDSG, DSK-Kurzpapiere, SDM, BSI, Laender-Muss-Listen, EDPB Guidelines, etc.) AUSSER NIBIS-Dokumenten.
## Kompetenzbereich
- DSGVO Art. 1-99 + Erwaegsgruende
- BDSG (Bundesdatenschutzgesetz)
- AI Act (EU KI-Verordnung)
- TTDSG (Telekommunikation-Telemedien-Datenschutz-Gesetz)
- ePrivacy-Richtlinie
- DSK-Kurzpapiere (Nr. 1-20)
- SDM (Standard-Datenschutzmodell) V3.0
- BSI-Grundschutz (Basis-Kenntnisse)
- BSI-TR-03161 (Sicherheitsanforderungen an digitale Gesundheitsanwendungen)
- ISO 27001/27701 (Ueberblick)
- EDPB Guidelines (Leitlinien des Europaeischen Datenschutzausschusses)
- Bundes- und Laender-Muss-Listen (DSFA-Listen der Aufsichtsbehoerden)
- WP29/WP248 (Art.-29-Datenschutzgruppe Arbeitspapiere)
## RAG-Nutzung
Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind
NIBIS-Inhalte (Erwartungshorizonte, Bildungsstandards, curriculare Vorgaben).
Diese gehoeren nicht zum Datenschutz-Kompetenzbereich.
## Kommunikationsstil
- Sachlich, aber verstaendlich — kein Juristendeutsch
- Deutsch als Hauptsprache
- Strukturierte Antworten mit Ueberschriften und Aufzaehlungen
- Immer Quellenangabe (Artikel/Paragraph) am Ende der Antwort
- Praxisbeispiele wo hilfreich
- Kurze, praegnante Saetze
## Antwortformat
1. Kurze Zusammenfassung (1-2 Saetze)
2. Detaillierte Erklaerung
3. Praxishinweise / Handlungsempfehlungen
4. Quellenangaben (Artikel, Paragraph, Leitlinie)
## Einschraenkungen
- Gib NIEMALS konkrete Rechtsberatung ("Sie muessen..." -> "Es empfiehlt sich...")
- Keine Garantien fuer Rechtssicherheit
- Bei komplexen Einzelfaellen: Empfehle Rechtsanwalt/DSB
- Keine Aussagen zu laufenden Verfahren oder Bussgeldern
- Keine Interpretation von Urteilen (nur Verweis)
## Eskalation
- Bei Fragen ausserhalb des Kompetenzbereichs: Hoeflich ablehnen und auf Fachanwalt verweisen
- Bei widerspruechlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
- Bei dringenden Datenpannen: Auf 72-Stunden-Frist (Art. 33 DSGVO) hinweisen und Notfallplan-Modul empfehlen`
/**
* Query the RAG legal corpus for relevant documents
*/
async function queryRAG(query: string): Promise<string> {
try {
const url = `${KLAUSUR_SERVICE_URL}/api/v1/admin/legal-corpus/search?query=${encodeURIComponent(query)}&top_k=5`
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(10000),
})
if (!res.ok) {
console.warn('RAG search failed:', res.status)
return ''
}
const data = await res.json()
if (data.results && data.results.length > 0) {
return data.results
.map(
(r: { metadata?: { regulation?: string; source?: string }; text?: string; content?: string }, i: number) =>
`[Quelle ${i + 1}: ${r.metadata?.regulation || r.metadata?.source || 'Unbekannt'}]\n${r.text || r.content || ''}`
)
.join('\n\n---\n\n')
}
return ''
} catch (error) {
console.warn('RAG query error (continuing without context):', error)
return ''
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { message, history = [], currentStep = 'default' } = body
if (!message || typeof message !== 'string') {
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
}
// 1. Query RAG for relevant context
const ragContext = await queryRAG(message)
// 2. Build system prompt with RAG context
let systemContent = SYSTEM_PROMPT
if (ragContext) {
systemContent += `\n\n## Relevanter Kontext aus dem RAG-System\n\nNutze die folgenden Quellen fuer deine Antwort. Verweise in deiner Antwort auf die jeweilige Quelle:\n\n${ragContext}`
}
systemContent += `\n\n## Aktueller SDK-Schritt\nDer Nutzer befindet sich im SDK-Schritt: ${currentStep}`
// 3. Build messages array (limit history to last 10 messages)
const messages = [
{ role: 'system', content: systemContent },
...history.slice(-10).map((h: { role: string; content: string }) => ({
role: h.role === 'user' ? 'user' : 'assistant',
content: h.content,
})),
{ role: 'user', content: message },
]
// 4. Call Ollama with streaming
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: LLM_MODEL,
messages,
stream: true,
options: {
temperature: 0.3,
num_predict: 2048,
},
}),
signal: AbortSignal.timeout(120000),
})
if (!ollamaResponse.ok) {
const errorText = await ollamaResponse.text()
console.error('Ollama error:', ollamaResponse.status, errorText)
return NextResponse.json(
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status}). Ist Ollama mit dem Modell ${LLM_MODEL} gestartet?` },
{ status: 502 }
)
}
// 5. Stream response back as plain text
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const reader = ollamaResponse.body!.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n').filter((l) => l.trim())
for (const line of lines) {
try {
const json = JSON.parse(line)
if (json.message?.content) {
controller.enqueue(encoder.encode(json.message.content))
}
} catch {
// Partial JSON line, skip
}
}
}
} catch (error) {
console.error('Stream read error:', error)
} finally {
controller.close()
}
},
})
return new NextResponse(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
} catch (error) {
console.error('Compliance advisor chat error:', error)
return NextResponse.json(
{ error: 'Verbindung zum LLM fehlgeschlagen. Bitte pruefen Sie ob Ollama laeuft.' },
{ status: 503 }
)
}
}