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:
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
674
admin-v2/app/(admin)/ai/rag-pipeline/dsfa/page.tsx
Normal file
674
admin-v2/app/(admin)/ai/rag-pipeline/dsfa/page.tsx
Normal 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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
39
admin-v2/app/(admin)/development/companion/page.tsx
Normal file
39
admin-v2/app/(admin)/development/companion/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
admin-v2/app/(admin)/education/companion/page.tsx
Normal file
76
admin-v2/app/(admin)/education/companion/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
596
admin-v2/app/(sdk)/sdk/advisory-board/documentation/page.tsx
Normal file
596
admin-v2/app/(sdk)/sdk/advisory-board/documentation/page.tsx
Normal 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 < 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 > 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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
343
admin-v2/app/(sdk)/sdk/audit-report/page.tsx
Normal file
343
admin-v2/app/(sdk)/sdk/audit-report/page.tsx
Normal 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">×</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>
|
||||
)
|
||||
}
|
||||
521
admin-v2/app/(sdk)/sdk/compliance-hub/page.tsx
Normal file
521
admin-v2/app/(sdk)/sdk/compliance-hub/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
385
admin-v2/app/(sdk)/sdk/compliance-scope/page.tsx
Normal file
385
admin-v2/app/(sdk)/sdk/compliance-scope/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
615
admin-v2/app/(sdk)/sdk/consent-management/page.tsx
Normal file
615
admin-v2/app/(sdk)/sdk/consent-management/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
357
admin-v2/app/(sdk)/sdk/dsms/page.tsx
Normal file
357
admin-v2/app/(sdk)/sdk/dsms/page.tsx
Normal 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
1147
admin-v2/app/(sdk)/sdk/notfallplan/page.tsx
Normal file
1147
admin-v2/app/(sdk)/sdk/notfallplan/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
202
admin-v2/app/(sdk)/sdk/source-policy/page.tsx
Normal file
202
admin-v2/app/(sdk)/sdk/source-policy/page.tsx
Normal 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">
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
15
admin-v2/app/(sdk)/sdk/tom/layout.tsx
Normal file
15
admin-v2/app/(sdk)/sdk/tom/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 – 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
1070
admin-v2/app/(sdk)/sdk/workflow/page.tsx
Normal file
1070
admin-v2/app/(sdk)/sdk/workflow/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
129
admin-v2/app/api/admin/companion/feedback/route.ts
Normal file
129
admin-v2/app/api/admin/companion/feedback/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
194
admin-v2/app/api/admin/companion/lesson/route.ts
Normal file
194
admin-v2/app/api/admin/companion/lesson/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
102
admin-v2/app/api/admin/companion/route.ts
Normal file
102
admin-v2/app/api/admin/companion/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
137
admin-v2/app/api/admin/companion/settings/route.ts
Normal file
137
admin-v2/app/api/admin/companion/settings/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
217
admin-v2/app/api/sdk/compliance-advisor/chat/route.ts
Normal file
217
admin-v2/app/api/sdk/compliance-advisor/chat/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
312
admin-v2/components/companion/CompanionDashboard.tsx
Normal file
312
admin-v2/components/companion/CompanionDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
admin-v2/components/companion/ModeToggle.tsx
Normal file
61
admin-v2/components/companion/ModeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
admin-v2/components/companion/companion-mode/EventsCard.tsx
Normal file
173
admin-v2/components/companion/companion-mode/EventsCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
203
admin-v2/components/companion/companion-mode/PhaseTimeline.tsx
Normal file
203
admin-v2/components/companion/companion-mode/PhaseTimeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
admin-v2/components/companion/companion-mode/StatsGrid.tsx
Normal file
114
admin-v2/components/companion/companion-mode/StatsGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
170
admin-v2/components/companion/companion-mode/SuggestionList.tsx
Normal file
170
admin-v2/components/companion/companion-mode/SuggestionList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
admin-v2/components/companion/index.ts
Normal file
24
admin-v2/components/companion/index.ts
Normal 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'
|
||||
153
admin-v2/components/companion/lesson-mode/HomeworkSection.tsx
Normal file
153
admin-v2/components/companion/lesson-mode/HomeworkSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
admin-v2/components/companion/lesson-mode/LessonActiveView.tsx
Normal file
172
admin-v2/components/companion/lesson-mode/LessonActiveView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
209
admin-v2/components/companion/lesson-mode/LessonEndedView.tsx
Normal file
209
admin-v2/components/companion/lesson-mode/LessonEndedView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
269
admin-v2/components/companion/lesson-mode/LessonStartForm.tsx
Normal file
269
admin-v2/components/companion/lesson-mode/LessonStartForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
194
admin-v2/components/companion/lesson-mode/QuickActionsBar.tsx
Normal file
194
admin-v2/components/companion/lesson-mode/QuickActionsBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
146
admin-v2/components/companion/lesson-mode/ReflectionSection.tsx
Normal file
146
admin-v2/components/companion/lesson-mode/ReflectionSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
220
admin-v2/components/companion/lesson-mode/VisualPieTimer.tsx
Normal file
220
admin-v2/components/companion/lesson-mode/VisualPieTimer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
201
admin-v2/components/companion/modals/FeedbackModal.tsx
Normal file
201
admin-v2/components/companion/modals/FeedbackModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
280
admin-v2/components/companion/modals/OnboardingModal.tsx
Normal file
280
admin-v2/components/companion/modals/OnboardingModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
admin-v2/components/companion/modals/SettingsModal.tsx
Normal file
248
admin-v2/components/companion/modals/SettingsModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
442
admin-v2/components/sdk/ComplianceAdvisorWidget.tsx
Normal file
442
admin-v2/components/sdk/ComplianceAdvisorWidget.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'agent'
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
interface ComplianceAdvisorWidgetProps {
|
||||
currentStep?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXAMPLE QUESTIONS BY STEP
|
||||
// =============================================================================
|
||||
|
||||
const EXAMPLE_QUESTIONS: Record<string, string[]> = {
|
||||
vvt: [
|
||||
'Was ist ein Verarbeitungsverzeichnis?',
|
||||
'Welche Informationen muss ich erfassen?',
|
||||
'Wie dokumentiere ich die Rechtsgrundlage?',
|
||||
],
|
||||
'compliance-scope': [
|
||||
'Was bedeutet L3?',
|
||||
'Wann brauche ich eine DSFA?',
|
||||
'Was ist der Unterschied zwischen L2 und L3?',
|
||||
],
|
||||
tom: [
|
||||
'Was sind TOM?',
|
||||
'Welche Massnahmen sind erforderlich?',
|
||||
'Wie dokumentiere ich Verschluesselung?',
|
||||
],
|
||||
dsfa: [
|
||||
'Was ist eine DSFA?',
|
||||
'Wann ist eine DSFA verpflichtend?',
|
||||
'Wie bewerte ich Risiken?',
|
||||
],
|
||||
loeschfristen: [
|
||||
'Wie definiere ich Loeschfristen?',
|
||||
'Was ist der Unterschied zwischen Loeschpflicht und Aufbewahrungspflicht?',
|
||||
'Wann muss ich Daten loeschen?',
|
||||
],
|
||||
default: [
|
||||
'Wie starte ich mit dem SDK?',
|
||||
'Was ist der erste Schritt?',
|
||||
'Welche Compliance-Anforderungen gelten fuer KI-Systeme?',
|
||||
],
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Get example questions for current step
|
||||
const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// Cleanup abort controller on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortControllerRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle send message with real LLM + RAG
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content.trim() || isTyping) return
|
||||
|
||||
const userMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: content.trim(),
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
setInputValue('')
|
||||
setIsTyping(true)
|
||||
|
||||
const agentMessageId = `msg-${Date.now()}-agent`
|
||||
|
||||
// Create abort controller for this request
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
try {
|
||||
// Build conversation history for context
|
||||
const history = messages.map((m) => ({
|
||||
role: m.role === 'user' ? 'user' : 'assistant',
|
||||
content: m.content,
|
||||
}))
|
||||
|
||||
const response = await fetch('/api/sdk/compliance-advisor/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: content.trim(),
|
||||
history,
|
||||
currentStep,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unbekannter Fehler' }))
|
||||
throw new Error(errorData.error || `Server-Fehler (${response.status})`)
|
||||
}
|
||||
|
||||
// Add empty agent message for streaming
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: agentMessageId,
|
||||
role: 'agent',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
])
|
||||
|
||||
// Read streaming response
|
||||
const reader = response.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulated = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
accumulated += decoder.decode(value, { stream: true })
|
||||
|
||||
// Update agent message with accumulated content
|
||||
const currentText = accumulated
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === agentMessageId ? { ...m, content: currentText } : m))
|
||||
)
|
||||
}
|
||||
|
||||
setIsTyping(false)
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
// User cancelled, keep partial response
|
||||
setIsTyping(false)
|
||||
return
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Verbindung fehlgeschlagen'
|
||||
|
||||
// Add or update agent message with error
|
||||
setMessages((prev) => {
|
||||
const hasAgent = prev.some((m) => m.id === agentMessageId)
|
||||
if (hasAgent) {
|
||||
return prev.map((m) =>
|
||||
m.id === agentMessageId
|
||||
? { ...m, content: `Fehler: ${errorMessage}` }
|
||||
: m
|
||||
)
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: agentMessageId,
|
||||
role: 'agent' as const,
|
||||
content: `Fehler: ${errorMessage}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]
|
||||
})
|
||||
setIsTyping(false)
|
||||
}
|
||||
},
|
||||
[isTyping, messages, currentStep]
|
||||
)
|
||||
|
||||
// Handle stop generation
|
||||
const handleStopGeneration = useCallback(() => {
|
||||
abortControllerRef.current?.abort()
|
||||
setIsTyping(false)
|
||||
}, [])
|
||||
|
||||
// Handle example question click
|
||||
const handleExampleClick = (question: string) => {
|
||||
handleSendMessage(question)
|
||||
}
|
||||
|
||||
// Handle key press
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage(inputValue)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-6 right-6 w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
|
||||
aria-label="Compliance Advisor oeffnen"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 w-[400px] h-[500px] bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-4 py-3 rounded-t-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm">Compliance Advisor</div>
|
||||
<div className="text-xs text-white/80">KI-gestuetzter Assistent</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-white/80 hover:text-white transition-colors"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-purple-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">
|
||||
Willkommen beim Compliance Advisor
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Stellen Sie Fragen zu DSGVO, KI-Verordnung und mehr.
|
||||
</p>
|
||||
|
||||
{/* Example Questions */}
|
||||
<div className="text-left space-y-2">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">
|
||||
Beispielfragen:
|
||||
</p>
|
||||
{exampleQuestions.map((question, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleExampleClick(question)}
|
||||
className="w-full text-left px-3 py-2 text-xs bg-white hover:bg-purple-50 border border-gray-200 rounded-lg transition-colors text-gray-700"
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${
|
||||
message.role === 'user' ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-3 py-2 ${
|
||||
message.role === 'user'
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-white border border-gray-200 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-sm ${message.role === 'agent' ? 'whitespace-pre-wrap' : ''}`}
|
||||
>
|
||||
{message.content || (message.role === 'agent' && isTyping ? '' : message.content)}
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
message.role === 'user'
|
||||
? 'text-indigo-200'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{message.timestamp.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
|
||||
<div
|
||||
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="border-t border-gray-200 p-3 bg-white rounded-b-2xl">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Frage eingeben..."
|
||||
disabled={isTyping}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50"
|
||||
/>
|
||||
{isTyping ? (
|
||||
<button
|
||||
onClick={handleStopGeneration}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
title="Generierung stoppen"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 6h12v12H6z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSendMessage(inputValue)}
|
||||
disabled={!inputValue.trim()}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -489,6 +489,30 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
isActive={pathname === '/sdk/security-backlog'}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/compliance-hub"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
}
|
||||
label="Compliance Hub"
|
||||
isActive={pathname === '/sdk/compliance-hub'}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/dsms"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
}
|
||||
label="DSMS"
|
||||
isActive={pathname === '/sdk/dsms'}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK, getStepById, getNextStep, getPreviousStep, SDKStep } from '@/lib/sdk'
|
||||
import { useSDK, getStepById, getNextStep, getPreviousStep, SDKStep, SDK_STEPS } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -133,7 +133,7 @@ export function StepHeader({
|
||||
|
||||
// Calculate step progress within phase
|
||||
const phaseSteps = currentStep ?
|
||||
(currentStep.phase === 1 ? 8 : 11) : 0
|
||||
SDK_STEPS.filter(s => s.phase === currentStep.phase).length : 0
|
||||
const stepNumber = currentStep?.order || 0
|
||||
|
||||
return (
|
||||
@@ -314,6 +314,28 @@ export const STEP_EXPLANATIONS = {
|
||||
},
|
||||
],
|
||||
},
|
||||
'compliance-scope': {
|
||||
title: 'Compliance Scope',
|
||||
description: 'Umfang und Tiefe Ihrer Compliance-Dokumentation bestimmen',
|
||||
explanation: 'Die Compliance Scope Engine bestimmt deterministisch, welche Dokumente Sie in welcher Tiefe benoetigen. Basierend auf 35 Fragen in 6 Bloecken werden Risiko-, Komplexitaets- und Assurance-Scores berechnet, die in ein 4-Level-Modell (L1 Lean bis L4 Zertifizierungsbereit) muenden.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'lightbulb' as const,
|
||||
title: 'Deterministisch',
|
||||
description: 'Alle Entscheidungen sind nachvollziehbar — keine KI, keine Black Box. Jede Einstufung wird mit Rechtsgrundlage und Audit-Trail begruendet.',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: '4-Level-Modell',
|
||||
description: 'L1 (Lean Startup) bis L4 (Zertifizierungsbereit). Hard Triggers (Art. 9, Minderjaehrige, Zertifizierungsziele) heben das Level automatisch an.',
|
||||
},
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'Hard Triggers',
|
||||
description: '50 deterministische Regeln pruefen besondere Kategorien (Art. 9), Minderjaehrige, KI-Einsatz, Drittlandtransfers und Zertifizierungsziele.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'use-case-assessment': {
|
||||
title: 'Anwendungsfall-Erfassung',
|
||||
description: 'Erfassen Sie Ihre KI-Anwendungsfälle systematisch',
|
||||
@@ -487,34 +509,44 @@ export const STEP_EXPLANATIONS = {
|
||||
'tom': {
|
||||
title: 'Technische und Organisatorische Massnahmen',
|
||||
description: 'Dokumentieren Sie Ihre TOMs nach Art. 32 DSGVO',
|
||||
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Sie umfassen Zutrittskontrolle, Zugangskontrolle, Zugriffskontrolle und mehr.',
|
||||
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Das Dashboard zeigt den Status aller aus dem TOM Generator abgeleiteten Massnahmen mit SDM-Mapping und Gap-Analyse.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'Kategorien',
|
||||
description: 'TOMs werden in technische (z.B. Verschluesselung) und organisatorische (z.B. Schulungen) Massnahmen unterteilt.',
|
||||
icon: 'warning' as const,
|
||||
title: 'Nachweispflicht',
|
||||
description: 'TOMs muessen nachweisbar real sein. Verknuepfen Sie Evidence-Dokumente (Policies, Zertifikate, Screenshots) mit jeder Massnahme, um die Rechenschaftspflicht (Art. 5 Abs. 2 DSGVO) zu erfuellen.',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Nachweis',
|
||||
description: 'Dokumentieren Sie fuer jede TOM einen Nachweis der Umsetzung.',
|
||||
icon: 'info' as const,
|
||||
title: 'Generator nutzen',
|
||||
description: 'Der 6-Schritt-Wizard leitet TOMs systematisch aus Ihrem Risikoprofil ab. Starten Sie dort, um eine vollstaendige Baseline zu erhalten.',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'SDM-Mapping',
|
||||
description: 'Kontrollen werden den 7 SDM-Gewaehrleistungszielen zugeordnet: Verfuegbarkeit, Integritaet, Vertraulichkeit, Nichtverkettung, Intervenierbarkeit, Transparenz, Datenminimierung.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'vvt': {
|
||||
title: 'Verarbeitungsverzeichnis',
|
||||
description: 'Erstellen Sie Ihr Verzeichnis nach Art. 30 DSGVO',
|
||||
explanation: 'Das Verarbeitungsverzeichnis dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Es ist fuer die meisten Unternehmen Pflicht.',
|
||||
description: 'Erstellen und verwalten Sie Ihr Verzeichnis nach Art. 30 DSGVO',
|
||||
explanation: 'Das Verarbeitungsverzeichnis (VVT) dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Der integrierte Generator-Fragebogen befuellt 70-90% der Pflichtfelder automatisch anhand Ihres Unternehmensprofils.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'Vollstaendigkeit',
|
||||
description: 'Das VVT muss alle Verarbeitungen enthalten. Fehlende Eintraege koennen bei Audits zu Beanstandungen fuehren.',
|
||||
title: 'Pflicht fuer alle',
|
||||
description: 'Die Ausnahme fuer Unternehmen <250 Mitarbeiter greift nur bei gelegentlicher, risikoarmer Verarbeitung ohne besondere Kategorien (Art. 30 Abs. 5).',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'Pflichtangaben',
|
||||
description: 'Jeder Eintrag muss enthalten: Zweck, Datenkategorien, Empfaenger, Loeschfristen, TOMs.',
|
||||
title: 'Zweck-zuerst',
|
||||
description: 'Definieren Sie Verarbeitungen nach Geschaeftszweck, nicht nach Tool. Ein Tool kann mehrere Verarbeitungen abdecken, eine Verarbeitung kann mehrere Tools nutzen.',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'Kein oeffentliches Dokument',
|
||||
description: 'Das VVT ist ein internes Dokument. Es muss der Aufsichtsbehoerde nur auf Verlangen vorgelegt werden (Art. 30 Abs. 4).',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -555,17 +587,22 @@ export const STEP_EXPLANATIONS = {
|
||||
'loeschfristen': {
|
||||
title: 'Loeschfristen',
|
||||
description: 'Definieren Sie Aufbewahrungsrichtlinien fuer Ihre Daten',
|
||||
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Nach Ablauf muessen die Daten geloescht oder anonymisiert werden.',
|
||||
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Die 3-Stufen-Logik (Zweckende, Aufbewahrungspflicht, Legal Hold) stellt sicher, dass alle gesetzlichen Anforderungen beruecksichtigt werden.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'Gesetzliche Fristen',
|
||||
description: 'Beachten Sie gesetzliche Aufbewahrungspflichten (z.B. Steuerrecht: 10 Jahre, Handelsrecht: 6 Jahre).',
|
||||
title: '3-Stufen-Logik',
|
||||
description: 'Jede Loeschfrist folgt einer 3-Stufen-Logik: 1. Zweckende (Daten werden nach Zweckwegfall geloescht), 2. Aufbewahrungspflicht (gesetzliche Fristen verhindern Loeschung), 3. Legal Hold (laufende Verfahren blockieren Loeschung).',
|
||||
},
|
||||
{
|
||||
icon: 'lightbulb' as const,
|
||||
title: 'Automatisierung',
|
||||
description: 'Richten Sie automatische Loeschprozesse ein, um Compliance sicherzustellen.',
|
||||
icon: 'info' as const,
|
||||
title: 'Deutsche Rechtsgrundlagen',
|
||||
description: 'Der Generator kennt die wichtigsten Aufbewahrungstreiber: AO (10 J. Steuer), HGB (10/6 J. Handel), UStG (10 J. Rechnungen), BGB (3 J. Verjaehrung), ArbZG (2 J. Zeiterfassung), AGG (6 Mon. Bewerbungen).',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'Backup-Behandlung',
|
||||
description: 'Auch Backups muessen ins Loeschkonzept einbezogen werden. Daten koennen nach primaerer Loeschung noch in Backup-Systemen existieren.',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -659,6 +696,91 @@ export const STEP_EXPLANATIONS = {
|
||||
},
|
||||
],
|
||||
},
|
||||
'source-policy': {
|
||||
title: 'Source Policy',
|
||||
description: 'Verwalten Sie Ihre Datenquellen-Governance',
|
||||
explanation: 'Die Source Policy definiert, welche externen Datenquellen fuer Ihre Anwendung zugelassen sind. Sie umfasst eine Whitelist, Operationsmatrix (Lookup, RAG, Training, Export), PII-Regeln und ein Audit-Trail.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'Lizenzierung',
|
||||
description: 'Pruefen Sie die Lizenzen aller Datenquellen (DL-DE-BY, CC-BY, CC0). Nicht-lizenzierte Quellen koennen rechtliche Risiken bergen.',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'PII-Regeln',
|
||||
description: 'Definieren Sie klare Regeln fuer den Umgang mit personenbezogenen Daten in externen Quellen.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'audit-report': {
|
||||
title: 'Audit Report',
|
||||
description: 'Erstellen und verwalten Sie Audit-Sitzungen',
|
||||
explanation: 'Im Audit Report erstellen Sie formelle Audit-Sitzungen mit Pruefer-Informationen, fuehren die Pruefung durch und generieren PDF-Reports. Jede Sitzung dokumentiert den Compliance-Stand zu einem bestimmten Zeitpunkt.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'lightbulb' as const,
|
||||
title: 'Regelmaessigkeit',
|
||||
description: 'Fuehren Sie mindestens jaehrlich ein formelles Audit durch. Dokumentieren Sie Abweichungen und Massnahmenplaene.',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'PDF-Export',
|
||||
description: 'Generieren Sie PDF-Reports in Deutsch oder Englisch fuer externe Pruefer.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'workflow': {
|
||||
title: 'Document Workflow',
|
||||
description: 'Verwalten Sie den Freigabe-Prozess Ihrer rechtlichen Dokumente',
|
||||
explanation: 'Der Document Workflow bietet einen Split-View-Editor mit synchronisiertem Scrollen. Dokumente durchlaufen den Status Draft → Review → Approved → Published. Aenderungen werden versioniert und der Freigabeprozess wird protokolliert.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'Vier-Augen-Prinzip',
|
||||
description: 'Rechtliche Dokumente sollten immer von mindestens einer weiteren Person geprueft werden, bevor sie veroeffentlicht werden.',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'Versionierung',
|
||||
description: 'Jede Aenderung wird als neue Version gespeichert. So koennen Sie jederzeit den Stand eines Dokuments nachvollziehen.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'consent-management': {
|
||||
title: 'Consent Verwaltung',
|
||||
description: 'Verwalten Sie Consent-Dokumente, Versionen und DSGVO-Prozesse',
|
||||
explanation: 'Die Consent Verwaltung umfasst das Lifecycle-Management Ihrer rechtlichen Dokumente (AGB, Datenschutz, Cookie-Richtlinien), die Verwaltung von E-Mail-Templates (16 Lifecycle-E-Mails) und die Steuerung der DSGVO-Prozesse (Art. 15-21).',
|
||||
tips: [
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'Dokumentversionen',
|
||||
description: 'Jede Aenderung an einem Consent-Dokument erzeugt eine neue Version. Aktive Nutzer muessen bei Aenderungen erneut zustimmen.',
|
||||
},
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'DSGVO-Fristen',
|
||||
description: 'Betroffenenrechte (Art. 15-21) haben gesetzliche Fristen. Auskunft: 30 Tage, Loeschung: unverzueglich.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'notfallplan': {
|
||||
title: 'Notfallplan & Breach Response',
|
||||
description: 'Verwalten Sie Ihr Datenpannen-Management nach Art. 33/34 DSGVO',
|
||||
explanation: 'Der Notfallplan definiert Ihren Prozess bei Datenpannen gemaess Art. 33/34 DSGVO. Er umfasst die 72-Stunden-Meldepflicht an die Aufsichtsbehoerde, die Benachrichtigung betroffener Personen bei hohem Risiko, Incident-Klassifizierung, Eskalationswege und Dokumentationspflichten.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: '72-Stunden-Frist',
|
||||
description: 'Art. 33 DSGVO: Meldung an die Aufsichtsbehoerde innerhalb von 72 Stunden nach Bekanntwerden. Verspaetete Meldungen muessen begruendet werden.',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'Dokumentationspflicht',
|
||||
description: 'Art. 33 Abs. 5: Alle Datenpannen muessen dokumentiert werden — auch solche, die nicht meldepflichtig sind. Die Dokumentation muss der Aufsichtsbehoerde auf Verlangen vorgelegt werden koennen.',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default StepHeader
|
||||
|
||||
362
admin-v2/components/sdk/compliance-scope/ScopeDecisionTab.tsx
Normal file
362
admin-v2/components/sdk/compliance-scope/ScopeDecisionTab.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
import type { ScopeDecision, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
|
||||
import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_DESCRIPTIONS, DEPTH_LEVEL_COLORS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
|
||||
|
||||
interface ScopeDecisionTabProps {
|
||||
decision: ScopeDecision | null
|
||||
}
|
||||
|
||||
export function ScopeDecisionTab({ decision }: ScopeDecisionTabProps) {
|
||||
const [expandedTrigger, setExpandedTrigger] = useState<number | null>(null)
|
||||
const [showAuditTrail, setShowAuditTrail] = useState(false)
|
||||
|
||||
if (!decision) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 rounded-full mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Keine Entscheidung vorhanden</h3>
|
||||
<p className="text-gray-600">Bitte führen Sie zuerst das Scope-Profiling durch.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 80) return 'from-red-500 to-red-600'
|
||||
if (score >= 60) return 'from-orange-500 to-orange-600'
|
||||
if (score >= 40) return 'from-yellow-500 to-yellow-600'
|
||||
return 'from-green-500 to-green-600'
|
||||
}
|
||||
|
||||
const getSeverityBadge = (severity: 'low' | 'medium' | 'high' | 'critical') => {
|
||||
const colors = {
|
||||
low: 'bg-gray-100 text-gray-800',
|
||||
medium: 'bg-yellow-100 text-yellow-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
critical: 'bg-red-100 text-red-800',
|
||||
}
|
||||
const labels = {
|
||||
low: 'Niedrig',
|
||||
medium: 'Mittel',
|
||||
high: 'Hoch',
|
||||
critical: 'Kritisch',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[severity]}`}>
|
||||
{labels[severity]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderScoreBar = (label: string, score: number | undefined) => {
|
||||
const value = score ?? 0
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||
<span className="text-sm font-bold text-gray-900">{value}/100</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={`h-full bg-gradient-to-r ${getScoreColor(value)} transition-all duration-500`}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Level Determination */}
|
||||
<div className={`${DEPTH_LEVEL_COLORS[decision.level].bg} border-2 ${DEPTH_LEVEL_COLORS[decision.level].border} rounded-xl p-6`}>
|
||||
<div className="flex items-start gap-6">
|
||||
<div className={`flex-shrink-0 w-20 h-20 ${DEPTH_LEVEL_COLORS[decision.level].badge} rounded-xl flex items-center justify-center`}>
|
||||
<span className={`text-3xl font-bold ${DEPTH_LEVEL_COLORS[decision.level].text}`}>
|
||||
{decision.level}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className={`text-2xl font-bold ${DEPTH_LEVEL_COLORS[decision.level].text} mb-2`}>
|
||||
{DEPTH_LEVEL_LABELS[decision.level]}
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">{DEPTH_LEVEL_DESCRIPTIONS[decision.level]}</p>
|
||||
{decision.reasoning && (
|
||||
<p className="text-sm text-gray-600 italic">{decision.reasoning}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score Breakdown */}
|
||||
{decision.scores && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Score-Analyse</h3>
|
||||
<div className="space-y-4">
|
||||
{renderScoreBar('Risiko-Score', decision.scores.riskScore)}
|
||||
{renderScoreBar('Komplexitäts-Score', decision.scores.complexityScore)}
|
||||
{renderScoreBar('Assurance-Score', decision.scores.assuranceScore)}
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
{renderScoreBar('Gesamt-Score', decision.scores.compositeScore)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hard Triggers */}
|
||||
{decision.hardTriggers && decision.hardTriggers.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Hard-Trigger</h3>
|
||||
<div className="space-y-3">
|
||||
{decision.hardTriggers.map((trigger, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`border rounded-lg overflow-hidden ${
|
||||
trigger.matched ? 'border-red-300 bg-red-50' : 'border-gray-200 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedTrigger(expandedTrigger === idx ? null : idx)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-opacity-80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{trigger.matched && (
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="font-medium text-gray-900">{trigger.label}</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-500 transition-transform ${
|
||||
expandedTrigger === idx ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{expandedTrigger === idx && (
|
||||
<div className="px-4 pb-4 pt-2 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-700 mb-2">{trigger.description}</p>
|
||||
{trigger.legalReference && (
|
||||
<p className="text-xs text-gray-600 mb-2">
|
||||
<span className="font-medium">Rechtsgrundlage:</span> {trigger.legalReference}
|
||||
</p>
|
||||
)}
|
||||
{trigger.matchedValue && (
|
||||
<p className="text-xs text-gray-700">
|
||||
<span className="font-medium">Erfasster Wert:</span> {trigger.matchedValue}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Required Documents */}
|
||||
{decision.requiredDocuments && decision.requiredDocuments.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Erforderliche Dokumente</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Typ</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Tiefe</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aufwand</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Status</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{decision.requiredDocuments.map((doc, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100 last:border-b-0 hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">
|
||||
{DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType}
|
||||
</span>
|
||||
{doc.isMandatory && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
||||
Pflicht
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-700">{doc.depthDescription}</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-700">
|
||||
{doc.effortEstimate ? `${doc.effortEstimate.days} Tage` : '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{doc.triggeredByHardTrigger && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
Hard-Trigger
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{doc.sdkStepUrl && (
|
||||
<a
|
||||
href={doc.sdkStepUrl}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
Zum SDK-Schritt →
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Risk Flags */}
|
||||
{decision.riskFlags && decision.riskFlags.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Risiko-Flags</h3>
|
||||
<div className="space-y-4">
|
||||
{decision.riskFlags.map((flag, idx) => (
|
||||
<div key={idx} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900">{flag.title}</h4>
|
||||
{getSeverityBadge(flag.severity)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 mb-2">{flag.description}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">Empfehlung:</span> {flag.recommendation}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap Analysis */}
|
||||
{decision.gapAnalysis && decision.gapAnalysis.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Gap-Analyse</h3>
|
||||
<div className="space-y-4">
|
||||
{decision.gapAnalysis.map((gap, idx) => (
|
||||
<div key={idx} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900">{gap.title}</h4>
|
||||
{getSeverityBadge(gap.severity)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 mb-2">{gap.description}</p>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">Empfehlung:</span> {gap.recommendation}
|
||||
</p>
|
||||
{gap.relatedDocuments && gap.relatedDocuments.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<span className="text-xs text-gray-500">Betroffene Dokumente: </span>
|
||||
{gap.relatedDocuments.map((doc, docIdx) => (
|
||||
<span
|
||||
key={docIdx}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 mr-1"
|
||||
>
|
||||
{DOCUMENT_TYPE_LABELS[doc] || doc}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next Actions */}
|
||||
{decision.nextActions && decision.nextActions.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Nächste Schritte</h3>
|
||||
<div className="space-y-4">
|
||||
{decision.nextActions.map((action, idx) => (
|
||||
<div key={idx} className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-purple-700">{action.priority}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">{action.title}</h4>
|
||||
<p className="text-sm text-gray-700 mb-2">{action.description}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{action.effortDays && (
|
||||
<span className="text-xs text-gray-600">
|
||||
<span className="font-medium">Aufwand:</span> {action.effortDays} Tage
|
||||
</span>
|
||||
)}
|
||||
{action.relatedDocuments && action.relatedDocuments.length > 0 && (
|
||||
<span className="text-xs text-gray-600">
|
||||
<span className="font-medium">Dokumente:</span> {action.relatedDocuments.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audit Trail */}
|
||||
{decision.auditTrail && decision.auditTrail.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAuditTrail(!showAuditTrail)}
|
||||
className="w-full flex items-center justify-between mb-4"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Audit-Trail</h3>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-500 transition-transform ${showAuditTrail ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{showAuditTrail && (
|
||||
<div className="space-y-3">
|
||||
{decision.auditTrail.map((entry, idx) => (
|
||||
<div key={idx} className="border-l-2 border-purple-300 pl-4 py-2">
|
||||
<h4 className="font-medium text-gray-900 mb-1">{entry.step}</h4>
|
||||
<p className="text-sm text-gray-700 mb-2">{entry.description}</p>
|
||||
{entry.details && entry.details.length > 0 && (
|
||||
<ul className="text-xs text-gray-600 space-y-1">
|
||||
{entry.details.map((detail, detailIdx) => (
|
||||
<li key={detailIdx}>• {detail}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
334
admin-v2/components/sdk/compliance-scope/ScopeExportTab.tsx
Normal file
334
admin-v2/components/sdk/compliance-scope/ScopeExportTab.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
'use client'
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import type { ScopeDecision, ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types'
|
||||
import { DEPTH_LEVEL_LABELS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
|
||||
|
||||
interface ScopeExportTabProps {
|
||||
decision: ScopeDecision | null
|
||||
answers: ScopeProfilingAnswer[]
|
||||
}
|
||||
|
||||
export function ScopeExportTab({ decision, answers }: ScopeExportTabProps) {
|
||||
const [copiedMarkdown, setCopiedMarkdown] = useState(false)
|
||||
|
||||
const handleDownloadJSON = useCallback(() => {
|
||||
if (!decision) return
|
||||
const dataStr = JSON.stringify(decision, null, 2)
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(dataBlob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `compliance-scope-decision-${new Date().toISOString().split('T')[0]}.json`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [decision])
|
||||
|
||||
const handleDownloadCSV = useCallback(() => {
|
||||
if (!decision || !decision.requiredDocuments) return
|
||||
|
||||
const headers = ['Typ', 'Tiefe', 'Aufwand (Tage)', 'Pflicht', 'Hard-Trigger']
|
||||
const rows = decision.requiredDocuments.map((doc) => [
|
||||
DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType,
|
||||
doc.depthDescription,
|
||||
doc.effortEstimate?.days?.toString() || '0',
|
||||
doc.isMandatory ? 'Ja' : 'Nein',
|
||||
doc.triggeredByHardTrigger ? 'Ja' : 'Nein',
|
||||
])
|
||||
|
||||
const csvContent = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n')
|
||||
|
||||
const dataBlob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(dataBlob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `compliance-scope-documents-${new Date().toISOString().split('T')[0]}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [decision])
|
||||
|
||||
const generateMarkdownSummary = useCallback(() => {
|
||||
if (!decision) return ''
|
||||
|
||||
let markdown = `# Compliance Scope Entscheidung\n\n`
|
||||
markdown += `**Datum:** ${new Date().toLocaleDateString('de-DE')}\n\n`
|
||||
markdown += `## Einstufung\n\n`
|
||||
markdown += `**Level:** ${decision.level} - ${DEPTH_LEVEL_LABELS[decision.level]}\n\n`
|
||||
if (decision.reasoning) {
|
||||
markdown += `**Begründung:** ${decision.reasoning}\n\n`
|
||||
}
|
||||
|
||||
if (decision.scores) {
|
||||
markdown += `## Scores\n\n`
|
||||
markdown += `- **Risiko-Score:** ${decision.scores.riskScore}/100\n`
|
||||
markdown += `- **Komplexitäts-Score:** ${decision.scores.complexityScore}/100\n`
|
||||
markdown += `- **Assurance-Score:** ${decision.scores.assuranceScore}/100\n`
|
||||
markdown += `- **Gesamt-Score:** ${decision.scores.compositeScore}/100\n\n`
|
||||
}
|
||||
|
||||
if (decision.hardTriggers && decision.hardTriggers.length > 0) {
|
||||
const matchedTriggers = decision.hardTriggers.filter((ht) => ht.matched)
|
||||
if (matchedTriggers.length > 0) {
|
||||
markdown += `## Aktive Hard-Trigger\n\n`
|
||||
matchedTriggers.forEach((trigger) => {
|
||||
markdown += `- **${trigger.label}**\n`
|
||||
markdown += ` - ${trigger.description}\n`
|
||||
if (trigger.legalReference) {
|
||||
markdown += ` - Rechtsgrundlage: ${trigger.legalReference}\n`
|
||||
}
|
||||
})
|
||||
markdown += `\n`
|
||||
}
|
||||
}
|
||||
|
||||
if (decision.requiredDocuments && decision.requiredDocuments.length > 0) {
|
||||
markdown += `## Erforderliche Dokumente\n\n`
|
||||
markdown += `| Typ | Tiefe | Aufwand | Pflicht | Hard-Trigger |\n`
|
||||
markdown += `|-----|-------|---------|---------|-------------|\n`
|
||||
decision.requiredDocuments.forEach((doc) => {
|
||||
markdown += `| ${DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType} | ${doc.depthDescription} | ${
|
||||
doc.effortEstimate?.days || 0
|
||||
} Tage | ${doc.isMandatory ? 'Ja' : 'Nein'} | ${doc.triggeredByHardTrigger ? 'Ja' : 'Nein'} |\n`
|
||||
})
|
||||
markdown += `\n`
|
||||
}
|
||||
|
||||
if (decision.riskFlags && decision.riskFlags.length > 0) {
|
||||
markdown += `## Risiko-Flags\n\n`
|
||||
decision.riskFlags.forEach((flag) => {
|
||||
markdown += `### ${flag.title} (${flag.severity})\n\n`
|
||||
markdown += `${flag.description}\n\n`
|
||||
markdown += `**Empfehlung:** ${flag.recommendation}\n\n`
|
||||
})
|
||||
}
|
||||
|
||||
if (decision.nextActions && decision.nextActions.length > 0) {
|
||||
markdown += `## Nächste Schritte\n\n`
|
||||
decision.nextActions.forEach((action) => {
|
||||
markdown += `${action.priority}. **${action.title}**\n`
|
||||
markdown += ` ${action.description}\n`
|
||||
if (action.effortDays) {
|
||||
markdown += ` Aufwand: ${action.effortDays} Tage\n`
|
||||
}
|
||||
markdown += `\n`
|
||||
})
|
||||
}
|
||||
|
||||
return markdown
|
||||
}, [decision])
|
||||
|
||||
const handleCopyMarkdown = useCallback(() => {
|
||||
const markdown = generateMarkdownSummary()
|
||||
navigator.clipboard.writeText(markdown).then(() => {
|
||||
setCopiedMarkdown(true)
|
||||
setTimeout(() => setCopiedMarkdown(false), 2000)
|
||||
})
|
||||
}, [generateMarkdownSummary])
|
||||
|
||||
const handlePrintView = useCallback(() => {
|
||||
if (!decision) return
|
||||
|
||||
const markdown = generateMarkdownSummary()
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Compliance Scope Entscheidung</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { color: #7c3aed; border-bottom: 3px solid #7c3aed; padding-bottom: 10px; }
|
||||
h2 { color: #5b21b6; margin-top: 30px; border-bottom: 2px solid #e5e7eb; padding-bottom: 8px; }
|
||||
h3 { color: #4c1d95; margin-top: 20px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 12px; text-align: left; }
|
||||
th { background-color: #f3f4f6; font-weight: 600; }
|
||||
ul { list-style-type: disc; padding-left: 20px; }
|
||||
li { margin: 8px 0; }
|
||||
@media print {
|
||||
body { margin: 20px; }
|
||||
h1, h2, h3 { page-break-after: avoid; }
|
||||
table { page-break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<pre style="white-space: pre-wrap; font-family: inherit;">${markdown}</pre>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (printWindow) {
|
||||
printWindow.document.write(htmlContent)
|
||||
printWindow.document.close()
|
||||
printWindow.focus()
|
||||
setTimeout(() => printWindow.print(), 250)
|
||||
}
|
||||
}, [decision, generateMarkdownSummary])
|
||||
|
||||
if (!decision) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 rounded-full mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Keine Daten zum Export</h3>
|
||||
<p className="text-gray-600">Bitte führen Sie zuerst das Scope-Profiling durch.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* JSON Export */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-semibold text-gray-900">JSON Export</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Exportieren Sie die vollständige Entscheidung als strukturierte JSON-Datei für weitere Verarbeitung oder
|
||||
Archivierung.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDownloadJSON}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
JSON herunterladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSV Export */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-semibold text-gray-900">CSV Export</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Exportieren Sie die Liste der erforderlichen Dokumente als CSV-Datei für Excel, Google Sheets oder andere
|
||||
Tabellenkalkulationen.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDownloadCSV}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
CSV herunterladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markdown Summary */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Markdown-Zusammenfassung</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Strukturierte Zusammenfassung im Markdown-Format für Dokumentation oder Berichte.
|
||||
</p>
|
||||
<textarea
|
||||
readOnly
|
||||
value={generateMarkdownSummary()}
|
||||
className="w-full h-64 px-4 py-3 border border-gray-300 rounded-lg font-mono text-sm text-gray-700 resize-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyMarkdown}
|
||||
className="mt-3 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
{copiedMarkdown ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Print View */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Druckansicht</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Öffnen Sie eine druckfreundliche HTML-Ansicht der Entscheidung in einem neuen Fenster.
|
||||
</p>
|
||||
<button
|
||||
onClick={handlePrintView}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
Druckansicht öffnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Info */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-blue-900 mb-1">Export-Hinweise</h4>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
<li>• JSON-Exporte enthalten alle Daten und können wieder importiert werden</li>
|
||||
<li>• CSV-Exporte sind ideal für Tabellenkalkulation und Aufwandsschätzungen</li>
|
||||
<li>• Markdown eignet sich für Dokumentation und Berichte</li>
|
||||
<li>• Die Druckansicht ist optimiert für PDF-Export über den Browser</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
267
admin-v2/components/sdk/compliance-scope/ScopeOverviewTab.tsx
Normal file
267
admin-v2/components/sdk/compliance-scope/ScopeOverviewTab.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import type { ComplianceScopeState, ScopeDecision, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
|
||||
import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_DESCRIPTIONS, DEPTH_LEVEL_COLORS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
|
||||
|
||||
interface ScopeOverviewTabProps {
|
||||
scopeState: ComplianceScopeState
|
||||
onStartProfiling: () => void
|
||||
onRefreshDecision: () => void
|
||||
}
|
||||
|
||||
export function ScopeOverviewTab({ scopeState, onStartProfiling, onRefreshDecision }: ScopeOverviewTabProps) {
|
||||
const { decision, answers } = scopeState
|
||||
const hasAnswers = answers && answers.length > 0
|
||||
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 80) return 'from-red-500 to-red-600'
|
||||
if (score >= 60) return 'from-orange-500 to-orange-600'
|
||||
if (score >= 40) return 'from-yellow-500 to-yellow-600'
|
||||
return 'from-green-500 to-green-600'
|
||||
}
|
||||
|
||||
const getScoreColorBg = (score: number): string => {
|
||||
if (score >= 80) return 'bg-red-100'
|
||||
if (score >= 60) return 'bg-orange-100'
|
||||
if (score >= 40) return 'bg-yellow-100'
|
||||
return 'bg-green-100'
|
||||
}
|
||||
|
||||
const renderScoreGauge = (label: string, score: number | undefined) => {
|
||||
const value = score ?? 0
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||
<span className="text-sm font-bold text-gray-900">{value}/100</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={`h-full bg-gradient-to-r ${getScoreColor(value)} transition-all duration-500`}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderLevelBadge = () => {
|
||||
if (!decision?.level) {
|
||||
return (
|
||||
<div className="bg-gray-100 border border-gray-300 rounded-xl p-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 bg-gray-200 rounded-full mb-4">
|
||||
<span className="text-4xl font-bold text-gray-400">?</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-600 mb-2">Noch nicht bewertet</h3>
|
||||
<p className="text-gray-500">
|
||||
Führen Sie das Scope-Profiling durch, um Ihre Compliance-Tiefe zu bestimmen.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const levelColors = DEPTH_LEVEL_COLORS[decision.level]
|
||||
return (
|
||||
<div className={`${levelColors.bg} border-2 ${levelColors.border} rounded-xl p-8 text-center`}>
|
||||
<div className={`inline-flex items-center justify-center w-24 h-24 ${levelColors.badge} rounded-full mb-4`}>
|
||||
<span className={`text-4xl font-bold ${levelColors.text}`}>{decision.level}</span>
|
||||
</div>
|
||||
<h3 className={`text-xl font-semibold ${levelColors.text} mb-2`}>
|
||||
{DEPTH_LEVEL_LABELS[decision.level]}
|
||||
</h3>
|
||||
<p className="text-gray-600">{DEPTH_LEVEL_DESCRIPTIONS[decision.level]}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderActiveHardTriggers = () => {
|
||||
if (!decision?.hardTriggers || decision.hardTriggers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const activeHardTriggers = decision.hardTriggers.filter((ht) => ht.matched)
|
||||
|
||||
if (activeHardTriggers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Aktive Hard-Trigger</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{activeHardTriggers.map((trigger, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border-l-4 border-red-500 bg-red-50 rounded-r-lg p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">{trigger.label}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">{trigger.description}</p>
|
||||
{trigger.legalReference && (
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
<span className="font-medium">Rechtsgrundlage:</span> {trigger.legalReference}
|
||||
</p>
|
||||
)}
|
||||
{trigger.matchedValue && (
|
||||
<p className="text-xs text-gray-700 mt-1">
|
||||
<span className="font-medium">Erfasster Wert:</span> {trigger.matchedValue}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderDocumentSummary = () => {
|
||||
if (!decision?.requiredDocuments) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mandatoryDocs = decision.requiredDocuments.filter((doc) => doc.isMandatory)
|
||||
const optionalDocs = decision.requiredDocuments.filter((doc) => !doc.isMandatory)
|
||||
const totalEffortDays = decision.requiredDocuments.reduce(
|
||||
(sum, doc) => sum + (doc.effortEstimate?.days ?? 0),
|
||||
0
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Dokumenten-Übersicht</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600">{mandatoryDocs.length}</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Pflichtdokumente</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">{optionalDocs.length}</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Optional</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900">{totalEffortDays}</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Tage Aufwand (geschätzt)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderRiskFlagsSummary = () => {
|
||||
if (!decision?.riskFlags || decision.riskFlags.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const critical = decision.riskFlags.filter((rf) => rf.severity === 'critical').length
|
||||
const high = decision.riskFlags.filter((rf) => rf.severity === 'high').length
|
||||
const medium = decision.riskFlags.filter((rf) => rf.severity === 'medium').length
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Risiko-Flags</h3>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
{critical > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
Kritisch
|
||||
</span>
|
||||
<span className="text-lg font-bold text-red-600">{critical}</span>
|
||||
</div>
|
||||
)}
|
||||
{high > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
|
||||
Hoch
|
||||
</span>
|
||||
<span className="text-lg font-bold text-orange-600">{high}</span>
|
||||
</div>
|
||||
)}
|
||||
{medium > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Mittel
|
||||
</span>
|
||||
<span className="text-lg font-bold text-yellow-600">{medium}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Level Badge */}
|
||||
{renderLevelBadge()}
|
||||
|
||||
{/* Scores Section */}
|
||||
{decision && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Score-Übersicht</h3>
|
||||
<div className="space-y-4">
|
||||
{renderScoreGauge('Risiko-Score', decision.scores?.riskScore)}
|
||||
{renderScoreGauge('Komplexitäts-Score', decision.scores?.complexityScore)}
|
||||
{renderScoreGauge('Assurance-Score', decision.scores?.assuranceScore)}
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
{renderScoreGauge('Gesamt-Score', decision.scores?.compositeScore)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Hard Triggers */}
|
||||
{renderActiveHardTriggers()}
|
||||
|
||||
{/* Document Summary */}
|
||||
{renderDocumentSummary()}
|
||||
|
||||
{/* Risk Flags Summary */}
|
||||
{renderRiskFlagsSummary()}
|
||||
|
||||
{/* CTA Section */}
|
||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{!hasAnswers ? 'Bereit für das Scope-Profiling?' : 'Ergebnis aktualisieren'}
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
{!hasAnswers
|
||||
? 'Beantworten Sie einige Fragen zu Ihrem Unternehmen und erhalten Sie eine präzise Compliance-Bewertung.'
|
||||
: 'Haben sich Ihre Unternehmensparameter geändert? Aktualisieren Sie Ihre Bewertung.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={!hasAnswers ? onStartProfiling : onRefreshDecision}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium whitespace-nowrap"
|
||||
>
|
||||
{!hasAnswers ? 'Scope-Profiling starten' : 'Ergebnis aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
410
admin-v2/components/sdk/compliance-scope/ScopeWizardTab.tsx
Normal file
410
admin-v2/components/sdk/compliance-scope/ScopeWizardTab.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
'use client'
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import type { ScopeProfilingAnswer, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
|
||||
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, getAnswerValue, getAllQuestions } from '@/lib/sdk/compliance-scope-profiling'
|
||||
import type { CompanyProfile } from '@/lib/sdk/types'
|
||||
import { prefillFromCompanyProfile } from '@/lib/sdk/compliance-scope-profiling'
|
||||
import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_COLORS } from '@/lib/sdk/compliance-scope-types'
|
||||
|
||||
interface ScopeWizardTabProps {
|
||||
answers: ScopeProfilingAnswer[]
|
||||
onAnswersChange: (answers: ScopeProfilingAnswer[]) => void
|
||||
onComplete: () => void
|
||||
companyProfile: CompanyProfile | null
|
||||
currentLevel: ComplianceDepthLevel | null
|
||||
}
|
||||
|
||||
export function ScopeWizardTab({
|
||||
answers,
|
||||
onAnswersChange,
|
||||
onComplete,
|
||||
companyProfile,
|
||||
currentLevel,
|
||||
}: ScopeWizardTabProps) {
|
||||
const [currentBlockIndex, setCurrentBlockIndex] = useState(0)
|
||||
const currentBlock = SCOPE_QUESTION_BLOCKS[currentBlockIndex]
|
||||
const totalProgress = getTotalProgress(answers)
|
||||
|
||||
const handleAnswerChange = useCallback(
|
||||
(questionId: string, value: any) => {
|
||||
const existingIndex = answers.findIndex((a) => a.questionId === questionId)
|
||||
if (existingIndex >= 0) {
|
||||
const newAnswers = [...answers]
|
||||
newAnswers[existingIndex] = { questionId, value, answeredAt: new Date().toISOString() }
|
||||
onAnswersChange(newAnswers)
|
||||
} else {
|
||||
onAnswersChange([...answers, { questionId, value, answeredAt: new Date().toISOString() }])
|
||||
}
|
||||
},
|
||||
[answers, onAnswersChange]
|
||||
)
|
||||
|
||||
const handlePrefillFromProfile = useCallback(() => {
|
||||
if (!companyProfile) return
|
||||
const prefilledAnswers = prefillFromCompanyProfile(companyProfile, answers)
|
||||
onAnswersChange(prefilledAnswers)
|
||||
}, [companyProfile, answers, onAnswersChange])
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentBlockIndex < SCOPE_QUESTION_BLOCKS.length - 1) {
|
||||
setCurrentBlockIndex(currentBlockIndex + 1)
|
||||
} else {
|
||||
onComplete()
|
||||
}
|
||||
}, [currentBlockIndex, onComplete])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (currentBlockIndex > 0) {
|
||||
setCurrentBlockIndex(currentBlockIndex - 1)
|
||||
}
|
||||
}, [currentBlockIndex])
|
||||
|
||||
const renderQuestion = (question: any) => {
|
||||
const currentValue = getAnswerValue(answers, question.id)
|
||||
|
||||
switch (question.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-900">
|
||||
{question.question}
|
||||
{question.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{question.helpText && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
title={question.helpText}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAnswerChange(question.id, true)}
|
||||
className={`flex-1 py-2 px-4 rounded-lg border-2 transition-all ${
|
||||
currentValue === true
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
Ja
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAnswerChange(question.id, false)}
|
||||
className={`flex-1 py-2 px-4 rounded-lg border-2 transition-all ${
|
||||
currentValue === false
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
Nein
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'single':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-900">
|
||||
{question.question}
|
||||
{question.required && <span className="text-red-500 ml-1">*</span>}
|
||||
{question.helpText && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-gray-400 hover:text-gray-600 inline"
|
||||
title={question.helpText}
|
||||
>
|
||||
<svg className="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{question.options?.map((option: any) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleAnswerChange(question.id, option.value)}
|
||||
className={`w-full text-left py-3 px-4 rounded-lg border-2 transition-all ${
|
||||
currentValue === option.value
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'multi':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-900">
|
||||
{question.question}
|
||||
{question.required && <span className="text-red-500 ml-1">*</span>}
|
||||
{question.helpText && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-gray-400 hover:text-gray-600 inline"
|
||||
title={question.helpText}
|
||||
>
|
||||
<svg className="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{question.options?.map((option: any) => {
|
||||
const selectedValues = Array.isArray(currentValue) ? currentValue : []
|
||||
const isChecked = selectedValues.includes(option.value)
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex items-center gap-3 py-3 px-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
isChecked
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-300 bg-white hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
const newValues = e.target.checked
|
||||
? [...selectedValues, option.value]
|
||||
: selectedValues.filter((v) => v !== option.value)
|
||||
handleAnswerChange(question.id, newValues)
|
||||
}}
|
||||
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span className={isChecked ? 'text-purple-700 font-medium' : 'text-gray-700'}>
|
||||
{option.label}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-900">
|
||||
{question.question}
|
||||
{question.required && <span className="text-red-500 ml-1">*</span>}
|
||||
{question.helpText && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-gray-400 hover:text-gray-600 inline"
|
||||
title={question.helpText}
|
||||
>
|
||||
<svg className="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={currentValue ?? ''}
|
||||
onChange={(e) => handleAnswerChange(question.id, parseInt(e.target.value, 10))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Zahl eingeben"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-900">
|
||||
{question.question}
|
||||
{question.required && <span className="text-red-500 ml-1">*</span>}
|
||||
{question.helpText && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-gray-400 hover:text-gray-600 inline"
|
||||
title={question.helpText}
|
||||
>
|
||||
<svg className="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue ?? ''}
|
||||
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Text eingeben"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 h-full">
|
||||
{/* Left Sidebar - Block Navigation */}
|
||||
<div className="w-64 flex-shrink-0">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 sticky top-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Fortschritt</h3>
|
||||
<div className="space-y-2">
|
||||
{SCOPE_QUESTION_BLOCKS.map((block, idx) => {
|
||||
const progress = getBlockProgress(answers, block.id)
|
||||
const isActive = idx === currentBlockIndex
|
||||
return (
|
||||
<button
|
||||
key={block.id}
|
||||
type="button"
|
||||
onClick={() => setCurrentBlockIndex(idx)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg transition-all ${
|
||||
isActive
|
||||
? 'bg-purple-50 border-2 border-purple-500'
|
||||
: 'bg-gray-50 border border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`text-sm font-medium ${isActive ? 'text-purple-700' : 'text-gray-700'}`}>
|
||||
{block.title}
|
||||
</span>
|
||||
<span className={`text-xs font-semibold ${isActive ? 'text-purple-600' : 'text-gray-500'}`}>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${isActive ? 'bg-purple-500' : 'bg-gray-400'}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 space-y-6">
|
||||
{/* Progress Bar */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Gesamtfortschritt</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{currentLevel && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Vorläufige Einstufung:</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold ${DEPTH_LEVEL_COLORS[currentLevel].badge} ${DEPTH_LEVEL_COLORS[currentLevel].text}`}
|
||||
>
|
||||
{currentLevel} - {DEPTH_LEVEL_LABELS[currentLevel]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-bold text-gray-900">{totalProgress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-500"
|
||||
style={{ width: `${totalProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Block */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">{currentBlock.title}</h2>
|
||||
<p className="text-gray-600">{currentBlock.description}</p>
|
||||
</div>
|
||||
{companyProfile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrefillFromProfile}
|
||||
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 border border-blue-300 rounded-lg hover:bg-blue-100 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Aus Unternehmensprofil übernehmen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Questions */}
|
||||
<div className="space-y-6">
|
||||
{currentBlock.questions.map((question) => (
|
||||
<div key={question.id} className="border-b border-gray-100 pb-6 last:border-b-0 last:pb-0">
|
||||
{renderQuestion(question)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center justify-between bg-white rounded-xl border border-gray-200 p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
disabled={currentBlockIndex === 0}
|
||||
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
Block {currentBlockIndex + 1} von {SCOPE_QUESTION_BLOCKS.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
|
||||
>
|
||||
{currentBlockIndex === SCOPE_QUESTION_BLOCKS.length - 1 ? 'Auswertung starten' : 'Weiter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
admin-v2/components/sdk/compliance-scope/index.ts
Normal file
4
admin-v2/components/sdk/compliance-scope/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ScopeOverviewTab } from './ScopeOverviewTab'
|
||||
export { ScopeWizardTab } from './ScopeWizardTab'
|
||||
export { ScopeDecisionTab } from './ScopeDecisionTab'
|
||||
export { ScopeExportTab } from './ScopeExportTab'
|
||||
320
admin-v2/components/sdk/dsfa/SourceAttribution.tsx
Normal file
320
admin-v2/components/sdk/dsfa/SourceAttribution.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { BookOpen, ExternalLink, Scale, ChevronDown, ChevronUp, Info } from 'lucide-react'
|
||||
import {
|
||||
DSFALicenseCode,
|
||||
DSFA_LICENSE_LABELS,
|
||||
SourceAttributionProps
|
||||
} from '@/lib/sdk/types'
|
||||
|
||||
/**
|
||||
* Get license badge color based on license type
|
||||
*/
|
||||
function getLicenseBadgeColor(licenseCode: DSFALicenseCode): string {
|
||||
switch (licenseCode) {
|
||||
case 'DL-DE-BY-2.0':
|
||||
case 'DL-DE-ZERO-2.0':
|
||||
return 'bg-blue-100 text-blue-700 border-blue-200'
|
||||
case 'CC-BY-4.0':
|
||||
return 'bg-green-100 text-green-700 border-green-200'
|
||||
case 'EDPB-LICENSE':
|
||||
return 'bg-purple-100 text-purple-700 border-purple-200'
|
||||
case 'PUBLIC_DOMAIN':
|
||||
return 'bg-gray-100 text-gray-700 border-gray-200'
|
||||
case 'PROPRIETARY':
|
||||
return 'bg-amber-100 text-amber-700 border-amber-200'
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-700 border-slate-200'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license URL based on license code
|
||||
*/
|
||||
function getLicenseUrl(licenseCode: DSFALicenseCode): string | null {
|
||||
switch (licenseCode) {
|
||||
case 'DL-DE-BY-2.0':
|
||||
return 'https://www.govdata.de/dl-de/by-2-0'
|
||||
case 'DL-DE-ZERO-2.0':
|
||||
return 'https://www.govdata.de/dl-de/zero-2-0'
|
||||
case 'CC-BY-4.0':
|
||||
return 'https://creativecommons.org/licenses/by/4.0/'
|
||||
case 'EDPB-LICENSE':
|
||||
return 'https://edpb.europa.eu/about-edpb/legal-notice_en'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* License badge component
|
||||
*/
|
||||
function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
|
||||
const licenseUrl = getLicenseUrl(licenseCode)
|
||||
const colorClass = getLicenseBadgeColor(licenseCode)
|
||||
const label = DSFA_LICENSE_LABELS[licenseCode] || licenseCode
|
||||
|
||||
if (licenseUrl) {
|
||||
return (
|
||||
<a
|
||||
href={licenseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorClass} hover:opacity-80 transition-opacity`}
|
||||
>
|
||||
<Scale className="w-3 h-3" />
|
||||
{label}
|
||||
<ExternalLink className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorClass}`}>
|
||||
<Scale className="w-3 h-3" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source item in the attribution list
|
||||
*/
|
||||
function SourceItem({
|
||||
source,
|
||||
index,
|
||||
showScore
|
||||
}: {
|
||||
source: SourceAttributionProps['sources'][0]
|
||||
index: number
|
||||
showScore: boolean
|
||||
}) {
|
||||
return (
|
||||
<li className="text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-slate-400 font-mono text-xs mt-0.5 min-w-[1.5rem]">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{source.sourceUrl ? (
|
||||
<a
|
||||
href={source.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline font-medium truncate"
|
||||
>
|
||||
{source.sourceName}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-slate-700 font-medium truncate">
|
||||
{source.sourceName}
|
||||
</span>
|
||||
)}
|
||||
{showScore && source.score !== undefined && (
|
||||
<span className="text-xs text-slate-400 font-mono">
|
||||
({(source.score * 100).toFixed(0)}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-0.5 leading-relaxed">
|
||||
{source.attributionText}
|
||||
</p>
|
||||
<div className="mt-1.5">
|
||||
<LicenseBadge licenseCode={source.licenseCode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact source badge for inline display
|
||||
*/
|
||||
function CompactSourceBadge({
|
||||
source
|
||||
}: {
|
||||
source: SourceAttributionProps['sources'][0]
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-600 border border-slate-200">
|
||||
<BookOpen className="w-3 h-3" />
|
||||
{source.sourceCode}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SourceAttribution component - displays source/license information for DSFA RAG results
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SourceAttribution
|
||||
* sources={[
|
||||
* {
|
||||
* sourceCode: "WP248",
|
||||
* sourceName: "WP248 rev.01 - Leitlinien zur DSFA",
|
||||
* attributionText: "Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)",
|
||||
* licenseCode: "EDPB-LICENSE",
|
||||
* sourceUrl: "https://ec.europa.eu/newsroom/article29/items/611236/en",
|
||||
* score: 0.87
|
||||
* }
|
||||
* ]}
|
||||
* showScores
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function SourceAttribution({
|
||||
sources,
|
||||
compact = false,
|
||||
showScores = false
|
||||
}: SourceAttributionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact)
|
||||
|
||||
if (!sources || sources.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Compact mode - just show badges
|
||||
if (compact && !isExpanded) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
<Info className="w-3 h-3" />
|
||||
Quellen ({sources.length})
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
{sources.slice(0, 3).map((source, i) => (
|
||||
<CompactSourceBadge key={i} source={source} />
|
||||
))}
|
||||
{sources.length > 3 && (
|
||||
<span className="text-xs text-slate-400">+{sources.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-slate-700 flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Quellen & Lizenzen
|
||||
</h4>
|
||||
{compact && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="text-xs text-slate-400 hover:text-slate-600 flex items-center gap-1"
|
||||
>
|
||||
Einklappen
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-3 space-y-3">
|
||||
{sources.map((source, i) => (
|
||||
<SourceItem
|
||||
key={i}
|
||||
source={source}
|
||||
index={i}
|
||||
showScore={showScores}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Aggregated license notice */}
|
||||
{sources.length > 1 && (
|
||||
<div className="mt-4 pt-3 border-t border-slate-200">
|
||||
<p className="text-xs text-slate-500">
|
||||
<strong>Hinweis:</strong> Die angezeigten Inhalte stammen aus {sources.length} verschiedenen Quellen
|
||||
mit unterschiedlichen Lizenzen. Bitte beachten Sie die jeweiligen Attributionsanforderungen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline source reference for use within text
|
||||
*/
|
||||
export function InlineSourceRef({
|
||||
sourceCode,
|
||||
sourceName,
|
||||
sourceUrl
|
||||
}: {
|
||||
sourceCode: string
|
||||
sourceName: string
|
||||
sourceUrl?: string
|
||||
}) {
|
||||
if (sourceUrl) {
|
||||
return (
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-blue-600 hover:text-blue-800 text-sm"
|
||||
title={sourceName}
|
||||
>
|
||||
[{sourceCode}]
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-slate-600 text-sm" title={sourceName}>
|
||||
[{sourceCode}]
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribution footer for generated documents
|
||||
*/
|
||||
export function AttributionFooter({
|
||||
sources,
|
||||
generatedAt
|
||||
}: {
|
||||
sources: SourceAttributionProps['sources']
|
||||
generatedAt?: Date
|
||||
}) {
|
||||
if (!sources || sources.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Group by license
|
||||
const byLicense = sources.reduce((acc, source) => {
|
||||
const key = source.licenseCode
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(source)
|
||||
return acc
|
||||
}, {} as Record<string, typeof sources>)
|
||||
|
||||
return (
|
||||
<footer className="mt-8 pt-4 border-t border-slate-200 text-xs text-slate-500">
|
||||
<h5 className="font-medium text-slate-600 mb-2">Quellennachweis</h5>
|
||||
<ul className="space-y-1">
|
||||
{Object.entries(byLicense).map(([licenseCode, licenseSources]) => (
|
||||
<li key={licenseCode}>
|
||||
<span className="font-medium">{DSFA_LICENSE_LABELS[licenseCode as DSFALicenseCode]}:</span>{' '}
|
||||
{licenseSources.map(s => s.sourceName).join(', ')}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{generatedAt && (
|
||||
<p className="mt-2 text-slate-400">
|
||||
Generiert am {generatedAt.toLocaleDateString('de-DE')} um {generatedAt.toLocaleTimeString('de-DE')}
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export default SourceAttribution
|
||||
@@ -201,6 +201,62 @@ export function ThresholdAnalysisSection({ dsfa, onUpdate, isSubmitting }: Thres
|
||||
{wp248Result.reason}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Annex-Trigger: Empfehlung bei >= 2 WP248 Kriterien */}
|
||||
{wp248Selected.length >= 2 && (
|
||||
<div className="mt-4 p-4 rounded-xl border bg-indigo-50 border-indigo-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-4 h-4 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-indigo-800 text-sm">Annex mit separater Risikobewertung empfohlen</p>
|
||||
<p className="text-sm text-indigo-700 mt-1">
|
||||
Bei {wp248Selected.length} erfuellten WP248-Kriterien wird ein Annex mit detaillierter Risikobewertung empfohlen.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-indigo-700 mb-1">Vorgeschlagene Annex-Scopes basierend auf Ihren Kriterien:</p>
|
||||
<ul className="text-xs text-indigo-600 space-y-1">
|
||||
{wp248Selected.includes('scoring_profiling') && (
|
||||
<li>- Annex: Profiling & Scoring — Detailanalyse der Bewertungslogik</li>
|
||||
)}
|
||||
{wp248Selected.includes('automated_decision') && (
|
||||
<li>- Annex: Automatisierte Einzelentscheidung — Art. 22 Pruefung</li>
|
||||
)}
|
||||
{wp248Selected.includes('systematic_monitoring') && (
|
||||
<li>- Annex: Systematische Ueberwachung — Verhaeltnismaessigkeitspruefung</li>
|
||||
)}
|
||||
{wp248Selected.includes('sensitive_data') && (
|
||||
<li>- Annex: Besondere Datenkategorien — Schutzbedarfsanalyse Art. 9</li>
|
||||
)}
|
||||
{wp248Selected.includes('large_scale') && (
|
||||
<li>- Annex: Umfangsanalyse — Quantitative Bewertung der Verarbeitung</li>
|
||||
)}
|
||||
{wp248Selected.includes('matching_combining') && (
|
||||
<li>- Annex: Datenzusammenfuehrung — Zweckbindungspruefung</li>
|
||||
)}
|
||||
{wp248Selected.includes('vulnerable_subjects') && (
|
||||
<li>- Annex: Schutzbeduerftige Betroffene — Verstaerkte Schutzmassnahmen</li>
|
||||
)}
|
||||
{wp248Selected.includes('innovative_technology') && (
|
||||
<li>- Annex: Innovative Technologie — Technikfolgenabschaetzung</li>
|
||||
)}
|
||||
{wp248Selected.includes('preventing_rights') && (
|
||||
<li>- Annex: Rechteausuebung — Barrierefreiheit der Betroffenenrechte</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{aiTriggersSelected.length > 0 && (
|
||||
<p className="text-xs text-indigo-500 mt-2">
|
||||
+ KI-Trigger aktiv: Zusaetzlicher Annex fuer KI-Risikobewertung empfohlen (AI Act Konformitaet).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 2: Art. 35 Abs. 3 Cases */}
|
||||
|
||||
@@ -11,6 +11,7 @@ export { DSFASidebar } from './DSFASidebar'
|
||||
export { StakeholderConsultationSection } from './StakeholderConsultationSection'
|
||||
export { Art36Warning } from './Art36Warning'
|
||||
export { ReviewScheduleSection } from './ReviewScheduleSection'
|
||||
export { SourceAttribution, InlineSourceRef, AttributionFooter } from './SourceAttribution'
|
||||
|
||||
// =============================================================================
|
||||
// DSFA Card Component
|
||||
|
||||
376
admin-v2/components/sdk/tom-dashboard/TOMEditorTab.tsx
Normal file
376
admin-v2/components/sdk/tom-dashboard/TOMEditorTab.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
||||
import { getControlById } from '@/lib/sdk/tom-generator/controls/loader'
|
||||
|
||||
interface TOMEditorTabProps {
|
||||
state: TOMGeneratorState
|
||||
selectedTOMId: string | null
|
||||
onUpdateTOM: (tomId: string, updates: Partial<DerivedTOM>) => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: { value: DerivedTOM['implementationStatus']; label: string; className: string }[] = [
|
||||
{ value: 'IMPLEMENTED', label: 'Implementiert', className: 'border-green-300 bg-green-50 text-green-700' },
|
||||
{ value: 'PARTIAL', label: 'Teilweise implementiert', className: 'border-yellow-300 bg-yellow-50 text-yellow-700' },
|
||||
{ value: 'NOT_IMPLEMENTED', label: 'Nicht implementiert', className: 'border-red-300 bg-red-50 text-red-700' },
|
||||
]
|
||||
|
||||
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
|
||||
TECHNICAL: { label: 'Technisch', className: 'bg-blue-100 text-blue-700' },
|
||||
ORGANIZATIONAL: { label: 'Organisatorisch', className: 'bg-indigo-100 text-indigo-700' },
|
||||
}
|
||||
|
||||
interface VVTActivity {
|
||||
id: string
|
||||
name?: string
|
||||
title?: string
|
||||
structuredToms?: { category?: string }[]
|
||||
}
|
||||
|
||||
export function TOMEditorTab({ state, selectedTOMId, onUpdateTOM, onBack }: TOMEditorTabProps) {
|
||||
const tom = useMemo(() => {
|
||||
if (!selectedTOMId) return null
|
||||
return state.derivedTOMs.find(t => t.id === selectedTOMId) || null
|
||||
}, [state.derivedTOMs, selectedTOMId])
|
||||
|
||||
const control = useMemo(() => {
|
||||
if (!tom) return null
|
||||
return getControlById(tom.controlId)
|
||||
}, [tom])
|
||||
|
||||
const [implementationStatus, setImplementationStatus] = useState<DerivedTOM['implementationStatus']>('NOT_IMPLEMENTED')
|
||||
const [responsiblePerson, setResponsiblePerson] = useState('')
|
||||
const [implementationDate, setImplementationDate] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [linkedEvidence, setLinkedEvidence] = useState<string[]>([])
|
||||
const [selectedEvidenceId, setSelectedEvidenceId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (tom) {
|
||||
setImplementationStatus(tom.implementationStatus)
|
||||
setResponsiblePerson(tom.responsiblePerson || '')
|
||||
setImplementationDate(tom.implementationDate ? new Date(tom.implementationDate).toISOString().slice(0, 10) : '')
|
||||
setNotes(tom.aiGeneratedDescription || '')
|
||||
setLinkedEvidence(tom.linkedEvidence || [])
|
||||
}
|
||||
}, [tom])
|
||||
|
||||
const vvtActivities = useMemo(() => {
|
||||
if (!control) return []
|
||||
try {
|
||||
const raw = localStorage.getItem('bp_vvt')
|
||||
if (!raw) return []
|
||||
const activities: VVTActivity[] = JSON.parse(raw)
|
||||
return activities.filter(a =>
|
||||
a.structuredToms?.some(t => t.category === control.category)
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, [control])
|
||||
|
||||
const availableDocuments = useMemo(() => {
|
||||
return (state.documents || []).filter(
|
||||
doc => !linkedEvidence.includes(doc.id)
|
||||
)
|
||||
}, [state.documents, linkedEvidence])
|
||||
|
||||
const linkedDocuments = useMemo(() => {
|
||||
return linkedEvidence
|
||||
.map(id => (state.documents || []).find(d => d.id === id))
|
||||
.filter(Boolean)
|
||||
}, [state.documents, linkedEvidence])
|
||||
|
||||
const evidenceGaps = useMemo(() => {
|
||||
if (!control?.evidenceRequirements) return []
|
||||
return control.evidenceRequirements.map(req => {
|
||||
const hasMatch = (state.documents || []).some(doc =>
|
||||
linkedEvidence.includes(doc.id) &&
|
||||
(doc.filename?.toLowerCase().includes(req.toLowerCase()) ||
|
||||
doc.documentType?.toLowerCase().includes(req.toLowerCase()))
|
||||
)
|
||||
return { requirement: req, fulfilled: hasMatch }
|
||||
})
|
||||
}, [control, state.documents, linkedEvidence])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!tom) return
|
||||
onUpdateTOM(tom.id, {
|
||||
implementationStatus,
|
||||
responsiblePerson: responsiblePerson || null,
|
||||
implementationDate: implementationDate ? new Date(implementationDate) : null,
|
||||
aiGeneratedDescription: notes || null,
|
||||
linkedEvidence,
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddEvidence = () => {
|
||||
if (!selectedEvidenceId) return
|
||||
setLinkedEvidence(prev => [...prev, selectedEvidenceId])
|
||||
setSelectedEvidenceId('')
|
||||
}
|
||||
|
||||
const handleRemoveEvidence = (docId: string) => {
|
||||
setLinkedEvidence(prev => prev.filter(id => id !== docId))
|
||||
}
|
||||
|
||||
if (!selectedTOMId || !tom) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">Keine TOM ausgewaehlt</h3>
|
||||
<p className="text-gray-500">Waehlen Sie eine TOM aus der Uebersicht, um sie zu bearbeiten.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const typeBadge = TYPE_BADGES[control?.type || 'TECHNICAL'] || TYPE_BADGES.TECHNICAL
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-purple-600 hover:text-purple-800 font-medium flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurueck zur Uebersicht
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Aenderungen speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* TOM Header Card */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="text-xs font-mono bg-gray-100 text-gray-600 px-2 py-1 rounded">{control?.code || tom.controlId}</span>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium ${typeBadge.className}`}>
|
||||
{typeBadge.label}
|
||||
</span>
|
||||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full font-medium">
|
||||
{control?.category || 'Unbekannt'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">{control?.name?.de || tom.controlId}</h2>
|
||||
{control?.description?.de && (
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{control.description.de}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Implementation Status */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Implementierungsstatus</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{STATUS_OPTIONS.map(opt => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex items-center gap-3 border rounded-lg p-3 cursor-pointer transition-all ${
|
||||
implementationStatus === opt.value
|
||||
? opt.className + ' ring-2 ring-offset-1 ring-current'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="implementationStatus"
|
||||
value={opt.value}
|
||||
checked={implementationStatus === opt.value}
|
||||
onChange={() => setImplementationStatus(opt.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
||||
implementationStatus === opt.value ? 'border-current' : 'border-gray-300'
|
||||
}`}>
|
||||
{implementationStatus === opt.value && (
|
||||
<div className="w-2 h-2 rounded-full bg-current" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Responsible Person */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Verantwortliche Person</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Umgesetzt von</label>
|
||||
<input
|
||||
type="text"
|
||||
value={responsiblePerson}
|
||||
onChange={e => setResponsiblePerson(e.target.value)}
|
||||
placeholder="Name der verantwortlichen Person"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Umsetzungsdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={implementationDate}
|
||||
onChange={e => setImplementationDate(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Anmerkungen</h3>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Anmerkungen zur Umsetzung, Besonderheiten, etc."
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Evidence Section */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Nachweisdokumente</h3>
|
||||
|
||||
{linkedDocuments.length > 0 ? (
|
||||
<div className="space-y-2 mb-4">
|
||||
{linkedDocuments.map(doc => doc && (
|
||||
<div key={doc.id} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-700">{doc.originalName || doc.filename || doc.id}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveEvidence(doc.id)}
|
||||
className="text-red-500 hover:text-red-700 text-xs font-medium"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 mb-4">Keine Nachweisdokumente verknuepft.</p>
|
||||
)}
|
||||
|
||||
{availableDocuments.length > 0 && (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Dokument hinzufuegen</label>
|
||||
<select
|
||||
value={selectedEvidenceId}
|
||||
onChange={e => setSelectedEvidenceId(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="">Dokument auswaehlen...</option>
|
||||
{availableDocuments.map(doc => (
|
||||
<option key={doc.id} value={doc.id}>{doc.originalName || doc.filename || doc.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddEvidence}
|
||||
disabled={!selectedEvidenceId}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Evidence Gaps */}
|
||||
{evidenceGaps.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Nachweis-Anforderungen</h3>
|
||||
<div className="space-y-2">
|
||||
{evidenceGaps.map((gap, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3">
|
||||
<div className={`w-5 h-5 rounded flex items-center justify-center flex-shrink-0 ${
|
||||
gap.fulfilled ? 'bg-green-100 text-green-600' : 'bg-red-50 text-red-400'
|
||||
}`}>
|
||||
{gap.fulfilled ? (
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-sm ${gap.fulfilled ? 'text-gray-700' : 'text-gray-500'}`}>
|
||||
{gap.requirement}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VVT Cross-References */}
|
||||
{vvtActivities.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">VVT-Querverweise</h3>
|
||||
<div className="space-y-2">
|
||||
{vvtActivities.map(activity => (
|
||||
<div key={activity.id} className="flex items-center gap-2 bg-purple-50 rounded-lg px-3 py-2">
|
||||
<svg className="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span className="text-sm text-purple-700">{activity.name || activity.title || activity.id}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Framework Mappings */}
|
||||
{control?.mappings && control.mappings.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Framework-Zuordnungen</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{control.mappings.map((mapping, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase">{mapping.framework}</span>
|
||||
<span className="text-sm text-gray-700">{mapping.reference}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Save */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 font-medium"
|
||||
>
|
||||
Zurueck zur Uebersicht
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-6 py-2.5 font-medium transition-colors"
|
||||
>
|
||||
Aenderungen speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
328
admin-v2/components/sdk/tom-dashboard/TOMGapExportTab.tsx
Normal file
328
admin-v2/components/sdk/tom-dashboard/TOMGapExportTab.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { TOMGeneratorState, GapAnalysisResult, DerivedTOM } from '@/lib/sdk/tom-generator/types'
|
||||
import { getControlById, getAllControls } from '@/lib/sdk/tom-generator/controls/loader'
|
||||
import {
|
||||
SDM_GOAL_LABELS,
|
||||
SDM_GOAL_DESCRIPTIONS,
|
||||
getSDMCoverageStats,
|
||||
MODULE_LABELS,
|
||||
getModuleCoverageStats,
|
||||
SDMGewaehrleistungsziel,
|
||||
TOMModuleCategory,
|
||||
} from '@/lib/sdk/tom-generator/sdm-mapping'
|
||||
|
||||
interface TOMGapExportTabProps {
|
||||
state: TOMGeneratorState
|
||||
onRunGapAnalysis: () => void
|
||||
}
|
||||
|
||||
function getScoreColor(score: number): string {
|
||||
if (score >= 75) return 'text-green-600'
|
||||
if (score >= 50) return 'text-yellow-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
function getScoreBgColor(score: number): string {
|
||||
if (score >= 75) return 'bg-green-50 border-green-200'
|
||||
if (score >= 50) return 'bg-yellow-50 border-yellow-200'
|
||||
return 'bg-red-50 border-red-200'
|
||||
}
|
||||
|
||||
function getBarColor(score: number): string {
|
||||
if (score >= 75) return 'bg-green-500'
|
||||
if (score >= 50) return 'bg-yellow-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
function downloadJSON(data: unknown, filename: string) {
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function TOMGapExportTab({ state, onRunGapAnalysis }: TOMGapExportTabProps) {
|
||||
const gap = state.gapAnalysis as GapAnalysisResult | null | undefined
|
||||
|
||||
const sdmGoals = useMemo(() => {
|
||||
const goals = Object.keys(SDM_GOAL_LABELS) as SDMGewaehrleistungsziel[]
|
||||
const allStats = getSDMCoverageStats(state.derivedTOMs)
|
||||
return goals.map(key => {
|
||||
const stats = allStats[key] || { total: 0, implemented: 0, partial: 0, missing: 0 }
|
||||
const total = stats.total || 1
|
||||
const percent = Math.round((stats.implemented / total) * 100)
|
||||
return {
|
||||
key,
|
||||
label: SDM_GOAL_LABELS[key],
|
||||
description: SDM_GOAL_DESCRIPTIONS[key],
|
||||
stats,
|
||||
percent,
|
||||
}
|
||||
})
|
||||
}, [state.derivedTOMs])
|
||||
|
||||
const modules = useMemo(() => {
|
||||
const moduleKeys = Object.keys(MODULE_LABELS) as TOMModuleCategory[]
|
||||
const allStats = getModuleCoverageStats(state.derivedTOMs)
|
||||
return moduleKeys.map(key => {
|
||||
const stats = allStats[key] || { total: 0, implemented: 0 }
|
||||
const total = stats.total || 1
|
||||
const percent = Math.round((stats.implemented / total) * 100)
|
||||
return {
|
||||
key,
|
||||
label: MODULE_LABELS[key],
|
||||
stats: { ...stats, partial: 0, missing: total - stats.implemented },
|
||||
percent,
|
||||
}
|
||||
})
|
||||
}, [state.derivedTOMs])
|
||||
|
||||
const handleExportTOMs = () => {
|
||||
downloadJSON(state.derivedTOMs, `tom-export-${new Date().toISOString().slice(0, 10)}.json`)
|
||||
}
|
||||
|
||||
const handleExportGap = () => {
|
||||
if (!gap) return
|
||||
downloadJSON(gap, `gap-analyse-${new Date().toISOString().slice(0, 10)}.json`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Gap Analysis */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Gap-Analyse</h3>
|
||||
<button
|
||||
onClick={onRunGapAnalysis}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Analyse ausfuehren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{gap ? (
|
||||
<div className="space-y-6">
|
||||
{/* Score Gauge */}
|
||||
<div className="flex justify-center">
|
||||
<div className={`rounded-xl border-2 p-8 text-center ${getScoreBgColor(gap.overallScore)}`}>
|
||||
<div className={`text-5xl font-bold ${getScoreColor(gap.overallScore)}`}>
|
||||
{gap.overallScore}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">von 100 Punkten</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Missing Controls */}
|
||||
{gap.missingControls && gap.missingControls.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-red-700 mb-2">
|
||||
Fehlende Kontrollen ({gap.missingControls.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{gap.missingControls.map((mc, idx) => {
|
||||
const control = getControlById(mc.controlId)
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2 bg-red-50 rounded-lg px-3 py-2">
|
||||
<span className="text-xs font-mono text-red-400">{control?.code || mc.controlId}</span>
|
||||
<span className="text-sm text-red-700">{control?.name?.de || mc.controlId}</span>
|
||||
{mc.reason && <span className="text-xs text-red-400 ml-auto">{mc.reason}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Partial Controls */}
|
||||
{gap.partialControls && gap.partialControls.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-yellow-700 mb-2">
|
||||
Teilweise implementierte Kontrollen ({gap.partialControls.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{gap.partialControls.map((pc, idx) => {
|
||||
const control = getControlById(pc.controlId)
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2 bg-yellow-50 rounded-lg px-3 py-2">
|
||||
<span className="text-xs font-mono text-yellow-500">{control?.code || pc.controlId}</span>
|
||||
<span className="text-sm text-yellow-700">{control?.name?.de || pc.controlId}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Missing Evidence */}
|
||||
{gap.missingEvidence && gap.missingEvidence.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-orange-700 mb-2">
|
||||
Fehlende Nachweise ({gap.missingEvidence.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{gap.missingEvidence.map((item, idx) => {
|
||||
const control = getControlById(item.controlId)
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2 bg-orange-50 rounded-lg px-3 py-2">
|
||||
<svg className="w-4 h-4 text-orange-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<span className="text-sm text-orange-700">
|
||||
{control?.name?.de || item.controlId}: {item.requiredEvidence.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{gap.recommendations && gap.recommendations.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-blue-700 mb-2">
|
||||
Empfehlungen ({gap.recommendations.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{gap.recommendations.map((rec, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 bg-blue-50 rounded-lg px-3 py-2">
|
||||
<svg className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm text-blue-700">
|
||||
{typeof rec === 'string' ? rec : (rec as { text?: string; message?: string }).text || (rec as { text?: string; message?: string }).message || JSON.stringify(rec)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<svg className="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<p className="text-sm">Fuehren Sie die Gap-Analyse aus, um Luecken in Ihren TOMs zu identifizieren.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SDM Gewaehrleistungsziele */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">SDM Gewaehrleistungsziele</h3>
|
||||
<div className="space-y-4">
|
||||
{sdmGoals.map(goal => (
|
||||
<div key={goal.key}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">{goal.label}</span>
|
||||
{goal.description && (
|
||||
<span className="text-xs text-gray-400 ml-2">{goal.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{goal.stats.implemented}/{goal.stats.total} implementiert
|
||||
{goal.stats.partial > 0 && ` | ${goal.stats.partial} teilweise`}
|
||||
{goal.stats.missing > 0 && ` | ${goal.stats.missing} fehlend`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full flex">
|
||||
<div
|
||||
className="bg-green-500 h-full transition-all"
|
||||
style={{ width: `${goal.percent}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-yellow-400 h-full transition-all"
|
||||
style={{ width: `${goal.stats.total ? Math.round((goal.stats.partial / goal.stats.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module Coverage */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Modul-Abdeckung</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{modules.map(mod => (
|
||||
<div key={mod.key} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">{mod.label}</div>
|
||||
<div className="flex items-end gap-2 mb-2">
|
||||
<span className={`text-2xl font-bold ${getScoreColor(mod.percent)}`}>
|
||||
{mod.percent}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 mb-1">
|
||||
({mod.stats.implemented}/{mod.stats.total})
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${getBarColor(mod.percent)}`}
|
||||
style={{ width: `${mod.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{mod.stats.partial > 0 && (
|
||||
<div className="text-xs text-yellow-600 mt-1">{mod.stats.partial} teilweise</div>
|
||||
)}
|
||||
{mod.stats.missing > 0 && (
|
||||
<div className="text-xs text-red-500 mt-0.5">{mod.stats.missing} fehlend</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Section */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Export</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<button
|
||||
onClick={handleExportTOMs}
|
||||
disabled={state.derivedTOMs.length === 0}
|
||||
className="flex flex-col items-center gap-2 border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-8 h-8 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700">JSON Export</span>
|
||||
<span className="text-xs text-gray-400">Alle TOMs als JSON</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportGap}
|
||||
disabled={!gap}
|
||||
className="flex flex-col items-center gap-2 border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-8 h-8 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700">Gap-Analyse Export</span>
|
||||
<span className="text-xs text-gray-400">Analyseergebnis als JSON</span>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 border border-dashed border-gray-300 rounded-lg p-4 bg-gray-50">
|
||||
<svg className="w-8 h-8 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-500">Vollstaendiger Export (ZIP)</span>
|
||||
<span className="text-xs text-gray-400 text-center">
|
||||
Nutzen Sie den TOM Generator fuer den vollstaendigen Export mit DOCX/PDF
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
267
admin-v2/components/sdk/tom-dashboard/TOMOverviewTab.tsx
Normal file
267
admin-v2/components/sdk/tom-dashboard/TOMOverviewTab.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
||||
import { getControlById, getControlsByCategory, getAllCategories } from '@/lib/sdk/tom-generator/controls/loader'
|
||||
import { SDM_GOAL_LABELS, getSDMCoverageStats, SDMGewaehrleistungsziel } from '@/lib/sdk/tom-generator/sdm-mapping'
|
||||
|
||||
interface TOMOverviewTabProps {
|
||||
state: TOMGeneratorState
|
||||
onSelectTOM: (tomId: string) => void
|
||||
onStartGenerator: () => void
|
||||
}
|
||||
|
||||
const STATUS_BADGES: Record<string, { label: string; className: string }> = {
|
||||
IMPLEMENTED: { label: 'Implementiert', className: 'bg-green-100 text-green-700' },
|
||||
PARTIAL: { label: 'Teilweise', className: 'bg-yellow-100 text-yellow-700' },
|
||||
NOT_IMPLEMENTED: { label: 'Fehlend', className: 'bg-red-100 text-red-700' },
|
||||
}
|
||||
|
||||
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
|
||||
TECHNICAL: { label: 'Technisch', className: 'bg-blue-100 text-blue-700' },
|
||||
ORGANIZATIONAL: { label: 'Organisatorisch', className: 'bg-indigo-100 text-indigo-700' },
|
||||
}
|
||||
|
||||
const SCHUTZZIELE: { key: SDMGewaehrleistungsziel; label: string }[] = [
|
||||
{ key: 'Vertraulichkeit', label: 'Vertraulichkeit' },
|
||||
{ key: 'Integritaet', label: 'Integritaet' },
|
||||
{ key: 'Verfuegbarkeit', label: 'Verfuegbarkeit' },
|
||||
{ key: 'Nichtverkettung', label: 'Nichtverkettung' },
|
||||
]
|
||||
|
||||
export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOverviewTabProps) {
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('ALL')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('ALL')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('ALL')
|
||||
const [applicabilityFilter, setApplicabilityFilter] = useState<string>('ALL')
|
||||
|
||||
const categories = useMemo(() => getAllCategories(), [])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const toms = state.derivedTOMs
|
||||
return {
|
||||
total: toms.length,
|
||||
implemented: toms.filter(t => t.implementationStatus === 'IMPLEMENTED').length,
|
||||
partial: toms.filter(t => t.implementationStatus === 'PARTIAL').length,
|
||||
missing: toms.filter(t => t.implementationStatus === 'NOT_IMPLEMENTED').length,
|
||||
}
|
||||
}, [state.derivedTOMs])
|
||||
|
||||
const sdmStats = useMemo(() => {
|
||||
const allStats = getSDMCoverageStats(state.derivedTOMs)
|
||||
return SCHUTZZIELE.map(sz => ({
|
||||
...sz,
|
||||
stats: allStats[sz.key] || { total: 0, implemented: 0, partial: 0, missing: 0 },
|
||||
}))
|
||||
}, [state.derivedTOMs])
|
||||
|
||||
const filteredTOMs = useMemo(() => {
|
||||
let toms = state.derivedTOMs
|
||||
|
||||
if (categoryFilter !== 'ALL') {
|
||||
const categoryControlIds = getControlsByCategory(categoryFilter).map(c => c.id)
|
||||
toms = toms.filter(t => categoryControlIds.includes(t.controlId))
|
||||
}
|
||||
|
||||
if (typeFilter !== 'ALL') {
|
||||
toms = toms.filter(t => {
|
||||
const ctrl = getControlById(t.controlId)
|
||||
return ctrl?.type === typeFilter
|
||||
})
|
||||
}
|
||||
|
||||
if (statusFilter !== 'ALL') {
|
||||
toms = toms.filter(t => t.implementationStatus === statusFilter)
|
||||
}
|
||||
|
||||
if (applicabilityFilter !== 'ALL') {
|
||||
toms = toms.filter(t => t.applicability === applicabilityFilter)
|
||||
}
|
||||
|
||||
return toms
|
||||
}, [state.derivedTOMs, categoryFilter, typeFilter, statusFilter, applicabilityFilter])
|
||||
|
||||
if (state.derivedTOMs.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">Keine TOMs vorhanden</h3>
|
||||
<p className="text-gray-500 mb-6 max-w-md">
|
||||
Starten Sie den TOM Generator, um technische und organisatorische Massnahmen basierend auf Ihrem Verarbeitungsverzeichnis abzuleiten.
|
||||
</p>
|
||||
<button
|
||||
onClick={onStartGenerator}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-6 py-3 font-medium transition-colors"
|
||||
>
|
||||
TOM Generator starten
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
||||
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Gesamt TOMs</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{stats.implemented}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Implementiert</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
||||
<div className="text-3xl font-bold text-yellow-600">{stats.partial}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Teilweise</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
||||
<div className="text-3xl font-bold text-red-600">{stats.missing}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Fehlend</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Art. 32 Schutzziele */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Art. 32 DSGVO Schutzziele</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{sdmStats.map(sz => {
|
||||
const total = sz.stats.total || 1
|
||||
const implPercent = Math.round((sz.stats.implemented / total) * 100)
|
||||
const partialPercent = Math.round((sz.stats.partial / total) * 100)
|
||||
return (
|
||||
<div key={sz.key} className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">{sz.label}</div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full flex">
|
||||
<div
|
||||
className="bg-green-500 h-full"
|
||||
style={{ width: `${implPercent}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-yellow-400 h-full"
|
||||
style={{ width: `${partialPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{sz.stats.implemented}/{sz.stats.total} implementiert
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Controls */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="ALL">Alle Kategorien</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Typ</label>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="ALL">Alle</option>
|
||||
<option value="TECHNICAL">Technisch</option>
|
||||
<option value="ORGANIZATIONAL">Organisatorisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="ALL">Alle</option>
|
||||
<option value="IMPLEMENTED">Implementiert</option>
|
||||
<option value="PARTIAL">Teilweise</option>
|
||||
<option value="NOT_IMPLEMENTED">Fehlend</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Anwendbarkeit</label>
|
||||
<select
|
||||
value={applicabilityFilter}
|
||||
onChange={e => setApplicabilityFilter(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="ALL">Alle</option>
|
||||
<option value="REQUIRED">Erforderlich</option>
|
||||
<option value="RECOMMENDED">Empfohlen</option>
|
||||
<option value="OPTIONAL">Optional</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TOM Card Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{filteredTOMs.map(tom => {
|
||||
const control = getControlById(tom.controlId)
|
||||
const statusBadge = STATUS_BADGES[tom.implementationStatus] || STATUS_BADGES.NOT_IMPLEMENTED
|
||||
const typeBadge = TYPE_BADGES[control?.type || 'TECHNICAL'] || TYPE_BADGES.TECHNICAL
|
||||
const evidenceCount = tom.linkedEvidence?.length || 0
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tom.id}
|
||||
onClick={() => onSelectTOM(tom.id)}
|
||||
className="bg-white rounded-xl border border-gray-200 p-5 text-left hover:border-purple-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono text-gray-400">{control?.code || tom.controlId}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadge.className}`}>
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeBadge.className}`}>
|
||||
{typeBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
{evidenceCount > 0 && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||
{evidenceCount} Nachweise
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-gray-800 group-hover:text-purple-700 transition-colors mb-1">
|
||||
{control?.name?.de || tom.controlId}
|
||||
</h4>
|
||||
<div className="text-xs text-gray-400">
|
||||
{control?.category || 'Unbekannte Kategorie'}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredTOMs.length === 0 && state.derivedTOMs.length > 0 && (
|
||||
<div className="text-center py-10 text-gray-500">
|
||||
<p>Keine TOMs entsprechen den aktuellen Filterkriterien.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
admin-v2/components/sdk/tom-dashboard/index.ts
Normal file
3
admin-v2/components/sdk/tom-dashboard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { TOMOverviewTab } from './TOMOverviewTab'
|
||||
export { TOMEditorTab } from './TOMEditorTab'
|
||||
export { TOMGapExportTab } from './TOMGapExportTab'
|
||||
3
admin-v2/hooks/companion/index.ts
Normal file
3
admin-v2/hooks/companion/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useCompanionData } from './useCompanionData'
|
||||
export { useLessonSession } from './useLessonSession'
|
||||
export { useKeyboardShortcuts, useKeyboardShortcutHints } from './useKeyboardShortcuts'
|
||||
156
admin-v2/hooks/companion/useCompanionData.ts
Normal file
156
admin-v2/hooks/companion/useCompanionData.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
113
admin-v2/hooks/companion/useKeyboardShortcuts.ts
Normal file
113
admin-v2/hooks/companion/useKeyboardShortcuts.ts
Normal 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
|
||||
}
|
||||
446
admin-v2/hooks/companion/useLessonSession.ts
Normal file
446
admin-v2/hooks/companion/useLessonSession.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
364
admin-v2/lib/companion/constants.ts
Normal file
364
admin-v2/lib/companion/constants.ts
Normal 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`
|
||||
}
|
||||
2
admin-v2/lib/companion/index.ts
Normal file
2
admin-v2/lib/companion/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './types'
|
||||
export * from './constants'
|
||||
329
admin-v2/lib/companion/types.ts
Normal file
329
admin-v2/lib/companion/types.ts
Normal 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
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
// =========================================================================
|
||||
|
||||
1508
admin-v2/lib/sdk/compliance-scope-engine.ts
Normal file
1508
admin-v2/lib/sdk/compliance-scope-engine.ts
Normal file
File diff suppressed because it is too large
Load Diff
722
admin-v2/lib/sdk/compliance-scope-golden-tests.ts
Normal file
722
admin-v2/lib/sdk/compliance-scope-golden-tests.ts
Normal 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'],
|
||||
},
|
||||
]
|
||||
821
admin-v2/lib/sdk/compliance-scope-profiling.ts
Normal file
821
admin-v2/lib/sdk/compliance-scope-profiling.ts
Normal 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)
|
||||
}
|
||||
1355
admin-v2/lib/sdk/compliance-scope-types.ts
Normal file
1355
admin-v2/lib/sdk/compliance-scope-types.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -6,3 +6,5 @@
|
||||
|
||||
export * from './types'
|
||||
export * from './api'
|
||||
export * from './risk-catalog'
|
||||
export * from './mitigation-library'
|
||||
|
||||
694
admin-v2/lib/sdk/dsfa/mitigation-library.ts
Normal file
694
admin-v2/lib/sdk/dsfa/mitigation-library.ts
Normal 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',
|
||||
}
|
||||
615
admin-v2/lib/sdk/dsfa/risk-catalog.ts
Normal file
615
admin-v2/lib/sdk/dsfa/risk-catalog.ts
Normal 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',
|
||||
}
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
578
admin-v2/lib/sdk/loeschfristen-baseline-catalog.ts
Normal file
578
admin-v2/lib/sdk/loeschfristen-baseline-catalog.ts
Normal 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()
|
||||
}
|
||||
325
admin-v2/lib/sdk/loeschfristen-compliance.ts
Normal file
325
admin-v2/lib/sdk/loeschfristen-compliance.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
353
admin-v2/lib/sdk/loeschfristen-export.ts
Normal file
353
admin-v2/lib/sdk/loeschfristen-export.ts
Normal 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)
|
||||
}
|
||||
538
admin-v2/lib/sdk/loeschfristen-profiling.ts
Normal file
538
admin-v2/lib/sdk/loeschfristen-profiling.ts
Normal 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',
|
||||
]
|
||||
346
admin-v2/lib/sdk/loeschfristen-types.ts
Normal file
346
admin-v2/lib/sdk/loeschfristen-types.ts
Normal 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'
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
192
admin-v2/lib/sdk/tom-generator/sdm-mapping.ts
Normal file
192
admin-v2/lib/sdk/tom-generator/sdm-mapping.ts
Normal 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
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
630
admin-v2/lib/sdk/vvt-baseline-catalog.ts
Normal file
630
admin-v2/lib/sdk/vvt-baseline-catalog.ts
Normal 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)
|
||||
}
|
||||
492
admin-v2/lib/sdk/vvt-profiling.ts
Normal file
492
admin-v2/lib/sdk/vvt-profiling.ts
Normal 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',
|
||||
]
|
||||
247
admin-v2/lib/sdk/vvt-types.ts
Normal file
247
admin-v2/lib/sdk/vvt-types.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
66
agent-core/soul/compliance-advisor.soul.md
Normal file
66
agent-core/soul/compliance-advisor.soul.md
Normal 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
|
||||
Reference in New Issue
Block a user