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 ee0c4b859c
commit 870302a82b
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 }
)
}
}

View File

@@ -0,0 +1,312 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Settings, MessageSquare, HelpCircle, RefreshCw } from 'lucide-react'
import { CompanionMode, TeacherSettings, FeedbackType } from '@/lib/companion/types'
import { DEFAULT_TEACHER_SETTINGS, STORAGE_KEYS } from '@/lib/companion/constants'
// Components
import { ModeToggle } from './ModeToggle'
import { PhaseTimeline } from './companion-mode/PhaseTimeline'
import { StatsGrid } from './companion-mode/StatsGrid'
import { SuggestionList } from './companion-mode/SuggestionList'
import { EventsCard } from './companion-mode/EventsCard'
import { LessonContainer } from './lesson-mode/LessonContainer'
import { SettingsModal } from './modals/SettingsModal'
import { FeedbackModal } from './modals/FeedbackModal'
import { OnboardingModal } from './modals/OnboardingModal'
// Hooks
import { useCompanionData } from '@/hooks/companion/useCompanionData'
import { useLessonSession } from '@/hooks/companion/useLessonSession'
import { useKeyboardShortcuts } from '@/hooks/companion/useKeyboardShortcuts'
export function CompanionDashboard() {
// Mode state
const [mode, setMode] = useState<CompanionMode>('companion')
// Modal states
const [showSettings, setShowSettings] = useState(false)
const [showFeedback, setShowFeedback] = useState(false)
const [showOnboarding, setShowOnboarding] = useState(false)
// Settings
const [settings, setSettings] = useState<TeacherSettings>(DEFAULT_TEACHER_SETTINGS)
// Load settings from localStorage
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.SETTINGS)
if (stored) {
try {
const parsed = JSON.parse(stored)
setSettings({ ...DEFAULT_TEACHER_SETTINGS, ...parsed })
} catch {
// Invalid stored settings
}
}
// Check if onboarding needed
const onboardingStored = localStorage.getItem(STORAGE_KEYS.ONBOARDING_STATE)
if (!onboardingStored) {
setShowOnboarding(true)
}
// Restore last mode
const lastMode = localStorage.getItem(STORAGE_KEYS.LAST_MODE) as CompanionMode
if (lastMode && ['companion', 'lesson', 'classic'].includes(lastMode)) {
setMode(lastMode)
}
}, [])
// Save mode to localStorage
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.LAST_MODE, mode)
}, [mode])
// Companion data hook
const { data: companionData, loading: companionLoading, refresh } = useCompanionData()
// Lesson session hook
const {
session,
startLesson,
endLesson,
pauseLesson,
resumeLesson,
extendTime,
skipPhase,
saveReflection,
addHomework,
removeHomework,
isPaused,
} = useLessonSession({
onOvertimeStart: () => {
// Play sound if enabled
if (settings.soundNotifications) {
// TODO: Play notification sound
}
},
})
// Handle pause/resume toggle
const handlePauseToggle = useCallback(() => {
if (isPaused) {
resumeLesson()
} else {
pauseLesson()
}
}, [isPaused, pauseLesson, resumeLesson])
// Keyboard shortcuts
useKeyboardShortcuts({
onPauseResume: mode === 'lesson' && session ? handlePauseToggle : undefined,
onExtend: mode === 'lesson' && session && !isPaused ? () => extendTime(5) : undefined,
onNextPhase: mode === 'lesson' && session && !isPaused ? skipPhase : undefined,
onCloseModal: () => {
setShowSettings(false)
setShowFeedback(false)
setShowOnboarding(false)
},
enabled: settings.showKeyboardShortcuts,
})
// Handle settings save
const handleSaveSettings = (newSettings: TeacherSettings) => {
setSettings(newSettings)
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(newSettings))
}
// Handle feedback submit
const handleFeedbackSubmit = async (type: FeedbackType, title: string, description: string) => {
const response = await fetch('/api/admin/companion/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
title,
description,
sessionId: session?.sessionId,
}),
})
if (!response.ok) {
throw new Error('Failed to submit feedback')
}
}
// Handle onboarding complete
const handleOnboardingComplete = (data: { state?: string; schoolType?: string }) => {
localStorage.setItem(STORAGE_KEYS.ONBOARDING_STATE, JSON.stringify({
...data,
completed: true,
completedAt: new Date().toISOString(),
}))
setShowOnboarding(false)
setSettings({ ...settings, onboardingCompleted: true })
}
// Handle lesson start
const handleStartLesson = (data: { classId: string; subject: string; topic?: string; templateId?: string }) => {
startLesson(data)
setMode('lesson')
}
return (
<div className={`min-h-[calc(100vh-200px)] ${settings.highContrastMode ? 'high-contrast' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<ModeToggle
currentMode={mode}
onModeChange={setMode}
disabled={!!session && session.status === 'in_progress'}
/>
<div className="flex items-center gap-2">
{/* Refresh Button */}
{mode === 'companion' && (
<button
onClick={refresh}
disabled={companionLoading}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Aktualisieren"
>
<RefreshCw className={`w-5 h-5 ${companionLoading ? 'animate-spin' : ''}`} />
</button>
)}
{/* Feedback Button */}
<button
onClick={() => setShowFeedback(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Feedback"
>
<MessageSquare className="w-5 h-5" />
</button>
{/* Settings Button */}
<button
onClick={() => setShowSettings(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Einstellungen"
>
<Settings className="w-5 h-5" />
</button>
{/* Help Button */}
<button
onClick={() => setShowOnboarding(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Hilfe"
>
<HelpCircle className="w-5 h-5" />
</button>
</div>
</div>
{/* Main Content */}
{mode === 'companion' && (
<div className="space-y-6">
{/* Phase Timeline */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Aktuelle Phase</h3>
{companionData ? (
<PhaseTimeline
phases={companionData.phases}
currentPhaseIndex={companionData.phases.findIndex(p => p.status === 'active')}
/>
) : (
<div className="h-10 bg-slate-100 rounded animate-pulse" />
)}
</div>
{/* Stats */}
<StatsGrid
stats={companionData?.stats || { classesCount: 0, studentsCount: 0, learningUnitsCreated: 0, gradesEntered: 0 }}
loading={companionLoading}
/>
{/* Two Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Suggestions */}
<SuggestionList
suggestions={companionData?.suggestions || []}
loading={companionLoading}
onSuggestionClick={(suggestion) => {
// Navigate to action target
window.location.href = suggestion.actionTarget
}}
/>
{/* Events */}
<EventsCard
events={companionData?.upcomingEvents || []}
loading={companionLoading}
/>
</div>
{/* Quick Start Lesson Button */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl p-6 text-white">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold mb-1">Bereit fuer die naechste Stunde?</h3>
<p className="text-blue-100">Starten Sie den Lesson-Modus fuer strukturierten Unterricht.</p>
</div>
<button
onClick={() => setMode('lesson')}
className="px-6 py-3 bg-white text-blue-600 rounded-xl font-semibold hover:bg-blue-50 transition-colors"
>
Stunde starten
</button>
</div>
</div>
</div>
)}
{mode === 'lesson' && (
<LessonContainer
session={session}
onStartLesson={handleStartLesson}
onEndLesson={endLesson}
onPauseToggle={handlePauseToggle}
onExtendTime={extendTime}
onSkipPhase={skipPhase}
onSaveReflection={saveReflection}
onAddHomework={addHomework}
onRemoveHomework={removeHomework}
/>
)}
{mode === 'classic' && (
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
<h2 className="text-xl font-semibold text-slate-800 mb-2">Classic Mode</h2>
<p className="text-slate-600 mb-4">
Die klassische Ansicht ohne Timer und Phasenstruktur.
</p>
<p className="text-sm text-slate-500">
Dieser Modus ist fuer flexible Unterrichtsgestaltung gedacht.
</p>
</div>
)}
{/* Modals */}
<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
settings={settings}
onSave={handleSaveSettings}
/>
<FeedbackModal
isOpen={showFeedback}
onClose={() => setShowFeedback(false)}
onSubmit={handleFeedbackSubmit}
/>
<OnboardingModal
isOpen={showOnboarding}
onClose={() => setShowOnboarding(false)}
onComplete={handleOnboardingComplete}
/>
</div>
)
}

View File

@@ -0,0 +1,61 @@
'use client'
import { GraduationCap, Timer, Layout } from 'lucide-react'
import { CompanionMode } from '@/lib/companion/types'
interface ModeToggleProps {
currentMode: CompanionMode
onModeChange: (mode: CompanionMode) => void
disabled?: boolean
}
const modes: { id: CompanionMode; label: string; icon: React.ReactNode; description: string }[] = [
{
id: 'companion',
label: 'Companion',
icon: <GraduationCap className="w-4 h-4" />,
description: 'Dashboard mit Vorschlaegen',
},
{
id: 'lesson',
label: 'Lesson',
icon: <Timer className="w-4 h-4" />,
description: 'Timer und Phasen',
},
{
id: 'classic',
label: 'Classic',
icon: <Layout className="w-4 h-4" />,
description: 'Klassische Ansicht',
},
]
export function ModeToggle({ currentMode, onModeChange, disabled }: ModeToggleProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-1 inline-flex gap-1">
{modes.map((mode) => {
const isActive = currentMode === mode.id
return (
<button
key={mode.id}
onClick={() => onModeChange(mode.id)}
disabled={disabled}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium
transition-all duration-200
${isActive
? 'bg-slate-900 text-white shadow-sm'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
title={mode.description}
>
{mode.icon}
<span>{mode.label}</span>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import { Calendar, FileQuestion, Users, Clock, ChevronRight } from 'lucide-react'
import { UpcomingEvent, EventType } from '@/lib/companion/types'
import { EVENT_TYPE_CONFIG } from '@/lib/companion/constants'
interface EventsCardProps {
events: UpcomingEvent[]
onEventClick?: (event: UpcomingEvent) => void
loading?: boolean
maxItems?: number
}
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
FileQuestion,
Users,
Clock,
Calendar,
}
function getEventIcon(type: EventType) {
const config = EVENT_TYPE_CONFIG[type]
const Icon = iconMap[config.icon] || Calendar
return { Icon, ...config }
}
function formatEventDate(dateStr: string, inDays: number): string {
if (inDays === 0) return 'Heute'
if (inDays === 1) return 'Morgen'
if (inDays < 7) return `In ${inDays} Tagen`
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
interface EventItemProps {
event: UpcomingEvent
onClick?: () => void
}
function EventItem({ event, onClick }: EventItemProps) {
const { Icon, color, bg } = getEventIcon(event.type)
const isUrgent = event.inDays <= 2
return (
<button
onClick={onClick}
className={`
w-full flex items-center gap-3 p-3 rounded-lg
transition-all duration-200
hover:bg-slate-50
${isUrgent ? 'bg-red-50/50' : ''}
`}
>
<div className={`p-2 rounded-lg ${bg}`}>
<Icon className={`w-4 h-4 ${color}`} />
</div>
<div className="flex-1 text-left min-w-0">
<p className="font-medium text-slate-900 truncate">{event.title}</p>
<p className={`text-sm ${isUrgent ? 'text-red-600 font-medium' : 'text-slate-500'}`}>
{formatEventDate(event.date, event.inDays)}
</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-400 flex-shrink-0" />
</button>
)
}
export function EventsCard({
events,
onEventClick,
loading,
maxItems = 5,
}: EventsCardProps) {
const displayEvents = events.slice(0, maxItems)
if (loading) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-14 bg-slate-100 rounded-lg animate-pulse" />
))}
</div>
</div>
)
}
if (events.length === 0) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<div className="text-center py-6">
<Calendar className="w-10 h-10 text-slate-300 mx-auto mb-2" />
<p className="text-sm text-slate-500">Keine anstehenden Termine</p>
</div>
</div>
)
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<span className="text-sm text-slate-500">
{events.length} Termin{events.length !== 1 ? 'e' : ''}
</span>
</div>
<div className="space-y-1">
{displayEvents.map((event) => (
<EventItem
key={event.id}
event={event}
onClick={() => onEventClick?.(event)}
/>
))}
</div>
{events.length > maxItems && (
<button className="w-full mt-3 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors">
Alle {events.length} anzeigen
</button>
)}
</div>
)
}
/**
* Compact inline version for header/toolbar
*/
export function EventsInline({ events }: { events: UpcomingEvent[] }) {
const nextEvent = events[0]
if (!nextEvent) {
return (
<div className="flex items-center gap-2 text-sm text-slate-500">
<Calendar className="w-4 h-4" />
<span>Keine Termine</span>
</div>
)
}
const { Icon, color } = getEventIcon(nextEvent.type)
const isUrgent = nextEvent.inDays <= 2
return (
<div className={`flex items-center gap-2 text-sm ${isUrgent ? 'text-red-600' : 'text-slate-600'}`}>
<Icon className={`w-4 h-4 ${color}`} />
<span className="truncate max-w-[150px]">{nextEvent.title}</span>
<span className="text-slate-400">-</span>
<span className={isUrgent ? 'font-medium' : ''}>
{formatEventDate(nextEvent.date, nextEvent.inDays)}
</span>
</div>
)
}

View File

@@ -0,0 +1,203 @@
'use client'
import { Check } from 'lucide-react'
import { Phase } from '@/lib/companion/types'
import { PHASE_COLORS, formatMinutes } from '@/lib/companion/constants'
interface PhaseTimelineProps {
phases: Phase[]
currentPhaseIndex: number
onPhaseClick?: (index: number) => void
compact?: boolean
}
export function PhaseTimeline({
phases,
currentPhaseIndex,
onPhaseClick,
compact = false,
}: PhaseTimelineProps) {
return (
<div className={`flex items-center ${compact ? 'gap-2' : 'gap-3'}`}>
{phases.map((phase, index) => {
const isActive = index === currentPhaseIndex
const isCompleted = phase.status === 'completed'
const isPast = index < currentPhaseIndex
const colors = PHASE_COLORS[phase.id]
return (
<div key={phase.id} className="flex items-center">
{/* Phase Dot/Circle */}
<button
onClick={() => onPhaseClick?.(index)}
disabled={!onPhaseClick}
className={`
relative flex items-center justify-center
${compact ? 'w-8 h-8' : 'w-10 h-10'}
rounded-full font-semibold text-sm
transition-all duration-300
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
${isActive
? `ring-4 ring-offset-2 ${colors.tailwind} text-white`
: isCompleted || isPast
? `${colors.tailwind} text-white opacity-80`
: 'bg-slate-200 text-slate-500'
}
`}
style={{
backgroundColor: isActive || isCompleted || isPast ? colors.hex : undefined,
// Use CSS custom property for ring color with Tailwind
'--tw-ring-color': isActive ? colors.hex : undefined,
} as React.CSSProperties}
title={`${phase.displayName} (${formatMinutes(phase.duration)})`}
>
{isCompleted ? (
<Check className={compact ? 'w-4 h-4' : 'w-5 h-5'} />
) : (
phase.shortName
)}
{/* Active indicator pulse */}
{isActive && (
<span
className="absolute inset-0 rounded-full animate-ping opacity-30"
style={{ backgroundColor: colors.hex }}
/>
)}
</button>
{/* Connector Line */}
{index < phases.length - 1 && (
<div
className={`
${compact ? 'w-4' : 'w-8'} h-1 mx-1
${isPast || isCompleted
? 'bg-gradient-to-r'
: 'bg-slate-200'
}
`}
style={{
background: isPast || isCompleted
? `linear-gradient(to right, ${colors.hex}, ${PHASE_COLORS[phases[index + 1].id].hex})`
: undefined,
}}
/>
)}
</div>
)
})}
</div>
)
}
/**
* Detailed Phase Timeline with labels and durations
*/
export function PhaseTimelineDetailed({
phases,
currentPhaseIndex,
onPhaseClick,
}: PhaseTimelineProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Unterrichtsphasen</h3>
<div className="flex items-start justify-between">
{phases.map((phase, index) => {
const isActive = index === currentPhaseIndex
const isCompleted = phase.status === 'completed'
const isPast = index < currentPhaseIndex
const colors = PHASE_COLORS[phase.id]
return (
<div key={phase.id} className="flex flex-col items-center flex-1">
{/* Top connector line */}
<div className="w-full flex items-center mb-2">
{index > 0 && (
<div
className="flex-1 h-1"
style={{
background: isPast || isCompleted
? PHASE_COLORS[phases[index - 1].id].hex
: '#e2e8f0',
}}
/>
)}
{index === 0 && <div className="flex-1" />}
{/* Phase Circle */}
<button
onClick={() => onPhaseClick?.(index)}
disabled={!onPhaseClick}
className={`
relative w-12 h-12 rounded-full
flex items-center justify-center
font-bold text-lg
transition-all duration-300
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
${isActive ? 'ring-4 ring-offset-2 shadow-lg' : ''}
`}
style={{
backgroundColor: isActive || isCompleted || isPast ? colors.hex : '#e2e8f0',
color: isActive || isCompleted || isPast ? 'white' : '#64748b',
'--tw-ring-color': isActive ? `${colors.hex}40` : undefined,
} as React.CSSProperties}
>
{isCompleted ? (
<Check className="w-6 h-6" />
) : (
phase.shortName
)}
{isActive && (
<span
className="absolute inset-0 rounded-full animate-ping opacity-20"
style={{ backgroundColor: colors.hex }}
/>
)}
</button>
{index < phases.length - 1 && (
<div
className="flex-1 h-1"
style={{
background: isCompleted ? colors.hex : '#e2e8f0',
}}
/>
)}
{index === phases.length - 1 && <div className="flex-1" />}
</div>
{/* Phase Label */}
<span
className={`
text-sm font-medium mt-2
${isActive ? 'text-slate-900' : 'text-slate-500'}
`}
>
{phase.displayName}
</span>
{/* Duration */}
<span
className={`
text-xs mt-1
${isActive ? 'text-slate-700' : 'text-slate-400'}
`}
>
{formatMinutes(phase.duration)}
</span>
{/* Actual time if completed */}
{phase.actualTime !== undefined && phase.actualTime > 0 && (
<span className="text-xs text-slate-400 mt-0.5">
(tatsaechlich: {Math.round(phase.actualTime / 60)} Min)
</span>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import { Users, GraduationCap, BookOpen, FileCheck } from 'lucide-react'
import { CompanionStats } from '@/lib/companion/types'
interface StatsGridProps {
stats: CompanionStats
loading?: boolean
}
interface StatCardProps {
label: string
value: number
icon: React.ReactNode
color: string
loading?: boolean
}
function StatCard({ label, value, icon, color, loading }: StatCardProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500 mb-1">{label}</p>
{loading ? (
<div className="h-8 w-16 bg-slate-200 rounded animate-pulse" />
) : (
<p className="text-2xl font-bold text-slate-900">{value}</p>
)}
</div>
<div className={`p-2 rounded-lg ${color}`}>
{icon}
</div>
</div>
</div>
)
}
export function StatsGrid({ stats, loading }: StatsGridProps) {
const statCards = [
{
label: 'Klassen',
value: stats.classesCount,
icon: <Users className="w-5 h-5 text-blue-600" />,
color: 'bg-blue-100',
},
{
label: 'Schueler',
value: stats.studentsCount,
icon: <GraduationCap className="w-5 h-5 text-green-600" />,
color: 'bg-green-100',
},
{
label: 'Lerneinheiten',
value: stats.learningUnitsCreated,
icon: <BookOpen className="w-5 h-5 text-purple-600" />,
color: 'bg-purple-100',
},
{
label: 'Noten',
value: stats.gradesEntered,
icon: <FileCheck className="w-5 h-5 text-amber-600" />,
color: 'bg-amber-100',
},
]
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{statCards.map((card) => (
<StatCard
key={card.label}
label={card.label}
value={card.value}
icon={card.icon}
color={card.color}
loading={loading}
/>
))}
</div>
)
}
/**
* Compact version of StatsGrid for sidebar or smaller spaces
*/
export function StatsGridCompact({ stats, loading }: StatsGridProps) {
const items = [
{ label: 'Klassen', value: stats.classesCount, icon: <Users className="w-4 h-4" /> },
{ label: 'Schueler', value: stats.studentsCount, icon: <GraduationCap className="w-4 h-4" /> },
{ label: 'Einheiten', value: stats.learningUnitsCreated, icon: <BookOpen className="w-4 h-4" /> },
{ label: 'Noten', value: stats.gradesEntered, icon: <FileCheck className="w-4 h-4" /> },
]
return (
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-medium text-slate-500 mb-3">Statistiken</h3>
<div className="space-y-3">
{items.map((item) => (
<div key={item.label} className="flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-600">
{item.icon}
<span className="text-sm">{item.label}</span>
</div>
{loading ? (
<div className="h-5 w-8 bg-slate-200 rounded animate-pulse" />
) : (
<span className="font-semibold text-slate-900">{item.value}</span>
)}
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,170 @@
'use client'
import { ChevronRight, Clock, Lightbulb, ClipboardCheck, BookOpen, Calendar, Users, MessageSquare, FileText } from 'lucide-react'
import { Suggestion, SuggestionPriority } from '@/lib/companion/types'
import { PRIORITY_COLORS } from '@/lib/companion/constants'
interface SuggestionListProps {
suggestions: Suggestion[]
onSuggestionClick?: (suggestion: Suggestion) => void
loading?: boolean
maxItems?: number
}
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
ClipboardCheck,
BookOpen,
Calendar,
Users,
Clock,
MessageSquare,
FileText,
Lightbulb,
}
function getIcon(iconName: string) {
const Icon = iconMap[iconName] || Lightbulb
return Icon
}
interface SuggestionCardProps {
suggestion: Suggestion
onClick?: () => void
}
function SuggestionCard({ suggestion, onClick }: SuggestionCardProps) {
const priorityStyles = PRIORITY_COLORS[suggestion.priority]
const Icon = getIcon(suggestion.icon)
return (
<button
onClick={onClick}
className={`
w-full p-4 rounded-xl border text-left
transition-all duration-200
hover:shadow-md hover:scale-[1.01]
${priorityStyles.bg} ${priorityStyles.border}
`}
>
<div className="flex items-start gap-3">
{/* Priority Dot & Icon */}
<div className="flex-shrink-0 relative">
<div className={`p-2 rounded-lg bg-white shadow-sm`}>
<Icon className={`w-5 h-5 ${priorityStyles.text}`} />
</div>
<span
className={`absolute -top-1 -right-1 w-3 h-3 rounded-full ${priorityStyles.dot}`}
title={`Prioritaet: ${suggestion.priority}`}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h4 className={`font-medium ${priorityStyles.text} mb-1`}>
{suggestion.title}
</h4>
<p className="text-sm text-slate-600 line-clamp-2">
{suggestion.description}
</p>
{/* Meta */}
<div className="flex items-center gap-3 mt-2">
<span className="inline-flex items-center gap-1 text-xs text-slate-500">
<Clock className="w-3 h-3" />
~{suggestion.estimatedTime} Min
</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${priorityStyles.bg} ${priorityStyles.text}`}>
{suggestion.priority}
</span>
</div>
</div>
{/* Arrow */}
<ChevronRight className="w-5 h-5 text-slate-400 flex-shrink-0" />
</div>
</button>
)
}
export function SuggestionList({
suggestions,
onSuggestionClick,
loading,
maxItems = 5,
}: SuggestionListProps) {
// Sort by priority: urgent > high > medium > low
const priorityOrder: Record<SuggestionPriority, number> = {
urgent: 0,
high: 1,
medium: 2,
low: 3,
}
const sortedSuggestions = [...suggestions]
.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
.slice(0, maxItems)
if (loading) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-24 bg-slate-100 rounded-xl animate-pulse" />
))}
</div>
</div>
)
}
if (suggestions.length === 0) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<div className="text-center py-8">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
<ClipboardCheck className="w-6 h-6 text-green-600" />
</div>
<p className="text-slate-600">Alles erledigt!</p>
<p className="text-sm text-slate-500 mt-1">Keine offenen Aufgaben</p>
</div>
</div>
)
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<span className="text-sm text-slate-500">
{suggestions.length} Aufgabe{suggestions.length !== 1 ? 'n' : ''}
</span>
</div>
<div className="space-y-3">
{sortedSuggestions.map((suggestion) => (
<SuggestionCard
key={suggestion.id}
suggestion={suggestion}
onClick={() => onSuggestionClick?.(suggestion)}
/>
))}
</div>
{suggestions.length > maxItems && (
<button className="w-full mt-4 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors">
Alle {suggestions.length} anzeigen
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,24 @@
// Main components
export { CompanionDashboard } from './CompanionDashboard'
export { ModeToggle } from './ModeToggle'
// Companion Mode components
export { PhaseTimeline, PhaseTimelineDetailed } from './companion-mode/PhaseTimeline'
export { StatsGrid, StatsGridCompact } from './companion-mode/StatsGrid'
export { SuggestionList } from './companion-mode/SuggestionList'
export { EventsCard, EventsInline } from './companion-mode/EventsCard'
// Lesson Mode components
export { LessonContainer } from './lesson-mode/LessonContainer'
export { LessonStartForm } from './lesson-mode/LessonStartForm'
export { LessonActiveView } from './lesson-mode/LessonActiveView'
export { LessonEndedView } from './lesson-mode/LessonEndedView'
export { VisualPieTimer, CompactTimer } from './lesson-mode/VisualPieTimer'
export { QuickActionsBar, QuickActionsCompact } from './lesson-mode/QuickActionsBar'
export { HomeworkSection } from './lesson-mode/HomeworkSection'
export { ReflectionSection } from './lesson-mode/ReflectionSection'
// Modals
export { SettingsModal } from './modals/SettingsModal'
export { FeedbackModal } from './modals/FeedbackModal'
export { OnboardingModal } from './modals/OnboardingModal'

View File

@@ -0,0 +1,153 @@
'use client'
import { useState } from 'react'
import { Plus, Trash2, BookOpen, Calendar } from 'lucide-react'
import { Homework } from '@/lib/companion/types'
interface HomeworkSectionProps {
homeworkList: Homework[]
onAdd: (title: string, dueDate: string) => void
onRemove: (id: string) => void
}
export function HomeworkSection({ homeworkList, onAdd, onRemove }: HomeworkSectionProps) {
const [newTitle, setNewTitle] = useState('')
const [newDueDate, setNewDueDate] = useState('')
const [isAdding, setIsAdding] = useState(false)
// Default due date to next week
const getDefaultDueDate = () => {
const date = new Date()
date.setDate(date.getDate() + 7)
return date.toISOString().split('T')[0]
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!newTitle.trim()) return
onAdd(newTitle.trim(), newDueDate || getDefaultDueDate())
setNewTitle('')
setNewDueDate('')
setIsAdding(false)
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<BookOpen className="w-5 h-5 text-slate-400" />
Hausaufgaben
</h3>
{!isAdding && (
<button
onClick={() => setIsAdding(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
Hinzufuegen
</button>
)}
</div>
{/* Add Form */}
{isAdding && (
<form onSubmit={handleSubmit} className="mb-4 p-4 bg-blue-50 rounded-xl">
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgabe
</label>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="z.B. Aufgabe 1-5 auf S. 42..."
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Faellig am
</label>
<input
type="date"
value={newDueDate}
onChange={(e) => setNewDueDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={!newTitle.trim()}
className="flex-1 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Speichern
</button>
<button
type="button"
onClick={() => {
setIsAdding(false)
setNewTitle('')
setNewDueDate('')
}}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg"
>
Abbrechen
</button>
</div>
</div>
</form>
)}
{/* Homework List */}
{homeworkList.length === 0 ? (
<div className="text-center py-8">
<BookOpen className="w-10 h-10 text-slate-300 mx-auto mb-2" />
<p className="text-slate-500">Keine Hausaufgaben eingetragen</p>
<p className="text-sm text-slate-400 mt-1">
Fuegen Sie Hausaufgaben hinzu, um sie zu dokumentieren
</p>
</div>
) : (
<div className="space-y-3">
{homeworkList.map((hw) => (
<div
key={hw.id}
className="flex items-start gap-3 p-4 bg-slate-50 rounded-xl group"
>
<div className="flex-1">
<p className="font-medium text-slate-900">{hw.title}</p>
<div className="flex items-center gap-2 mt-1 text-sm text-slate-500">
<Calendar className="w-3 h-3" />
<span>Faellig: {formatDate(hw.dueDate)}</span>
</div>
</div>
<button
onClick={() => onRemove(hw.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg opacity-0 group-hover:opacity-100 transition-all"
title="Entfernen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,172 @@
'use client'
import { BookOpen, Clock, Users } from 'lucide-react'
import { LessonSession } from '@/lib/companion/types'
import { VisualPieTimer } from './VisualPieTimer'
import { QuickActionsBar } from './QuickActionsBar'
import { PhaseTimelineDetailed } from '../companion-mode/PhaseTimeline'
import {
PHASE_COLORS,
PHASE_DISPLAY_NAMES,
formatTime,
getTimerColorStatus,
} from '@/lib/companion/constants'
interface LessonActiveViewProps {
session: LessonSession
onPauseToggle: () => void
onExtendTime: (minutes: number) => void
onSkipPhase: () => void
onEndLesson: () => void
}
export function LessonActiveView({
session,
onPauseToggle,
onExtendTime,
onSkipPhase,
onEndLesson,
}: LessonActiveViewProps) {
const currentPhase = session.phases[session.currentPhaseIndex]
const phaseId = currentPhase?.phase || 'einstieg'
const phaseColor = PHASE_COLORS[phaseId].hex
const phaseName = PHASE_DISPLAY_NAMES[phaseId]
// Calculate timer values
const phaseDurationSeconds = (currentPhase?.duration || 0) * 60
const elapsedInPhase = currentPhase?.actualTime || 0
const remainingSeconds = phaseDurationSeconds - elapsedInPhase
const progress = Math.min(elapsedInPhase / phaseDurationSeconds, 1)
const isOvertime = remainingSeconds < 0
const colorStatus = getTimerColorStatus(remainingSeconds, isOvertime)
const isLastPhase = session.currentPhaseIndex === session.phases.length - 1
// Calculate total elapsed
const totalElapsedMinutes = Math.floor(session.elapsedTime / 60)
return (
<div className="space-y-6">
{/* Header with Session Info */}
<div
className="bg-gradient-to-r rounded-xl p-6 text-white"
style={{
background: `linear-gradient(135deg, ${phaseColor}, ${phaseColor}dd)`,
}}
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-white/80 text-sm mb-1">
<Users className="w-4 h-4" />
<span>{session.className}</span>
<span className="mx-2">|</span>
<BookOpen className="w-4 h-4" />
<span>{session.subject}</span>
</div>
<h2 className="text-2xl font-bold">
{session.topic || phaseName}
</h2>
</div>
<div className="text-right">
<div className="text-white/80 text-sm">Gesamtzeit</div>
<div className="text-xl font-mono font-bold">
{formatTime(session.elapsedTime)}
</div>
</div>
</div>
</div>
{/* Main Timer Section */}
<div className="bg-white border border-slate-200 rounded-xl p-8">
<div className="flex flex-col items-center">
{/* Visual Pie Timer */}
<VisualPieTimer
progress={progress}
remainingSeconds={remainingSeconds}
totalSeconds={phaseDurationSeconds}
colorStatus={colorStatus}
isPaused={session.isPaused}
currentPhaseName={phaseName}
phaseColor={phaseColor}
onTogglePause={onPauseToggle}
size="lg"
/>
{/* Quick Actions */}
<div className="mt-8 w-full max-w-md">
<QuickActionsBar
onExtend={onExtendTime}
onPause={onPauseToggle}
onResume={onPauseToggle}
onSkip={onSkipPhase}
onEnd={onEndLesson}
isPaused={session.isPaused}
isLastPhase={isLastPhase}
/>
</div>
</div>
</div>
{/* Phase Timeline */}
<PhaseTimelineDetailed
phases={session.phases.map((p, i) => ({
id: p.phase,
shortName: p.phase[0].toUpperCase(),
displayName: PHASE_DISPLAY_NAMES[p.phase],
duration: p.duration,
status: p.status === 'active' ? 'active' : p.status === 'completed' ? 'completed' : 'planned',
actualTime: p.actualTime,
color: PHASE_COLORS[p.phase].hex,
}))}
currentPhaseIndex={session.currentPhaseIndex}
onPhaseClick={(index) => {
// Optional: Allow clicking to navigate to a phase
}}
/>
{/* Lesson Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-slate-900">{totalElapsedMinutes}</div>
<div className="text-sm text-slate-500">Minuten vergangen</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<div
className="w-5 h-5 rounded-full mx-auto mb-2"
style={{ backgroundColor: phaseColor }}
/>
<div className="text-2xl font-bold text-slate-900">
{session.currentPhaseIndex + 1}/{session.phases.length}
</div>
<div className="text-sm text-slate-500">Phase</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-slate-900">
{session.totalPlannedDuration - totalElapsedMinutes}
</div>
<div className="text-sm text-slate-500">Minuten verbleibend</div>
</div>
</div>
{/* Keyboard Shortcuts Hint */}
<div className="text-center text-sm text-slate-400">
<span className="inline-flex items-center gap-4">
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">Leertaste</kbd> Pause
</span>
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">E</kbd> +5 Min
</span>
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">N</kbd> Weiter
</span>
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
'use client'
import { LessonSession, LessonStatus } from '@/lib/companion/types'
import { LessonStartForm } from './LessonStartForm'
import { LessonActiveView } from './LessonActiveView'
import { LessonEndedView } from './LessonEndedView'
interface LessonContainerProps {
session: LessonSession | null
onStartLesson: (data: { classId: string; subject: string; topic?: string; templateId?: string }) => void
onEndLesson: () => void
onPauseToggle: () => void
onExtendTime: (minutes: number) => void
onSkipPhase: () => void
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
onAddHomework: (title: string, dueDate: string) => void
onRemoveHomework: (id: string) => void
loading?: boolean
}
export function LessonContainer({
session,
onStartLesson,
onEndLesson,
onPauseToggle,
onExtendTime,
onSkipPhase,
onSaveReflection,
onAddHomework,
onRemoveHomework,
loading,
}: LessonContainerProps) {
// Determine which view to show based on session state
const getView = (): 'start' | 'active' | 'ended' => {
if (!session) return 'start'
const status = session.status
if (status === 'completed') return 'ended'
if (status === 'not_started') return 'start'
return 'active'
}
const view = getView()
if (view === 'start') {
return (
<LessonStartForm
onStart={onStartLesson}
loading={loading}
/>
)
}
if (view === 'ended' && session) {
return (
<LessonEndedView
session={session}
onSaveReflection={onSaveReflection}
onAddHomework={onAddHomework}
onRemoveHomework={onRemoveHomework}
onStartNew={() => onEndLesson()} // This will clear the session and show start form
/>
)
}
if (session) {
return (
<LessonActiveView
session={session}
onPauseToggle={onPauseToggle}
onExtendTime={onExtendTime}
onSkipPhase={onSkipPhase}
onEndLesson={onEndLesson}
/>
)
}
return null
}

View File

@@ -0,0 +1,209 @@
'use client'
import { useState } from 'react'
import { CheckCircle, Clock, BarChart3, Plus, RefreshCw } from 'lucide-react'
import { LessonSession } from '@/lib/companion/types'
import { HomeworkSection } from './HomeworkSection'
import { ReflectionSection } from './ReflectionSection'
import {
PHASE_COLORS,
PHASE_DISPLAY_NAMES,
formatTime,
formatMinutes,
} from '@/lib/companion/constants'
interface LessonEndedViewProps {
session: LessonSession
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
onAddHomework: (title: string, dueDate: string) => void
onRemoveHomework: (id: string) => void
onStartNew: () => void
}
export function LessonEndedView({
session,
onSaveReflection,
onAddHomework,
onRemoveHomework,
onStartNew,
}: LessonEndedViewProps) {
const [activeTab, setActiveTab] = useState<'summary' | 'homework' | 'reflection'>('summary')
// Calculate analytics
const totalPlannedSeconds = session.totalPlannedDuration * 60
const totalActualSeconds = session.elapsedTime
const timeDiff = totalActualSeconds - totalPlannedSeconds
const timeDiffMinutes = Math.round(timeDiff / 60)
const startTime = new Date(session.startTime)
const endTime = session.endTime ? new Date(session.endTime) : new Date()
return (
<div className="space-y-6">
{/* Success Header */}
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-xl p-6 text-white">
<div className="flex items-center gap-4">
<div className="p-3 bg-white/20 rounded-full">
<CheckCircle className="w-8 h-8" />
</div>
<div>
<h2 className="text-2xl font-bold">Stunde beendet!</h2>
<p className="text-green-100">
{session.className} - {session.subject}
{session.topic && ` - ${session.topic}`}
</p>
</div>
</div>
</div>
{/* Tab Navigation */}
<div className="bg-white border border-slate-200 rounded-xl p-1 flex">
{[
{ id: 'summary', label: 'Zusammenfassung', icon: BarChart3 },
{ id: 'homework', label: 'Hausaufgaben', icon: Plus },
{ id: 'reflection', label: 'Reflexion', icon: RefreshCw },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`
flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg
font-medium transition-all duration-200
${activeTab === tab.id
? 'bg-slate-900 text-white'
: 'text-slate-600 hover:bg-slate-100'
}
`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'summary' && (
<div className="space-y-6">
{/* Time Overview */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-slate-400" />
Zeitauswertung
</h3>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="text-center p-4 bg-slate-50 rounded-xl">
<div className="text-2xl font-bold text-slate-900">
{formatTime(totalActualSeconds)}
</div>
<div className="text-sm text-slate-500">Tatsaechlich</div>
</div>
<div className="text-center p-4 bg-slate-50 rounded-xl">
<div className="text-2xl font-bold text-slate-900">
{formatMinutes(session.totalPlannedDuration)}
</div>
<div className="text-sm text-slate-500">Geplant</div>
</div>
<div className={`text-center p-4 rounded-xl ${timeDiff > 0 ? 'bg-amber-50' : 'bg-green-50'}`}>
<div className={`text-2xl font-bold ${timeDiff > 0 ? 'text-amber-600' : 'text-green-600'}`}>
{timeDiffMinutes > 0 ? '+' : ''}{timeDiffMinutes} Min
</div>
<div className={`text-sm ${timeDiff > 0 ? 'text-amber-500' : 'text-green-500'}`}>
Differenz
</div>
</div>
</div>
{/* Session Times */}
<div className="flex items-center justify-between text-sm text-slate-500 border-t border-slate-100 pt-4">
<span>Start: {startTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
<span>Ende: {endTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
{/* Phase Breakdown */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-slate-400" />
Phasen-Analyse
</h3>
<div className="space-y-4">
{session.phases.map((phase) => {
const plannedSeconds = phase.duration * 60
const actualSeconds = phase.actualTime
const diff = actualSeconds - plannedSeconds
const diffMinutes = Math.round(diff / 60)
const percentage = Math.min((actualSeconds / plannedSeconds) * 100, 150)
return (
<div key={phase.phase} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: PHASE_COLORS[phase.phase].hex }}
/>
<span className="font-medium text-slate-700">
{PHASE_DISPLAY_NAMES[phase.phase]}
</span>
</div>
<div className="flex items-center gap-3 text-slate-500">
<span>{Math.round(actualSeconds / 60)} / {phase.duration} Min</span>
<span className={`
px-2 py-0.5 rounded text-xs font-medium
${diff > 60 ? 'bg-amber-100 text-amber-700' : diff < -60 ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}
`}>
{diffMinutes > 0 ? '+' : ''}{diffMinutes} Min
</span>
</div>
</div>
{/* Progress Bar */}
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(percentage, 100)}%`,
backgroundColor: percentage > 100
? '#f59e0b' // amber for overtime
: PHASE_COLORS[phase.phase].hex,
}}
/>
</div>
</div>
)
})}
</div>
</div>
</div>
)}
{activeTab === 'homework' && (
<HomeworkSection
homeworkList={session.homeworkList}
onAdd={onAddHomework}
onRemove={onRemoveHomework}
/>
)}
{activeTab === 'reflection' && (
<ReflectionSection
reflection={session.reflection}
onSave={onSaveReflection}
/>
)}
{/* Start New Lesson Button */}
<div className="pt-4">
<button
onClick={onStartNew}
className="w-full py-4 px-6 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors flex items-center justify-center gap-2"
>
<RefreshCw className="w-5 h-5" />
Neue Stunde starten
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,269 @@
'use client'
import { useState } from 'react'
import { Play, Clock, BookOpen, Users, ChevronDown, Info } from 'lucide-react'
import { LessonTemplate, PhaseDurations, Class } from '@/lib/companion/types'
import {
SYSTEM_TEMPLATES,
DEFAULT_PHASE_DURATIONS,
PHASE_DISPLAY_NAMES,
PHASE_ORDER,
calculateTotalDuration,
formatMinutes,
} from '@/lib/companion/constants'
interface LessonStartFormProps {
onStart: (data: {
classId: string
subject: string
topic?: string
templateId?: string
}) => void
loading?: boolean
availableClasses?: Class[]
}
// Mock classes for development
const MOCK_CLASSES: Class[] = [
{ id: 'c1', name: '9a', grade: '9', studentCount: 28 },
{ id: 'c2', name: '9b', grade: '9', studentCount: 26 },
{ id: 'c3', name: '10a', grade: '10', studentCount: 24 },
{ id: 'c4', name: 'Deutsch LK', grade: 'Q1', studentCount: 18 },
{ id: 'c5', name: 'Mathe GK', grade: 'Q2', studentCount: 22 },
]
const SUBJECTS = [
'Deutsch',
'Mathematik',
'Englisch',
'Biologie',
'Physik',
'Chemie',
'Geschichte',
'Geographie',
'Politik',
'Kunst',
'Musik',
'Sport',
'Informatik',
'Sonstiges',
]
export function LessonStartForm({
onStart,
loading,
availableClasses = MOCK_CLASSES,
}: LessonStartFormProps) {
const [selectedClass, setSelectedClass] = useState('')
const [selectedSubject, setSelectedSubject] = useState('')
const [topic, setTopic] = useState('')
const [selectedTemplate, setSelectedTemplate] = useState<LessonTemplate | null>(
SYSTEM_TEMPLATES[0] as LessonTemplate
)
const [showTemplateDetails, setShowTemplateDetails] = useState(false)
const totalDuration = selectedTemplate
? calculateTotalDuration(selectedTemplate.durations)
: calculateTotalDuration(DEFAULT_PHASE_DURATIONS)
const canStart = selectedClass && selectedSubject
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!canStart) return
onStart({
classId: selectedClass,
subject: selectedSubject,
topic: topic || undefined,
templateId: selectedTemplate?.templateId,
})
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-blue-100 rounded-xl">
<Play className="w-6 h-6 text-blue-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-slate-900">Neue Stunde starten</h2>
<p className="text-sm text-slate-500">
Waehlen Sie Klasse, Fach und Template
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Class Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<Users className="w-4 h-4 inline mr-2" />
Klasse *
</label>
<select
value={selectedClass}
onChange={(e) => setSelectedClass(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
required
>
<option value="">Klasse auswaehlen...</option>
{availableClasses.map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name} ({cls.studentCount} Schueler)
</option>
))}
</select>
</div>
{/* Subject Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<BookOpen className="w-4 h-4 inline mr-2" />
Fach *
</label>
<select
value={selectedSubject}
onChange={(e) => setSelectedSubject(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
required
>
<option value="">Fach auswaehlen...</option>
{SUBJECTS.map((subject) => (
<option key={subject} value={subject}>
{subject}
</option>
))}
</select>
</div>
{/* Topic (Optional) */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Thema (optional)
</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="z.B. Quadratische Funktionen, Gedichtanalyse..."
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Template Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<Clock className="w-4 h-4 inline mr-2" />
Template
</label>
<div className="space-y-2">
{SYSTEM_TEMPLATES.map((template) => {
const tpl = template as LessonTemplate
const isSelected = selectedTemplate?.templateId === tpl.templateId
const total = calculateTotalDuration(tpl.durations)
return (
<button
key={tpl.templateId}
type="button"
onClick={() => setSelectedTemplate(tpl)}
className={`
w-full p-4 rounded-xl border text-left transition-all
${isSelected
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500/20'
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}
`}
>
<div className="flex items-center justify-between">
<div>
<p className={`font-medium ${isSelected ? 'text-blue-900' : 'text-slate-900'}`}>
{tpl.name}
</p>
<p className="text-sm text-slate-500">{tpl.description}</p>
</div>
<span className={`text-sm font-medium ${isSelected ? 'text-blue-600' : 'text-slate-500'}`}>
{formatMinutes(total)}
</span>
</div>
</button>
)
})}
</div>
{/* Template Details Toggle */}
{selectedTemplate && (
<button
type="button"
onClick={() => setShowTemplateDetails(!showTemplateDetails)}
className="mt-3 flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
>
<Info className="w-4 h-4" />
Phasendauern anzeigen
<ChevronDown className={`w-4 h-4 transition-transform ${showTemplateDetails ? 'rotate-180' : ''}`} />
</button>
)}
{/* Template Details */}
{showTemplateDetails && selectedTemplate && (
<div className="mt-3 p-4 bg-slate-50 rounded-xl">
<div className="grid grid-cols-5 gap-2">
{PHASE_ORDER.map((phaseId) => (
<div key={phaseId} className="text-center">
<p className="text-xs text-slate-500">{PHASE_DISPLAY_NAMES[phaseId]}</p>
<p className="font-medium text-slate-900">
{selectedTemplate.durations[phaseId]} Min
</p>
</div>
))}
</div>
</div>
)}
</div>
{/* Summary & Start Button */}
<div className="pt-4 border-t border-slate-200">
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-slate-600">
Gesamtdauer: <span className="font-semibold">{formatMinutes(totalDuration)}</span>
</div>
{selectedClass && (
<div className="text-sm text-slate-600">
Klasse: <span className="font-semibold">
{availableClasses.find((c) => c.id === selectedClass)?.name}
</span>
</div>
)}
</div>
<button
type="submit"
disabled={!canStart || loading}
className={`
w-full py-4 px-6 rounded-xl font-semibold text-lg
flex items-center justify-center gap-3
transition-all duration-200
${canStart && !loading
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-lg shadow-blue-500/25'
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
}
`}
>
{loading ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Stunde wird gestartet...
</>
) : (
<>
<Play className="w-5 h-5" />
Stunde starten
</>
)}
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,194 @@
'use client'
import { Plus, Pause, Play, SkipForward, Square, Clock } from 'lucide-react'
interface QuickActionsBarProps {
onExtend: (minutes: number) => void
onPause: () => void
onResume: () => void
onSkip: () => void
onEnd: () => void
isPaused: boolean
isLastPhase: boolean
disabled?: boolean
}
export function QuickActionsBar({
onExtend,
onPause,
onResume,
onSkip,
onEnd,
isPaused,
isLastPhase,
disabled,
}: QuickActionsBarProps) {
return (
<div
className="flex items-center justify-center gap-3 p-4 bg-white border border-slate-200 rounded-xl"
role="toolbar"
aria-label="Steuerung"
>
{/* Extend +5 Min */}
<button
onClick={() => onExtend(5)}
disabled={disabled || isPaused}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled || isPaused
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-blue-50 text-blue-700 hover:bg-blue-100 active:scale-95'
}
`}
title="+5 Minuten (E)"
aria-keyshortcuts="e"
aria-label="5 Minuten verlaengern"
>
<Plus className="w-5 h-5" />
<span>5 Min</span>
</button>
{/* Pause / Resume */}
<button
onClick={isPaused ? onResume : onPause}
disabled={disabled}
className={`
flex items-center gap-2 px-6 py-3 rounded-xl
font-semibold transition-all duration-200
min-w-[52px] min-h-[52px] justify-center
${disabled
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: isPaused
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-500/25 active:scale-95'
: 'bg-amber-500 text-white hover:bg-amber-600 shadow-lg shadow-amber-500/25 active:scale-95'
}
`}
title={isPaused ? 'Fortsetzen (Leertaste)' : 'Pausieren (Leertaste)'}
aria-keyshortcuts="Space"
aria-label={isPaused ? 'Stunde fortsetzen' : 'Stunde pausieren'}
>
{isPaused ? (
<>
<Play className="w-5 h-5" />
<span>Fortsetzen</span>
</>
) : (
<>
<Pause className="w-5 h-5" />
<span>Pause</span>
</>
)}
</button>
{/* Skip Phase / End Lesson */}
{isLastPhase ? (
<button
onClick={onEnd}
disabled={disabled}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-red-50 text-red-700 hover:bg-red-100 active:scale-95'
}
`}
title="Stunde beenden"
aria-label="Stunde beenden"
>
<Square className="w-5 h-5" />
<span>Beenden</span>
</button>
) : (
<button
onClick={onSkip}
disabled={disabled || isPaused}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled || isPaused
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 active:scale-95'
}
`}
title="Naechste Phase (N)"
aria-keyshortcuts="n"
aria-label="Zur naechsten Phase springen"
>
<SkipForward className="w-5 h-5" />
<span>Weiter</span>
</button>
)}
</div>
)
}
/**
* Compact version for mobile or sidebar
*/
export function QuickActionsCompact({
onExtend,
onPause,
onResume,
onSkip,
isPaused,
isLastPhase,
disabled,
}: Omit<QuickActionsBarProps, 'onEnd'>) {
return (
<div className="flex items-center gap-2">
<button
onClick={() => onExtend(5)}
disabled={disabled || isPaused}
className={`
p-2 rounded-lg transition-all
${disabled || isPaused
? 'text-slate-300'
: 'text-blue-600 hover:bg-blue-50'
}
`}
title="+5 Min"
>
<Clock className="w-5 h-5" />
</button>
<button
onClick={isPaused ? onResume : onPause}
disabled={disabled}
className={`
p-2 rounded-lg transition-all
${disabled
? 'text-slate-300'
: isPaused
? 'text-green-600 hover:bg-green-50'
: 'text-amber-600 hover:bg-amber-50'
}
`}
title={isPaused ? 'Fortsetzen' : 'Pausieren'}
>
{isPaused ? <Play className="w-5 h-5" /> : <Pause className="w-5 h-5" />}
</button>
{!isLastPhase && (
<button
onClick={onSkip}
disabled={disabled || isPaused}
className={`
p-2 rounded-lg transition-all
${disabled || isPaused
? 'text-slate-300'
: 'text-slate-600 hover:bg-slate-50'
}
`}
title="Naechste Phase"
>
<SkipForward className="w-5 h-5" />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,146 @@
'use client'
import { useState, useEffect } from 'react'
import { Star, Save, CheckCircle } from 'lucide-react'
import { LessonReflection } from '@/lib/companion/types'
interface ReflectionSectionProps {
reflection?: LessonReflection
onSave: (rating: number, notes: string, nextSteps: string) => void
}
export function ReflectionSection({ reflection, onSave }: ReflectionSectionProps) {
const [rating, setRating] = useState(reflection?.rating || 0)
const [notes, setNotes] = useState(reflection?.notes || '')
const [nextSteps, setNextSteps] = useState(reflection?.nextSteps || '')
const [hoverRating, setHoverRating] = useState(0)
const [saved, setSaved] = useState(false)
useEffect(() => {
if (reflection) {
setRating(reflection.rating)
setNotes(reflection.notes)
setNextSteps(reflection.nextSteps)
}
}, [reflection])
const handleSave = () => {
if (rating === 0) return
onSave(rating, notes, nextSteps)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const ratingLabels = [
'', // 0
'Verbesserungsbedarf',
'Okay',
'Gut',
'Sehr gut',
'Ausgezeichnet',
]
return (
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-6">
{/* Star Rating */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Wie lief die Stunde?
</label>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((star) => {
const isFilled = star <= (hoverRating || rating)
return (
<button
key={star}
type="button"
onClick={() => setRating(star)}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
className="p-1 transition-transform hover:scale-110"
aria-label={`${star} Stern${star > 1 ? 'e' : ''}`}
>
<Star
className={`w-8 h-8 ${
isFilled
? 'fill-amber-400 text-amber-400'
: 'text-slate-300'
}`}
/>
</button>
)
})}
{(hoverRating || rating) > 0 && (
<span className="ml-3 text-sm text-slate-600">
{ratingLabels[hoverRating || rating]}
</span>
)}
</div>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Notizen zur Stunde
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Was lief gut? Was koennte besser laufen? Besondere Vorkommnisse..."
rows={4}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
{/* Next Steps */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Naechste Schritte
</label>
<textarea
value={nextSteps}
onChange={(e) => setNextSteps(e.target.value)}
placeholder="Was muss fuer die naechste Stunde vorbereitet werden? Follow-ups..."
rows={3}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
{/* Save Button */}
<button
onClick={handleSave}
disabled={rating === 0}
className={`
w-full py-3 px-6 rounded-xl font-semibold
flex items-center justify-center gap-2
transition-all duration-200
${saved
? 'bg-green-600 text-white'
: rating === 0
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{saved ? (
<>
<CheckCircle className="w-5 h-5" />
Gespeichert!
</>
) : (
<>
<Save className="w-5 h-5" />
Reflexion speichern
</>
)}
</button>
{/* Previous Reflection Info */}
{reflection?.savedAt && (
<p className="text-center text-sm text-slate-400">
Zuletzt gespeichert: {new Date(reflection.savedAt).toLocaleString('de-DE')}
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,220 @@
'use client'
import { Pause, Play } from 'lucide-react'
import { TimerColorStatus } from '@/lib/companion/types'
import {
PIE_TIMER_RADIUS,
PIE_TIMER_CIRCUMFERENCE,
PIE_TIMER_STROKE_WIDTH,
PIE_TIMER_SIZE,
TIMER_COLOR_CLASSES,
TIMER_BG_COLORS,
formatTime,
} from '@/lib/companion/constants'
interface VisualPieTimerProps {
progress: number // 0-1 (how much time has elapsed)
remainingSeconds: number
totalSeconds: number
colorStatus: TimerColorStatus
isPaused: boolean
currentPhaseName: string
phaseColor: string
onTogglePause?: () => void
size?: 'sm' | 'md' | 'lg'
}
const sizeConfig = {
sm: { outer: 120, viewBox: 100, radius: 38, stroke: 6, fontSize: 'text-lg' },
md: { outer: 180, viewBox: 100, radius: 40, stroke: 7, fontSize: 'text-2xl' },
lg: { outer: 240, viewBox: 100, radius: 42, stroke: 8, fontSize: 'text-4xl' },
}
export function VisualPieTimer({
progress,
remainingSeconds,
totalSeconds,
colorStatus,
isPaused,
currentPhaseName,
phaseColor,
onTogglePause,
size = 'lg',
}: VisualPieTimerProps) {
const config = sizeConfig[size]
const circumference = 2 * Math.PI * config.radius
// Calculate stroke-dashoffset for progress
// Progress goes from 0 (full) to 1 (empty), so offset decreases as time passes
const strokeDashoffset = circumference * (1 - progress)
// For overtime, show a pulsing full circle
const isOvertime = colorStatus === 'overtime'
const displayTime = formatTime(remainingSeconds)
// Get color classes based on status
const colorClasses = TIMER_COLOR_CLASSES[colorStatus]
const bgColorClass = TIMER_BG_COLORS[colorStatus]
return (
<div className="flex flex-col items-center">
{/* Timer Circle */}
<div
className={`relative ${bgColorClass} rounded-full p-4 transition-colors duration-300`}
style={{ width: config.outer, height: config.outer }}
>
<svg
width="100%"
height="100%"
viewBox={`0 0 ${config.viewBox} ${config.viewBox}`}
className="transform -rotate-90"
>
{/* Background circle */}
<circle
cx={config.viewBox / 2}
cy={config.viewBox / 2}
r={config.radius}
fill="none"
stroke="currentColor"
strokeWidth={config.stroke}
className="text-slate-200"
/>
{/* Progress circle */}
<circle
cx={config.viewBox / 2}
cy={config.viewBox / 2}
r={config.radius}
fill="none"
stroke={isOvertime ? '#dc2626' : phaseColor}
strokeWidth={config.stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={isOvertime ? 0 : strokeDashoffset}
className={`transition-all duration-100 ${isOvertime ? 'animate-pulse' : ''}`}
/>
</svg>
{/* Center Content */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
{/* Time Display */}
<span
className={`
font-mono font-bold ${config.fontSize}
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
`}
>
{displayTime}
</span>
{/* Phase Name */}
<span className="text-sm text-slate-500 mt-1">
{currentPhaseName}
</span>
{/* Paused Indicator */}
{isPaused && (
<span className="text-xs text-amber-600 font-medium mt-1 flex items-center gap-1">
<Pause className="w-3 h-3" />
Pausiert
</span>
)}
{/* Overtime Badge */}
{isOvertime && (
<span className="absolute -bottom-2 px-2 py-0.5 bg-red-600 text-white text-xs font-bold rounded-full">
+{Math.abs(Math.floor(remainingSeconds / 60))} Min
</span>
)}
</div>
{/* Pause/Play Button (overlay) */}
{onTogglePause && (
<button
onClick={onTogglePause}
className={`
absolute inset-0 rounded-full
flex items-center justify-center
opacity-0 hover:opacity-100
bg-black/20 backdrop-blur-sm
transition-opacity duration-200
`}
aria-label={isPaused ? 'Fortsetzen' : 'Pausieren'}
>
{isPaused ? (
<Play className="w-12 h-12 text-white" />
) : (
<Pause className="w-12 h-12 text-white" />
)}
</button>
)}
</div>
{/* Status Text */}
<div className="mt-4 text-center">
{isOvertime ? (
<p className="text-red-600 font-semibold animate-pulse">
Ueberzogen - Zeit fuer die naechste Phase!
</p>
) : colorStatus === 'critical' ? (
<p className="text-red-500 font-medium">
Weniger als 2 Minuten verbleibend
</p>
) : colorStatus === 'warning' ? (
<p className="text-amber-500">
Weniger als 5 Minuten verbleibend
</p>
) : null}
</div>
</div>
)
}
/**
* Compact timer for header/toolbar
*/
export function CompactTimer({
remainingSeconds,
colorStatus,
isPaused,
phaseName,
phaseColor,
}: {
remainingSeconds: number
colorStatus: TimerColorStatus
isPaused: boolean
phaseName: string
phaseColor: string
}) {
const isOvertime = colorStatus === 'overtime'
return (
<div className="flex items-center gap-3 px-4 py-2 bg-white border border-slate-200 rounded-xl">
{/* Phase indicator */}
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: phaseColor }}
/>
{/* Phase name */}
<span className="text-sm font-medium text-slate-600">{phaseName}</span>
{/* Time */}
<span
className={`
font-mono font-bold
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
`}
>
{formatTime(remainingSeconds)}
</span>
{/* Paused badge */}
{isPaused && (
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
Pausiert
</span>
)}
</div>
)
}

View File

@@ -0,0 +1,201 @@
'use client'
import { useState } from 'react'
import { X, MessageSquare, Bug, Lightbulb, Send, CheckCircle } from 'lucide-react'
import { FeedbackType } from '@/lib/companion/types'
interface FeedbackModalProps {
isOpen: boolean
onClose: () => void
onSubmit: (type: FeedbackType, title: string, description: string) => Promise<void>
}
const feedbackTypes: { id: FeedbackType; label: string; icon: typeof Bug; color: string }[] = [
{ id: 'bug', label: 'Bug melden', icon: Bug, color: 'text-red-600 bg-red-50' },
{ id: 'feature', label: 'Feature-Wunsch', icon: Lightbulb, color: 'text-amber-600 bg-amber-50' },
{ id: 'feedback', label: 'Allgemeines Feedback', icon: MessageSquare, color: 'text-blue-600 bg-blue-50' },
]
export function FeedbackModal({ isOpen, onClose, onSubmit }: FeedbackModalProps) {
const [type, setType] = useState<FeedbackType>('feedback')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
if (!isOpen) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !description.trim()) return
setIsSubmitting(true)
try {
await onSubmit(type, title.trim(), description.trim())
setIsSuccess(true)
setTimeout(() => {
setIsSuccess(false)
setTitle('')
setDescription('')
setType('feedback')
onClose()
}, 2000)
} catch (error) {
console.error('Failed to submit feedback:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-xl">
<MessageSquare className="w-5 h-5 text-blue-600" />
</div>
<h2 className="text-xl font-semibold text-slate-900">Feedback senden</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Success State */}
{isSuccess ? (
<div className="p-12 text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-xl font-semibold text-slate-900 mb-2">Vielen Dank!</h3>
<p className="text-slate-600">Ihr Feedback wurde erfolgreich gesendet.</p>
</div>
) : (
<form onSubmit={handleSubmit}>
{/* Content */}
<div className="p-6 space-y-6">
{/* Feedback Type */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Art des Feedbacks
</label>
<div className="grid grid-cols-3 gap-3">
{feedbackTypes.map((ft) => (
<button
key={ft.id}
type="button"
onClick={() => setType(ft.id)}
className={`
p-4 rounded-xl border-2 text-center transition-all
${type === ft.id
? 'border-blue-500 bg-blue-50'
: 'border-slate-200 hover:border-slate-300'
}
`}
>
<div className={`w-10 h-10 rounded-lg ${ft.color} flex items-center justify-center mx-auto mb-2`}>
<ft.icon className="w-5 h-5" />
</div>
<span className={`text-sm font-medium ${type === ft.id ? 'text-blue-700' : 'text-slate-700'}`}>
{ft.label}
</span>
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Titel *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={
type === 'bug'
? 'z.B. Timer stoppt nach Pause nicht mehr'
: type === 'feature'
? 'z.B. Materialien an Stunde anhaengen'
: 'z.B. Super nuetzliches Tool!'
}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Beschreibung *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
type === 'bug'
? 'Bitte beschreiben Sie den Fehler moeglichst genau. Was haben Sie gemacht? Was ist passiert? Was haetten Sie erwartet?'
: type === 'feature'
? 'Beschreiben Sie die gewuenschte Funktion. Warum waere sie hilfreich?'
: 'Teilen Sie uns Ihre Gedanken mit...'
}
rows={5}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
required
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-slate-200 bg-slate-50">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={!title.trim() || !description.trim() || isSubmitting}
className={`
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
transition-all duration-200
${!title.trim() || !description.trim() || isSubmitting
? 'bg-slate-200 text-slate-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{isSubmitting ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Senden...
</>
) : (
<>
<Send className="w-4 h-4" />
Absenden
</>
)}
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,280 @@
'use client'
import { useState } from 'react'
import { ChevronRight, ChevronLeft, Check, GraduationCap, Settings, Timer } from 'lucide-react'
interface OnboardingModalProps {
isOpen: boolean
onClose: () => void
onComplete: (data: { state?: string; schoolType?: string }) => void
}
const STATES = [
'Baden-Wuerttemberg',
'Bayern',
'Berlin',
'Brandenburg',
'Bremen',
'Hamburg',
'Hessen',
'Mecklenburg-Vorpommern',
'Niedersachsen',
'Nordrhein-Westfalen',
'Rheinland-Pfalz',
'Saarland',
'Sachsen',
'Sachsen-Anhalt',
'Schleswig-Holstein',
'Thueringen',
]
const SCHOOL_TYPES = [
'Grundschule',
'Hauptschule',
'Realschule',
'Gymnasium',
'Gesamtschule',
'Berufsschule',
'Foerderschule',
'Andere',
]
interface Step {
id: number
title: string
description: string
icon: typeof GraduationCap
}
const steps: Step[] = [
{
id: 1,
title: 'Willkommen',
description: 'Der Companion hilft Ihnen bei der Unterrichtsplanung und -durchfuehrung.',
icon: GraduationCap,
},
{
id: 2,
title: 'Ihre Schule',
description: 'Waehlen Sie Ihr Bundesland und Ihre Schulform.',
icon: Settings,
},
{
id: 3,
title: 'Bereit!',
description: 'Sie koennen jetzt mit dem Lesson-Modus starten.',
icon: Timer,
},
]
export function OnboardingModal({ isOpen, onClose, onComplete }: OnboardingModalProps) {
const [currentStep, setCurrentStep] = useState(1)
const [selectedState, setSelectedState] = useState('')
const [selectedSchoolType, setSelectedSchoolType] = useState('')
if (!isOpen) return null
const canProceed = () => {
if (currentStep === 2) {
return selectedState !== '' && selectedSchoolType !== ''
}
return true
}
const handleNext = () => {
if (currentStep < 3) {
setCurrentStep(currentStep + 1)
} else {
onComplete({
state: selectedState,
schoolType: selectedSchoolType,
})
}
}
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const currentStepData = steps[currentStep - 1]
const Icon = currentStepData.icon
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 overflow-hidden">
{/* Progress Bar */}
<div className="h-1 bg-slate-100">
<div
className="h-full bg-blue-600 transition-all duration-300"
style={{ width: `${(currentStep / 3) * 100}%` }}
/>
</div>
{/* Content */}
<div className="p-8">
{/* Step Indicator */}
<div className="flex items-center justify-center gap-2 mb-8">
{steps.map((step) => (
<div
key={step.id}
className={`
w-3 h-3 rounded-full transition-all
${step.id === currentStep
? 'bg-blue-600 scale-125'
: step.id < currentStep
? 'bg-blue-600'
: 'bg-slate-200'
}
`}
/>
))}
</div>
{/* Icon */}
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<Icon className="w-8 h-8 text-blue-600" />
</div>
{/* Title & Description */}
<h2 className="text-2xl font-bold text-slate-900 text-center mb-2">
{currentStepData.title}
</h2>
<p className="text-slate-600 text-center mb-8">
{currentStepData.description}
</p>
{/* Step Content */}
{currentStep === 1 && (
<div className="space-y-4 text-center">
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-blue-50 rounded-xl">
<div className="text-2xl mb-1">5</div>
<div className="text-xs text-slate-600">Phasen</div>
</div>
<div className="p-4 bg-green-50 rounded-xl">
<div className="text-2xl mb-1">45</div>
<div className="text-xs text-slate-600">Minuten</div>
</div>
<div className="p-4 bg-purple-50 rounded-xl">
<div className="text-2xl mb-1"></div>
<div className="text-xs text-slate-600">Flexibel</div>
</div>
</div>
<p className="text-sm text-slate-500 mt-4">
Einstieg Erarbeitung Sicherung Transfer Reflexion
</p>
</div>
)}
{currentStep === 2 && (
<div className="space-y-4">
{/* State Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Bundesland
</label>
<select
value={selectedState}
onChange={(e) => setSelectedState(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">Bitte waehlen...</option>
{STATES.map((state) => (
<option key={state} value={state}>
{state}
</option>
))}
</select>
</div>
{/* School Type Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schulform
</label>
<select
value={selectedSchoolType}
onChange={(e) => setSelectedSchoolType(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">Bitte waehlen...</option>
{SCHOOL_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
</div>
)}
{currentStep === 3 && (
<div className="text-center space-y-4">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<Check className="w-10 h-10 text-green-600" />
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-sm text-slate-600">
<strong>Bundesland:</strong> {selectedState || 'Nicht angegeben'}
</p>
<p className="text-sm text-slate-600">
<strong>Schulform:</strong> {selectedSchoolType || 'Nicht angegeben'}
</p>
</div>
<p className="text-sm text-slate-500">
Sie koennen diese Einstellungen jederzeit aendern.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
<button
onClick={currentStep === 1 ? onClose : handleBack}
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
{currentStep === 1 ? (
'Ueberspringen'
) : (
<>
<ChevronLeft className="w-4 h-4" />
Zurueck
</>
)}
</button>
<button
onClick={handleNext}
disabled={!canProceed()}
className={`
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
transition-all duration-200
${canProceed()
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
}
`}
>
{currentStep === 3 ? (
<>
<Check className="w-4 h-4" />
Fertig
</>
) : (
<>
Weiter
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,248 @@
'use client'
import { useState, useEffect } from 'react'
import { X, Settings, Save, RotateCcw } from 'lucide-react'
import { TeacherSettings, PhaseDurations } from '@/lib/companion/types'
import {
DEFAULT_TEACHER_SETTINGS,
DEFAULT_PHASE_DURATIONS,
PHASE_ORDER,
PHASE_DISPLAY_NAMES,
PHASE_COLORS,
calculateTotalDuration,
} from '@/lib/companion/constants'
interface SettingsModalProps {
isOpen: boolean
onClose: () => void
settings: TeacherSettings
onSave: (settings: TeacherSettings) => void
}
export function SettingsModal({
isOpen,
onClose,
settings,
onSave,
}: SettingsModalProps) {
const [localSettings, setLocalSettings] = useState<TeacherSettings>(settings)
const [durations, setDurations] = useState<PhaseDurations>(settings.defaultPhaseDurations)
useEffect(() => {
setLocalSettings(settings)
setDurations(settings.defaultPhaseDurations)
}, [settings])
if (!isOpen) return null
const totalDuration = calculateTotalDuration(durations)
const handleDurationChange = (phase: keyof PhaseDurations, value: number) => {
const newDurations = { ...durations, [phase]: Math.max(1, Math.min(60, value)) }
setDurations(newDurations)
}
const handleReset = () => {
setDurations(DEFAULT_PHASE_DURATIONS)
setLocalSettings(DEFAULT_TEACHER_SETTINGS)
}
const handleSave = () => {
const newSettings: TeacherSettings = {
...localSettings,
defaultPhaseDurations: durations,
}
onSave(newSettings)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-100 rounded-xl">
<Settings className="w-5 h-5 text-slate-600" />
</div>
<h2 className="text-xl font-semibold text-slate-900">Einstellungen</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6 overflow-y-auto max-h-[60vh]">
{/* Phase Durations */}
<div>
<h3 className="text-sm font-medium text-slate-700 mb-4">
Standard-Phasendauern (Minuten)
</h3>
<div className="space-y-4">
{PHASE_ORDER.map((phase) => (
<div key={phase} className="flex items-center gap-4">
<div className="flex items-center gap-2 w-32">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: PHASE_COLORS[phase].hex }}
/>
<span className="text-sm text-slate-700">
{PHASE_DISPLAY_NAMES[phase]}
</span>
</div>
<input
type="number"
min={1}
max={60}
value={durations[phase]}
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value) || 1)}
className="w-20 px-3 py-2 border border-slate-200 rounded-lg text-center focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<input
type="range"
min={1}
max={45}
value={durations[phase]}
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value))}
className="flex-1"
style={{
accentColor: PHASE_COLORS[phase].hex,
}}
/>
</div>
))}
</div>
<div className="mt-4 p-3 bg-slate-50 rounded-xl flex items-center justify-between">
<span className="text-sm text-slate-600">Gesamtdauer:</span>
<span className="font-semibold text-slate-900">{totalDuration} Minuten</span>
</div>
</div>
{/* Other Settings */}
<div className="space-y-4 pt-4 border-t border-slate-200">
<h3 className="text-sm font-medium text-slate-700 mb-4">
Weitere Einstellungen
</h3>
{/* Auto Advance */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Automatischer Phasenwechsel
</span>
<p className="text-xs text-slate-500">
Phasen automatisch wechseln wenn Zeit abgelaufen
</p>
</div>
<input
type="checkbox"
checked={localSettings.autoAdvancePhases}
onChange={(e) =>
setLocalSettings({ ...localSettings, autoAdvancePhases: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* Sound Notifications */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Ton-Benachrichtigungen
</span>
<p className="text-xs text-slate-500">
Signalton bei Phasenende und Warnungen
</p>
</div>
<input
type="checkbox"
checked={localSettings.soundNotifications}
onChange={(e) =>
setLocalSettings({ ...localSettings, soundNotifications: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* Keyboard Shortcuts */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Tastaturkuerzel anzeigen
</span>
<p className="text-xs text-slate-500">
Hinweise zu Tastaturkuerzeln einblenden
</p>
</div>
<input
type="checkbox"
checked={localSettings.showKeyboardShortcuts}
onChange={(e) =>
setLocalSettings({ ...localSettings, showKeyboardShortcuts: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* High Contrast */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Hoher Kontrast
</span>
<p className="text-xs text-slate-500">
Bessere Sichtbarkeit durch erhoehten Kontrast
</p>
</div>
<input
type="checkbox"
checked={localSettings.highContrastMode}
onChange={(e) =>
setLocalSettings({ ...localSettings, highContrastMode: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
<button
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
<RotateCcw className="w-4 h-4" />
Zuruecksetzen
</button>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
<Save className="w-4 h-4" />
Speichern
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -4,11 +4,15 @@
* GridOverlay Component
*
* SVG overlay for displaying detected OCR grid structure on document images.
* Shows recognized (green), problematic (orange), manual (blue), and empty (transparent) cells.
* Supports click-to-edit for problematic cells.
* Features:
* - Cell status visualization (recognized/problematic/manual/empty)
* - 1mm grid overlay for A4 pages (210x297mm)
* - Text at original bounding-box positions
* - Editable text (contentEditable) at original positions
* - Click-to-edit for cells
*/
import { useCallback } from 'react'
import { useCallback, useState } from 'react'
import { cn } from '@/lib/utils'
export type CellStatus = 'empty' | 'recognized' | 'problematic' | 'manual'
@@ -24,6 +28,10 @@ export interface GridCell {
confidence: number
status: CellStatus
column_type?: 'english' | 'german' | 'example' | 'unknown'
x_mm?: number
y_mm?: number
width_mm?: number
height_mm?: number
}
export interface GridData {
@@ -42,57 +50,198 @@ export interface GridData {
total: number
coverage: number
}
page_dimensions?: {
width_mm: number
height_mm: number
format: string
}
source?: string
}
interface GridOverlayProps {
grid: GridData
imageUrl?: string
onCellClick?: (cell: GridCell) => void
onCellTextChange?: (cell: GridCell, newText: string) => void
selectedCell?: GridCell | null
showEmpty?: boolean
showLabels?: boolean
showNumbers?: boolean // Show block numbers in cells
highlightedBlockNumber?: number | null // Highlight specific block
showNumbers?: boolean
showTextLabels?: boolean
showMmGrid?: boolean
showTextAtPosition?: boolean
editableText?: boolean
highlightedBlockNumber?: number | null
className?: string
}
// Status colors
const STATUS_COLORS = {
recognized: {
fill: 'rgba(34, 197, 94, 0.2)', // green-500 with opacity
stroke: '#22c55e', // green-500
fill: 'rgba(34, 197, 94, 0.2)',
stroke: '#22c55e',
hoverFill: 'rgba(34, 197, 94, 0.3)',
},
problematic: {
fill: 'rgba(249, 115, 22, 0.3)', // orange-500 with opacity
stroke: '#f97316', // orange-500
fill: 'rgba(249, 115, 22, 0.3)',
stroke: '#f97316',
hoverFill: 'rgba(249, 115, 22, 0.4)',
},
manual: {
fill: 'rgba(59, 130, 246, 0.2)', // blue-500 with opacity
stroke: '#3b82f6', // blue-500
fill: 'rgba(59, 130, 246, 0.2)',
stroke: '#3b82f6',
hoverFill: 'rgba(59, 130, 246, 0.3)',
},
empty: {
fill: 'transparent',
stroke: 'rgba(148, 163, 184, 0.3)', // slate-400 with opacity
stroke: 'rgba(148, 163, 184, 0.3)',
hoverFill: 'rgba(148, 163, 184, 0.1)',
},
}
// A4 dimensions for mm grid
const A4_WIDTH_MM = 210
const A4_HEIGHT_MM = 297
// Helper to calculate block number (1-indexed, row-by-row)
export function getCellBlockNumber(cell: GridCell, grid: GridData): number {
return cell.row * grid.columns + cell.col + 1
}
/**
* 1mm Grid SVG Lines for A4 format.
* Renders inside a viewBox="0 0 100 100" (percentage-based).
*/
function MmGridLines() {
const lines: React.ReactNode[] = []
// Vertical lines: 210 lines for 210mm
for (let mm = 0; mm <= A4_WIDTH_MM; mm++) {
const x = (mm / A4_WIDTH_MM) * 100
const isCm = mm % 10 === 0
lines.push(
<line
key={`v-${mm}`}
x1={x}
y1={0}
x2={x}
y2={100}
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
strokeWidth={isCm ? 0.08 : 0.03}
/>
)
}
// Horizontal lines: 297 lines for 297mm
for (let mm = 0; mm <= A4_HEIGHT_MM; mm++) {
const y = (mm / A4_HEIGHT_MM) * 100
const isCm = mm % 10 === 0
lines.push(
<line
key={`h-${mm}`}
x1={0}
y1={y}
x2={100}
y2={y}
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
strokeWidth={isCm ? 0.08 : 0.03}
/>
)
}
return <g style={{ pointerEvents: 'none' }}>{lines}</g>
}
/**
* Positioned text overlay using absolute-positioned HTML divs.
* Each cell's text appears at its bounding-box position with matching font size.
*/
function PositionedTextLayer({
cells,
editable,
onTextChange,
}: {
cells: GridCell[]
editable: boolean
onTextChange?: (cell: GridCell, text: string) => void
}) {
const [hoveredCell, setHoveredCell] = useState<string | null>(null)
return (
<div className="absolute inset-0" style={{ pointerEvents: editable ? 'auto' : 'none' }}>
{cells.map((cell) => {
if (cell.status === 'empty' || !cell.text) return null
const cellKey = `pos-${cell.row}-${cell.col}`
const isHovered = hoveredCell === cellKey
// Estimate font size from cell height: height_pct maps to roughly pt size
// A4 at 100% = 297mm height. Cell height in % * 297mm / 100 = height_mm
// Font size ~= height_mm * 2.2 (roughly matching print)
const heightMm = cell.height_mm ?? (cell.height / 100 * A4_HEIGHT_MM)
const fontSizePt = Math.max(6, Math.min(18, heightMm * 2.2))
return (
<div
key={cellKey}
className={cn(
'absolute overflow-hidden transition-colors duration-100',
editable && 'cursor-text hover:bg-yellow-100/40',
isHovered && !editable && 'bg-blue-100/30',
)}
style={{
left: `${cell.x}%`,
top: `${cell.y}%`,
width: `${cell.width}%`,
height: `${cell.height}%`,
fontSize: `${fontSizePt}pt`,
fontFamily: '"Georgia", "Times New Roman", serif',
lineHeight: 1.1,
color: cell.status === 'manual' ? '#1e40af' : '#1a1a1a',
padding: '0 1px',
display: 'flex',
alignItems: 'center',
}}
onMouseEnter={() => setHoveredCell(cellKey)}
onMouseLeave={() => setHoveredCell(null)}
>
{editable ? (
<span
contentEditable
suppressContentEditableWarning
className="outline-none w-full"
style={{ minHeight: '1em' }}
onBlur={(e) => {
const newText = e.currentTarget.textContent ?? ''
if (newText !== cell.text && onTextChange) {
onTextChange(cell, newText)
}
}}
>
{cell.text}
</span>
) : (
<span className="truncate">{cell.text}</span>
)}
</div>
)
})}
</div>
)
}
export function GridOverlay({
grid,
imageUrl,
onCellClick,
onCellTextChange,
selectedCell,
showEmpty = false,
showLabels = true,
showNumbers = false,
showTextLabels = false,
showMmGrid = false,
showTextAtPosition = false,
editableText = false,
highlightedBlockNumber,
className,
}: GridOverlayProps) {
@@ -125,6 +274,9 @@ export function GridOverlay({
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
{/* 1mm Grid */}
{showMmGrid && <MmGridLines />}
{/* Column type labels */}
{showLabels && grid.column_types.length > 0 && (
<g>
@@ -150,15 +302,14 @@ export function GridOverlay({
</g>
)}
{/* Grid cells */}
{flatCells.map((cell) => {
{/* Grid cells (skip if showing text at position to avoid double rendering) */}
{!showTextAtPosition && flatCells.map((cell) => {
const colors = STATUS_COLORS[cell.status]
const isSelected = selectedCell?.row === cell.row && selectedCell?.col === cell.col
const isClickable = cell.status !== 'empty' && onCellClick
const blockNumber = getCellBlockNumber(cell, grid)
const isHighlighted = highlightedBlockNumber === blockNumber
// Skip empty cells if not showing them
if (!showEmpty && cell.status === 'empty') {
return null
}
@@ -170,7 +321,6 @@ export function GridOverlay({
onClick={() => handleCellClick(cell)}
className={isClickable ? 'cursor-pointer' : ''}
>
{/* Cell rectangle */}
<rect
x={cell.x}
y={cell.y}
@@ -186,7 +336,6 @@ export function GridOverlay({
)}
/>
{/* Block number badge */}
{showNumbers && cell.status !== 'empty' && (
<>
<rect
@@ -211,8 +360,7 @@ export function GridOverlay({
</>
)}
{/* Status indicator dot (only when not showing numbers) */}
{!showNumbers && cell.status !== 'empty' && (
{!showNumbers && !showTextLabels && cell.status !== 'empty' && (
<circle
cx={cell.x + 0.8}
cy={cell.y + 0.8}
@@ -223,7 +371,20 @@ export function GridOverlay({
/>
)}
{/* Confidence indicator (for recognized cells) */}
{showTextLabels && (cell.status === 'recognized' || cell.status === 'manual') && cell.text && (
<text
x={cell.x + cell.width / 2}
y={cell.y + cell.height / 2 + Math.min(cell.height * 0.2, 0.5)}
textAnchor="middle"
fontSize={Math.min(cell.height * 0.5, 1.4)}
fill={cell.status === 'manual' ? '#1e40af' : '#166534'}
fontWeight="500"
style={{ pointerEvents: 'none' }}
>
{cell.text.length > 15 ? cell.text.slice(0, 15) + '\u2026' : cell.text}
</text>
)}
{cell.status === 'recognized' && cell.confidence < 0.7 && (
<text
x={cell.x + cell.width - 0.5}
@@ -236,7 +397,6 @@ export function GridOverlay({
</text>
)}
{/* Selection highlight */}
{isSelected && (
<rect
x={cell.x}
@@ -254,7 +414,26 @@ export function GridOverlay({
)
})}
{/* Row boundaries (optional grid lines) */}
{/* Show cell outlines when in positioned text mode */}
{showTextAtPosition && flatCells.map((cell) => {
if (cell.status === 'empty') return null
return (
<rect
key={`outline-${cell.row}-${cell.col}`}
x={cell.x}
y={cell.y}
width={cell.width}
height={cell.height}
fill="none"
stroke="rgba(99, 102, 241, 0.2)"
strokeWidth={0.08}
rx={0.1}
style={{ pointerEvents: 'none' }}
/>
)
})}
{/* Row boundaries */}
{grid.row_boundaries.map((y, idx) => (
<line
key={`row-line-${idx}`}
@@ -282,22 +461,30 @@ export function GridOverlay({
/>
))}
</svg>
{/* Positioned text HTML overlay (outside SVG for proper text rendering) */}
{showTextAtPosition && (
<PositionedTextLayer
cells={flatCells.filter(c => c.status !== 'empty' && c.text)}
editable={editableText}
onTextChange={onCellTextChange}
/>
)}
</div>
)
}
/**
* GridStats Component
*
* Displays statistics about the grid detection results.
*/
interface GridStatsProps {
stats: GridData['stats']
deskewAngle?: number
source?: string
className?: string
}
export function GridStats({ stats, deskewAngle, className }: GridStatsProps) {
export function GridStats({ stats, deskewAngle, source, className }: GridStatsProps) {
const coveragePercent = Math.round(stats.coverage * 100)
return (
@@ -326,6 +513,11 @@ export function GridStats({ stats, deskewAngle, className }: GridStatsProps) {
Begradigt: {deskewAngle.toFixed(1)}
</div>
)}
{source && (
<div className="px-3 py-1.5 bg-cyan-50 text-cyan-700 rounded-lg text-sm font-medium">
Quelle: {source === 'tesseract+grid_service' ? 'Tesseract' : 'Vision LLM'}
</div>
)}
</div>
)
}

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useSDK, getStepById, getNextStep, getPreviousStep, SDKStep } from '@/lib/sdk'
import { useSDK, getStepById, getNextStep, getPreviousStep, SDKStep, SDK_STEPS } from '@/lib/sdk'
// =============================================================================
// TYPES
@@ -133,7 +133,7 @@ export function StepHeader({
// Calculate step progress within phase
const phaseSteps = currentStep ?
(currentStep.phase === 1 ? 8 : 11) : 0
SDK_STEPS.filter(s => s.phase === currentStep.phase).length : 0
const stepNumber = currentStep?.order || 0
return (
@@ -314,6 +314,28 @@ export const STEP_EXPLANATIONS = {
},
],
},
'compliance-scope': {
title: 'Compliance Scope',
description: 'Umfang und Tiefe Ihrer Compliance-Dokumentation bestimmen',
explanation: 'Die Compliance Scope Engine bestimmt deterministisch, welche Dokumente Sie in welcher Tiefe benoetigen. Basierend auf 35 Fragen in 6 Bloecken werden Risiko-, Komplexitaets- und Assurance-Scores berechnet, die in ein 4-Level-Modell (L1 Lean bis L4 Zertifizierungsbereit) muenden.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Deterministisch',
description: 'Alle Entscheidungen sind nachvollziehbar — keine KI, keine Black Box. Jede Einstufung wird mit Rechtsgrundlage und Audit-Trail begruendet.',
},
{
icon: 'info' as const,
title: '4-Level-Modell',
description: 'L1 (Lean Startup) bis L4 (Zertifizierungsbereit). Hard Triggers (Art. 9, Minderjaehrige, Zertifizierungsziele) heben das Level automatisch an.',
},
{
icon: 'warning' as const,
title: 'Hard Triggers',
description: '50 deterministische Regeln pruefen besondere Kategorien (Art. 9), Minderjaehrige, KI-Einsatz, Drittlandtransfers und Zertifizierungsziele.',
},
],
},
'use-case-assessment': {
title: 'Anwendungsfall-Erfassung',
description: 'Erfassen Sie Ihre KI-Anwendungsfälle systematisch',
@@ -487,34 +509,44 @@ export const STEP_EXPLANATIONS = {
'tom': {
title: 'Technische und Organisatorische Massnahmen',
description: 'Dokumentieren Sie Ihre TOMs nach Art. 32 DSGVO',
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Sie umfassen Zutrittskontrolle, Zugangskontrolle, Zugriffskontrolle und mehr.',
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Das Dashboard zeigt den Status aller aus dem TOM Generator abgeleiteten Massnahmen mit SDM-Mapping und Gap-Analyse.',
tips: [
{
icon: 'info' as const,
title: 'Kategorien',
description: 'TOMs werden in technische (z.B. Verschluesselung) und organisatorische (z.B. Schulungen) Massnahmen unterteilt.',
icon: 'warning' as const,
title: 'Nachweispflicht',
description: 'TOMs muessen nachweisbar real sein. Verknuepfen Sie Evidence-Dokumente (Policies, Zertifikate, Screenshots) mit jeder Massnahme, um die Rechenschaftspflicht (Art. 5 Abs. 2 DSGVO) zu erfuellen.',
},
{
icon: 'success' as const,
title: 'Nachweis',
description: 'Dokumentieren Sie fuer jede TOM einen Nachweis der Umsetzung.',
icon: 'info' as const,
title: 'Generator nutzen',
description: 'Der 6-Schritt-Wizard leitet TOMs systematisch aus Ihrem Risikoprofil ab. Starten Sie dort, um eine vollstaendige Baseline zu erhalten.',
},
{
icon: 'info' as const,
title: 'SDM-Mapping',
description: 'Kontrollen werden den 7 SDM-Gewaehrleistungszielen zugeordnet: Verfuegbarkeit, Integritaet, Vertraulichkeit, Nichtverkettung, Intervenierbarkeit, Transparenz, Datenminimierung.',
},
],
},
'vvt': {
title: 'Verarbeitungsverzeichnis',
description: 'Erstellen Sie Ihr Verzeichnis nach Art. 30 DSGVO',
explanation: 'Das Verarbeitungsverzeichnis dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Es ist fuer die meisten Unternehmen Pflicht.',
description: 'Erstellen und verwalten Sie Ihr Verzeichnis nach Art. 30 DSGVO',
explanation: 'Das Verarbeitungsverzeichnis (VVT) dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Der integrierte Generator-Fragebogen befuellt 70-90% der Pflichtfelder automatisch anhand Ihres Unternehmensprofils.',
tips: [
{
icon: 'warning' as const,
title: 'Vollstaendigkeit',
description: 'Das VVT muss alle Verarbeitungen enthalten. Fehlende Eintraege koennen bei Audits zu Beanstandungen fuehren.',
title: 'Pflicht fuer alle',
description: 'Die Ausnahme fuer Unternehmen <250 Mitarbeiter greift nur bei gelegentlicher, risikoarmer Verarbeitung ohne besondere Kategorien (Art. 30 Abs. 5).',
},
{
icon: 'info' as const,
title: 'Pflichtangaben',
description: 'Jeder Eintrag muss enthalten: Zweck, Datenkategorien, Empfaenger, Loeschfristen, TOMs.',
title: 'Zweck-zuerst',
description: 'Definieren Sie Verarbeitungen nach Geschaeftszweck, nicht nach Tool. Ein Tool kann mehrere Verarbeitungen abdecken, eine Verarbeitung kann mehrere Tools nutzen.',
},
{
icon: 'info' as const,
title: 'Kein oeffentliches Dokument',
description: 'Das VVT ist ein internes Dokument. Es muss der Aufsichtsbehoerde nur auf Verlangen vorgelegt werden (Art. 30 Abs. 4).',
},
],
},
@@ -555,17 +587,22 @@ export const STEP_EXPLANATIONS = {
'loeschfristen': {
title: 'Loeschfristen',
description: 'Definieren Sie Aufbewahrungsrichtlinien fuer Ihre Daten',
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Nach Ablauf muessen die Daten geloescht oder anonymisiert werden.',
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Die 3-Stufen-Logik (Zweckende, Aufbewahrungspflicht, Legal Hold) stellt sicher, dass alle gesetzlichen Anforderungen beruecksichtigt werden.',
tips: [
{
icon: 'warning' as const,
title: 'Gesetzliche Fristen',
description: 'Beachten Sie gesetzliche Aufbewahrungspflichten (z.B. Steuerrecht: 10 Jahre, Handelsrecht: 6 Jahre).',
title: '3-Stufen-Logik',
description: 'Jede Loeschfrist folgt einer 3-Stufen-Logik: 1. Zweckende (Daten werden nach Zweckwegfall geloescht), 2. Aufbewahrungspflicht (gesetzliche Fristen verhindern Loeschung), 3. Legal Hold (laufende Verfahren blockieren Loeschung).',
},
{
icon: 'lightbulb' as const,
title: 'Automatisierung',
description: 'Richten Sie automatische Loeschprozesse ein, um Compliance sicherzustellen.',
icon: 'info' as const,
title: 'Deutsche Rechtsgrundlagen',
description: 'Der Generator kennt die wichtigsten Aufbewahrungstreiber: AO (10 J. Steuer), HGB (10/6 J. Handel), UStG (10 J. Rechnungen), BGB (3 J. Verjaehrung), ArbZG (2 J. Zeiterfassung), AGG (6 Mon. Bewerbungen).',
},
{
icon: 'info' as const,
title: 'Backup-Behandlung',
description: 'Auch Backups muessen ins Loeschkonzept einbezogen werden. Daten koennen nach primaerer Loeschung noch in Backup-Systemen existieren.',
},
],
},
@@ -659,6 +696,91 @@ export const STEP_EXPLANATIONS = {
},
],
},
'source-policy': {
title: 'Source Policy',
description: 'Verwalten Sie Ihre Datenquellen-Governance',
explanation: 'Die Source Policy definiert, welche externen Datenquellen fuer Ihre Anwendung zugelassen sind. Sie umfasst eine Whitelist, Operationsmatrix (Lookup, RAG, Training, Export), PII-Regeln und ein Audit-Trail.',
tips: [
{
icon: 'warning' as const,
title: 'Lizenzierung',
description: 'Pruefen Sie die Lizenzen aller Datenquellen (DL-DE-BY, CC-BY, CC0). Nicht-lizenzierte Quellen koennen rechtliche Risiken bergen.',
},
{
icon: 'info' as const,
title: 'PII-Regeln',
description: 'Definieren Sie klare Regeln fuer den Umgang mit personenbezogenen Daten in externen Quellen.',
},
],
},
'audit-report': {
title: 'Audit Report',
description: 'Erstellen und verwalten Sie Audit-Sitzungen',
explanation: 'Im Audit Report erstellen Sie formelle Audit-Sitzungen mit Pruefer-Informationen, fuehren die Pruefung durch und generieren PDF-Reports. Jede Sitzung dokumentiert den Compliance-Stand zu einem bestimmten Zeitpunkt.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Regelmaessigkeit',
description: 'Fuehren Sie mindestens jaehrlich ein formelles Audit durch. Dokumentieren Sie Abweichungen und Massnahmenplaene.',
},
{
icon: 'success' as const,
title: 'PDF-Export',
description: 'Generieren Sie PDF-Reports in Deutsch oder Englisch fuer externe Pruefer.',
},
],
},
'workflow': {
title: 'Document Workflow',
description: 'Verwalten Sie den Freigabe-Prozess Ihrer rechtlichen Dokumente',
explanation: 'Der Document Workflow bietet einen Split-View-Editor mit synchronisiertem Scrollen. Dokumente durchlaufen den Status Draft → Review → Approved → Published. Aenderungen werden versioniert und der Freigabeprozess wird protokolliert.',
tips: [
{
icon: 'warning' as const,
title: 'Vier-Augen-Prinzip',
description: 'Rechtliche Dokumente sollten immer von mindestens einer weiteren Person geprueft werden, bevor sie veroeffentlicht werden.',
},
{
icon: 'info' as const,
title: 'Versionierung',
description: 'Jede Aenderung wird als neue Version gespeichert. So koennen Sie jederzeit den Stand eines Dokuments nachvollziehen.',
},
],
},
'consent-management': {
title: 'Consent Verwaltung',
description: 'Verwalten Sie Consent-Dokumente, Versionen und DSGVO-Prozesse',
explanation: 'Die Consent Verwaltung umfasst das Lifecycle-Management Ihrer rechtlichen Dokumente (AGB, Datenschutz, Cookie-Richtlinien), die Verwaltung von E-Mail-Templates (16 Lifecycle-E-Mails) und die Steuerung der DSGVO-Prozesse (Art. 15-21).',
tips: [
{
icon: 'info' as const,
title: 'Dokumentversionen',
description: 'Jede Aenderung an einem Consent-Dokument erzeugt eine neue Version. Aktive Nutzer muessen bei Aenderungen erneut zustimmen.',
},
{
icon: 'warning' as const,
title: 'DSGVO-Fristen',
description: 'Betroffenenrechte (Art. 15-21) haben gesetzliche Fristen. Auskunft: 30 Tage, Loeschung: unverzueglich.',
},
],
},
'notfallplan': {
title: 'Notfallplan & Breach Response',
description: 'Verwalten Sie Ihr Datenpannen-Management nach Art. 33/34 DSGVO',
explanation: 'Der Notfallplan definiert Ihren Prozess bei Datenpannen gemaess Art. 33/34 DSGVO. Er umfasst die 72-Stunden-Meldepflicht an die Aufsichtsbehoerde, die Benachrichtigung betroffener Personen bei hohem Risiko, Incident-Klassifizierung, Eskalationswege und Dokumentationspflichten.',
tips: [
{
icon: 'warning' as const,
title: '72-Stunden-Frist',
description: 'Art. 33 DSGVO: Meldung an die Aufsichtsbehoerde innerhalb von 72 Stunden nach Bekanntwerden. Verspaetete Meldungen muessen begruendet werden.',
},
{
icon: 'info' as const,
title: 'Dokumentationspflicht',
description: 'Art. 33 Abs. 5: Alle Datenpannen muessen dokumentiert werden — auch solche, die nicht meldepflichtig sind. Die Dokumentation muss der Aufsichtsbehoerde auf Verlangen vorgelegt werden koennen.',
},
],
},
}
export default StepHeader

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export { useCompanionData } from './useCompanionData'
export { useLessonSession } from './useLessonSession'
export { useKeyboardShortcuts, useKeyboardShortcutHints } from './useKeyboardShortcuts'

View File

@@ -0,0 +1,156 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { CompanionData } from '@/lib/companion/types'
import { createDefaultPhases } from '@/lib/companion/constants'
interface UseCompanionDataOptions {
pollingInterval?: number // ms, default 30000
autoRefresh?: boolean
}
interface UseCompanionDataReturn {
data: CompanionData | null
loading: boolean
error: string | null
refresh: () => Promise<void>
lastUpdated: Date | null
}
// Mock data for development - will be replaced with actual API calls
function getMockData(): CompanionData {
return {
context: {
currentPhase: 'erarbeitung',
phaseDisplayName: 'Erarbeitung',
},
stats: {
classesCount: 4,
studentsCount: 96,
learningUnitsCreated: 23,
gradesEntered: 156,
},
phases: createDefaultPhases(),
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,
},
{
id: '3',
title: 'Material hochladen',
description: 'Arbeitsblatt fuer naechste Woche bereitstellen',
priority: 'medium',
icon: 'FileText',
actionTarget: '/development/content',
estimatedTime: 15,
},
{
id: '4',
title: 'Lernstandserhebung planen',
description: 'Mathe 7a - Naechster Test in 2 Wochen',
priority: 'low',
icon: 'Calendar',
actionTarget: '/education/planning',
estimatedTime: 45,
},
],
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,
},
{
id: 'e3',
title: 'Notenschluss Q1',
date: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(),
type: 'deadline',
inDays: 14,
},
],
}
}
export function useCompanionData(options: UseCompanionDataOptions = {}): UseCompanionDataReturn {
const { pollingInterval = 30000, autoRefresh = true } = options
const [data, setData] = useState<CompanionData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const fetchData = useCallback(async () => {
try {
// TODO: Replace with actual API call
// const response = await fetch('/api/admin/companion')
// if (!response.ok) throw new Error('Failed to fetch companion data')
// const result = await response.json()
// setData(result.data)
// For now, use mock data with a small delay to simulate network
await new Promise((resolve) => setTimeout(resolve, 300))
setData(getMockData())
setLastUpdated(new Date())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}, [])
const refresh = useCallback(async () => {
setLoading(true)
await fetchData()
}, [fetchData])
// Initial fetch
useEffect(() => {
fetchData()
}, [fetchData])
// Polling
useEffect(() => {
if (!autoRefresh || pollingInterval <= 0) return
const interval = setInterval(fetchData, pollingInterval)
return () => clearInterval(interval)
}, [autoRefresh, pollingInterval, fetchData])
return {
data,
loading,
error,
refresh,
lastUpdated,
}
}

View File

@@ -0,0 +1,113 @@
'use client'
import { useEffect, useCallback, useRef } from 'react'
import { KEYBOARD_SHORTCUTS } from '@/lib/companion/constants'
interface UseKeyboardShortcutsOptions {
onPauseResume?: () => void
onExtend?: () => void
onNextPhase?: () => void
onCloseModal?: () => void
onShowHelp?: () => void
enabled?: boolean
}
export function useKeyboardShortcuts({
onPauseResume,
onExtend,
onNextPhase,
onCloseModal,
onShowHelp,
enabled = true,
}: UseKeyboardShortcutsOptions) {
// Track if we're in an input field
const isInputFocused = useRef(false)
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!enabled) return
// Don't trigger shortcuts when typing in inputs
const target = event.target as HTMLElement
const isInput =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
if (isInput) {
isInputFocused.current = true
// Only allow Escape in inputs
if (event.key !== 'Escape') return
} else {
isInputFocused.current = false
}
// Handle shortcuts
switch (event.key) {
case KEYBOARD_SHORTCUTS.PAUSE_RESUME:
if (!isInput) {
event.preventDefault()
onPauseResume?.()
}
break
case KEYBOARD_SHORTCUTS.EXTEND_5MIN:
case KEYBOARD_SHORTCUTS.EXTEND_5MIN.toUpperCase():
if (!isInput) {
event.preventDefault()
onExtend?.()
}
break
case KEYBOARD_SHORTCUTS.NEXT_PHASE:
case KEYBOARD_SHORTCUTS.NEXT_PHASE.toUpperCase():
if (!isInput) {
event.preventDefault()
onNextPhase?.()
}
break
case KEYBOARD_SHORTCUTS.CLOSE_MODAL:
event.preventDefault()
onCloseModal?.()
break
case KEYBOARD_SHORTCUTS.SHOW_HELP:
if (!isInput) {
event.preventDefault()
onShowHelp?.()
}
break
}
},
[enabled, onPauseResume, onExtend, onNextPhase, onCloseModal, onShowHelp]
)
useEffect(() => {
if (!enabled) return
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [enabled, handleKeyDown])
return {
isInputFocused: isInputFocused.current,
}
}
/**
* Hook to display keyboard shortcut hints
*/
export function useKeyboardShortcutHints(show: boolean) {
const shortcuts = [
{ key: 'Leertaste', action: 'Pause/Fortsetzen', code: 'space' },
{ key: 'E', action: '+5 Minuten', code: 'e' },
{ key: 'N', action: 'Naechste Phase', code: 'n' },
{ key: 'Esc', action: 'Modal schliessen', code: 'escape' },
]
if (!show) return null
return shortcuts
}

View File

@@ -0,0 +1,446 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { LessonSession, LessonPhase, TimerState, PhaseDurations } from '@/lib/companion/types'
import {
PHASE_ORDER,
PHASE_DISPLAY_NAMES,
PHASE_COLORS,
DEFAULT_PHASE_DURATIONS,
SYSTEM_TEMPLATES,
getTimerColorStatus,
STORAGE_KEYS,
} from '@/lib/companion/constants'
interface UseLessonSessionOptions {
onPhaseComplete?: (phaseIndex: number) => void
onLessonComplete?: (session: LessonSession) => void
onOvertimeStart?: () => void
}
interface UseLessonSessionReturn {
session: LessonSession | null
timerState: TimerState | null
startLesson: (data: {
classId: string
className?: string
subject: string
topic?: string
templateId?: string
}) => void
endLesson: () => void
pauseLesson: () => void
resumeLesson: () => void
extendTime: (minutes: number) => void
skipPhase: () => void
saveReflection: (rating: number, notes: string, nextSteps: string) => void
addHomework: (title: string, dueDate: string) => void
removeHomework: (id: string) => void
isRunning: boolean
isPaused: boolean
}
function createInitialPhases(durations: PhaseDurations): LessonPhase[] {
return PHASE_ORDER.map((phaseId) => ({
phase: phaseId,
duration: durations[phaseId],
status: 'planned',
actualTime: 0,
}))
}
function generateSessionId(): string {
return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
export function useLessonSession(
options: UseLessonSessionOptions = {}
): UseLessonSessionReturn {
const { onPhaseComplete, onLessonComplete, onOvertimeStart } = options
const [session, setSession] = useState<LessonSession | null>(null)
const [timerState, setTimerState] = useState<TimerState | null>(null)
const timerRef = useRef<NodeJS.Timeout | null>(null)
const lastTickRef = useRef<number>(Date.now())
const hasTriggeredOvertimeRef = useRef(false)
// Calculate timer state from session
const calculateTimerState = useCallback((sess: LessonSession): TimerState | null => {
if (!sess || sess.status === 'completed') return null
const currentPhase = sess.phases[sess.currentPhaseIndex]
if (!currentPhase) return null
const phaseDurationSeconds = currentPhase.duration * 60
const elapsedInPhase = currentPhase.actualTime
const remainingSeconds = phaseDurationSeconds - elapsedInPhase
const progress = Math.min(elapsedInPhase / phaseDurationSeconds, 1)
const isOvertime = remainingSeconds < 0
return {
isRunning: sess.status === 'in_progress' && !sess.isPaused,
isPaused: sess.isPaused,
elapsedSeconds: elapsedInPhase,
remainingSeconds: Math.max(remainingSeconds, -999),
totalSeconds: phaseDurationSeconds,
progress,
colorStatus: getTimerColorStatus(remainingSeconds, isOvertime),
currentPhase,
}
}, [])
// Timer tick function
const tick = useCallback(() => {
if (!session || session.isPaused || session.status !== 'in_progress') return
const now = Date.now()
const delta = Math.floor((now - lastTickRef.current) / 1000)
lastTickRef.current = now
if (delta <= 0) return
setSession((prev) => {
if (!prev) return null
const updatedPhases = [...prev.phases]
const currentPhase = updatedPhases[prev.currentPhaseIndex]
if (!currentPhase) return prev
currentPhase.actualTime += delta
// Check for overtime
const phaseDurationSeconds = currentPhase.duration * 60
if (
currentPhase.actualTime > phaseDurationSeconds &&
!hasTriggeredOvertimeRef.current
) {
hasTriggeredOvertimeRef.current = true
onOvertimeStart?.()
}
// Update total elapsed time
const totalElapsed = prev.elapsedTime + delta
return {
...prev,
phases: updatedPhases,
elapsedTime: totalElapsed,
overtimeMinutes: Math.max(
0,
Math.floor((currentPhase.actualTime - phaseDurationSeconds) / 60)
),
}
})
}, [session, onOvertimeStart])
// Start/stop timer based on session state
useEffect(() => {
if (session?.status === 'in_progress' && !session.isPaused) {
lastTickRef.current = Date.now()
timerRef.current = setInterval(tick, 100) // Update every 100ms for smooth animation
} else {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current)
}
}
}, [session?.status, session?.isPaused, tick])
// Update timer state when session changes
useEffect(() => {
if (session) {
setTimerState(calculateTimerState(session))
} else {
setTimerState(null)
}
}, [session, calculateTimerState])
// Persist session to localStorage
useEffect(() => {
if (session) {
localStorage.setItem(STORAGE_KEYS.CURRENT_SESSION, JSON.stringify(session))
}
}, [session])
// Restore session from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.CURRENT_SESSION)
if (stored) {
try {
const parsed = JSON.parse(stored) as LessonSession
// Only restore if session is not completed and not too old (< 24h)
const sessionTime = new Date(parsed.startTime).getTime()
const isRecent = Date.now() - sessionTime < 24 * 60 * 60 * 1000
if (parsed.status !== 'completed' && isRecent) {
// Pause the restored session
setSession({ ...parsed, isPaused: true })
}
} catch {
// Invalid stored session, ignore
}
}
}, [])
const startLesson = useCallback(
(data: {
classId: string
className?: string
subject: string
topic?: string
templateId?: string
}) => {
// Find template durations
let durations = DEFAULT_PHASE_DURATIONS
if (data.templateId) {
const template = SYSTEM_TEMPLATES.find((t) => t.templateId === data.templateId)
if (template) {
durations = template.durations as PhaseDurations
}
}
const phases = createInitialPhases(durations)
phases[0].status = 'active'
phases[0].startedAt = new Date().toISOString()
const newSession: LessonSession = {
sessionId: generateSessionId(),
classId: data.classId,
className: data.className || data.classId,
subject: data.subject,
topic: data.topic,
startTime: new Date().toISOString(),
phases,
totalPlannedDuration: Object.values(durations).reduce((a, b) => a + b, 0),
currentPhaseIndex: 0,
elapsedTime: 0,
isPaused: false,
pauseDuration: 0,
overtimeMinutes: 0,
status: 'in_progress',
homeworkList: [],
materials: [],
}
hasTriggeredOvertimeRef.current = false
setSession(newSession)
},
[]
)
const endLesson = useCallback(() => {
if (!session) return
const completedSession: LessonSession = {
...session,
status: 'completed',
endTime: new Date().toISOString(),
phases: session.phases.map((p, i) => ({
...p,
status: i <= session.currentPhaseIndex ? 'completed' : 'skipped',
completedAt: i <= session.currentPhaseIndex ? new Date().toISOString() : undefined,
})),
}
setSession(completedSession)
onLessonComplete?.(completedSession)
}, [session, onLessonComplete])
const pauseLesson = useCallback(() => {
if (!session || session.isPaused) return
setSession((prev) =>
prev
? {
...prev,
isPaused: true,
pausedAt: new Date().toISOString(),
status: 'paused',
}
: null
)
}, [session])
const resumeLesson = useCallback(() => {
if (!session || !session.isPaused) return
const pausedAt = session.pausedAt ? new Date(session.pausedAt).getTime() : Date.now()
const pauseDelta = Math.floor((Date.now() - pausedAt) / 1000)
setSession((prev) =>
prev
? {
...prev,
isPaused: false,
pausedAt: undefined,
pauseDuration: prev.pauseDuration + pauseDelta,
status: 'in_progress',
}
: null
)
lastTickRef.current = Date.now()
}, [session])
const extendTime = useCallback(
(minutes: number) => {
if (!session) return
setSession((prev) => {
if (!prev) return null
const updatedPhases = [...prev.phases]
const currentPhase = updatedPhases[prev.currentPhaseIndex]
if (!currentPhase) return prev
currentPhase.duration += minutes
// Reset overtime trigger if we've added time
if (hasTriggeredOvertimeRef.current) {
const phaseDurationSeconds = currentPhase.duration * 60
if (currentPhase.actualTime < phaseDurationSeconds) {
hasTriggeredOvertimeRef.current = false
}
}
return {
...prev,
phases: updatedPhases,
totalPlannedDuration: prev.totalPlannedDuration + minutes,
}
})
},
[session]
)
const skipPhase = useCallback(() => {
if (!session) return
const nextPhaseIndex = session.currentPhaseIndex + 1
// Check if this was the last phase
if (nextPhaseIndex >= session.phases.length) {
endLesson()
return
}
setSession((prev) => {
if (!prev) return null
const updatedPhases = [...prev.phases]
// Complete current phase
updatedPhases[prev.currentPhaseIndex] = {
...updatedPhases[prev.currentPhaseIndex],
status: 'completed',
completedAt: new Date().toISOString(),
}
// Start next phase
updatedPhases[nextPhaseIndex] = {
...updatedPhases[nextPhaseIndex],
status: 'active',
startedAt: new Date().toISOString(),
}
return {
...prev,
phases: updatedPhases,
currentPhaseIndex: nextPhaseIndex,
overtimeMinutes: 0,
}
})
hasTriggeredOvertimeRef.current = false
onPhaseComplete?.(session.currentPhaseIndex)
}, [session, endLesson, onPhaseComplete])
const saveReflection = useCallback(
(rating: number, notes: string, nextSteps: string) => {
if (!session) return
setSession((prev) =>
prev
? {
...prev,
reflection: {
rating,
notes,
nextSteps,
savedAt: new Date().toISOString(),
},
}
: null
)
},
[session]
)
const addHomework = useCallback(
(title: string, dueDate: string) => {
if (!session) return
const newHomework = {
id: `hw-${Date.now()}`,
title,
dueDate,
completed: false,
}
setSession((prev) =>
prev
? {
...prev,
homeworkList: [...prev.homeworkList, newHomework],
}
: null
)
},
[session]
)
const removeHomework = useCallback(
(id: string) => {
if (!session) return
setSession((prev) =>
prev
? {
...prev,
homeworkList: prev.homeworkList.filter((hw) => hw.id !== id),
}
: null
)
},
[session]
)
// Clear session (for starting new)
const clearSession = useCallback(() => {
setSession(null)
localStorage.removeItem(STORAGE_KEYS.CURRENT_SESSION)
}, [])
return {
session,
timerState,
startLesson,
endLesson: session?.status === 'completed' ? clearSession : endLesson,
pauseLesson,
resumeLesson,
extendTime,
skipPhase,
saveReflection,
addHomework,
removeHomework,
isRunning: session?.status === 'in_progress' && !session?.isPaused,
isPaused: session?.isPaused ?? false,
}
}

View File

@@ -0,0 +1,364 @@
/**
* Constants for Companion Module
* Phase colors, defaults, and configuration
*/
import { PhaseId, PhaseDurations, Phase, TeacherSettings } from './types'
// ============================================================================
// Phase Colors (Didactic Color Psychology)
// ============================================================================
export const PHASE_COLORS: Record<PhaseId, { hex: string; tailwind: string; gradient: string }> = {
einstieg: {
hex: '#4A90E2',
tailwind: 'bg-blue-500',
gradient: 'from-blue-500 to-blue-600',
},
erarbeitung: {
hex: '#F5A623',
tailwind: 'bg-orange-500',
gradient: 'from-orange-500 to-orange-600',
},
sicherung: {
hex: '#7ED321',
tailwind: 'bg-green-500',
gradient: 'from-green-500 to-green-600',
},
transfer: {
hex: '#9013FE',
tailwind: 'bg-purple-600',
gradient: 'from-purple-600 to-purple-700',
},
reflexion: {
hex: '#6B7280',
tailwind: 'bg-gray-500',
gradient: 'from-gray-500 to-gray-600',
},
}
// ============================================================================
// Phase Definitions
// ============================================================================
export const PHASE_SHORT_NAMES: Record<PhaseId, string> = {
einstieg: 'E',
erarbeitung: 'A',
sicherung: 'S',
transfer: 'T',
reflexion: 'R',
}
export const PHASE_DISPLAY_NAMES: Record<PhaseId, string> = {
einstieg: 'Einstieg',
erarbeitung: 'Erarbeitung',
sicherung: 'Sicherung',
transfer: 'Transfer',
reflexion: 'Reflexion',
}
export const PHASE_DESCRIPTIONS: Record<PhaseId, string> = {
einstieg: 'Motivation, Kontext setzen, Vorwissen aktivieren',
erarbeitung: 'Hauptinhalt, aktives Lernen, neue Konzepte',
sicherung: 'Konsolidierung, Zusammenfassung, Uebungen',
transfer: 'Anwendung, neue Kontexte, kreative Aufgaben',
reflexion: 'Rueckblick, Selbsteinschaetzung, Ausblick',
}
export const PHASE_ORDER: PhaseId[] = [
'einstieg',
'erarbeitung',
'sicherung',
'transfer',
'reflexion',
]
// ============================================================================
// Default Durations (in minutes)
// ============================================================================
export const DEFAULT_PHASE_DURATIONS: PhaseDurations = {
einstieg: 8,
erarbeitung: 20,
sicherung: 10,
transfer: 7,
reflexion: 5,
}
export const DEFAULT_LESSON_LENGTH = 45 // minutes (German standard)
export const EXTENDED_LESSON_LENGTH = 50 // minutes (with buffer)
// ============================================================================
// Timer Thresholds (in seconds)
// ============================================================================
export const TIMER_WARNING_THRESHOLD = 5 * 60 // 5 minutes = warning (yellow)
export const TIMER_CRITICAL_THRESHOLD = 2 * 60 // 2 minutes = critical (red)
// ============================================================================
// SVG Pie Timer Constants
// ============================================================================
export const PIE_TIMER_RADIUS = 42
export const PIE_TIMER_CIRCUMFERENCE = 2 * Math.PI * PIE_TIMER_RADIUS // ~263.89
export const PIE_TIMER_STROKE_WIDTH = 8
export const PIE_TIMER_SIZE = 120 // viewBox size
// ============================================================================
// Timer Color Classes
// ============================================================================
export const TIMER_COLOR_CLASSES = {
plenty: 'text-green-500 stroke-green-500',
warning: 'text-amber-500 stroke-amber-500',
critical: 'text-red-500 stroke-red-500',
overtime: 'text-red-600 stroke-red-600 animate-pulse',
}
export const TIMER_BG_COLORS = {
plenty: 'bg-green-500/10',
warning: 'bg-amber-500/10',
critical: 'bg-red-500/10',
overtime: 'bg-red-600/20',
}
// ============================================================================
// Keyboard Shortcuts
// ============================================================================
export const KEYBOARD_SHORTCUTS = {
PAUSE_RESUME: ' ', // Spacebar
EXTEND_5MIN: 'e',
NEXT_PHASE: 'n',
CLOSE_MODAL: 'Escape',
SHOW_HELP: '?',
} as const
export const KEYBOARD_SHORTCUT_DESCRIPTIONS: Record<string, string> = {
' ': 'Pause/Fortsetzen',
'e': '+5 Minuten',
'n': 'Naechste Phase',
'Escape': 'Modal schliessen',
'?': 'Hilfe anzeigen',
}
// ============================================================================
// Default Settings
// ============================================================================
export const DEFAULT_TEACHER_SETTINGS: TeacherSettings = {
defaultPhaseDurations: DEFAULT_PHASE_DURATIONS,
preferredLessonLength: DEFAULT_LESSON_LENGTH,
autoAdvancePhases: true,
soundNotifications: true,
showKeyboardShortcuts: true,
highContrastMode: false,
onboardingCompleted: false,
}
// ============================================================================
// System Templates
// ============================================================================
export const SYSTEM_TEMPLATES = [
{
templateId: 'standard-45',
name: 'Standard (45 Min)',
description: 'Klassische Unterrichtsstunde',
durations: DEFAULT_PHASE_DURATIONS,
isSystemTemplate: true,
},
{
templateId: 'double-90',
name: 'Doppelstunde (90 Min)',
description: 'Fuer laengere Arbeitsphasen',
durations: {
einstieg: 10,
erarbeitung: 45,
sicherung: 15,
transfer: 12,
reflexion: 8,
},
isSystemTemplate: true,
},
{
templateId: 'math-focused',
name: 'Mathematik-fokussiert',
description: 'Lange Erarbeitung und Sicherung',
durations: {
einstieg: 5,
erarbeitung: 25,
sicherung: 10,
transfer: 5,
reflexion: 5,
},
isSystemTemplate: true,
},
{
templateId: 'language-practice',
name: 'Sprachpraxis',
description: 'Betont kommunikative Phasen',
durations: {
einstieg: 10,
erarbeitung: 15,
sicherung: 8,
transfer: 10,
reflexion: 7,
},
isSystemTemplate: true,
},
]
// ============================================================================
// Suggestion Icons (Lucide icon names)
// ============================================================================
export const SUGGESTION_ICONS = {
grading: 'ClipboardCheck',
homework: 'BookOpen',
planning: 'Calendar',
meeting: 'Users',
deadline: 'Clock',
material: 'FileText',
communication: 'MessageSquare',
default: 'Lightbulb',
}
// ============================================================================
// Priority Colors
// ============================================================================
export const PRIORITY_COLORS = {
urgent: {
bg: 'bg-red-100',
text: 'text-red-700',
border: 'border-red-200',
dot: 'bg-red-500',
},
high: {
bg: 'bg-orange-100',
text: 'text-orange-700',
border: 'border-orange-200',
dot: 'bg-orange-500',
},
medium: {
bg: 'bg-yellow-100',
text: 'text-yellow-700',
border: 'border-yellow-200',
dot: 'bg-yellow-500',
},
low: {
bg: 'bg-slate-100',
text: 'text-slate-700',
border: 'border-slate-200',
dot: 'bg-slate-400',
},
}
// ============================================================================
// Event Type Icons & Colors
// ============================================================================
export const EVENT_TYPE_CONFIG = {
exam: {
icon: 'FileQuestion',
color: 'text-red-600',
bg: 'bg-red-50',
},
parent_meeting: {
icon: 'Users',
color: 'text-blue-600',
bg: 'bg-blue-50',
},
deadline: {
icon: 'Clock',
color: 'text-amber-600',
bg: 'bg-amber-50',
},
other: {
icon: 'Calendar',
color: 'text-slate-600',
bg: 'bg-slate-50',
},
}
// ============================================================================
// Storage Keys
// ============================================================================
export const STORAGE_KEYS = {
SETTINGS: 'companion_settings',
CURRENT_SESSION: 'companion_current_session',
ONBOARDING_STATE: 'companion_onboarding',
CUSTOM_TEMPLATES: 'companion_custom_templates',
LAST_MODE: 'companion_last_mode',
}
// ============================================================================
// API Endpoints (relative to backend)
// ============================================================================
export const API_ENDPOINTS = {
DASHBOARD: '/api/state/dashboard',
LESSON_START: '/api/classroom/sessions',
LESSON_UPDATE: '/api/classroom/sessions', // + /{id}
TEMPLATES: '/api/classroom/templates',
SETTINGS: '/api/teacher/settings',
FEEDBACK: '/api/feedback',
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Create default phases array from durations
*/
export function createDefaultPhases(durations: PhaseDurations = DEFAULT_PHASE_DURATIONS): Phase[] {
return PHASE_ORDER.map((phaseId, index) => ({
id: phaseId,
shortName: PHASE_SHORT_NAMES[phaseId],
displayName: PHASE_DISPLAY_NAMES[phaseId],
duration: durations[phaseId],
status: index === 0 ? 'active' : 'planned',
color: PHASE_COLORS[phaseId].hex,
}))
}
/**
* Calculate total duration from phase durations
*/
export function calculateTotalDuration(durations: PhaseDurations): number {
return Object.values(durations).reduce((sum, d) => sum + d, 0)
}
/**
* Get timer color status based on remaining time
*/
export function getTimerColorStatus(
remainingSeconds: number,
isOvertime: boolean
): 'plenty' | 'warning' | 'critical' | 'overtime' {
if (isOvertime) return 'overtime'
if (remainingSeconds <= TIMER_CRITICAL_THRESHOLD) return 'critical'
if (remainingSeconds <= TIMER_WARNING_THRESHOLD) return 'warning'
return 'plenty'
}
/**
* Format seconds as MM:SS
*/
export function formatTime(seconds: number): string {
const absSeconds = Math.abs(seconds)
const mins = Math.floor(absSeconds / 60)
const secs = absSeconds % 60
const sign = seconds < 0 ? '-' : ''
return `${sign}${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
/**
* Format minutes as "X Min"
*/
export function formatMinutes(minutes: number): string {
return `${minutes} Min`
}

View File

@@ -0,0 +1,2 @@
export * from './types'
export * from './constants'

View File

@@ -0,0 +1,329 @@
/**
* TypeScript Types for Companion Module
* Migration from Flask companion.py/companion_js.py
*/
// ============================================================================
// Phase System
// ============================================================================
export type PhaseId = 'einstieg' | 'erarbeitung' | 'sicherung' | 'transfer' | 'reflexion'
export interface Phase {
id: PhaseId
shortName: string // E, A, S, T, R
displayName: string
duration: number // minutes
status: 'planned' | 'active' | 'completed'
actualTime?: number // seconds (actual time spent)
color: string // hex color
}
export interface PhaseContext {
currentPhase: PhaseId
phaseDisplayName: string
}
// ============================================================================
// Dashboard / Companion Mode
// ============================================================================
export interface CompanionStats {
classesCount: number
studentsCount: number
learningUnitsCreated: number
gradesEntered: number
}
export interface Progress {
percentage: number
completed: number
total: number
}
export type SuggestionPriority = 'urgent' | 'high' | 'medium' | 'low'
export interface Suggestion {
id: string
title: string
description: string
priority: SuggestionPriority
icon: string // lucide icon name
actionTarget: string // navigation path
estimatedTime: number // minutes
}
export type EventType = 'exam' | 'parent_meeting' | 'deadline' | 'other'
export interface UpcomingEvent {
id: string
title: string
date: string // ISO date string
type: EventType
inDays: number
}
export interface CompanionData {
context: PhaseContext
stats: CompanionStats
phases: Phase[]
progress: Progress
suggestions: Suggestion[]
upcomingEvents: UpcomingEvent[]
}
// ============================================================================
// Lesson Mode
// ============================================================================
export type LessonStatus =
| 'not_started'
| 'in_progress'
| 'paused'
| 'completed'
| 'overtime'
export interface LessonPhase {
phase: PhaseId
duration: number // planned duration in minutes
status: 'planned' | 'active' | 'completed' | 'skipped'
actualTime: number // actual time spent in seconds
startedAt?: string // ISO timestamp
completedAt?: string // ISO timestamp
}
export interface Homework {
id: string
title: string
description?: string
dueDate: string // ISO date
attachments?: string[]
completed?: boolean
}
export interface Material {
id: string
title: string
type: 'document' | 'video' | 'presentation' | 'link' | 'other'
url?: string
fileName?: string
}
export interface LessonReflection {
rating: number // 1-5 stars
notes: string
nextSteps: string
savedAt?: string
}
export interface LessonSession {
sessionId: string
classId: string
className: string
subject: string
topic?: string
startTime: string // ISO timestamp
endTime?: string // ISO timestamp
phases: LessonPhase[]
totalPlannedDuration: number // minutes
currentPhaseIndex: number
elapsedTime: number // seconds
isPaused: boolean
pausedAt?: string
pauseDuration: number // total pause time in seconds
overtimeMinutes: number
status: LessonStatus
homeworkList: Homework[]
materials: Material[]
reflection?: LessonReflection
}
// ============================================================================
// Lesson Templates
// ============================================================================
export interface PhaseDurations {
einstieg: number
erarbeitung: number
sicherung: number
transfer: number
reflexion: number
}
export interface LessonTemplate {
templateId: string
name: string
description?: string
subject?: string
durations: PhaseDurations
isSystemTemplate: boolean
createdBy?: string
createdAt?: string
}
// ============================================================================
// Settings
// ============================================================================
export interface TeacherSettings {
defaultPhaseDurations: PhaseDurations
preferredLessonLength: number // minutes (default 45)
autoAdvancePhases: boolean
soundNotifications: boolean
showKeyboardShortcuts: boolean
highContrastMode: boolean
onboardingCompleted: boolean
selectedTemplateId?: string
}
// ============================================================================
// Timer State
// ============================================================================
export type TimerColorStatus = 'plenty' | 'warning' | 'critical' | 'overtime'
export interface TimerState {
isRunning: boolean
isPaused: boolean
elapsedSeconds: number
remainingSeconds: number
totalSeconds: number
progress: number // 0-1
colorStatus: TimerColorStatus
currentPhase: LessonPhase | null
}
// ============================================================================
// Forms
// ============================================================================
export interface LessonStartFormData {
classId: string
subject: string
topic?: string
templateId?: string
customDurations?: PhaseDurations
}
export interface Class {
id: string
name: string
grade: string
studentCount: number
}
// ============================================================================
// Feedback
// ============================================================================
export type FeedbackType = 'bug' | 'feature' | 'feedback'
export interface FeedbackSubmission {
type: FeedbackType
title: string
description: string
screenshot?: string // base64
sessionId?: string
metadata?: Record<string, unknown>
}
// ============================================================================
// Onboarding
// ============================================================================
export interface OnboardingStep {
step: number
title: string
description: string
completed: boolean
}
export interface OnboardingState {
currentStep: number
totalSteps: number
steps: OnboardingStep[]
selectedState?: string // Bundesland
selectedSchoolType?: string
completed: boolean
}
// ============================================================================
// WebSocket Messages
// ============================================================================
export type WSMessageType =
| 'phase_update'
| 'timer_tick'
| 'overtime_warning'
| 'pause_toggle'
| 'session_end'
| 'sync_request'
export interface WSMessage {
type: WSMessageType
payload: {
sessionId: string
phase?: number
elapsed?: number
isPaused?: boolean
overtimeMinutes?: number
[key: string]: unknown
}
timestamp: string
}
// ============================================================================
// API Responses
// ============================================================================
export interface APIResponse<T> {
success: boolean
data?: T
error?: string
message?: string
}
export interface DashboardResponse extends APIResponse<CompanionData> {}
export interface LessonResponse extends APIResponse<LessonSession> {}
export interface TemplatesResponse extends APIResponse<{ templates: LessonTemplate[] }> {}
export interface SettingsResponse extends APIResponse<TeacherSettings> {}
// ============================================================================
// Component Props
// ============================================================================
export type CompanionMode = 'companion' | 'lesson' | 'classic'
export interface ModeToggleProps {
currentMode: CompanionMode
onModeChange: (mode: CompanionMode) => void
}
export interface PhaseTimelineProps {
phases: Phase[]
currentPhaseIndex: number
onPhaseClick?: (index: number) => void
}
export interface VisualPieTimerProps {
progress: number // 0-1
remainingSeconds: number
totalSeconds: number
colorStatus: TimerColorStatus
isPaused: boolean
currentPhaseName: string
phaseColor: string
}
export interface QuickActionsBarProps {
onExtend: (minutes: number) => void
onPause: () => void
onResume: () => void
onSkip: () => void
isPaused: boolean
isLastPhase: boolean
disabled?: boolean
}

View File

@@ -486,15 +486,6 @@ export const navigation: NavCategory[] = [
audience: ['Lehrer', 'Entwickler'],
oldAdminPath: '/admin/klausur-korrektur',
},
{
id: 'companion',
name: 'Companion',
href: '/education/companion',
description: 'Unterrichts-Timer & Phasen',
purpose: 'Strukturierter Unterricht mit 5-Phasen-Modell (E-A-S-T-R). Visual Timer, Hausaufgaben-Tracking und Reflexion.',
audience: ['Lehrer'],
oldAdminPath: '/admin/companion',
},
],
},
// =========================================================================

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,722 @@
import type { ScopeProfilingAnswer, ComplianceDepthLevel, ScopeDocumentType } from './compliance-scope-types'
export interface GoldenTest {
id: string
name: string
description: string
answers: ScopeProfilingAnswer[]
expectedLevel: ComplianceDepthLevel | null // null for prefill tests
expectedMinDocuments?: ScopeDocumentType[]
expectedHardTriggerIds?: string[]
expectedDsfaRequired?: boolean
tags: string[]
}
export const GOLDEN_TESTS: GoldenTest[] = [
// GT-01: 2-Person Freelancer, nur B2B, DE-Hosting → L1
{
id: 'GT-01',
name: '2-Person Freelancer B2B',
description: 'Kleinstes Setup ohne besondere Risiken',
answers: [
{ questionId: 'org_employee_count', value: '2' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'de' },
{ questionId: 'org_industry', value: 'consulting' },
{ questionId: 'data_health', value: false },
{ questionId: 'data_genetic', value: false },
{ questionId: 'data_biometric', value: false },
{ questionId: 'data_racial_ethnic', value: false },
{ questionId: 'data_political_opinion', value: false },
{ questionId: 'data_religious', value: false },
{ questionId: 'data_union_membership', value: false },
{ questionId: 'data_sexual_orientation', value: false },
{ questionId: 'data_criminal', value: false },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
{ questionId: 'process_has_dsfa', value: true },
{ questionId: 'process_has_incident_plan', value: true },
{ questionId: 'data_volume', value: '<1000' },
{ questionId: 'org_customer_count', value: '<100' },
],
expectedLevel: 'L1',
expectedMinDocuments: ['VVT', 'TOM', 'COOKIE_BANNER'],
expectedHardTriggerIds: [],
expectedDsfaRequired: false,
tags: ['baseline', 'freelancer', 'b2b'],
},
// GT-02: Solo IT-Berater → L1
{
id: 'GT-02',
name: 'Solo IT-Berater',
description: 'Einzelperson, minimale Datenverarbeitung',
answers: [
{ questionId: 'org_employee_count', value: '1' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'de' },
{ questionId: 'org_industry', value: 'it_services' },
{ questionId: 'data_health', value: false },
{ questionId: 'data_genetic', value: false },
{ questionId: 'data_biometric', value: false },
{ questionId: 'data_volume', value: '<1000' },
{ questionId: 'org_customer_count', value: '<50' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
],
expectedLevel: 'L1',
expectedHardTriggerIds: [],
tags: ['baseline', 'solo', 'minimal'],
},
// GT-03: 5-Person Agentur, Website, kein Tracking → L1
{
id: 'GT-03',
name: '5-Person Agentur ohne Tracking',
description: 'Kleine Agentur, einfache Website ohne Analytics',
answers: [
{ questionId: 'org_employee_count', value: '5' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'marketing' },
{ questionId: 'tech_has_website', value: true },
{ questionId: 'tech_has_tracking', value: false },
{ questionId: 'data_volume', value: '1000-10000' },
{ questionId: 'org_customer_count', value: '100-1000' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
],
expectedLevel: 'L1',
expectedMinDocuments: ['VVT', 'TOM', 'COOKIE_BANNER'],
tags: ['baseline', 'agency', 'simple'],
},
// GT-04: 30-Person SaaS B2B, EU-Cloud → L2 (scale trigger)
{
id: 'GT-04',
name: '30-Person SaaS B2B',
description: 'Scale-Trigger durch Mitarbeiterzahl',
answers: [
{ questionId: 'org_employee_count', value: '30' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'software' },
{ questionId: 'tech_has_cloud', value: true },
{ questionId: 'data_volume', value: '10000-100000' },
{ questionId: 'org_customer_count', value: '1000-10000' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
{ questionId: 'process_has_dsfa', value: false },
],
expectedLevel: 'L2',
expectedMinDocuments: ['VVT', 'TOM', 'AVV', 'COOKIE_BANNER'],
tags: ['scale', 'saas', 'growth'],
},
// GT-05: 50-Person Handel B2C, Webshop → L2 (B2C+Webshop)
{
id: 'GT-05',
name: '50-Person E-Commerce B2C',
description: 'B2C mit Webshop erhöht Anforderungen',
answers: [
{ questionId: 'org_employee_count', value: '50' },
{ questionId: 'org_business_model', value: 'b2c' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'retail' },
{ questionId: 'tech_has_webshop', value: true },
{ questionId: 'data_volume', value: '100000-1000000' },
{ questionId: 'org_customer_count', value: '10000-100000' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
],
expectedLevel: 'L2',
expectedHardTriggerIds: ['HT-H01'],
expectedMinDocuments: ['VVT', 'TOM', 'AVV', 'COOKIE_BANNER', 'EINWILLIGUNG'],
tags: ['b2c', 'webshop', 'retail'],
},
// GT-06: 80-Person Dienstleister, Cloud → L2 (scale)
{
id: 'GT-06',
name: '80-Person Dienstleister',
description: 'Größerer Betrieb mit Cloud-Services',
answers: [
{ questionId: 'org_employee_count', value: '80' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'professional_services' },
{ questionId: 'tech_has_cloud', value: true },
{ questionId: 'data_volume', value: '100000-1000000' },
{ questionId: 'org_customer_count', value: '1000-10000' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
],
expectedLevel: 'L2',
expectedMinDocuments: ['VVT', 'TOM', 'AVV'],
tags: ['scale', 'services'],
},
// GT-07: 20-Person Startup mit GA4 Tracking → L2 (tracking)
{
id: 'GT-07',
name: 'Startup mit Google Analytics',
description: 'Tracking-Tools erhöhen Compliance-Anforderungen',
answers: [
{ questionId: 'org_employee_count', value: '20' },
{ questionId: 'org_business_model', value: 'b2c' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'technology' },
{ questionId: 'tech_has_website', value: true },
{ questionId: 'tech_has_tracking', value: true },
{ questionId: 'tech_tracking_tools', value: 'google_analytics' },
{ questionId: 'data_volume', value: '10000-100000' },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L2',
expectedMinDocuments: ['VVT', 'TOM', 'COOKIE_BANNER', 'EINWILLIGUNG'],
tags: ['tracking', 'analytics', 'startup'],
},
// GT-08: Kita-App (Minderjaehrige) → L3 (HT-B01)
{
id: 'GT-08',
name: 'Kita-App für Eltern',
description: 'Datenverarbeitung von Minderjährigen unter 16',
answers: [
{ questionId: 'org_employee_count', value: '15' },
{ questionId: 'org_business_model', value: 'b2c' },
{ questionId: 'tech_hosting_location', value: 'de' },
{ questionId: 'org_industry', value: 'education' },
{ questionId: 'data_subjects_minors', value: true },
{ questionId: 'data_subjects_minors_age', value: '<16' },
{ questionId: 'data_volume', value: '1000-10000' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-B01'],
expectedDsfaRequired: true,
expectedMinDocuments: ['VVT', 'TOM', 'DSFA', 'EINWILLIGUNG', 'AVV'],
tags: ['hard-trigger', 'minors', 'education'],
},
// GT-09: Krankenhaus-Software → L3 (HT-A01)
{
id: 'GT-09',
name: 'Krankenhaus-Verwaltungssoftware',
description: 'Gesundheitsdaten Art. 9 DSGVO',
answers: [
{ questionId: 'org_employee_count', value: '200' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'de' },
{ questionId: 'org_industry', value: 'healthcare' },
{ questionId: 'data_health', value: true },
{ questionId: 'data_volume', value: '>1000000' },
{ questionId: 'org_customer_count', value: '10-50' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-A01'],
expectedDsfaRequired: true,
expectedMinDocuments: ['VVT', 'TOM', 'DSFA', 'AVV'],
tags: ['hard-trigger', 'health', 'art9'],
},
// GT-10: HR-Scoring-Plattform → L3 (HT-C01)
{
id: 'GT-10',
name: 'HR-Scoring für Bewerbungen',
description: 'Automatisierte Entscheidungen im HR-Bereich',
answers: [
{ questionId: 'org_employee_count', value: '40' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'hr_tech' },
{ questionId: 'tech_has_adm', value: true },
{ questionId: 'tech_adm_type', value: 'profiling' },
{ questionId: 'tech_adm_impact', value: 'employment' },
{ questionId: 'data_volume', value: '100000-1000000' },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-C01'],
expectedDsfaRequired: true,
expectedMinDocuments: ['VVT', 'TOM', 'DSFA', 'AVV'],
tags: ['hard-trigger', 'adm', 'profiling'],
},
// GT-11: Fintech Kreditscoring → L3 (HT-H05 + C01)
{
id: 'GT-11',
name: 'Fintech Kreditscoring',
description: 'Finanzsektor mit automatisierten Entscheidungen',
answers: [
{ questionId: 'org_employee_count', value: '120' },
{ questionId: 'org_business_model', value: 'b2c' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'finance' },
{ questionId: 'tech_has_adm', value: true },
{ questionId: 'tech_adm_type', value: 'scoring' },
{ questionId: 'tech_adm_impact', value: 'credit' },
{ questionId: 'data_volume', value: '>1000000' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-H05', 'HT-C01'],
expectedDsfaRequired: true,
expectedMinDocuments: ['VVT', 'TOM', 'DSFA', 'AVV'],
tags: ['hard-trigger', 'finance', 'scoring'],
},
// GT-12: Bildungsplattform Minderjaehrige → L3 (HT-B01)
{
id: 'GT-12',
name: 'Online-Lernplattform für Schüler',
description: 'Bildungssektor mit minderjährigen Nutzern',
answers: [
{ questionId: 'org_employee_count', value: '35' },
{ questionId: 'org_business_model', value: 'b2c' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'education' },
{ questionId: 'data_subjects_minors', value: true },
{ questionId: 'data_subjects_minors_age', value: '<16' },
{ questionId: 'tech_has_tracking', value: true },
{ questionId: 'data_volume', value: '100000-1000000' },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-B01'],
expectedDsfaRequired: true,
tags: ['hard-trigger', 'education', 'minors'],
},
// GT-13: Datenbroker → L3 (HT-H02)
{
id: 'GT-13',
name: 'Datenbroker / Adresshandel',
description: 'Geschäftsmodell basiert auf Datenhandel',
answers: [
{ questionId: 'org_employee_count', value: '25' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'data_broker' },
{ questionId: 'data_is_core_business', value: true },
{ questionId: 'data_volume', value: '>1000000' },
{ questionId: 'org_customer_count', value: '100-1000' },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-H02'],
expectedDsfaRequired: true,
tags: ['hard-trigger', 'data-broker'],
},
// GT-14: Video + ADM → L3 (HT-D05)
{
id: 'GT-14',
name: 'Videoüberwachung mit Gesichtserkennung',
description: 'Biometrische Daten mit automatisierter Verarbeitung',
answers: [
{ questionId: 'org_employee_count', value: '60' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'de' },
{ questionId: 'org_industry', value: 'security' },
{ questionId: 'data_biometric', value: true },
{ questionId: 'tech_has_video_surveillance', value: true },
{ questionId: 'tech_has_adm', value: true },
{ questionId: 'data_volume', value: '100000-1000000' },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-D05'],
expectedDsfaRequired: true,
tags: ['hard-trigger', 'biometric', 'video'],
},
// GT-15: 500-MA Konzern ohne Zert → L3 (HT-G04)
{
id: 'GT-15',
name: 'Großunternehmen ohne Zertifizierung',
description: 'Scale-Trigger durch Unternehmensgröße',
answers: [
{ questionId: 'org_employee_count', value: '500' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'manufacturing' },
{ questionId: 'data_volume', value: '>1000000' },
{ questionId: 'org_customer_count', value: '>100000' },
{ questionId: 'cert_has_iso27001', value: false },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-G04'],
expectedDsfaRequired: true,
tags: ['hard-trigger', 'scale', 'enterprise'],
},
// GT-16: ISO 27001 Anbieter → L4 (HT-F01)
{
id: 'GT-16',
name: 'ISO 27001 zertifizierter Cloud-Provider',
description: 'Zertifizierung erfordert höchste Compliance',
answers: [
{ questionId: 'org_employee_count', value: '150' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'cloud_services' },
{ questionId: 'cert_has_iso27001', value: true },
{ questionId: 'data_volume', value: '>1000000' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
{ questionId: 'process_has_dsfa', value: true },
],
expectedLevel: 'L4',
expectedHardTriggerIds: ['HT-F01'],
expectedMinDocuments: ['VVT', 'TOM', 'DSFA', 'AVV', 'CERT_ISO27001'],
tags: ['hard-trigger', 'certification', 'iso'],
},
// GT-17: TISAX Automobilzulieferer → L4 (HT-F04)
{
id: 'GT-17',
name: 'TISAX-zertifizierter Automobilzulieferer',
description: 'Automotive-Branche mit TISAX-Anforderungen',
answers: [
{ questionId: 'org_employee_count', value: '300' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'de' },
{ questionId: 'org_industry', value: 'automotive' },
{ questionId: 'cert_has_tisax', value: true },
{ questionId: 'data_volume', value: '>1000000' },
{ questionId: 'org_customer_count', value: '10-50' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
],
expectedLevel: 'L4',
expectedHardTriggerIds: ['HT-F04'],
tags: ['hard-trigger', 'certification', 'tisax'],
},
// GT-18: ISO 27701 Cloud-Provider → L4 (HT-F02)
{
id: 'GT-18',
name: 'ISO 27701 Privacy-zertifiziert',
description: 'Privacy-spezifische Zertifizierung',
answers: [
{ questionId: 'org_employee_count', value: '200' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'cloud_services' },
{ questionId: 'cert_has_iso27701', value: true },
{ questionId: 'data_volume', value: '>1000000' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
{ questionId: 'process_has_dsfa', value: true },
],
expectedLevel: 'L4',
expectedHardTriggerIds: ['HT-F02'],
tags: ['hard-trigger', 'certification', 'privacy'],
},
// GT-19: Grosskonzern + Art.9 + >1M DS → L4 (HT-G05)
{
id: 'GT-19',
name: 'Konzern mit sensiblen Massendaten',
description: 'Kombination aus Scale und Art. 9 Daten',
answers: [
{ questionId: 'org_employee_count', value: '2000' },
{ questionId: 'org_business_model', value: 'b2c' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'insurance' },
{ questionId: 'data_health', value: true },
{ questionId: 'data_volume', value: '>1000000' },
{ questionId: 'org_customer_count', value: '>100000' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
],
expectedLevel: 'L4',
expectedHardTriggerIds: ['HT-G05'],
expectedDsfaRequired: true,
tags: ['hard-trigger', 'scale', 'art9'],
},
// GT-20: Nur B2C Webshop → L2 (HT-H01)
{
id: 'GT-20',
name: 'Reiner B2C Webshop',
description: 'B2C-Trigger ohne weitere Risiken',
answers: [
{ questionId: 'org_employee_count', value: '12' },
{ questionId: 'org_business_model', value: 'b2c' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'retail' },
{ questionId: 'tech_has_webshop', value: true },
{ questionId: 'data_volume', value: '10000-100000' },
{ questionId: 'org_customer_count', value: '1000-10000' },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L2',
expectedHardTriggerIds: ['HT-H01'],
tags: ['b2c', 'webshop'],
},
// GT-21: Keine Daten, keine MA → L1
{
id: 'GT-21',
name: 'Minimale Datenverarbeitung',
description: 'Absolute Baseline ohne Risiken',
answers: [
{ questionId: 'org_employee_count', value: '1' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'de' },
{ questionId: 'org_industry', value: 'consulting' },
{ questionId: 'data_volume', value: '<1000' },
{ questionId: 'org_customer_count', value: '<50' },
{ questionId: 'tech_has_website', value: false },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L1',
expectedHardTriggerIds: [],
tags: ['baseline', 'minimal'],
},
// GT-22: Alle Art.9 Kategorien → L3 (HT-A09)
{
id: 'GT-22',
name: 'Alle Art. 9 Kategorien',
description: 'Multiple sensible Datenkategorien',
answers: [
{ questionId: 'org_employee_count', value: '50' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'research' },
{ questionId: 'data_health', value: true },
{ questionId: 'data_genetic', value: true },
{ questionId: 'data_biometric', value: true },
{ questionId: 'data_racial_ethnic', value: true },
{ questionId: 'data_political_opinion', value: true },
{ questionId: 'data_religious', value: true },
{ questionId: 'data_union_membership', value: true },
{ questionId: 'data_sexual_orientation', value: true },
{ questionId: 'data_criminal', value: true },
{ questionId: 'data_volume', value: '100000-1000000' },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-A09'],
expectedDsfaRequired: true,
tags: ['hard-trigger', 'art9', 'multiple-categories'],
},
// GT-23: Drittland + Art.9 → L3 (HT-E04)
{
id: 'GT-23',
name: 'Drittlandtransfer mit Art. 9 Daten',
description: 'Kombination aus Drittland und sensiblen Daten',
answers: [
{ questionId: 'org_employee_count', value: '45' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'us' },
{ questionId: 'org_industry', value: 'healthcare' },
{ questionId: 'data_health', value: true },
{ questionId: 'tech_has_third_country_transfer', value: true },
{ questionId: 'data_volume', value: '100000-1000000' },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-E04'],
expectedDsfaRequired: true,
tags: ['hard-trigger', 'third-country', 'art9'],
},
// GT-24: Minderjaehrige + Art.9 → L4 (HT-B02)
{
id: 'GT-24',
name: 'Minderjährige mit Gesundheitsdaten',
description: 'Kombination aus vulnerabler Gruppe und Art. 9',
answers: [
{ questionId: 'org_employee_count', value: '30' },
{ questionId: 'org_business_model', value: 'b2c' },
{ questionId: 'tech_hosting_location', value: 'de' },
{ questionId: 'org_industry', value: 'healthcare' },
{ questionId: 'data_subjects_minors', value: true },
{ questionId: 'data_subjects_minors_age', value: '<16' },
{ questionId: 'data_health', value: true },
{ questionId: 'data_volume', value: '10000-100000' },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L4',
expectedHardTriggerIds: ['HT-B02'],
expectedDsfaRequired: true,
tags: ['hard-trigger', 'minors', 'health', 'combined-risk'],
},
// GT-25: KI autonome Entscheidungen → L3 (HT-C02)
{
id: 'GT-25',
name: 'KI mit autonomen Entscheidungen',
description: 'AI Act relevante autonome Systeme',
answers: [
{ questionId: 'org_employee_count', value: '70' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'ai_services' },
{ questionId: 'tech_has_adm', value: true },
{ questionId: 'tech_adm_type', value: 'autonomous_decision' },
{ questionId: 'tech_has_ai', value: true },
{ questionId: 'data_volume', value: '100000-1000000' },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-C02'],
expectedDsfaRequired: true,
tags: ['hard-trigger', 'ai', 'adm'],
},
// GT-26: Multiple Zertifizierungen → L4 (HT-F01-05)
{
id: 'GT-26',
name: 'Multiple Zertifizierungen',
description: 'Mehrere Zertifizierungen kombiniert',
answers: [
{ questionId: 'org_employee_count', value: '250' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'cloud_services' },
{ questionId: 'cert_has_iso27001', value: true },
{ questionId: 'cert_has_iso27701', value: true },
{ questionId: 'cert_has_soc2', value: true },
{ questionId: 'data_volume', value: '>1000000' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
{ questionId: 'process_has_dsfa', value: true },
],
expectedLevel: 'L4',
expectedHardTriggerIds: ['HT-F01', 'HT-F02', 'HT-F03'],
tags: ['hard-trigger', 'certification', 'multiple'],
},
// GT-27: Oeffentlicher Sektor + Gesundheit → L3 (HT-H07 + A01)
{
id: 'GT-27',
name: 'Öffentlicher Sektor mit Gesundheitsdaten',
description: 'Behörde mit Art. 9 Datenverarbeitung',
answers: [
{ questionId: 'org_employee_count', value: '120' },
{ questionId: 'org_business_model', value: 'b2g' },
{ questionId: 'tech_hosting_location', value: 'de' },
{ questionId: 'org_industry', value: 'public_sector' },
{ questionId: 'org_is_public_sector', value: true },
{ questionId: 'data_health', value: true },
{ questionId: 'data_volume', value: '>1000000' },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-H07', 'HT-A01'],
expectedDsfaRequired: true,
tags: ['hard-trigger', 'public-sector', 'health'],
},
// GT-28: Bildung + KI + Minderjaehrige → L4 (HT-B03)
{
id: 'GT-28',
name: 'EdTech mit KI für Minderjährige',
description: 'Triple-Risiko: Bildung, KI, vulnerable Gruppe',
answers: [
{ questionId: 'org_employee_count', value: '55' },
{ questionId: 'org_business_model', value: 'b2c' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'education' },
{ questionId: 'data_subjects_minors', value: true },
{ questionId: 'data_subjects_minors_age', value: '<16' },
{ questionId: 'tech_has_ai', value: true },
{ questionId: 'tech_has_adm', value: true },
{ questionId: 'data_volume', value: '100000-1000000' },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L4',
expectedHardTriggerIds: ['HT-B03'],
expectedDsfaRequired: true,
tags: ['hard-trigger', 'education', 'ai', 'minors', 'triple-risk'],
},
// GT-29: Freelancer mit 1 Art.9 → L3 (hard trigger override despite low score)
{
id: 'GT-29',
name: 'Freelancer mit Gesundheitsdaten',
description: 'Hard Trigger überschreibt niedrige Score-Bewertung',
answers: [
{ questionId: 'org_employee_count', value: '1' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'de' },
{ questionId: 'org_industry', value: 'healthcare' },
{ questionId: 'data_health', value: true },
{ questionId: 'data_volume', value: '<1000' },
{ questionId: 'org_customer_count', value: '<50' },
{ questionId: 'process_has_vvt', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-A01'],
expectedDsfaRequired: true,
tags: ['hard-trigger', 'override', 'art9', 'freelancer'],
},
// GT-30: Enterprise, alle Prozesse vorhanden → L3 (good process maturity)
{
id: 'GT-30',
name: 'Enterprise mit reifer Prozesslandschaft',
description: 'Große Organisation mit allen Compliance-Prozessen',
answers: [
{ questionId: 'org_employee_count', value: '450' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'tech_hosting_location', value: 'eu' },
{ questionId: 'org_industry', value: 'manufacturing' },
{ questionId: 'data_volume', value: '>1000000' },
{ questionId: 'org_customer_count', value: '10000-100000' },
{ questionId: 'process_has_vvt', value: true },
{ questionId: 'process_has_tom', value: true },
{ questionId: 'process_has_dsfa', value: true },
{ questionId: 'process_has_incident_plan', value: true },
{ questionId: 'process_has_dsb', value: true },
{ questionId: 'process_has_training', value: true },
],
expectedLevel: 'L3',
expectedHardTriggerIds: ['HT-G04'],
tags: ['enterprise', 'mature', 'all-processes'],
},
// GT-31: SMB, nur 1 Block beantwortet → L1 (graceful degradation)
{
id: 'GT-31',
name: 'Unvollständige Profilerstellung',
description: 'Test für graceful degradation bei unvollständigen Antworten',
answers: [
{ questionId: 'org_employee_count', value: '8' },
{ questionId: 'org_business_model', value: 'b2b' },
{ questionId: 'org_industry', value: 'consulting' },
// Nur Block 1 (Organization) beantwortet, Rest fehlt
],
expectedLevel: 'L1',
expectedHardTriggerIds: [],
tags: ['incomplete', 'degradation', 'edge-case'],
},
// GT-32: CompanyProfile Prefill Konsistenz → null (prefill test, no expected level)
{
id: 'GT-32',
name: 'CompanyProfile Prefill Test',
description: 'Prüft ob CompanyProfile-Daten korrekt in ScopeProfile übernommen werden',
answers: [
{ questionId: 'org_employee_count', value: '25' },
{ questionId: 'org_business_model', value: 'b2c' },
{ questionId: 'org_industry', value: 'retail' },
{ questionId: 'tech_hosting_location', value: 'eu' },
// Diese Werte sollten mit CompanyProfile-Prefill übereinstimmen
],
expectedLevel: null,
tags: ['prefill', 'integration', 'consistency'],
},
]

View File

@@ -0,0 +1,821 @@
import type {
ScopeQuestionBlock,
ScopeQuestionBlockId,
ScopeProfilingQuestion,
ScopeProfilingAnswer,
ComplianceScopeState,
} from './compliance-scope-types'
import type { CompanyProfile } from './types'
/**
* Block 1: Organisation & Reife
*/
const BLOCK_1_ORGANISATION: ScopeQuestionBlock = {
id: 'organisation',
title: 'Organisation & Reife',
description: 'Grundlegende Informationen zu Ihrer Organisation und Compliance-Zielen',
order: 1,
questions: [
{
id: 'org_employee_count',
type: 'number',
label: 'Wie viele Mitarbeiter hat Ihre Organisation?',
helpText: 'Geben Sie die Gesamtzahl aller Beschäftigten an (inkl. Teilzeit, Minijobs)',
required: true,
scoreWeights: { risk: 5, complexity: 8, assurance: 6 },
mapsToCompanyProfile: 'employeeCount',
},
{
id: 'org_customer_count',
type: 'single',
label: 'Wie viele Kunden/Nutzer betreuen Sie?',
helpText: 'Schätzen Sie die Anzahl aktiver Kunden oder Nutzer',
required: true,
options: [
{ value: '<100', label: 'Weniger als 100' },
{ value: '100-1000', label: '100 bis 1.000' },
{ value: '1000-10000', label: '1.000 bis 10.000' },
{ value: '10000-100000', label: '10.000 bis 100.000' },
{ value: '100000+', label: 'Mehr als 100.000' },
],
scoreWeights: { risk: 6, complexity: 7, assurance: 6 },
},
{
id: 'org_annual_revenue',
type: 'single',
label: 'Wie hoch ist Ihr jährlicher Umsatz?',
helpText: 'Wählen Sie die zutreffende Umsatzklasse',
required: true,
options: [
{ value: '<2Mio', label: 'Unter 2 Mio. EUR' },
{ value: '2-10Mio', label: '2 bis 10 Mio. EUR' },
{ value: '10-50Mio', label: '10 bis 50 Mio. EUR' },
{ value: '>50Mio', label: 'Über 50 Mio. EUR' },
],
scoreWeights: { risk: 4, complexity: 6, assurance: 7 },
mapsToCompanyProfile: 'annualRevenue',
},
{
id: 'org_cert_target',
type: 'multi',
label: 'Welche Zertifizierungen streben Sie an oder besitzen Sie bereits?',
helpText: 'Mehrfachauswahl möglich. Zertifizierungen erhöhen den Assurance-Bedarf',
required: false,
options: [
{ value: 'ISO27001', label: 'ISO 27001 (Informationssicherheit)' },
{ value: 'ISO27701', label: 'ISO 27701 (Datenschutz-Erweiterung)' },
{ value: 'TISAX', label: 'TISAX (Automotive)' },
{ value: 'SOC2', label: 'SOC 2 (US-Standard)' },
{ value: 'BSI-Grundschutz', label: 'BSI IT-Grundschutz' },
{ value: 'Keine', label: 'Keine Zertifizierung geplant' },
],
scoreWeights: { risk: 3, complexity: 5, assurance: 10 },
},
{
id: 'org_industry',
type: 'single',
label: 'In welcher Branche sind Sie tätig?',
helpText: 'Ihre Branche beeinflusst Risikobewertung und regulatorische Anforderungen',
required: true,
options: [
{ value: 'it_software', label: 'IT & Software' },
{ value: 'healthcare', label: 'Gesundheitswesen' },
{ value: 'education', label: 'Bildung & Forschung' },
{ value: 'finance', label: 'Finanzdienstleistungen' },
{ value: 'retail', label: 'Einzelhandel & E-Commerce' },
{ value: 'manufacturing', label: 'Produktion & Fertigung' },
{ value: 'consulting', label: 'Beratung & Dienstleistungen' },
{ value: 'public', label: 'Öffentliche Verwaltung' },
{ value: 'other', label: 'Sonstige' },
],
scoreWeights: { risk: 7, complexity: 5, assurance: 6 },
mapsToCompanyProfile: 'industry',
mapsToVVTQuestion: 'org_industry',
mapsToLFQuestion: 'org-branche',
},
{
id: 'org_business_model',
type: 'single',
label: 'Was ist Ihr primäres Geschäftsmodell?',
helpText: 'B2C-Modelle haben höhere Datenschutzanforderungen',
required: true,
options: [
{ value: 'b2b', label: 'B2B (Business-to-Business)' },
{ value: 'b2c', label: 'B2C (Business-to-Consumer)' },
{ value: 'both', label: 'B2B und B2C gemischt' },
{ value: 'b2g', label: 'B2G (Business-to-Government)' },
],
scoreWeights: { risk: 6, complexity: 5, assurance: 5 },
mapsToCompanyProfile: 'businessModel',
mapsToVVTQuestion: 'org_b2b_b2c',
mapsToLFQuestion: 'org-geschaeftsmodell',
},
{
id: 'org_has_dsb',
type: 'boolean',
label: 'Haben Sie einen Datenschutzbeauftragten bestellt?',
helpText: 'Ein DSB ist bei mehr als 20 Personen mit regelmäßiger Datenverarbeitung Pflicht',
required: true,
scoreWeights: { risk: 5, complexity: 3, assurance: 6 },
},
],
}
/**
* Block 2: Daten & Betroffene
*/
const BLOCK_2_DATA: ScopeQuestionBlock = {
id: 'data',
title: 'Daten & Betroffene',
description: 'Art und Umfang der verarbeiteten personenbezogenen Daten',
order: 2,
questions: [
{
id: 'data_minors',
type: 'boolean',
label: 'Verarbeiten Sie Daten von Minderjährigen?',
helpText: 'Besondere Schutzpflichten für unter 16-Jährige (bzw. 13-Jährige bei Online-Diensten)',
required: true,
scoreWeights: { risk: 10, complexity: 5, assurance: 7 },
mapsToVVTQuestion: 'data_minors',
},
{
id: 'data_art9',
type: 'multi',
label: 'Verarbeiten Sie besondere Kategorien personenbezogener Daten (Art. 9 DSGVO)?',
helpText: 'Diese Daten unterliegen erhöhten Schutzanforderungen',
required: true,
options: [
{ value: 'gesundheit', label: 'Gesundheitsdaten' },
{ value: 'biometrie', label: 'Biometrische Daten (z.B. Fingerabdruck, Gesichtserkennung)' },
{ value: 'genetik', label: 'Genetische Daten' },
{ value: 'politisch', label: 'Politische Meinungen' },
{ value: 'religion', label: 'Religiöse/weltanschauliche Überzeugungen' },
{ value: 'gewerkschaft', label: 'Gewerkschaftszugehörigkeit' },
{ value: 'sexualleben', label: 'Sexualleben/sexuelle Orientierung' },
{ value: 'strafrechtlich', label: 'Strafrechtliche Verurteilungen/Straftaten' },
{ value: 'ethnisch', label: 'Ethnische Herkunft' },
],
scoreWeights: { risk: 10, complexity: 8, assurance: 9 },
mapsToVVTQuestion: 'data_health',
},
{
id: 'data_hr',
type: 'boolean',
label: 'Verarbeiten Sie Personaldaten (HR)?',
helpText: 'Bewerberdaten, Gehälter, Leistungsbeurteilungen etc.',
required: true,
scoreWeights: { risk: 6, complexity: 4, assurance: 5 },
mapsToVVTQuestion: 'dept_hr',
mapsToLFQuestion: 'data-hr',
},
{
id: 'data_communication',
type: 'boolean',
label: 'Verarbeiten Sie Kommunikationsdaten (E-Mail, Chat, Telefonie)?',
helpText: 'Inhalte oder Metadaten von Kommunikationsvorgängen',
required: true,
scoreWeights: { risk: 7, complexity: 5, assurance: 6 },
},
{
id: 'data_financial',
type: 'boolean',
label: 'Verarbeiten Sie Finanzdaten (Konten, Zahlungen)?',
helpText: 'Bankdaten, Kreditkartendaten, Buchhaltungsdaten',
required: true,
scoreWeights: { risk: 8, complexity: 6, assurance: 7 },
mapsToVVTQuestion: 'dept_finance',
mapsToLFQuestion: 'data-buchhaltung',
},
{
id: 'data_volume',
type: 'single',
label: 'Wie viele Personendatensätze verarbeiten Sie insgesamt?',
helpText: 'Schätzen Sie die Gesamtzahl betroffener Personen',
required: true,
options: [
{ value: '<1000', label: 'Unter 1.000' },
{ value: '1000-10000', label: '1.000 bis 10.000' },
{ value: '10000-100000', label: '10.000 bis 100.000' },
{ value: '100000-1000000', label: '100.000 bis 1 Mio.' },
{ value: '>1000000', label: 'Über 1 Mio.' },
],
scoreWeights: { risk: 7, complexity: 6, assurance: 6 },
},
],
}
/**
* Block 3: Verarbeitung & Zweck
*/
const BLOCK_3_PROCESSING: ScopeQuestionBlock = {
id: 'processing',
title: 'Verarbeitung & Zweck',
description: 'Wie und wofür werden personenbezogene Daten verarbeitet?',
order: 3,
questions: [
{
id: 'proc_tracking',
type: 'boolean',
label: 'Setzen Sie Tracking oder Profiling ein?',
helpText: 'Web-Analytics, Werbe-Tracking, Nutzungsprofile etc.',
required: true,
scoreWeights: { risk: 7, complexity: 6, assurance: 6 },
},
{
id: 'proc_adm_scoring',
type: 'boolean',
label: 'Treffen Sie automatisierte Entscheidungen (Art. 22 DSGVO)?',
helpText: 'Scoring, Bonitätsprüfung, automatische Ablehnung ohne menschliche Beteiligung',
required: true,
scoreWeights: { risk: 9, complexity: 8, assurance: 8 },
},
{
id: 'proc_ai_usage',
type: 'multi',
label: 'Setzen Sie KI-Systeme ein?',
helpText: 'KI-Einsatz kann zusätzliche Anforderungen (EU AI Act) auslösen',
required: true,
options: [
{ value: 'keine', label: 'Keine KI im Einsatz' },
{ value: 'chatbot', label: 'Chatbots/Virtuelle Assistenten' },
{ value: 'scoring', label: 'Scoring/Risikobewertung' },
{ value: 'profiling', label: 'Profiling/Verhaltensvorhersage' },
{ value: 'generativ', label: 'Generative KI (Text, Bild, Code)' },
{ value: 'autonom', label: 'Autonome Systeme/Entscheidungen' },
],
scoreWeights: { risk: 8, complexity: 9, assurance: 7 },
},
{
id: 'proc_data_combination',
type: 'boolean',
label: 'Führen Sie Daten aus verschiedenen Quellen zusammen?',
helpText: 'Data Matching, Anreicherung aus externen Quellen',
required: true,
scoreWeights: { risk: 7, complexity: 7, assurance: 6 },
},
{
id: 'proc_employee_monitoring',
type: 'boolean',
label: 'Überwachen Sie Mitarbeiter (Zeiterfassung, Standort, IT-Nutzung)?',
helpText: 'Beschäftigtendatenschutz nach § 26 BDSG',
required: true,
scoreWeights: { risk: 8, complexity: 6, assurance: 7 },
},
{
id: 'proc_video_surveillance',
type: 'boolean',
label: 'Setzen Sie Videoüberwachung ein?',
helpText: 'Kameras in Büros, Produktionsstätten, Verkaufsräumen etc.',
required: true,
scoreWeights: { risk: 8, complexity: 5, assurance: 7 },
mapsToVVTQuestion: 'special_video_surveillance',
mapsToLFQuestion: 'data-video',
},
],
}
/**
* Block 4: Technik/Hosting/Transfers
*/
const BLOCK_4_TECH: ScopeQuestionBlock = {
id: 'tech',
title: 'Technik, Hosting & Transfers',
description: 'Technische Infrastruktur und Datenübermittlung',
order: 4,
questions: [
{
id: 'tech_hosting_location',
type: 'single',
label: 'Wo werden Ihre Daten primär gehostet?',
helpText: 'Standort bestimmt anwendbares Datenschutzrecht',
required: true,
options: [
{ value: 'de', label: 'Deutschland' },
{ value: 'eu', label: 'EU (ohne Deutschland)' },
{ value: 'ewr', label: 'EWR (z.B. Norwegen, Island)' },
{ value: 'us_adequacy', label: 'USA (mit Angemessenheitsbeschluss/DPF)' },
{ value: 'drittland', label: 'Drittland ohne Angemessenheitsbeschluss' },
],
scoreWeights: { risk: 7, complexity: 6, assurance: 7 },
},
{
id: 'tech_subprocessors',
type: 'boolean',
label: 'Nutzen Sie Auftragsverarbeiter (externe Dienstleister)?',
helpText: 'Cloud-Anbieter, Hosting, E-Mail-Service, CRM etc. erfordert AVV nach Art. 28 DSGVO',
required: true,
scoreWeights: { risk: 6, complexity: 7, assurance: 7 },
},
{
id: 'tech_third_country',
type: 'boolean',
label: 'Übermitteln Sie Daten in Drittländer?',
helpText: 'Transfer außerhalb EU/EWR erfordert Schutzmaßnahmen (SCC, BCR etc.)',
required: true,
scoreWeights: { risk: 9, complexity: 8, assurance: 8 },
mapsToVVTQuestion: 'transfer_cloud_us',
},
{
id: 'tech_encryption_rest',
type: 'boolean',
label: 'Sind Daten im Ruhezustand verschlüsselt (at rest)?',
helpText: 'Datenbank-, Dateisystem- oder Volume-Verschlüsselung',
required: true,
scoreWeights: { risk: -5, complexity: 3, assurance: 7 },
},
{
id: 'tech_encryption_transit',
type: 'boolean',
label: 'Sind Daten bei Übertragung verschlüsselt (in transit)?',
helpText: 'TLS/SSL für alle Verbindungen',
required: true,
scoreWeights: { risk: -5, complexity: 2, assurance: 7 },
},
{
id: 'tech_cloud_providers',
type: 'multi',
label: 'Welche Cloud-Anbieter nutzen Sie?',
helpText: 'Mehrfachauswahl möglich',
required: false,
options: [
{ value: 'aws', label: 'Amazon Web Services (AWS)' },
{ value: 'azure', label: 'Microsoft Azure' },
{ value: 'gcp', label: 'Google Cloud Platform (GCP)' },
{ value: 'hetzner', label: 'Hetzner' },
{ value: 'ionos', label: 'IONOS' },
{ value: 'ovh', label: 'OVH' },
{ value: 'andere', label: 'Andere Anbieter' },
{ value: 'keine', label: 'Keine Cloud-Nutzung (On-Premise)' },
],
scoreWeights: { risk: 5, complexity: 6, assurance: 6 },
},
],
}
/**
* Block 5: Rechte & Prozesse
*/
const BLOCK_5_PROCESSES: ScopeQuestionBlock = {
id: 'processes',
title: 'Rechte & Prozesse',
description: 'Etablierte Datenschutz- und Sicherheitsprozesse',
order: 5,
questions: [
{
id: 'proc_dsar_process',
type: 'boolean',
label: 'Haben Sie einen Prozess für Betroffenenrechte (DSAR)?',
helpText: 'Auskunft, Löschung, Berichtigung, Widerspruch etc. Art. 15-22 DSGVO',
required: true,
scoreWeights: { risk: 6, complexity: 5, assurance: 8 },
},
{
id: 'proc_deletion_concept',
type: 'boolean',
label: 'Haben Sie ein Löschkonzept?',
helpText: 'Definierte Löschfristen und automatisierte Löschroutinen',
required: true,
scoreWeights: { risk: 7, complexity: 6, assurance: 8 },
},
{
id: 'proc_incident_response',
type: 'boolean',
label: 'Haben Sie einen Notfallplan für Datenschutzvorfälle?',
helpText: 'Incident Response Plan, 72h-Meldepflicht an Aufsichtsbehörde (Art. 33 DSGVO)',
required: true,
scoreWeights: { risk: 8, complexity: 6, assurance: 9 },
},
{
id: 'proc_regular_audits',
type: 'boolean',
label: 'Führen Sie regelmäßige Datenschutz-Audits durch?',
helpText: 'Interne oder externe Prüfungen mindestens jährlich',
required: true,
scoreWeights: { risk: 5, complexity: 4, assurance: 9 },
},
{
id: 'proc_training',
type: 'boolean',
label: 'Schulen Sie Ihre Mitarbeiter im Datenschutz?',
helpText: 'Awareness-Trainings, Onboarding, jährliche Auffrischung',
required: true,
scoreWeights: { risk: 6, complexity: 3, assurance: 7 },
},
],
}
/**
* Block 6: Produktkontext
*/
const BLOCK_6_PRODUCT: ScopeQuestionBlock = {
id: 'product',
title: 'Produktkontext',
description: 'Spezifische Merkmale Ihrer Produkte und Services',
order: 6,
questions: [
{
id: 'prod_type',
type: 'multi',
label: 'Welche Art von Produkten/Services bieten Sie an?',
helpText: 'Mehrfachauswahl möglich',
required: true,
options: [
{ value: 'webapp', label: 'Web-Anwendung' },
{ value: 'mobile', label: 'Mobile App (iOS/Android)' },
{ value: 'saas', label: 'SaaS-Plattform' },
{ value: 'onpremise', label: 'On-Premise Software' },
{ value: 'api', label: 'API/Schnittstellen' },
{ value: 'iot', label: 'IoT/Hardware' },
{ value: 'beratung', label: 'Beratungsleistungen' },
{ value: 'handel', label: 'Handel/Vertrieb' },
],
scoreWeights: { risk: 5, complexity: 6, assurance: 5 },
},
{
id: 'prod_cookies_consent',
type: 'boolean',
label: 'Benötigen Sie Cookie-Consent (Tracking-Cookies)?',
helpText: 'Nicht-essenzielle Cookies erfordern opt-in Einwilligung',
required: true,
scoreWeights: { risk: 5, complexity: 4, assurance: 6 },
},
{
id: 'prod_webshop',
type: 'boolean',
label: 'Betreiben Sie einen Online-Shop?',
helpText: 'E-Commerce mit Zahlungsabwicklung, Bestellverwaltung',
required: true,
scoreWeights: { risk: 7, complexity: 6, assurance: 6 },
},
{
id: 'prod_api_external',
type: 'boolean',
label: 'Bieten Sie externe APIs an (Daten-Weitergabe an Dritte)?',
helpText: 'Programmierschnittstellen für Partner, Entwickler etc.',
required: true,
scoreWeights: { risk: 7, complexity: 7, assurance: 7 },
},
{
id: 'prod_data_broker',
type: 'boolean',
label: 'Handeln Sie mit Daten (Data Brokerage, Adresshandel)?',
helpText: 'Verkauf oder Vermittlung personenbezogener Daten',
required: true,
scoreWeights: { risk: 10, complexity: 8, assurance: 9 },
},
],
}
/**
* All question blocks in order
*/
export const SCOPE_QUESTION_BLOCKS: ScopeQuestionBlock[] = [
BLOCK_1_ORGANISATION,
BLOCK_2_DATA,
BLOCK_3_PROCESSING,
BLOCK_4_TECH,
BLOCK_5_PROCESSES,
BLOCK_6_PRODUCT,
]
/**
* Prefill scope answers from CompanyProfile
*/
export function prefillFromCompanyProfile(
profile: CompanyProfile
): ScopeProfilingAnswer[] {
const answers: ScopeProfilingAnswer[] = []
// employeeCount
if (profile.employeeCount != null) {
answers.push({
questionId: 'org_employee_count',
value: profile.employeeCount,
})
}
// annualRevenue
if (profile.annualRevenue) {
answers.push({
questionId: 'org_annual_revenue',
value: profile.annualRevenue,
})
}
// industry
if (profile.industry) {
answers.push({
questionId: 'org_industry',
value: profile.industry,
})
}
// businessModel
if (profile.businessModel) {
answers.push({
questionId: 'org_business_model',
value: profile.businessModel,
})
}
// dpoName -> org_has_dsb
if (profile.dpoName && profile.dpoName.trim() !== '') {
answers.push({
questionId: 'org_has_dsb',
value: true,
})
}
// usesAI -> proc_ai_usage
if (profile.usesAI === true) {
// We don't know which specific AI type, so just mark as "generativ" as a default
answers.push({
questionId: 'proc_ai_usage',
value: ['generativ'],
})
} else if (profile.usesAI === false) {
answers.push({
questionId: 'proc_ai_usage',
value: ['keine'],
})
}
// offerings -> prod_type mapping
if (profile.offerings && profile.offerings.length > 0) {
const prodTypes: string[] = []
const offeringsLower = profile.offerings.map((o) => o.toLowerCase())
if (offeringsLower.some((o) => o.includes('webapp') || o.includes('web'))) {
prodTypes.push('webapp')
}
if (
offeringsLower.some((o) => o.includes('mobile') || o.includes('app'))
) {
prodTypes.push('mobile')
}
if (offeringsLower.some((o) => o.includes('saas') || o.includes('cloud'))) {
prodTypes.push('saas')
}
if (
offeringsLower.some(
(o) => o.includes('onpremise') || o.includes('on-premise')
)
) {
prodTypes.push('onpremise')
}
if (offeringsLower.some((o) => o.includes('api'))) {
prodTypes.push('api')
}
if (offeringsLower.some((o) => o.includes('iot') || o.includes('hardware'))) {
prodTypes.push('iot')
}
if (
offeringsLower.some(
(o) => o.includes('beratung') || o.includes('consulting')
)
) {
prodTypes.push('beratung')
}
if (
offeringsLower.some(
(o) => o.includes('handel') || o.includes('shop') || o.includes('commerce')
)
) {
prodTypes.push('handel')
}
if (prodTypes.length > 0) {
answers.push({
questionId: 'prod_type',
value: prodTypes,
})
}
}
return answers
}
/**
* Prefill scope answers from VVT profiling answers
*/
export function prefillFromVVTAnswers(
vvtAnswers: Record<string, unknown>
): ScopeProfilingAnswer[] {
const answers: ScopeProfilingAnswer[] = []
// Build reverse mapping: VVT question -> Scope question
const reverseMap: Record<string, string> = {}
for (const block of SCOPE_QUESTION_BLOCKS) {
for (const q of block.questions) {
if (q.mapsToVVTQuestion) {
reverseMap[q.mapsToVVTQuestion] = q.id
}
}
}
// Map VVT answers to scope answers
for (const [vvtQuestionId, vvtValue] of Object.entries(vvtAnswers)) {
const scopeQuestionId = reverseMap[vvtQuestionId]
if (scopeQuestionId) {
answers.push({
questionId: scopeQuestionId,
value: vvtValue,
})
}
}
return answers
}
/**
* Prefill scope answers from Loeschfristen profiling answers
*/
export function prefillFromLoeschfristenAnswers(
lfAnswers: Array<{ questionId: string; value: unknown }>
): ScopeProfilingAnswer[] {
const answers: ScopeProfilingAnswer[] = []
// Build reverse mapping: LF question -> Scope question
const reverseMap: Record<string, string> = {}
for (const block of SCOPE_QUESTION_BLOCKS) {
for (const q of block.questions) {
if (q.mapsToLFQuestion) {
reverseMap[q.mapsToLFQuestion] = q.id
}
}
}
// Map LF answers to scope answers
for (const lfAnswer of lfAnswers) {
const scopeQuestionId = reverseMap[lfAnswer.questionId]
if (scopeQuestionId) {
answers.push({
questionId: scopeQuestionId,
value: lfAnswer.value,
})
}
}
return answers
}
/**
* Export scope answers in VVT format
*/
export function exportToVVTAnswers(
scopeAnswers: ScopeProfilingAnswer[]
): Record<string, unknown> {
const vvtAnswers: Record<string, unknown> = {}
for (const answer of scopeAnswers) {
// Find the question
let question: ScopeProfilingQuestion | undefined
for (const block of SCOPE_QUESTION_BLOCKS) {
question = block.questions.find((q) => q.id === answer.questionId)
if (question) break
}
if (question?.mapsToVVTQuestion) {
vvtAnswers[question.mapsToVVTQuestion] = answer.value
}
}
return vvtAnswers
}
/**
* Export scope answers in Loeschfristen format
*/
export function exportToLoeschfristenAnswers(
scopeAnswers: ScopeProfilingAnswer[]
): Array<{ questionId: string; value: unknown }> {
const lfAnswers: Array<{ questionId: string; value: unknown }> = []
for (const answer of scopeAnswers) {
// Find the question
let question: ScopeProfilingQuestion | undefined
for (const block of SCOPE_QUESTION_BLOCKS) {
question = block.questions.find((q) => q.id === answer.questionId)
if (question) break
}
if (question?.mapsToLFQuestion) {
lfAnswers.push({
questionId: question.mapsToLFQuestion,
value: answer.value,
})
}
}
return lfAnswers
}
/**
* Export scope answers for TOM generator
*/
export function exportToTOMProfile(
scopeAnswers: ScopeProfilingAnswer[]
): Record<string, unknown> {
const tomProfile: Record<string, unknown> = {}
// Get answer values
const getVal = (qId: string) => getAnswerValue(scopeAnswers, qId)
// Map relevant scope answers to TOM profile fields
tomProfile.industry = getVal('org_industry')
tomProfile.employeeCount = getVal('org_employee_count')
tomProfile.hasDataMinors = getVal('data_minors')
tomProfile.hasSpecialCategories = Array.isArray(getVal('data_art9'))
? (getVal('data_art9') as string[]).length > 0
: false
tomProfile.hasAutomatedDecisions = getVal('proc_adm_scoring')
tomProfile.usesAI = Array.isArray(getVal('proc_ai_usage'))
? !(getVal('proc_ai_usage') as string[]).includes('keine')
: false
tomProfile.hasThirdCountryTransfer = getVal('tech_third_country')
tomProfile.hasEncryptionRest = getVal('tech_encryption_rest')
tomProfile.hasEncryptionTransit = getVal('tech_encryption_transit')
tomProfile.hasIncidentResponse = getVal('proc_incident_response')
tomProfile.hasDeletionConcept = getVal('proc_deletion_concept')
tomProfile.hasRegularAudits = getVal('proc_regular_audits')
tomProfile.hasTraining = getVal('proc_training')
return tomProfile
}
/**
* Check if a block is complete (all required questions answered)
*/
export function isBlockComplete(
answers: ScopeProfilingAnswer[],
blockId: ScopeQuestionBlockId
): boolean {
const block = SCOPE_QUESTION_BLOCKS.find((b) => b.id === blockId)
if (!block) return false
const requiredQuestions = block.questions.filter((q) => q.required)
const answeredQuestionIds = new Set(answers.map((a) => a.questionId))
return requiredQuestions.every((q) => answeredQuestionIds.has(q.id))
}
/**
* Get progress for a specific block (0-100)
*/
export function getBlockProgress(
answers: ScopeProfilingAnswer[],
blockId: ScopeQuestionBlockId
): number {
const block = SCOPE_QUESTION_BLOCKS.find((b) => b.id === blockId)
if (!block) return 0
const requiredQuestions = block.questions.filter((q) => q.required)
if (requiredQuestions.length === 0) return 100
const answeredQuestionIds = new Set(answers.map((a) => a.questionId))
const answeredCount = requiredQuestions.filter((q) =>
answeredQuestionIds.has(q.id)
).length
return Math.round((answeredCount / requiredQuestions.length) * 100)
}
/**
* Get total progress across all blocks (0-100)
*/
export function getTotalProgress(answers: ScopeProfilingAnswer[]): number {
let totalRequired = 0
let totalAnswered = 0
const answeredQuestionIds = new Set(answers.map((a) => a.questionId))
for (const block of SCOPE_QUESTION_BLOCKS) {
const requiredQuestions = block.questions.filter((q) => q.required)
totalRequired += requiredQuestions.length
totalAnswered += requiredQuestions.filter((q) =>
answeredQuestionIds.has(q.id)
).length
}
if (totalRequired === 0) return 100
return Math.round((totalAnswered / totalRequired) * 100)
}
/**
* Get answer value for a specific question
*/
export function getAnswerValue(
answers: ScopeProfilingAnswer[],
questionId: string
): unknown {
const answer = answers.find((a) => a.questionId === questionId)
return answer?.value
}
/**
* Get all questions as a flat array
*/
export function getAllQuestions(): ScopeProfilingQuestion[] {
return SCOPE_QUESTION_BLOCKS.flatMap((block) => block.questions)
}

File diff suppressed because it is too large Load Diff

View File

@@ -62,6 +62,9 @@ const initialState: SDKState = {
// Company Profile
companyProfile: null,
// Compliance Scope
complianceScope: null,
// Progress
currentPhase: 1,
currentStep: 'company-profile',
@@ -179,6 +182,16 @@ function sdkReducer(state: SDKState, action: ExtendedSDKAction): SDKState {
: null,
})
case 'SET_COMPLIANCE_SCOPE':
return updateState({ complianceScope: action.payload })
case 'UPDATE_COMPLIANCE_SCOPE':
return updateState({
complianceScope: state.complianceScope
? { ...state.complianceScope, ...action.payload }
: null,
})
case 'ADD_IMPORTED_DOCUMENT':
return updateState({
importedDocuments: [...state.importedDocuments, action.payload],
@@ -448,6 +461,10 @@ interface SDKContextValue {
setCompanyProfile: (profile: CompanyProfile) => void
updateCompanyProfile: (updates: Partial<CompanyProfile>) => void
// Compliance Scope
setComplianceScope: (scope: import('./compliance-scope-types').ComplianceScopeState) => void
updateComplianceScope: (updates: Partial<import('./compliance-scope-types').ComplianceScopeState>) => void
// Import (for existing customers)
addImportedDocument: (doc: ImportedDocument) => void
setGapAnalysis: (analysis: GapAnalysis) => void
@@ -740,6 +757,15 @@ export function SDKProvider({
dispatch({ type: 'UPDATE_COMPANY_PROFILE', payload: updates })
}, [])
// Compliance Scope
const setComplianceScope = useCallback((scope: import('./compliance-scope-types').ComplianceScopeState) => {
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: scope })
}, [])
const updateComplianceScope = useCallback((updates: Partial<import('./compliance-scope-types').ComplianceScopeState>) => {
dispatch({ type: 'UPDATE_COMPLIANCE_SCOPE', payload: updates })
}, [])
// Import Document
const addImportedDocument = useCallback((doc: ImportedDocument) => {
dispatch({ type: 'ADD_IMPORTED_DOCUMENT', payload: doc })
@@ -1040,6 +1066,8 @@ export function SDKProvider({
setCustomerType,
setCompanyProfile,
updateCompanyProfile,
setComplianceScope,
updateComplianceScope,
addImportedDocument,
setGapAnalysis,
validateCheckpoint,

View File

@@ -6,3 +6,5 @@
export * from './types'
export * from './api'
export * from './risk-catalog'
export * from './mitigation-library'

View File

@@ -0,0 +1,694 @@
/**
* DSFA Massnahmenbibliothek - Vordefinierte Massnahmen
*
* ~50 Massnahmen gegliedert nach SDM-Gewaehrleistungszielen
* (Vertraulichkeit, Integritaet, Verfuegbarkeit, Datenminimierung,
* Transparenz, Nichtverkettung, Intervenierbarkeit) sowie
* Automatisierung/KI, Rechtlich/Organisatorisch.
*
* Quellen: Art. 25/32 DSGVO, SDM V2.0, BSI Grundschutz,
* Baseline-DSFA Katalog
*/
import type { DSFAMitigationType } from './types'
import type { SDMGoal } from './types'
// =============================================================================
// TYPES
// =============================================================================
export interface CatalogMitigation {
id: string
type: DSFAMitigationType
sdmGoals: SDMGoal[]
title: string
description: string
legalBasis: string
evidenceTypes: string[]
addressesRiskIds: string[]
effectiveness: 'low' | 'medium' | 'high'
}
// =============================================================================
// MASSNAHMENBIBLIOTHEK
// =============================================================================
export const MITIGATION_LIBRARY: CatalogMitigation[] = [
// =========================================================================
// VERTRAULICHKEIT (Access Control & Encryption)
// =========================================================================
{
id: 'M-ACC-01',
type: 'technical',
sdmGoals: ['vertraulichkeit'],
title: 'Multi-Faktor-Authentifizierung (MFA) & Conditional Access',
description: 'Einfuehrung von MFA fuer alle Benutzerkonten mit Zugriff auf personenbezogene Daten. Conditional Access Policies beschraenken den Zugriff basierend auf Standort, Geraet und Risikobewertung.',
legalBasis: 'Art. 32 Abs. 1 lit. b DSGVO',
evidenceTypes: ['MFA-Policy-Screenshot', 'Conditional-Access-Regeln', 'Login-Statistiken'],
addressesRiskIds: ['R-CONF-02', 'R-CONF-06'],
effectiveness: 'high',
},
{
id: 'M-ACC-02',
type: 'technical',
sdmGoals: ['vertraulichkeit'],
title: 'Passwort-Policy & Credential-Schutz',
description: 'Durchsetzung starker Passwort-Richtlinien, Credential-Rotation, Einsatz eines Passwort-Managers und Monitoring auf kompromittierte Zugangsdaten (Breach Detection).',
legalBasis: 'Art. 32 Abs. 1 lit. b DSGVO',
evidenceTypes: ['Passwort-Policy-Dokument', 'Breach-Detection-Report'],
addressesRiskIds: ['R-CONF-02'],
effectiveness: 'medium',
},
{
id: 'M-CONF-01',
type: 'technical',
sdmGoals: ['vertraulichkeit'],
title: 'Rollenbasierte Zugriffskontrolle (RBAC) & Least Privilege',
description: 'Implementierung eines RBAC-Systems mit dem Prinzip der minimalen Berechtigung. Jeder Benutzer erhaelt nur die Rechte, die fuer seine Aufgabe erforderlich sind.',
legalBasis: 'Art. 32 Abs. 1 lit. b DSGVO, Art. 25 Abs. 2 DSGVO',
evidenceTypes: ['Rollen-Matrix', 'Berechtigungs-Audit-Report', 'Access-Review-Protokoll'],
addressesRiskIds: ['R-CONF-01', 'R-CONF-03', 'R-INT-04'],
effectiveness: 'high',
},
{
id: 'M-CONF-02',
type: 'technical',
sdmGoals: ['vertraulichkeit'],
title: 'Security Configuration Management',
description: 'Regelmaessige Ueberpruefung und Haertung der Systemkonfiguration. Automatisierte Konfigurationschecks (CIS Benchmarks) und Monitoring auf Konfigurationsaenderungen.',
legalBasis: 'Art. 32 Abs. 1 lit. d DSGVO',
evidenceTypes: ['CIS-Benchmark-Report', 'Konfigurationsaenderungs-Log'],
addressesRiskIds: ['R-CONF-01'],
effectiveness: 'high',
},
{
id: 'M-CONF-03',
type: 'organizational',
sdmGoals: ['vertraulichkeit'],
title: 'Regelmaessige Zugriffsrechte-Ueberpruefung (Access Review)',
description: 'Quartalsweiser Review aller Zugriffsberechtigungen durch Vorgesetzte. Entzug nicht mehr benoetigter Rechte, Offboarding-Prozess bei Mitarbeiteraustritt.',
legalBasis: 'Art. 32 Abs. 1 lit. d DSGVO',
evidenceTypes: ['Access-Review-Protokoll', 'Offboarding-Checkliste'],
addressesRiskIds: ['R-CONF-01', 'R-CONF-03'],
effectiveness: 'medium',
},
{
id: 'M-CONF-04',
type: 'technical',
sdmGoals: ['vertraulichkeit', 'integritaet'],
title: 'Privileged Access Management (PAM)',
description: 'Absicherung administrativer Zugriffe durch Just-in-Time-Elevation, Session-Recording und Break-Glass-Prozeduren fuer Notfallzugriffe.',
legalBasis: 'Art. 32 Abs. 1 lit. b DSGVO',
evidenceTypes: ['PAM-Policy', 'Session-Recording-Logs', 'Break-Glass-Protokolle'],
addressesRiskIds: ['R-CONF-03', 'R-INT-04'],
effectiveness: 'high',
},
{
id: 'M-CONF-05',
type: 'organizational',
sdmGoals: ['vertraulichkeit'],
title: 'Vier-Augen-Prinzip fuer sensible Operationen',
description: 'Fuer den Zugriff auf besonders schutzwuerdige Daten oder kritische Systemoperationen ist die Genehmigung durch eine zweite Person erforderlich.',
legalBasis: 'Art. 32 Abs. 1 lit. b DSGVO',
evidenceTypes: ['Prozessbeschreibung', 'Genehmigungsprotokoll'],
addressesRiskIds: ['R-CONF-03'],
effectiveness: 'medium',
},
{
id: 'M-CONF-06',
type: 'technical',
sdmGoals: ['vertraulichkeit'],
title: 'Verschluesselung at-rest und in-transit',
description: 'Vollstaendige Verschluesselung personenbezogener Daten bei Speicherung (AES-256) und Uebertragung (TLS 1.3). Verwaltung der Schluessel ueber ein zentrales Key-Management-System.',
legalBasis: 'Art. 32 Abs. 1 lit. a DSGVO',
evidenceTypes: ['Verschluesselungs-Policy', 'TLS-Konfigurationsreport', 'KMS-Audit'],
addressesRiskIds: ['R-CONF-04', 'R-TRANS-01', 'R-AUTO-05'],
effectiveness: 'high',
},
{
id: 'M-CONF-07',
type: 'technical',
sdmGoals: ['vertraulichkeit'],
title: 'End-to-End-Verschluesselung fuer Kommunikation',
description: 'Einsatz von End-to-End-Verschluesselung fuer sensible Kommunikation (E-Mail, Messaging), sodass auch der Betreiber keinen Zugriff auf die Inhalte hat.',
legalBasis: 'Art. 32 Abs. 1 lit. a DSGVO',
evidenceTypes: ['E2E-Konfiguration', 'Testbericht'],
addressesRiskIds: ['R-CONF-04'],
effectiveness: 'high',
},
{
id: 'M-CONF-08',
type: 'technical',
sdmGoals: ['vertraulichkeit', 'datenminimierung'],
title: 'Log-Sanitization & PII-Filtering',
description: 'Automatische Filterung personenbezogener Daten aus Logs, Fehlermeldungen und Debug-Ausgaben. Einsatz von Tokenisierung oder Maskierung.',
legalBasis: 'Art. 25 Abs. 1 DSGVO, Art. 32 Abs. 1 lit. a DSGVO',
evidenceTypes: ['Log-Policy', 'PII-Filter-Konfiguration', 'Stichproben-Audit'],
addressesRiskIds: ['R-CONF-07'],
effectiveness: 'medium',
},
// =========================================================================
// INTEGRITAET (Audit, Monitoring, Integrity Checks)
// =========================================================================
{
id: 'M-INT-01',
type: 'technical',
sdmGoals: ['integritaet'],
title: 'Input-Validierung & Injection-Schutz',
description: 'Konsequente Validierung aller Eingaben, Prepared Statements fuer Datenbankzugriffe, Content Security Policy und Output-Encoding zum Schutz vor Injection-Angriffen.',
legalBasis: 'Art. 32 Abs. 1 lit. b DSGVO',
evidenceTypes: ['SAST-Report', 'Penetrationstest-Bericht', 'WAF-Regeln'],
addressesRiskIds: ['R-INT-01'],
effectiveness: 'high',
},
{
id: 'M-INT-02',
type: 'technical',
sdmGoals: ['integritaet', 'transparenz'],
title: 'Audit-Logging & SIEM-Integration',
description: 'Lueckenlose Protokollierung aller sicherheitsrelevanten Ereignisse mit Weiterleitung an ein SIEM-System. Manipulation-sichere Logs mit Integritaetspruefung.',
legalBasis: 'Art. 32 Abs. 1 lit. d DSGVO',
evidenceTypes: ['SIEM-Dashboard-Screenshot', 'Audit-Log-Beispiel', 'Alert-Regeln'],
addressesRiskIds: ['R-INT-01', 'R-INT-04', 'R-INT-05', 'R-CONF-03', 'R-ORG-04'],
effectiveness: 'high',
},
{
id: 'M-INT-03',
type: 'technical',
sdmGoals: ['integritaet'],
title: 'Web Application Firewall (WAF) & API-Gateway',
description: 'Einsatz einer WAF zum Schutz vor OWASP Top 10 Angriffen und eines API-Gateways fuer Rate-Limiting, Schema-Validierung und Anomalie-Erkennung.',
legalBasis: 'Art. 32 Abs. 1 lit. b DSGVO',
evidenceTypes: ['WAF-Regelset', 'API-Gateway-Konfiguration', 'Blockierungs-Statistiken'],
addressesRiskIds: ['R-INT-01'],
effectiveness: 'medium',
},
{
id: 'M-INT-04',
type: 'technical',
sdmGoals: ['integritaet'],
title: 'Daten-Synchronisations-Monitoring & Integritaetspruefung',
description: 'Automatische Ueberwachung von Synchronisationsprozessen mit Checksummen-Vergleich, Konflikterkennung und Alerting bei Inkonsistenzen.',
legalBasis: 'Art. 32 Abs. 1 lit. b DSGVO',
evidenceTypes: ['Sync-Monitoring-Dashboard', 'Checksummen-Report', 'Incident-Log'],
addressesRiskIds: ['R-INT-02'],
effectiveness: 'medium',
},
{
id: 'M-INT-05',
type: 'technical',
sdmGoals: ['integritaet'],
title: 'Versionierung & Change-Tracking fuer personenbezogene Daten',
description: 'Alle Aenderungen an personenbezogenen Daten werden versioniert gespeichert (Audit-Trail). Wer hat wann was geaendert ist jederzeit nachvollziehbar.',
legalBasis: 'Art. 5 Abs. 1 lit. f DSGVO',
evidenceTypes: ['Versionierungs-Schema', 'Change-Log-Beispiel'],
addressesRiskIds: ['R-INT-02', 'R-INT-05'],
effectiveness: 'medium',
},
// =========================================================================
// VERFUEGBARKEIT (Backup, Recovery, Redundancy)
// =========================================================================
{
id: 'M-AVAIL-01',
type: 'technical',
sdmGoals: ['verfuegbarkeit'],
title: 'Backup-Strategie mit 3-2-1-Regel',
description: 'Implementierung einer Backup-Strategie nach der 3-2-1-Regel: 3 Kopien, 2 verschiedene Medien, 1 Offsite. Verschluesselte Backups mit regelmaessiger Integritaetspruefung.',
legalBasis: 'Art. 32 Abs. 1 lit. c DSGVO',
evidenceTypes: ['Backup-Policy', 'Backup-Monitoring-Report', 'Offsite-Nachweis'],
addressesRiskIds: ['R-AVAIL-01', 'R-AVAIL-03', 'R-INT-03'],
effectiveness: 'high',
},
{
id: 'M-AVAIL-02',
type: 'organizational',
sdmGoals: ['verfuegbarkeit'],
title: 'Regelmaessige Restore-Tests & Disaster Recovery Uebungen',
description: 'Mindestens quartalsweise Durchfuehrung von Restore-Tests und jaehrliche Disaster-Recovery-Uebungen. Dokumentation der Ergebnisse und Lessons Learned.',
legalBasis: 'Art. 32 Abs. 1 lit. d DSGVO',
evidenceTypes: ['Restore-Test-Protokoll', 'DR-Uebungs-Dokumentation', 'RTO/RPO-Nachweis'],
addressesRiskIds: ['R-AVAIL-01', 'R-AVAIL-03', 'R-INT-03'],
effectiveness: 'high',
},
{
id: 'M-AVAIL-03',
type: 'technical',
sdmGoals: ['verfuegbarkeit'],
title: 'Endpoint Protection & Anti-Ransomware',
description: 'Einsatz von Endpoint-Detection-and-Response (EDR) Loesungen mit spezifischem Ransomware-Schutz, Verhaltensanalyse und automatischer Isolation kompromittierter Systeme.',
legalBasis: 'Art. 32 Abs. 1 lit. b DSGVO',
evidenceTypes: ['EDR-Dashboard', 'Threat-Detection-Statistiken', 'Incident-Response-Plan'],
addressesRiskIds: ['R-AVAIL-01'],
effectiveness: 'high',
},
{
id: 'M-AVAIL-04',
type: 'technical',
sdmGoals: ['verfuegbarkeit'],
title: 'Redundanz & High-Availability-Architektur',
description: 'Redundante Systemauslegung mit automatischem Failover, Load-Balancing und geo-redundanter Datenhaltung zur Sicherstellung der Verfuegbarkeit.',
legalBasis: 'Art. 32 Abs. 1 lit. b DSGVO',
evidenceTypes: ['HA-Architekturdiagramm', 'Failover-Testprotokoll', 'SLA-Dokumentation'],
addressesRiskIds: ['R-AVAIL-02', 'R-AVAIL-04'],
effectiveness: 'high',
},
{
id: 'M-AVAIL-05',
type: 'organizational',
sdmGoals: ['verfuegbarkeit', 'intervenierbarkeit'],
title: 'Exit-Strategie & Datenportabilitaetsplan',
description: 'Dokumentierte Exit-Strategie fuer jeden kritischen Anbieter mit Datenexport-Verfahren, Migrationsplan und Uebergangsfristen. Regelmaessiger Export-Test.',
legalBasis: 'Art. 28 Abs. 3 lit. g DSGVO, Art. 20 DSGVO',
evidenceTypes: ['Exit-Plan-Dokument', 'Export-Test-Protokoll', 'Vertragliche-Regelung'],
addressesRiskIds: ['R-AVAIL-02', 'R-AVAIL-05'],
effectiveness: 'medium',
},
{
id: 'M-AVAIL-06',
type: 'technical',
sdmGoals: ['verfuegbarkeit'],
title: 'DDoS-Schutz & Rate-Limiting',
description: 'Einsatz von DDoS-Mitigation-Services, CDN-basiertem Schutz und anwendungsspezifischem Rate-Limiting zur Abwehr von Verfuegbarkeitsangriffen.',
legalBasis: 'Art. 32 Abs. 1 lit. b DSGVO',
evidenceTypes: ['DDoS-Schutz-Konfiguration', 'Rate-Limit-Regeln', 'Traffic-Analyse'],
addressesRiskIds: ['R-AVAIL-04'],
effectiveness: 'high',
},
// =========================================================================
// DATENMINIMIERUNG (Retention, Anonymization, Purpose Limitation)
// =========================================================================
{
id: 'M-DMIN-01',
type: 'technical',
sdmGoals: ['datenminimierung'],
title: 'Privacy by Design: Datenerhebung auf das Minimum beschraenken',
description: 'Technische Massnahmen zur Beschraenkung der Datenerhebung: Pflichtfelder minimieren, optionale Felder deutlich kennzeichnen, Default-Einstellungen datenschutzfreundlich.',
legalBasis: 'Art. 25 Abs. 1 DSGVO',
evidenceTypes: ['Formular-Review', 'Default-Settings-Dokumentation'],
addressesRiskIds: ['R-RIGHTS-07', 'R-CONF-07'],
effectiveness: 'medium',
},
{
id: 'M-DMIN-02',
type: 'technical',
sdmGoals: ['datenminimierung', 'nichtverkettung'],
title: 'Pseudonymisierung & Anonymisierung',
description: 'Einsatz von Pseudonymisierungsverfahren (Token-basiert, Hash-basiert) und k-Anonymity/Differential Privacy bei der Weitergabe oder Analyse von Daten.',
legalBasis: 'Art. 25 Abs. 1 DSGVO, Art. 32 Abs. 1 lit. a DSGVO',
evidenceTypes: ['Pseudonymisierungs-Konzept', 'Re-Identifizierungs-Risiko-Analyse'],
addressesRiskIds: ['R-RIGHTS-04', 'R-RIGHTS-07'],
effectiveness: 'high',
},
{
id: 'M-DMIN-03',
type: 'technical',
sdmGoals: ['datenminimierung'],
title: 'Automatisiertes Loeschkonzept mit Aufbewahrungsfristen',
description: 'Implementierung automatischer Loeschroutinen basierend auf definierten Aufbewahrungsfristen. Monitoring der Loeschvorgaenge und Nachweis der Loeschung.',
legalBasis: 'Art. 5 Abs. 1 lit. e DSGVO, Art. 17 DSGVO',
evidenceTypes: ['Loeschkonzept-Dokument', 'Loeschfrist-Uebersicht', 'Loeschprotokoll'],
addressesRiskIds: ['R-RIGHTS-07', 'R-ORG-02'],
effectiveness: 'high',
},
{
id: 'M-DMIN-04',
type: 'organizational',
sdmGoals: ['datenminimierung'],
title: 'Regelmaessige Ueberpruefung der Datenbestaende',
description: 'Jaehrlicher Review aller gespeicherten personenbezogenen Daten auf Erforderlichkeit. Identifikation und Bereinigung von Altbestaenden, verwaisten Datensaetzen und redundanten Kopien.',
legalBasis: 'Art. 5 Abs. 1 lit. e DSGVO',
evidenceTypes: ['Datenbestand-Review-Bericht', 'Bereinigungs-Protokoll'],
addressesRiskIds: ['R-ORG-02'],
effectiveness: 'medium',
},
// =========================================================================
// TRANSPARENZ (Information, Documentation, Auditability)
// =========================================================================
{
id: 'M-TRANS-01',
type: 'organizational',
sdmGoals: ['transparenz'],
title: 'Datenschutzhinweise & Privacy Notices',
description: 'Umfassende, verstaendliche Datenschutzhinweise gemaess Art. 13/14 DSGVO an allen Erhebungsstellen. Layered-Approach fuer unterschiedliche Detailstufen.',
legalBasis: 'Art. 13, Art. 14 DSGVO',
evidenceTypes: ['Privacy-Notice-Review', 'Zustellungs-Nachweis', 'Usability-Test'],
addressesRiskIds: ['R-CONF-05', 'R-RIGHTS-02', 'R-RIGHTS-03', 'R-RIGHTS-06', 'R-TRANS-03', 'R-SPEC-02'],
effectiveness: 'medium',
},
{
id: 'M-TRANS-02',
type: 'technical',
sdmGoals: ['transparenz'],
title: 'Vollstaendiger Audit-Trail fuer personenbezogene Daten',
description: 'Lueckenloser, manipulationssicherer Audit-Trail fuer alle Verarbeitungsvorgaenge personenbezogener Daten. Wer hat wann auf welche Daten zugegriffen oder sie veraendert.',
legalBasis: 'Art. 5 Abs. 2 DSGVO (Rechenschaftspflicht)',
evidenceTypes: ['Audit-Trail-Architektur', 'Log-Integritaets-Nachweis', 'Beispiel-Audit-Export'],
addressesRiskIds: ['R-INT-05'],
effectiveness: 'high',
},
{
id: 'M-TRANS-03',
type: 'technical',
sdmGoals: ['transparenz'],
title: 'Erklaerbarkeit von KI-Entscheidungen (Explainability)',
description: 'Implementierung von Erklaerungsverfahren (SHAP, LIME, Feature-Importance) fuer automatisierte Entscheidungen. Bereitstellung verstaendlicher Begruendungen fuer Betroffene.',
legalBasis: 'Art. 22 Abs. 3 DSGVO, Art. 13 Abs. 2 lit. f DSGVO',
evidenceTypes: ['XAI-Konzept', 'Erklaerbarkeits-Beispiel', 'Betroffenen-Information'],
addressesRiskIds: ['R-AUTO-01', 'R-AUTO-03', 'R-RIGHTS-01'],
effectiveness: 'medium',
},
{
id: 'M-TRANS-04',
type: 'organizational',
sdmGoals: ['transparenz'],
title: 'Ueberwachungs-Folgenabschaetzung & Informationspflicht',
description: 'Bei systematischer Ueberwachung: Gesonderte Folgenabschaetzung, klare Beschilderung/Information, Verhaeltnismaessigkeitspruefung und zeitliche Begrenzung.',
legalBasis: 'Art. 35 Abs. 3 lit. c DSGVO, Art. 13 DSGVO',
evidenceTypes: ['Ueberwachungs-DSFA', 'Beschilderungs-Nachweis', 'Verhaeltnismaessigkeits-Bewertung'],
addressesRiskIds: ['R-RIGHTS-03'],
effectiveness: 'medium',
},
{
id: 'M-TRANS-05',
type: 'organizational',
sdmGoals: ['transparenz'],
title: 'Verzeichnis von Verarbeitungstaetigkeiten (VVT) pflegen',
description: 'Vollstaendiges und aktuelles VVT gemaess Art. 30 DSGVO fuer alle Verarbeitungstaetigkeiten. Regelmaessige Aktualisierung bei Aenderungen.',
legalBasis: 'Art. 30 DSGVO',
evidenceTypes: ['VVT-Export', 'Aktualisierungs-Log'],
addressesRiskIds: ['R-RIGHTS-06'],
effectiveness: 'medium',
},
{
id: 'M-TRANS-06',
type: 'legal',
sdmGoals: ['transparenz', 'vertraulichkeit'],
title: 'Transfer Impact Assessment (TIA) fuer Drittlandtransfer',
description: 'Durchfuehrung eines Transfer Impact Assessments vor jedem Drittlandtransfer. Bewertung des Schutzniveaus im Empfaengerland und Festlegung zusaetzlicher Garantien.',
legalBasis: 'Art. 46 DSGVO, Schrems-II-Urteil',
evidenceTypes: ['TIA-Dokument', 'Schutzniveau-Analyse', 'Zusaetzliche-Garantien-Vereinbarung'],
addressesRiskIds: ['R-TRANS-01', 'R-TRANS-02'],
effectiveness: 'high',
},
{
id: 'M-TRANS-07',
type: 'legal',
sdmGoals: ['vertraulichkeit'],
title: 'Standardvertragsklauseln (SCC) & Supplementary Measures',
description: 'Abschluss aktueller EU-Standardvertragsklauseln (2021/914) mit Auftragsverarbeitern im Drittland. Ergaenzende technische und organisatorische Massnahmen (Verschluesselung, Pseudonymisierung).',
legalBasis: 'Art. 46 Abs. 2 lit. c DSGVO',
evidenceTypes: ['Unterzeichnete SCC', 'Supplementary-Measures-Dokumentation'],
addressesRiskIds: ['R-TRANS-01', 'R-TRANS-02'],
effectiveness: 'medium',
},
// =========================================================================
// NICHTVERKETTUNG (Purpose Limitation, Data Separation, DLP)
// =========================================================================
{
id: 'M-NONL-01',
type: 'technical',
sdmGoals: ['nichtverkettung'],
title: 'Zweckbindung & Consent-Management',
description: 'Technische Durchsetzung der Zweckbindung: Daten werden nur fuer den erhobenen Zweck verwendet. Consent-Management-System protokolliert und erzwingt Einwilligungen.',
legalBasis: 'Art. 5 Abs. 1 lit. b DSGVO, Art. 6 Abs. 1 lit. a DSGVO',
evidenceTypes: ['Consent-Management-System', 'Zweckbindungs-Matrix', 'Consent-Protokolle'],
addressesRiskIds: ['R-CONF-05', 'R-RIGHTS-02', 'R-RIGHTS-03'],
effectiveness: 'high',
},
{
id: 'M-NONL-02',
type: 'technical',
sdmGoals: ['nichtverkettung'],
title: 'Data Loss Prevention (DLP) & Datenklassifikation',
description: 'Implementierung von DLP-Regeln zur Verhinderung unkontrollierter Datenweitergabe. Datenklassifikation (oeffentlich, intern, vertraulich, streng vertraulich) als Grundlage.',
legalBasis: 'Art. 32 Abs. 1 lit. b DSGVO',
evidenceTypes: ['DLP-Policy', 'Datenklassifikations-Schema', 'DLP-Incident-Report'],
addressesRiskIds: ['R-RIGHTS-02'],
effectiveness: 'medium',
},
{
id: 'M-NONL-03',
type: 'technical',
sdmGoals: ['nichtverkettung', 'datenminimierung'],
title: 'Differential Privacy & k-Anonymity bei Datenanalysen',
description: 'Einsatz von Differential Privacy oder k-Anonymity-Verfahren bei der Analyse personenbezogener Daten, um Re-Identifizierung zu verhindern.',
legalBasis: 'Art. 25 Abs. 1 DSGVO',
evidenceTypes: ['Anonymisierungs-Konzept', 'Privacy-Budget-Berechnung', 'k-Anonymity-Nachweis'],
addressesRiskIds: ['R-RIGHTS-04', 'R-AUTO-05'],
effectiveness: 'high',
},
{
id: 'M-NONL-04',
type: 'technical',
sdmGoals: ['nichtverkettung'],
title: 'Mandantentrennung & Datenisolierung',
description: 'Strikte logische oder physische Trennung personenbezogener Daten verschiedener Mandanten/Zwecke. Verhinderung unbeabsichtigter Zusammenfuehrung.',
legalBasis: 'Art. 5 Abs. 1 lit. b DSGVO',
evidenceTypes: ['Mandantentrennungs-Konzept', 'Isolierungs-Test-Bericht'],
addressesRiskIds: ['R-RIGHTS-04'],
effectiveness: 'high',
},
// =========================================================================
// INTERVENIERBARKEIT (Data Subject Rights, Correction, Deletion)
// =========================================================================
{
id: 'M-INTERV-01',
type: 'technical',
sdmGoals: ['intervenierbarkeit'],
title: 'DSAR-Workflow (Data Subject Access Request)',
description: 'Automatisierter Workflow fuer Betroffenenanfragen (Auskunft, Loeschung, Berichtigung, Export). Fristenmanagement (1 Monat), Identitaetspruefung und Dokumentation.',
legalBasis: 'Art. 15-22 DSGVO, Art. 12 Abs. 3 DSGVO',
evidenceTypes: ['DSAR-Workflow-Dokumentation', 'Bearbeitungszeiten-Statistik', 'Audit-Trail'],
addressesRiskIds: ['R-RIGHTS-05', 'R-AVAIL-05'],
effectiveness: 'high',
},
{
id: 'M-INTERV-02',
type: 'technical',
sdmGoals: ['intervenierbarkeit'],
title: 'Self-Service Datenverwaltung fuer Betroffene',
description: 'Bereitstellung eines Self-Service-Portals, ueber das Betroffene ihre Daten einsehen, korrigieren, exportieren und die Loeschung beantragen koennen.',
legalBasis: 'Art. 15-20 DSGVO',
evidenceTypes: ['Portal-Screenshot', 'Funktions-Testprotokoll', 'Nutzungs-Statistik'],
addressesRiskIds: ['R-RIGHTS-05'],
effectiveness: 'high',
},
{
id: 'M-INTERV-03',
type: 'organizational',
sdmGoals: ['intervenierbarkeit'],
title: 'Widerspruchs- und Einschraenkungsprozess',
description: 'Definierter Prozess fuer die Bearbeitung von Widerspruechen (Art. 21) und Einschraenkungsersuchen (Art. 18). Technische Moeglichkeit zur Sperrung einzelner Datensaetze.',
legalBasis: 'Art. 18, Art. 21 DSGVO',
evidenceTypes: ['Prozessbeschreibung', 'Sperr-Funktionalitaets-Nachweis'],
addressesRiskIds: ['R-RIGHTS-05'],
effectiveness: 'medium',
},
{
id: 'M-INTERV-04',
type: 'organizational',
sdmGoals: ['intervenierbarkeit'],
title: 'Human-in-the-Loop bei automatisierten Entscheidungen',
description: 'Sicherstellung menschlicher Ueberpruefung bei automatisierten Entscheidungen mit erheblicher Auswirkung. Eskalationsprozess und Einspruchsmoeglichkeit fuer Betroffene.',
legalBasis: 'Art. 22 Abs. 3 DSGVO',
evidenceTypes: ['HITL-Prozessbeschreibung', 'Eskalations-Statistik', 'Einspruchs-Protokoll'],
addressesRiskIds: ['R-AUTO-04'],
effectiveness: 'high',
},
// =========================================================================
// AUTOMATISIERUNG / KI
// =========================================================================
{
id: 'M-AUTO-01',
type: 'technical',
sdmGoals: ['nichtverkettung', 'transparenz'],
title: 'Bias-Monitoring & Fairness-Tests',
description: 'Regelmaessige Ueberpruefung von KI-Modellen auf Bias und Diskriminierung. Fairness-Metriken (Demographic Parity, Equal Opportunity) und Korrekturmassnahmen bei Abweichungen.',
legalBasis: 'Art. 22 Abs. 3 DSGVO, AI Act Art. 10',
evidenceTypes: ['Bias-Audit-Report', 'Fairness-Metriken-Dashboard', 'Korrektur-Dokumentation'],
addressesRiskIds: ['R-RIGHTS-01', 'R-AUTO-01', 'R-AUTO-02'],
effectiveness: 'high',
},
{
id: 'M-AUTO-02',
type: 'technical',
sdmGoals: ['transparenz'],
title: 'KI-Modell-Dokumentation & Model Cards',
description: 'Ausfuehrliche Dokumentation aller KI-Modelle: Trainingsdaten, Architektur, Performance-Metriken, bekannte Einschraenkungen, Einsatzzweck (Model Cards).',
legalBasis: 'Art. 13 Abs. 2 lit. f DSGVO, AI Act Art. 11',
evidenceTypes: ['Model-Card', 'Performance-Report', 'Einsatzbereich-Dokumentation'],
addressesRiskIds: ['R-AUTO-01', 'R-AUTO-03'],
effectiveness: 'medium',
},
{
id: 'M-AUTO-03',
type: 'organizational',
sdmGoals: ['intervenierbarkeit', 'transparenz'],
title: 'KI-Governance-Framework & Human Oversight Board',
description: 'Etablierung eines KI-Governance-Frameworks mit einem Human Oversight Board, das alle KI-Systeme mit hohem Risiko ueberwacht und Interventionsmoeglichkeiten hat.',
legalBasis: 'Art. 22 DSGVO, AI Act Art. 14',
evidenceTypes: ['Governance-Policy', 'Oversight-Board-Protokolle', 'Interventions-Log'],
addressesRiskIds: ['R-AUTO-01', 'R-AUTO-04'],
effectiveness: 'high',
},
{
id: 'M-AUTO-04',
type: 'technical',
sdmGoals: ['nichtverkettung', 'datenminimierung'],
title: 'Datenschutzkonformes KI-Training (Privacy-Preserving ML)',
description: 'Einsatz von Federated Learning, Differential Privacy beim Training oder synthetischen Trainingsdaten, um personenbezogene Daten im Modell zu schuetzen.',
legalBasis: 'Art. 25 Abs. 1 DSGVO',
evidenceTypes: ['Privacy-Preserving-ML-Konzept', 'Training-Daten-Analyse', 'Modell-Invertierbarkeiots-Test'],
addressesRiskIds: ['R-AUTO-02', 'R-AUTO-05'],
effectiveness: 'high',
},
// =========================================================================
// ORGANISATORISCHE MASSNAHMEN
// =========================================================================
{
id: 'M-ORG-01',
type: 'organizational',
sdmGoals: ['vertraulichkeit', 'integritaet'],
title: 'Datenschutz-Schulungen & Awareness-Programm',
description: 'Regelmaessige verpflichtende Datenschutz-Schulungen fuer alle Mitarbeiter. Awareness-Kampagnen zu Phishing, Social Engineering und sicherem Datenumgang.',
legalBasis: 'Art. 32 Abs. 1 lit. b DSGVO, Art. 39 Abs. 1 lit. a DSGVO',
evidenceTypes: ['Schulungsplan', 'Teilnahmequoten', 'Phishing-Simulations-Ergebnis'],
addressesRiskIds: ['R-CONF-06', 'R-ORG-03'],
effectiveness: 'medium',
},
{
id: 'M-ORG-02',
type: 'organizational',
sdmGoals: ['integritaet'],
title: 'Verpflichtung auf Vertraulichkeit & Datenschutz-Policy',
description: 'Schriftliche Verpflichtung aller Mitarbeiter und externen Dienstleister auf Vertraulichkeit und Einhaltung der Datenschutz-Policies.',
legalBasis: 'Art. 28 Abs. 3 lit. b DSGVO, Art. 29 DSGVO',
evidenceTypes: ['Unterzeichnete-Verpflichtungserklaerung', 'Datenschutz-Policy'],
addressesRiskIds: ['R-ORG-03'],
effectiveness: 'medium',
},
{
id: 'M-ORG-03',
type: 'organizational',
sdmGoals: ['transparenz'],
title: 'Datenpannen-Erkennungs- und Meldeprozess (Incident Response)',
description: 'Definierter Incident-Response-Prozess mit klaren Eskalationswegen, 72h-Meldepflicht-Tracking, Klassifizierungsschema und Kommunikationsplan.',
legalBasis: 'Art. 33, Art. 34 DSGVO',
evidenceTypes: ['Incident-Response-Plan', 'Melde-Template', 'Uebungs-Protokoll'],
addressesRiskIds: ['R-ORG-04'],
effectiveness: 'high',
},
{
id: 'M-ORG-04',
type: 'technical',
sdmGoals: ['transparenz', 'verfuegbarkeit'],
title: 'Automatisiertes Breach-Detection & Alerting',
description: 'Automatische Erkennung von Datenpannen durch Anomalie-Detection, ungewoehnliche Zugriffsmuster und Datenexfiltrations-Erkennung mit sofortigem Alert an den Incident-Response-Team.',
legalBasis: 'Art. 33 Abs. 1 DSGVO',
evidenceTypes: ['Alert-Regeln', 'Detection-Dashboard', 'Reaktionszeiten-Statistik'],
addressesRiskIds: ['R-ORG-04'],
effectiveness: 'high',
},
// =========================================================================
// RECHTLICHE MASSNAHMEN
// =========================================================================
{
id: 'M-LEGAL-01',
type: 'legal',
sdmGoals: ['transparenz'],
title: 'Angemessenheitsbeschluss oder Binding Corporate Rules (BCR)',
description: 'Sicherstellung, dass Drittlandtransfers auf einem Angemessenheitsbeschluss oder genehmigten BCRs basieren. Laufende Ueberwachung des Schutzniveaus.',
legalBasis: 'Art. 45, Art. 47 DSGVO',
evidenceTypes: ['Angemessenheitsbeschluss-Referenz', 'BCR-Genehmigung'],
addressesRiskIds: ['R-TRANS-02'],
effectiveness: 'high',
},
{
id: 'M-LEGAL-02',
type: 'legal',
sdmGoals: ['transparenz'],
title: 'Auftragsverarbeitungsvertrag (AVV) nach Art. 28 DSGVO',
description: 'Abschluss vollstaendiger AVVs mit allen Auftragsverarbeitern. Regelung von Zweck, Dauer, Datenkategorien, Weisungsbindung, Sub-Auftragsverarbeiter und Audit-Rechten.',
legalBasis: 'Art. 28 Abs. 3 DSGVO',
evidenceTypes: ['Unterzeichneter-AVV', 'Sub-Auftragsverarbeiter-Liste', 'Audit-Bericht'],
addressesRiskIds: ['R-ORG-01', 'R-TRANS-03'],
effectiveness: 'high',
},
{
id: 'M-LEGAL-03',
type: 'legal',
sdmGoals: ['transparenz'],
title: 'Regelmaessige Auftragsverarbeiter-Audits',
description: 'Jaehrliche Ueberpruefung der Auftragsverarbeiter auf Einhaltung der AVV-Vorgaben. Dokumentierte Audits vor Ort oder anhand von Zertifizierungen (SOC 2, ISO 27001).',
legalBasis: 'Art. 28 Abs. 3 lit. h DSGVO',
evidenceTypes: ['Audit-Bericht', 'Zertifizierungs-Nachweis', 'Massnahmenplan'],
addressesRiskIds: ['R-ORG-01'],
effectiveness: 'medium',
},
{
id: 'M-LEGAL-04',
type: 'legal',
sdmGoals: ['intervenierbarkeit', 'transparenz'],
title: 'Altersverifikation & Eltern-Einwilligung (Art. 8)',
description: 'Implementierung einer altersgerechten Verifikation und Einholung der Eltern-Einwilligung bei Minderjaehrigen unter 16 Jahren. Kindgerechte Datenschutzinformationen.',
legalBasis: 'Art. 8 DSGVO, EG 38 DSGVO',
evidenceTypes: ['Altersverifikations-Konzept', 'Eltern-Einwilligungs-Formular', 'Kindgerechte-Privacy-Notice'],
addressesRiskIds: ['R-SPEC-02'],
effectiveness: 'medium',
},
]
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
export function getMitigationsBySDMGoal(goal: SDMGoal): CatalogMitigation[] {
return MITIGATION_LIBRARY.filter(m => m.sdmGoals.includes(goal))
}
export function getMitigationsByType(type: DSFAMitigationType): CatalogMitigation[] {
return MITIGATION_LIBRARY.filter(m => m.type === type)
}
export function getMitigationsForRisk(riskId: string): CatalogMitigation[] {
return MITIGATION_LIBRARY.filter(m => m.addressesRiskIds.includes(riskId))
}
export function getCatalogMitigationById(id: string): CatalogMitigation | undefined {
return MITIGATION_LIBRARY.find(m => m.id === id)
}
export function getMitigationsByEffectiveness(effectiveness: 'low' | 'medium' | 'high'): CatalogMitigation[] {
return MITIGATION_LIBRARY.filter(m => m.effectiveness === effectiveness)
}
export const MITIGATION_TYPE_LABELS: Record<DSFAMitigationType, string> = {
technical: 'Technisch',
organizational: 'Organisatorisch',
legal: 'Rechtlich',
}
export const SDM_GOAL_LABELS: Record<SDMGoal, string> = {
datenminimierung: 'Datenminimierung',
verfuegbarkeit: 'Verfuegbarkeit',
integritaet: 'Integritaet',
vertraulichkeit: 'Vertraulichkeit',
nichtverkettung: 'Nichtverkettung',
transparenz: 'Transparenz',
intervenierbarkeit: 'Intervenierbarkeit',
}
export const EFFECTIVENESS_LABELS: Record<string, string> = {
low: 'Gering',
medium: 'Mittel',
high: 'Hoch',
}

View File

@@ -0,0 +1,615 @@
/**
* DSFA Risikokatalog - Vordefinierte Risikoszenarien
*
* ~40 Risiken gegliedert nach Vertraulichkeit, Integritaet, Verfuegbarkeit,
* Rechte & Freiheiten, Drittlandtransfer und Automatisierung.
*
* Quellen: EG 75 DSGVO, Art. 32 DSGVO, Art. 28/46 DSGVO, Art. 22 DSGVO,
* Baseline-DSFA Katalog, SDM V2.0
*/
import type { DSFARiskCategory } from './types'
import type { SDMGoal } from './types'
// =============================================================================
// TYPES
// =============================================================================
export interface CatalogRisk {
id: string
category: DSFARiskCategory
sdmGoal: SDMGoal
title: string
description: string
impactExamples: string[]
typicalLikelihood: 'low' | 'medium' | 'high'
typicalImpact: 'low' | 'medium' | 'high'
wp248Criteria: string[]
applicableTo: string[]
mitigationIds: string[]
}
// =============================================================================
// RISIKOKATALOG
// =============================================================================
export const RISK_CATALOG: CatalogRisk[] = [
// =========================================================================
// VERTRAULICHKEIT (Confidentiality)
// =========================================================================
{
id: 'R-CONF-01',
category: 'confidentiality',
sdmGoal: 'vertraulichkeit',
title: 'Unbefugte Offenlegung durch Fehlkonfiguration',
description: 'Personenbezogene Daten werden durch fehlerhafte Systemkonfiguration (z.B. offene APIs, fehlerhafte Zugriffsrechte, oeffentliche Cloud-Speicher) unbefugt zugaenglich.',
impactExamples: ['Identitaetsdiebstahl', 'Reputationsschaden', 'Diskriminierung'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K4', 'K5'],
applicableTo: ['cloud_storage', 'web_application', 'api_service'],
mitigationIds: ['M-CONF-01', 'M-CONF-02', 'M-CONF-03'],
},
{
id: 'R-CONF-02',
category: 'confidentiality',
sdmGoal: 'vertraulichkeit',
title: 'Account Takeover / Credential Stuffing',
description: 'Angreifer uebernehmen Benutzerkonten durch gestohlene Zugangsdaten, Brute-Force-Angriffe oder Phishing und erlangen Zugriff auf personenbezogene Daten.',
impactExamples: ['Kontrollverlust ueber eigene Daten', 'Finanzieller Schaden', 'Missbrauch der Identitaet'],
typicalLikelihood: 'high',
typicalImpact: 'high',
wp248Criteria: ['K4', 'K7'],
applicableTo: ['identity', 'web_application', 'email_service'],
mitigationIds: ['M-ACC-01', 'M-ACC-02'],
},
{
id: 'R-CONF-03',
category: 'confidentiality',
sdmGoal: 'vertraulichkeit',
title: 'Unbefugter Zugriff durch Support-/Administrationspersonal',
description: 'Administratoren oder Support-Mitarbeiter greifen ohne dienstliche Notwendigkeit auf personenbezogene Daten zu (Insider-Bedrohung).',
impactExamples: ['Verletzung der Privatsphaere', 'Datenmissbrauch', 'Vertrauensverlust'],
typicalLikelihood: 'medium',
typicalImpact: 'medium',
wp248Criteria: ['K4'],
applicableTo: ['identity', 'crm', 'cloud_storage', 'support_system'],
mitigationIds: ['M-CONF-04', 'M-CONF-05', 'M-INT-02'],
},
{
id: 'R-CONF-04',
category: 'confidentiality',
sdmGoal: 'vertraulichkeit',
title: 'Datenleck durch unzureichende Verschluesselung',
description: 'Personenbezogene Daten werden bei Uebertragung oder Speicherung nicht oder unzureichend verschluesselt und koennen abgefangen werden.',
impactExamples: ['Man-in-the-Middle-Angriff', 'Datendiebstahl bei Speichermedien-Verlust'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K4', 'K8'],
applicableTo: ['cloud_storage', 'email_service', 'mobile_app', 'api_service'],
mitigationIds: ['M-CONF-06', 'M-CONF-07'],
},
{
id: 'R-CONF-05',
category: 'confidentiality',
sdmGoal: 'vertraulichkeit',
title: 'Unkontrollierte Datenweitergabe an Dritte',
description: 'Personenbezogene Daten werden ohne Rechtsgrundlage oder ueber das vereinbarte Mass hinaus an Dritte weitergegeben (z.B. durch Tracking, Analyse-Tools, Sub-Auftragsverarbeiter).',
impactExamples: ['Unerwuenschte Werbung', 'Profiling ohne Wissen', 'Kontrollverlust'],
typicalLikelihood: 'medium',
typicalImpact: 'medium',
wp248Criteria: ['K1', 'K6'],
applicableTo: ['web_application', 'analytics', 'marketing', 'crm'],
mitigationIds: ['M-NONL-01', 'M-TRANS-01'],
},
{
id: 'R-CONF-06',
category: 'confidentiality',
sdmGoal: 'vertraulichkeit',
title: 'Social Engineering / Phishing gegen Betroffene',
description: 'Betroffene werden durch manipulative Kommunikation dazu verleitet, personenbezogene Daten preiszugeben oder Zugriff zu gewaehren.',
impactExamples: ['Identitaetsdiebstahl', 'Finanzieller Schaden', 'Uebernahme von Konten'],
typicalLikelihood: 'high',
typicalImpact: 'medium',
wp248Criteria: ['K7'],
applicableTo: ['email_service', 'web_application', 'identity'],
mitigationIds: ['M-ACC-01', 'M-ORG-01'],
},
{
id: 'R-CONF-07',
category: 'confidentiality',
sdmGoal: 'vertraulichkeit',
title: 'Unbeabsichtigte Offenlegung in Logs/Debugging',
description: 'Personenbezogene Daten gelangen in Protokolldateien, Fehlermeldungen oder Debug-Ausgaben und werden dort nicht geschuetzt.',
impactExamples: ['Zugriff durch Unbefugte auf Logdaten', 'Langzeitspeicherung ohne Rechtsgrundlage'],
typicalLikelihood: 'medium',
typicalImpact: 'medium',
wp248Criteria: ['K4'],
applicableTo: ['api_service', 'web_application', 'cloud_storage'],
mitigationIds: ['M-CONF-08', 'M-DMIN-01'],
},
// =========================================================================
// INTEGRITAET (Integrity)
// =========================================================================
{
id: 'R-INT-01',
category: 'integrity',
sdmGoal: 'integritaet',
title: 'Datenmanipulation durch externen Angriff',
description: 'Personenbezogene Daten werden durch einen Cyberangriff (SQL-Injection, API-Manipulation) veraendert, ohne dass dies erkannt wird.',
impactExamples: ['Falsche Entscheidungen auf Basis manipulierter Daten', 'Rufschaedigung'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K4', 'K8'],
applicableTo: ['api_service', 'web_application', 'database'],
mitigationIds: ['M-INT-01', 'M-INT-02', 'M-INT-03'],
},
{
id: 'R-INT-02',
category: 'integrity',
sdmGoal: 'integritaet',
title: 'Fehlerhafte Synchronisation zwischen Systemen',
description: 'Bei der Synchronisation personenbezogener Daten zwischen verschiedenen Systemen kommt es zu Inkonsistenzen, Duplikaten oder Datenverlust.',
impactExamples: ['Falsche Kontaktdaten', 'Doppelte Verarbeitung', 'Falsche Auskuenfte'],
typicalLikelihood: 'medium',
typicalImpact: 'medium',
wp248Criteria: ['K6'],
applicableTo: ['crm', 'cloud_storage', 'erp', 'identity'],
mitigationIds: ['M-INT-04', 'M-INT-05'],
},
{
id: 'R-INT-03',
category: 'integrity',
sdmGoal: 'integritaet',
title: 'Backup-Korruption oder fehlerhafte Wiederherstellung',
description: 'Backups personenbezogener Daten sind beschaedigt, unvollstaendig oder veraltet, sodass eine zuverlaessige Wiederherstellung nicht moeglich ist.',
impactExamples: ['Datenverlust bei Wiederherstellung', 'Veraltete Datenbasis', 'Compliance-Verstoss'],
typicalLikelihood: 'low',
typicalImpact: 'high',
wp248Criteria: ['K5'],
applicableTo: ['database', 'cloud_storage', 'erp'],
mitigationIds: ['M-AVAIL-01', 'M-AVAIL-02'],
},
{
id: 'R-INT-04',
category: 'integrity',
sdmGoal: 'integritaet',
title: 'Unbemerkte Aenderung von Zugriffsrechten',
description: 'Zugriffsberechtigungen werden unbefugt oder fehlerhaft geaendert, wodurch unberechtigte Personen Zugang zu personenbezogenen Daten erhalten.',
impactExamples: ['Privilege Escalation', 'Unbefugter Datenzugriff'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K4'],
applicableTo: ['identity', 'cloud_storage', 'api_service'],
mitigationIds: ['M-INT-02', 'M-CONF-04'],
},
{
id: 'R-INT-05',
category: 'integrity',
sdmGoal: 'integritaet',
title: 'Fehlende Nachvollziehbarkeit von Datenveraenderungen',
description: 'Aenderungen an personenbezogenen Daten werden nicht protokolliert, sodass Manipulationen oder Fehler nicht erkannt oder nachvollzogen werden koennen.',
impactExamples: ['Unmoeglich festzustellen wer/wann Daten geaendert hat', 'Audit-Versagen'],
typicalLikelihood: 'medium',
typicalImpact: 'medium',
wp248Criteria: ['K3'],
applicableTo: ['database', 'crm', 'erp', 'web_application'],
mitigationIds: ['M-INT-02', 'M-TRANS-02'],
},
// =========================================================================
// VERFUEGBARKEIT (Availability)
// =========================================================================
{
id: 'R-AVAIL-01',
category: 'availability',
sdmGoal: 'verfuegbarkeit',
title: 'Ransomware-Angriff mit Datenverschluesselung',
description: 'Schadsoftware verschluesselt personenbezogene Daten und macht sie unzugaenglich. Die Wiederherstellung erfordert entweder Loesegeldzahlung oder Backup-Restore.',
impactExamples: ['Verlust des Zugangs zu eigenen Daten', 'Betriebsunterbrechung', 'Loesegeld-Erpressung'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K5', 'K8'],
applicableTo: ['cloud_storage', 'database', 'erp', 'web_application'],
mitigationIds: ['M-AVAIL-01', 'M-AVAIL-02', 'M-AVAIL-03'],
},
{
id: 'R-AVAIL-02',
category: 'availability',
sdmGoal: 'verfuegbarkeit',
title: 'Provider-Ausfall / Cloud-Service Nichtverfuegbarkeit',
description: 'Der Cloud-/Hosting-Provider faellt aus, was den Zugang zu personenbezogenen Daten verhindert. Betroffene koennen ihre Rechte nicht ausueben.',
impactExamples: ['Keine Auskunft moeglich', 'Vertragsverletzung', 'Geschaeftsunterbrechung'],
typicalLikelihood: 'low',
typicalImpact: 'high',
wp248Criteria: ['K5', 'K9'],
applicableTo: ['cloud_storage', 'web_application', 'api_service'],
mitigationIds: ['M-AVAIL-04', 'M-AVAIL-05'],
},
{
id: 'R-AVAIL-03',
category: 'availability',
sdmGoal: 'verfuegbarkeit',
title: 'Datenverlust durch fehlende oder ungetestete Backups',
description: 'Personenbezogene Daten gehen unwiederbringlich verloren, weil keine ausreichenden Backups existieren oder Restore-Prozesse nicht getestet werden.',
impactExamples: ['Unwiderruflicher Datenverlust', 'Verlust von Beweismitteln', 'Compliance-Verstoss'],
typicalLikelihood: 'low',
typicalImpact: 'high',
wp248Criteria: ['K5'],
applicableTo: ['database', 'cloud_storage', 'erp'],
mitigationIds: ['M-AVAIL-01', 'M-AVAIL-02'],
},
{
id: 'R-AVAIL-04',
category: 'availability',
sdmGoal: 'verfuegbarkeit',
title: 'DDoS-Angriff auf oeffentliche Dienste',
description: 'Ein Distributed-Denial-of-Service-Angriff verhindert den Zugang zu Systemen, die personenbezogene Daten verarbeiten.',
impactExamples: ['Betroffene koennen Rechte nicht ausueben', 'Geschaeftsausfall'],
typicalLikelihood: 'medium',
typicalImpact: 'medium',
wp248Criteria: ['K5', 'K9'],
applicableTo: ['web_application', 'api_service'],
mitigationIds: ['M-AVAIL-06', 'M-AVAIL-04'],
},
{
id: 'R-AVAIL-05',
category: 'availability',
sdmGoal: 'verfuegbarkeit',
title: 'Vendor Lock-in mit Kontrollverlust',
description: 'Abhaengigkeit von einem einzelnen Anbieter erschwert oder verhindert den Zugang zu personenbezogenen Daten bei Vertragsbeendigung oder Anbieterwechsel.',
impactExamples: ['Datenexport nicht moeglich', 'Erzwungene Weiternutzung', 'Datenverlust bei Kuendigung'],
typicalLikelihood: 'medium',
typicalImpact: 'medium',
wp248Criteria: ['K9'],
applicableTo: ['cloud_storage', 'erp', 'crm'],
mitigationIds: ['M-AVAIL-05', 'M-INTERV-01'],
},
// =========================================================================
// RECHTE & FREIHEITEN (Rights & Freedoms)
// =========================================================================
{
id: 'R-RIGHTS-01',
category: 'rights_freedoms',
sdmGoal: 'nichtverkettung',
title: 'Diskriminierung durch automatisierte Verarbeitung',
description: 'Automatisierte Entscheidungssysteme fuehren zu einer diskriminierenden Behandlung bestimmter Personengruppen aufgrund von Merkmalen wie Alter, Geschlecht, Herkunft oder Gesundheitszustand.',
impactExamples: ['Benachteiligung bei Kreditvergabe', 'Ausschluss von Dienstleistungen', 'Ungleichbehandlung'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K1', 'K2', 'K7'],
applicableTo: ['ai_ml', 'scoring', 'identity'],
mitigationIds: ['M-AUTO-01', 'M-AUTO-02', 'M-TRANS-03'],
},
{
id: 'R-RIGHTS-02',
category: 'rights_freedoms',
sdmGoal: 'nichtverkettung',
title: 'Unzulaessiges Profiling ohne Einwilligung',
description: 'Nutzerverhalten wird systematisch analysiert und zu Profilen zusammengefuehrt, ohne dass eine Rechtsgrundlage oder Einwilligung vorliegt.',
impactExamples: ['Persoenlichkeitsprofile ohne Wissen', 'Gezielte Manipulation', 'Filterblase'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K1', 'K3', 'K6'],
applicableTo: ['analytics', 'marketing', 'web_application', 'ai_ml'],
mitigationIds: ['M-NONL-01', 'M-NONL-02', 'M-TRANS-01'],
},
{
id: 'R-RIGHTS-03',
category: 'rights_freedoms',
sdmGoal: 'transparenz',
title: 'Systematische Ueberwachung von Betroffenen',
description: 'Betroffene werden systematisch ueberwacht (z.B. durch Standorttracking, E-Mail-Monitoring, Videoueberwachung), ohne angemessene Transparenz oder Rechtsgrundlage.',
impactExamples: ['Einschuechterungseffekt (Chilling Effect)', 'Verletzung der Privatsphaere', 'Vertrauensverlust'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K3', 'K4', 'K7'],
applicableTo: ['monitoring', 'hr_system', 'mobile_app'],
mitigationIds: ['M-TRANS-01', 'M-TRANS-04', 'M-NONL-01'],
},
{
id: 'R-RIGHTS-04',
category: 'rights_freedoms',
sdmGoal: 'nichtverkettung',
title: 'Re-Identifizierung pseudonymisierter Daten',
description: 'Pseudonymisierte oder anonymisierte Daten werden durch Zusammenfuehrung mit anderen Datenquellen re-identifiziert, wodurch der Schutz der Betroffenen aufgehoben wird.',
impactExamples: ['Verlust der Anonymitaet', 'Unerwuenschte Identifizierung', 'Zweckentfremdung'],
typicalLikelihood: 'low',
typicalImpact: 'high',
wp248Criteria: ['K1', 'K6', 'K8'],
applicableTo: ['analytics', 'ai_ml', 'research'],
mitigationIds: ['M-NONL-03', 'M-NONL-04', 'M-DMIN-02'],
},
{
id: 'R-RIGHTS-05',
category: 'rights_freedoms',
sdmGoal: 'intervenierbarkeit',
title: 'Hinderung bei Ausuebung von Betroffenenrechten',
description: 'Betroffene werden an der Ausuebung ihrer Rechte (Auskunft, Loeschung, Berichtigung, Widerspruch) gehindert — z.B. durch fehlende Prozesse, technische Huerden oder Verzoegerungen.',
impactExamples: ['Keine Loeschung moeglich', 'Verzoegerte Auskunft', 'Bussgeld gem. Art. 83'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K9'],
applicableTo: ['web_application', 'crm', 'identity', 'cloud_storage'],
mitigationIds: ['M-INTERV-01', 'M-INTERV-02', 'M-INTERV-03'],
},
{
id: 'R-RIGHTS-06',
category: 'rights_freedoms',
sdmGoal: 'transparenz',
title: 'Fehlende oder unzureichende Informationspflichten',
description: 'Betroffene werden nicht oder unzureichend ueber die Verarbeitung ihrer Daten informiert (Verstoss gegen Art. 13/14 DSGVO).',
impactExamples: ['Keine informierte Einwilligung moeglich', 'Vertrauensverlust', 'Bussgeld'],
typicalLikelihood: 'medium',
typicalImpact: 'medium',
wp248Criteria: ['K9'],
applicableTo: ['web_application', 'mobile_app', 'marketing'],
mitigationIds: ['M-TRANS-01', 'M-TRANS-05'],
},
{
id: 'R-RIGHTS-07',
category: 'rights_freedoms',
sdmGoal: 'datenminimierung',
title: 'Uebermassige Datenerhebung (Verstoss Datenminimierung)',
description: 'Es werden mehr personenbezogene Daten erhoben als fuer den Verarbeitungszweck notwendig (Verstoss gegen Art. 5 Abs. 1 lit. c DSGVO).',
impactExamples: ['Unnoetige Risikoexposition', 'Hoeherer Schaden bei Datenpanne'],
typicalLikelihood: 'medium',
typicalImpact: 'medium',
wp248Criteria: ['K5'],
applicableTo: ['web_application', 'mobile_app', 'crm', 'hr_system'],
mitigationIds: ['M-DMIN-01', 'M-DMIN-02', 'M-DMIN-03'],
},
// =========================================================================
// DRITTLANDTRANSFER
// =========================================================================
{
id: 'R-TRANS-01',
category: 'rights_freedoms',
sdmGoal: 'vertraulichkeit',
title: 'Zugriff durch Drittland-Behoerden (FISA/CLOUD Act)',
description: 'Behoerden eines Drittlandes (z.B. USA) greifen auf personenbezogene Daten zu, die bei einem Cloud-Provider in der EU oder im Drittland gespeichert sind.',
impactExamples: ['Ueberwachung ohne Wissen', 'Kein Rechtsschutz', 'Schrems-II-Risiko'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K4', 'K5', 'K7'],
applicableTo: ['cloud_storage', 'email_service', 'crm', 'analytics'],
mitigationIds: ['M-TRANS-06', 'M-TRANS-07', 'M-CONF-06'],
},
{
id: 'R-TRANS-02',
category: 'rights_freedoms',
sdmGoal: 'vertraulichkeit',
title: 'Unzureichende Schutzgarantien bei Drittlandtransfer',
description: 'Personenbezogene Daten werden in Drittlaender uebermittelt, ohne dass angemessene Garantien (SCC, BCR, Angemessenheitsbeschluss) vorhanden sind.',
impactExamples: ['Rechtswidriger Transfer', 'Bussgeld', 'Untersagung der Verarbeitung'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K5', 'K7'],
applicableTo: ['cloud_storage', 'email_service', 'crm', 'analytics'],
mitigationIds: ['M-TRANS-06', 'M-TRANS-07', 'M-LEGAL-01'],
},
{
id: 'R-TRANS-03',
category: 'rights_freedoms',
sdmGoal: 'transparenz',
title: 'Intransparente Sub-Auftragsverarbeiter-Kette',
description: 'Die Kette der Sub-Auftragsverarbeiter ist nicht transparent. Betroffene und Verantwortliche wissen nicht, wo ihre Daten tatsaechlich verarbeitet werden.',
impactExamples: ['Unkontrollierte Datenweitergabe', 'Unbekannter Verarbeitungsort'],
typicalLikelihood: 'medium',
typicalImpact: 'medium',
wp248Criteria: ['K5'],
applicableTo: ['cloud_storage', 'crm', 'analytics'],
mitigationIds: ['M-TRANS-01', 'M-LEGAL-02'],
},
// =========================================================================
// AUTOMATISIERUNG / KI
// =========================================================================
{
id: 'R-AUTO-01',
category: 'rights_freedoms',
sdmGoal: 'transparenz',
title: 'KI-Fehlentscheidung mit erheblicher Auswirkung',
description: 'Ein KI-System trifft eine fehlerhafte automatisierte Entscheidung (z.B. Ablehnung, Sperrung, Bewertung), die erhebliche Auswirkungen auf eine betroffene Person hat.',
impactExamples: ['Unrechtmaessige Ablehnung', 'Falsche Risikoeinstufung', 'Benachteiligung'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K1', 'K2', 'K8'],
applicableTo: ['ai_ml', 'scoring', 'hr_system'],
mitigationIds: ['M-AUTO-01', 'M-AUTO-02', 'M-AUTO-03'],
},
{
id: 'R-AUTO-02',
category: 'rights_freedoms',
sdmGoal: 'nichtverkettung',
title: 'Algorithmischer Bias in Trainingsdaten',
description: 'KI-Modelle spiegeln Vorurteile in den Trainingsdaten wider und treffen diskriminierende Entscheidungen bezueglich geschuetzter Merkmale.',
impactExamples: ['Diskriminierung nach Geschlecht/Herkunft', 'Systematische Benachteiligung'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K1', 'K2', 'K7', 'K8'],
applicableTo: ['ai_ml', 'scoring'],
mitigationIds: ['M-AUTO-01', 'M-AUTO-04'],
},
{
id: 'R-AUTO-03',
category: 'rights_freedoms',
sdmGoal: 'transparenz',
title: 'Fehlende Erklaerbarkeit automatisierter Entscheidungen',
description: 'Automatisierte Entscheidungen koennen den Betroffenen nicht erklaert werden ("Black Box"), sodass der Anspruch auf aussagekraeftige Informationen (Art. 22 Abs. 3) nicht erfuellt wird.',
impactExamples: ['Keine Anfechtbarkeit', 'Vertrauensverlust', 'Verstoss gegen Art. 22'],
typicalLikelihood: 'high',
typicalImpact: 'medium',
wp248Criteria: ['K2', 'K8'],
applicableTo: ['ai_ml', 'scoring'],
mitigationIds: ['M-AUTO-02', 'M-TRANS-03'],
},
{
id: 'R-AUTO-04',
category: 'rights_freedoms',
sdmGoal: 'intervenierbarkeit',
title: 'Fehlende menschliche Aufsicht bei KI-Entscheidungen',
description: 'Automatisierte Entscheidungen werden ohne menschliche Ueberpruefung oder Interventionsmoeglichkeit getroffen, obwohl dies erforderlich waere.',
impactExamples: ['Keine Korrekturmoeglichkeit', 'Eskalation von Fehlern'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K2', 'K8'],
applicableTo: ['ai_ml', 'scoring', 'hr_system'],
mitigationIds: ['M-AUTO-03', 'M-INTERV-04'],
},
{
id: 'R-AUTO-05',
category: 'confidentiality',
sdmGoal: 'vertraulichkeit',
title: 'Datenleck durch KI-Training mit personenbezogenen Daten',
description: 'Personenbezogene Daten, die fuer das Training von KI-Modellen verwendet werden, koennen durch das Modell reproduziert oder extrahiert werden (Model Inversion, Membership Inference).',
impactExamples: ['Offenlegung von Trainingsdaten', 'Re-Identifizierung'],
typicalLikelihood: 'low',
typicalImpact: 'high',
wp248Criteria: ['K4', 'K8'],
applicableTo: ['ai_ml'],
mitigationIds: ['M-CONF-06', 'M-NONL-03', 'M-AUTO-04'],
},
// =========================================================================
// ORGANISATORISCHE RISIKEN
// =========================================================================
{
id: 'R-ORG-01',
category: 'rights_freedoms',
sdmGoal: 'transparenz',
title: 'Fehlende oder fehlerhafte Auftragsverarbeitungsvertraege',
description: 'Mit Auftragsverarbeitern existieren keine oder unzureichende Vertraege gemaess Art. 28 DSGVO, sodass Pflichten und Rechte nicht geregelt sind.',
impactExamples: ['Keine Kontrolle ueber Verarbeiter', 'Bussgeld', 'Datenmissbrauch durch Verarbeiter'],
typicalLikelihood: 'medium',
typicalImpact: 'medium',
wp248Criteria: ['K5'],
applicableTo: ['cloud_storage', 'crm', 'analytics', 'email_service'],
mitigationIds: ['M-LEGAL-02', 'M-LEGAL-03'],
},
{
id: 'R-ORG-02',
category: 'rights_freedoms',
sdmGoal: 'datenminimierung',
title: 'Fehlende Loeschprozesse / Ueberschreitung von Aufbewahrungsfristen',
description: 'Personenbezogene Daten werden laenger als notwendig gespeichert, weil keine automatischen Loeschprozesse oder Aufbewahrungsfristen definiert sind.',
impactExamples: ['Unnoetige Risikoexposition', 'Verstoss gegen Speicherbegrenzung', 'Bussgeld'],
typicalLikelihood: 'high',
typicalImpact: 'medium',
wp248Criteria: ['K5'],
applicableTo: ['database', 'cloud_storage', 'crm', 'erp', 'email_service'],
mitigationIds: ['M-DMIN-03', 'M-DMIN-04'],
},
{
id: 'R-ORG-03',
category: 'integrity',
sdmGoal: 'integritaet',
title: 'Unzureichende Schulung/Sensibilisierung der Mitarbeiter',
description: 'Mitarbeiter sind nicht ausreichend im Umgang mit personenbezogenen Daten geschult und verursachen durch Unkenntnis Datenpannen oder Verarbeitungsfehler.',
impactExamples: ['Versehentliche Datenweitergabe', 'Phishing-Erfolg', 'Fehlerhafte Verarbeitung'],
typicalLikelihood: 'high',
typicalImpact: 'medium',
wp248Criteria: ['K5', 'K7'],
applicableTo: ['hr_system', 'email_service', 'crm', 'web_application'],
mitigationIds: ['M-ORG-01', 'M-ORG-02'],
},
{
id: 'R-ORG-04',
category: 'rights_freedoms',
sdmGoal: 'transparenz',
title: 'Fehlende Datenpannen-Erkennung und -Meldung',
description: 'Datenpannen werden nicht rechtzeitig erkannt oder nicht innerhalb der 72-Stunden-Frist (Art. 33 DSGVO) an die Aufsichtsbehoerde gemeldet.',
impactExamples: ['Verspaetete Meldung', 'Bussgeld', 'Verzoegerte Benachrichtigung Betroffener'],
typicalLikelihood: 'medium',
typicalImpact: 'high',
wp248Criteria: ['K5'],
applicableTo: ['web_application', 'cloud_storage', 'database', 'api_service'],
mitigationIds: ['M-ORG-03', 'M-ORG-04', 'M-INT-02'],
},
// =========================================================================
// BESONDERE DATENKATEGORIEN
// =========================================================================
{
id: 'R-SPEC-01',
category: 'confidentiality',
sdmGoal: 'vertraulichkeit',
title: 'Kompromittierung besonderer Datenkategorien (Art. 9)',
description: 'Besonders schutzwuerdige Daten (Gesundheit, Religion, Biometrie, Gewerkschaftszugehoerigkeit) werden offengelegt oder missbraucht.',
impactExamples: ['Schwerwiegende Diskriminierung', 'Existenzielle Bedrohung', 'Soziale Ausgrenzung'],
typicalLikelihood: 'low',
typicalImpact: 'high',
wp248Criteria: ['K4', 'K7'],
applicableTo: ['hr_system', 'health_system', 'identity'],
mitigationIds: ['M-CONF-06', 'M-CONF-01', 'M-CONF-04'],
},
{
id: 'R-SPEC-02',
category: 'rights_freedoms',
sdmGoal: 'intervenierbarkeit',
title: 'Verarbeitung von Kinderdaten ohne angemessenen Schutz',
description: 'Daten von Minderjaehrigen werden verarbeitet, ohne die besonderen Schutzmassnahmen fuer Kinder (Art. 8, EG 38 DSGVO) zu beachten.',
impactExamples: ['Langzeitfolgen fuer Minderjaehrige', 'Einschraenkung der Entwicklung', 'Manipulation'],
typicalLikelihood: 'low',
typicalImpact: 'high',
wp248Criteria: ['K4', 'K7'],
applicableTo: ['web_application', 'mobile_app', 'education'],
mitigationIds: ['M-LEGAL-04', 'M-DMIN-01', 'M-TRANS-01'],
},
]
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
export function getRisksByCategory(category: DSFARiskCategory): CatalogRisk[] {
return RISK_CATALOG.filter(r => r.category === category)
}
export function getRisksBySDMGoal(goal: SDMGoal): CatalogRisk[] {
return RISK_CATALOG.filter(r => r.sdmGoal === goal)
}
export function getRisksByWP248Criterion(criterionCode: string): CatalogRisk[] {
return RISK_CATALOG.filter(r => r.wp248Criteria.includes(criterionCode))
}
export function getRisksByComponent(component: string): CatalogRisk[] {
return RISK_CATALOG.filter(r => r.applicableTo.includes(component))
}
export function getCatalogRiskById(id: string): CatalogRisk | undefined {
return RISK_CATALOG.find(r => r.id === id)
}
export const RISK_CATEGORY_LABELS: Record<DSFARiskCategory, string> = {
confidentiality: 'Vertraulichkeit',
integrity: 'Integritaet',
availability: 'Verfuegbarkeit',
rights_freedoms: 'Rechte & Freiheiten',
}
export const COMPONENT_FAMILY_LABELS: Record<string, string> = {
identity: 'Identitaet & Zugang',
cloud_storage: 'Cloud-Speicher',
web_application: 'Web-Anwendung',
api_service: 'API-Service',
email_service: 'E-Mail-Dienst',
mobile_app: 'Mobile App',
database: 'Datenbank',
crm: 'CRM-System',
erp: 'ERP-System',
analytics: 'Analyse/Tracking',
marketing: 'Marketing',
ai_ml: 'KI / Machine Learning',
scoring: 'Scoring / Bewertung',
hr_system: 'HR-System',
health_system: 'Gesundheitssystem',
monitoring: 'Ueberwachungssystem',
support_system: 'Support-System',
education: 'Bildungsplattform',
research: 'Forschung',
}

View File

@@ -5,6 +5,57 @@
* aligned with the backend Go models.
*/
// =============================================================================
// SDM GEWAEHRLEISTUNGSZIELE (Standard-Datenschutzmodell V2.0)
// =============================================================================
export type SDMGoal =
| 'datenminimierung'
| 'verfuegbarkeit'
| 'integritaet'
| 'vertraulichkeit'
| 'nichtverkettung'
| 'transparenz'
| 'intervenierbarkeit'
export const SDM_GOALS: Record<SDMGoal, { name: string; description: string; article: string }> = {
datenminimierung: {
name: 'Datenminimierung',
description: 'Verarbeitung personenbezogener Daten auf das dem Zweck angemessene, erhebliche und notwendige Mass beschraenken.',
article: 'Art. 5 Abs. 1 lit. c DSGVO',
},
verfuegbarkeit: {
name: 'Verfuegbarkeit',
description: 'Personenbezogene Daten muessen dem Verantwortlichen zur Verfuegung stehen und ordnungsgemaess im vorgesehenen Prozess verwendet werden koennen.',
article: 'Art. 32 Abs. 1 lit. b DSGVO',
},
integritaet: {
name: 'Integritaet',
description: 'Personenbezogene Daten bleiben waehrend der Verarbeitung unversehrt, vollstaendig und aktuell.',
article: 'Art. 5 Abs. 1 lit. d DSGVO',
},
vertraulichkeit: {
name: 'Vertraulichkeit',
description: 'Kein unbefugter Zugriff auf personenbezogene Daten. Nur befugte Personen koennen auf Daten zugreifen.',
article: 'Art. 32 Abs. 1 lit. b DSGVO',
},
nichtverkettung: {
name: 'Nichtverkettung',
description: 'Personenbezogene Daten duerfen nicht ohne Weiteres fuer einen anderen als den erhobenen Zweck zusammengefuehrt werden (Zweckbindung).',
article: 'Art. 5 Abs. 1 lit. b DSGVO',
},
transparenz: {
name: 'Transparenz',
description: 'Die Verarbeitung personenbezogener Daten muss fuer Betroffene und Aufsichtsbehoerden nachvollziehbar sein.',
article: 'Art. 5 Abs. 1 lit. a DSGVO',
},
intervenierbarkeit: {
name: 'Intervenierbarkeit',
description: 'Den Betroffenen werden wirksame Moeglichkeiten der Einflussnahme (Auskunft, Berichtigung, Loeschung, Widerspruch) auf die Verarbeitung gewaehrt.',
article: 'Art. 15-21 DSGVO',
},
}
// =============================================================================
// ENUMS & CONSTANTS
// =============================================================================

View File

@@ -55,6 +55,25 @@ export type {
GenerateResponse,
} from './sdk-client'
// Compliance Scope Engine
export type {
ComplianceDepthLevel,
ComplianceScores,
ComplianceScopeState,
ScopeDecision,
ScopeProfilingAnswer,
ScopeDocumentType,
} from './compliance-scope-types'
export {
DEPTH_LEVEL_LABELS,
DEPTH_LEVEL_DESCRIPTIONS,
DEPTH_LEVEL_COLORS,
DOCUMENT_TYPE_LABELS,
STORAGE_KEY as SCOPE_STORAGE_KEY,
createEmptyScopeState,
} from './compliance-scope-types'
export { complianceScopeEngine } from './compliance-scope-engine'
// Demo Data Seeding (stored via API like real customer data)
export {
generateDemoState,

View File

@@ -0,0 +1,578 @@
/**
* Loeschfristen Baseline-Katalog
*
* 18 vordefinierte Aufbewahrungsfristen-Templates fuer gaengige
* Datenobjekte in deutschen Unternehmen. Basierend auf AO, HGB,
* UStG, BGB, ArbZG, AGG, BDSG und BSIG.
*
* Werden genutzt, um neue Loeschfrist-Policies schnell aus
* bewaehrten Vorlagen zu erstellen.
*/
import type {
LoeschfristPolicy,
RetentionDriverType,
DeletionMethodType,
StorageLocation,
PolicyStatus,
ReviewInterval,
RetentionUnit,
DeletionTriggerLevel,
} from './loeschfristen-types'
import { createEmptyPolicy } from './loeschfristen-types'
// =============================================================================
// BASELINE TEMPLATE INTERFACE
// =============================================================================
export interface BaselineTemplate {
templateId: string
dataObjectName: string
description: string
affectedGroups: string[]
dataCategories: string[]
primaryPurpose: string
deletionTrigger: DeletionTriggerLevel
retentionDriver: RetentionDriverType | null
retentionDriverDetail: string
retentionDuration: number | null
retentionUnit: RetentionUnit | null
retentionDescription: string
startEvent: string
deletionMethod: DeletionMethodType
deletionMethodDetail: string
responsibleRole: string
reviewInterval: ReviewInterval
tags: string[]
}
// =============================================================================
// BASELINE TEMPLATES (18 Vorlagen)
// =============================================================================
export const BASELINE_TEMPLATES: BaselineTemplate[] = [
// ==================== 1. Personalakten ====================
{
templateId: 'personal-akten',
dataObjectName: 'Personalakten',
description:
'Vollstaendige Personalakten inkl. Arbeitsvertraege, Zeugnisse, Abmahnungen und sonstige beschaeftigungsrelevante Dokumente.',
affectedGroups: ['Mitarbeiter'],
dataCategories: ['Stammdaten', 'Vertragsdaten', 'Gehaltsdaten', 'Zeugnisse'],
primaryPurpose:
'Dokumentation und Nachweisfuehrung des Beschaeftigungsverhaeltnisses sowie Erfuellung steuerrechtlicher Aufbewahrungspflichten.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'AO_147',
retentionDriverDetail:
'Aufbewahrungspflicht gemaess 147 AO fuer steuerlich relevante Unterlagen der Personalakte.',
retentionDuration: 10,
retentionUnit: 'YEARS',
retentionDescription: '10 Jahre nach Ende des Beschaeftigungsverhaeltnisses',
startEvent: 'Ende des Beschaeftigungsverhaeltnisses',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Loeschung aller digitalen Personalakten-Dokumente nach Ablauf der Aufbewahrungsfrist. Papierakten werden datenschutzkonform vernichtet.',
responsibleRole: 'HR-Abteilung',
reviewInterval: 'ANNUAL',
tags: ['hr', 'steuer'],
},
// ==================== 2. Buchhaltungsbelege ====================
{
templateId: 'buchhaltungsbelege',
dataObjectName: 'Buchhaltungsbelege',
description:
'Buchungsbelege, Kontoauszuege, Kassenbuecher und sonstige Belege der laufenden Buchhaltung.',
affectedGroups: ['Kunden', 'Lieferanten'],
dataCategories: ['Finanzdaten', 'Transaktionsdaten', 'Kontodaten'],
primaryPurpose:
'Ordnungsgemaesse Buchfuehrung und Erfuellung handelsrechtlicher Aufbewahrungspflichten nach HGB.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'HGB_257',
retentionDriverDetail:
'Aufbewahrungspflicht gemaess 257 HGB fuer Handelsbuecher und Buchungsbelege.',
retentionDuration: 10,
retentionUnit: 'YEARS',
retentionDescription: '10 Jahre nach Ende des Geschaeftsjahres',
startEvent: 'Ende des Geschaeftsjahres',
deletionMethod: 'MANUAL_REVIEW_DELETE',
deletionMethodDetail:
'Manuelle Pruefung durch die Buchhaltung vor Loeschung, um sicherzustellen, dass keine laufenden Pruefungen oder Rechtsstreitigkeiten bestehen.',
responsibleRole: 'Buchhaltung',
reviewInterval: 'ANNUAL',
tags: ['finanzen', 'hgb'],
},
// ==================== 3. Rechnungen ====================
{
templateId: 'rechnungen',
dataObjectName: 'Rechnungen',
description:
'Eingangs- und Ausgangsrechnungen inkl. Rechnungsanhaenge und rechnungsbegruendende Unterlagen.',
affectedGroups: ['Kunden', 'Lieferanten'],
dataCategories: ['Rechnungsdaten', 'Umsatzsteuerdaten', 'Adressdaten'],
primaryPurpose:
'Dokumentation umsatzsteuerrelevanter Vorgaenge und Erfuellung der Aufbewahrungspflicht nach UStG.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'USTG_14B',
retentionDriverDetail:
'Aufbewahrungspflicht gemaess 14b UStG fuer Rechnungen und rechnungsbegruendende Unterlagen.',
retentionDuration: 10,
retentionUnit: 'YEARS',
retentionDescription: '10 Jahre ab Rechnungsdatum',
startEvent: 'Rechnungsdatum',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Loeschung nach Ablauf der 10-Jahres-Frist. Vor Loeschung wird geprueft, ob Rechnungen in laufenden Betriebspruefungen benoetigt werden.',
responsibleRole: 'Buchhaltung',
reviewInterval: 'ANNUAL',
tags: ['finanzen', 'ustg'],
},
// ==================== 4. Geschaeftsbriefe ====================
{
templateId: 'geschaeftsbriefe',
dataObjectName: 'Geschaeftsbriefe',
description:
'Empfangene und versandte Handelsbriefe, Geschaeftskorrespondenz und geschaeftsrelevante E-Mails.',
affectedGroups: ['Kunden', 'Lieferanten'],
dataCategories: ['Korrespondenz', 'Vertragskommunikation', 'Angebote'],
primaryPurpose:
'Nachweisfuehrung geschaeftlicher Kommunikation und Erfuellung der handelsrechtlichen Aufbewahrungspflicht fuer Handelsbriefe.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'HGB_257',
retentionDriverDetail:
'Aufbewahrungspflicht gemaess 257 HGB fuer empfangene und versandte Handelsbriefe (6 Jahre).',
retentionDuration: 6,
retentionUnit: 'YEARS',
retentionDescription: '6 Jahre ab Eingang oder Versand des Geschaeftsbriefes',
startEvent: 'Eingang bzw. Versand des Geschaeftsbriefes',
deletionMethod: 'MANUAL_REVIEW_DELETE',
deletionMethodDetail:
'Manuelle Pruefung durch die Geschaeftsleitung, da Geschaeftsbriefe ggf. als Beweismittel in Rechtsstreitigkeiten dienen koennen.',
responsibleRole: 'Geschaeftsleitung',
reviewInterval: 'ANNUAL',
tags: ['kommunikation', 'hgb'],
},
// ==================== 5. Bewerbungsunterlagen ====================
{
templateId: 'bewerbungsunterlagen',
dataObjectName: 'Bewerbungsunterlagen',
description:
'Eingereichte Bewerbungsunterlagen inkl. Anschreiben, Lebenslauf, Zeugnisse und Korrespondenz mit Bewerbern.',
affectedGroups: ['Bewerber'],
dataCategories: ['Bewerbungsdaten', 'Qualifikationen', 'Kontaktdaten'],
primaryPurpose:
'Durchfuehrung des Bewerbungsverfahrens und Absicherung gegen Entschaedigungsansprueche nach dem AGG.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'AGG_15',
retentionDriverDetail:
'Aufbewahrung fuer 6 Monate nach Absage gemaess 15 Abs. 4 AGG (Frist fuer Geltendmachung von Entschaedigungsanspruechen).',
retentionDuration: 6,
retentionUnit: 'MONTHS',
retentionDescription: '6 Monate nach Absage oder Stellenbesetzung',
startEvent: 'Absage oder endgueltige Stellenbesetzung',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Loeschung aller Bewerbungsunterlagen und zugehoeriger Kommunikation nach Ablauf der 6-Monats-Frist.',
responsibleRole: 'HR-Abteilung',
reviewInterval: 'QUARTERLY',
tags: ['hr', 'bewerbung'],
},
// ==================== 6. Kundenstammdaten ====================
{
templateId: 'kundenstammdaten',
dataObjectName: 'Kundenstammdaten',
description:
'Stammdaten von Kunden inkl. Kontaktdaten, Anschrift, Kundennummer und Kommunikationspraeferenzen.',
affectedGroups: ['Kunden'],
dataCategories: ['Stammdaten', 'Kontaktdaten', 'Adressdaten'],
primaryPurpose:
'Pflege der Kundenbeziehung, Vertragserfuellung und Absicherung gegen Verjaehrung vertraglicher Ansprueche.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'BGB_195',
retentionDriverDetail:
'Aufbewahrung fuer die Dauer der regelmaessigen Verjaehrungsfrist gemaess 195 BGB (3 Jahre).',
retentionDuration: 3,
retentionUnit: 'YEARS',
retentionDescription: '3 Jahre nach letzter geschaeftlicher Interaktion',
startEvent: 'Letzte geschaeftliche Interaktion mit dem Kunden',
deletionMethod: 'MANUAL_REVIEW_DELETE',
deletionMethodDetail:
'Manuelle Pruefung durch den Vertrieb vor Loeschung, um sicherzustellen, dass keine aktiven Geschaeftsbeziehungen oder offenen Forderungen bestehen.',
responsibleRole: 'Vertrieb',
reviewInterval: 'ANNUAL',
tags: ['crm', 'kunden'],
},
// ==================== 7. Newsletter-Einwilligungen ====================
{
templateId: 'newsletter-einwilligungen',
dataObjectName: 'Newsletter-Einwilligungen',
description:
'Einwilligungserklaerungen fuer den Newsletter-Versand inkl. Double-Opt-in-Nachweis und Abmeldezeitpunkt.',
affectedGroups: ['Abonnenten'],
dataCategories: ['Einwilligungsdaten', 'E-Mail-Adresse', 'Opt-in-Nachweis'],
primaryPurpose:
'Nachweis der wirksamen Einwilligung zum Newsletter-Versand gemaess Art. 7 DSGVO und Dokumentation des Widerrufs.',
deletionTrigger: 'PURPOSE_END',
retentionDriver: null,
retentionDriverDetail:
'Keine gesetzliche Aufbewahrungspflicht. Daten werden bis zum Widerruf der Einwilligung gespeichert.',
retentionDuration: null,
retentionUnit: null,
retentionDescription: 'Bis zum Widerruf der Einwilligung durch den Abonnenten',
startEvent: 'Widerruf der Einwilligung durch den Abonnenten',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Loeschung der personenbezogenen Daten nach Eingang des Widerrufs. Der Einwilligungsnachweis selbst wird fuer die Dauer der Nachweispflicht aufbewahrt.',
responsibleRole: 'Marketing',
reviewInterval: 'SEMI_ANNUAL',
tags: ['marketing', 'einwilligung'],
},
// ==================== 8. Webserver-Logs ====================
{
templateId: 'webserver-logs',
dataObjectName: 'Webserver-Logs',
description:
'Server-Zugriffsprotokolle inkl. IP-Adressen, Zeitstempel, aufgerufene URLs und HTTP-Statuscodes.',
affectedGroups: ['Website-Besucher'],
dataCategories: ['IP-Adressen', 'Zugriffszeitpunkte', 'User-Agent-Daten'],
primaryPurpose:
'Sicherstellung der IT-Sicherheit, Erkennung von Angriffen und Stoerungen sowie Erfuellung der Protokollierungspflicht nach BSIG.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'BSIG',
retentionDriverDetail:
'Aufbewahrung gemaess BSI-Gesetz / IT-Sicherheitsgesetz 2.0 fuer die Analyse von Sicherheitsvorfaellen.',
retentionDuration: 7,
retentionUnit: 'DAYS',
retentionDescription: '7 Tage nach Zeitpunkt des Zugriffs',
startEvent: 'Zeitpunkt des Server-Zugriffs',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Rotation und Loeschung der Logdateien nach 7 Tagen durch den Webserver (logrotate).',
responsibleRole: 'IT-Abteilung',
reviewInterval: 'QUARTERLY',
tags: ['it', 'logs'],
},
// ==================== 9. Videoueberwachung ====================
{
templateId: 'videoueberwachung',
dataObjectName: 'Videoueberwachung',
description:
'Aufnahmen der Videoueberwachung in Geschaeftsraeumen, Eingangsbereichen und Parkplaetzen.',
affectedGroups: ['Besucher', 'Mitarbeiter'],
dataCategories: ['Videodaten', 'Bilddaten', 'Zeitstempel'],
primaryPurpose:
'Schutz des Eigentums und der Sicherheit von Personen sowie Aufklaerung von Vorfaellen in den ueberwachten Bereichen.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'BDSG_35',
retentionDriverDetail:
'Unverzuegliche Loeschung nach Zweckwegfall gemaess 35 BDSG bzw. Art. 17 DSGVO. Maximale Speicherdauer 48 Stunden.',
retentionDuration: 2,
retentionUnit: 'DAYS',
retentionDescription: '48 Stunden (2 Tage) nach Aufnahmezeitpunkt',
startEvent: 'Aufnahmezeitpunkt der Videosequenz',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatisches Ueberschreiben der Aufnahmen durch das Videomanagementsystem nach Ablauf der 48-Stunden-Frist.',
responsibleRole: 'Facility Management',
reviewInterval: 'QUARTERLY',
tags: ['sicherheit', 'video'],
},
// ==================== 10. Gehaltsabrechnungen ====================
{
templateId: 'gehaltsabrechnungen',
dataObjectName: 'Gehaltsabrechnungen',
description:
'Monatliche Gehaltsabrechnungen, Lohnsteuerbescheinigungen und Sozialversicherungsmeldungen.',
affectedGroups: ['Mitarbeiter'],
dataCategories: ['Gehaltsdaten', 'Steuerdaten', 'Sozialversicherungsdaten'],
primaryPurpose:
'Dokumentation der Lohn- und Gehaltszahlungen sowie Erfuellung steuerrechtlicher und sozialversicherungsrechtlicher Aufbewahrungspflichten.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'AO_147',
retentionDriverDetail:
'Aufbewahrungspflicht gemaess 147 AO fuer lohnsteuerrelevante Unterlagen und Gehaltsbuchungen.',
retentionDuration: 10,
retentionUnit: 'YEARS',
retentionDescription: '10 Jahre nach Ende des Geschaeftsjahres',
startEvent: 'Ende des Geschaeftsjahres der jeweiligen Abrechnung',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Loeschung der digitalen Gehaltsabrechnungen nach Ablauf der Aufbewahrungsfrist. Papierbelege werden datenschutzkonform vernichtet.',
responsibleRole: 'Lohnbuchhaltung',
reviewInterval: 'ANNUAL',
tags: ['hr', 'steuer'],
},
// ==================== 11. Vertraege ====================
{
templateId: 'vertraege',
dataObjectName: 'Vertraege',
description:
'Geschaeftsvertraege, Rahmenvereinbarungen, Dienstleistungsvertraege und zugehoerige Anlagen und Nachtraege.',
affectedGroups: ['Vertragspartner'],
dataCategories: ['Vertragsdaten', 'Kontaktdaten', 'Konditionen'],
primaryPurpose:
'Dokumentation vertraglicher Vereinbarungen und Sicherung von Beweismitteln fuer die Dauer moeglicher Rechtsstreitigkeiten.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'HGB_257',
retentionDriverDetail:
'Aufbewahrungspflicht gemaess 257 HGB fuer handelsrechtlich relevante Vertragsunterlagen.',
retentionDuration: 10,
retentionUnit: 'YEARS',
retentionDescription: '10 Jahre nach Ende der Vertragslaufzeit',
startEvent: 'Ende der Vertragslaufzeit bzw. Vertragsbeendigung',
deletionMethod: 'MANUAL_REVIEW_DELETE',
deletionMethodDetail:
'Manuelle Pruefung durch die Rechtsabteilung vor Loeschung, um sicherzustellen, dass keine laufenden oder angedrohten Rechtsstreitigkeiten bestehen.',
responsibleRole: 'Rechtsabteilung',
reviewInterval: 'ANNUAL',
tags: ['recht', 'vertraege'],
},
// ==================== 12. Zeiterfassungsdaten ====================
{
templateId: 'zeiterfassung',
dataObjectName: 'Zeiterfassungsdaten',
description:
'Arbeitszeitaufzeichnungen inkl. Beginn, Ende, Pausen und Ueberstunden der Beschaeftigten.',
affectedGroups: ['Mitarbeiter'],
dataCategories: ['Arbeitszeiten', 'Pausenzeiten', 'Ueberstunden'],
primaryPurpose:
'Erfuellung der gesetzlichen Aufzeichnungspflicht fuer Arbeitszeiten und Nachweis der Einhaltung des Arbeitszeitgesetzes.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'ARBZG_16',
retentionDriverDetail:
'Aufbewahrungspflicht gemaess 16 Abs. 2 ArbZG fuer Aufzeichnungen ueber die Arbeitszeit.',
retentionDuration: 2,
retentionUnit: 'YEARS',
retentionDescription: '2 Jahre nach Ende des Erfassungszeitraums',
startEvent: 'Ende des jeweiligen Erfassungszeitraums',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Loeschung der Zeiterfassungsdaten nach Ablauf der 2-Jahres-Frist im Zeiterfassungssystem.',
responsibleRole: 'HR-Abteilung',
reviewInterval: 'ANNUAL',
tags: ['hr', 'arbzg'],
},
// ==================== 13. Krankmeldungen ====================
{
templateId: 'krankmeldungen',
dataObjectName: 'Krankmeldungen',
description:
'Arbeitsunfaehigkeitsbescheinigungen, Krankmeldungen und zugehoerige Abwesenheitsdokumentationen.',
affectedGroups: ['Mitarbeiter'],
dataCategories: ['Gesundheitsdaten', 'Abwesenheitszeiten', 'AU-Bescheinigungen'],
primaryPurpose:
'Dokumentation von Fehlzeiten, Entgeltfortzahlung im Krankheitsfall und Absicherung gegen Verjaehrung arbeitsrechtlicher Ansprueche.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'BGB_195',
retentionDriverDetail:
'Aufbewahrung fuer die Dauer der regelmaessigen Verjaehrungsfrist gemaess 195 BGB zur Absicherung von Erstattungsanspruechen.',
retentionDuration: 3,
retentionUnit: 'YEARS',
retentionDescription: '3 Jahre nach Ende des Beschaeftigungsverhaeltnisses',
startEvent: 'Ende des Beschaeftigungsverhaeltnisses',
deletionMethod: 'MANUAL_REVIEW_DELETE',
deletionMethodDetail:
'Manuelle Pruefung durch die HR-Abteilung vor Loeschung, da Krankmeldungen besondere Kategorien personenbezogener Daten (Gesundheitsdaten) enthalten.',
responsibleRole: 'HR-Abteilung',
reviewInterval: 'ANNUAL',
tags: ['hr', 'gesundheit'],
},
// ==================== 14. Steuererklaerungen ====================
{
templateId: 'steuererklaerungen',
dataObjectName: 'Steuererklaerungen',
description:
'Koerperschaftsteuer-, Gewerbesteuer- und Umsatzsteuererklaerungen inkl. Anlagen und Bescheide.',
affectedGroups: ['Unternehmen'],
dataCategories: ['Steuerdaten', 'Finanzkennzahlen', 'Bescheide'],
primaryPurpose:
'Erfuellung steuerrechtlicher Dokumentationspflichten und Nachweisfuehrung gegenueber den Finanzbehoerden.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'AO_147',
retentionDriverDetail:
'Aufbewahrungspflicht gemaess 147 AO fuer Steuererklaerungen und zugehoerige Unterlagen.',
retentionDuration: 10,
retentionUnit: 'YEARS',
retentionDescription: '10 Jahre ab dem jeweiligen Steuerjahr',
startEvent: 'Ende des betreffenden Steuerjahres',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Loeschung nach Ablauf der 10-Jahres-Frist, sofern keine laufende Betriebspruefung oder Einspruchsverfahren vorliegen.',
responsibleRole: 'Steuerberater/Buchhaltung',
reviewInterval: 'ANNUAL',
tags: ['finanzen', 'steuer'],
},
// ==================== 15. Gesellschafterprotokolle ====================
{
templateId: 'protokolle-gesellschafter',
dataObjectName: 'Gesellschafterprotokolle',
description:
'Protokolle der Gesellschafterversammlungen, Beschluesse, Abstimmungsergebnisse und notarielle Urkunden.',
affectedGroups: ['Gesellschafter'],
dataCategories: ['Beschlussdaten', 'Abstimmungsergebnisse', 'Protokolle'],
primaryPurpose:
'Dokumentation gesellschaftsrechtlicher Beschluesse und Erfuellung handelsrechtlicher Aufbewahrungspflichten.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'HGB_257',
retentionDriverDetail:
'Aufbewahrungspflicht gemaess 257 HGB fuer Eroeffnungsbilanzen, Jahresabschluesse und zugehoerige Beschluesse.',
retentionDuration: 10,
retentionUnit: 'YEARS',
retentionDescription: '10 Jahre ab Beschlussdatum',
startEvent: 'Datum des jeweiligen Gesellschafterbeschlusses',
deletionMethod: 'PHYSICAL_DESTROY',
deletionMethodDetail:
'Physische Vernichtung der Papieroriginale durch zertifizierten Aktenvernichtungsdienstleister (DIN 66399, Sicherheitsstufe P-4). Digitale Kopien werden parallel geloescht.',
responsibleRole: 'Geschaeftsleitung',
reviewInterval: 'ANNUAL',
tags: ['recht', 'gesellschaft'],
},
// ==================== 16. CRM-Kontakthistorie ====================
{
templateId: 'crm-kontakthistorie',
dataObjectName: 'CRM-Kontakthistorie',
description:
'Kontaktverlauf im CRM-System inkl. Anrufe, E-Mails, Termine, Notizen und Angebotsverlauf.',
affectedGroups: ['Kunden', 'Interessenten'],
dataCategories: ['Kommunikationsdaten', 'Interaktionshistorie', 'Angebotsdaten'],
primaryPurpose:
'Pflege der Kundenbeziehung und Nachverfolgung geschaeftlicher Interaktionen fuer Vertriebs- und Servicezwecke.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'BGB_195',
retentionDriverDetail:
'Aufbewahrung fuer die Dauer der regelmaessigen Verjaehrungsfrist gemaess 195 BGB zur Absicherung vertraglicher Ansprueche.',
retentionDuration: 3,
retentionUnit: 'YEARS',
retentionDescription: '3 Jahre nach letztem Kontakt mit dem Kunden oder Interessenten',
startEvent: 'Letzter dokumentierter Kontakt im CRM-System',
deletionMethod: 'ANONYMIZATION',
deletionMethodDetail:
'Anonymisierung der personenbezogenen Daten im CRM-System, sodass statistische Auswertungen weiterhin moeglich sind, aber kein Personenbezug mehr hergestellt werden kann.',
responsibleRole: 'Vertrieb',
reviewInterval: 'SEMI_ANNUAL',
tags: ['crm', 'kunden'],
},
// ==================== 17. Backup-Daten ====================
{
templateId: 'backup-daten',
dataObjectName: 'Backup-Daten',
description:
'Vollstaendige und inkrementelle Sicherungskopien aller Systeme, Datenbanken und Dateisysteme.',
affectedGroups: ['Alle Betroffenengruppen'],
dataCategories: ['Systemsicherungen', 'Datenbankkopien', 'Dateisystemsicherungen'],
primaryPurpose:
'Sicherstellung der Datenwiederherstellung im Katastrophenfall und Gewaehrleistung der Geschaeftskontinuitaet.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'BSIG',
retentionDriverDetail:
'Aufbewahrung von Backups fuer 90 Tage gemaess BSI-Grundschutz-Empfehlungen zur Sicherstellung der Wiederherstellbarkeit.',
retentionDuration: 90,
retentionUnit: 'DAYS',
retentionDescription: '90 Tage nach Erstellung des Backups',
startEvent: 'Erstellungsdatum des jeweiligen Backups',
deletionMethod: 'CRYPTO_ERASE',
deletionMethodDetail:
'Kryptographische Loeschung durch Vernichtung der Verschluesselungsschluessel, sodass die verschluesselten Backup-Daten nicht mehr entschluesselt werden koennen.',
responsibleRole: 'IT-Abteilung',
reviewInterval: 'QUARTERLY',
tags: ['it', 'backup'],
},
// ==================== 18. Cookie-Consent-Nachweise ====================
{
templateId: 'cookie-consent-logs',
dataObjectName: 'Cookie-Consent-Nachweise',
description:
'Nachweise ueber Cookie-Einwilligungen der Website-Besucher inkl. Consent-ID, Zeitstempel, gesetzte Praeferenzen und IP-Adresse.',
affectedGroups: ['Website-Besucher'],
dataCategories: ['Consent-Daten', 'IP-Adressen', 'Zeitstempel', 'Praeferenzen'],
primaryPurpose:
'Nachweisfuehrung der Einwilligung in die Cookie-Nutzung gemaess Art. 7 Abs. 1 DSGVO und ePrivacy-Richtlinie.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'BGB_195',
retentionDriverDetail:
'Aufbewahrung der Consent-Nachweise fuer die Dauer der regelmaessigen Verjaehrungsfrist gemaess 195 BGB zur Absicherung gegen Abmahnungen.',
retentionDuration: 3,
retentionUnit: 'YEARS',
retentionDescription: '3 Jahre nach Zeitpunkt der Einwilligung',
startEvent: 'Zeitpunkt der Cookie-Einwilligung (Consent-Zeitstempel)',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Loeschung der Consent-Nachweise nach Ablauf der 3-Jahres-Frist durch das Consent-Management-System.',
responsibleRole: 'Datenschutzbeauftragter',
reviewInterval: 'ANNUAL',
tags: ['datenschutz', 'consent'],
},
]
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
/**
* Erstellt eine vollstaendige LoeschfristPolicy aus einem BaselineTemplate.
* Nutzt createEmptyPolicy() als Basis und ueberlagert die Template-Felder.
*/
export function templateToPolicy(template: BaselineTemplate): LoeschfristPolicy {
const base = createEmptyPolicy()
return {
...base,
dataObjectName: template.dataObjectName,
description: template.description,
affectedGroups: [...template.affectedGroups],
dataCategories: [...template.dataCategories],
primaryPurpose: template.primaryPurpose,
deletionTrigger: template.deletionTrigger,
retentionDriver: template.retentionDriver,
retentionDriverDetail: template.retentionDriverDetail,
retentionDuration: template.retentionDuration,
retentionUnit: template.retentionUnit,
retentionDescription: template.retentionDescription,
startEvent: template.startEvent,
deletionMethod: template.deletionMethod,
deletionMethodDetail: template.deletionMethodDetail,
responsibleRole: template.responsibleRole,
reviewInterval: template.reviewInterval,
tags: [...template.tags],
}
}
/**
* Gibt alle Templates zurueck, die einen bestimmten Tag enthalten.
*/
export function getTemplatesByTag(tag: string): BaselineTemplate[] {
return BASELINE_TEMPLATES.filter(t => t.tags.includes(tag))
}
/**
* Findet ein Template anhand seiner templateId.
*/
export function getTemplateById(templateId: string): BaselineTemplate | undefined {
return BASELINE_TEMPLATES.find(t => t.templateId === templateId)
}
/**
* Gibt alle im Katalog verwendeten Tags als sortierte Liste zurueck.
*/
export function getAllTemplateTags(): string[] {
const tags = new Set<string>()
BASELINE_TEMPLATES.forEach(t => t.tags.forEach(tag => tags.add(tag)))
return Array.from(tags).sort()
}

View File

@@ -0,0 +1,325 @@
// =============================================================================
// Loeschfristen Module - Compliance Check Engine
// Prueft Policies auf Vollstaendigkeit, Konsistenz und DSGVO-Konformitaet
// =============================================================================
import {
LoeschfristPolicy,
PolicyStatus,
isPolicyOverdue,
getActiveLegalHolds,
} from './loeschfristen-types'
// =============================================================================
// TYPES
// =============================================================================
export type ComplianceIssueType =
| 'MISSING_TRIGGER'
| 'MISSING_LEGAL_BASIS'
| 'OVERDUE_REVIEW'
| 'NO_RESPONSIBLE'
| 'LEGAL_HOLD_CONFLICT'
| 'STALE_DRAFT'
| 'UNCOVERED_VVT_CATEGORY'
export type ComplianceIssueSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
export interface ComplianceIssue {
id: string
policyId: string
policyName: string
type: ComplianceIssueType
severity: ComplianceIssueSeverity
title: string
description: string
recommendation: string
}
export interface ComplianceCheckResult {
issues: ComplianceIssue[]
score: number // 0-100
stats: {
total: number
passed: number
failed: number
bySeverity: Record<ComplianceIssueSeverity, number>
}
}
// =============================================================================
// HELPERS
// =============================================================================
let issueCounter = 0
function createIssueId(): string {
issueCounter++
return `CI-${Date.now()}-${String(issueCounter).padStart(4, '0')}`
}
function createIssue(
policy: LoeschfristPolicy,
type: ComplianceIssueType,
severity: ComplianceIssueSeverity,
title: string,
description: string,
recommendation: string
): ComplianceIssue {
return {
id: createIssueId(),
policyId: policy.policyId,
policyName: policy.dataObjectName || policy.policyId,
type,
severity,
title,
description,
recommendation,
}
}
function daysBetween(dateStr: string, now: Date): number {
const date = new Date(dateStr)
const diffMs = now.getTime() - date.getTime()
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
}
// =============================================================================
// INDIVIDUAL CHECKS
// =============================================================================
/**
* Check 1: MISSING_TRIGGER (HIGH)
* Policy has no deletionTrigger set, or trigger is PURPOSE_END but no startEvent defined.
*/
function checkMissingTrigger(policy: LoeschfristPolicy): ComplianceIssue | null {
if (!policy.deletionTrigger) {
return createIssue(
policy,
'MISSING_TRIGGER',
'HIGH',
'Kein Loeschtrigger definiert',
`Die Policy "${policy.dataObjectName}" hat keinen Loeschtrigger gesetzt. Ohne Trigger ist unklar, wann die Daten geloescht werden.`,
'Definieren Sie einen Loeschtrigger (Zweckende, Aufbewahrungspflicht oder Legal Hold) fuer diese Policy.'
)
}
if (policy.deletionTrigger === 'PURPOSE_END' && !policy.startEvent.trim()) {
return createIssue(
policy,
'MISSING_TRIGGER',
'HIGH',
'Zweckende ohne Startereignis',
`Die Policy "${policy.dataObjectName}" nutzt "Zweckende" als Trigger, hat aber kein Startereignis definiert. Ohne Startereignis laesst sich der Loeschzeitpunkt nicht berechnen.`,
'Definieren Sie ein konkretes Startereignis (z.B. "Vertragsende", "Abmeldung", "Projektabschluss").'
)
}
return null
}
/**
* Check 2: MISSING_LEGAL_BASIS (HIGH)
* Policy with RETENTION_DRIVER trigger but no retentionDriver set.
*/
function checkMissingLegalBasis(policy: LoeschfristPolicy): ComplianceIssue | null {
if (policy.deletionTrigger === 'RETENTION_DRIVER' && !policy.retentionDriver) {
return createIssue(
policy,
'MISSING_LEGAL_BASIS',
'HIGH',
'Aufbewahrungspflicht ohne Rechtsgrundlage',
`Die Policy "${policy.dataObjectName}" hat "Aufbewahrungspflicht" als Trigger, aber keinen konkreten Aufbewahrungstreiber (z.B. AO 147, HGB 257) zugeordnet.`,
'Waehlen Sie den passenden gesetzlichen Aufbewahrungstreiber aus oder wechseln Sie den Trigger-Typ.'
)
}
return null
}
/**
* Check 3: OVERDUE_REVIEW (MEDIUM)
* Policy where nextReviewDate is in the past.
*/
function checkOverdueReview(policy: LoeschfristPolicy): ComplianceIssue | null {
if (isPolicyOverdue(policy)) {
const overdueDays = daysBetween(policy.nextReviewDate, new Date())
return createIssue(
policy,
'OVERDUE_REVIEW',
'MEDIUM',
'Ueberfaellige Pruefung',
`Die Policy "${policy.dataObjectName}" haette am ${new Date(policy.nextReviewDate).toLocaleDateString('de-DE')} geprueft werden muessen. Die Pruefung ist ${overdueDays} Tag(e) ueberfaellig.`,
'Fuehren Sie umgehend eine Pruefung dieser Policy durch und aktualisieren Sie das naechste Pruefungsdatum.'
)
}
return null
}
/**
* Check 4: NO_RESPONSIBLE (MEDIUM)
* Policy with no responsiblePerson AND no responsibleRole.
*/
function checkNoResponsible(policy: LoeschfristPolicy): ComplianceIssue | null {
if (!policy.responsiblePerson.trim() && !policy.responsibleRole.trim()) {
return createIssue(
policy,
'NO_RESPONSIBLE',
'MEDIUM',
'Keine verantwortliche Person/Rolle',
`Die Policy "${policy.dataObjectName}" hat weder eine verantwortliche Person noch eine verantwortliche Rolle zugewiesen. Ohne Verantwortlichkeit kann die Loeschung nicht zuverlaessig durchgefuehrt werden.`,
'Weisen Sie eine verantwortliche Person oder zumindest eine verantwortliche Rolle (z.B. "Datenschutzbeauftragter", "IT-Leitung") zu.'
)
}
return null
}
/**
* Check 5: LEGAL_HOLD_CONFLICT (CRITICAL)
* Policy has active legal hold but deletionMethod is AUTO_DELETE.
*/
function checkLegalHoldConflict(policy: LoeschfristPolicy): ComplianceIssue | null {
const activeHolds = getActiveLegalHolds(policy)
if (activeHolds.length > 0 && policy.deletionMethod === 'AUTO_DELETE') {
const holdReasons = activeHolds.map((h) => h.reason).join(', ')
return createIssue(
policy,
'LEGAL_HOLD_CONFLICT',
'CRITICAL',
'Legal Hold mit automatischer Loeschung',
`Die Policy "${policy.dataObjectName}" hat ${activeHolds.length} aktive(n) Legal Hold(s) (${holdReasons}), aber die Loeschmethode ist auf "Automatische Loeschung" gesetzt. Dies kann zu unbeabsichtigter Vernichtung von Beweismitteln fuehren.`,
'Aendern Sie die Loeschmethode auf "Manuelle Pruefung & Loeschung" oder deaktivieren Sie die automatische Loeschung, solange der Legal Hold aktiv ist.'
)
}
return null
}
/**
* Check 6: STALE_DRAFT (LOW)
* Policy in DRAFT status older than 90 days.
*/
function checkStaleDraft(policy: LoeschfristPolicy): ComplianceIssue | null {
if (policy.status === 'DRAFT') {
const ageInDays = daysBetween(policy.createdAt, new Date())
if (ageInDays > 90) {
return createIssue(
policy,
'STALE_DRAFT',
'LOW',
'Veralteter Entwurf',
`Die Policy "${policy.dataObjectName}" ist seit ${ageInDays} Tagen im Entwurfsstatus. Entwuerfe, die laenger als 90 Tage nicht finalisiert werden, deuten auf unvollstaendige Dokumentation hin.`,
'Finalisieren Sie den Entwurf und setzen Sie den Status auf "Aktiv", oder archivieren Sie die Policy, falls sie nicht mehr benoetigt wird.'
)
}
}
return null
}
// =============================================================================
// MAIN COMPLIANCE CHECK
// =============================================================================
/**
* Fuehrt einen vollstaendigen Compliance-Check ueber alle Policies durch.
*
* @param policies - Alle Loeschfrist-Policies
* @param vvtDataCategories - Optionale Datenkategorien aus dem VVT (localStorage)
* @returns ComplianceCheckResult mit Issues, Score und Statistiken
*/
export function runComplianceCheck(
policies: LoeschfristPolicy[],
vvtDataCategories?: string[]
): ComplianceCheckResult {
// Reset counter for deterministic IDs within a single check run
issueCounter = 0
const issues: ComplianceIssue[] = []
// Run checks 1-6 for each policy
for (const policy of policies) {
const checks = [
checkMissingTrigger(policy),
checkMissingLegalBasis(policy),
checkOverdueReview(policy),
checkNoResponsible(policy),
checkLegalHoldConflict(policy),
checkStaleDraft(policy),
]
for (const issue of checks) {
if (issue !== null) {
issues.push(issue)
}
}
}
// Check 7: UNCOVERED_VVT_CATEGORY (MEDIUM)
if (vvtDataCategories && vvtDataCategories.length > 0) {
const coveredCategories = new Set<string>()
for (const policy of policies) {
for (const category of policy.dataCategories) {
coveredCategories.add(category.toLowerCase().trim())
}
}
for (const vvtCategory of vvtDataCategories) {
const normalized = vvtCategory.toLowerCase().trim()
if (!coveredCategories.has(normalized)) {
issues.push({
id: createIssueId(),
policyId: '-',
policyName: '-',
type: 'UNCOVERED_VVT_CATEGORY',
severity: 'MEDIUM',
title: `Datenkategorie ohne Loeschfrist: "${vvtCategory}"`,
description: `Die Datenkategorie "${vvtCategory}" ist im Verzeichnis der Verarbeitungstaetigkeiten (VVT) erfasst, hat aber keine zugehoerige Loeschfrist-Policy. Gemaess DSGVO Art. 5 Abs. 1 lit. e muss fuer jede Datenkategorie eine Speicherbegrenzung definiert sein.`,
recommendation: `Erstellen Sie eine neue Loeschfrist-Policy fuer die Datenkategorie "${vvtCategory}" oder ordnen Sie sie einer bestehenden Policy zu.`,
})
}
}
}
// Calculate score
const bySeverity: Record<ComplianceIssueSeverity, number> = {
LOW: 0,
MEDIUM: 0,
HIGH: 0,
CRITICAL: 0,
}
for (const issue of issues) {
bySeverity[issue.severity]++
}
const rawScore =
100 -
(bySeverity.CRITICAL * 15 +
bySeverity.HIGH * 10 +
bySeverity.MEDIUM * 5 +
bySeverity.LOW * 2)
const score = Math.max(0, rawScore)
// Calculate pass/fail per policy
const failedPolicyIds = new Set(
issues.filter((i) => i.policyId !== '-').map((i) => i.policyId)
)
const totalPolicies = policies.length
const failedCount = failedPolicyIds.size
const passedCount = totalPolicies - failedCount
return {
issues,
score,
stats: {
total: totalPolicies,
passed: passedCount,
failed: failedCount,
bySeverity,
},
}
}

View File

@@ -0,0 +1,353 @@
// =============================================================================
// Loeschfristen Module - Export & Report Generation
// JSON, CSV, Markdown-Compliance-Report und Browser-Download
// =============================================================================
import {
LoeschfristPolicy,
RETENTION_DRIVER_META,
DELETION_METHOD_LABELS,
STATUS_LABELS,
TRIGGER_LABELS,
formatRetentionDuration,
getEffectiveDeletionTrigger,
} from './loeschfristen-types'
import {
runComplianceCheck,
ComplianceCheckResult,
ComplianceIssueSeverity,
} from './loeschfristen-compliance'
// =============================================================================
// JSON EXPORT
// =============================================================================
interface PolicyExportEnvelope {
exportDate: string
version: string
totalPolicies: number
policies: LoeschfristPolicy[]
}
/**
* Exportiert alle Policies als pretty-printed JSON.
* Enthaelt Metadaten (Exportdatum, Version, Anzahl).
*/
export function exportPoliciesAsJSON(policies: LoeschfristPolicy[]): string {
const exportData: PolicyExportEnvelope = {
exportDate: new Date().toISOString(),
version: '1.0',
totalPolicies: policies.length,
policies: policies,
}
return JSON.stringify(exportData, null, 2)
}
// =============================================================================
// CSV EXPORT
// =============================================================================
/**
* Escapes a CSV field value according to RFC 4180.
* Fields containing commas, double quotes, or newlines are wrapped in quotes.
* Existing double quotes are doubled.
*/
function escapeCSVField(value: string): string {
if (
value.includes(',') ||
value.includes('"') ||
value.includes('\n') ||
value.includes('\r') ||
value.includes(';')
) {
return `"${value.replace(/"/g, '""')}"`
}
return value
}
/**
* Formats a date string to German locale format (DD.MM.YYYY).
* Returns empty string for null/undefined/empty values.
*/
function formatDateDE(dateStr: string | null | undefined): string {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
if (isNaN(date.getTime())) return ''
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
} catch {
return ''
}
}
/**
* Exportiert alle Policies als CSV mit BOM fuer Excel-Kompatibilitaet.
* Trennzeichen ist Semikolon (;) fuer deutschsprachige Excel-Versionen.
*/
export function exportPoliciesAsCSV(policies: LoeschfristPolicy[]): string {
const BOM = '\uFEFF'
const SEPARATOR = ';'
const headers = [
'LF-Nr.',
'Datenobjekt',
'Beschreibung',
'Loeschtrigger',
'Aufbewahrungstreiber',
'Frist',
'Startereignis',
'Loeschmethode',
'Verantwortlich',
'Status',
'Legal Hold aktiv',
'Letzte Pruefung',
'Naechste Pruefung',
]
const rows: string[] = []
// Header row
rows.push(headers.map(escapeCSVField).join(SEPARATOR))
// Data rows
for (const policy of policies) {
const effectiveTrigger = getEffectiveDeletionTrigger(policy)
const triggerLabel = TRIGGER_LABELS[effectiveTrigger]
const driverLabel = policy.retentionDriver
? RETENTION_DRIVER_META[policy.retentionDriver].label
: ''
const durationLabel = formatRetentionDuration(
policy.retentionDuration,
policy.retentionUnit
)
const methodLabel = DELETION_METHOD_LABELS[policy.deletionMethod]
const statusLabel = STATUS_LABELS[policy.status]
// Combine responsiblePerson and responsibleRole
const responsible = [policy.responsiblePerson, policy.responsibleRole]
.filter((s) => s.trim())
.join(' / ')
const legalHoldActive = policy.hasActiveLegalHold ? 'Ja' : 'Nein'
const row = [
policy.policyId,
policy.dataObjectName,
policy.description,
triggerLabel,
driverLabel,
durationLabel,
policy.startEvent,
methodLabel,
responsible || '-',
statusLabel,
legalHoldActive,
formatDateDE(policy.lastReviewDate),
formatDateDE(policy.nextReviewDate),
]
rows.push(row.map(escapeCSVField).join(SEPARATOR))
}
return BOM + rows.join('\r\n')
}
// =============================================================================
// COMPLIANCE SUMMARY (MARKDOWN)
// =============================================================================
const SEVERITY_LABELS: Record<ComplianceIssueSeverity, string> = {
CRITICAL: 'Kritisch',
HIGH: 'Hoch',
MEDIUM: 'Mittel',
LOW: 'Niedrig',
}
const SEVERITY_EMOJI: Record<ComplianceIssueSeverity, string> = {
CRITICAL: '[!!!]',
HIGH: '[!!]',
MEDIUM: '[!]',
LOW: '[i]',
}
/**
* Returns a textual rating based on the compliance score.
*/
function getScoreRating(score: number): string {
if (score >= 90) return 'Ausgezeichnet'
if (score >= 75) return 'Gut'
if (score >= 50) return 'Verbesserungswuerdig'
if (score >= 25) return 'Mangelhaft'
return 'Kritisch'
}
/**
* Generiert einen Markdown-formatierten Compliance-Bericht.
* Enthaelt: Uebersicht, Score, Issue-Liste, Empfehlungen.
*/
export function generateComplianceSummary(
policies: LoeschfristPolicy[],
vvtDataCategories?: string[]
): string {
const result: ComplianceCheckResult = runComplianceCheck(policies, vvtDataCategories)
const now = new Date()
const lines: string[] = []
// Header
lines.push('# Compliance-Bericht: Loeschfristen')
lines.push('')
lines.push(
`**Erstellt am:** ${now.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} um ${now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr`
)
lines.push('')
// Overview
lines.push('## Uebersicht')
lines.push('')
lines.push(`| Kennzahl | Wert |`)
lines.push(`|----------|------|`)
lines.push(`| Gepruefte Policies | ${result.stats.total} |`)
lines.push(`| Bestanden | ${result.stats.passed} |`)
lines.push(`| Beanstandungen | ${result.stats.failed} |`)
lines.push(`| Compliance-Score | **${result.score}/100** (${getScoreRating(result.score)}) |`)
lines.push('')
// Severity breakdown
lines.push('## Befunde nach Schweregrad')
lines.push('')
lines.push('| Schweregrad | Anzahl |')
lines.push('|-------------|--------|')
const severityOrder: ComplianceIssueSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
for (const severity of severityOrder) {
const count = result.stats.bySeverity[severity]
lines.push(`| ${SEVERITY_LABELS[severity]} | ${count} |`)
}
lines.push('')
// Status distribution of policies
const statusCounts: Record<string, number> = {}
for (const policy of policies) {
const label = STATUS_LABELS[policy.status]
statusCounts[label] = (statusCounts[label] || 0) + 1
}
lines.push('## Policy-Status-Verteilung')
lines.push('')
lines.push('| Status | Anzahl |')
lines.push('|--------|--------|')
for (const [label, count] of Object.entries(statusCounts)) {
lines.push(`| ${label} | ${count} |`)
}
lines.push('')
// Issues list
if (result.issues.length === 0) {
lines.push('## Befunde')
lines.push('')
lines.push('Keine Beanstandungen gefunden. Alle Policies sind konform.')
lines.push('')
} else {
lines.push('## Befunde')
lines.push('')
// Group issues by severity
for (const severity of severityOrder) {
const issuesForSeverity = result.issues.filter((i) => i.severity === severity)
if (issuesForSeverity.length === 0) continue
lines.push(`### ${SEVERITY_LABELS[severity]} ${SEVERITY_EMOJI[severity]}`)
lines.push('')
for (const issue of issuesForSeverity) {
const policyRef =
issue.policyId !== '-' ? ` (${issue.policyId})` : ''
lines.push(`**${issue.title}**${policyRef}`)
lines.push('')
lines.push(`> ${issue.description}`)
lines.push('')
lines.push(`Empfehlung: ${issue.recommendation}`)
lines.push('')
lines.push('---')
lines.push('')
}
}
}
// Recommendations summary
lines.push('## Zusammenfassung der Empfehlungen')
lines.push('')
if (result.stats.bySeverity.CRITICAL > 0) {
lines.push(
`1. **Sofortmassnahmen erforderlich:** ${result.stats.bySeverity.CRITICAL} kritische(r) Befund(e) muessen umgehend behoben werden (Legal Hold-Konflikte).`
)
}
if (result.stats.bySeverity.HIGH > 0) {
lines.push(
`${result.stats.bySeverity.CRITICAL > 0 ? '2' : '1'}. **Hohe Prioritaet:** ${result.stats.bySeverity.HIGH} Befund(e) mit hoher Prioritaet (fehlende Trigger/Rechtsgrundlagen) sollten zeitnah bearbeitet werden.`
)
}
if (result.stats.bySeverity.MEDIUM > 0) {
lines.push(
`- **Mittlere Prioritaet:** ${result.stats.bySeverity.MEDIUM} Befund(e) betreffen ueberfaellige Pruefungen, fehlende Verantwortlichkeiten oder nicht abgedeckte Datenkategorien.`
)
}
if (result.stats.bySeverity.LOW > 0) {
lines.push(
`- **Niedrige Prioritaet:** ${result.stats.bySeverity.LOW} Befund(e) betreffen veraltete Entwuerfe, die finalisiert oder archiviert werden sollten.`
)
}
if (result.issues.length === 0) {
lines.push(
'Alle Policies sind konform. Stellen Sie sicher, dass die naechsten Pruefungstermine eingehalten werden.'
)
}
lines.push('')
// Footer
lines.push('---')
lines.push('')
lines.push(
'*Dieser Bericht wurde automatisch generiert und ersetzt keine rechtliche Beratung. Die Verantwortung fuer die DSGVO-Konformitaet liegt beim Verantwortlichen (Art. 4 Nr. 7 DSGVO).*'
)
return lines.join('\n')
}
// =============================================================================
// BROWSER DOWNLOAD UTILITY
// =============================================================================
/**
* Loest einen Datei-Download im Browser aus.
* Erstellt ein temporaeres Blob-URL und simuliert einen Link-Klick.
*
* @param content - Der Dateiinhalt als String
* @param filename - Der gewuenschte Dateiname (z.B. "loeschfristen-export.json")
* @param mimeType - Der MIME-Typ (z.B. "application/json", "text/csv;charset=utf-8")
*/
export function downloadFile(
content: string,
filename: string,
mimeType: string
): void {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}

View File

@@ -0,0 +1,538 @@
// =============================================================================
// Loeschfristen Module - Profiling Wizard
// 4-Step Profiling (15 Fragen) zur Generierung von Baseline-Loeschrichtlinien
// =============================================================================
import type { LoeschfristPolicy, StorageLocation } from './loeschfristen-types'
import { BASELINE_TEMPLATES, type BaselineTemplate, templateToPolicy } from './loeschfristen-baseline-catalog'
// =============================================================================
// TYPES
// =============================================================================
export type ProfilingStepId = 'organization' | 'data-categories' | 'systems' | 'special'
export interface ProfilingQuestion {
id: string
step: ProfilingStepId
question: string // German
helpText?: string
type: 'single' | 'multi' | 'boolean' | 'number'
options?: { value: string; label: string }[]
required: boolean
}
export interface ProfilingAnswer {
questionId: string
value: string | string[] | boolean | number
}
export interface ProfilingStep {
id: ProfilingStepId
title: string
description: string
questions: ProfilingQuestion[]
}
export interface ProfilingResult {
matchedTemplates: BaselineTemplate[]
generatedPolicies: LoeschfristPolicy[]
additionalStorageLocations: StorageLocation[]
hasLegalHoldRequirement: boolean
}
// =============================================================================
// PROFILING STEPS (4 Steps, 15 Questions)
// =============================================================================
export const PROFILING_STEPS: ProfilingStep[] = [
// =========================================================================
// Step 1: Organisation (4 Fragen)
// =========================================================================
{
id: 'organization',
title: 'Organisation',
description: 'Allgemeine Informationen zu Ihrem Unternehmen, um branchenspezifische Loeschfristen zu ermitteln.',
questions: [
{
id: 'org-branche',
step: 'organization',
question: 'In welcher Branche ist Ihr Unternehmen taetig?',
helpText: 'Die Branche bestimmt, welche branchenspezifischen Aufbewahrungspflichten relevant sind.',
type: 'single',
options: [
{ value: 'it-software', label: 'IT / Software' },
{ value: 'handel', label: 'Handel' },
{ value: 'dienstleistung', label: 'Dienstleistung' },
{ value: 'gesundheitswesen', label: 'Gesundheitswesen' },
{ value: 'bildung', label: 'Bildung' },
{ value: 'fertigung-industrie', label: 'Fertigung / Industrie' },
{ value: 'finanzwesen', label: 'Finanzwesen' },
{ value: 'oeffentlicher-sektor', label: 'Oeffentlicher Sektor' },
{ value: 'sonstige', label: 'Sonstige' },
],
required: true,
},
{
id: 'org-mitarbeiter',
step: 'organization',
question: 'Wie viele Mitarbeiter hat Ihr Unternehmen?',
helpText: 'Die Unternehmensgroesse beeinflusst den Umfang der erforderlichen Loeschkonzepte.',
type: 'single',
options: [
{ value: '<10', label: 'Weniger als 10' },
{ value: '10-49', label: '10 bis 49' },
{ value: '50-249', label: '50 bis 249' },
{ value: '250+', label: '250 und mehr' },
],
required: true,
},
{
id: 'org-geschaeftsmodell',
step: 'organization',
question: 'Welches Geschaeftsmodell verfolgen Sie?',
helpText: 'B2B und B2C haben unterschiedliche Anforderungen an die Datenhaltung.',
type: 'single',
options: [
{ value: 'b2b', label: 'B2B (Geschaeftskunden)' },
{ value: 'b2c', label: 'B2C (Endkunden)' },
{ value: 'beides', label: 'Beides (B2B und B2C)' },
],
required: true,
},
{
id: 'org-website',
step: 'organization',
question: 'Betreiben Sie eine Website oder Online-Praesenz?',
helpText: 'Websites erzeugen Webserver-Logs und erfordern Cookie-Consent-Verwaltung.',
type: 'boolean',
required: true,
},
],
},
// =========================================================================
// Step 2: Datenkategorien (5 Fragen)
// =========================================================================
{
id: 'data-categories',
title: 'Datenkategorien',
description: 'Welche Arten personenbezogener Daten verarbeiten Sie? Dies bestimmt die relevanten Aufbewahrungsfristen.',
questions: [
{
id: 'data-hr',
step: 'data-categories',
question: 'Verarbeiten Sie HR-/Personaldaten (Personalakten, Gehaltsabrechnungen, Zeiterfassung)?',
helpText: 'Personalakten unterliegen umfangreichen gesetzlichen Aufbewahrungspflichten (bis zu 10 Jahre).',
type: 'boolean',
required: true,
},
{
id: 'data-buchhaltung',
step: 'data-categories',
question: 'Fuehren Sie eine Buchhaltung mit Finanzdaten (Rechnungen, Belege, Steuererklarungen)?',
helpText: 'Buchhaltungsunterlagen muessen gemaess HGB und AO bis zu 10 Jahre aufbewahrt werden.',
type: 'boolean',
required: true,
},
{
id: 'data-vertraege',
step: 'data-categories',
question: 'Verwalten Sie Vertraege mit Kunden oder Lieferanten?',
helpText: 'Vertragsunterlagen und Geschaeftsbriefe haben spezifische Aufbewahrungspflichten.',
type: 'boolean',
required: true,
},
{
id: 'data-marketing',
step: 'data-categories',
question: 'Betreiben Sie Marketing-Aktivitaeten (Newsletter, CRM-Kampagnen)?',
helpText: 'Marketing-Einwilligungen und Kontakthistorien muessen dokumentiert und verwaltet werden.',
type: 'boolean',
required: true,
},
{
id: 'data-video',
step: 'data-categories',
question: 'Setzen Sie Videoueberwachung ein?',
helpText: 'Videoueberwachungsdaten haben besonders kurze Loeschfristen (in der Regel 72 Stunden).',
type: 'boolean',
required: true,
},
],
},
// =========================================================================
// Step 3: Systeme (3 Fragen)
// =========================================================================
{
id: 'systems',
title: 'Systeme & Infrastruktur',
description: 'Welche IT-Systeme und Infrastruktur nutzen Sie? Dies beeinflusst die Speicherorte in Ihrem Loeschkonzept.',
questions: [
{
id: 'sys-cloud',
step: 'systems',
question: 'Nutzen Sie Cloud-Dienste zur Datenspeicherung oder -verarbeitung?',
helpText: 'Cloud-Speicherorte muessen in den Loeschrichtlinien als separate Speicherorte dokumentiert werden.',
type: 'boolean',
required: true,
},
{
id: 'sys-backup',
step: 'systems',
question: 'Haben Sie Backup-Systeme im Einsatz?',
helpText: 'Backups erfordern eine eigene Loeschstrategie, da Daten dort nach der primaeren Loeschung weiter existieren koennen.',
type: 'boolean',
required: true,
},
{
id: 'sys-erp',
step: 'systems',
question: 'Setzen Sie ein ERP- oder CRM-System ein?',
helpText: 'ERP-/CRM-Systeme sind haeufig zentrale Speicherorte fuer Kunden- und Geschaeftsdaten.',
type: 'boolean',
required: true,
},
],
},
// =========================================================================
// Step 4: Spezielle Anforderungen (3 Fragen)
// =========================================================================
{
id: 'special',
title: 'Spezielle Anforderungen',
description: 'Gibt es besondere rechtliche oder organisatorische Anforderungen, die Ihr Loeschkonzept beeinflussen?',
questions: [
{
id: 'special-legal-hold',
step: 'special',
question: 'Gibt es Legal-Hold-Anforderungen (z.B. laufende Rechtsstreitigkeiten, behoerdliche Untersuchungen)?',
helpText: 'Bei einem Legal Hold muessen betroffene Daten trotz abgelaufener Loeschfristen aufbewahrt werden.',
type: 'boolean',
required: true,
},
{
id: 'special-archivierung',
step: 'special',
question: 'Benoetigen Sie eine Langzeitarchivierung von Dokumenten?',
helpText: 'Langzeitarchivierung kann ueber die gesetzlichen Mindestfristen hinausgehen und erfordert eine gesonderte Rechtfertigung.',
type: 'boolean',
required: true,
},
{
id: 'special-gesundheit',
step: 'special',
question: 'Verarbeiten Sie Gesundheitsdaten (z.B. Krankmeldungen, Arbeitsmedizin)?',
helpText: 'Gesundheitsdaten sind besonders schuetzenswerte Daten nach Art. 9 DSGVO und unterliegen strengeren Anforderungen.',
type: 'boolean',
required: true,
},
],
},
]
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
/**
* Retrieve the value of a specific answer by question ID.
*/
export function getAnswerValue(answers: ProfilingAnswer[], questionId: string): unknown {
const answer = answers.find(a => a.questionId === questionId)
return answer?.value ?? undefined
}
/**
* Check whether all required questions in a given step have been answered.
*/
export function isStepComplete(answers: ProfilingAnswer[], stepId: ProfilingStepId): boolean {
const step = PROFILING_STEPS.find(s => s.id === stepId)
if (!step) return false
return step.questions
.filter(q => q.required)
.every(q => {
const answer = answers.find(a => a.questionId === q.id)
if (!answer) return false
// Check that the value is not empty
const val = answer.value
if (val === undefined || val === null) return false
if (typeof val === 'string' && val.trim() === '') return false
if (Array.isArray(val) && val.length === 0) return false
return true
})
}
/**
* Calculate overall profiling progress as a percentage (0-100).
*/
export function getProfilingProgress(answers: ProfilingAnswer[]): number {
const totalRequired = PROFILING_STEPS.reduce(
(sum, step) => sum + step.questions.filter(q => q.required).length,
0
)
if (totalRequired === 0) return 100
const answeredRequired = PROFILING_STEPS.reduce((sum, step) => {
return (
sum +
step.questions.filter(q => q.required).filter(q => {
const answer = answers.find(a => a.questionId === q.id)
if (!answer) return false
const val = answer.value
if (val === undefined || val === null) return false
if (typeof val === 'string' && val.trim() === '') return false
if (Array.isArray(val) && val.length === 0) return false
return true
}).length
)
}, 0)
return Math.round((answeredRequired / totalRequired) * 100)
}
// =============================================================================
// CORE GENERATOR
// =============================================================================
/**
* Generate deletion policies based on the profiling answers.
*
* Logic:
* - Match baseline templates based on boolean and categorical answers
* - Deduplicate matched templates by templateId
* - Convert matched templates to full LoeschfristPolicy objects
* - Add additional storage locations (Cloud, Backup) if applicable
* - Detect legal hold requirements
*/
export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): ProfilingResult {
const matchedTemplateIds = new Set<string>()
// -------------------------------------------------------------------------
// Helper to get a boolean answer
// -------------------------------------------------------------------------
const getBool = (questionId: string): boolean => {
const val = getAnswerValue(answers, questionId)
return val === true
}
const getString = (questionId: string): string => {
const val = getAnswerValue(answers, questionId)
return typeof val === 'string' ? val : ''
}
// -------------------------------------------------------------------------
// Always-included templates (universally recommended)
// -------------------------------------------------------------------------
matchedTemplateIds.add('protokolle-gesellschafter')
// -------------------------------------------------------------------------
// HR data (data-hr = true)
// -------------------------------------------------------------------------
if (getBool('data-hr')) {
matchedTemplateIds.add('personal-akten')
matchedTemplateIds.add('gehaltsabrechnungen')
matchedTemplateIds.add('zeiterfassung')
matchedTemplateIds.add('bewerbungsunterlagen')
matchedTemplateIds.add('krankmeldungen')
}
// -------------------------------------------------------------------------
// Buchhaltung (data-buchhaltung = true)
// -------------------------------------------------------------------------
if (getBool('data-buchhaltung')) {
matchedTemplateIds.add('buchhaltungsbelege')
matchedTemplateIds.add('rechnungen')
matchedTemplateIds.add('steuererklaerungen')
}
// -------------------------------------------------------------------------
// Vertraege (data-vertraege = true)
// -------------------------------------------------------------------------
if (getBool('data-vertraege')) {
matchedTemplateIds.add('vertraege')
matchedTemplateIds.add('geschaeftsbriefe')
matchedTemplateIds.add('kundenstammdaten')
}
// -------------------------------------------------------------------------
// Marketing (data-marketing = true)
// -------------------------------------------------------------------------
if (getBool('data-marketing')) {
matchedTemplateIds.add('newsletter-einwilligungen')
matchedTemplateIds.add('crm-kontakthistorie')
matchedTemplateIds.add('cookie-consent-logs')
}
// -------------------------------------------------------------------------
// Video (data-video = true)
// -------------------------------------------------------------------------
if (getBool('data-video')) {
matchedTemplateIds.add('videoueberwachung')
}
// -------------------------------------------------------------------------
// Website (org-website = true)
// -------------------------------------------------------------------------
if (getBool('org-website')) {
matchedTemplateIds.add('webserver-logs')
matchedTemplateIds.add('cookie-consent-logs')
}
// -------------------------------------------------------------------------
// ERP/CRM (sys-erp = true)
// -------------------------------------------------------------------------
if (getBool('sys-erp')) {
matchedTemplateIds.add('kundenstammdaten')
matchedTemplateIds.add('crm-kontakthistorie')
}
// -------------------------------------------------------------------------
// Backup (sys-backup = true)
// -------------------------------------------------------------------------
if (getBool('sys-backup')) {
matchedTemplateIds.add('backup-daten')
}
// -------------------------------------------------------------------------
// Gesundheitsdaten (special-gesundheit = true)
// -------------------------------------------------------------------------
if (getBool('special-gesundheit')) {
// Ensure krankmeldungen is included even without full HR data
matchedTemplateIds.add('krankmeldungen')
}
// -------------------------------------------------------------------------
// Resolve matched templates from catalog
// -------------------------------------------------------------------------
const matchedTemplates: BaselineTemplate[] = []
for (const templateId of matchedTemplateIds) {
const template = BASELINE_TEMPLATES.find(t => t.templateId === templateId)
if (template) {
matchedTemplates.push(template)
}
}
// -------------------------------------------------------------------------
// Convert to policies
// -------------------------------------------------------------------------
const generatedPolicies: LoeschfristPolicy[] = matchedTemplates.map(template =>
templateToPolicy(template)
)
// -------------------------------------------------------------------------
// Additional storage locations
// -------------------------------------------------------------------------
const additionalStorageLocations: StorageLocation[] = []
if (getBool('sys-cloud')) {
const cloudLocation: StorageLocation = {
id: crypto.randomUUID(),
name: 'Cloud-Speicher',
type: 'CLOUD',
isBackup: false,
provider: null,
deletionCapable: true,
}
additionalStorageLocations.push(cloudLocation)
// Add Cloud storage location to all generated policies
for (const policy of generatedPolicies) {
policy.storageLocations.push({ ...cloudLocation, id: crypto.randomUUID() })
}
}
if (getBool('sys-backup')) {
const backupLocation: StorageLocation = {
id: crypto.randomUUID(),
name: 'Backup-System',
type: 'BACKUP',
isBackup: true,
provider: null,
deletionCapable: true,
}
additionalStorageLocations.push(backupLocation)
// Add Backup storage location to all generated policies
for (const policy of generatedPolicies) {
policy.storageLocations.push({ ...backupLocation, id: crypto.randomUUID() })
}
}
// -------------------------------------------------------------------------
// Legal Hold
// -------------------------------------------------------------------------
const hasLegalHoldRequirement = getBool('special-legal-hold')
// If legal hold is active, mark all generated policies accordingly
if (hasLegalHoldRequirement) {
for (const policy of generatedPolicies) {
policy.hasActiveLegalHold = true
policy.deletionTrigger = 'LEGAL_HOLD'
}
}
// -------------------------------------------------------------------------
// Tag policies with profiling metadata
// -------------------------------------------------------------------------
const branche = getString('org-branche')
const mitarbeiter = getString('org-mitarbeiter')
for (const policy of generatedPolicies) {
policy.tags = [
...policy.tags,
'profiling-generated',
...(branche ? [`branche:${branche}`] : []),
...(mitarbeiter ? [`groesse:${mitarbeiter}`] : []),
]
}
return {
matchedTemplates,
generatedPolicies,
additionalStorageLocations,
hasLegalHoldRequirement,
}
}
// =============================================================================
// COMPLIANCE SCOPE INTEGRATION
// =============================================================================
/**
* Prefill Loeschfristen profiling answers from Compliance Scope Engine answers.
* The Scope Engine acts as the "Single Source of Truth" for organizational questions.
*/
export function prefillFromScopeAnswers(
scopeAnswers: import('./compliance-scope-types').ScopeProfilingAnswer[]
): ProfilingAnswer[] {
const { exportToLoeschfristenAnswers } = require('./compliance-scope-profiling')
const exported = exportToLoeschfristenAnswers(scopeAnswers) as Array<{ questionId: string; value: unknown }>
return exported.map(item => ({
questionId: item.questionId,
value: item.value as string | string[] | boolean | number,
}))
}
/**
* Get the list of Loeschfristen question IDs that are prefilled from Scope answers.
* These questions should show "Aus Scope-Analyse uebernommen" hint.
*/
export const SCOPE_PREFILLED_LF_QUESTIONS = [
'org-branche',
'org-mitarbeiter',
'org-geschaeftsmodell',
'org-website',
'data-hr',
'data-buchhaltung',
'data-vertraege',
'data-marketing',
'data-video',
'sys-cloud',
'sys-erp',
]

View File

@@ -0,0 +1,346 @@
// =============================================================================
// Loeschfristen Module - TypeScript Types
// 3-Level Loeschlogik: Zweckende -> Aufbewahrungstreiber -> Legal Hold
// =============================================================================
// =============================================================================
// ENUMS & LITERAL TYPES
// =============================================================================
export type DeletionTriggerLevel = 'PURPOSE_END' | 'RETENTION_DRIVER' | 'LEGAL_HOLD'
export type RetentionDriverType =
| 'AO_147' // 10 Jahre Steuerunterlagen
| 'HGB_257' // 10/6 Jahre Handelsbuecher/-briefe
| 'USTG_14B' // 10 Jahre Rechnungen
| 'BGB_195' // 3 Jahre Verjaehrung
| 'ARBZG_16' // 2 Jahre Zeiterfassung
| 'AGG_15' // 6 Monate Bewerbungen
| 'BDSG_35' // Unverzuegliche Loeschung
| 'BSIG' // 90 Tage Sicherheitslogs
| 'CUSTOM'
export type DeletionMethodType =
| 'AUTO_DELETE'
| 'MANUAL_REVIEW_DELETE'
| 'ANONYMIZATION'
| 'AGGREGATION'
| 'CRYPTO_ERASE'
| 'PHYSICAL_DESTROY'
export type PolicyStatus = 'DRAFT' | 'ACTIVE' | 'REVIEW_NEEDED' | 'ARCHIVED'
export type ReviewInterval = 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL'
export type RetentionUnit = 'DAYS' | 'MONTHS' | 'YEARS'
export type StorageLocationType =
| 'DATABASE' | 'FILE_SYSTEM' | 'CLOUD' | 'EMAIL' | 'BACKUP' | 'PAPER' | 'OTHER'
export type LegalHoldStatus = 'ACTIVE' | 'RELEASED' | 'EXPIRED'
// =============================================================================
// INTERFACES
// =============================================================================
export interface LegalHold {
id: string
reason: string
legalBasis: string
responsiblePerson: string
startDate: string
expectedEndDate: string | null
actualEndDate: string | null
status: LegalHoldStatus
affectedDataCategories: string[]
}
export interface StorageLocation {
id: string
name: string
type: StorageLocationType
isBackup: boolean
provider: string | null
deletionCapable: boolean
}
export interface LoeschfristPolicy {
id: string
policyId: string // LF-2026-001
dataObjectName: string
description: string
affectedGroups: string[]
dataCategories: string[]
primaryPurpose: string
// 3-Level Loeschlogik
deletionTrigger: DeletionTriggerLevel
retentionDriver: RetentionDriverType | null
retentionDriverDetail: string
retentionDuration: number | null
retentionUnit: RetentionUnit | null
retentionDescription: string
startEvent: string
hasActiveLegalHold: boolean
legalHolds: LegalHold[]
// Speicherorte & Loeschung
storageLocations: StorageLocation[]
deletionMethod: DeletionMethodType
deletionMethodDetail: string
// Verantwortung & Workflow
responsibleRole: string
responsiblePerson: string
releaseProcess: string
linkedVVTActivityIds: string[]
// Status & Review
status: PolicyStatus
lastReviewDate: string
nextReviewDate: string
reviewInterval: ReviewInterval
tags: string[]
createdAt: string
updatedAt: string
}
// =============================================================================
// CONSTANTS
// =============================================================================
export interface RetentionDriverMeta {
label: string
statute: string
defaultDuration: number | null
defaultUnit: RetentionUnit | null
description: string
}
export const RETENTION_DRIVER_META: Record<RetentionDriverType, RetentionDriverMeta> = {
AO_147: {
label: 'Abgabenordnung (AO) 147',
statute: '147 AO',
defaultDuration: 10,
defaultUnit: 'YEARS',
description: 'Aufbewahrung steuerrelevanter Unterlagen (Buchungsbelege, Bilanzen, Jahresabschluesse)',
},
HGB_257: {
label: 'Handelsgesetzbuch (HGB) 257',
statute: '257 HGB',
defaultDuration: 10,
defaultUnit: 'YEARS',
description: 'Handelsbuecher und Buchungsbelege (10 J.), empfangene/gesendete Handelsbriefe (6 J.)',
},
USTG_14B: {
label: 'Umsatzsteuergesetz (UStG) 14b',
statute: '14b UStG',
defaultDuration: 10,
defaultUnit: 'YEARS',
description: 'Aufbewahrung von Rechnungen und rechnungsbegruendenden Unterlagen',
},
BGB_195: {
label: 'Buergerliches Gesetzbuch (BGB) 195',
statute: '195 BGB',
defaultDuration: 3,
defaultUnit: 'YEARS',
description: 'Regelmaessige Verjaehrungsfrist fuer vertragliche Ansprueche',
},
ARBZG_16: {
label: 'Arbeitszeitgesetz (ArbZG) 16',
statute: '16 Abs. 2 ArbZG',
defaultDuration: 2,
defaultUnit: 'YEARS',
description: 'Aufbewahrung von Arbeitszeitaufzeichnungen',
},
AGG_15: {
label: 'Allg. Gleichbehandlungsgesetz (AGG) 15',
statute: '15 Abs. 4 AGG',
defaultDuration: 6,
defaultUnit: 'MONTHS',
description: 'Frist fuer Geltendmachung von Entschaedigungsanspruechen nach Absage',
},
BDSG_35: {
label: 'BDSG 35 / DSGVO Art. 17',
statute: '35 BDSG / Art. 17 DSGVO',
defaultDuration: null,
defaultUnit: null,
description: 'Unverzuegliche Loeschung nach Zweckwegfall (kein fester Zeitraum)',
},
BSIG: {
label: 'BSI-Gesetz (BSIG)',
statute: 'BSIG / IT-SiG 2.0',
defaultDuration: 90,
defaultUnit: 'DAYS',
description: 'Aufbewahrung von Sicherheitslogs fuer Vorfallsanalyse',
},
CUSTOM: {
label: 'Individuelle Frist',
statute: 'Individuell',
defaultDuration: null,
defaultUnit: null,
description: 'Benutzerdefinierte Aufbewahrungsfrist',
},
}
export const DELETION_METHOD_LABELS: Record<DeletionMethodType, string> = {
AUTO_DELETE: 'Automatische Loeschung',
MANUAL_REVIEW_DELETE: 'Manuelle Pruefung & Loeschung',
ANONYMIZATION: 'Anonymisierung',
AGGREGATION: 'Aggregation (statistische Verdichtung)',
CRYPTO_ERASE: 'Kryptographische Loeschung',
PHYSICAL_DESTROY: 'Physische Vernichtung',
}
export const STATUS_LABELS: Record<PolicyStatus, string> = {
DRAFT: 'Entwurf',
ACTIVE: 'Aktiv',
REVIEW_NEEDED: 'Pruefung erforderlich',
ARCHIVED: 'Archiviert',
}
export const STATUS_COLORS: Record<PolicyStatus, string> = {
DRAFT: 'bg-gray-100 text-gray-700 border-gray-200',
ACTIVE: 'bg-green-100 text-green-700 border-green-200',
REVIEW_NEEDED: 'bg-yellow-100 text-yellow-700 border-yellow-200',
ARCHIVED: 'bg-blue-100 text-blue-700 border-blue-200',
}
export const TRIGGER_LABELS: Record<DeletionTriggerLevel, string> = {
PURPOSE_END: 'Zweckende',
RETENTION_DRIVER: 'Aufbewahrungspflicht',
LEGAL_HOLD: 'Legal Hold',
}
export const TRIGGER_COLORS: Record<DeletionTriggerLevel, string> = {
PURPOSE_END: 'bg-green-100 text-green-700',
RETENTION_DRIVER: 'bg-blue-100 text-blue-700',
LEGAL_HOLD: 'bg-red-100 text-red-700',
}
export const REVIEW_INTERVAL_LABELS: Record<ReviewInterval, string> = {
QUARTERLY: 'Vierteljaehrlich',
SEMI_ANNUAL: 'Halbjaehrlich',
ANNUAL: 'Jaehrlich',
}
export const STORAGE_LOCATION_LABELS: Record<StorageLocationType, string> = {
DATABASE: 'Datenbank',
FILE_SYSTEM: 'Dateisystem',
CLOUD: 'Cloud-Speicher',
EMAIL: 'E-Mail-System',
BACKUP: 'Backup-System',
PAPER: 'Papierarchiv',
OTHER: 'Sonstiges',
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
let policyCounter = 0
export function generatePolicyId(): string {
policyCounter++
const year = new Date().getFullYear()
const num = String(policyCounter).padStart(3, '0')
return `LF-${year}-${num}`
}
export function createEmptyPolicy(): LoeschfristPolicy {
const now = new Date().toISOString()
const nextYear = new Date()
nextYear.setFullYear(nextYear.getFullYear() + 1)
return {
id: crypto.randomUUID(),
policyId: generatePolicyId(),
dataObjectName: '',
description: '',
affectedGroups: [],
dataCategories: [],
primaryPurpose: '',
deletionTrigger: 'PURPOSE_END',
retentionDriver: null,
retentionDriverDetail: '',
retentionDuration: null,
retentionUnit: null,
retentionDescription: '',
startEvent: '',
hasActiveLegalHold: false,
legalHolds: [],
storageLocations: [],
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail: '',
responsibleRole: '',
responsiblePerson: '',
releaseProcess: '',
linkedVVTActivityIds: [],
status: 'DRAFT',
lastReviewDate: now,
nextReviewDate: nextYear.toISOString(),
reviewInterval: 'ANNUAL',
tags: [],
createdAt: now,
updatedAt: now,
}
}
export function createEmptyLegalHold(): LegalHold {
return {
id: crypto.randomUUID(),
reason: '',
legalBasis: '',
responsiblePerson: '',
startDate: new Date().toISOString().split('T')[0],
expectedEndDate: null,
actualEndDate: null,
status: 'ACTIVE',
affectedDataCategories: [],
}
}
export function createEmptyStorageLocation(): StorageLocation {
return {
id: crypto.randomUUID(),
name: '',
type: 'DATABASE',
isBackup: false,
provider: null,
deletionCapable: true,
}
}
export function formatRetentionDuration(
duration: number | null,
unit: RetentionUnit | null
): string {
if (duration === null || unit === null) return 'Bis Zweckwegfall'
const unitLabels: Record<RetentionUnit, string> = {
DAYS: duration === 1 ? 'Tag' : 'Tage',
MONTHS: duration === 1 ? 'Monat' : 'Monate',
YEARS: duration === 1 ? 'Jahr' : 'Jahre',
}
return `${duration} ${unitLabels[unit]}`
}
export function isPolicyOverdue(policy: LoeschfristPolicy): boolean {
if (!policy.nextReviewDate) return false
return new Date(policy.nextReviewDate) <= new Date()
}
export function getActiveLegalHolds(policy: LoeschfristPolicy): LegalHold[] {
return policy.legalHolds.filter(h => h.status === 'ACTIVE')
}
export function getEffectiveDeletionTrigger(policy: LoeschfristPolicy): DeletionTriggerLevel {
if (policy.hasActiveLegalHold && getActiveLegalHolds(policy).length > 0) {
return 'LEGAL_HOLD'
}
if (policy.retentionDriver && policy.retentionDriver !== 'CUSTOM') {
return 'RETENTION_DRIVER'
}
return 'PURPOSE_END'
}
// =============================================================================
// LOCALSTORAGE KEY
// =============================================================================
export const LOESCHFRISTEN_STORAGE_KEY = 'bp_loeschfristen'

View File

@@ -63,6 +63,7 @@ type TOMGeneratorAction =
| { type: 'UPDATE_DERIVED_TOM'; payload: { id: string; data: Partial<DerivedTOM> } }
| { type: 'SET_GAP_ANALYSIS'; payload: GapAnalysisResult }
| { type: 'ADD_EXPORT'; payload: ExportRecord }
| { type: 'BULK_UPDATE_TOMS'; payload: { updates: Array<{ id: string; data: Partial<DerivedTOM> }> } }
| { type: 'LOAD_STATE'; payload: TOMGeneratorState }
// =============================================================================
@@ -236,6 +237,16 @@ function tomGeneratorReducer(
})
}
case 'BULK_UPDATE_TOMS': {
let updatedTOMs = [...state.derivedTOMs]
for (const update of action.payload.updates) {
updatedTOMs = updatedTOMs.map((tom) =>
tom.id === update.id ? { ...tom, ...update.data } : tom
)
}
return updateState({ derivedTOMs: updatedTOMs })
}
case 'LOAD_STATE': {
return action.payload
}
@@ -283,6 +294,7 @@ interface TOMGeneratorContextValue {
// TOM derivation
deriveTOMs: () => void
updateDerivedTOM: (id: string, data: Partial<DerivedTOM>) => void
bulkUpdateTOMs: (updates: Array<{ id: string; data: Partial<DerivedTOM> }>) => void
// Gap analysis
runGapAnalysis: () => void

View File

@@ -2072,6 +2072,287 @@ const CONTROL_LIBRARY_DATA: ControlLibrary = {
complexity: 'HIGH',
tags: ['dpia', 'dsfa', 'risk-assessment'],
},
// =========================================================================
// DELETION / VERNICHTUNG — Sichere Datenloeschung & Datentraegervernichtung
// =========================================================================
{
id: 'TOM-DL-01',
code: 'TOM-DL-01',
category: 'SEPARATION',
type: 'TECHNICAL',
name: {
de: 'Sichere Datenloeschung',
en: 'Secure Data Deletion',
},
description: {
de: 'Implementierung sicherer Loeschverfahren, die personenbezogene Daten unwiederbringlich entfernen (z.B. nach DIN 66399).',
en: 'Implementation of secure deletion procedures that irrecoverably remove personal data (e.g. per DIN 66399).',
},
mappings: [
{ framework: 'GDPR_ART17', reference: 'Art. 17' },
{ framework: 'GDPR_ART5', reference: 'Art. 5 Abs. 1 lit. e' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.8.10' },
{ framework: 'BSI_C5', reference: 'SY-09' },
],
applicabilityConditions: [
{
field: 'dataProfile.dataVolume',
operator: 'NOT_EQUALS',
value: 'NONE',
result: 'REQUIRED',
priority: 30,
},
],
defaultApplicability: 'REQUIRED',
evidenceRequirements: [
'Loeschkonzept / Loeschrichtlinie',
'Loeschprotokolle mit Zeitstempeln',
'DIN 66399 Konformitaetsnachweis',
],
reviewFrequency: 'ANNUAL',
priority: 'HIGH',
complexity: 'MEDIUM',
tags: ['deletion', 'loeschung', 'data-lifecycle', 'din-66399'],
},
{
id: 'TOM-DL-02',
code: 'TOM-DL-02',
category: 'SEPARATION',
type: 'TECHNICAL',
name: {
de: 'Datentraegervernichtung',
en: 'Media Destruction',
},
description: {
de: 'Physische Vernichtung von Datentraegern (Festplatten, SSDs, USB-Sticks, Papier) gemaess DIN 66399 Schutzklassen.',
en: 'Physical destruction of storage media (hard drives, SSDs, USB sticks, paper) per DIN 66399 protection classes.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.7.14' },
{ framework: 'BSI_C5', reference: 'AM-08' },
],
applicabilityConditions: [
{
field: 'dataProfile.dataVolume',
operator: 'NOT_EQUALS',
value: 'NONE',
result: 'RECOMMENDED',
priority: 20,
},
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: [
'Vernichtungsprotokoll mit Seriennummern',
'Zertifikat des Vernichtungsdienstleisters',
'DIN 66399 Sicherheitsstufe-Nachweis',
],
reviewFrequency: 'ANNUAL',
priority: 'MEDIUM',
complexity: 'LOW',
tags: ['deletion', 'media-destruction', 'physical-security', 'din-66399'],
},
{
id: 'TOM-DL-03',
code: 'TOM-DL-03',
category: 'SEPARATION',
type: 'ORGANIZATIONAL',
name: {
de: 'Loeschprotokollierung',
en: 'Deletion Logging',
},
description: {
de: 'Systematische Protokollierung aller Loeschvorgaenge mit Zeitstempel, Verantwortlichem, Datenobjekt und Loeschmethode.',
en: 'Systematic logging of all deletion operations with timestamp, responsible person, data object, and deletion method.',
},
mappings: [
{ framework: 'GDPR_ART5', reference: 'Art. 5 Abs. 2 (Rechenschaftspflicht)' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.8.10' },
],
applicabilityConditions: [
{
field: 'dataProfile.dataVolume',
operator: 'NOT_EQUALS',
value: 'NONE',
result: 'REQUIRED',
priority: 25,
},
],
defaultApplicability: 'REQUIRED',
evidenceRequirements: [
'Loeschprotokoll-Template',
'Archivierte Loeschprotokolle (Stichprobe)',
'Automatisierungsnachweis (bei automatischen Loeschungen)',
],
reviewFrequency: 'SEMI_ANNUAL',
priority: 'HIGH',
complexity: 'LOW',
tags: ['deletion', 'logging', 'accountability', 'documentation'],
},
{
id: 'TOM-DL-04',
code: 'TOM-DL-04',
category: 'SEPARATION',
type: 'TECHNICAL',
name: {
de: 'Backup-Bereinigung',
en: 'Backup Sanitization',
},
description: {
de: 'Sicherstellung, dass personenbezogene Daten auch in Backup-Systemen nach Ablauf der Loeschfrist entfernt werden.',
en: 'Ensuring that personal data is also removed from backup systems after the retention period expires.',
},
mappings: [
{ framework: 'GDPR_ART17', reference: 'Art. 17 Abs. 2' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.8.13' },
],
applicabilityConditions: [
{
field: 'techProfile.hasBackups',
operator: 'EQUALS',
value: true,
result: 'REQUIRED',
priority: 25,
},
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: [
'Backup-Loeschkonzept',
'Backup-Rotationsplan',
'Nachweis der Backup-Bereinigung',
],
reviewFrequency: 'SEMI_ANNUAL',
priority: 'MEDIUM',
complexity: 'HIGH',
tags: ['deletion', 'backup', 'data-lifecycle', 'retention'],
},
// =========================================================================
// SCHULUNG / VERTRAULICHKEIT — Training & Awareness
// =========================================================================
{
id: 'TOM-TR-01',
code: 'TOM-TR-01',
category: 'REVIEW',
type: 'ORGANIZATIONAL',
name: {
de: 'Datenschutzschulung',
en: 'Data Protection Training',
},
description: {
de: 'Regelmaessige Schulung aller Mitarbeiter zu Datenschutzgrundlagen, DSGVO-Anforderungen und betrieblichen Datenschutzrichtlinien.',
en: 'Regular training of all employees on data protection fundamentals, GDPR requirements, and organizational data protection policies.',
},
mappings: [
{ framework: 'GDPR_ART39', reference: 'Art. 39 Abs. 1 lit. b' },
{ framework: 'GDPR_ART47', reference: 'Art. 47 Abs. 2 lit. n' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.6.3' },
],
applicabilityConditions: [
{
field: 'orgProfile.employeeCount',
operator: 'GREATER_THAN',
value: 0,
result: 'REQUIRED',
priority: 30,
},
],
defaultApplicability: 'REQUIRED',
evidenceRequirements: [
'Schulungsplan (jaehrlich)',
'Teilnahmelisten / Schulungsnachweise',
'Schulungsmaterialien / Praesentation',
'Wissenstest-Ergebnisse (optional)',
],
reviewFrequency: 'ANNUAL',
priority: 'HIGH',
complexity: 'LOW',
tags: ['training', 'schulung', 'awareness', 'organizational'],
},
{
id: 'TOM-TR-02',
code: 'TOM-TR-02',
category: 'REVIEW',
type: 'ORGANIZATIONAL',
name: {
de: 'Verpflichtung auf Datengeheimnis',
en: 'Confidentiality Obligation',
},
description: {
de: 'Schriftliche Verpflichtung aller Mitarbeiter und externen Dienstleister auf die Vertraulichkeit personenbezogener Daten.',
en: 'Written obligation of all employees and external service providers to maintain confidentiality of personal data.',
},
mappings: [
{ framework: 'GDPR_ART28', reference: 'Art. 28 Abs. 3 lit. b' },
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 4' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.6.6' },
],
applicabilityConditions: [
{
field: 'orgProfile.employeeCount',
operator: 'GREATER_THAN',
value: 0,
result: 'REQUIRED',
priority: 30,
},
],
defaultApplicability: 'REQUIRED',
evidenceRequirements: [
'Muster-Verpflichtungserklaerung',
'Unterschriebene Verpflichtungserklaerungen',
'Register der verpflichteten Personen',
],
reviewFrequency: 'ANNUAL',
priority: 'HIGH',
complexity: 'LOW',
tags: ['training', 'confidentiality', 'vertraulichkeit', 'obligation'],
},
{
id: 'TOM-TR-03',
code: 'TOM-TR-03',
category: 'REVIEW',
type: 'ORGANIZATIONAL',
name: {
de: 'Security Awareness Programm',
en: 'Security Awareness Program',
},
description: {
de: 'Fortlaufendes Awareness-Programm zu IT-Sicherheit, Phishing-Erkennung, Social Engineering und sicherem Umgang mit Daten.',
en: 'Ongoing awareness program on IT security, phishing detection, social engineering, and safe data handling.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. d' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.6.3' },
{ framework: 'BSI_C5', reference: 'ORP.3' },
],
applicabilityConditions: [
{
field: 'orgProfile.employeeCount',
operator: 'GREATER_THAN',
value: 10,
result: 'REQUIRED',
priority: 20,
},
{
field: 'orgProfile.employeeCount',
operator: 'GREATER_THAN',
value: 0,
result: 'RECOMMENDED',
priority: 15,
},
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: [
'Awareness-Programm-Dokumentation',
'Phishing-Simulationsergebnisse',
'Teilnahmenachweise',
],
reviewFrequency: 'SEMI_ANNUAL',
priority: 'MEDIUM',
complexity: 'MEDIUM',
tags: ['training', 'security-awareness', 'phishing', 'social-engineering'],
},
],
}

View File

@@ -0,0 +1,192 @@
// =============================================================================
// SDM (Standard-Datenschutzmodell) Mapping
// Maps ControlCategories to SDM Gewaehrleistungsziele and Spec Modules
// =============================================================================
import { ControlCategory } from './types'
// =============================================================================
// TYPES
// =============================================================================
export type SDMGewaehrleistungsziel =
| 'Verfuegbarkeit'
| 'Integritaet'
| 'Vertraulichkeit'
| 'Nichtverkettung'
| 'Intervenierbarkeit'
| 'Transparenz'
| 'Datenminimierung'
export type TOMModuleCategory =
| 'IDENTITY_AUTH'
| 'LOGGING'
| 'DOCUMENTATION'
| 'SEPARATION'
| 'RETENTION'
| 'DELETION'
| 'TRAINING'
| 'REVIEW'
export const SDM_GOAL_LABELS: Record<SDMGewaehrleistungsziel, string> = {
Verfuegbarkeit: 'Verfuegbarkeit',
Integritaet: 'Integritaet',
Vertraulichkeit: 'Vertraulichkeit',
Nichtverkettung: 'Nichtverkettung',
Intervenierbarkeit: 'Intervenierbarkeit',
Transparenz: 'Transparenz',
Datenminimierung: 'Datenminimierung',
}
export const SDM_GOAL_DESCRIPTIONS: Record<SDMGewaehrleistungsziel, string> = {
Verfuegbarkeit: 'Personenbezogene Daten muessen zeitgerecht zur Verfuegung stehen und ordnungsgemaess verarbeitet werden koennen.',
Integritaet: 'Personenbezogene Daten muessen unversehrt, vollstaendig und aktuell bleiben.',
Vertraulichkeit: 'Nur Befugte duerfen personenbezogene Daten zur Kenntnis nehmen.',
Nichtverkettung: 'Daten duerfen nicht ohne Weiteres fuer andere Zwecke zusammengefuehrt werden.',
Intervenierbarkeit: 'Betroffene muessen ihre Rechte wahrnehmen koennen (Auskunft, Berichtigung, Loeschung).',
Transparenz: 'Verarbeitungsvorgaenge muessen nachvollziehbar dokumentiert sein.',
Datenminimierung: 'Nur die fuer den Zweck erforderlichen Daten duerfen verarbeitet werden.',
}
export const MODULE_LABELS: Record<TOMModuleCategory, string> = {
IDENTITY_AUTH: 'Identitaet & Authentifizierung',
LOGGING: 'Protokollierung',
DOCUMENTATION: 'Dokumentation',
SEPARATION: 'Trennung',
RETENTION: 'Aufbewahrung',
DELETION: 'Loeschung & Vernichtung',
TRAINING: 'Schulung & Vertraulichkeit',
REVIEW: 'Ueberpruefung & Bewertung',
}
// =============================================================================
// MAPPINGS
// =============================================================================
/**
* Maps ControlCategory to its primary SDM Gewaehrleistungsziele
*/
export const SDM_CATEGORY_MAPPING: Record<ControlCategory, SDMGewaehrleistungsziel[]> = {
ACCESS_CONTROL: ['Vertraulichkeit'],
ADMISSION_CONTROL: ['Vertraulichkeit', 'Integritaet'],
ACCESS_AUTHORIZATION: ['Vertraulichkeit', 'Nichtverkettung'],
TRANSFER_CONTROL: ['Vertraulichkeit', 'Integritaet'],
INPUT_CONTROL: ['Integritaet', 'Transparenz'],
ORDER_CONTROL: ['Transparenz', 'Intervenierbarkeit'],
AVAILABILITY: ['Verfuegbarkeit'],
SEPARATION: ['Nichtverkettung', 'Datenminimierung'],
ENCRYPTION: ['Vertraulichkeit', 'Integritaet'],
PSEUDONYMIZATION: ['Datenminimierung', 'Nichtverkettung'],
RESILIENCE: ['Verfuegbarkeit'],
RECOVERY: ['Verfuegbarkeit', 'Integritaet'],
REVIEW: ['Transparenz', 'Intervenierbarkeit'],
}
/**
* Maps ControlCategory to Spec Module Categories
*/
export const MODULE_CATEGORY_MAPPING: Record<ControlCategory, TOMModuleCategory[]> = {
ACCESS_CONTROL: ['IDENTITY_AUTH'],
ADMISSION_CONTROL: ['IDENTITY_AUTH'],
ACCESS_AUTHORIZATION: ['IDENTITY_AUTH', 'DOCUMENTATION'],
TRANSFER_CONTROL: ['DOCUMENTATION'],
INPUT_CONTROL: ['LOGGING'],
ORDER_CONTROL: ['DOCUMENTATION'],
AVAILABILITY: ['REVIEW'],
SEPARATION: ['SEPARATION'],
ENCRYPTION: ['IDENTITY_AUTH'],
PSEUDONYMIZATION: ['SEPARATION', 'DELETION'],
RESILIENCE: ['REVIEW'],
RECOVERY: ['REVIEW'],
REVIEW: ['REVIEW', 'TRAINING'],
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
import type { DerivedTOM, ControlLibraryEntry } from './types'
import { getControlById } from './controls/loader'
/**
* Get SDM goals for a given control (by looking up its category)
*/
export function getSDMGoalsForControl(controlId: string): SDMGewaehrleistungsziel[] {
const control = getControlById(controlId)
if (!control) return []
return SDM_CATEGORY_MAPPING[control.category] || []
}
/**
* Get derived TOMs that map to a specific SDM goal
*/
export function getTOMsBySDMGoal(
toms: DerivedTOM[],
goal: SDMGewaehrleistungsziel
): DerivedTOM[] {
return toms.filter(tom => {
const goals = getSDMGoalsForControl(tom.controlId)
return goals.includes(goal)
})
}
/**
* Get derived TOMs belonging to a specific module
*/
export function getTOMsByModule(
toms: DerivedTOM[],
module: TOMModuleCategory
): DerivedTOM[] {
return toms.filter(tom => {
const control = getControlById(tom.controlId)
if (!control) return false
const modules = MODULE_CATEGORY_MAPPING[control.category] || []
return modules.includes(module)
})
}
/**
* Get SDM goal coverage statistics
*/
export function getSDMCoverageStats(toms: DerivedTOM[]): Record<SDMGewaehrleistungsziel, {
total: number
implemented: number
partial: number
missing: number
}> {
const goals = Object.keys(SDM_GOAL_LABELS) as SDMGewaehrleistungsziel[]
const stats = {} as Record<SDMGewaehrleistungsziel, { total: number; implemented: number; partial: number; missing: number }>
for (const goal of goals) {
const goalTOMs = getTOMsBySDMGoal(toms, goal)
stats[goal] = {
total: goalTOMs.length,
implemented: goalTOMs.filter(t => t.implementationStatus === 'IMPLEMENTED').length,
partial: goalTOMs.filter(t => t.implementationStatus === 'PARTIAL').length,
missing: goalTOMs.filter(t => t.implementationStatus === 'NOT_IMPLEMENTED').length,
}
}
return stats
}
/**
* Get module coverage statistics
*/
export function getModuleCoverageStats(toms: DerivedTOM[]): Record<TOMModuleCategory, {
total: number
implemented: number
}> {
const modules = Object.keys(MODULE_LABELS) as TOMModuleCategory[]
const stats = {} as Record<TOMModuleCategory, { total: number; implemented: number }>
for (const mod of modules) {
const modTOMs = getTOMsByModule(toms, mod)
stats[mod] = {
total: modTOMs.length,
implemented: modTOMs.filter(t => t.implementationStatus === 'IMPLEMENTED').length,
}
}
return stats
}

View File

@@ -899,3 +899,65 @@ export function createInitialTOMGeneratorState(
* Alias for createInitialTOMGeneratorState (for API compatibility)
*/
export const createEmptyTOMGeneratorState = createInitialTOMGeneratorState
// =============================================================================
// SDM TYPES (Standard-Datenschutzmodell)
// =============================================================================
export type SDMGewaehrleistungsziel =
| 'Verfuegbarkeit'
| 'Integritaet'
| 'Vertraulichkeit'
| 'Nichtverkettung'
| 'Intervenierbarkeit'
| 'Transparenz'
| 'Datenminimierung'
export type TOMModuleCategory =
| 'IDENTITY_AUTH'
| 'LOGGING'
| 'DOCUMENTATION'
| 'SEPARATION'
| 'RETENTION'
| 'DELETION'
| 'TRAINING'
| 'REVIEW'
/**
* Maps ControlCategory to SDM Gewaehrleistungsziele.
* Used by the TOM Dashboard to display SDM coverage.
*/
export const SDM_CATEGORY_MAPPING: Record<ControlCategory, SDMGewaehrleistungsziel[]> = {
ACCESS_CONTROL: ['Vertraulichkeit'],
ADMISSION_CONTROL: ['Vertraulichkeit', 'Integritaet'],
ACCESS_AUTHORIZATION: ['Vertraulichkeit', 'Nichtverkettung'],
TRANSFER_CONTROL: ['Vertraulichkeit', 'Integritaet'],
INPUT_CONTROL: ['Integritaet', 'Transparenz'],
ORDER_CONTROL: ['Transparenz', 'Intervenierbarkeit'],
AVAILABILITY: ['Verfuegbarkeit'],
SEPARATION: ['Nichtverkettung', 'Datenminimierung'],
ENCRYPTION: ['Vertraulichkeit', 'Integritaet'],
PSEUDONYMIZATION: ['Datenminimierung', 'Nichtverkettung'],
RESILIENCE: ['Verfuegbarkeit'],
RECOVERY: ['Verfuegbarkeit', 'Integritaet'],
REVIEW: ['Transparenz', 'Intervenierbarkeit'],
}
/**
* Maps ControlCategory to Spec Module Categories.
*/
export const MODULE_CATEGORY_MAPPING: Record<ControlCategory, TOMModuleCategory[]> = {
ACCESS_CONTROL: ['IDENTITY_AUTH'],
ADMISSION_CONTROL: ['IDENTITY_AUTH'],
ACCESS_AUTHORIZATION: ['IDENTITY_AUTH', 'DOCUMENTATION'],
TRANSFER_CONTROL: ['DOCUMENTATION'],
INPUT_CONTROL: ['LOGGING'],
ORDER_CONTROL: ['DOCUMENTATION'],
AVAILABILITY: ['REVIEW'],
SEPARATION: ['SEPARATION'],
ENCRYPTION: ['IDENTITY_AUTH'],
PSEUDONYMIZATION: ['SEPARATION', 'DELETION'],
RESILIENCE: ['REVIEW'],
RECOVERY: ['REVIEW'],
REVIEW: ['REVIEW', 'TRAINING'],
}

View File

@@ -314,10 +314,23 @@ export const SDK_STEPS: SDKStep[] = [
isOptional: false,
},
{
id: 'use-case-assessment',
id: 'compliance-scope',
phase: 1,
package: 'vorbereitung',
order: 2,
name: 'Compliance Scope',
nameShort: 'Scope',
description: 'Umfang und Tiefe Ihrer Compliance-Dokumentation bestimmen',
url: '/sdk/compliance-scope',
checkpointId: 'CP-SCOPE',
prerequisiteSteps: ['company-profile'],
isOptional: false,
},
{
id: 'use-case-assessment',
phase: 1,
package: 'vorbereitung',
order: 3,
name: 'Anwendungsfall-Erfassung',
nameShort: 'Anwendung',
description: 'AI-Anwendungsfälle strukturiert dokumentieren',
@@ -330,7 +343,7 @@ export const SDK_STEPS: SDKStep[] = [
id: 'import',
phase: 1,
package: 'vorbereitung',
order: 3,
order: 4,
name: 'Dokument-Import',
nameShort: 'Import',
description: 'Bestehende Dokumente hochladen (Bestandskunden)',
@@ -343,7 +356,7 @@ export const SDK_STEPS: SDKStep[] = [
id: 'screening',
phase: 1,
package: 'vorbereitung',
order: 4,
order: 5,
name: 'System Screening',
nameShort: 'Screening',
description: 'SBOM + Security Check',
@@ -356,7 +369,7 @@ export const SDK_STEPS: SDKStep[] = [
id: 'modules',
phase: 1,
package: 'vorbereitung',
order: 5,
order: 6,
name: 'Compliance Modules',
nameShort: 'Module',
description: 'Abgleich welche Regulierungen gelten',
@@ -365,6 +378,19 @@ export const SDK_STEPS: SDKStep[] = [
prerequisiteSteps: ['screening'],
isOptional: false,
},
{
id: 'source-policy',
phase: 1,
package: 'vorbereitung',
order: 7,
name: 'Source Policy',
nameShort: 'Quellen',
description: 'Datenquellen-Governance & Whitelist',
url: '/sdk/source-policy',
checkpointId: 'CP-SPOL',
prerequisiteSteps: ['modules'],
isOptional: false,
},
// =============================================================================
// PAKET 2: ANALYSE (Assessment)
@@ -379,7 +405,7 @@ export const SDK_STEPS: SDKStep[] = [
description: 'Prüfaspekte aus Regulierungen ableiten',
url: '/sdk/requirements',
checkpointId: 'CP-REQ',
prerequisiteSteps: ['modules'],
prerequisiteSteps: ['source-policy'],
isOptional: false,
},
{
@@ -447,6 +473,19 @@ export const SDK_STEPS: SDKStep[] = [
prerequisiteSteps: ['ai-act'],
isOptional: false,
},
{
id: 'audit-report',
phase: 1,
package: 'analyse',
order: 7,
name: 'Audit Report',
nameShort: 'Report',
description: 'Audit-Sitzungen & PDF-Report',
url: '/sdk/audit-report',
checkpointId: 'CP-AREP',
prerequisiteSteps: ['audit-checklist'],
isOptional: false,
},
// =============================================================================
// PAKET 3: DOKUMENTATION (Compliance Docs)
@@ -461,7 +500,7 @@ export const SDK_STEPS: SDKStep[] = [
description: 'NIS2, DSGVO, AI Act Pflichten',
url: '/sdk/obligations',
checkpointId: 'CP-OBL',
prerequisiteSteps: ['audit-checklist'],
prerequisiteSteps: ['audit-report'],
isOptional: false,
},
{
@@ -572,6 +611,19 @@ export const SDK_STEPS: SDKStep[] = [
prerequisiteSteps: ['cookie-banner'],
isOptional: true,
},
{
id: 'workflow',
phase: 2,
package: 'rechtliche-texte',
order: 5,
name: 'Document Workflow',
nameShort: 'Workflow',
description: 'Versionierung & Freigabe-Workflow',
url: '/sdk/workflow',
checkpointId: 'CP-WRKF',
prerequisiteSteps: ['document-generator'],
isOptional: false,
},
// =============================================================================
// PAKET 5: BETRIEB (Operations)
@@ -586,7 +638,7 @@ export const SDK_STEPS: SDKStep[] = [
description: 'Betroffenenrechte-Portal',
url: '/sdk/dsr',
checkpointId: 'CP-DSR',
prerequisiteSteps: ['cookie-banner'],
prerequisiteSteps: ['workflow'],
isOptional: false,
},
{
@@ -615,6 +667,32 @@ export const SDK_STEPS: SDKStep[] = [
prerequisiteSteps: ['escalations'],
isOptional: false,
},
{
id: 'consent-management',
phase: 2,
package: 'betrieb',
order: 4,
name: 'Consent Verwaltung',
nameShort: 'Consent Mgmt',
description: 'Dokument-Lifecycle & DSGVO-Prozesse',
url: '/sdk/consent-management',
checkpointId: 'CP-CMGMT',
prerequisiteSteps: ['vendor-compliance'],
isOptional: false,
},
{
id: 'notfallplan',
phase: 2,
package: 'betrieb',
order: 5,
name: 'Notfallplan & Breach Response',
nameShort: 'Notfallplan',
description: 'Datenpannen-Management nach Art. 33/34 DSGVO',
url: '/sdk/notfallplan',
checkpointId: 'CP-NOTF',
prerequisiteSteps: ['consent-management'],
isOptional: false,
},
]
// =============================================================================
@@ -1208,6 +1286,9 @@ export interface SDKState {
// Company Profile (collected before use cases)
companyProfile: CompanyProfile | null
// Compliance Scope (determines depth level L1-L4)
complianceScope: import('./compliance-scope-types').ComplianceScopeState | null
// Progress
currentPhase: SDKPhase
currentStep: string
@@ -1265,6 +1346,8 @@ export type SDKAction =
| { type: 'SET_CUSTOMER_TYPE'; payload: CustomerType }
| { type: 'SET_COMPANY_PROFILE'; payload: CompanyProfile }
| { type: 'UPDATE_COMPANY_PROFILE'; payload: Partial<CompanyProfile> }
| { type: 'SET_COMPLIANCE_SCOPE'; payload: import('./compliance-scope-types').ComplianceScopeState }
| { type: 'UPDATE_COMPLIANCE_SCOPE'; payload: Partial<import('./compliance-scope-types').ComplianceScopeState> }
| { type: 'ADD_IMPORTED_DOCUMENT'; payload: ImportedDocument }
| { type: 'UPDATE_IMPORTED_DOCUMENT'; payload: { id: string; data: Partial<ImportedDocument> } }
| { type: 'DELETE_IMPORTED_DOCUMENT'; payload: string }
@@ -1783,3 +1866,243 @@ export const JURISDICTION_LABELS: Record<Jurisdiction, string> = {
US: 'United States',
INTL: 'International',
}
// =============================================================================
// DSFA RAG TYPES (Source Attribution & Corpus Management)
// =============================================================================
/**
* License codes for DSFA source documents
*/
export type DSFALicenseCode =
| 'DL-DE-BY-2.0' // Datenlizenz Deutschland Namensnennung
| 'DL-DE-ZERO-2.0' // Datenlizenz Deutschland Zero
| 'CC-BY-4.0' // Creative Commons Attribution 4.0
| 'EDPB-LICENSE' // EDPB Document License
| 'PUBLIC_DOMAIN' // Public Domain
| 'PROPRIETARY' // Internal/Proprietary
/**
* Document types in the DSFA corpus
*/
export type DSFADocumentType = 'guideline' | 'checklist' | 'regulation' | 'template'
/**
* Category for DSFA chunks (for filtering)
*/
export type DSFACategory =
| 'threshold_analysis'
| 'risk_assessment'
| 'mitigation'
| 'consultation'
| 'documentation'
| 'process'
| 'criteria'
/**
* DSFA source registry entry
*/
export interface DSFASource {
id: string
sourceCode: string
name: string
fullName?: string
organization?: string
sourceUrl?: string
eurLexCelex?: string
licenseCode: DSFALicenseCode
licenseName: string
licenseUrl?: string
attributionRequired: boolean
attributionText: string
documentType?: DSFADocumentType
language: string
}
/**
* DSFA document entry
*/
export interface DSFADocument {
id: string
sourceId: string
title: string
description?: string
fileName?: string
fileType?: string
fileSizeBytes?: number
minioBucket: string
minioPath?: string
originalUrl?: string
ocrProcessed: boolean
textExtracted: boolean
chunksGenerated: number
lastIndexedAt?: string
metadata: Record<string, unknown>
createdAt: string
updatedAt: string
}
/**
* DSFA chunk with full attribution
*/
export interface DSFAChunk {
chunkId: string
content: string
sectionTitle?: string
pageNumber?: number
category?: DSFACategory
documentId: string
documentTitle?: string
sourceId: string
sourceCode: string
sourceName: string
attributionText: string
licenseCode: DSFALicenseCode
licenseName: string
licenseUrl?: string
attributionRequired: boolean
sourceUrl?: string
documentType?: DSFADocumentType
}
/**
* DSFA search result with score and attribution
*/
export interface DSFASearchResult {
chunkId: string
content: string
score: number
sourceCode: string
sourceName: string
attributionText: string
licenseCode: DSFALicenseCode
licenseName: string
licenseUrl?: string
attributionRequired: boolean
sourceUrl?: string
documentType?: DSFADocumentType
category?: DSFACategory
sectionTitle?: string
pageNumber?: number
}
/**
* DSFA search response with aggregated attribution
*/
export interface DSFASearchResponse {
query: string
results: DSFASearchResult[]
totalResults: number
licensesUsed: string[]
attributionNotice: string
}
/**
* Source statistics for dashboard
*/
export interface DSFASourceStats {
sourceId: string
sourceCode: string
name: string
organization?: string
licenseCode: DSFALicenseCode
documentType?: DSFADocumentType
documentCount: number
chunkCount: number
lastIndexedAt?: string
}
/**
* Corpus statistics for dashboard
*/
export interface DSFACorpusStats {
sources: DSFASourceStats[]
totalSources: number
totalDocuments: number
totalChunks: number
qdrantCollection: string
qdrantPointsCount: number
qdrantStatus: string
}
/**
* License information
*/
export interface DSFALicenseInfo {
code: DSFALicenseCode
name: string
url?: string
attributionRequired: boolean
modificationAllowed: boolean
commercialUse: boolean
}
/**
* Ingestion request for DSFA documents
*/
export interface DSFAIngestRequest {
documentUrl?: string
documentText?: string
title?: string
}
/**
* Ingestion response
*/
export interface DSFAIngestResponse {
sourceCode: string
documentId?: string
chunksCreated: number
message: string
}
/**
* Props for SourceAttribution component
*/
export interface SourceAttributionProps {
sources: Array<{
sourceCode: string
sourceName: string
attributionText: string
licenseCode: DSFALicenseCode
sourceUrl?: string
score?: number
}>
compact?: boolean
showScores?: boolean
}
/**
* License code display labels
*/
export const DSFA_LICENSE_LABELS: Record<DSFALicenseCode, string> = {
'DL-DE-BY-2.0': 'Datenlizenz DE Namensnennung 2.0',
'DL-DE-ZERO-2.0': 'Datenlizenz DE Zero 2.0',
'CC-BY-4.0': 'CC BY 4.0 International',
'EDPB-LICENSE': 'EDPB Document License',
'PUBLIC_DOMAIN': 'Public Domain',
'PROPRIETARY': 'Proprietary',
}
/**
* Document type display labels
*/
export const DSFA_DOCUMENT_TYPE_LABELS: Record<DSFADocumentType, string> = {
guideline: 'Leitlinie',
checklist: 'Prüfliste',
regulation: 'Verordnung',
template: 'Vorlage',
}
/**
* Category display labels
*/
export const DSFA_CATEGORY_LABELS: Record<DSFACategory, string> = {
threshold_analysis: 'Schwellwertanalyse',
risk_assessment: 'Risikobewertung',
mitigation: 'Risikominderung',
consultation: 'Behördenkonsultation',
documentation: 'Dokumentation',
process: 'Prozessschritte',
criteria: 'Kriterien',
}

View File

@@ -0,0 +1,630 @@
/**
* VVT Baseline-Katalog
*
* Vordefinierte Verarbeitungstaetigkeiten als Templates.
* Werden vom Profiling-Fragebogen (Generator) genutzt, um
* auf Basis der Antworten VVT-Eintraege vorzubefuellen.
*/
import type { VVTActivity, BusinessFunction } from './vvt-types'
export interface BaselineTemplate {
templateId: string
businessFunction: BusinessFunction
name: string
description: string
purposes: string[]
legalBases: { type: string; description?: string; reference?: string }[]
dataSubjectCategories: string[]
personalDataCategories: string[]
recipientCategories: { type: string; name: string; description?: string }[]
retentionPeriod: { duration?: number; durationUnit?: string; description: string; legalBasis?: string; deletionProcedure?: string }
tomDescription: string
structuredToms: {
accessControl: string[]
confidentiality: string[]
integrity: string[]
availability: string[]
separation: string[]
}
typicalSystems: string[]
protectionLevel: 'LOW' | 'MEDIUM' | 'HIGH'
dpiaRequired: boolean
tags: string[]
}
// =============================================================================
// BASELINE TEMPLATES
// =============================================================================
export const VVT_BASELINE_CATALOG: BaselineTemplate[] = [
// ==================== HR ====================
{
templateId: 'hr-mitarbeiterverwaltung',
businessFunction: 'hr',
name: 'Mitarbeiterverwaltung',
description: 'Verwaltung von Stammdaten, Vertraegen und Personalakten der Beschaeftigten',
purposes: ['Durchfuehrung des Beschaeftigungsverhaeltnisses', 'Personalverwaltung und -planung'],
legalBases: [
{ type: 'CONTRACT', description: 'Arbeitsvertrag', reference: 'Art. 6 Abs. 1 lit. b DSGVO' },
{ type: 'LEGAL_OBLIGATION', description: 'Arbeitsrechtliche Pflichten', reference: '§ 26 BDSG' },
],
dataSubjectCategories: ['EMPLOYEES'],
personalDataCategories: ['NAME', 'CONTACT', 'ADDRESS', 'DOB', 'SOCIAL_SECURITY', 'TAX_ID', 'BANK_ACCOUNT', 'EMPLOYMENT_DATA'],
recipientCategories: [
{ type: 'INTERNAL', name: 'Personalabteilung' },
{ type: 'AUTHORITY', name: 'Finanzamt' },
{ type: 'AUTHORITY', name: 'Sozialversicherungstraeger' },
],
retentionPeriod: { duration: 10, durationUnit: 'YEARS', description: '10 Jahre nach Ende des Beschaeftigungsverhaeltnisses', legalBasis: 'HGB § 257, AO § 147', deletionProcedure: 'Sichere Loeschung nach Ablauf' },
tomDescription: 'Zugriffskontrolle auf Personalakten, Verschluesselung, Protokollierung',
structuredToms: {
accessControl: ['RBAC', 'Need-to-know-Prinzip', 'Personalakten nur fuer HR'],
confidentiality: ['Verschluesselung personenbezogener Daten', 'Vertraulichkeitsvereinbarungen'],
integrity: ['Aenderungsprotokollierung', 'Vier-Augen-Prinzip bei Gehaltsaenderungen'],
availability: ['Regelmaessige Backups', 'Redundante Speicherung'],
separation: ['Trennung Personal-/Gehaltsdaten'],
},
typicalSystems: ['HR-Software', 'Gehaltsabrechnung', 'Dokumentenmanagement'],
protectionLevel: 'HIGH',
dpiaRequired: false,
tags: ['hr', 'mitarbeiter', 'personal'],
},
{
templateId: 'hr-gehaltsabrechnung',
businessFunction: 'hr',
name: 'Gehaltsabrechnung',
description: 'Berechnung und Auszahlung von Gehaeltern, Sozialabgaben und Steuern',
purposes: ['Lohn- und Gehaltsabrechnung', 'Erfuellung steuer- und sozialversicherungsrechtlicher Pflichten'],
legalBases: [
{ type: 'CONTRACT', description: 'Arbeitsvertrag', reference: 'Art. 6 Abs. 1 lit. b DSGVO' },
{ type: 'LEGAL_OBLIGATION', description: 'Steuer-/Sozialversicherungsrecht', reference: 'Art. 6 Abs. 1 lit. c DSGVO' },
],
dataSubjectCategories: ['EMPLOYEES'],
personalDataCategories: ['NAME', 'ADDRESS', 'SOCIAL_SECURITY', 'TAX_ID', 'BANK_ACCOUNT', 'SALARY_DATA'],
recipientCategories: [
{ type: 'PROCESSOR', name: 'Lohnbuero / Steuerberater' },
{ type: 'AUTHORITY', name: 'Finanzamt' },
{ type: 'AUTHORITY', name: 'Krankenkassen' },
],
retentionPeriod: { duration: 10, durationUnit: 'YEARS', description: '10 Jahre (steuerrechtlich)', legalBasis: 'AO § 147 Abs. 1 Nr. 1', deletionProcedure: 'Automatische Loeschung nach Fristablauf' },
tomDescription: 'Strenge Zugriffskontrolle, Verschluesselung, Trennung von Stamm- und Gehaltsdaten',
structuredToms: {
accessControl: ['Nur Lohnbuchhaltung/Steuerberater', 'MFA'],
confidentiality: ['Verschluesselung at-rest und in-transit', 'Vertraulichkeitsklausel'],
integrity: ['Revisionssichere Ablage', 'Pruefprotokoll'],
availability: ['Monatliche Backups', 'Jahresabschluss-Archiv'],
separation: ['Gehaltsdaten getrennt von allgemeinen Personaldaten'],
},
typicalSystems: ['DATEV', 'Lohnabrechnungssoftware'],
protectionLevel: 'HIGH',
dpiaRequired: false,
tags: ['hr', 'gehalt', 'lohn', 'steuer'],
},
{
templateId: 'hr-bewerbermanagement',
businessFunction: 'hr',
name: 'Bewerbermanagement',
description: 'Entgegennahme, Verwaltung und Bewertung von Bewerbungen',
purposes: ['Bearbeitung eingehender Bewerbungen', 'Bewerberauswahl'],
legalBases: [
{ type: 'CONTRACT', description: 'Vorvertragliche Massnahmen', reference: 'Art. 6 Abs. 1 lit. b DSGVO' },
{ type: 'LEGITIMATE_INTEREST', description: 'Bewerberauswahl', reference: '§ 26 Abs. 1 BDSG' },
],
dataSubjectCategories: ['APPLICANTS'],
personalDataCategories: ['NAME', 'CONTACT', 'ADDRESS', 'EDUCATION_DATA', 'EMPLOYMENT_DATA', 'PHOTO_VIDEO'],
recipientCategories: [
{ type: 'INTERNAL', name: 'Personalabteilung' },
{ type: 'INTERNAL', name: 'Fachabteilung' },
],
retentionPeriod: { duration: 6, durationUnit: 'MONTHS', description: '6 Monate nach Absage (AGG-Frist)', legalBasis: 'AGG § 15 Abs. 4', deletionProcedure: 'Automatische Loeschung 6 Monate nach Absage' },
tomDescription: 'Zugriffsbeschraenkung auf beteiligte Entscheidungstraeger, verschluesselte Uebertragung',
structuredToms: {
accessControl: ['Nur HR + Fachabteilung', 'Zeitlich begrenzter Zugriff'],
confidentiality: ['TLS fuer Bewerbungsportale', 'Verschluesselter E-Mail-Empfang'],
integrity: ['Unveraenderbare Bewerbungseingaenge'],
availability: ['Regelmaessige Backups'],
separation: ['Getrennte Bewerberdatenbank'],
},
typicalSystems: ['Bewerbermanagementsystem', 'E-Mail'],
protectionLevel: 'MEDIUM',
dpiaRequired: false,
tags: ['hr', 'bewerbung', 'recruiting'],
},
{
templateId: 'hr-zeiterfassung',
businessFunction: 'hr',
name: 'Zeiterfassung',
description: 'Erfassung von Arbeitszeiten, Urlaub und Fehlzeiten',
purposes: ['Arbeitszeiterfassung gemaess ArbZG', 'Urlaubsverwaltung'],
legalBases: [
{ type: 'LEGAL_OBLIGATION', description: 'Arbeitszeitgesetz', reference: 'Art. 6 Abs. 1 lit. c DSGVO, ArbZG § 16 Abs. 2' },
],
dataSubjectCategories: ['EMPLOYEES'],
personalDataCategories: ['NAME', 'EMPLOYMENT_DATA'],
recipientCategories: [
{ type: 'INTERNAL', name: 'Personalabteilung' },
{ type: 'INTERNAL', name: 'Vorgesetzte' },
],
retentionPeriod: { duration: 2, durationUnit: 'YEARS', description: '2 Jahre (ArbZG)', legalBasis: 'ArbZG § 16 Abs. 2', deletionProcedure: 'Automatische Loeschung' },
tomDescription: 'Zugriffskontrolle nach Abteilung, Protokollierung von Aenderungen',
structuredToms: {
accessControl: ['Vorgesetzte sehen nur eigene Abteilung', 'HR sieht alle'],
confidentiality: ['Krankmeldungen nur HR'],
integrity: ['Aenderungshistorie'],
availability: ['Taegliches Backup'],
separation: ['Trennung Zeitdaten / Gehaltsdaten'],
},
typicalSystems: ['Zeiterfassungssystem', 'HR-Software'],
protectionLevel: 'MEDIUM',
dpiaRequired: false,
tags: ['hr', 'zeiterfassung', 'arbeitszeit'],
},
// ==================== FINANCE ====================
{
templateId: 'finance-buchhaltung',
businessFunction: 'finance',
name: 'Buchhaltung & Rechnungswesen',
description: 'Finanzbuchhaltung, Rechnungsstellung und Zahlungsverkehr',
purposes: ['Finanzbuchhaltung', 'Rechnungsstellung', 'Erfuellung handels-/steuerrechtlicher Aufbewahrungspflichten'],
legalBases: [
{ type: 'LEGAL_OBLIGATION', description: 'HGB, AO', reference: 'Art. 6 Abs. 1 lit. c DSGVO' },
{ type: 'CONTRACT', description: 'Vertragserfuellung', reference: 'Art. 6 Abs. 1 lit. b DSGVO' },
],
dataSubjectCategories: ['CUSTOMERS', 'SUPPLIERS', 'BUSINESS_PARTNERS'],
personalDataCategories: ['NAME', 'CONTACT', 'ADDRESS', 'BANK_ACCOUNT', 'PAYMENT_DATA', 'CONTRACT_DATA', 'TAX_ID'],
recipientCategories: [
{ type: 'PROCESSOR', name: 'Steuerberater / Wirtschaftspruefer' },
{ type: 'AUTHORITY', name: 'Finanzamt' },
],
retentionPeriod: { duration: 10, durationUnit: 'YEARS', description: '10 Jahre (HGB) / 6 Jahre (Geschaeftsbriefe)', legalBasis: 'HGB § 257, AO § 147', deletionProcedure: 'Loeschung nach Ablauf der jeweiligen Frist' },
tomDescription: 'Zugriffskontrolle nach Vier-Augen-Prinzip, revisionssichere Archivierung',
structuredToms: {
accessControl: ['Vier-Augen-Prinzip', 'RBAC nach Buchhaltungsrollen'],
confidentiality: ['Verschluesselung Finanzdaten'],
integrity: ['Revisionssichere Archivierung (GoBD)', 'Aenderungsprotokoll'],
availability: ['Redundante Speicherung', 'Jaehrliche Backups'],
separation: ['Trennung Debitoren/Kreditoren'],
},
typicalSystems: ['DATEV', 'ERP-System', 'Buchhaltungssoftware'],
protectionLevel: 'MEDIUM',
dpiaRequired: false,
tags: ['finance', 'buchhaltung', 'rechnungswesen'],
},
{
templateId: 'finance-zahlungsverkehr',
businessFunction: 'finance',
name: 'Zahlungsverkehr',
description: 'Abwicklung von Zahlungen, SEPA-Lastschriften und Ueberweisungen',
purposes: ['Zahlungsabwicklung', 'Mahnwesen'],
legalBases: [
{ type: 'CONTRACT', description: 'Vertragserfuellung', reference: 'Art. 6 Abs. 1 lit. b DSGVO' },
],
dataSubjectCategories: ['CUSTOMERS', 'SUPPLIERS'],
personalDataCategories: ['NAME', 'BANK_ACCOUNT', 'PAYMENT_DATA'],
recipientCategories: [
{ type: 'PROCESSOR', name: 'Zahlungsdienstleister' },
{ type: 'PROCESSOR', name: 'Kreditinstitut' },
],
retentionPeriod: { duration: 10, durationUnit: 'YEARS', description: '10 Jahre', legalBasis: 'HGB § 257', deletionProcedure: 'Automatische Loeschung' },
tomDescription: 'PCI-DSS-konforme Verarbeitung, Verschluesselung, Zugriffsbeschraenkung',
structuredToms: {
accessControl: ['Streng limitierter Zugriff', 'MFA'],
confidentiality: ['TLS 1.3', 'Tokenisierung von Zahlungsdaten'],
integrity: ['Transaktionsprotokoll'],
availability: ['Hochverfuegbarer Zahlungsservice'],
separation: ['Zahlungsdaten getrennt von CRM'],
},
typicalSystems: ['Banking-Software', 'Payment Gateway'],
protectionLevel: 'HIGH',
dpiaRequired: false,
tags: ['finance', 'zahlung', 'payment'],
},
// ==================== SALES / CRM ====================
{
templateId: 'sales-kundenverwaltung',
businessFunction: 'sales_crm',
name: 'Kundenverwaltung (CRM)',
description: 'Verwaltung von Kundenbeziehungen, Kontakten und Vertriebsaktivitaeten',
purposes: ['Kundenbetreuung', 'Vertragserfuellung', 'Vertriebssteuerung'],
legalBases: [
{ type: 'CONTRACT', description: 'Kundenvertrag', reference: 'Art. 6 Abs. 1 lit. b DSGVO' },
{ type: 'LEGITIMATE_INTEREST', description: 'Kundenbindung', reference: 'Art. 6 Abs. 1 lit. f DSGVO' },
],
dataSubjectCategories: ['CUSTOMERS', 'PROSPECTIVE_CUSTOMERS', 'BUSINESS_PARTNERS'],
personalDataCategories: ['NAME', 'CONTACT', 'ADDRESS', 'CONTRACT_DATA', 'COMMUNICATION_DATA'],
recipientCategories: [
{ type: 'INTERNAL', name: 'Vertrieb' },
{ type: 'INTERNAL', name: 'Kundenservice' },
],
retentionPeriod: { duration: 3, durationUnit: 'YEARS', description: '3 Jahre nach letzter Interaktion (Verjaeherung)', legalBasis: 'BGB § 195', deletionProcedure: 'Loeschung nach Inaktivitaetsfrist' },
tomDescription: 'Zugriffskontrolle nach Kundengruppen, Verschluesselung, regemaessige Datenpflege',
structuredToms: {
accessControl: ['RBAC nach Vertriebsgebiet', 'Kundendaten-Owner'],
confidentiality: ['Verschluesselung in CRM', 'VPN fuer Fernzugriff'],
integrity: ['Aenderungshistorie im CRM'],
availability: ['Cloud-CRM mit SLA 99.9%'],
separation: ['Mandantentrennung'],
},
typicalSystems: ['CRM-System', 'E-Mail', 'Telefon'],
protectionLevel: 'MEDIUM',
dpiaRequired: false,
tags: ['sales', 'crm', 'kunden', 'vertrieb'],
},
{
templateId: 'sales-vertriebssteuerung',
businessFunction: 'sales_crm',
name: 'Vertriebssteuerung',
description: 'Analyse von Vertriebskennzahlen und Pipeline-Management',
purposes: ['Vertriebsoptimierung', 'Umsatzplanung'],
legalBases: [
{ type: 'LEGITIMATE_INTEREST', description: 'Unternehmenssteuerung', reference: 'Art. 6 Abs. 1 lit. f DSGVO' },
],
dataSubjectCategories: ['CUSTOMERS', 'PROSPECTIVE_CUSTOMERS'],
personalDataCategories: ['NAME', 'CONTRACT_DATA', 'COMMUNICATION_DATA'],
recipientCategories: [
{ type: 'INTERNAL', name: 'Vertriebsleitung' },
{ type: 'INTERNAL', name: 'Geschaeftsfuehrung' },
],
retentionPeriod: { duration: 3, durationUnit: 'YEARS', description: '3 Jahre', legalBasis: 'Berechtigtes Interesse', deletionProcedure: 'Anonymisierung nach Ablauf' },
tomDescription: 'Aggregierte Auswertungen wo moeglich, Zugriffsbeschraenkung auf Fuehrungsebene',
structuredToms: {
accessControl: ['Nur Management'],
confidentiality: ['Aggregierte Reports bevorzugt'],
integrity: ['Nachvollziehbare Berechnungen'],
availability: ['Dashboard-Verfuegbarkeit'],
separation: ['Reporting getrennt von Operativdaten'],
},
typicalSystems: ['CRM-System', 'BI-Tool'],
protectionLevel: 'LOW',
dpiaRequired: false,
tags: ['sales', 'vertrieb', 'reporting'],
},
// ==================== MARKETING ====================
{
templateId: 'marketing-newsletter',
businessFunction: 'marketing',
name: 'Newsletter-Marketing',
description: 'Versand von Marketing-E-Mails und Newslettern an Abonnenten',
purposes: ['Direktmarketing', 'Kundenbindung', 'Informationsversand'],
legalBases: [
{ type: 'CONSENT', description: 'Einwilligung zum Newsletter-Empfang', reference: 'Art. 6 Abs. 1 lit. a DSGVO, § 7 Abs. 2 UWG' },
],
dataSubjectCategories: ['NEWSLETTER_SUBSCRIBERS', 'CUSTOMERS'],
personalDataCategories: ['NAME', 'CONTACT', 'USAGE_DATA'],
recipientCategories: [
{ type: 'PROCESSOR', name: 'E-Mail-Dienstleister' },
{ type: 'INTERNAL', name: 'Marketing-Abteilung' },
],
retentionPeriod: { description: 'Bis zum Widerruf der Einwilligung', deletionProcedure: 'Sofortige Loeschung bei Abmeldung' },
tomDescription: 'Double-Opt-In, Abmeldelink in jeder E-Mail, Einwilligungsprotokollierung',
structuredToms: {
accessControl: ['Nur Marketing-Team'],
confidentiality: ['TLS-Versand', 'Keine Weitergabe an Dritte'],
integrity: ['Einwilligungsnachweis (Timestamp, IP, Version)'],
availability: ['Redundanter E-Mail-Service'],
separation: ['Newsletter-Liste getrennt von CRM'],
},
typicalSystems: ['Newsletter-Tool', 'E-Mail-Marketing-Plattform'],
protectionLevel: 'LOW',
dpiaRequired: false,
tags: ['marketing', 'newsletter', 'email'],
},
{
templateId: 'marketing-website-analytics',
businessFunction: 'marketing',
name: 'Website-Analytics',
description: 'Analyse des Nutzerverhaltens auf der Website mittels Tracking-Tools',
purposes: ['Website-Optimierung', 'Reichweitenmessung'],
legalBases: [
{ type: 'CONSENT', description: 'Cookie-Einwilligung', reference: 'Art. 6 Abs. 1 lit. a DSGVO, § 25 TDDDG' },
],
dataSubjectCategories: ['WEBSITE_USERS'],
personalDataCategories: ['IP_ADDRESS', 'DEVICE_ID', 'USAGE_DATA', 'LOCATION_DATA'],
recipientCategories: [
{ type: 'PROCESSOR', name: 'Analytics-Anbieter' },
{ type: 'INTERNAL', name: 'Marketing' },
],
retentionPeriod: { duration: 14, durationUnit: 'MONTHS', description: '14 Monate', deletionProcedure: 'Automatische Loeschung/Anonymisierung' },
tomDescription: 'IP-Anonymisierung, Cookie-Consent-Management, Opt-Out-Moeglichkeit',
structuredToms: {
accessControl: ['Nur Webanalyse-Team'],
confidentiality: ['IP-Anonymisierung', 'Pseudonymisierung'],
integrity: ['Datenqualitaetspruefung'],
availability: ['CDN-basiertes Tracking'],
separation: ['Analytics getrennt von personenbezogenen Profilen'],
},
typicalSystems: ['Matomo', 'Plausible', 'Google Analytics'],
protectionLevel: 'MEDIUM',
dpiaRequired: false,
tags: ['marketing', 'analytics', 'website', 'tracking'],
},
{
templateId: 'marketing-social-media',
businessFunction: 'marketing',
name: 'Social-Media-Marketing',
description: 'Betrieb von Social-Media-Kanaelen und Interaktion mit Nutzern',
purposes: ['Oeffentlichkeitsarbeit', 'Kundeninteraktion'],
legalBases: [
{ type: 'LEGITIMATE_INTEREST', description: 'Marketing', reference: 'Art. 6 Abs. 1 lit. f DSGVO' },
],
dataSubjectCategories: ['WEBSITE_USERS', 'CUSTOMERS'],
personalDataCategories: ['NAME', 'CONTACT', 'USAGE_DATA', 'COMMUNICATION_DATA'],
recipientCategories: [
{ type: 'CONTROLLER', name: 'Social-Media-Plattform (gemeinsame Verantwortlichkeit)' },
],
retentionPeriod: { description: 'Abhaengig von Plattform-Einstellungen', deletionProcedure: 'Regelmaessige Pruefung und Bereinigung' },
tomDescription: 'Datenschutzeinstellungen der Plattform, gemeinsame Verantwortlichkeit gemaess Art. 26',
structuredToms: {
accessControl: ['Nur Social-Media-Manager', 'Passwort-Manager'],
confidentiality: ['Plattform-Datenschutzeinstellungen'],
integrity: ['Redaktionsplan'],
availability: ['Multi-Kanal-Management'],
separation: ['Geschaeftlich/Privat getrennt'],
},
typicalSystems: ['Social-Media-Plattformen', 'Social-Media-Management-Tool'],
protectionLevel: 'LOW',
dpiaRequired: false,
tags: ['marketing', 'social-media'],
},
// ==================== SUPPORT ====================
{
templateId: 'support-ticketsystem',
businessFunction: 'support',
name: 'Kundenservice / Ticketsystem',
description: 'Bearbeitung von Kundenanfragen und Support-Tickets',
purposes: ['Kundenservice', 'Reklamationsbearbeitung', 'Vertragserfuellung'],
legalBases: [
{ type: 'CONTRACT', description: 'Kundenvertrag', reference: 'Art. 6 Abs. 1 lit. b DSGVO' },
],
dataSubjectCategories: ['CUSTOMERS', 'APP_USERS'],
personalDataCategories: ['NAME', 'CONTACT', 'CONTRACT_DATA', 'COMMUNICATION_DATA'],
recipientCategories: [
{ type: 'INTERNAL', name: 'Support-Team' },
{ type: 'PROCESSOR', name: 'Helpdesk-Software-Anbieter' },
],
retentionPeriod: { duration: 3, durationUnit: 'YEARS', description: '3 Jahre nach Ticketschliessung', legalBasis: 'BGB § 195', deletionProcedure: 'Automatische Loeschung geschlossener Tickets' },
tomDescription: 'Zugriffskontrolle nach Ticket-Owner, Verschluesselung, Audit-Trail',
structuredToms: {
accessControl: ['Ticket-basierte Zugriffskontrolle', 'Agent-Rollen'],
confidentiality: ['TLS', 'Verschluesselung'],
integrity: ['Ticket-Historie unveraenderbar'],
availability: ['Hochverfuegbarer Helpdesk'],
separation: ['Mandantentrennung'],
},
typicalSystems: ['Helpdesk-Software', 'E-Mail', 'Chat'],
protectionLevel: 'MEDIUM',
dpiaRequired: false,
tags: ['support', 'kundenservice', 'tickets'],
},
// ==================== IT OPERATIONS ====================
{
templateId: 'it-systemadministration',
businessFunction: 'it_operations',
name: 'Systemadministration',
description: 'Verwaltung von IT-Systemen, Benutzerkonten und Zugriffsrechten',
purposes: ['IT-Betrieb', 'Benutzerverwaltung', 'Sicherheitsueberwachung'],
legalBases: [
{ type: 'LEGITIMATE_INTEREST', description: 'IT-Sicherheit', reference: 'Art. 6 Abs. 1 lit. f DSGVO' },
{ type: 'CONTRACT', description: 'Bereitstellung IT-Dienste', reference: 'Art. 6 Abs. 1 lit. b DSGVO' },
],
dataSubjectCategories: ['EMPLOYEES', 'APP_USERS'],
personalDataCategories: ['NAME', 'CONTACT', 'LOGIN_DATA', 'IP_ADDRESS', 'DEVICE_ID'],
recipientCategories: [
{ type: 'INTERNAL', name: 'IT-Abteilung' },
{ type: 'PROCESSOR', name: 'IT-Dienstleister' },
],
retentionPeriod: { duration: 1, durationUnit: 'YEARS', description: '1 Jahr nach Kontodeaktivierung', deletionProcedure: 'Automatische Loeschung deaktivierter Konten' },
tomDescription: 'PAM, MFA, Protokollierung, regelmaessige Rechtereviews',
structuredToms: {
accessControl: ['PAM (Privileged Access Management)', 'MFA', 'Regelmaessige Rechtereviews'],
confidentiality: ['Verschluesselung', 'Passwort-Policies'],
integrity: ['Change Management', 'Konfigurationsmanagement'],
availability: ['Redundanz', 'Monitoring', 'Alerting'],
separation: ['Prod/Dev/Staging getrennt', 'Admin-Netze isoliert'],
},
typicalSystems: ['Active Directory / IAM', 'Monitoring', 'ITSM'],
protectionLevel: 'MEDIUM',
dpiaRequired: false,
tags: ['it', 'admin', 'benutzerverwaltung'],
},
{
templateId: 'it-backup',
businessFunction: 'it_operations',
name: 'Backup & Recovery',
description: 'Sicherung und Wiederherstellung von Daten und Systemen',
purposes: ['Datensicherung', 'Disaster Recovery', 'Geschaeftskontinuitaet'],
legalBases: [
{ type: 'LEGITIMATE_INTEREST', description: 'Datensicherheit', reference: 'Art. 6 Abs. 1 lit. f DSGVO, Art. 32 DSGVO' },
],
dataSubjectCategories: ['EMPLOYEES', 'CUSTOMERS'],
personalDataCategories: ['NAME', 'CONTACT', 'CONTRACT_DATA'],
recipientCategories: [
{ type: 'PROCESSOR', name: 'Backup-Dienstleister' },
{ type: 'INTERNAL', name: 'IT-Abteilung' },
],
retentionPeriod: { duration: 90, durationUnit: 'DAYS', description: '90 Tage Aufbewahrung der Backups', deletionProcedure: 'Automatische Rotation und Loeschung' },
tomDescription: 'Verschluesselung, Zugriffskontrolle, regelmaessige Wiederherstellungstests',
structuredToms: {
accessControl: ['Nur Backup-Admins', 'Separater Encryption Key'],
confidentiality: ['AES-256-Verschluesselung', 'Verschluesselter Transport'],
integrity: ['Checksummen-Pruefung', 'Regelmaessige Restore-Tests'],
availability: ['3-2-1-Backup-Regel', 'Georedundanz'],
separation: ['Backup-Netzwerk isoliert'],
},
typicalSystems: ['Backup-Software', 'Cloud-Storage'],
protectionLevel: 'MEDIUM',
dpiaRequired: false,
tags: ['it', 'backup', 'recovery'],
},
{
templateId: 'it-logging',
businessFunction: 'it_operations',
name: 'Protokollierung & Logging',
description: 'Erfassung von System- und Sicherheitslogs zur Fehlerbehebung und Angriffserkennung',
purposes: ['IT-Sicherheit', 'Fehlerbehebung', 'Angriffserkennung'],
legalBases: [
{ type: 'LEGITIMATE_INTEREST', description: 'IT-Sicherheit und Betrieb', reference: 'Art. 6 Abs. 1 lit. f DSGVO' },
],
dataSubjectCategories: ['EMPLOYEES', 'APP_USERS', 'WEBSITE_USERS'],
personalDataCategories: ['IP_ADDRESS', 'LOGIN_DATA', 'USAGE_DATA', 'DEVICE_ID'],
recipientCategories: [
{ type: 'INTERNAL', name: 'IT-Sicherheit' },
{ type: 'PROCESSOR', name: 'SIEM-Anbieter' },
],
retentionPeriod: { duration: 90, durationUnit: 'DAYS', description: '90 Tage (Standard) / 1 Jahr (Security-Logs)', deletionProcedure: 'Automatische Rotation' },
tomDescription: 'SIEM, Integritaetsschutz der Logs, Zugriffskontrolle, Pseudonymisierung',
structuredToms: {
accessControl: ['Nur Security-Team', 'Read-Only fuer Auditoren'],
confidentiality: ['Pseudonymisierung wo moeglich'],
integrity: ['WORM-Storage fuer Security-Logs', 'Hashketten'],
availability: ['Redundante Log-Speicherung'],
separation: ['Zentrale Log-Infrastruktur getrennt'],
},
typicalSystems: ['SIEM', 'ELK Stack', 'Syslog'],
protectionLevel: 'MEDIUM',
dpiaRequired: false,
tags: ['it', 'logging', 'sicherheit'],
},
{
templateId: 'it-iam',
businessFunction: 'it_operations',
name: 'Identity & Access Management',
description: 'Verwaltung von Identitaeten, Authentifizierung und Autorisierung',
purposes: ['Zugriffskontrolle', 'Identitaetsverwaltung', 'Compliance'],
legalBases: [
{ type: 'LEGITIMATE_INTEREST', description: 'IT-Sicherheit', reference: 'Art. 6 Abs. 1 lit. f DSGVO' },
{ type: 'CONTRACT', description: 'Bereitstellung IT-Dienste', reference: 'Art. 6 Abs. 1 lit. b DSGVO' },
],
dataSubjectCategories: ['EMPLOYEES', 'APP_USERS'],
personalDataCategories: ['NAME', 'CONTACT', 'LOGIN_DATA'],
recipientCategories: [
{ type: 'INTERNAL', name: 'IT-Abteilung' },
{ type: 'PROCESSOR', name: 'IAM-Anbieter' },
],
retentionPeriod: { duration: 6, durationUnit: 'MONTHS', description: '6 Monate nach Kontodeaktivierung', deletionProcedure: 'Automatische Deprovisionierung' },
tomDescription: 'MFA, SSO, regelmaessige Access Reviews, Least-Privilege-Prinzip',
structuredToms: {
accessControl: ['MFA', 'SSO', 'Least Privilege', 'Regelmaessige Reviews'],
confidentiality: ['Passwort-Hashing (bcrypt)', 'Token-basierte Auth'],
integrity: ['Audit-Trail aller Aenderungen'],
availability: ['Hochverfuegbarer IdP'],
separation: ['Identitaeten pro Mandant'],
},
typicalSystems: ['IAM-System', 'SSO Provider', 'MFA'],
protectionLevel: 'HIGH',
dpiaRequired: false,
tags: ['it', 'iam', 'zugriffskontrolle'],
},
// ==================== OTHER ====================
{
templateId: 'other-videokonferenz',
businessFunction: 'other',
name: 'Videokonferenzen',
description: 'Durchfuehrung von Video-Meetings und Online-Besprechungen',
purposes: ['Interne Kommunikation', 'Kundeninteraktion', 'Remote-Arbeit'],
legalBases: [
{ type: 'LEGITIMATE_INTEREST', description: 'Geschaeftliche Kommunikation', reference: 'Art. 6 Abs. 1 lit. f DSGVO' },
{ type: 'CONTRACT', description: 'Arbeitsvertrag (bei Mitarbeitern)', reference: 'Art. 6 Abs. 1 lit. b DSGVO' },
],
dataSubjectCategories: ['EMPLOYEES', 'CUSTOMERS', 'BUSINESS_PARTNERS'],
personalDataCategories: ['NAME', 'CONTACT', 'PHOTO_VIDEO', 'IP_ADDRESS', 'COMMUNICATION_DATA'],
recipientCategories: [
{ type: 'PROCESSOR', name: 'Videokonferenz-Anbieter' },
],
retentionPeriod: { description: 'Keine dauerhafte Speicherung von Meetings (sofern nicht aufgezeichnet)', deletionProcedure: 'Aufzeichnungen nach Verwendungszweck loeschen' },
tomDescription: 'Ende-zu-Ende-Verschluesselung, Warteraum, Passwortschutz, Aufnahme nur mit Einwilligung',
structuredToms: {
accessControl: ['Meeting-Passwort', 'Warteraum', 'Host-Kontrolle'],
confidentiality: ['TLS / E2E-Verschluesselung'],
integrity: ['Teilnehmerliste'],
availability: ['Redundante Infrastruktur'],
separation: ['Separate Meeting-Raeume'],
},
typicalSystems: ['Jitsi', 'Zoom', 'Teams', 'Google Meet'],
protectionLevel: 'LOW',
dpiaRequired: false,
tags: ['kommunikation', 'video', 'meeting'],
},
{
templateId: 'other-besuchermanagement',
businessFunction: 'other',
name: 'Besuchermanagement',
description: 'Erfassung und Verwaltung von Besuchern am Firmenstandort',
purposes: ['Zutrittskontrolle', 'Sicherheit', 'Nachverfolgung'],
legalBases: [
{ type: 'LEGITIMATE_INTEREST', description: 'Gebaeudesicherheit', reference: 'Art. 6 Abs. 1 lit. f DSGVO' },
],
dataSubjectCategories: ['VISITORS'],
personalDataCategories: ['NAME', 'CONTACT', 'PHOTO_VIDEO'],
recipientCategories: [
{ type: 'INTERNAL', name: 'Empfang / Sicherheit' },
],
retentionPeriod: { duration: 30, durationUnit: 'DAYS', description: '30 Tage nach Besuch', deletionProcedure: 'Automatische Loeschung' },
tomDescription: 'Besucherausweise, Begleitpflicht, zeitlich begrenzter Zugang',
structuredToms: {
accessControl: ['Besucherausweise', 'Begleitpflicht'],
confidentiality: ['Besucherliste nicht oeffentlich einsehbar'],
integrity: ['Besuchsprotokoll'],
availability: ['Papier-Backup fuer Besucherliste'],
separation: ['Besucherbereich getrennt'],
},
typicalSystems: ['Besuchermanagementsystem', 'Zutrittskontrollsystem'],
protectionLevel: 'LOW',
dpiaRequired: false,
tags: ['besucher', 'zutritt', 'empfang'],
},
]
// =============================================================================
// HELPER: Convert template to VVTActivity
// =============================================================================
export function templateToActivity(template: BaselineTemplate, vvtId: string): Omit<import('./vvt-types').VVTActivity, 'id'> & { id: string } {
const now = new Date().toISOString()
return {
id: crypto.randomUUID(),
vvtId,
name: template.name,
description: template.description,
purposes: template.purposes,
legalBases: template.legalBases,
dataSubjectCategories: template.dataSubjectCategories,
personalDataCategories: template.personalDataCategories,
recipientCategories: template.recipientCategories,
thirdCountryTransfers: [],
retentionPeriod: template.retentionPeriod,
tomDescription: template.tomDescription,
businessFunction: template.businessFunction,
systems: template.typicalSystems.map((s, i) => ({ systemId: `sys-${i}`, name: s })),
deploymentModel: 'cloud',
dataSources: [{ type: 'DATA_SUBJECT', description: 'Direkt von der betroffenen Person' }],
dataFlows: [],
protectionLevel: template.protectionLevel,
dpiaRequired: template.dpiaRequired,
structuredToms: template.structuredToms,
status: 'DRAFT',
responsible: '',
owner: '',
createdAt: now,
updatedAt: now,
}
}
// =============================================================================
// HELPER: Get templates by business function
// =============================================================================
export function getTemplatesByFunction(fn: BusinessFunction): BaselineTemplate[] {
return VVT_BASELINE_CATALOG.filter(t => t.businessFunction === fn)
}
export function getTemplateById(templateId: string): BaselineTemplate | undefined {
return VVT_BASELINE_CATALOG.find(t => t.templateId === templateId)
}

View File

@@ -0,0 +1,492 @@
/**
* VVT Profiling — Generator-Fragebogen
*
* ~25 Fragen in 6 Schritten, die auf Basis der Antworten
* Baseline-Verarbeitungstaetigkeiten generieren.
*/
import { VVT_BASELINE_CATALOG, templateToActivity } from './vvt-baseline-catalog'
import { generateVVTId } from './vvt-types'
import type { VVTActivity, BusinessFunction } from './vvt-types'
// =============================================================================
// TYPES
// =============================================================================
export interface ProfilingQuestion {
id: string
step: number
question: string
type: 'single_choice' | 'multi_choice' | 'number' | 'text' | 'boolean'
options?: { value: string; label: string }[]
helpText?: string
triggersTemplates: string[] // Template-IDs that get activated when answered positively
}
export interface ProfilingStep {
step: number
title: string
description: string
}
export interface ProfilingAnswers {
[questionId: string]: string | string[] | number | boolean
}
export interface ProfilingResult {
answers: ProfilingAnswers
generatedActivities: VVTActivity[]
coverageScore: number
art30Abs5Exempt: boolean
}
// =============================================================================
// STEPS
// =============================================================================
export const PROFILING_STEPS: ProfilingStep[] = [
{ step: 1, title: 'Organisation', description: 'Grunddaten zu Ihrem Unternehmen' },
{ step: 2, title: 'Geschaeftsbereiche', description: 'Welche Bereiche sind aktiv?' },
{ step: 3, title: 'Systeme & Tools', description: 'Welche IT-Systeme nutzen Sie?' },
{ step: 4, title: 'Datenkategorien', description: 'Welche besonderen Daten verarbeiten Sie?' },
{ step: 5, title: 'Drittlandtransfers', description: 'Transfers ausserhalb der EU/EWR' },
{ step: 6, title: 'Besondere Verarbeitungen', description: 'KI, Scoring, Ueberwachung' },
]
// =============================================================================
// QUESTIONS
// =============================================================================
export const PROFILING_QUESTIONS: ProfilingQuestion[] = [
// === STEP 1: Organisation ===
{
id: 'org_industry',
step: 1,
question: 'In welcher Branche ist Ihr Unternehmen taetig?',
type: 'single_choice',
options: [
{ value: 'it_software', label: 'IT & Software' },
{ value: 'healthcare', label: 'Gesundheitswesen' },
{ value: 'education', label: 'Bildung & Erziehung' },
{ value: 'finance', label: 'Finanzdienstleistungen' },
{ value: 'retail', label: 'Handel & E-Commerce' },
{ value: 'manufacturing', label: 'Produktion & Industrie' },
{ value: 'consulting', label: 'Beratung & Dienstleistung' },
{ value: 'public', label: 'Oeffentlicher Sektor' },
{ value: 'other', label: 'Sonstige' },
],
triggersTemplates: [],
},
{
id: 'org_employees',
step: 1,
question: 'Wie viele Mitarbeiter hat Ihr Unternehmen?',
type: 'number',
helpText: 'Relevant fuer Art. 30 Abs. 5 DSGVO (Ausnahme < 250 Mitarbeiter)',
triggersTemplates: [],
},
{
id: 'org_locations',
step: 1,
question: 'An wie vielen Standorten ist Ihr Unternehmen taetig?',
type: 'single_choice',
options: [
{ value: '1', label: '1 Standort' },
{ value: '2-5', label: '2-5 Standorte' },
{ value: '6-20', label: '6-20 Standorte' },
{ value: '20+', label: 'Mehr als 20 Standorte' },
],
triggersTemplates: [],
},
{
id: 'org_b2b_b2c',
step: 1,
question: 'Welches Geschaeftsmodell betreiben Sie?',
type: 'single_choice',
options: [
{ value: 'b2b', label: 'B2B (Geschaeftskunden)' },
{ value: 'b2c', label: 'B2C (Endkunden)' },
{ value: 'both', label: 'Beides (B2B + B2C)' },
{ value: 'b2g', label: 'B2G (Oeffentlicher Sektor)' },
],
triggersTemplates: [],
},
// === STEP 2: Geschaeftsbereiche ===
{
id: 'dept_hr',
step: 2,
question: 'Haben Sie eine Personalabteilung / HR?',
type: 'boolean',
triggersTemplates: ['hr-mitarbeiterverwaltung', 'hr-gehaltsabrechnung', 'hr-zeiterfassung'],
},
{
id: 'dept_recruiting',
step: 2,
question: 'Betreiben Sie aktives Recruiting / Bewerbermanagement?',
type: 'boolean',
triggersTemplates: ['hr-bewerbermanagement'],
},
{
id: 'dept_finance',
step: 2,
question: 'Haben Sie eine Finanz-/Buchhaltungsabteilung?',
type: 'boolean',
triggersTemplates: ['finance-buchhaltung', 'finance-zahlungsverkehr'],
},
{
id: 'dept_sales',
step: 2,
question: 'Haben Sie einen Vertrieb / Kundenverwaltung?',
type: 'boolean',
triggersTemplates: ['sales-kundenverwaltung', 'sales-vertriebssteuerung'],
},
{
id: 'dept_marketing',
step: 2,
question: 'Betreiben Sie Marketing-Aktivitaeten?',
type: 'boolean',
triggersTemplates: ['marketing-social-media'],
},
{
id: 'dept_support',
step: 2,
question: 'Haben Sie einen Kundenservice / Support?',
type: 'boolean',
triggersTemplates: ['support-ticketsystem'],
},
// === STEP 3: Systeme & Tools ===
{
id: 'sys_crm',
step: 3,
question: 'Nutzen Sie ein CRM-System (z.B. Salesforce, HubSpot, Pipedrive)?',
type: 'boolean',
triggersTemplates: ['sales-kundenverwaltung'],
},
{
id: 'sys_website_analytics',
step: 3,
question: 'Nutzen Sie Website-Analytics (z.B. Matomo, Google Analytics)?',
type: 'boolean',
triggersTemplates: ['marketing-website-analytics'],
},
{
id: 'sys_newsletter',
step: 3,
question: 'Versenden Sie Newsletter (z.B. Mailchimp, CleverReach)?',
type: 'boolean',
triggersTemplates: ['marketing-newsletter'],
},
{
id: 'sys_video',
step: 3,
question: 'Nutzen Sie Videokonferenz-Tools (z.B. Zoom, Teams, Jitsi)?',
type: 'boolean',
triggersTemplates: ['other-videokonferenz'],
},
{
id: 'sys_erp',
step: 3,
question: 'Nutzen Sie ein ERP-System?',
type: 'boolean',
helpText: 'z.B. SAP, ERPNext, Microsoft Dynamics',
triggersTemplates: ['finance-buchhaltung'],
},
{
id: 'sys_visitor',
step: 3,
question: 'Haben Sie ein Besuchermanagement-System?',
type: 'boolean',
triggersTemplates: ['other-besuchermanagement'],
},
// === STEP 4: Datenkategorien ===
{
id: 'data_health',
step: 4,
question: 'Verarbeiten Sie Gesundheitsdaten (Art. 9 DSGVO)?',
type: 'boolean',
helpText: 'z.B. Krankmeldungen, Arbeitsmedizin, Gesundheitsversorgung',
triggersTemplates: [],
},
{
id: 'data_minors',
step: 4,
question: 'Verarbeiten Sie Daten von Minderjaehrigen?',
type: 'boolean',
helpText: 'z.B. Schueler, Kinder unter 16 Jahren',
triggersTemplates: [],
},
{
id: 'data_biometric',
step: 4,
question: 'Verarbeiten Sie biometrische Daten zur Identifizierung?',
type: 'boolean',
helpText: 'z.B. Fingerabdruck, Gesichtserkennung, Stimmerkennung',
triggersTemplates: [],
},
{
id: 'data_criminal',
step: 4,
question: 'Verarbeiten Sie Daten ueber strafrechtliche Verurteilungen (Art. 10 DSGVO)?',
type: 'boolean',
helpText: 'z.B. Fuehrungszeugnisse',
triggersTemplates: [],
},
// === STEP 5: Drittlandtransfers ===
{
id: 'transfer_cloud_us',
step: 5,
question: 'Nutzen Sie Cloud-Dienste mit Sitz in den USA?',
type: 'boolean',
helpText: 'z.B. AWS, Azure, Google Cloud, Microsoft 365',
triggersTemplates: [],
},
{
id: 'transfer_support_non_eu',
step: 5,
question: 'Haben Sie Support-Mitarbeiter oder Dienstleister ausserhalb der EU?',
type: 'boolean',
triggersTemplates: [],
},
{
id: 'transfer_subprocessor',
step: 5,
question: 'Nutzen Sie Auftragsverarbeiter mit Unteraufragnehmern in Drittlaendern?',
type: 'boolean',
triggersTemplates: [],
},
// === STEP 6: Besondere Verarbeitungen ===
{
id: 'special_ai',
step: 6,
question: 'Setzen Sie KI oder automatisierte Entscheidungsfindung ein?',
type: 'boolean',
helpText: 'z.B. Chatbots, Scoring, Profiling, automatische Bewertungen',
triggersTemplates: [],
},
{
id: 'special_video_surveillance',
step: 6,
question: 'Betreiben Sie Videoueberwachung?',
type: 'boolean',
triggersTemplates: [],
},
{
id: 'special_tracking',
step: 6,
question: 'Betreiben Sie umfangreiches Nutzer-Tracking oder Profiling?',
type: 'boolean',
helpText: 'z.B. Verhaltensprofiling, Cross-Device-Tracking',
triggersTemplates: [],
},
]
// =============================================================================
// GENERATOR LOGIC
// =============================================================================
export function generateActivities(answers: ProfilingAnswers): ProfilingResult {
// Collect all triggered template IDs
const triggeredIds = new Set<string>()
for (const question of PROFILING_QUESTIONS) {
const answer = answers[question.id]
if (!answer) continue
// Boolean questions: if true, trigger templates
if (question.type === 'boolean' && answer === true) {
question.triggersTemplates.forEach(id => triggeredIds.add(id))
}
}
// Always add IT baseline templates (every company needs these)
triggeredIds.add('it-systemadministration')
triggeredIds.add('it-backup')
triggeredIds.add('it-logging')
triggeredIds.add('it-iam')
// Generate activities from triggered templates
const existingIds: string[] = []
const activities: VVTActivity[] = []
for (const templateId of triggeredIds) {
const template = VVT_BASELINE_CATALOG.find(t => t.templateId === templateId)
if (!template) continue
const vvtId = generateVVTId(existingIds)
existingIds.push(vvtId)
const activity = templateToActivity(template, vvtId)
// Enrich with profiling answers
enrichActivityFromAnswers(activity, answers)
activities.push(activity)
}
// Calculate coverage score
const totalFields = activities.length * 12 // 12 key fields per activity
let filledFields = 0
for (const a of activities) {
if (a.name) filledFields++
if (a.description) filledFields++
if (a.purposes.length > 0) filledFields++
if (a.legalBases.length > 0) filledFields++
if (a.dataSubjectCategories.length > 0) filledFields++
if (a.personalDataCategories.length > 0) filledFields++
if (a.recipientCategories.length > 0) filledFields++
if (a.retentionPeriod.description) filledFields++
if (a.tomDescription) filledFields++
if (a.businessFunction !== 'other') filledFields++
if (a.structuredToms.accessControl.length > 0) filledFields++
if (a.responsible || a.owner) filledFields++
}
const coverageScore = totalFields > 0 ? Math.round((filledFields / totalFields) * 100) : 0
// Art. 30 Abs. 5 check
const employeeCount = typeof answers.org_employees === 'number' ? answers.org_employees : 0
const hasSpecialCategories = answers.data_health === true || answers.data_biometric === true || answers.data_criminal === true
const art30Abs5Exempt = employeeCount < 250 && !hasSpecialCategories
return {
answers,
generatedActivities: activities,
coverageScore,
art30Abs5Exempt,
}
}
// =============================================================================
// ENRICHMENT
// =============================================================================
function enrichActivityFromAnswers(activity: VVTActivity, answers: ProfilingAnswers): void {
// Add third-country transfers if US cloud is used
if (answers.transfer_cloud_us === true) {
activity.thirdCountryTransfers.push({
country: 'US',
recipient: 'Cloud-Dienstleister (USA)',
transferMechanism: 'SCC_PROCESSOR',
additionalMeasures: ['Verschluesselung at-rest', 'Transfer Impact Assessment'],
})
}
// Add special data categories if applicable
if (answers.data_health === true) {
if (!activity.personalDataCategories.includes('HEALTH_DATA')) {
// Only add to HR activities
if (activity.businessFunction === 'hr') {
activity.personalDataCategories.push('HEALTH_DATA')
// Ensure Art. 9 legal basis
if (!activity.legalBases.some(lb => lb.type.startsWith('ART9_'))) {
activity.legalBases.push({
type: 'ART9_EMPLOYMENT',
description: 'Arbeitsrechtliche Verarbeitung',
reference: 'Art. 9 Abs. 2 lit. b DSGVO',
})
}
}
}
}
if (answers.data_minors === true) {
if (!activity.dataSubjectCategories.includes('MINORS')) {
// Add to relevant activities (education, app users)
if (activity.businessFunction === 'support' || activity.businessFunction === 'product_engineering') {
activity.dataSubjectCategories.push('MINORS')
}
}
}
// Set DPIA required for special processing
if (answers.special_ai === true || answers.special_video_surveillance === true || answers.special_tracking === true) {
if (answers.special_ai === true && activity.businessFunction === 'product_engineering') {
activity.dpiaRequired = true
}
}
}
// =============================================================================
// HELPERS
// =============================================================================
export function getQuestionsForStep(step: number): ProfilingQuestion[] {
return PROFILING_QUESTIONS.filter(q => q.step === step)
}
export function getStepProgress(answers: ProfilingAnswers, step: number): number {
const questions = getQuestionsForStep(step)
if (questions.length === 0) return 100
const answered = questions.filter(q => {
const a = answers[q.id]
return a !== undefined && a !== null && a !== ''
}).length
return Math.round((answered / questions.length) * 100)
}
export function getTotalProgress(answers: ProfilingAnswers): number {
const total = PROFILING_QUESTIONS.length
if (total === 0) return 100
const answered = PROFILING_QUESTIONS.filter(q => {
const a = answers[q.id]
return a !== undefined && a !== null && a !== ''
}).length
return Math.round((answered / total) * 100)
}
// =============================================================================
// COMPLIANCE SCOPE INTEGRATION
// =============================================================================
/**
* Prefill VVT profiling answers from Compliance Scope Engine answers.
* The Scope Engine acts as the "Single Source of Truth" for organizational questions.
* Redundant questions are auto-filled with a "prefilled" marker.
*/
export function prefillFromScopeAnswers(
scopeAnswers: import('./compliance-scope-types').ScopeProfilingAnswer[]
): ProfilingAnswers {
const { exportToVVTAnswers } = require('./compliance-scope-profiling')
const exported = exportToVVTAnswers(scopeAnswers) as Record<string, unknown>
const prefilled: ProfilingAnswers = {}
for (const [key, value] of Object.entries(exported)) {
if (value !== undefined && value !== null) {
prefilled[key] = value as string | string[] | number | boolean
}
}
return prefilled
}
/**
* Get the list of VVT question IDs that are prefilled from Scope answers.
* These questions should show "Aus Scope-Analyse uebernommen" hint.
*/
export const SCOPE_PREFILLED_VVT_QUESTIONS = [
'org_industry',
'org_employees',
'org_b2b_b2c',
'dept_hr',
'dept_finance',
'dept_marketing',
'data_health',
'data_minors',
'data_biometric',
'data_criminal',
'special_ai',
'special_video_surveillance',
'special_tracking',
'transfer_cloud_us',
'transfer_subprocessor',
'transfer_support_non_eu',
]

View File

@@ -0,0 +1,247 @@
/**
* VVT (Verarbeitungsverzeichnis) Types — Art. 30 DSGVO
*
* Re-exports common types from vendor-compliance/types.ts and adds
* VVT-specific interfaces for the 4-tab VVT module.
*/
// Re-exports from vendor-compliance/types.ts
export type {
DataSubjectCategory,
PersonalDataCategory,
LegalBasisType,
TransferMechanismType,
RecipientCategoryType,
ProcessingActivityStatus,
ProtectionLevel,
ThirdCountryTransfer,
RetentionPeriod,
LegalBasis,
RecipientCategory,
DataSource,
SystemReference,
DataFlow,
DataSourceType,
LocalizedText,
} from './vendor-compliance/types'
export {
DATA_SUBJECT_CATEGORY_META,
PERSONAL_DATA_CATEGORY_META,
LEGAL_BASIS_META,
TRANSFER_MECHANISM_META,
isSpecialCategory,
hasAdequacyDecision,
generateVVTId,
} from './vendor-compliance/types'
// =============================================================================
// VVT-SPECIFIC TYPES
// =============================================================================
export interface VVTOrganizationHeader {
organizationName: string
industry: string
locations: string[]
employeeCount: number
dpoName: string
dpoContact: string
vvtVersion: string
lastReviewDate: string
nextReviewDate: string
reviewInterval: 'quarterly' | 'semi_annual' | 'annual'
}
export type BusinessFunction =
| 'hr'
| 'finance'
| 'sales_crm'
| 'marketing'
| 'support'
| 'it_operations'
| 'product_engineering'
| 'legal'
| 'management'
| 'other'
export interface StructuredTOMs {
accessControl: string[]
confidentiality: string[]
integrity: string[]
availability: string[]
separation: string[]
}
export interface VVTActivity {
// Pflichtfelder Art. 30 Abs. 1 (Controller)
id: string
vvtId: string
name: string
description: string
purposes: string[]
legalBases: { type: string; description?: string; reference?: string }[]
dataSubjectCategories: string[]
personalDataCategories: string[]
recipientCategories: { type: string; name: string; description?: string; isThirdCountry?: boolean; country?: string }[]
thirdCountryTransfers: { country: string; recipient: string; transferMechanism: string; additionalMeasures?: string[] }[]
retentionPeriod: { duration?: number; durationUnit?: string; description: string; legalBasis?: string; deletionProcedure?: string }
tomDescription: string
// Generator-Optimierung (Layer B)
businessFunction: BusinessFunction
systems: { systemId: string; name: string; description?: string; type?: string }[]
deploymentModel: 'cloud' | 'on_prem' | 'hybrid'
dataSources: { type: string; description?: string }[]
dataFlows: { sourceSystem?: string; targetSystem?: string; description: string; dataCategories: string[] }[]
protectionLevel: 'LOW' | 'MEDIUM' | 'HIGH'
dpiaRequired: boolean
structuredToms: StructuredTOMs
// Workflow
status: 'DRAFT' | 'REVIEW' | 'APPROVED' | 'ARCHIVED'
responsible: string
owner: string
createdAt: string
updatedAt: string
}
// Processor-Record (Art. 30 Abs. 2)
export interface VVTProcessorActivity {
id: string
vvtId: string
controllerReference: string
processingCategories: string[]
subProcessorChain: SubProcessor[]
thirdCountryTransfers: { country: string; recipient: string; transferMechanism: string }[]
tomDescription: string
status: 'DRAFT' | 'REVIEW' | 'APPROVED' | 'ARCHIVED'
}
export interface SubProcessor {
name: string
purpose: string
country: string
isThirdCountry: boolean
}
// =============================================================================
// CONSTANTS
// =============================================================================
export const BUSINESS_FUNCTION_LABELS: Record<BusinessFunction, string> = {
hr: 'Personal (HR)',
finance: 'Finanzen & Buchhaltung',
sales_crm: 'Vertrieb & CRM',
marketing: 'Marketing',
support: 'Kundenservice',
it_operations: 'IT-Betrieb',
product_engineering: 'Produktentwicklung',
legal: 'Recht & Compliance',
management: 'Geschaeftsfuehrung',
other: 'Sonstiges',
}
export const STATUS_LABELS: Record<string, string> = {
DRAFT: 'Entwurf',
REVIEW: 'In Pruefung',
APPROVED: 'Genehmigt',
ARCHIVED: 'Archiviert',
}
export const STATUS_COLORS: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700',
REVIEW: 'bg-yellow-100 text-yellow-700',
APPROVED: 'bg-green-100 text-green-700',
ARCHIVED: 'bg-red-100 text-red-700',
}
export const PROTECTION_LEVEL_LABELS: Record<string, string> = {
LOW: 'Niedrig',
MEDIUM: 'Mittel',
HIGH: 'Hoch',
}
export const DEPLOYMENT_LABELS: Record<string, string> = {
cloud: 'Cloud',
on_prem: 'On-Premise',
hybrid: 'Hybrid',
}
export const REVIEW_INTERVAL_LABELS: Record<string, string> = {
quarterly: 'Vierteljaehrlich',
semi_annual: 'Halbjaehrlich',
annual: 'Jaehrlich',
}
// Art. 9 special categories for highlighting
export const ART9_CATEGORIES: string[] = [
'HEALTH_DATA',
'GENETIC_DATA',
'BIOMETRIC_DATA',
'RACIAL_ETHNIC',
'POLITICAL_OPINIONS',
'RELIGIOUS_BELIEFS',
'TRADE_UNION',
'SEX_LIFE',
'CRIMINAL_DATA',
]
// =============================================================================
// HELPER: Create empty activity
// =============================================================================
export function createEmptyActivity(vvtId: string): VVTActivity {
const now = new Date().toISOString()
return {
id: crypto.randomUUID(),
vvtId,
name: '',
description: '',
purposes: [],
legalBases: [],
dataSubjectCategories: [],
personalDataCategories: [],
recipientCategories: [],
thirdCountryTransfers: [],
retentionPeriod: { description: '', legalBasis: '', deletionProcedure: '' },
tomDescription: '',
businessFunction: 'other',
systems: [],
deploymentModel: 'cloud',
dataSources: [],
dataFlows: [],
protectionLevel: 'MEDIUM',
dpiaRequired: false,
structuredToms: {
accessControl: [],
confidentiality: [],
integrity: [],
availability: [],
separation: [],
},
status: 'DRAFT',
responsible: '',
owner: '',
createdAt: now,
updatedAt: now,
}
}
// =============================================================================
// HELPER: Default organization header
// =============================================================================
export function createDefaultOrgHeader(): VVTOrganizationHeader {
return {
organizationName: '',
industry: '',
locations: [],
employeeCount: 0,
dpoName: '',
dpoContact: '',
vvtVersion: '1.0',
lastReviewDate: new Date().toISOString().split('T')[0],
nextReviewDate: '',
reviewInterval: 'annual',
}
}

View File

@@ -0,0 +1,66 @@
# 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**: Beantworte nur Fragen zu DSGVO, BDSG, AI Act, TTDSG, ePrivacy
## 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)
- ISO 27001/27701 (Ueberblick)
## 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)
## Beispiel-Fragen pro SDK-Schritt
- VVT: "Was muss in einem Verarbeitungsverzeichnis stehen?"
- DSFA: "Wann ist eine Datenschutz-Folgenabschaetzung Pflicht?"
- TOM: "Welche technischen Massnahmen fordert Art. 32 DSGVO?"
- Loeschfristen: "Wie lange darf ich Bewerbungsunterlagen aufbewahren?"
- Cookie Banner: "Brauche ich ein Cookie-Banner fuer funktionale Cookies?"
- Einwilligungen: "Was sind die Anforderungen an eine wirksame Einwilligung?"
- Compliance Scope: "Was bedeutet Level L3 fuer mein Unternehmen?"
## 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 widersprüchlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
- Bei dringenden Datenpannen: Auf 72-Stunden-Frist (Art. 33 DSGVO) hinweisen und Notfallplan-Modul empfehlen
## Metrik-Ziele
- Quellenangabe in > 95% der Antworten
- Verstaendlichkeits-Score > 85%
- Nutzer-Zufriedenheit > 4.0/5.0
- Durchschnittliche Antwortzeit < 3 Sekunden