refactor: Consolidate standalone services into admin-v2, add new SDK modules

Remove standalone services (ai-compliance-sdk root, developer-portal,
dsms-gateway, dsms-node, night-scheduler) and legacy compliance/dsgvo pages.
Add new SDK pipeline modules (academy, document-crawler, dsb-portal,
incidents, whistleblower, reporting, sso, multi-tenant, industry-templates).
Add drafting engine, legal corpus files (AT/CH/DE), pitch-deck,
blog and Förderantrag pages.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-15 09:05:18 +01:00
parent 626f4966e2
commit 70f2b0ae64
396 changed files with 43163 additions and 80397 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ export default function ArchitecturePage() {
databases: ['PostgreSQL', 'Qdrant']
}}
relatedPages={[
{ name: 'Compliance Hub', href: '/compliance/hub', description: 'Compliance-Module' },
{ name: 'Compliance Hub', href: '/sdk/compliance-hub', description: 'Compliance-Module' },
{ name: 'AI Hub', href: '/ai', description: 'KI-Module' },
]}
/>

View File

@@ -1,831 +0,0 @@
'use client'
/**
* EU-AI-Act Risk Classification Page
*
* Self-assessment and documentation of AI risk categories according to EU AI Act.
* Provides module-by-module risk assessment, warning lines, and exportable documentation.
*/
import { useState } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// =============================================================================
// TYPES
// =============================================================================
type RiskLevel = 'unacceptable' | 'high' | 'limited' | 'minimal'
interface ModuleAssessment {
id: string
name: string
description: string
riskLevel: RiskLevel
justification: string
humanInLoop: boolean
transparencyMeasures: string[]
aiActArticle: string
}
interface WarningLine {
id: string
title: string
description: string
wouldTrigger: RiskLevel
currentStatus: 'safe' | 'approaching' | 'violated'
recommendation: string
}
// =============================================================================
// DATA - Breakpilot Module Assessments
// =============================================================================
const MODULE_ASSESSMENTS: ModuleAssessment[] = [
{
id: 'text-suggestions',
name: 'Textvorschlaege / Formulierhilfen',
description: 'KI-generierte Textvorschlaege fuer Gutachten und Feedback',
riskLevel: 'minimal',
justification: 'Reine Assistenzfunktion ohne Entscheidungswirkung. Lehrer editieren und finalisieren alle Texte.',
humanInLoop: true,
transparencyMeasures: ['KI-Label auf generierten Texten', 'Editierbare Vorschlaege'],
aiActArticle: 'Art. 69 (Freiwillige Verhaltenskodizes)',
},
{
id: 'rag-sources',
name: 'RAG-basierte Quellenanzeige',
description: 'Retrieval Augmented Generation fuer Lehrplan- und Erwartungshorizont-Referenzen',
riskLevel: 'minimal',
justification: 'Zitierende Referenzfunktion. Zeigt nur offizielle Quellen an, trifft keine Entscheidungen.',
humanInLoop: true,
transparencyMeasures: ['Quellenangaben', 'Direkte Links zu Originaldokumenten'],
aiActArticle: 'Art. 69 (Freiwillige Verhaltenskodizes)',
},
{
id: 'correction-suggestions',
name: 'Korrektur-Vorschlaege',
description: 'KI-Vorschlaege fuer Bewertungskriterien und Punktevergabe',
riskLevel: 'limited',
justification: 'Vorschlaege ohne bindende Wirkung. Lehrkraft behaelt vollstaendige Entscheidungshoheit.',
humanInLoop: true,
transparencyMeasures: [
'Klare Kennzeichnung als KI-Vorschlag',
'Begruendung fuer jeden Vorschlag',
'Einfache Ueberschreibung moeglich',
],
aiActArticle: 'Art. 52 (Transparenzpflichten)',
},
{
id: 'eh-matching',
name: 'Erwartungshorizont-Abgleich',
description: 'Automatischer Abgleich von Schuelerantworten mit Erwartungshorizonten',
riskLevel: 'limited',
justification: 'Empfehlung, keine Entscheidung. Zeigt Uebereinstimmungen auf, bewertet nicht eigenstaendig.',
humanInLoop: true,
transparencyMeasures: [
'Visualisierung der Matching-Logik',
'Confidence-Score angezeigt',
'Manuelle Korrektur jederzeit moeglich',
],
aiActArticle: 'Art. 52 (Transparenzpflichten)',
},
{
id: 'report-drafts',
name: 'Zeugnis-Textentwurf',
description: 'KI-generierte Entwuerfe fuer Zeugnistexte und Beurteilungen',
riskLevel: 'limited',
justification: 'Entwurf, der von der Lehrkraft finalisiert wird. Keine automatische Uebernahme.',
humanInLoop: true,
transparencyMeasures: [
'Entwurf-Wasserzeichen',
'Pflicht zur manuellen Freigabe',
'Vollstaendig editierbar',
],
aiActArticle: 'Art. 52 (Transparenzpflichten)',
},
{
id: 'edu-search',
name: 'Bildungssuche (edu-search)',
description: 'Semantische Suche in Lehrplaenen und Bildungsmaterialien',
riskLevel: 'minimal',
justification: 'Informationsretrieval ohne Bewertungsfunktion. Reine Suchfunktion.',
humanInLoop: true,
transparencyMeasures: ['Quellenangaben', 'Ranking-Transparenz'],
aiActArticle: 'Art. 69 (Freiwillige Verhaltenskodizes)',
},
]
// =============================================================================
// DATA - Warning Lines (What we must never build)
// =============================================================================
const WARNING_LINES: WarningLine[] = [
{
id: 'auto-grading',
title: 'Automatische Notenvergabe',
description: 'KI berechnet und vergibt Noten ohne menschliche Pruefung',
wouldTrigger: 'high',
currentStatus: 'safe',
recommendation: 'Noten immer als Vorschlag, nie als finale Entscheidung',
},
{
id: 'student-classification',
title: 'Schuelerklassifikation',
description: 'Automatische Einteilung in Leistungsgruppen (leistungsstark/schwach)',
wouldTrigger: 'high',
currentStatus: 'safe',
recommendation: 'Keine automatische Kategorisierung von Schuelern implementieren',
},
{
id: 'promotion-decisions',
title: 'Versetzungsentscheidungen',
description: 'Automatisierte Logik fuer Versetzung/Nichtversetzung',
wouldTrigger: 'high',
currentStatus: 'safe',
recommendation: 'Versetzungsentscheidungen ausschliesslich bei Lehrkraeften belassen',
},
{
id: 'unreviewed-assessments',
title: 'Ungeprueft freigegebene Bewertungen',
description: 'KI-Bewertungen ohne menschliche Kontrolle an Schueler/Eltern',
wouldTrigger: 'high',
currentStatus: 'safe',
recommendation: 'Immer manuellen Freigabe-Schritt vor Veroeffentlichung',
},
{
id: 'behavioral-profiling',
title: 'Verhaltensprofilierung',
description: 'Erstellung von Persoenlichkeits- oder Verhaltensprofilen von Schuelern',
wouldTrigger: 'unacceptable',
currentStatus: 'safe',
recommendation: 'Keine Verhaltensanalyse oder Profiling implementieren',
},
{
id: 'algorithmic-optimization',
title: 'Algorithmische Schuloptimierung',
description: 'KI optimiert Schulentscheidungen (Klassenzuteilung, Ressourcen)',
wouldTrigger: 'high',
currentStatus: 'safe',
recommendation: 'Schulorganisatorische Entscheidungen bei Menschen belassen',
},
{
id: 'auto-accept',
title: 'Auto-Accept Funktionen',
description: 'Ein-Klick-Uebernahme von KI-Vorschlaegen ohne Pruefung',
wouldTrigger: 'high',
currentStatus: 'safe',
recommendation: 'Immer bewusste Bestaetigungsschritte einbauen',
},
{
id: 'emotion-detection',
title: 'Emotionserkennung',
description: 'Analyse von Emotionen oder psychischem Zustand von Schuelern',
wouldTrigger: 'unacceptable',
currentStatus: 'safe',
recommendation: 'Keine biometrische oder emotionale Analyse',
},
]
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
const getRiskLevelInfo = (level: RiskLevel) => {
switch (level) {
case 'unacceptable':
return {
label: 'Unzulaessig',
color: 'bg-black text-white',
borderColor: 'border-black',
description: 'Verboten nach EU-AI-Act',
}
case 'high':
return {
label: 'Hoch',
color: 'bg-red-600 text-white',
borderColor: 'border-red-600',
description: 'Strenge Anforderungen, Konformitaetsbewertung',
}
case 'limited':
return {
label: 'Begrenzt',
color: 'bg-amber-500 text-white',
borderColor: 'border-amber-500',
description: 'Transparenzpflichten',
}
case 'minimal':
return {
label: 'Minimal',
color: 'bg-green-600 text-white',
borderColor: 'border-green-600',
description: 'Freiwillige Verhaltenskodizes',
}
}
}
const getStatusInfo = (status: 'safe' | 'approaching' | 'violated') => {
switch (status) {
case 'safe':
return { label: 'Sicher', color: 'bg-green-100 text-green-700', icon: '✓' }
case 'approaching':
return { label: 'Annaehernd', color: 'bg-amber-100 text-amber-700', icon: '⚠' }
case 'violated':
return { label: 'Verletzt', color: 'bg-red-100 text-red-700', icon: '✗' }
}
}
// =============================================================================
// COMPONENT
// =============================================================================
export default function AIActClassificationPage() {
const [activeTab, setActiveTab] = useState<'overview' | 'modules' | 'warnings' | 'documentation'>('overview')
const [expandedModule, setExpandedModule] = useState<string | null>(null)
// Calculate statistics
const stats = {
totalModules: MODULE_ASSESSMENTS.length,
minimalRisk: MODULE_ASSESSMENTS.filter((m) => m.riskLevel === 'minimal').length,
limitedRisk: MODULE_ASSESSMENTS.filter((m) => m.riskLevel === 'limited').length,
highRisk: MODULE_ASSESSMENTS.filter((m) => m.riskLevel === 'high').length,
humanInLoop: MODULE_ASSESSMENTS.filter((m) => m.humanInLoop).length,
warningsTotal: WARNING_LINES.length,
warningsSafe: WARNING_LINES.filter((w) => w.currentStatus === 'safe').length,
}
const generateMemo = () => {
const date = new Date().toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
const memo = `
================================================================================
EU-AI-ACT RISIKOKLASSIFIZIERUNG - BREAKPILOT
================================================================================
Erstellungsdatum: ${date}
Version: 1.0
Verantwortlich: Breakpilot GmbH
--------------------------------------------------------------------------------
1. ZUSAMMENFASSUNG
--------------------------------------------------------------------------------
System: Breakpilot KI-Assistenzsystem fuer Bildung
Gesamtrisikokategorie: LIMITED RISK (Art. 52 EU-AI-Act)
Begruendung:
- KI liefert ausschliesslich Vorschlaege und Entwuerfe
- Kein automatisiertes Entscheiden ueber Schueler
- Mensch-in-the-Loop ist technisch erzwungen
- Keine Schuelerklassifikation oder Profiling
- Alle paedagogischen Entscheidungen verbleiben bei Lehrkraeften
--------------------------------------------------------------------------------
2. MODUL-BEWERTUNG
--------------------------------------------------------------------------------
${MODULE_ASSESSMENTS.map(
(m) => `
${m.name}
Risikostufe: ${getRiskLevelInfo(m.riskLevel).label.toUpperCase()}
Begruendung: ${m.justification}
Human-in-Loop: ${m.humanInLoop ? 'JA' : 'NEIN'}
AI-Act Artikel: ${m.aiActArticle}
`
).join('')}
--------------------------------------------------------------------------------
3. TRANSPARENZMASSNAHMEN
--------------------------------------------------------------------------------
Alle KI-generierten Inhalte sind:
- Klar als KI-Vorschlag gekennzeichnet
- Vollstaendig editierbar durch die Lehrkraft
- Mit Quellenangaben versehen (wo zutreffend)
- Erst nach manueller Freigabe wirksam
Zusaetzliche UI-Hinweise:
- "Dieser Text wurde durch KI vorgeschlagen"
- "Endverantwortung liegt bei der Lehrkraft"
- Confidence-Scores wo relevant
--------------------------------------------------------------------------------
4. HUMAN-IN-THE-LOOP GARANTIEN
--------------------------------------------------------------------------------
Technisch erzwungene Massnahmen:
- Kein Auto-Accept fuer KI-Vorschlaege
- Kein 1-Click-Bewerten
- Pflicht-Bestaetigung vor Freigabe
- Audit-Trail aller Aenderungen
--------------------------------------------------------------------------------
5. WARNLINIEN (NICHT IMPLEMENTIEREN)
--------------------------------------------------------------------------------
${WARNING_LINES.map(
(w) => `
[${getStatusInfo(w.currentStatus).icon}] ${w.title}
Wuerde ausloesen: ${getRiskLevelInfo(w.wouldTrigger).label}
Status: ${getStatusInfo(w.currentStatus).label}
`
).join('')}
--------------------------------------------------------------------------------
6. RECHTLICHE EINORDNUNG
--------------------------------------------------------------------------------
Breakpilot faellt NICHT unter die High-Risk Kategorie des EU-AI-Act, da:
1. Keine automatisierten Entscheidungen ueber natuerliche Personen
2. Keine Bewertung von Schuelern ohne menschliche Kontrolle
3. Keine Zugangs- oder Selektionsentscheidungen
4. Reine Assistenzfunktion mit Human-in-the-Loop
Die Transparenzpflichten nach Art. 52 werden durch entsprechende
UI-Kennzeichnungen und Nutzerinformationen erfuellt.
--------------------------------------------------------------------------------
7. MANAGEMENT-STATEMENT
--------------------------------------------------------------------------------
"Breakpilot ist ein KI-Assistenzsystem mit begrenztem Risiko gemaess EU-AI-Act.
Die KI trifft keine Entscheidungen, sondern unterstuetzt Lehrkraefte transparent
und nachvollziehbar. Alle paedagogischen und rechtlichen Entscheidungen
verbleiben beim Menschen."
================================================================================
Dieses Dokument dient der internen Compliance-Dokumentation und kann
Auditoren auf Anfrage vorgelegt werden.
================================================================================
`
return memo
}
const downloadMemo = () => {
const memo = generateMemo()
const blob = new Blob([memo], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `breakpilot-ai-act-klassifizierung-${new Date().toISOString().split('T')[0]}.txt`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const tabs = [
{ id: 'overview', name: 'Uebersicht', icon: '📊' },
{ id: 'modules', name: 'Module', icon: '🧩' },
{ id: 'warnings', name: 'Warnlinien', icon: '⚠️' },
{ id: 'documentation', name: 'Dokumentation', icon: '📄' },
]
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-white border-b border-slate-200">
<div className="max-w-7xl mx-auto px-6 py-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center">
<span className="text-2xl">🤖</span>
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">EU-AI-Act Klassifizierung</h1>
<p className="text-slate-600">Risikoklassifizierung und Compliance-Dokumentation</p>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-6 py-6">
{/* Page Purpose */}
<PagePurpose
title="KI-Risikoklassifizierung nach EU-AI-Act"
purpose="Selbstbewertung und Dokumentation der Risikokategorien aller KI-Module gemaess EU-AI-Act. Definiert Warnlinien fuer Features, die nicht implementiert werden duerfen."
audience={['Management', 'DSB', 'Compliance Officer', 'Auditor', 'Investoren']}
gdprArticles={['EU-AI-Act Art. 52', 'EU-AI-Act Art. 69', 'EU-AI-Act Anhang III']}
/>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Module gesamt</div>
<div className="text-2xl font-bold text-slate-900">{stats.totalModules}</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Minimal Risk</div>
<div className="text-2xl font-bold text-green-600">{stats.minimalRisk}</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Limited Risk</div>
<div className="text-2xl font-bold text-amber-600">{stats.limitedRisk}</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Human-in-Loop</div>
<div className="text-2xl font-bold text-blue-600">{stats.humanInLoop}/{stats.totalModules}</div>
</div>
</div>
{/* Classification Banner */}
<div className="mt-6 bg-gradient-to-r from-amber-50 to-amber-100 border border-amber-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-full bg-amber-200 flex items-center justify-center flex-shrink-0">
<span className="text-3xl"></span>
</div>
<div>
<h2 className="text-xl font-bold text-amber-900">Gesamtklassifizierung: LIMITED RISK</h2>
<p className="text-amber-800 mt-1">
Breakpilot ist ein KI-Assistenzsystem mit <strong>begrenztem Risiko</strong> gemaess EU-AI-Act (Art. 52).
Es gelten Transparenzpflichten, aber keine Konformitaetsbewertung.
</p>
<div className="flex flex-wrap gap-2 mt-3">
<span className="px-3 py-1 bg-amber-200 text-amber-800 rounded-full text-sm font-medium">
Art. 52 Transparenz
</span>
<span className="px-3 py-1 bg-green-200 text-green-800 rounded-full text-sm font-medium">
Human-in-the-Loop
</span>
<span className="px-3 py-1 bg-blue-200 text-blue-800 rounded-full text-sm font-medium">
Assistiv, nicht autonom
</span>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 mt-6 border-b border-slate-200">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-slate-600 hover:text-slate-900'
}`}
>
<span className="mr-2">{tab.icon}</span>
{tab.name}
</button>
))}
</div>
{/* Tab Content */}
<div className="mt-6">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Risk Level Pyramid */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">EU-AI-Act Risikopyramide</h3>
<div className="space-y-3">
{/* Unacceptable */}
<div className="flex items-center gap-4">
<div className="w-32 text-right">
<span className="px-3 py-1 bg-black text-white text-xs rounded font-medium">
Unzulaessig
</span>
</div>
<div className="flex-1 h-8 bg-slate-100 rounded relative overflow-hidden">
<div className="absolute inset-y-0 left-0 w-0 bg-black rounded" />
<span className="absolute inset-0 flex items-center justify-center text-xs text-slate-500">
0 Module - Social Scoring, Manipulation verboten
</span>
</div>
</div>
{/* High */}
<div className="flex items-center gap-4">
<div className="w-32 text-right">
<span className="px-3 py-1 bg-red-600 text-white text-xs rounded font-medium">
Hoch
</span>
</div>
<div className="flex-1 h-8 bg-slate-100 rounded relative overflow-hidden">
<div className="absolute inset-y-0 left-0 w-0 bg-red-600 rounded" />
<span className="absolute inset-0 flex items-center justify-center text-xs text-slate-500">
0 Module - Keine automatischen Entscheidungen
</span>
</div>
</div>
{/* Limited */}
<div className="flex items-center gap-4">
<div className="w-32 text-right">
<span className="px-3 py-1 bg-amber-500 text-white text-xs rounded font-medium">
Begrenzt
</span>
</div>
<div className="flex-1 h-8 bg-slate-100 rounded relative overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-amber-500 rounded transition-all"
style={{ width: `${(stats.limitedRisk / stats.totalModules) * 100}%` }}
/>
<span className="absolute inset-0 flex items-center justify-center text-xs font-medium text-slate-700">
{stats.limitedRisk} Module - Transparenzpflichten
</span>
</div>
</div>
{/* Minimal */}
<div className="flex items-center gap-4">
<div className="w-32 text-right">
<span className="px-3 py-1 bg-green-600 text-white text-xs rounded font-medium">
Minimal
</span>
</div>
<div className="flex-1 h-8 bg-slate-100 rounded relative overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-green-600 rounded transition-all"
style={{ width: `${(stats.minimalRisk / stats.totalModules) * 100}%` }}
/>
<span className="absolute inset-0 flex items-center justify-center text-xs font-medium text-slate-700">
{stats.minimalRisk} Module - Freiwillige Kodizes
</span>
</div>
</div>
</div>
</div>
{/* Key Arguments */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Kernargumente fuer Limited Risk</h3>
<div className="grid md:grid-cols-2 gap-4">
<div className="flex items-start gap-3 p-4 bg-green-50 rounded-lg">
<span className="text-xl"></span>
<div>
<div className="font-medium text-green-900">Assistiv, nicht autonom</div>
<div className="text-sm text-green-700">KI liefert Vorschlaege, keine Entscheidungen</div>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-green-50 rounded-lg">
<span className="text-xl"></span>
<div>
<div className="font-medium text-green-900">Human-in-the-Loop</div>
<div className="text-sm text-green-700">Lehrkraft hat immer das letzte Wort</div>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-green-50 rounded-lg">
<span className="text-xl"></span>
<div>
<div className="font-medium text-green-900">Keine Schuelerklassifikation</div>
<div className="text-sm text-green-700">Keine Kategorisierung oder Profiling</div>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-green-50 rounded-lg">
<span className="text-xl"></span>
<div>
<div className="font-medium text-green-900">Transparente Kennzeichnung</div>
<div className="text-sm text-green-700">KI-Inhalte sind klar markiert</div>
</div>
</div>
</div>
</div>
{/* Management Statement */}
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 text-white">
<div className="flex items-start gap-4">
<span className="text-3xl">💬</span>
<div>
<h3 className="font-semibold text-lg">Management-Statement (Pitch-faehig)</h3>
<blockquote className="mt-3 text-slate-300 italic border-l-4 border-amber-500 pl-4">
&ldquo;Breakpilot ist ein KI-Assistenzsystem mit begrenztem Risiko gemaess EU-AI-Act.
Die KI trifft keine Entscheidungen, sondern unterstuetzt Lehrkraefte transparent und nachvollziehbar.
Alle paedagogischen und rechtlichen Entscheidungen verbleiben beim Menschen.&rdquo;
</blockquote>
</div>
</div>
</div>
</div>
)}
{/* Modules Tab */}
{activeTab === 'modules' && (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Modul</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Risikostufe</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Human-in-Loop</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">AI-Act Artikel</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{MODULE_ASSESSMENTS.map((module) => {
const riskInfo = getRiskLevelInfo(module.riskLevel)
const isExpanded = expandedModule === module.id
return (
<>
<tr key={module.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<div className="font-medium text-slate-800">{module.name}</div>
<div className="text-sm text-slate-500">{module.description}</div>
</td>
<td className="px-4 py-3">
<span className={`px-3 py-1 rounded text-xs font-medium ${riskInfo.color}`}>
{riskInfo.label}
</span>
</td>
<td className="px-4 py-3">
{module.humanInLoop ? (
<span className="text-green-600 font-medium"> Ja</span>
) : (
<span className="text-red-600 font-medium"> Nein</span>
)}
</td>
<td className="px-4 py-3 text-sm text-slate-600">{module.aiActArticle}</td>
<td className="px-4 py-3">
<button
onClick={() => setExpandedModule(isExpanded ? null : module.id)}
className="text-purple-600 hover:text-purple-800 text-sm"
>
{isExpanded ? 'Weniger' : 'Details'}
</button>
</td>
</tr>
{isExpanded && (
<tr key={`${module.id}-details`}>
<td colSpan={5} className="px-4 py-4 bg-slate-50">
<div className="space-y-3">
<div>
<div className="text-xs font-medium text-slate-500 uppercase mb-1">Begruendung</div>
<div className="text-sm text-slate-700">{module.justification}</div>
</div>
<div>
<div className="text-xs font-medium text-slate-500 uppercase mb-1">
Transparenzmassnahmen
</div>
<ul className="text-sm text-slate-700 list-disc list-inside">
{module.transparencyMeasures.map((measure, i) => (
<li key={i}>{measure}</li>
))}
</ul>
</div>
</div>
</td>
</tr>
)}
</>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* Warnings Tab */}
{activeTab === 'warnings' && (
<div className="space-y-6">
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<span className="text-3xl">🚫</span>
<div>
<h3 className="font-semibold text-red-900">
Warnlinien: Was wir NIEMALS bauen duerfen
</h3>
<p className="text-red-700 mt-1">
Diese Features wuerden Breakpilot in die High-Risk oder Unzulaessig-Kategorie verschieben.
Sie sind explizit von der Roadmap ausgeschlossen.
</p>
</div>
</div>
</div>
<div className="grid gap-4">
{WARNING_LINES.map((warning) => {
const statusInfo = getStatusInfo(warning.currentStatus)
const riskInfo = getRiskLevelInfo(warning.wouldTrigger)
return (
<div
key={warning.id}
className={`bg-white rounded-xl border-2 ${
warning.currentStatus === 'safe'
? 'border-green-200'
: warning.currentStatus === 'approaching'
? 'border-amber-300'
: 'border-red-400'
} p-4`}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center text-lg ${statusInfo.color}`}
>
{statusInfo.icon}
</div>
<div>
<h4 className="font-semibold text-slate-900">{warning.title}</h4>
<p className="text-sm text-slate-600 mt-1">{warning.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Wuerde ausloesen:</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${riskInfo.color}`}>
{riskInfo.label}
</span>
</div>
</div>
<div className="mt-3 pl-13 ml-13">
<div className="flex items-center gap-2 text-sm">
<span className="text-slate-500">Empfehlung:</span>
<span className="text-slate-700">{warning.recommendation}</span>
</div>
</div>
</div>
)
})}
</div>
{/* Safe Zone Indicator */}
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-full bg-green-200 flex items-center justify-center">
<span className="text-3xl"></span>
</div>
<div>
<h3 className="font-semibold text-green-900">
Alle Warnlinien eingehalten: {stats.warningsSafe}/{stats.warningsTotal}
</h3>
<p className="text-green-700 mt-1">
Breakpilot befindet sich sicher im Limited/Minimal Risk Bereich des EU-AI-Act.
</p>
</div>
</div>
</div>
</div>
)}
{/* Documentation Tab */}
{activeTab === 'documentation' && (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-slate-900">Klassifizierungs-Memo exportieren</h3>
<p className="text-slate-600 mt-1">
Generiert ein vollstaendiges Compliance-Dokument zur Vorlage bei Auditoren oder Investoren.
</p>
</div>
<button
onClick={downloadMemo}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" 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>
Als TXT herunterladen
</button>
</div>
</div>
{/* Preview */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Vorschau</h3>
<pre className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-xs font-mono whitespace-pre-wrap max-h-96 overflow-y-auto">
{generateMemo()}
</pre>
</div>
{/* Human-in-the-Loop Documentation */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Human-in-the-Loop Nachweis</h3>
<div className="space-y-4">
<div className="p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium text-blue-900">Technische Massnahmen</h4>
<ul className="mt-2 text-sm text-blue-800 space-y-1">
<li> Kein Auto-Accept Button fuer KI-Vorschlaege</li>
<li> Mindestens 2 Klicks fuer Uebernahme erforderlich</li>
<li> Alle KI-Outputs sind editierbar</li>
<li> Pflicht-Review vor Freigabe an Schueler/Eltern</li>
<li> Audit-Trail dokumentiert alle menschlichen Eingriffe</li>
</ul>
</div>
<div className="p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium text-blue-900">UI-Kennzeichnungen</h4>
<ul className="mt-2 text-sm text-blue-800 space-y-1">
<li> &ldquo;KI-Vorschlag&rdquo; Label auf allen generierten Inhalten</li>
<li> &ldquo;Endverantwortung liegt bei der Lehrkraft&rdquo; Hinweis</li>
<li> Confidence-Scores wo relevant</li>
<li> Quellenangaben fuer RAG-basierte Inhalte</li>
</ul>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,775 +0,0 @@
'use client'
/**
* Audit Checklist Page - 476+ Requirements Interactive Checklist
*
* Features:
* - Session management (create, start, complete)
* - Paginated checklist with search & filters
* - Sign-off workflow with digital signatures
* - Progress tracking with statistics
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// Types
interface AuditSession {
id: string
name: 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
}
interface ChecklistItem {
requirement_id: string
regulation_code: string
article: string
paragraph?: string
title: string
description?: string
current_result: string
notes?: string
is_signed: boolean
signed_at?: string
signed_by?: string
evidence_count: number
controls_mapped: number
implementation_status: string
priority: number
}
interface AuditStatistics {
total: number
compliant: number
compliant_with_notes: number
non_compliant: number
not_applicable: number
pending: number
completion_percentage: number
}
// Haupt-/Nebenabweichungen aus ISMS
interface FindingsData {
major_count: number // Hauptabweichungen (blockiert Zertifizierung)
minor_count: number // Nebenabweichungen (erfordert CAPA)
ofi_count: number // Verbesserungspotenziale
total: number
open_majors: number // Offene Hauptabweichungen
open_minors: number // Offene Nebenabweichungen
}
const RESULT_COLORS: Record<string, { bg: string; text: string; label: string }> = {
compliant: { bg: 'bg-green-100', text: 'text-green-700', label: 'Konform' },
compliant_notes: { bg: 'bg-green-50', text: 'text-green-600', label: 'Konform (mit Anm.)' },
non_compliant: { bg: 'bg-red-100', text: 'text-red-700', label: 'Nicht konform' },
not_applicable: { bg: 'bg-slate-100', text: 'text-slate-600', label: 'N/A' },
pending: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Ausstehend' },
}
export default function AuditChecklistPage() {
const [sessions, setSessions] = useState<AuditSession[]>([])
const [selectedSession, setSelectedSession] = useState<AuditSession | null>(null)
const [checklist, setChecklist] = useState<ChecklistItem[]>([])
const [statistics, setStatistics] = useState<AuditStatistics | null>(null)
const [loading, setLoading] = useState(true)
const [checklistLoading, setChecklistLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [findings, setFindings] = useState<FindingsData | null>(null)
// Filters
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [regulationFilter, setRegulationFilter] = useState<string>('')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false)
const [showSignOffModal, setShowSignOffModal] = useState(false)
const [selectedItem, setSelectedItem] = useState<ChecklistItem | null>(null)
// New session form
const [newSession, setNewSession] = useState({
name: '',
auditor_name: '',
auditor_email: '',
auditor_organization: '',
regulation_codes: [] as string[],
})
useEffect(() => {
loadSessions()
loadFindings()
}, [])
const loadFindings = async () => {
try {
const res = await fetch('/api/admin/compliance/isms/findings/summary')
if (res.ok) {
const data = await res.json()
setFindings(data)
}
} catch (err) {
console.error('Failed to load findings:', err)
}
}
useEffect(() => {
if (selectedSession) {
loadChecklist()
}
}, [selectedSession, page, statusFilter, regulationFilter, search])
const loadSessions = async () => {
setLoading(true)
try {
const res = await fetch('/api/admin/audit/sessions')
if (res.ok) {
const data = await res.json()
setSessions(data)
}
} catch (err) {
console.error('Failed to load sessions:', err)
setError('Sessions konnten nicht geladen werden')
} finally {
setLoading(false)
}
}
const loadChecklist = async () => {
if (!selectedSession) return
setChecklistLoading(true)
try {
const params = new URLSearchParams({
page: page.toString(),
page_size: '50',
})
if (statusFilter) params.set('status_filter', statusFilter)
if (regulationFilter) params.set('regulation_filter', regulationFilter)
if (search) params.set('search', search)
const res = await fetch(`/api/admin/compliance/audit/checklist/${selectedSession.id}?${params}`)
if (res.ok) {
const data = await res.json()
setChecklist(data.items || [])
setStatistics(data.statistics)
setTotalPages(data.pagination?.total_pages || 1)
}
} catch (err) {
console.error('Failed to load checklist:', err)
} finally {
setChecklistLoading(false)
}
}
const createSession = async () => {
try {
const res = await fetch('/api/admin/audit/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSession),
})
if (res.ok) {
const session = await res.json()
setSessions([session, ...sessions])
setSelectedSession(session)
setShowCreateModal(false)
setNewSession({
name: '',
auditor_name: '',
auditor_email: '',
auditor_organization: '',
regulation_codes: [],
})
}
} catch (err) {
console.error('Failed to create session:', err)
}
}
const startSession = async (sessionId: string) => {
try {
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/start`, {
method: 'PUT',
})
if (res.ok) {
loadSessions()
if (selectedSession?.id === sessionId) {
setSelectedSession({ ...selectedSession, status: 'in_progress' })
}
}
} catch (err) {
console.error('Failed to start session:', err)
}
}
const completeSession = async (sessionId: string) => {
try {
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/complete`, {
method: 'PUT',
})
if (res.ok) {
loadSessions()
if (selectedSession?.id === sessionId) {
setSelectedSession({ ...selectedSession, status: 'completed' })
}
}
} catch (err) {
console.error('Failed to complete session:', err)
}
}
const signOffItem = async (result: string, notes: string, sign: boolean) => {
if (!selectedSession || !selectedItem) return
try {
const res = await fetch(
`/api/admin/compliance/audit/checklist/${selectedSession.id}/items/${selectedItem.requirement_id}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ result, notes, sign }),
}
)
if (res.ok) {
loadChecklist()
loadSessions()
setShowSignOffModal(false)
setSelectedItem(null)
}
} catch (err) {
console.error('Failed to sign off:', err)
}
}
const downloadPdf = async (sessionId: string) => {
window.open(`/api/admin/audit/sessions/${sessionId}/pdf`, '_blank')
}
return (
<div className="space-y-6">
<PagePurpose
title="Audit Checkliste"
purpose="Interaktive Checkliste mit 476+ Compliance-Anforderungen aus DSGVO, AI Act, CRA und BSI TR-03161. Erstellen Sie Audit-Sessions, bewerten Sie Anforderungen und generieren Sie Audit-Reports mit digitalen Signaturen."
audience={['Auditor', 'DSB', 'Compliance Officer']}
gdprArticles={['Art. 5 (Rechenschaftspflicht)', 'Art. 30 (Verzeichnis)', 'Art. 32 (Sicherheit)']}
architecture={{
services: ['Python Backend', 'PostgreSQL'],
databases: ['compliance_audit_sessions', 'compliance_audit_signoffs', 'compliance_requirements'],
}}
relatedPages={[
{ name: 'Compliance Hub', href: '/compliance/hub', description: 'Uebersicht & Dashboard' },
{ name: 'Audit Report', href: '/compliance/audit-report', description: 'PDF-Reports generieren' },
{ name: 'Controls', href: '/compliance/controls', description: 'Technische Massnahmen' },
]}
/>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-700">{error}</p>
</div>
)}
{/* Haupt-/Nebenabweichungen Uebersicht */}
{findings && (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900">Audit Findings (ISMS)</h2>
<span className={`px-3 py-1 text-sm rounded-full ${
findings.open_majors > 0
? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'
}`}>
{findings.open_majors > 0 ? 'Zertifizierung blockiert' : 'Zertifizierungsfaehig'}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="text-center p-4 bg-red-50 rounded-lg border border-red-200">
<p className="text-3xl font-bold text-red-700">{findings.major_count}</p>
<p className="text-sm text-red-600 font-medium">Hauptabweichungen</p>
<p className="text-xs text-red-500 mt-1">(MAJOR)</p>
{findings.open_majors > 0 && (
<p className="text-xs text-red-700 mt-2 font-medium">
{findings.open_majors} offen
</p>
)}
</div>
<div className="text-center p-4 bg-orange-50 rounded-lg border border-orange-200">
<p className="text-3xl font-bold text-orange-700">{findings.minor_count}</p>
<p className="text-sm text-orange-600 font-medium">Nebenabweichungen</p>
<p className="text-xs text-orange-500 mt-1">(MINOR)</p>
{findings.open_minors > 0 && (
<p className="text-xs text-orange-700 mt-2 font-medium">
{findings.open_minors} offen
</p>
)}
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
<p className="text-3xl font-bold text-blue-700">{findings.ofi_count}</p>
<p className="text-sm text-blue-600 font-medium">Verbesserungen</p>
<p className="text-xs text-blue-500 mt-1">(OFI)</p>
</div>
<div className="text-center p-4 bg-slate-50 rounded-lg border border-slate-200">
<p className="text-3xl font-bold text-slate-700">{findings.total}</p>
<p className="text-sm text-slate-600 font-medium">Gesamt Findings</p>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg border border-purple-200">
<div className="flex flex-col items-center">
<svg className={`w-8 h-8 ${findings.open_majors === 0 ? 'text-green-500' : 'text-red-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{findings.open_majors === 0 ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
) : (
<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>
<p className="text-sm text-purple-600 font-medium mt-2">Zertifizierung</p>
<p className={`text-xs mt-1 font-medium ${findings.open_majors === 0 ? 'text-green-600' : 'text-red-600'}`}>
{findings.open_majors === 0 ? 'Moeglich' : 'Blockiert'}
</p>
</div>
</div>
</div>
<div className="mt-4 p-3 bg-slate-50 rounded-lg">
<p className="text-xs text-slate-600">
<strong>Hauptabweichung (MAJOR):</strong> Signifikante Abweichung von Anforderungen - blockiert Zertifizierung bis zur Behebung.{' '}
<strong>Nebenabweichung (MINOR):</strong> Kleinere Abweichung - erfordert CAPA (Corrective Action) innerhalb 90 Tagen.
</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sessions Sidebar */}
<div className="lg:col-span-1 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900">Audit Sessions</h2>
<button
onClick={() => setShowCreateModal(true)}
className="p-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : sessions.length === 0 ? (
<div className="text-center py-8 text-slate-500">
<p>Keine Sessions vorhanden</p>
<button
onClick={() => setShowCreateModal(true)}
className="mt-2 text-purple-600 hover:text-purple-700"
>
Erste Session erstellen
</button>
</div>
) : (
<div className="space-y-2">
{sessions.map((session) => (
<div
key={session.id}
onClick={() => setSelectedSession(session)}
className={`p-4 rounded-lg border cursor-pointer transition-colors ${
selectedSession?.id === session.id
? 'border-purple-500 bg-purple-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className={`px-2 py-0.5 text-xs rounded-full ${
session.status === 'completed' ? 'bg-green-100 text-green-700' :
session.status === 'in_progress' ? 'bg-blue-100 text-blue-700' :
session.status === 'archived' ? 'bg-slate-100 text-slate-700' :
'bg-yellow-100 text-yellow-700'
}`}>
{session.status === 'completed' ? 'Abgeschlossen' :
session.status === 'in_progress' ? 'In Bearbeitung' :
session.status === 'archived' ? 'Archiviert' : 'Entwurf'}
</span>
<span className="text-xs text-slate-500">{session.completion_percentage.toFixed(0)}%</span>
</div>
<h3 className="font-medium text-slate-900 truncate">{session.name}</h3>
<p className="text-sm text-slate-500">{session.auditor_name}</p>
<div className="mt-2 h-1.5 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-500"
style={{ width: `${session.completion_percentage}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
{/* Checklist Content */}
<div className="lg:col-span-3 space-y-4">
{!selectedSession ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 mx-auto text-slate-300 mb-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 2" />
</svg>
<h3 className="text-lg font-medium text-slate-900">Waehlen Sie eine Session</h3>
<p className="text-slate-500 mt-2">Waehlen Sie eine Audit-Session aus der Liste oder erstellen Sie eine neue.</p>
</div>
) : (
<>
{/* Session Header */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold text-slate-900">{selectedSession.name}</h2>
<p className="text-slate-500">{selectedSession.auditor_name} {selectedSession.auditor_organization && `- ${selectedSession.auditor_organization}`}</p>
</div>
<div className="flex gap-2">
{selectedSession.status === 'draft' && (
<button
onClick={() => startSession(selectedSession.id)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Starten
</button>
)}
{selectedSession.status === 'in_progress' && (
<button
onClick={() => completeSession(selectedSession.id)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Abschliessen
</button>
)}
{selectedSession.status === 'completed' && (
<button
onClick={() => downloadPdf(selectedSession.id)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
PDF Export
</button>
)}
</div>
</div>
{/* Statistics */}
{statistics && (
<div className="grid grid-cols-6 gap-4">
<div className="text-center p-3 bg-slate-50 rounded-lg">
<p className="text-2xl font-bold text-slate-900">{statistics.total}</p>
<p className="text-xs text-slate-500">Gesamt</p>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<p className="text-2xl font-bold text-green-700">{statistics.compliant + statistics.compliant_with_notes}</p>
<p className="text-xs text-green-600">Konform</p>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<p className="text-2xl font-bold text-red-700">{statistics.non_compliant}</p>
<p className="text-xs text-red-600">Nicht konform</p>
</div>
<div className="text-center p-3 bg-slate-50 rounded-lg">
<p className="text-2xl font-bold text-slate-700">{statistics.not_applicable}</p>
<p className="text-xs text-slate-500">N/A</p>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<p className="text-2xl font-bold text-yellow-700">{statistics.pending}</p>
<p className="text-xs text-yellow-600">Ausstehend</p>
</div>
<div className="text-center p-3 bg-purple-50 rounded-lg">
<p className="text-2xl font-bold text-purple-700">{statistics.completion_percentage.toFixed(0)}%</p>
<p className="text-xs text-purple-600">Fortschritt</p>
</div>
</div>
)}
</div>
{/* Filters */}
<div className="flex gap-4">
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
placeholder="Suche..."
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Alle Status</option>
<option value="pending">Ausstehend</option>
<option value="compliant">Konform</option>
<option value="non_compliant">Nicht konform</option>
<option value="not_applicable">N/A</option>
</select>
</div>
{/* Checklist Table */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
{checklistLoading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : checklist.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Eintraege gefunden
</div>
) : (
<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">Regulation</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Artikel</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Controls</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{checklist.map((item) => {
const resultConfig = RESULT_COLORS[item.current_result] || RESULT_COLORS.pending
return (
<tr key={item.requirement_id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono text-sm text-purple-600">{item.regulation_code}</span>
</td>
<td className="px-4 py-3">
<span className="font-medium">{item.article}</span>
{item.paragraph && <span className="text-slate-500 text-sm"> {item.paragraph}</span>}
</td>
<td className="px-4 py-3">
<p className="text-sm text-slate-900 line-clamp-2">{item.title}</p>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
item.controls_mapped > 0 ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'
}`}>
{item.controls_mapped}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${resultConfig.bg} ${resultConfig.text}`}>
{resultConfig.label}
</span>
{item.is_signed && (
<svg className="w-4 h-4 text-green-600 inline-block ml-1" 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>
)}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => { setSelectedItem(item); setShowSignOffModal(true) }}
className="px-3 py-1 text-sm bg-purple-100 text-purple-700 rounded hover:bg-purple-200"
disabled={selectedSession.status !== 'in_progress' && selectedSession.status !== 'draft'}
>
Bewerten
</button>
</td>
</tr>
)
})}
</tbody>
</table>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="px-4 py-3 border-t flex items-center justify-between">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1 border rounded disabled:opacity-50"
>
Zurueck
</button>
<span className="text-sm text-slate-500">
Seite {page} von {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1 border rounded disabled:opacity-50"
>
Weiter
</button>
</div>
)}
</div>
</>
)}
</div>
</div>
{/* Create Session Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md">
<h3 className="text-lg font-semibold mb-4">Neue Audit Session</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
value={newSession.name}
onChange={(e) => setNewSession({ ...newSession, name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
placeholder="z.B. Q1 2026 DSGVO Audit"
/>
</div>
<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 })}
className="w-full px-3 py-2 border rounded-lg"
placeholder="Dr. Max Mustermann"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Organisation (optional)</label>
<input
type="text"
value={newSession.auditor_organization}
onChange={(e) => setNewSession({ ...newSession, auditor_organization: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
placeholder="TÜV Rheinland"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 border rounded-lg"
>
Abbrechen
</button>
<button
onClick={createSession}
disabled={!newSession.name || !newSession.auditor_name}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
Erstellen
</button>
</div>
</div>
</div>
)}
{/* Sign Off Modal */}
{showSignOffModal && selectedItem && (
<SignOffModal
item={selectedItem}
onClose={() => { setShowSignOffModal(false); setSelectedItem(null) }}
onSignOff={signOffItem}
/>
)}
</div>
)
}
// Sign Off Modal Component
function SignOffModal({
item,
onClose,
onSignOff,
}: {
item: ChecklistItem
onClose: () => void
onSignOff: (result: string, notes: string, sign: boolean) => void
}) {
const [result, setResult] = useState(item.current_result === 'pending' ? '' : item.current_result)
const [notes, setNotes] = useState(item.notes || '')
const [sign, setSign] = useState(false)
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg">
<h3 className="text-lg font-semibold mb-2">Anforderung bewerten</h3>
<p className="text-sm text-slate-500 mb-4">
{item.regulation_code} {item.article}: {item.title}
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Bewertung</label>
<div className="grid grid-cols-2 gap-2">
{[
{ value: 'compliant', label: 'Konform', color: 'green' },
{ value: 'compliant_notes', label: 'Konform (mit Anm.)', color: 'green' },
{ value: 'non_compliant', label: 'Nicht konform', color: 'red' },
{ value: 'not_applicable', label: 'Nicht anwendbar', color: 'slate' },
].map((opt) => (
<button
key={opt.value}
onClick={() => setResult(opt.value)}
className={`p-3 rounded-lg border-2 text-left transition-colors ${
result === opt.value
? opt.color === 'green' ? 'border-green-500 bg-green-50' :
opt.color === 'red' ? 'border-red-500 bg-red-50' :
'border-slate-500 bg-slate-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<span className="font-medium">{opt.label}</span>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Anmerkungen</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full px-3 py-2 border rounded-lg"
rows={3}
placeholder="Optionale Anmerkungen..."
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="sign"
checked={sign}
onChange={(e) => setSign(e.target.checked)}
className="w-4 h-4 rounded"
/>
<label htmlFor="sign" className="text-sm text-slate-700">
Digitale Signatur erstellen (SHA-256)
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button onClick={onClose} className="px-4 py-2 border rounded-lg">
Abbrechen
</button>
<button
onClick={() => onSignOff(result, notes, sign)}
disabled={!result}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
Speichern
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,705 +0,0 @@
'use client'
/**
* Audit Report Management Page
*
* Create and manage GDPR audit sessions with PDF report generation.
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
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
}
// Available regulations for filtering
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 [sessions, setSessions] = useState<AuditSession[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'sessions' | 'new' | 'export'>('sessions')
// New session form
const [newSession, setNewSession] = useState({
name: '',
description: '',
auditor_name: '',
auditor_email: '',
auditor_organization: '',
regulation_codes: [] as string[],
})
const [creating, setCreating] = useState(false)
// PDF generation
const [generatingPdf, setGeneratingPdf] = useState<string | null>(null)
const [pdfLanguage, setPdfLanguage] = useState<'de' | 'en'>('de')
// Status filter
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')
}
// Reset form and refresh
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')
}
// Download the PDF
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>
{/* Page Purpose */}
<PagePurpose
title="Audit Report"
purpose="Erstellen und verwalten Sie DSGVO-Audit-Sessions. Generieren Sie PDF-Berichte fuer Auditoren und Aufsichtsbehoerden mit vollstaendiger Checkliste, Sign-Off-Status und digitalen Signaturen."
audience={['DSB', 'Auditor', 'Compliance Officer']}
gdprArticles={[
'Art. 5 (Rechenschaftspflicht)',
'Art. 24 (Verantwortung des Verantwortlichen)',
'Art. 39 (Aufgaben des DSB)',
]}
architecture={{
services: ['backend (Python)', 'ReportLab PDF'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'DSMS', href: '/compliance/dsms', description: 'Uebersicht Datenschutz-Management' },
{ name: 'Einwilligungen', href: '/compliance/einwilligungen', description: 'Consent-Tracking' },
{ name: 'VVT', href: '/compliance/vvt', description: 'Verarbeitungsverzeichnis' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Tabs */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTab('sessions')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeTab === 'sessions'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Audit-Sessions
</button>
<button
onClick={() => setActiveTab('new')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeTab === 'new'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
+ Neues Audit
</button>
<button
onClick={() => setActiveTab('export')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeTab === 'export'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Export-Optionen
</button>
</div>
{/* Sessions Tab */}
{activeTab === 'sessions' && (
<div>
{/* Filter */}
<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>
<button
onClick={fetchSessions}
className="px-3 py-2 text-sm text-purple-600 hover:text-purple-700"
>
Aktualisieren
</button>
</div>
{/* Sessions List */}
{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">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-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>
<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>
{/* Progress Bar */}
<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>
{/* Statistics */}
<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>
{/* Actions */}
<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 flex items-center gap-2"
>
{generatingPdf === session.id ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Generiere PDF...
</>
) : (
<>
<svg className="w-4 h-4" 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>
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">
{/* Name */}
<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>
{/* Description */}
<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 des Audit-Umfangs"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
{/* Auditor Info */}
<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, Aufsichtsbehoerde"
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>
{/* Regulations */}
<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>
{/* Submit */}
<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 flex items-center gap-2"
>
{creating ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Erstelle...
</>
) : (
'Audit-Session erstellen'
)}
</button>
</div>
</div>
</div>
)}
{/* Export Options Tab */}
{activeTab === 'export' && (
<div className="space-y-6">
{/* PDF Language Settings */}
<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 className="space-y-4">
<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>
{/* Export Types Info */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Verfuegbare Export-Formate</h3>
<div className="space-y-4">
<div className="flex items-start gap-4 p-4 bg-slate-50 rounded-lg">
<div className="w-10 h-10 rounded-lg bg-red-100 flex items-center justify-center flex-shrink-0">
<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="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>
<h4 className="font-medium text-slate-800">PDF Audit Report</h4>
<p className="text-sm text-slate-600 mt-1">
Vollstaendiger Audit-Bericht mit Deckblatt, Executive Summary, Checkliste und digitalen Signaturen.
Ideal fuer Aufsichtsbehoerden und offizielle Dokumentation.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-4 bg-slate-50 rounded-lg">
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center flex-shrink-0">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</div>
<div>
<h4 className="font-medium text-slate-800">ZIP Export-Paket</h4>
<p className="text-sm text-slate-600 mt-1">
Komplettes Export-Paket mit Regelwerken, Controls, Nachweisen und interaktivem HTML-Index.
Fuer externe Auditoren zur detaillierten Pruefung.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-4 bg-slate-50 rounded-lg">
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
<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 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>
<div>
<h4 className="font-medium text-slate-800">Compliance Report (JSON)</h4>
<p className="text-sm text-slate-600 mt-1">
Strukturierter Bericht mit Statistiken, Trends und Empfehlungen.
Fuer Integration in andere Systeme und Dashboards.
</p>
</div>
</div>
</div>
</div>
{/* Tip */}
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-purple-600 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="font-medium text-purple-800">Tipp</h4>
<p className="text-sm text-purple-700 mt-1">
Der PDF-Report enthaelt SHA-256-Signaturen fuer alle Sign-Offs. Diese koennen zur Integritaetspruefung
verwendet werden und belegen, dass die Bewertungen nicht nachtraeglich veraendert wurden.
</p>
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,648 +0,0 @@
'use client'
/**
* Consent Admin Panel
*
* Admin interface for managing:
* - Documents (AGB, Privacy, etc.)
* - Document Versions
* - Email Templates
* - GDPR Processes (Art. 15-21)
* - Statistics
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// 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 ConsentPage() {
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(() => {
// Get token from localStorage
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 (err) {
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 (err) {
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 = [
// Onboarding
{ 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' },
// Security
{ 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' },
// Consent & Legal
{ 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' },
// Data Subject Rights (GDPR)
{ 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' },
// Account Lifecycle
{ 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',
status: 'active'
},
{
article: '16',
title: 'Recht auf Berichtigung',
description: 'Recht auf Berichtigung unrichtiger personenbezogener Daten',
actions: ['Daten bearbeiten', 'Aenderungshistorie fuehren', 'Benachrichtigung senden'],
sla: '30 Tage',
status: 'active'
},
{
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',
status: 'active'
},
{
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',
status: 'active'
},
{
article: '19',
title: 'Mitteilungspflicht',
description: 'Pflicht zur Mitteilung von Berichtigung, Loeschung oder Einschraenkung an Empfaenger',
actions: ['Empfaenger ermitteln', 'Mitteilungen versenden', 'Protokollierung'],
sla: 'Unverzueglich',
status: 'active'
},
{
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',
status: 'active'
},
{
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',
status: 'active'
},
]
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>
{/* Page Purpose */}
<PagePurpose
title="Consent Verwaltung"
purpose="Verwalten Sie rechtliche Dokumente (AGB, Datenschutz, Cookie-Richtlinien) und deren Versionen. Jede Einwilligung eines Benutzers basiert auf diesen Dokumenten und muss nachvollziehbar sein."
audience={['DSB', 'Entwickler', 'Compliance Officer']}
gdprArticles={['Art. 7 (Einwilligung)', 'Art. 13/14 (Informationspflichten)']}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'DSR-Verwaltung', href: '/compliance/dsr', description: 'Datenschutzanfragen bearbeiten' },
{ name: 'DSGVO-Audit', href: '/compliance/audit', description: 'Audit-Dokumentation erstellen' },
{ name: 'Workflow', href: '/compliance/workflow', description: 'Freigabe-Prozesse konfigurieren' },
]}
collapsible={true}
defaultCollapsed={false}
/>
{/* Token Input */}
{!authToken && (
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
<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 className="mb-6">
<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`}>
{category.key === 'onboarding' && ''}
{category.key === 'security' && ''}
{category.key === 'consent' && ''}
{category.key === 'gdpr' && ''}
{category.key === 'lifecycle' && ''}
</div>
<div>
<h4 className="font-medium text-slate-900">{template.name}</h4>
<p className="text-sm text-slate-500">{template.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorschau
</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* GDPR Processes Tab - Articles 15-21 */}
{activeTab === 'gdpr' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">DSGVO Betroffenenrechte</h2>
<p className="text-sm text-slate-500 mt-1">Artikel 15-21 Prozesse und Vorlagen</p>
</div>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ DSR Anfrage erstellen
</button>
</div>
{/* Info Banner */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<span className="text-2xl">*</span>
<div>
<h4 className="font-medium text-purple-900">Data Subject Rights (DSR)</h4>
<p className="text-sm text-purple-700 mt-1">
Hier verwalten Sie alle DSGVO-Anfragen. Jeder Artikel hat definierte Prozesse, SLAs und automatisierte Workflows.
</p>
</div>
</div>
</div>
{/* GDPR Process Cards */}
<div className="space-y-4">
{gdprProcesses.map((process) => (
<div
key={process.article}
className="border border-slate-200 rounded-xl p-5 hover:border-purple-300 transition-colors bg-white"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center font-bold text-lg">
{process.article}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-slate-900">{process.title}</h3>
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
</div>
<p className="text-sm text-slate-600 mb-3">{process.description}</p>
{/* Actions */}
<div className="flex flex-wrap gap-2 mb-3">
{process.actions.map((action, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
{action}
</span>
))}
</div>
{/* SLA */}
<div className="flex items-center gap-4 text-sm">
<span className="text-slate-500">
SLA: <span className="font-medium text-slate-700">{process.sla}</span>
</span>
<span className="text-slate-300">|</span>
<span className="text-slate-500">
Offene Anfragen: <span className="font-medium text-slate-700">0</span>
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<button className="px-3 py-1.5 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg">
Anfragen
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorlage
</button>
</div>
</div>
</div>
))}
</div>
{/* DSR Request Statistics */}
<div className="mt-8 pt-6 border-t border-slate-200">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-4">DSR Uebersicht</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-slate-900">0</div>
<div className="text-xs text-slate-500 mt-1">Offen</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-700">0</div>
<div className="text-xs text-slate-500 mt-1">Erledigt</div>
</div>
<div className="bg-yellow-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-yellow-700">0</div>
<div className="text-xs text-slate-500 mt-1">In Bearbeitung</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-red-700">0</div>
<div className="text-xs text-slate-500 mt-1">Ueberfaellig</div>
</div>
</div>
</div>
</div>
)}
{/* Stats Tab */}
{activeTab === 'stats' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Statistiken</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Aktive Zustimmungen</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Dokumente</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Offene DSR-Anfragen</div>
</div>
</div>
<div className="border border-slate-200 rounded-lg p-6">
<h3 className="font-semibold text-slate-900 mb-4">Zustimmungsrate nach Dokument</h3>
<div className="text-center py-8 text-slate-500">
Noch keine Daten verfuegbar
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,484 +0,0 @@
'use client'
/**
* Control Catalogue Page
*
* Features:
* - List all 44+ controls with filters
* - Domain-based organization (9 domains)
* - Status update / Review workflow
* - Evidence linking
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
interface Control {
id: string
control_id: string
domain: string
control_type: string
title: string
description: string
pass_criteria: string
implementation_guidance: string
code_reference: string
is_automated: boolean
automation_tool: string
owner: string
status: string
status_notes: string
last_reviewed_at: string | null
next_review_at: string | null
evidence_count: 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',
}
const DOMAIN_COLORS: Record<string, string> = {
gov: 'bg-slate-100 text-slate-700',
priv: 'bg-blue-100 text-blue-700',
iam: 'bg-purple-100 text-purple-700',
crypto: 'bg-yellow-100 text-yellow-700',
sdlc: 'bg-green-100 text-green-700',
ops: 'bg-orange-100 text-orange-700',
ai: 'bg-pink-100 text-pink-700',
cra: 'bg-cyan-100 text-cyan-700',
aud: 'bg-indigo-100 text-indigo-700',
}
const STATUS_STYLES: Record<string, { bg: string; text: string; icon: string; label: string }> = {
pass: { bg: 'bg-green-100', text: 'text-green-700', icon: 'M5 13l4 4L19 7', label: 'Bestanden' },
partial: { bg: 'bg-yellow-100', text: 'text-yellow-700', icon: 'M12 8v4m0 4h.01', label: 'Teilweise' },
fail: { bg: 'bg-red-100', text: 'text-red-700', icon: 'M6 18L18 6M6 6l12 12', label: 'Nicht bestanden' },
planned: { bg: 'bg-slate-100', text: 'text-slate-700', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z', label: 'Geplant' },
'n/a': { bg: 'bg-slate-100', text: 'text-slate-500', icon: 'M20 12H4', label: 'Nicht anwendbar' },
}
export default function ControlsPage() {
const [controls, setControls] = useState<Control[]>([])
const [loading, setLoading] = useState(true)
const [selectedControl, setSelectedControl] = useState<Control | null>(null)
const [filterDomain, setFilterDomain] = useState<string>('')
const [filterStatus, setFilterStatus] = useState<string>('')
const [searchTerm, setSearchTerm] = useState('')
const [reviewModalOpen, setReviewModalOpen] = useState(false)
const [reviewStatus, setReviewStatus] = useState('pass')
const [reviewNotes, setReviewNotes] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
loadControls()
}, [filterDomain, filterStatus])
const loadControls = async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterDomain) params.append('domain', filterDomain)
if (filterStatus) params.append('status', filterStatus)
if (searchTerm) params.append('search', searchTerm)
const res = await fetch(`/api/admin/compliance/controls?${params}`)
if (res.ok) {
const data = await res.json()
setControls(data.controls || [])
}
} catch (error) {
console.error('Failed to load controls:', error)
} finally {
setLoading(false)
}
}
const handleSearch = () => {
loadControls()
}
const openReviewModal = (control: Control) => {
setSelectedControl(control)
setReviewStatus(control.status || 'planned')
setReviewNotes(control.status_notes || '')
setReviewModalOpen(true)
}
const submitReview = async () => {
if (!selectedControl) return
setSaving(true)
try {
const res = await fetch(`/api/admin/compliance/controls/${selectedControl.control_id}/review`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: reviewStatus,
status_notes: reviewNotes,
}),
})
if (res.ok) {
setReviewModalOpen(false)
loadControls()
} else {
alert('Fehler beim Speichern')
}
} catch (error) {
console.error('Review failed:', error)
alert('Fehler beim Speichern')
} finally {
setSaving(false)
}
}
const filteredControls = controls.filter((c) => {
if (searchTerm) {
const term = searchTerm.toLowerCase()
return (
c.control_id.toLowerCase().includes(term) ||
c.title.toLowerCase().includes(term) ||
(c.description && c.description.toLowerCase().includes(term))
)
}
return true
})
const getDaysUntilReview = (nextReview: string | null) => {
if (!nextReview) return null
const days = Math.ceil((new Date(nextReview).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
return days
}
// Statistics
const stats = {
total: controls.length,
pass: controls.filter(c => c.status === 'pass').length,
partial: controls.filter(c => c.status === 'partial').length,
fail: controls.filter(c => c.status === 'fail').length,
planned: controls.filter(c => c.status === 'planned').length,
automated: controls.filter(c => c.is_automated).length,
overdue: controls.filter(c => {
if (!c.next_review_at) return false
return new Date(c.next_review_at) < new Date()
}).length,
}
return (
<div className="min-h-screen bg-slate-50 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Control Catalogue</h1>
<p className="text-slate-600">Technische & organisatorische Massnahmen</p>
</div>
<Link
href="/compliance/hub"
className="flex items-center gap-2 text-slate-600 hover:text-slate-800"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Compliance Hub
</Link>
</div>
{/* Page Purpose */}
<PagePurpose
title="Control Catalogue"
purpose="Der Control-Katalog dokumentiert alle technischen und organisatorischen Massnahmen (TOMs) zur Einhaltung von ISO 27001, DSGVO, AI Act und BSI TR-03161. Jede Massnahme wird regelmaessig reviewed und mit Nachweisen verknuepft."
audience={['CISO', 'DSB', 'Compliance Officer', 'Entwickler']}
gdprArticles={['Art. 32 (Sicherheit)', 'Art. 25 (Privacy by Design)']}
architecture={{
services: ['Python Backend (FastAPI)', 'compliance_controls Modul'],
databases: ['PostgreSQL (compliance_controls Table)'],
}}
relatedPages={[
{ name: 'Evidence', href: '/compliance/evidence', description: 'Nachweise zu Controls verwalten' },
{ name: 'Audit Checklist', href: '/compliance/audit-checklist', description: 'Anforderungen pruefen' },
{ name: 'Risks', href: '/compliance/risks', description: 'Risiko-Matrix verwalten' },
]}
/>
{/* Statistics Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 border border-slate-200">
<p className="text-sm text-slate-500">Gesamt</p>
<p className="text-2xl font-bold text-slate-900">{stats.total}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-green-200">
<p className="text-sm text-green-600">Bestanden</p>
<p className="text-2xl font-bold text-green-700">{stats.pass}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-yellow-200">
<p className="text-sm text-yellow-600">Teilweise</p>
<p className="text-2xl font-bold text-yellow-700">{stats.partial}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-red-200">
<p className="text-sm text-red-600">Nicht bestanden</p>
<p className="text-2xl font-bold text-red-700">{stats.fail}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200">
<p className="text-sm text-slate-500">Geplant</p>
<p className="text-2xl font-bold text-slate-700">{stats.planned}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-blue-200">
<p className="text-sm text-blue-600">Automatisiert</p>
<p className="text-2xl font-bold text-blue-700">{stats.automated}</p>
</div>
<div className={`bg-white rounded-xl p-4 border ${stats.overdue > 0 ? 'border-red-300 bg-red-50' : 'border-slate-200'}`}>
<p className="text-sm text-slate-500">Review faellig</p>
<p className={`text-2xl font-bold ${stats.overdue > 0 ? 'text-red-600' : 'text-slate-700'}`}>
{stats.overdue}
</p>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-xl shadow-sm border p-4 mb-6">
<div className="flex flex-wrap items-center gap-4">
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="Control suchen (ID, Titel, Beschreibung)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<select
value={filterDomain}
onChange={(e) => setFilterDomain(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Domains</option>
{Object.entries(DOMAIN_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Status</option>
{Object.entries(STATUS_STYLES).map(([key, style]) => (
<option key={key} value={key}>{style.label}</option>
))}
</select>
<button
onClick={handleSearch}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Filtern
</button>
</div>
</div>
{/* Controls Table */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : filteredControls.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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 2" />
</svg>
<p className="text-slate-500">Keine Controls gefunden</p>
<p className="text-sm text-slate-400 mt-1">
Versuchen Sie andere Filter oder laden Sie die Compliance-Daten im Hub
</p>
</div>
) : (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="px-4 py-3 border-b bg-slate-50 flex justify-between items-center">
<span className="text-sm text-slate-500">{filteredControls.length} Controls</span>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Domain</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Automatisiert</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Nachweise</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Review</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{filteredControls.map((control) => {
const statusStyle = STATUS_STYLES[control.status] || STATUS_STYLES.planned
const daysUntilReview = getDaysUntilReview(control.next_review_at)
return (
<tr key={control.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-primary-600">{control.control_id}</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${DOMAIN_COLORS[control.domain] || 'bg-slate-100 text-slate-700'}`}>
{DOMAIN_LABELS[control.domain] || control.domain}
</span>
</td>
<td className="px-4 py-3">
<div>
<p className="font-medium text-slate-900">{control.title}</p>
{control.description && (
<p className="text-sm text-slate-500 truncate max-w-md">{control.description}</p>
)}
</div>
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full ${statusStyle.bg} ${statusStyle.text}`}>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={statusStyle.icon} />
</svg>
{statusStyle.label}
</span>
</td>
<td className="px-4 py-3 text-center">
{control.is_automated ? (
<span className="inline-flex items-center gap-1 text-green-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-xs">{control.automation_tool}</span>
</span>
) : (
<span className="text-slate-400 text-xs">Manuell</span>
)}
</td>
<td className="px-4 py-3 text-center">
<Link
href={`/compliance/evidence?control=${control.control_id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{control.evidence_count || 0}
</Link>
</td>
<td className="px-4 py-3 text-center">
{daysUntilReview !== null ? (
<span className={`text-sm ${
daysUntilReview < 0
? 'text-red-600 font-medium'
: daysUntilReview < 14
? 'text-yellow-600'
: 'text-slate-500'
}`}>
{daysUntilReview < 0
? `${Math.abs(daysUntilReview)}d ueberfaellig`
: `${daysUntilReview}d`}
</span>
) : (
<span className="text-slate-400 text-sm">-</span>
)}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => openReviewModal(control)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Review
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* Review Modal */}
{reviewModalOpen && selectedControl && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
<div className="p-6 border-b">
<h3 className="text-lg font-semibold text-slate-900">
Control Review
</h3>
<p className="text-sm text-slate-500 font-mono">{selectedControl.control_id}</p>
</div>
<div className="p-6 space-y-4">
<div>
<p className="font-medium text-slate-700 mb-1">{selectedControl.title}</p>
{selectedControl.pass_criteria && (
<div className="p-3 bg-slate-50 rounded-lg text-sm">
<p className="font-medium text-slate-600 mb-1">Pass-Kriterium:</p>
<p className="text-slate-600">{selectedControl.pass_criteria}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Status</label>
<div className="grid grid-cols-5 gap-2">
{Object.entries(STATUS_STYLES).map(([key, style]) => (
<button
key={key}
onClick={() => setReviewStatus(key)}
className={`p-2 rounded-lg border-2 text-xs font-medium transition-colors ${
reviewStatus === key
? `${style.bg} ${style.text} border-current`
: 'border-slate-200 hover:border-slate-300'
}`}
>
{style.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Notizen</label>
<textarea
value={reviewNotes}
onChange={(e) => setReviewNotes(e.target.value)}
placeholder="Begruendung, Nachweise, naechste Schritte..."
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<div className="p-6 border-t bg-slate-50 flex justify-end gap-3">
<button
onClick={() => setReviewModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={saving}
>
Abbrechen
</button>
<button
onClick={submitReview}
disabled={saving}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,691 +0,0 @@
'use client'
/**
* DSFA - Datenschutz-Folgenabschaetzung
*
* Art. 35 DSGVO - Datenschutz-Folgenabschaetzung
*/
import { useState } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface DSFAProject {
id: string
name: string
description: string
status: 'draft' | 'in_progress' | 'completed' | 'review_needed'
riskLevel: 'low' | 'medium' | 'high' | 'critical'
createdAt: string
lastUpdated: string
dpoApproval?: boolean
phases: {
description: boolean
necessity: boolean
risks: boolean
measures: boolean
consultation: boolean
}
}
interface RiskAssessment {
id: string
category: string
risk: string
likelihood: 'rare' | 'unlikely' | 'possible' | 'likely' | 'certain'
impact: 'negligible' | 'minor' | 'moderate' | 'major' | 'severe'
riskScore: number
mitigations: string[]
residualRisk: 'acceptable' | 'tolerable' | 'unacceptable'
}
export default function DSFAPage() {
const [activeTab, setActiveTab] = useState<'overview' | 'projects' | 'methodology' | 'templates'>('overview')
const [expandedProject, setExpandedProject] = useState<string | null>('ai_processing')
const dsfaProjects: DSFAProject[] = [
{
id: 'ai_processing',
name: 'KI-gestuetzte Korrektur und Bewertung',
description: 'Automatische Korrektur von Schuelerarbeiten mittels KI (Ollama/OpenAI)',
status: 'in_progress',
riskLevel: 'high',
createdAt: '2024-10-01',
lastUpdated: '2024-12-01',
dpoApproval: false,
phases: {
description: true,
necessity: true,
risks: true,
measures: false,
consultation: false
}
},
{
id: 'learning_analytics',
name: 'Lernfortschrittsanalyse',
description: 'Systematische Analyse des Lernverhaltens zur Personalisierung',
status: 'completed',
riskLevel: 'medium',
createdAt: '2024-06-15',
lastUpdated: '2024-11-15',
dpoApproval: true,
phases: {
description: true,
necessity: true,
risks: true,
measures: true,
consultation: true
}
},
{
id: 'biometric_voice',
name: 'Voice-Service Spracherkennung',
description: 'Sprachbasierte Interaktion mit potentieller Stimmerkennung',
status: 'draft',
riskLevel: 'high',
createdAt: '2024-11-01',
lastUpdated: '2024-11-01',
dpoApproval: false,
phases: {
description: true,
necessity: false,
risks: false,
measures: false,
consultation: false
}
},
]
const riskAssessments: RiskAssessment[] = [
{
id: 'r1',
category: 'Vertraulichkeit',
risk: 'Unbefugter Zugriff auf Schuelerdaten durch Drittanbieter-KI',
likelihood: 'unlikely',
impact: 'major',
riskScore: 12,
mitigations: [
'Lokale Verarbeitung mit Ollama priorisieren',
'Anonymisierung vor Cloud-Verarbeitung',
'Standardvertragsklauseln mit OpenAI'
],
residualRisk: 'tolerable'
},
{
id: 'r2',
category: 'Integritaet',
risk: 'Fehlerhafte KI-Bewertungen fuehren zu falschen Noten',
likelihood: 'possible',
impact: 'moderate',
riskScore: 9,
mitigations: [
'Menschliche Ueberpruefung aller KI-Bewertungen',
'Transparente Darstellung als "Vorschlag"',
'Feedback-Mechanismus fuer Korrekturen'
],
residualRisk: 'acceptable'
},
{
id: 'r3',
category: 'Verfuegbarkeit',
risk: 'Systemausfall verhindert Zugriff auf Lernmaterialien',
likelihood: 'rare',
impact: 'minor',
riskScore: 2,
mitigations: [
'Offline-Faehigkeit der App',
'Redundante Datenhaltung',
'Automatische Backups'
],
residualRisk: 'acceptable'
},
{
id: 'r4',
category: 'Rechte der Betroffenen',
risk: 'Automatisierte Entscheidungen ohne menschliche Intervention',
likelihood: 'possible',
impact: 'major',
riskScore: 12,
mitigations: [
'KI nur als Unterstuetzung, finale Entscheidung beim Lehrer',
'Recht auf menschliche Ueberpruefung dokumentiert',
'Transparente Information ueber KI-Einsatz'
],
residualRisk: 'tolerable'
},
]
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Abgeschlossen</span>
case 'in_progress':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">In Bearbeitung</span>
case 'draft':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">Entwurf</span>
case 'review_needed':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Pruefung erforderlich</span>
default:
return null
}
}
const getRiskBadge = (level: string) => {
switch (level) {
case 'critical':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Kritisch</span>
case 'high':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">Hoch</span>
case 'medium':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Mittel</span>
case 'low':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Niedrig</span>
default:
return null
}
}
const getResidualRiskBadge = (risk: string) => {
switch (risk) {
case 'acceptable':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Akzeptabel</span>
case 'tolerable':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Tolerierbar</span>
case 'unacceptable':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Nicht akzeptabel</span>
default:
return null
}
}
const calculatePhaseProgress = (phases: DSFAProject['phases']) => {
const total = Object.keys(phases).length
const completed = Object.values(phases).filter(Boolean).length
return Math.round((completed / total) * 100)
}
return (
<div>
<PagePurpose
title="Datenschutz-Folgenabschaetzung (DSFA)"
purpose="Systematische Risikoanalyse fuer Verarbeitungen mit hohem Risiko gemaess Art. 35 DSGVO. Dokumentiert Risiken, Massnahmen und DSB-Freigaben."
audience={['DSB', 'Projektleiter', 'Entwickler', 'Geschaeftsfuehrung']}
gdprArticles={['Art. 35 (Datenschutz-Folgenabschaetzung)', 'Art. 36 (Vorherige Konsultation)']}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'VVT', href: '/compliance/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'TOMs', href: '/compliance/tom', description: 'Technische Massnahmen' },
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management-System' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Tabs */}
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
<div className="flex gap-2">
{[
{ id: 'overview', label: 'Uebersicht' },
{ id: 'projects', label: 'DSFA-Projekte' },
{ id: 'methodology', label: 'Methodik' },
{ id: 'templates', label: 'Vorlagen' },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Statistics */}
<div className="grid grid-cols-1 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-slate-900">{dsfaProjects.length}</div>
<div className="text-sm text-slate-500">DSFA-Projekte</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">
{dsfaProjects.filter(p => p.status === 'completed').length}
</div>
<div className="text-sm text-slate-500">Abgeschlossen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-blue-600">
{dsfaProjects.filter(p => p.status === 'in_progress').length}
</div>
<div className="text-sm text-slate-500">In Bearbeitung</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-orange-600">
{dsfaProjects.filter(p => p.riskLevel === 'high' || p.riskLevel === 'critical').length}
</div>
<div className="text-sm text-slate-500">Hohes Risiko</div>
</div>
</div>
{/* When is DSFA required */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Wann ist eine DSFA erforderlich?</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<h3 className="font-medium text-slate-700">Art. 35 Abs. 3 - Pflichtfaelle:</h3>
<ul className="space-y-2 text-sm text-slate-600">
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
Systematische Bewertung persoenlicher Aspekte (Profiling)
</li>
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
Umfangreiche Verarbeitung besonderer Kategorien (Art. 9)
</li>
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
Systematische Ueberwachung oeffentlicher Bereiche
</li>
</ul>
</div>
<div className="space-y-3">
<h3 className="font-medium text-slate-700">Zusaetzliche Kriterien (DSK-Liste):</h3>
<ul className="space-y-2 text-sm text-slate-600">
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Verarbeitung von Daten Minderjaehriger
</li>
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Einsatz neuer Technologien (z.B. KI)
</li>
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Zusammenfuehrung von Datensaetzen
</li>
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Automatisierte Entscheidungsfindung
</li>
</ul>
</div>
</div>
</div>
{/* Risk Matrix */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Risiko-Matrix (KI-Verarbeitung)</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 font-medium text-slate-500">Kategorie</th>
<th className="text-left py-3 px-4 font-medium text-slate-500">Risiko</th>
<th className="text-left py-3 px-4 font-medium text-slate-500">Score</th>
<th className="text-left py-3 px-4 font-medium text-slate-500">Massnahmen</th>
<th className="text-left py-3 px-4 font-medium text-slate-500">Restrisiko</th>
</tr>
</thead>
<tbody>
{riskAssessments.map(risk => (
<tr key={risk.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4 font-medium text-slate-900">{risk.category}</td>
<td className="py-3 px-4 text-slate-600">{risk.risk}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${
risk.riskScore >= 12 ? 'bg-red-100 text-red-800' :
risk.riskScore >= 6 ? 'bg-yellow-100 text-yellow-800' :
'bg-green-100 text-green-800'
}`}>
{risk.riskScore}
</span>
</td>
<td className="py-3 px-4">
<ul className="text-xs text-slate-600 space-y-1">
{risk.mitigations.slice(0, 2).map((m, i) => (
<li key={i}> {m}</li>
))}
{risk.mitigations.length > 2 && (
<li className="text-slate-400">+{risk.mitigations.length - 2} weitere</li>
)}
</ul>
</td>
<td className="py-3 px-4">{getResidualRiskBadge(risk.residualRisk)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Projects Tab */}
{activeTab === 'projects' && (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">DSFA-Projekte</h2>
<p className="text-sm text-slate-500">{dsfaProjects.length} dokumentierte Folgenabschaetzungen</p>
</div>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700">
+ Neue DSFA
</button>
</div>
{dsfaProjects.map(project => (
<div key={project.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setExpandedProject(expandedProject === project.id ? null : project.id)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="text-left">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-slate-900">{project.name}</h3>
{getStatusBadge(project.status)}
{getRiskBadge(project.riskLevel)}
{project.dpoApproval && (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
DSB-Freigabe
</span>
)}
</div>
<p className="text-sm text-slate-500 mt-1">{project.description}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right text-sm text-slate-500">
<div>{calculatePhaseProgress(project.phases)}% abgeschlossen</div>
</div>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedProject === project.id ? '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>
</div>
</button>
{expandedProject === project.id && (
<div className="px-6 pb-6 border-t border-slate-100">
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Phases */}
<div>
<h4 className="text-sm font-medium text-slate-500 mb-3">DSFA-Phasen</h4>
<div className="space-y-2">
{[
{ key: 'description', label: 'Beschreibung der Verarbeitung' },
{ key: 'necessity', label: 'Notwendigkeit & Verhaeltnismaessigkeit' },
{ key: 'risks', label: 'Risikobewertung' },
{ key: 'measures', label: 'Abhilfemassnahmen' },
{ key: 'consultation', label: 'DSB-Konsultation' },
].map(phase => (
<div key={phase.key} className="flex items-center gap-3">
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${
project.phases[phase.key as keyof typeof project.phases]
? 'bg-green-100 text-green-600'
: 'bg-slate-100 text-slate-400'
}`}>
{project.phases[phase.key as keyof typeof project.phases] ? (
<svg className="w-3 h-3" 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>
) : (
<span className="w-2 h-2 rounded-full bg-slate-300" />
)}
</div>
<span className={project.phases[phase.key as keyof typeof project.phases] ? 'text-slate-900' : 'text-slate-500'}>
{phase.label}
</span>
</div>
))}
</div>
</div>
{/* Meta Info */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Erstellt</h4>
<p className="text-slate-700">{project.createdAt}</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Letzte Aktualisierung</h4>
<p className="text-slate-700">{project.lastUpdated}</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">DSB-Freigabe</h4>
<p className={project.dpoApproval ? 'text-green-600 font-medium' : 'text-yellow-600'}>
{project.dpoApproval ? 'Erteilt' : 'Ausstehend'}
</p>
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-100 flex gap-2">
<button className="px-3 py-1.5 text-sm text-purple-600 hover:text-purple-700 font-medium">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-700">
PDF exportieren
</button>
{!project.dpoApproval && (
<button className="px-3 py-1.5 text-sm text-green-600 hover:text-green-700">
Zur DSB-Freigabe einreichen
</button>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Methodology Tab */}
{activeTab === 'methodology' && (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">DSFA-Prozess nach Art. 35 DSGVO</h2>
<div className="space-y-6">
{[
{
step: 1,
title: 'Schwellwertanalyse',
description: 'Pruefung ob eine DSFA erforderlich ist anhand der Kriterien aus Art. 35 Abs. 3 und der DSK-Positivliste.',
details: [
'Verarbeitung besonderer Kategorien (Art. 9)?',
'Systematisches Profiling?',
'Neue Technologien im Einsatz?',
'Daten von Minderjaehrigen?'
]
},
{
step: 2,
title: 'Beschreibung der Verarbeitung',
description: 'Systematische Beschreibung der geplanten Verarbeitungsvorgaenge und Zwecke.',
details: [
'Art, Umfang, Umstaende der Verarbeitung',
'Zweck der Verarbeitung',
'Betroffene Personengruppen',
'Verantwortlichkeiten'
]
},
{
step: 3,
title: 'Notwendigkeit & Verhaeltnismaessigkeit',
description: 'Bewertung ob die Verarbeitung notwendig und verhaeltnismaessig ist.',
details: [
'Rechtsgrundlage vorhanden?',
'Zweckbindung eingehalten?',
'Datenminimierung beachtet?',
'Speicherbegrenzung definiert?'
]
},
{
step: 4,
title: 'Risikobewertung',
description: 'Systematische Bewertung der Risiken fuer Rechte und Freiheiten der Betroffenen.',
details: [
'Risiken identifizieren',
'Eintrittswahrscheinlichkeit bewerten',
'Schwere der Auswirkungen bewerten',
'Risiko-Score berechnen'
]
},
{
step: 5,
title: 'Abhilfemassnahmen',
description: 'Definition von Massnahmen zur Eindaemmung der identifizierten Risiken.',
details: [
'Technische Massnahmen (TOMs)',
'Organisatorische Massnahmen',
'Restrisiko-Bewertung',
'Implementierungsplan'
]
},
{
step: 6,
title: 'DSB-Konsultation',
description: 'Einholung der Stellungnahme des Datenschutzbeauftragten.',
details: [
'DSFA dem DSB vorlegen',
'Stellungnahme dokumentieren',
'Ggf. Anpassungen vornehmen',
'Freigabe erteilen'
]
},
{
step: 7,
title: 'Vorherige Konsultation (Art. 36)',
description: 'Bei verbleibendem hohen Risiko: Konsultation der Aufsichtsbehoerde.',
details: [
'Nur bei hohem Restrisiko erforderlich',
'Aufsichtsbehoerde hat 8 Wochen zur Pruefung',
'Dokumentation der Konsultation',
'Umsetzung der Auflagen'
]
}
].map(item => (
<div key={item.step} className="flex gap-4">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-purple-100 text-purple-700 flex items-center justify-center font-bold">
{item.step}
</div>
<div className="flex-grow">
<h3 className="font-semibold text-slate-900">{item.title}</h3>
<p className="text-sm text-slate-600 mt-1">{item.description}</p>
<ul className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-slate-500">
{item.details.map((detail, idx) => (
<li key={idx} className="flex items-center gap-1">
<span className="text-purple-400"></span> {detail}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Templates Tab */}
{activeTab === 'templates' && (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">DSFA-Vorlagen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
name: 'Standard DSFA-Vorlage',
description: 'Vollstaendige Vorlage nach Art. 35 DSGVO',
format: 'DOCX',
size: '45 KB'
},
{
name: 'KI-Verarbeitung Template',
description: 'Spezialvorlage fuer KI/ML-Anwendungen',
format: 'DOCX',
size: '52 KB'
},
{
name: 'Risikobewertungs-Matrix',
description: 'Excel-Vorlage fuer systematische Risikobewertung',
format: 'XLSX',
size: '28 KB'
},
{
name: 'Schwellwert-Checkliste',
description: 'Checkliste zur Pruefung ob DSFA erforderlich',
format: 'PDF',
size: '120 KB'
},
{
name: 'DSB-Konsultationsformular',
description: 'Formular zur internen DSB-Freigabe',
format: 'DOCX',
size: '32 KB'
},
{
name: 'Aufsichtsbehoerden-Vorlage',
description: 'Vorlage fuer Art. 36 Konsultation',
format: 'DOCX',
size: '38 KB'
}
].map(template => (
<div key={template.name} className="p-4 border border-slate-200 rounded-lg hover:border-purple-300 transition-colors">
<div className="flex items-start justify-between">
<div>
<h3 className="font-medium text-slate-900">{template.name}</h3>
<p className="text-sm text-slate-500 mt-1">{template.description}</p>
</div>
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs font-medium">
{template.format}
</span>
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-xs text-slate-400">{template.size}</span>
<button className="text-sm text-purple-600 hover:text-purple-700 font-medium">
Herunterladen
</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Info */}
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" 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>
<h4 className="font-semibold text-yellow-900">Wichtiger Hinweis</h4>
<p className="text-sm text-yellow-800 mt-1">
Eine DSFA ist <strong>vor</strong> Beginn der Verarbeitung durchzufuehren. Bei wesentlichen Aenderungen
an bestehenden Verarbeitungen muss die DSFA aktualisiert werden. Die Dokumentation muss
der Aufsichtsbehoerde auf Anfrage vorgelegt werden koennen.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,377 +0,0 @@
'use client'
/**
* DSMS (Data Protection Management System) Admin Page
*
* Central hub for data protection compliance management
*/
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
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: '/compliance/consent',
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: '/compliance/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: '/compliance/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: '/compliance/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: '/compliance/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: '/compliance/dsfa',
items: [
{ name: 'KI-Verarbeitung', status: 'in_progress' },
{ name: 'Profiling-Risiken', status: 'complete' },
{ name: 'Automatisierte Entscheidungen', status: 'in_progress' },
],
},
]
// Get status badge
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
}
}
// Calculate overall compliance score
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>
{/* Page Purpose */}
<PagePurpose
title="Datenschutz-Management-System (DSMS)"
purpose="Zentrale Uebersicht aller Datenschutz-Massnahmen und deren Status. Hier verfolgen Sie den Compliance-Fortschritt und identifizieren offene Aufgaben."
audience={['DSB', 'Compliance Officer', 'Geschaeftsfuehrung']}
gdprArticles={[
'Art. 5 (Grundsaetze)',
'Art. 24 (Verantwortung)',
'Art. 30 (Verarbeitungsverzeichnis)',
'Art. 32 (Sicherheit)',
'Art. 35 (DSFA)',
]}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'Consent', href: '/compliance/consent', description: 'Dokumente und Versionen' },
{ name: 'DSR', href: '/compliance/dsr', description: 'Betroffenenanfragen' },
{ name: 'TOMs', href: '/compliance/tom', description: 'Technische Massnahmen' },
{ name: 'VVT', href: '/compliance/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'DSFA', href: '/compliance/dsfa', description: 'Datenschutz-Folgenabschaetzung' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Compliance Score */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-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 mb-6">
<Link
href="/compliance/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="/compliance/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="/compliance/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="/compliance/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 */}
<div className="mb-6">
<Link
href="/compliance/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>
</div>
{/* Compliance Modules */}
<h2 className="text-lg font-semibold text-slate-900 mb-4">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="mt-6 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>
)
}

View File

@@ -1,429 +0,0 @@
'use client'
/**
* DSR (Data Subject Requests) Admin Page
*
* GDPR Article 15-21 Request Management
*/
import { useEffect, useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface DSRRequest {
id: string
request_number: string
requester_email: string
requester_name: string
request_type: string
status: string
priority: string
created_at: string
deadline: string
assigned_to?: string
notes?: string
}
interface DSRStats {
total: number
pending: number
in_progress: number
completed: number
overdue: number
}
export default function DSRPage() {
const [adminToken, setAdminToken] = useState('')
const [requests, setRequests] = useState<DSRRequest[]>([])
const [stats, setStats] = useState<DSRStats | null>(null)
const [loading, setLoading] = useState(false)
const [selectedRequest, setSelectedRequest] = useState<DSRRequest | null>(null)
const [filter, setFilter] = useState<string>('all')
const [error, setError] = useState<string | null>(null)
const API_BASE = 'http://localhost:8081/api/v1'
// Load saved token
useEffect(() => {
const savedToken = localStorage.getItem('adminToken')
if (savedToken) {
setAdminToken(savedToken)
}
}, [])
// Save token
const saveToken = (token: string) => {
setAdminToken(token)
localStorage.setItem('adminToken', token)
}
// Fetch DSR requests
const fetchRequests = useCallback(async () => {
if (!adminToken) return
setLoading(true)
setError(null)
try {
const response = await fetch(`${API_BASE}/dsr/requests`, {
headers: {
'Authorization': `Bearer ${adminToken}`,
},
})
if (!response.ok) {
if (response.status === 401) {
throw new Error('Nicht autorisiert - Token ungueltig')
}
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
setRequests(data.requests || [])
// Calculate stats
const allRequests = data.requests || []
const now = new Date()
setStats({
total: allRequests.length,
pending: allRequests.filter((r: DSRRequest) => r.status === 'pending').length,
in_progress: allRequests.filter((r: DSRRequest) => r.status === 'in_progress').length,
completed: allRequests.filter((r: DSRRequest) => r.status === 'completed').length,
overdue: allRequests.filter((r: DSRRequest) => new Date(r.deadline) < now && r.status !== 'completed').length,
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [adminToken])
useEffect(() => {
if (adminToken) {
fetchRequests()
}
}, [adminToken, fetchRequests])
// Get status badge color
const getStatusColor = (status: string) => {
switch (status) {
case 'pending':
return 'bg-yellow-100 text-yellow-800'
case 'in_progress':
return 'bg-blue-100 text-blue-800'
case 'completed':
return 'bg-green-100 text-green-800'
case 'rejected':
return 'bg-red-100 text-red-800'
default:
return 'bg-slate-100 text-slate-800'
}
}
// Get priority badge color
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent':
return 'bg-red-100 text-red-800'
case 'high':
return 'bg-orange-100 text-orange-800'
case 'normal':
return 'bg-slate-100 text-slate-800'
case 'low':
return 'bg-slate-50 text-slate-600'
default:
return 'bg-slate-100 text-slate-800'
}
}
// Get request type label
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
'access': 'Auskunft (Art. 15)',
'rectification': 'Berichtigung (Art. 16)',
'erasure': 'Loeschung (Art. 17)',
'restriction': 'Einschraenkung (Art. 18)',
'portability': 'Datenuebertragbarkeit (Art. 20)',
'objection': 'Widerspruch (Art. 21)',
}
return labels[type] || type
}
// Filter requests
const filteredRequests = requests.filter(r => {
if (filter === 'all') return true
if (filter === 'overdue') {
return new Date(r.deadline) < new Date() && r.status !== 'completed'
}
return r.status === filter
})
// Format date
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
// Check if overdue
const isOverdue = (deadline: string, status: string) => {
return new Date(deadline) < new Date() && status !== 'completed'
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Datenschutzanfragen (DSR)"
purpose="Verwalten Sie alle Betroffenenanfragen nach DSGVO Art. 15-21. Hier bearbeiten Sie Auskunfts-, Loesch- und Berichtigungsanfragen mit automatischer Fristueberwachung."
audience={['DSB', 'Compliance Officer', 'Support']}
gdprArticles={[
'Art. 15 (Auskunftsrecht)',
'Art. 16 (Berichtigung)',
'Art. 17 (Loeschung)',
'Art. 18 (Einschraenkung)',
'Art. 20 (Datenuebertragbarkeit)',
'Art. 21 (Widerspruch)',
]}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'Consent Verwaltung', href: '/compliance/consent', description: 'Dokumente und Zustimmungen' },
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management-System' },
{ name: 'Audit', href: '/compliance/audit', description: 'Audit-Dokumentation' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Token Input */}
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">
Admin Token
</label>
<div className="flex gap-2">
<input
type="password"
value={adminToken}
onChange={(e) => saveToken(e.target.value)}
placeholder="JWT Token eingeben..."
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
<button
onClick={fetchRequests}
disabled={!adminToken || loading}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50"
>
{loading ? 'Laden...' : 'Laden'}
</button>
</div>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{stats.total}</div>
<div className="text-sm text-slate-500">Gesamt</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-yellow-600">{stats.pending}</div>
<div className="text-sm text-slate-500">Offen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-blue-600">{stats.in_progress}</div>
<div className="text-sm text-slate-500">In Bearbeitung</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
<div className="text-sm text-slate-500">Abgeschlossen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className={`text-2xl font-bold ${stats.overdue > 0 ? 'text-red-600' : 'text-slate-400'}`}>
{stats.overdue}
</div>
<div className="text-sm text-slate-500">Ueberfaellig</div>
</div>
</div>
)}
{/* Filter Tabs */}
<div className="flex gap-2 mb-4 overflow-x-auto">
{[
{ value: 'all', label: 'Alle' },
{ value: 'pending', label: 'Offen' },
{ value: 'in_progress', label: 'In Bearbeitung' },
{ value: 'completed', label: 'Abgeschlossen' },
{ value: 'overdue', label: 'Ueberfaellig' },
].map((tab) => (
<button
key={tab.value}
onClick={() => setFilter(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
filter === tab.value
? 'bg-purple-600 text-white'
: 'bg-white text-slate-700 border border-slate-200 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Requests Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Nr.</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-left text-xs font-medium text-slate-500 uppercase">Anfragesteller</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Prioritaet</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Frist</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredRequests.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
Keine Anfragen gefunden
</td>
</tr>
) : (
filteredRequests.map((request) => (
<tr key={request.id} className={isOverdue(request.deadline, request.status) ? 'bg-red-50' : ''}>
<td className="px-4 py-3 text-sm font-mono text-slate-900">{request.request_number}</td>
<td className="px-4 py-3 text-sm text-slate-700">{getTypeLabel(request.request_type)}</td>
<td className="px-4 py-3">
<div className="text-sm text-slate-900">{request.requester_name}</div>
<div className="text-xs text-slate-500">{request.requester_email}</div>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(request.status)}`}>
{request.status}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityColor(request.priority)}`}>
{request.priority}
</span>
</td>
<td className="px-4 py-3">
<span className={`text-sm ${isOverdue(request.deadline, request.status) ? 'text-red-600 font-medium' : 'text-slate-700'}`}>
{formatDate(request.deadline)}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={() => setSelectedRequest(request)}
className="text-purple-600 hover:text-purple-700 text-sm font-medium"
>
Details
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Detail Modal */}
{selectedRequest && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">
Anfrage {selectedRequest.request_number}
</h3>
<button
onClick={() => setSelectedRequest(null)}
className="text-slate-400 hover:text-slate-600"
>
<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>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-slate-500">Typ</div>
<div className="font-medium text-slate-900">{getTypeLabel(selectedRequest.request_type)}</div>
</div>
<div>
<div className="text-sm text-slate-500">Status</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(selectedRequest.status)}`}>
{selectedRequest.status}
</span>
</div>
<div>
<div className="text-sm text-slate-500">Anfragesteller</div>
<div className="font-medium text-slate-900">{selectedRequest.requester_name}</div>
<div className="text-sm text-slate-500">{selectedRequest.requester_email}</div>
</div>
<div>
<div className="text-sm text-slate-500">Frist</div>
<div className={`font-medium ${isOverdue(selectedRequest.deadline, selectedRequest.status) ? 'text-red-600' : 'text-slate-900'}`}>
{formatDate(selectedRequest.deadline)}
</div>
</div>
<div>
<div className="text-sm text-slate-500">Eingegangen</div>
<div className="font-medium text-slate-900">{formatDate(selectedRequest.created_at)}</div>
</div>
<div>
<div className="text-sm text-slate-500">Zugewiesen an</div>
<div className="font-medium text-slate-900">{selectedRequest.assigned_to || '-'}</div>
</div>
</div>
{selectedRequest.notes && (
<div>
<div className="text-sm text-slate-500 mb-1">Notizen</div>
<div className="bg-slate-50 rounded-lg p-3 text-sm text-slate-700">
{selectedRequest.notes}
</div>
</div>
)}
<div className="flex gap-2 pt-4">
<button className="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700">
Bearbeiten
</button>
<button className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50">
Abschliessen
</button>
</div>
</div>
</div>
</div>
)}
{/* Info Box */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<h4 className="font-semibold text-purple-900 mb-2">DSGVO-Fristen</h4>
<ul className="text-sm text-purple-800 space-y-1">
<li>Art. 15 (Auskunft): 1 Monat, verlaengerbar auf 3 Monate</li>
<li>Art. 16 (Berichtigung): Unverzueglich</li>
<li>Art. 17 (Loeschung): Unverzueglich</li>
<li>Art. 18 (Einschraenkung): Unverzueglich</li>
<li>Art. 20 (Datenuebertragbarkeit): 1 Monat</li>
<li>Art. 21 (Widerspruch): Unverzueglich</li>
</ul>
</div>
</div>
)
}

View File

@@ -1,498 +0,0 @@
'use client'
/**
* Einwilligungsverwaltung - User Consent Management
*
* Zentrale Uebersicht aller Nutzer-Einwilligungen aus:
* - Website
* - App
* - PWA
*
* Kategorien: Marketing, Statistik, Cookies, Rechtliche Dokumente
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
const API_BASE = '/api/admin/consent'
type Tab = 'overview' | 'documents' | 'cookies' | 'marketing' | 'audit'
interface ConsentStats {
total_users: number
consented_users: number
consent_rate: number
pending_consents: number
}
interface AuditEntry {
id: string
user_id: string
action: string
entity_type: string
entity_id: string
details: Record<string, unknown>
ip_address: string
created_at: string
}
interface ConsentSummary {
category: string
total: number
accepted: number
declined: number
pending: number
rate: number
}
export default function EinwilligungenPage() {
const [activeTab, setActiveTab] = useState<Tab>('overview')
const [stats, setStats] = useState<ConsentStats | null>(null)
const [auditLog, setAuditLog] = useState<AuditEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [authToken, setAuthToken] = useState<string>('')
useEffect(() => {
const token = localStorage.getItem('bp_admin_token')
if (token) {
setAuthToken(token)
}
}, [])
useEffect(() => {
if (activeTab === 'overview') {
loadStats()
} else if (activeTab === 'audit') {
loadAuditLog()
}
}, [activeTab, authToken])
async function loadStats() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/stats`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setStats(data)
} else {
setError('Fehler beim Laden der Statistiken')
}
} catch {
setError('Verbindungsfehler')
} finally {
setLoading(false)
}
}
async function loadAuditLog() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/audit-log?limit=50`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setAuditLog(data.entries || [])
} else {
setError('Fehler beim Laden des Audit-Logs')
}
} catch {
setError('Verbindungsfehler')
} finally {
setLoading(false)
}
}
// Mock data for consent summary (in production, this comes from API)
const consentSummary: ConsentSummary[] = [
{ category: 'AGB', total: 1250, accepted: 1248, declined: 0, pending: 2, rate: 99.8 },
{ category: 'Datenschutz', total: 1250, accepted: 1245, declined: 3, pending: 2, rate: 99.6 },
{ category: 'Cookies (Notwendig)', total: 1250, accepted: 1250, declined: 0, pending: 0, rate: 100 },
{ category: 'Cookies (Analyse)', total: 1250, accepted: 892, declined: 358, pending: 0, rate: 71.4 },
{ category: 'Cookies (Marketing)', total: 1250, accepted: 456, declined: 794, pending: 0, rate: 36.5 },
{ category: 'Newsletter', total: 1250, accepted: 312, declined: 938, pending: 0, rate: 25.0 },
]
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'documents', label: 'Dokumenten-Consents' },
{ id: 'cookies', label: 'Cookie-Consents' },
{ id: 'marketing', label: 'Marketing-Consents' },
{ id: 'audit', label: 'Audit-Trail' },
]
const getActionLabel = (action: string) => {
const labels: Record<string, string> = {
'consent_given': 'Zustimmung erteilt',
'consent_withdrawn': 'Zustimmung widerrufen',
'cookie_consent_updated': 'Cookie-Einstellungen aktualisiert',
'data_access': 'Datenzugriff',
'data_export_requested': 'Datenexport angefordert',
'data_deletion_requested': 'Loeschung angefordert',
'account_suspended': 'Account gesperrt',
'account_restored': 'Account wiederhergestellt',
}
return labels[action] || action
}
const getActionColor = (action: string) => {
if (action.includes('given') || action.includes('restored')) return 'bg-green-100 text-green-700'
if (action.includes('withdrawn') || action.includes('deleted') || action.includes('suspended')) return 'bg-red-100 text-red-700'
return 'bg-blue-100 text-blue-700'
}
return (
<div>
<PagePurpose
title="Einwilligungsverwaltung"
purpose="Zentrale Uebersicht aller Nutzer-Einwilligungen. Hier sehen Sie alle Zustimmungen zu rechtlichen Dokumenten, Cookies, Marketing und Statistik - erfasst ueber Website, App und PWA."
audience={['DSB', 'Compliance Officer', 'Marketing']}
gdprArticles={['Art. 6 (Rechtmaessigkeit)', 'Art. 7 (Einwilligung)', 'Art. 21 (Widerspruch)']}
architecture={{
services: ['consent-service (Go)'],
databases: ['PostgreSQL (user_consents, cookie_consents)'],
}}
relatedPages={[
{ name: 'Consent Dokumente', href: '/compliance/consent', description: 'Rechtliche Dokumente verwalten' },
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management' },
{ name: 'DSR', href: '/compliance/dsr', description: 'Betroffenenanfragen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{stats?.total_users || 1250}</div>
<div className="text-sm text-slate-500">Registrierte Nutzer</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats?.consented_users || 1245}</div>
<div className="text-sm text-slate-500">Mit Zustimmung</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-yellow-600">{stats?.pending_consents || 5}</div>
<div className="text-sm text-slate-500">Ausstehend</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-purple-600">{stats?.consent_rate?.toFixed(1) || 99.6}%</div>
<div className="text-sm text-slate-500">Zustimmungsrate</div>
</div>
</div>
{/* Tabs */}
<div className="mb-6">
<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>
{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>
)}
{/* Content */}
<div className="bg-white rounded-xl border border-slate-200">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Consent-Uebersicht nach Kategorie</h2>
<div className="space-y-4">
{consentSummary.map((item) => (
<div key={item.category} className="border border-slate-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium text-slate-900">{item.category}</h3>
<span className={`px-2 py-1 rounded text-xs font-medium ${
item.rate >= 90 ? 'bg-green-100 text-green-700' :
item.rate >= 50 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{item.rate}% Zustimmung
</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden mb-3">
<div
className={`h-full ${item.rate >= 90 ? 'bg-green-500' : item.rate >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${item.rate}%` }}
/>
</div>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-slate-500">Gesamt:</span>
<span className="ml-1 font-medium">{item.total}</span>
</div>
<div>
<span className="text-green-600">Akzeptiert:</span>
<span className="ml-1 font-medium">{item.accepted}</span>
</div>
<div>
<span className="text-red-600">Abgelehnt:</span>
<span className="ml-1 font-medium">{item.declined}</span>
</div>
<div>
<span className="text-yellow-600">Ausstehend:</span>
<span className="ml-1 font-medium">{item.pending}</span>
</div>
</div>
</div>
))}
</div>
{/* Export Button */}
<div className="mt-6 pt-6 border-t border-slate-200">
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium">
Consent-Report exportieren (CSV)
</button>
</div>
</div>
)}
{/* 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">Dokumenten-Einwilligungen</h2>
<div className="flex gap-2">
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="">Alle Dokumente</option>
<option value="terms">AGB</option>
<option value="privacy">Datenschutz</option>
<option value="cookies">Cookies</option>
</select>
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="withdrawn">Widerrufen</option>
</select>
</div>
</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">Nutzer-ID</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Dokument</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Version</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Status</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Datum</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Quelle</th>
</tr>
</thead>
<tbody>
{/* Sample data - in production, this comes from API */}
{[
{ id: 'usr_123', doc: 'AGB', version: 'v2.1.0', status: 'active', date: '2024-12-15', source: 'Website' },
{ id: 'usr_124', doc: 'Datenschutz', version: 'v3.0.0', status: 'active', date: '2024-12-15', source: 'App' },
{ id: 'usr_125', doc: 'AGB', version: 'v2.1.0', status: 'withdrawn', date: '2024-12-14', source: 'PWA' },
].map((consent, idx) => (
<tr key={idx} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4 font-mono text-sm">{consent.id}</td>
<td className="py-3 px-4">{consent.doc}</td>
<td className="py-3 px-4 text-sm text-slate-500">{consent.version}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${
consent.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{consent.status === 'active' ? 'Aktiv' : 'Widerrufen'}
</span>
</td>
<td className="py-3 px-4 text-sm text-slate-500">{consent.date}</td>
<td className="py-3 px-4">
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">{consent.source}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Cookies Tab */}
{activeTab === 'cookies' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Cookie-Einwilligungen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ name: 'Notwendige Cookies', key: 'necessary', mandatory: true, rate: 100, description: 'Erforderlich fuer Grundfunktionen' },
{ name: 'Funktionale Cookies', key: 'functional', mandatory: false, rate: 82.3, description: 'Verbesserte Nutzererfahrung' },
{ name: 'Analyse Cookies', key: 'analytics', mandatory: false, rate: 71.4, description: 'Anonyme Nutzungsstatistiken' },
{ name: 'Marketing Cookies', key: 'marketing', mandatory: false, rate: 36.5, description: 'Personalisierte Werbung' },
].map((category) => (
<div key={category.key} className="border border-slate-200 rounded-xl p-5">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-slate-900">{category.name}</h3>
<p className="text-sm text-slate-500">{category.description}</p>
</div>
{category.mandatory && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-medium">Pflicht</span>
)}
</div>
<div className="flex items-center gap-4">
<div className="flex-grow h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${category.rate >= 80 ? 'bg-green-500' : category.rate >= 50 ? 'bg-yellow-500' : 'bg-orange-500'}`}
style={{ width: `${category.rate}%` }}
/>
</div>
<span className="text-lg font-bold text-slate-900">{category.rate}%</span>
</div>
</div>
))}
</div>
<div className="mt-6 p-4 bg-slate-50 rounded-lg">
<h4 className="font-medium text-slate-900 mb-2">Cookie-Banner Einstellungen</h4>
<p className="text-sm text-slate-600">
Das Cookie-Banner wird auf allen Plattformen (Website, App, PWA) einheitlich angezeigt.
Nutzer koennen ihre Praeferenzen jederzeit in den Einstellungen aendern.
</p>
</div>
</div>
)}
{/* Marketing Tab */}
{activeTab === 'marketing' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Marketing-Einwilligungen</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
{[
{ name: 'E-Mail Newsletter', rate: 25.0, total: 1250, subscribed: 312 },
{ name: 'Push-Benachrichtigungen', rate: 45.2, total: 1250, subscribed: 565 },
{ name: 'Personalisierte Werbung', rate: 18.5, total: 1250, subscribed: 231 },
].map((channel) => (
<div key={channel.name} className="bg-white border border-slate-200 rounded-xl p-5">
<h3 className="font-semibold text-slate-900 mb-2">{channel.name}</h3>
<div className="text-3xl font-bold text-purple-600 mb-1">{channel.rate}%</div>
<div className="text-sm text-slate-500">{channel.subscribed} von {channel.total} Nutzern</div>
<div className="mt-4 h-2 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-purple-500" style={{ width: `${channel.rate}%` }} />
</div>
</div>
))}
</div>
<div className="border border-slate-200 rounded-lg p-4">
<h4 className="font-medium text-slate-900 mb-3">Opt-Out Anfragen (letzte 30 Tage)</h4>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xl font-bold text-slate-900">23</div>
<div className="text-xs text-slate-500">Newsletter</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xl font-bold text-slate-900">45</div>
<div className="text-xs text-slate-500">Push</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xl font-bold text-slate-900">12</div>
<div className="text-xs text-slate-500">Werbung</div>
</div>
</div>
</div>
</div>
)}
{/* Audit Tab */}
{activeTab === 'audit' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Consent Audit-Trail</h2>
<div className="flex gap-2">
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="">Alle Aktionen</option>
<option value="consent_given">Zustimmung erteilt</option>
<option value="consent_withdrawn">Zustimmung widerrufen</option>
<option value="cookie_consent_updated">Cookie aktualisiert</option>
</select>
<button
onClick={loadAuditLog}
className="px-3 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm hover:bg-slate-200"
>
Aktualisieren
</button>
</div>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Audit-Log...</div>
) : (
<div className="space-y-3">
{(auditLog.length > 0 ? auditLog : [
// Sample data
{ id: '1', user_id: 'usr_123', action: 'consent_given', entity_type: 'document', entity_id: 'doc_agb', details: {}, ip_address: '192.168.1.1', created_at: '2024-12-15T10:30:00Z' },
{ id: '2', user_id: 'usr_124', action: 'cookie_consent_updated', entity_type: 'cookie', entity_id: 'analytics', details: {}, ip_address: '192.168.1.2', created_at: '2024-12-15T10:25:00Z' },
{ id: '3', user_id: 'usr_125', action: 'consent_withdrawn', entity_type: 'document', entity_id: 'doc_newsletter', details: {}, ip_address: '192.168.1.3', created_at: '2024-12-15T10:20:00Z' },
]).map((entry) => (
<div key={entry.id} className="border border-slate-200 rounded-lg p-4 hover:bg-slate-50">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${getActionColor(entry.action)}`}>
{getActionLabel(entry.action)}
</span>
<span className="font-mono text-sm text-slate-600">{entry.user_id}</span>
</div>
<span className="text-sm text-slate-400">
{new Date(entry.created_at).toLocaleString('de-DE')}
</span>
</div>
<div className="mt-2 text-sm text-slate-500">
<span className="text-slate-400">Entity:</span> {entry.entity_type} / {entry.entity_id}
<span className="mx-2 text-slate-300">|</span>
<span className="text-slate-400">IP:</span> {entry.ip_address}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* GDPR Notice */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex 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>
<h4 className="font-semibold text-purple-900">DSGVO-Hinweis</h4>
<p className="text-sm text-purple-800 mt-1">
Alle Einwilligungen werden revisionssicher gespeichert und koennen jederzeit nachgewiesen werden.
Nutzer koennen ihre Einwilligungen gemaess Art. 7 Abs. 3 DSGVO jederzeit widerrufen.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,583 +0,0 @@
'use client'
/**
* Evidence Management Page
*
* Features:
* - List evidence by control
* - File upload
* - URL/Link adding
* - Evidence status tracking
*/
import { useState, useEffect, useRef, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
interface Evidence {
id: string
control_id: string
evidence_type: string
title: string
description: string
artifact_path: string | null
artifact_url: string | null
artifact_hash: string | null
file_size_bytes: number | null
mime_type: string | null
status: string
source: string
ci_job_id: string | null
valid_from: string
valid_until: string | null
collected_at: string
}
interface Control {
id: string
control_id: string
title: string
}
const EVIDENCE_TYPES = [
{ value: 'scan_report', label: 'Scan Report', icon: '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' },
{ value: 'policy_document', label: 'Policy Dokument', icon: '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' },
{ value: 'config_snapshot', label: 'Config Snapshot', icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4' },
{ value: 'test_result', label: 'Test Ergebnis', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
{ value: 'screenshot', label: 'Screenshot', icon: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z' },
{ value: 'external_link', label: 'Externer Link', icon: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14' },
{ value: 'manual_upload', label: 'Manueller Upload', icon: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12' },
]
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
valid: { bg: 'bg-green-100', text: 'text-green-700', label: 'Gueltig' },
expired: { bg: 'bg-red-100', text: 'text-red-700', label: 'Abgelaufen' },
pending: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Ausstehend' },
failed: { bg: 'bg-red-100', text: 'text-red-700', label: 'Fehlgeschlagen' },
}
function EvidencePageContent({ initialControlId }: { initialControlId: string | null }) {
const [evidence, setEvidence] = useState<Evidence[]>([])
const [controls, setControls] = useState<Control[]>([])
const [loading, setLoading] = useState(true)
const [filterControlId, setFilterControlId] = useState(initialControlId || '')
const [filterType, setFilterType] = useState('')
const [uploadModalOpen, setUploadModalOpen] = useState(false)
const [linkModalOpen, setLinkModalOpen] = useState(false)
const [uploading, setUploading] = useState(false)
const [newEvidence, setNewEvidence] = useState({
control_id: initialControlId || '',
evidence_type: 'manual_upload',
title: '',
description: '',
artifact_url: '',
})
const fileInputRef = useRef<HTMLInputElement>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
useEffect(() => {
loadData()
}, [filterControlId, filterType])
const loadData = async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterControlId) params.append('control_id', filterControlId)
if (filterType) params.append('evidence_type', filterType)
const [evidenceRes, controlsRes] = await Promise.all([
fetch(`/api/admin/compliance/evidence?${params}`),
fetch(`/api/admin/compliance/controls`),
])
if (evidenceRes.ok) {
const data = await evidenceRes.json()
setEvidence(data.evidence || [])
}
if (controlsRes.ok) {
const data = await controlsRes.json()
setControls(data.controls || [])
}
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setLoading(false)
}
}
const handleFileUpload = async () => {
if (!selectedFile || !newEvidence.control_id || !newEvidence.title) {
alert('Bitte alle Pflichtfelder ausfuellen')
return
}
setUploading(true)
try {
const formData = new FormData()
formData.append('file', selectedFile)
const params = new URLSearchParams({
control_id: newEvidence.control_id,
evidence_type: newEvidence.evidence_type,
title: newEvidence.title,
})
if (newEvidence.description) {
params.append('description', newEvidence.description)
}
const res = await fetch(`/api/admin/compliance/evidence/upload?${params}`, {
method: 'POST',
body: formData,
})
if (res.ok) {
setUploadModalOpen(false)
resetForm()
loadData()
} else {
const error = await res.text()
alert(`Upload fehlgeschlagen: ${error}`)
}
} catch (error) {
console.error('Upload failed:', error)
alert('Upload fehlgeschlagen')
} finally {
setUploading(false)
}
}
const handleLinkSubmit = async () => {
if (!newEvidence.control_id || !newEvidence.title || !newEvidence.artifact_url) {
alert('Bitte alle Pflichtfelder ausfuellen')
return
}
setUploading(true)
try {
const res = await fetch(`/api/admin/compliance/evidence`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
control_id: newEvidence.control_id,
evidence_type: 'external_link',
title: newEvidence.title,
description: newEvidence.description,
artifact_url: newEvidence.artifact_url,
source: 'manual',
}),
})
if (res.ok) {
setLinkModalOpen(false)
resetForm()
loadData()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Failed to add link:', error)
alert('Fehler beim Hinzufuegen')
} finally {
setUploading(false)
}
}
const resetForm = () => {
setNewEvidence({
control_id: filterControlId || '',
evidence_type: 'manual_upload',
title: '',
description: '',
artifact_url: '',
})
setSelectedFile(null)
}
const formatFileSize = (bytes: number | null) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const getControlTitle = (controlUuid: string) => {
const control = controls.find((c) => c.id === controlUuid)
return control?.control_id || controlUuid
}
// Statistics
const stats = {
total: evidence.length,
valid: evidence.filter(e => e.status === 'valid').length,
expired: evidence.filter(e => e.status === 'expired').length,
pending: evidence.filter(e => e.status === 'pending').length,
automated: evidence.filter(e => e.source === 'ci_pipeline').length,
}
return (
<div className="min-h-screen bg-slate-50 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Evidence Management</h1>
<p className="text-slate-600">Nachweise & Artefakte</p>
</div>
<Link
href="/compliance/hub"
className="flex items-center gap-2 text-slate-600 hover:text-slate-800"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Compliance Hub
</Link>
</div>
{/* Page Purpose */}
<PagePurpose
title="Evidence Management"
purpose="Verwalten Sie alle Nachweise und Artefakte, die die Einhaltung von Compliance-Anforderungen belegen. Jeder Control kann mit mehreren Nachweisen verknuepft werden - von automatischen Scan-Reports bis zu manuellen Dokumenten."
audience={['CISO', 'DSB', 'Compliance Officer', 'Auditoren']}
gdprArticles={['Art. 5(2) (Rechenschaftspflicht)', 'Art. 30 (Verzeichnis von Verarbeitungstaetigkeiten)']}
architecture={{
services: ['Python Backend (FastAPI)', 'compliance_evidence Modul'],
databases: ['PostgreSQL (compliance_evidence Table)', 'MinIO (Datei-Storage)'],
}}
relatedPages={[
{ name: 'Controls', href: '/compliance/controls', description: 'Control-Katalog verwalten' },
{ name: 'Audit Checklist', href: '/compliance/audit-checklist', description: 'Anforderungen pruefen' },
]}
/>
{/* Statistics Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 border border-slate-200">
<p className="text-sm text-slate-500">Gesamt</p>
<p className="text-2xl font-bold text-slate-900">{stats.total}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-green-200">
<p className="text-sm text-green-600">Gueltig</p>
<p className="text-2xl font-bold text-green-700">{stats.valid}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-red-200">
<p className="text-sm text-red-600">Abgelaufen</p>
<p className="text-2xl font-bold text-red-700">{stats.expired}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-yellow-200">
<p className="text-sm text-yellow-600">Ausstehend</p>
<p className="text-2xl font-bold text-yellow-700">{stats.pending}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-blue-200">
<p className="text-sm text-blue-600">CI/CD</p>
<p className="text-2xl font-bold text-blue-700">{stats.automated}</p>
</div>
</div>
{/* Actions & Filters */}
<div className="bg-white rounded-xl shadow-sm border p-4 mb-6">
<div className="flex flex-wrap items-center gap-4">
<select
value={filterControlId}
onChange={(e) => setFilterControlId(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Controls</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
</select>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Typen</option>
{EVIDENCE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<div className="flex-1" />
<button
onClick={() => { resetForm(); setLinkModalOpen(true) }}
className="px-4 py-2 border border-primary-600 text-primary-600 rounded-lg hover:bg-primary-50"
>
Link hinzufuegen
</button>
<button
onClick={() => { resetForm(); setUploadModalOpen(true) }}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Datei hochladen
</button>
</div>
</div>
{/* Evidence List */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : evidence.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 text-slate-300 mx-auto mb-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>
<p className="text-slate-500 mb-4">Keine Nachweise gefunden</p>
<button
onClick={() => setUploadModalOpen(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Ersten Nachweis hinzufuegen
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{evidence.map((ev) => {
const statusStyle = STATUS_STYLES[ev.status] || STATUS_STYLES.pending
return (
<div key={ev.id} className="bg-white rounded-xl shadow-sm border p-4 hover:border-primary-300 transition-colors">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={EVIDENCE_TYPES.find((t) => t.value === ev.evidence_type)?.icon || '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>
<span className={`px-2 py-0.5 text-xs rounded-full ${statusStyle.bg} ${statusStyle.text}`}>
{statusStyle.label}
</span>
</div>
<span className="text-xs text-slate-500 font-mono">{getControlTitle(ev.control_id)}</span>
</div>
<h4 className="font-medium text-slate-900 mb-1">{ev.title}</h4>
{ev.description && (
<p className="text-sm text-slate-500 mb-3 line-clamp-2">{ev.description}</p>
)}
<div className="flex items-center justify-between text-xs text-slate-500 pt-3 border-t">
<span>{EVIDENCE_TYPES.find(t => t.value === ev.evidence_type)?.label || ev.evidence_type}</span>
<span>{formatFileSize(ev.file_size_bytes)}</span>
</div>
{ev.artifact_url && (
<a
href={ev.artifact_url}
target="_blank"
rel="noopener noreferrer"
className="mt-3 block text-sm text-primary-600 hover:text-primary-700 truncate"
>
{ev.artifact_url}
</a>
)}
<div className="mt-3 flex items-center justify-between text-xs text-slate-400">
<span>Erfasst: {new Date(ev.collected_at).toLocaleDateString('de-DE')}</span>
{ev.source === 'ci_pipeline' && (
<span className="bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded">CI/CD</span>
)}
</div>
</div>
)
})}
</div>
)}
{/* Upload Modal */}
{uploadModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
<div className="p-6 border-b">
<h3 className="text-lg font-semibold text-slate-900">Datei hochladen</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
<select
value={newEvidence.control_id}
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Control auswaehlen...</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={newEvidence.evidence_type}
onChange={(e) => setNewEvidence({ ...newEvidence, evidence_type: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{EVIDENCE_TYPES.filter((t) => t.value !== 'external_link').map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={newEvidence.title}
onChange={(e) => setNewEvidence({ ...newEvidence, title: e.target.value })}
placeholder="z.B. Semgrep Scan Report 2026-01"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={newEvidence.description}
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Datei *</label>
<input
type="file"
ref={fileInputRef}
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
{selectedFile && (
<p className="mt-1 text-sm text-slate-500">
{selectedFile.name} ({formatFileSize(selectedFile.size)})
</p>
)}
</div>
</div>
<div className="p-6 border-t bg-slate-50 flex justify-end gap-3">
<button
onClick={() => setUploadModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={uploading}
>
Abbrechen
</button>
<button
onClick={handleFileUpload}
disabled={uploading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{uploading ? 'Hochladen...' : 'Hochladen'}
</button>
</div>
</div>
</div>
)}
{/* Link Modal */}
{linkModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
<div className="p-6 border-b">
<h3 className="text-lg font-semibold text-slate-900">Link/Quelle hinzufuegen</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
<select
value={newEvidence.control_id}
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Control auswaehlen...</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={newEvidence.title}
onChange={(e) => setNewEvidence({ ...newEvidence, title: e.target.value })}
placeholder="z.B. GitHub Branch Protection Settings"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">URL *</label>
<input
type="url"
value={newEvidence.artifact_url}
onChange={(e) => setNewEvidence({ ...newEvidence, artifact_url: e.target.value })}
placeholder="https://github.com/..."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={newEvidence.description}
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<div className="p-6 border-t bg-slate-50 flex justify-end gap-3">
<button
onClick={() => setLinkModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={uploading}
>
Abbrechen
</button>
<button
onClick={handleLinkSubmit}
disabled={uploading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{uploading ? 'Speichern...' : 'Hinzufuegen'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
function EvidencePageWithParams() {
const searchParams = useSearchParams()
const initialControlId = searchParams.get('control')
return <EvidencePageContent initialControlId={initialControlId} />
}
export default function EvidencePage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-slate-50 p-6 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
}>
<EvidencePageWithParams />
</Suspense>
)
}

View File

@@ -1,545 +0,0 @@
'use client'
/**
* Compliance Hub - Central Compliance Management Dashboard
*
* Features:
* - Compliance Score Overview
* - Quick Access to all compliance modules
* - 474 Control-Mappings with statistics
* - Haupt-/Nebenabweichungen (Major/Minor findings)
* - Regulations overview
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
// 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 ControlMapping {
id: string
control_id: string
requirement_id: string
control_title: string
requirement_title: string
regulation_code: string
mapping_strength: string
}
interface MappingsData {
mappings: ControlMapping[]
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">
<PagePurpose
title="Compliance Hub"
purpose="Zentrale Verwaltung aller Compliance-Anforderungen nach DSGVO, AI Act, BSI TR-03161 und weiteren Regulierungen. Hier sehen Sie den aktuellen Compliance-Stand und haben Zugriff auf alle Module."
audience={['DSB', 'Compliance Officer', 'Auditor', 'Entwickler']}
gdprArticles={['Art. 5 (Grundsaetze)', 'Art. 24 (Verantwortung)', 'Art. 32 (Sicherheit)']}
architecture={{
services: ['Python Backend', 'PostgreSQL'],
databases: ['compliance_*'],
}}
relatedPages={[
{ name: 'Audit Checkliste', href: '/compliance/audit-checklist', description: '476 Anforderungen pruefen' },
{ name: 'Controls', href: '/compliance/controls', description: 'Technische Massnahmen' },
{ name: 'Risiken', href: '/compliance/risks', description: 'Risikoregister & Matrix' },
]}
/>
{/* 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 - Always visible at top */}
<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="/compliance/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="/compliance/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="/compliance/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="/compliance/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="/compliance/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="/compliance/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">
{/* Score Card */}
<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>
{/* Stats Cards */}
<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">
{/* 474 Control-Mappings Card */}
<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="/compliance/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>
{/* Haupt-/Nebenabweichungen Card */}
<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="/compliance/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 - Full Width */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Controls nach Domain</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(dashboard?.controls_by_domain || {}).map(([domain, stats]) => {
const total = stats.total || 0
const pass = stats.pass || 0
const partial = stats.partial || 0
const passPercent = total > 0 ? ((pass + partial * 0.5) / total) * 100 : 0
return (
<div key={domain} className="p-3 rounded-lg bg-slate-50">
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-slate-700">
{DOMAIN_LABELS[domain] || domain.toUpperCase()}
</span>
<span className="text-slate-500">
{pass}/{total} ({passPercent.toFixed(0)}%)
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden flex">
<div className="bg-green-500 h-full" style={{ width: `${(pass / total) * 100}%` }} />
<div className="bg-yellow-500 h-full" style={{ width: `${(partial / total) * 100}%` }} />
</div>
</div>
)
})}
</div>
</div>
{/* Regulations Table */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Verordnungen & Standards ({regulations.length})</h3>
<button onClick={loadData} className="text-sm text-purple-600 hover:text-purple-700">
Aktualisieren
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Code</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Anforderungen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{regulations.slice(0, 15).map((reg) => (
<tr key={reg.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-purple-600">{reg.code}</span>
</td>
<td className="px-4 py-3">
<p className="font-medium text-slate-900">{reg.name}</p>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${
reg.regulation_type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' :
reg.regulation_type === 'eu_directive' ? 'bg-purple-100 text-purple-700' :
reg.regulation_type === 'bsi_standard' ? 'bg-green-100 text-green-700' :
'bg-slate-100 text-slate-700'
}`}>
{reg.regulation_type === 'eu_regulation' ? 'EU-VO' :
reg.regulation_type === 'eu_directive' ? 'EU-RL' :
reg.regulation_type === 'bsi_standard' ? 'BSI' :
reg.regulation_type === 'de_law' ? 'DE' : reg.regulation_type}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-medium">{reg.requirement_count}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
)
}

View File

@@ -1,511 +0,0 @@
'use client'
/**
* Loeschfristen - Data Retention Management
*
* Art. 17 DSGVO - Recht auf Loeschung
* Art. 5 Abs. 1 lit. e DSGVO - Speicherbegrenzung
*
* Verwaltet:
* - Aufbewahrungsfristen
* - Consent-Deadlines
* - Automatische Loeschungen
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
const API_BASE = '/api/admin/consent'
interface RetentionPolicy {
id: string
dataCategory: string
retentionPeriod: string
legalBasis: string
autoDelete: boolean
lastRun?: string
nextRun?: string
itemsToDelete?: number
}
interface ConsentDeadline {
id: string
userId: string
documentName: string
versionNumber: string
deadlineAt: string
reminderCount: number
daysRemaining: number
status: 'pending' | 'overdue' | 'completed'
}
interface DeletionJob {
id: string
dataCategory: string
scheduledAt: string
status: 'scheduled' | 'running' | 'completed' | 'failed'
itemsProcessed: number
itemsTotal: number
completedAt?: string
}
export default function LoeschfristenPage() {
const [activeTab, setActiveTab] = useState<'policies' | 'deadlines' | 'jobs' | 'manual'>('policies')
const [loading, setLoading] = useState(false)
const [processing, setProcessing] = useState(false)
// Mock data - in production, this comes from API
const retentionPolicies: RetentionPolicy[] = [
{
id: 'pol_1',
dataCategory: 'Nutzerkonten (inaktiv)',
retentionPeriod: '3 Jahre nach letzter Aktivitaet',
legalBasis: 'Art. 5 Abs. 1 lit. e DSGVO',
autoDelete: true,
lastRun: '2024-12-01',
nextRun: '2025-01-01',
itemsToDelete: 23
},
{
id: 'pol_2',
dataCategory: 'Consent-Nachweise',
retentionPeriod: '6 Jahre nach Widerruf',
legalBasis: 'Nachweispflicht',
autoDelete: true,
lastRun: '2024-12-01',
nextRun: '2025-01-01',
itemsToDelete: 0
},
{
id: 'pol_3',
dataCategory: 'System-Logs',
retentionPeriod: '90 Tage',
legalBasis: 'Berechtigtes Interesse (IT-Sicherheit)',
autoDelete: true,
lastRun: '2024-12-14',
nextRun: '2024-12-15',
itemsToDelete: 15420
},
{
id: 'pol_4',
dataCategory: 'Security-Logs',
retentionPeriod: '2 Jahre',
legalBasis: 'Berechtigtes Interesse (Sicherheit)',
autoDelete: true,
lastRun: '2024-12-01',
nextRun: '2025-01-01',
itemsToDelete: 0
},
{
id: 'pol_5',
dataCategory: 'Lernfortschrittsdaten',
retentionPeriod: 'Ende Schuljahr + 1 Jahr',
legalBasis: 'Vertragserfuellung',
autoDelete: false,
itemsToDelete: 45
},
{
id: 'pol_6',
dataCategory: 'KI-Verarbeitungsdaten',
retentionPeriod: 'Sofortige Loeschung',
legalBasis: 'Datenminimierung',
autoDelete: true,
lastRun: '2024-12-15',
nextRun: 'Kontinuierlich',
itemsToDelete: 0
},
]
const consentDeadlines: ConsentDeadline[] = [
{ id: 'dl_1', userId: 'usr_456', documentName: 'AGB', versionNumber: 'v2.1.0', deadlineAt: '2025-01-15', reminderCount: 2, daysRemaining: 20, status: 'pending' },
{ id: 'dl_2', userId: 'usr_789', documentName: 'Datenschutz', versionNumber: 'v3.0.0', deadlineAt: '2024-12-28', reminderCount: 3, daysRemaining: 3, status: 'pending' },
{ id: 'dl_3', userId: 'usr_012', documentName: 'AGB', versionNumber: 'v2.1.0', deadlineAt: '2024-12-10', reminderCount: 4, daysRemaining: -5, status: 'overdue' },
]
const deletionJobs: DeletionJob[] = [
{ id: 'job_1', dataCategory: 'System-Logs', scheduledAt: '2024-12-14T02:00:00', status: 'completed', itemsProcessed: 12500, itemsTotal: 12500, completedAt: '2024-12-14T02:15:00' },
{ id: 'job_2', dataCategory: 'Inaktive Sessions', scheduledAt: '2024-12-14T03:00:00', status: 'completed', itemsProcessed: 450, itemsTotal: 450, completedAt: '2024-12-14T03:02:00' },
{ id: 'job_3', dataCategory: 'System-Logs', scheduledAt: '2024-12-15T02:00:00', status: 'scheduled', itemsProcessed: 0, itemsTotal: 15420 },
]
async function triggerDeadlineProcessing() {
setProcessing(true)
try {
const token = localStorage.getItem('bp_admin_token')
const res = await fetch(`${API_BASE}/deadlines`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
})
if (res.ok) {
alert('Deadline-Verarbeitung gestartet')
} else {
alert('Fehler bei der Verarbeitung')
}
} catch {
alert('Verbindungsfehler')
} finally {
setProcessing(false)
}
}
const tabs = [
{ id: 'policies', label: 'Aufbewahrungsfristen' },
{ id: 'deadlines', label: 'Consent-Deadlines' },
{ id: 'jobs', label: 'Loeschjobs' },
{ id: 'manual', label: 'Manuelle Loeschung' },
]
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Abgeschlossen</span>
case 'running':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Laeuft</span>
case 'scheduled':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Geplant</span>
case 'failed':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Fehlgeschlagen</span>
case 'pending':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Ausstehend</span>
case 'overdue':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Ueberfaellig</span>
default:
return null
}
}
return (
<div>
<PagePurpose
title="Loeschfristen & Datenaufbewahrung"
purpose="Verwaltung von Aufbewahrungsfristen, automatischen Loeschungen und Consent-Deadlines gemaess DSGVO Art. 5 (Speicherbegrenzung) und Art. 17 (Recht auf Loeschung)."
audience={['DSB', 'IT-Admin', 'Compliance Officer']}
gdprArticles={['Art. 5 Abs. 1 lit. e (Speicherbegrenzung)', 'Art. 17 (Recht auf Loeschung)']}
architecture={{
services: ['consent-service (Go)', 'cron-jobs'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'VVT', href: '/compliance/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'DSR', href: '/compliance/dsr', description: 'Loeschanfragen' },
{ name: 'Einwilligungen', href: '/compliance/einwilligungen', description: 'Consent-Uebersicht' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{retentionPolicies.length}</div>
<div className="text-sm text-slate-500">Aufbewahrungsrichtlinien</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-yellow-600">
{consentDeadlines.filter(d => d.status === 'pending').length}
</div>
<div className="text-sm text-slate-500">Offene Deadlines</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-red-600">
{consentDeadlines.filter(d => d.status === 'overdue').length}
</div>
<div className="text-sm text-slate-500">Ueberfaellige</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-purple-600">
{retentionPolicies.reduce((sum, p) => sum + (p.itemsToDelete || 0), 0).toLocaleString()}
</div>
<div className="text-sm text-slate-500">Zur Loeschung vorgemerkt</div>
</div>
</div>
{/* Tabs */}
<div className="mb-6">
<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 as typeof activeTab)}
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>
<div className="bg-white rounded-xl border border-slate-200">
{/* Policies Tab */}
{activeTab === 'policies' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Aufbewahrungsfristen</h2>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium">
+ Neue Richtlinie
</button>
</div>
<div className="space-y-4">
{retentionPolicies.map((policy) => (
<div key={policy.id} className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-grow">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold text-slate-900">{policy.dataCategory}</h3>
{policy.autoDelete ? (
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Auto-Loeschung</span>
) : (
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">Manuell</span>
)}
{(policy.itemsToDelete || 0) > 0 && (
<span className="px-2 py-0.5 bg-orange-100 text-orange-700 rounded text-xs">
{policy.itemsToDelete} zur Loeschung
</span>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-slate-500">Frist:</span>
<span className="ml-1 font-medium text-slate-700">{policy.retentionPeriod}</span>
</div>
<div>
<span className="text-slate-500">Rechtsgrundlage:</span>
<span className="ml-1 text-slate-600">{policy.legalBasis}</span>
</div>
{policy.lastRun && (
<div>
<span className="text-slate-500">Letzter Lauf:</span>
<span className="ml-1 text-slate-600">{policy.lastRun}</span>
</div>
)}
{policy.nextRun && (
<div>
<span className="text-slate-500">Naechster Lauf:</span>
<span className="ml-1 text-slate-600">{policy.nextRun}</span>
</div>
)}
</div>
</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">
Bearbeiten
</button>
{(policy.itemsToDelete || 0) > 0 && (
<button className="px-3 py-1.5 text-sm text-white bg-red-600 hover:bg-red-700 rounded-lg">
Jetzt loeschen
</button>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Deadlines Tab */}
{activeTab === 'deadlines' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Consent-Deadlines</h2>
<button
onClick={triggerDeadlineProcessing}
disabled={processing}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium disabled:opacity-50"
>
{processing ? 'Verarbeite...' : 'Deadlines verarbeiten'}
</button>
</div>
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
Nutzer haben 30 Tage Zeit, neue Pflichtdokumente zu akzeptieren.
Nach Ablauf wird der Account gesperrt, bis die Zustimmung erteilt wird.
</p>
</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">Nutzer</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Dokument</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Deadline</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Erinnerungen</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Status</th>
<th className="text-right py-3 px-4 text-sm font-medium text-slate-500">Aktionen</th>
</tr>
</thead>
<tbody>
{consentDeadlines.map((deadline) => (
<tr key={deadline.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4 font-mono text-sm">{deadline.userId}</td>
<td className="py-3 px-4">
<div>{deadline.documentName}</div>
<div className="text-xs text-slate-500">{deadline.versionNumber}</div>
</td>
<td className="py-3 px-4">
<div>{deadline.deadlineAt}</div>
<div className={`text-xs ${deadline.daysRemaining < 0 ? 'text-red-600' : deadline.daysRemaining <= 7 ? 'text-orange-600' : 'text-slate-500'}`}>
{deadline.daysRemaining < 0
? `${Math.abs(deadline.daysRemaining)} Tage ueberfaellig`
: `${deadline.daysRemaining} Tage verbleibend`}
</div>
</td>
<td className="py-3 px-4">
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
{deadline.reminderCount} gesendet
</span>
</td>
<td className="py-3 px-4">{getStatusBadge(deadline.status)}</td>
<td className="py-3 px-4 text-right">
<button className="text-purple-600 hover:text-purple-700 text-sm font-medium mr-3">
Erinnerung senden
</button>
{deadline.status === 'overdue' && (
<button className="text-red-600 hover:text-red-700 text-sm font-medium">
Account sperren
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Jobs Tab */}
{activeTab === 'jobs' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Loeschjobs</h2>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium">
+ Neuer Job
</button>
</div>
<div className="space-y-4">
{deletionJobs.map((job) => (
<div key={job.id} className="border border-slate-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h3 className="font-medium text-slate-900">{job.dataCategory}</h3>
{getStatusBadge(job.status)}
</div>
<span className="text-sm text-slate-500">
Geplant: {new Date(job.scheduledAt).toLocaleString('de-DE')}
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex-grow h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${job.status === 'completed' ? 'bg-green-500' : job.status === 'running' ? 'bg-blue-500' : 'bg-slate-300'}`}
style={{ width: `${job.itemsTotal > 0 ? (job.itemsProcessed / job.itemsTotal) * 100 : 0}%` }}
/>
</div>
<span className="text-sm text-slate-600 whitespace-nowrap">
{job.itemsProcessed.toLocaleString()} / {job.itemsTotal.toLocaleString()}
</span>
</div>
{job.completedAt && (
<div className="mt-2 text-xs text-slate-500">
Abgeschlossen: {new Date(job.completedAt).toLocaleString('de-DE')}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Manual Tab */}
{activeTab === 'manual' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Manuelle Loeschung</h2>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div className="flex gap-3">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" 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>
<h4 className="font-semibold text-red-900">Achtung: Manuelle Loeschung</h4>
<p className="text-sm text-red-800 mt-1">
Manuelle Loeschungen sind unwiderruflich. Stellen Sie sicher, dass keine gesetzlichen
Aufbewahrungsfristen verletzt werden und alle notwendigen Backups erstellt wurden.
</p>
</div>
</div>
</div>
<div className="space-y-6">
<div className="border border-slate-200 rounded-lg p-4">
<h3 className="font-medium text-slate-900 mb-3">Nutzer-Daten loeschen</h3>
<div className="flex gap-3">
<input
type="text"
placeholder="Nutzer-ID eingeben..."
className="flex-grow px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<button className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium">
Daten loeschen
</button>
</div>
<p className="text-xs text-slate-500 mt-2">
Loescht alle personenbezogenen Daten eines Nutzers (Art. 17 DSGVO)
</p>
</div>
<div className="border border-slate-200 rounded-lg p-4">
<h3 className="font-medium text-slate-900 mb-3">Alte Logs bereinigen</h3>
<div className="flex gap-3">
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="system">System-Logs</option>
<option value="audit">Audit-Logs</option>
<option value="access">Zugriffs-Logs</option>
</select>
<input
type="number"
placeholder="Aelter als (Tage)"
className="w-40 px-3 py-2 border border-slate-300 rounded-lg text-sm"
defaultValue={90}
/>
<button className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium">
Logs bereinigen
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* Info */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex 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>
<h4 className="font-semibold text-purple-900">Speicherbegrenzung (Art. 5)</h4>
<p className="text-sm text-purple-800 mt-1">
Personenbezogene Daten duerfen nur so lange gespeichert werden, wie es fuer die Zwecke
erforderlich ist. Die automatische Loeschung stellt die Einhaltung dieser Vorgabe sicher.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,601 +0,0 @@
'use client'
/**
* Service Module Registry Page
*
* Features:
* - List all Breakpilot services with regulation mappings
* - Filter by type, criticality, PII, AI
* - Detail panel with regulations
* - Seed functionality
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
interface ServiceModule {
id: string
name: string
display_name: string
description: string | null
service_type: string
port: number | null
technology_stack: string[]
repository_path: string | null
docker_image: string | null
data_categories: string[]
processes_pii: boolean
processes_health_data: boolean
ai_components: boolean
criticality: string
owner_team: string | null
is_active: boolean
compliance_score: number | null
regulation_count: number
risk_count: number
created_at: string
regulations?: Array<{
code: string
name: string
relevance_level: string
notes: string | null
}>
}
interface ModulesOverview {
total_modules: number
modules_by_type: Record<string, number>
modules_by_criticality: Record<string, number>
modules_processing_pii: number
modules_with_ai: number
average_compliance_score: number | null
regulations_coverage: Record<string, number>
}
const SERVICE_TYPE_CONFIG: Record<string, { icon: string; color: string; bgColor: string }> = {
backend: { icon: '⚙️', color: 'text-blue-700', bgColor: 'bg-blue-100' },
database: { icon: '🗄️', color: 'text-purple-700', bgColor: 'bg-purple-100' },
ai: { icon: '🤖', color: 'text-pink-700', bgColor: 'bg-pink-100' },
communication: { icon: '💬', color: 'text-green-700', bgColor: 'bg-green-100' },
storage: { icon: '📦', color: 'text-orange-700', bgColor: 'bg-orange-100' },
infrastructure: { icon: '🌐', color: 'text-slate-700', bgColor: 'bg-slate-100' },
monitoring: { icon: '📊', color: 'text-cyan-700', bgColor: 'bg-cyan-100' },
security: { icon: '🔒', color: 'text-red-700', bgColor: 'bg-red-100' },
}
const CRITICALITY_CONFIG: Record<string, { color: string; bgColor: string }> = {
critical: { color: 'text-red-700', bgColor: 'bg-red-100' },
high: { color: 'text-orange-700', bgColor: 'bg-orange-100' },
medium: { color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
low: { color: 'text-green-700', bgColor: 'bg-green-100' },
}
const RELEVANCE_CONFIG: Record<string, { color: string; bgColor: string }> = {
critical: { color: 'text-red-700', bgColor: 'bg-red-100' },
high: { color: 'text-orange-700', bgColor: 'bg-orange-100' },
medium: { color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
low: { color: 'text-green-700', bgColor: 'bg-green-100' },
}
export default function ModulesPage() {
const [modules, setModules] = useState<ServiceModule[]>([])
const [overview, setOverview] = useState<ModulesOverview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [typeFilter, setTypeFilter] = useState<string>('all')
const [criticalityFilter, setCriticalityFilter] = useState<string>('all')
const [piiFilter, setPiiFilter] = useState<boolean | null>(null)
const [aiFilter, setAiFilter] = useState<boolean | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const [selectedModule, setSelectedModule] = useState<ServiceModule | null>(null)
const [loadingDetail, setLoadingDetail] = useState(false)
useEffect(() => {
fetchModules()
fetchOverview()
}, [])
const fetchModules = async () => {
try {
setLoading(true)
const params = new URLSearchParams()
if (typeFilter !== 'all') params.append('service_type', typeFilter)
if (criticalityFilter !== 'all') params.append('criticality', criticalityFilter)
if (piiFilter !== null) params.append('processes_pii', String(piiFilter))
if (aiFilter !== null) params.append('ai_components', String(aiFilter))
const url = `/api/admin/compliance/modules${params.toString() ? '?' + params.toString() : ''}`
const res = await fetch(url)
if (!res.ok) throw new Error('Failed to fetch modules')
const data = await res.json()
setModules(data.modules || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}
const fetchOverview = async () => {
try {
const res = await fetch(`/api/admin/compliance/modules/overview`)
if (!res.ok) throw new Error('Failed to fetch overview')
const data = await res.json()
setOverview(data)
} catch (err) {
console.error('Failed to fetch overview:', err)
}
}
const fetchModuleDetail = async (moduleId: string) => {
try {
setLoadingDetail(true)
const res = await fetch(`/api/admin/compliance/modules/${moduleId}`)
if (!res.ok) throw new Error('Failed to fetch module details')
const data = await res.json()
setSelectedModule(data)
} catch (err) {
console.error('Failed to fetch module details:', err)
} finally {
setLoadingDetail(false)
}
}
const seedModules = async (force: boolean = false) => {
try {
const res = await fetch(`/api/admin/compliance/modules/seed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force }),
})
if (!res.ok) throw new Error('Failed to seed modules')
const data = await res.json()
alert(`Seeded ${data.modules_created} modules with ${data.mappings_created} regulation mappings`)
fetchModules()
fetchOverview()
} catch (err) {
alert('Failed to seed modules: ' + (err instanceof Error ? err.message : 'Unknown error'))
}
}
const filteredModules = modules.filter(m => {
if (!searchTerm) return true
const term = searchTerm.toLowerCase()
return (
m.name.toLowerCase().includes(term) ||
m.display_name.toLowerCase().includes(term) ||
(m.description && m.description.toLowerCase().includes(term)) ||
m.technology_stack.some(t => t.toLowerCase().includes(term))
)
})
const modulesByType = filteredModules.reduce((acc, m) => {
const type = m.service_type || 'unknown'
if (!acc[type]) acc[type] = []
acc[type].push(m)
return acc
}, {} as Record<string, ServiceModule[]>)
return (
<div className="min-h-screen bg-slate-50 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Service Module Registry</h1>
<p className="text-slate-600">
Alle {overview?.total_modules || 0} Breakpilot-Services mit Regulation-Mappings
</p>
</div>
<Link
href="/compliance/hub"
className="flex items-center gap-2 text-slate-600 hover:text-slate-800"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Compliance Hub
</Link>
</div>
{/* Page Purpose */}
<PagePurpose
title="Service Module Registry"
purpose="Das Service Registry dokumentiert alle Breakpilot-Microservices und deren regulatorische Anforderungen. Jeder Service wird mit relevanten Regulierungen (DSGVO, AI Act, BSI TR-03161) verknuepft und zeigt an, welche Compliance-Anforderungen gelten."
audience={['Entwickler', 'CISO', 'Compliance Officer', 'Architekten']}
gdprArticles={['Art. 30 (Verzeichnis)', 'Art. 32 (Sicherheit)']}
architecture={{
services: ['Python Backend (FastAPI)', 'compliance_modules Modul'],
databases: ['PostgreSQL (service_modules, module_regulation_mappings Tables)'],
}}
relatedPages={[
{ name: 'Controls', href: '/compliance/controls', description: 'Massnahmen verwalten' },
{ name: 'Risks', href: '/compliance/risks', description: 'Risikomatrix' },
]}
/>
{/* Overview Stats */}
{overview && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 border border-slate-200">
<p className="text-3xl font-bold text-blue-600">{overview.total_modules}</p>
<p className="text-sm text-slate-500">Services</p>
</div>
<div className="bg-white rounded-xl p-4 border border-red-200">
<p className="text-3xl font-bold text-red-600">{overview.modules_by_criticality?.critical || 0}</p>
<p className="text-sm text-slate-500">Critical</p>
</div>
<div className="bg-white rounded-xl p-4 border border-purple-200">
<p className="text-3xl font-bold text-purple-600">{overview.modules_processing_pii}</p>
<p className="text-sm text-slate-500">PII-Processing</p>
</div>
<div className="bg-white rounded-xl p-4 border border-pink-200">
<p className="text-3xl font-bold text-pink-600">{overview.modules_with_ai}</p>
<p className="text-sm text-slate-500">AI-Komponenten</p>
</div>
<div className="bg-white rounded-xl p-4 border border-green-200">
<p className="text-3xl font-bold text-green-600">
{Object.keys(overview.regulations_coverage || {}).length}
</p>
<p className="text-sm text-slate-500">Regulations</p>
</div>
<div className="bg-white rounded-xl p-4 border border-cyan-200">
<p className="text-3xl font-bold text-cyan-600">
{overview.average_compliance_score !== null
? `${overview.average_compliance_score}%`
: 'N/A'}
</p>
<p className="text-sm text-slate-500">Avg. Score</p>
</div>
</div>
)}
{/* Filters */}
<div className="bg-white rounded-xl shadow-sm border p-4 mb-6">
<div className="flex flex-wrap gap-4 items-end">
<div>
<label className="block text-xs text-slate-500 mb-1">Service Type</label>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="all">Alle Typen</option>
<option value="backend">Backend</option>
<option value="database">Database</option>
<option value="ai">AI/ML</option>
<option value="communication">Communication</option>
<option value="storage">Storage</option>
<option value="infrastructure">Infrastructure</option>
<option value="monitoring">Monitoring</option>
<option value="security">Security</option>
</select>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Criticality</label>
<select
value={criticalityFilter}
onChange={(e) => setCriticalityFilter(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">PII</label>
<select
value={piiFilter === null ? 'all' : String(piiFilter)}
onChange={(e) => {
const val = e.target.value
setPiiFilter(val === 'all' ? null : val === 'true')
}}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="true">Verarbeitet PII</option>
<option value="false">Keine PII</option>
</select>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">AI</label>
<select
value={aiFilter === null ? 'all' : String(aiFilter)}
onChange={(e) => {
const val = e.target.value
setAiFilter(val === 'all' ? null : val === 'true')
}}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="true">Mit AI</option>
<option value="false">Ohne AI</option>
</select>
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-xs text-slate-500 mb-1">Suche</label>
<input
type="text"
placeholder="Service, Beschreibung, Technologie..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm w-full"
/>
</div>
<button
onClick={fetchModules}
className="px-4 py-2 bg-slate-100 rounded-lg hover:bg-slate-200 text-sm"
>
Filter
</button>
<button
onClick={() => seedModules(false)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm"
>
Seed
</button>
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 p-4 rounded-lg mb-6">
{error}
</div>
)}
{/* Main Content */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : (
<div className="flex gap-6">
{/* Module List */}
<div className="flex-1 space-y-4">
{Object.entries(modulesByType).map(([type, typeModules]) => (
<div key={type} className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className={`px-4 py-2 border-b ${SERVICE_TYPE_CONFIG[type]?.bgColor || 'bg-slate-100'}`}>
<span className="text-lg mr-2">{SERVICE_TYPE_CONFIG[type]?.icon || '📁'}</span>
<span className={`font-semibold ${SERVICE_TYPE_CONFIG[type]?.color || 'text-slate-700'}`}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</span>
<span className="text-slate-500 ml-2">({typeModules.length})</span>
</div>
<div className="divide-y">
{typeModules.map((module) => (
<div
key={module.id}
onClick={() => fetchModuleDetail(module.name)}
className={`p-4 cursor-pointer hover:bg-slate-50 transition ${
selectedModule?.id === module.id ? 'bg-blue-50' : ''
}`}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{module.display_name}</span>
{module.port && (
<span className="text-xs text-slate-400">:{module.port}</span>
)}
</div>
<div className="text-sm text-slate-500 mt-1">{module.name}</div>
{module.description && (
<div className="text-sm text-slate-600 mt-1 line-clamp-2">
{module.description}
</div>
)}
<div className="flex flex-wrap gap-1 mt-2">
{module.technology_stack.slice(0, 4).map((tech, i) => (
<span key={i} className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">
{tech}
</span>
))}
{module.technology_stack.length > 4 && (
<span className="px-2 py-0.5 text-slate-400 text-xs">
+{module.technology_stack.length - 4}
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`px-2 py-0.5 text-xs rounded ${
CRITICALITY_CONFIG[module.criticality]?.bgColor || 'bg-slate-100'
} ${CRITICALITY_CONFIG[module.criticality]?.color || 'text-slate-700'}`}>
{module.criticality}
</span>
<div className="flex gap-1 mt-1">
{module.processes_pii && (
<span className="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded" title="Verarbeitet PII">
PII
</span>
)}
{module.ai_components && (
<span className="px-1.5 py-0.5 bg-pink-100 text-pink-700 text-xs rounded" title="AI-Komponenten">
AI
</span>
)}
</div>
<div className="text-xs text-slate-400 mt-1">
{module.regulation_count} Regulations
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
{filteredModules.length === 0 && !loading && (
<div className="text-center py-12 text-slate-500 bg-white rounded-xl shadow-sm border">
Keine Module gefunden.
<button
onClick={() => seedModules(false)}
className="text-primary-600 hover:underline ml-1"
>
Jetzt seeden?
</button>
</div>
)}
</div>
{/* Detail Panel */}
{selectedModule && (
<div className="w-96 bg-white rounded-xl shadow-sm border sticky top-6 h-fit">
<div className={`px-4 py-3 border-b ${SERVICE_TYPE_CONFIG[selectedModule.service_type]?.bgColor || 'bg-slate-100'}`}>
<div className="flex items-center justify-between">
<span className="text-lg">{SERVICE_TYPE_CONFIG[selectedModule.service_type]?.icon || '📁'}</span>
<button
onClick={() => setSelectedModule(null)}
className="text-slate-400 hover:text-slate-600"
>
</button>
</div>
<h3 className="font-bold text-lg mt-2">{selectedModule.display_name}</h3>
<div className="text-sm text-slate-600">{selectedModule.name}</div>
</div>
{loadingDetail ? (
<div className="p-4 text-center text-slate-500">Lade Details...</div>
) : (
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
{selectedModule.description && (
<div>
<div className="text-xs text-slate-500 uppercase mb-1">Beschreibung</div>
<div className="text-sm text-slate-700">{selectedModule.description}</div>
</div>
)}
<div className="grid grid-cols-2 gap-2 text-sm">
{selectedModule.port && (
<div>
<span className="text-slate-500">Port:</span>
<span className="ml-1 font-mono">{selectedModule.port}</span>
</div>
)}
<div>
<span className="text-slate-500">Criticality:</span>
<span className={`ml-1 px-1.5 py-0.5 rounded text-xs ${
CRITICALITY_CONFIG[selectedModule.criticality]?.bgColor || ''
} ${CRITICALITY_CONFIG[selectedModule.criticality]?.color || ''}`}>
{selectedModule.criticality}
</span>
</div>
</div>
<div>
<div className="text-xs text-slate-500 uppercase mb-1">Tech Stack</div>
<div className="flex flex-wrap gap-1">
{selectedModule.technology_stack.map((tech, i) => (
<span key={i} className="px-2 py-0.5 bg-slate-100 text-slate-700 text-xs rounded">
{tech}
</span>
))}
</div>
</div>
{selectedModule.data_categories.length > 0 && (
<div>
<div className="text-xs text-slate-500 uppercase mb-1">Daten-Kategorien</div>
<div className="flex flex-wrap gap-1">
{selectedModule.data_categories.map((cat, i) => (
<span key={i} className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded">
{cat}
</span>
))}
</div>
</div>
)}
<div className="flex flex-wrap gap-2">
{selectedModule.processes_pii && (
<span className="px-2 py-1 bg-purple-100 text-purple-700 text-xs rounded">
Verarbeitet PII
</span>
)}
{selectedModule.ai_components && (
<span className="px-2 py-1 bg-pink-100 text-pink-700 text-xs rounded">
AI-Komponenten
</span>
)}
{selectedModule.processes_health_data && (
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">
Gesundheitsdaten
</span>
)}
</div>
{selectedModule.regulations && selectedModule.regulations.length > 0 && (
<div>
<div className="text-xs text-slate-500 uppercase mb-2">
Applicable Regulations ({selectedModule.regulations.length})
</div>
<div className="space-y-2">
{selectedModule.regulations.map((reg, i) => (
<div key={i} className="p-2 bg-slate-50 rounded text-sm">
<div className="flex justify-between items-start">
<span className="font-medium">{reg.code}</span>
<span className={`px-1.5 py-0.5 rounded text-xs ${
RELEVANCE_CONFIG[reg.relevance_level]?.bgColor || 'bg-slate-100'
} ${RELEVANCE_CONFIG[reg.relevance_level]?.color || 'text-slate-700'}`}>
{reg.relevance_level}
</span>
</div>
<div className="text-slate-500 text-xs">{reg.name}</div>
{reg.notes && (
<div className="text-slate-600 text-xs mt-1 italic">{reg.notes}</div>
)}
</div>
))}
</div>
</div>
)}
{selectedModule.owner_team && (
<div>
<div className="text-xs text-slate-500 uppercase mb-1">Owner</div>
<div className="text-sm text-slate-700">{selectedModule.owner_team}</div>
</div>
)}
{selectedModule.repository_path && (
<div>
<div className="text-xs text-slate-500 uppercase mb-1">Repository</div>
<code className="text-xs bg-slate-100 px-2 py-1 rounded block">
{selectedModule.repository_path}
</code>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Regulations Coverage Overview */}
{overview && overview.regulations_coverage && Object.keys(overview.regulations_coverage).length > 0 && (
<div className="bg-white rounded-xl shadow-sm border p-4 mt-6">
<h3 className="font-semibold text-slate-900 mb-4">Regulation Coverage</h3>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Object.entries(overview.regulations_coverage)
.sort(([, a], [, b]) => b - a)
.map(([code, count]) => (
<div key={code} className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600">{count}</div>
<div className="text-xs text-slate-600 truncate" title={code}>{code}</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +0,0 @@
'use client'
import { getCategoryById } from '@/lib/navigation'
import { ModuleCard } from '@/components/common/ModuleCard'
import { PagePurpose } from '@/components/common/PagePurpose'
export default function CompliancePage() {
const category = getCategoryById('compliance')
if (!category) {
return <div>Kategorie nicht gefunden</div>
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title={category.name}
purpose="Diese Kategorie umfasst alle Module fuer Datenschutz, DSGVO-Compliance und rechtliche Dokumentation. Hier verwalten Sie Einwilligungen, bearbeiten Betroffenenanfragen und dokumentieren Audit-Nachweise."
audience={['DSB', 'Compliance Officer', 'Auditoren']}
gdprArticles={['Art. 5 (Rechenschaftspflicht)', 'Art. 7 (Einwilligung)', 'Art. 15-21 (Betroffenenrechte)']}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
collapsible={true}
defaultCollapsed={false}
/>
{/* Modules Grid */}
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{category.modules.map((module) => (
<ModuleCard key={module.id} module={module} category={category} />
))}
</div>
{/* Info Section */}
<div className="mt-8 bg-purple-50 border border-purple-200 rounded-xl p-6">
<h3 className="font-semibold text-purple-800 flex items-center gap-2">
<span>🛡</span>
DSGVO-Konformitaet
</h3>
<p className="text-sm text-purple-700 mt-2">
Alle Module in dieser Kategorie sind darauf ausgelegt, die DSGVO-Anforderungen zu erfuellen.
Die Dokumentation aller Verarbeitungstaetigkeiten erfolgt automatisch und kann jederzeit
fuer Audits exportiert werden.
</p>
</div>
</div>
)
}

View File

@@ -1,446 +0,0 @@
'use client'
/**
* Requirements Page - Alle Compliance-Anforderungen mit Implementation-Status
*
* Features:
* - Liste aller 19 Verordnungen mit URLs zu Originaldokumenten
* - 558+ Requirements mit Implementation-Status
* - Filterung nach Regulation, Status, Prioritaet
* - Detail-Ansicht mit Breakpilot-Interpretation
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// Types
interface Regulation {
id: string
code: string
name: string
full_name: string
regulation_type: string
source_url: string
local_pdf_path?: string
effective_date?: string
description: string
is_active: boolean
requirement_count: number
}
interface Requirement {
id: string
regulation_id: string
regulation_code: string
article: string
paragraph?: string
title: string
description?: string
requirement_text?: string
breakpilot_interpretation?: string
implementation_status: 'not_started' | 'in_progress' | 'implemented' | 'verified' | 'not_applicable'
implementation_details?: string
code_references?: Array<{ file: string; line?: number; description?: string }>
evidence_description?: string
priority: number
is_applicable: boolean
controls_count: number
}
const STATUS_CONFIG: Record<string, { bg: string; text: string; label: string }> = {
not_started: { bg: 'bg-slate-100', text: 'text-slate-700', label: 'Nicht begonnen' },
in_progress: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'In Arbeit' },
implemented: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Implementiert' },
verified: { bg: 'bg-green-100', text: 'text-green-700', label: 'Verifiziert' },
not_applicable: { bg: 'bg-slate-50', text: 'text-slate-500', label: 'N/A' },
}
const PRIORITY_CONFIG: Record<number, { bg: string; text: string; label: string }> = {
1: { bg: 'bg-red-100', text: 'text-red-700', label: 'Kritisch' },
2: { bg: 'bg-orange-100', text: 'text-orange-700', label: 'Hoch' },
3: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Mittel' },
}
const REGULATION_TYPE_LABELS: Record<string, string> = {
eu_regulation: 'EU-Verordnung',
eu_directive: 'EU-Richtlinie',
de_law: 'DE Gesetz',
bsi_standard: 'BSI Standard',
}
export default function RequirementsPage() {
const [regulations, setRegulations] = useState<Regulation[]>([])
const [requirements, setRequirements] = useState<Requirement[]>([])
const [selectedRegulation, setSelectedRegulation] = useState<string | null>(null)
const [selectedRequirement, setSelectedRequirement] = useState<Requirement | null>(null)
const [loading, setLoading] = useState(true)
const [requirementsLoading, setRequirementsLoading] = useState(false)
// Filters
const [statusFilter, setStatusFilter] = useState<string>('')
const [priorityFilter, setPriorityFilter] = useState<string>('')
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
loadRegulations()
}, [])
useEffect(() => {
if (selectedRegulation) {
loadRequirements(selectedRegulation)
}
}, [selectedRegulation])
const loadRegulations = async () => {
setLoading(true)
try {
const res = await fetch('/api/admin/compliance/regulations')
if (res.ok) {
const data = await res.json()
setRegulations(data.regulations || data || [])
}
} catch (err) {
console.error('Failed to load regulations:', err)
} finally {
setLoading(false)
}
}
const loadRequirements = async (regulationCode: string) => {
setRequirementsLoading(true)
try {
const params = new URLSearchParams({ regulation_code: regulationCode })
if (statusFilter) params.set('status', statusFilter)
if (priorityFilter) params.set('priority', priorityFilter)
if (searchQuery) params.set('search', searchQuery)
const res = await fetch(`/api/admin/compliance/requirements?${params}`)
if (res.ok) {
const data = await res.json()
setRequirements(data.requirements || data || [])
}
} catch (err) {
console.error('Failed to load requirements:', err)
} finally {
setRequirementsLoading(false)
}
}
const filteredRequirements = requirements.filter(req => {
if (statusFilter && req.implementation_status !== statusFilter) return false
if (priorityFilter && req.priority !== parseInt(priorityFilter)) return false
if (searchQuery) {
const query = searchQuery.toLowerCase()
return (
req.title.toLowerCase().includes(query) ||
req.article.toLowerCase().includes(query) ||
req.description?.toLowerCase().includes(query)
)
}
return true
})
const totalRequirements = regulations.reduce((sum, r) => sum + (r.requirement_count || 0), 0)
return (
<div className="space-y-6">
<PagePurpose
title="Requirements & Anforderungen"
purpose="Uebersicht aller 558+ Compliance-Anforderungen aus 19 Verordnungen (DSGVO, AI Act, CRA, BSI-TR-03161, etc.). Sehen Sie den Implementation-Status und wie Breakpilot jede Anforderung erfuellt."
audience={['DSB', 'Compliance Officer', 'Entwickler', 'Auditoren']}
gdprArticles={['Art. 5 (Rechenschaftspflicht)', 'Art. 24 (Verantwortung)']}
architecture={{
services: ['Python Backend', 'PostgreSQL'],
databases: ['compliance_regulations', 'compliance_requirements', 'compliance_control_mappings'],
}}
relatedPages={[
{ name: 'Compliance Hub', href: '/compliance/hub', description: 'Dashboard & Uebersicht' },
{ name: 'Controls', href: '/compliance/controls', description: 'Technische Massnahmen' },
{ name: 'Audit Checklist', href: '/compliance/audit-checklist', description: 'Audit durchfuehren' },
]}
/>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl shadow-sm border p-4">
<p className="text-3xl font-bold text-purple-600">{regulations.length}</p>
<p className="text-sm text-slate-600">Verordnungen</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-4">
<p className="text-3xl font-bold text-blue-600">{totalRequirements}</p>
<p className="text-sm text-slate-600">Anforderungen</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-4">
<p className="text-3xl font-bold text-green-600">
{regulations.filter(r => r.regulation_type === 'eu_regulation').length}
</p>
<p className="text-sm text-slate-600">EU-Verordnungen</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-4">
<p className="text-3xl font-bold text-orange-600">
{regulations.filter(r => r.regulation_type === 'bsi_standard').length}
</p>
<p className="text-sm text-slate-600">BSI Standards</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Regulations List */}
<div className="lg:col-span-1 space-y-4">
<h2 className="text-lg font-semibold text-slate-900">Verordnungen & Standards</h2>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<div className="space-y-2 max-h-[70vh] overflow-y-auto pr-2">
{regulations.map((reg) => (
<div
key={reg.id}
onClick={() => setSelectedRegulation(reg.code)}
className={`p-4 rounded-lg border cursor-pointer transition-colors ${
selectedRegulation === reg.code
? 'border-purple-500 bg-purple-50'
: 'border-slate-200 hover:border-slate-300 bg-white'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-mono font-bold text-purple-600">{reg.code}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${
reg.regulation_type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' :
reg.regulation_type === 'eu_directive' ? 'bg-indigo-100 text-indigo-700' :
reg.regulation_type === 'bsi_standard' ? 'bg-orange-100 text-orange-700' :
'bg-slate-100 text-slate-700'
}`}>
{REGULATION_TYPE_LABELS[reg.regulation_type] || reg.regulation_type}
</span>
</div>
<h3 className="font-medium text-slate-900 text-sm">{reg.name}</h3>
<p className="text-xs text-slate-500 mt-1 line-clamp-2">{reg.description}</p>
<div className="flex items-center justify-between mt-3">
<span className="text-xs text-slate-600">
{reg.requirement_count || 0} Anforderungen
</span>
{reg.source_url && (
<a
href={reg.source_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-xs text-purple-600 hover:text-purple-700 flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Original
</a>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Requirements List */}
<div className="lg:col-span-2 space-y-4">
{!selectedRegulation ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 mx-auto text-slate-300 mb-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>
<h3 className="text-lg font-medium text-slate-900">Waehlen Sie eine Verordnung</h3>
<p className="text-slate-500 mt-2">Waehlen Sie eine Verordnung aus der Liste um deren Anforderungen zu sehen.</p>
</div>
) : (
<>
{/* Filters */}
<div className="flex flex-wrap gap-4 items-center">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suche in Anforderungen..."
className="flex-1 min-w-[200px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Alle Status</option>
<option value="not_started">Nicht begonnen</option>
<option value="in_progress">In Arbeit</option>
<option value="implemented">Implementiert</option>
<option value="verified">Verifiziert</option>
<option value="not_applicable">N/A</option>
</select>
<select
value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value)}
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Alle Prioritaeten</option>
<option value="1">Kritisch</option>
<option value="2">Hoch</option>
<option value="3">Mittel</option>
</select>
</div>
{/* Requirements Table */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
{requirementsLoading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : filteredRequirements.length === 0 ? (
<div className="text-center py-12 text-slate-500">
{requirements.length === 0 ? (
<>
<p className="font-medium">Keine Anforderungen gefunden</p>
<p className="text-sm mt-2">Starten Sie den Scraper um Anforderungen zu extrahieren.</p>
<button
onClick={() => {/* TODO: Trigger scraper */}}
className="mt-4 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Anforderungen extrahieren
</button>
</>
) : (
<p>Keine Anforderungen entsprechen den Filterkriterien</p>
)}
</div>
) : (
<div className="divide-y divide-slate-200">
{filteredRequirements.map((req) => {
const statusConfig = STATUS_CONFIG[req.implementation_status] || STATUS_CONFIG.not_started
const priorityConfig = PRIORITY_CONFIG[req.priority] || PRIORITY_CONFIG[2]
return (
<div
key={req.id}
className={`p-4 hover:bg-slate-50 cursor-pointer transition-colors ${
selectedRequirement?.id === req.id ? 'bg-purple-50' : ''
}`}
onClick={() => setSelectedRequirement(selectedRequirement?.id === req.id ? null : req)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm font-medium text-purple-600">
{req.article}
</span>
{req.paragraph && (
<span className="text-xs text-slate-500">({req.paragraph})</span>
)}
<span className={`px-2 py-0.5 text-xs rounded-full ${priorityConfig.bg} ${priorityConfig.text}`}>
{priorityConfig.label}
</span>
</div>
<h4 className="font-medium text-slate-900">{req.title}</h4>
{req.description && (
<p className="text-sm text-slate-600 mt-1 line-clamp-2">{req.description}</p>
)}
</div>
<div className="flex flex-col items-end gap-2 ml-4">
<span className={`px-3 py-1 text-xs rounded-full ${statusConfig.bg} ${statusConfig.text}`}>
{statusConfig.label}
</span>
{req.controls_count > 0 && (
<span className="text-xs text-slate-500">
{req.controls_count} Controls
</span>
)}
</div>
</div>
{/* Expanded Details */}
{selectedRequirement?.id === req.id && (
<div className="mt-4 pt-4 border-t border-slate-200 space-y-4">
{req.requirement_text && (
<div>
<h5 className="text-xs font-medium text-slate-500 uppercase mb-1">Originaltext</h5>
<p className="text-sm text-slate-700 bg-slate-50 p-3 rounded-lg">
{req.requirement_text}
</p>
</div>
)}
{req.breakpilot_interpretation && (
<div>
<h5 className="text-xs font-medium text-slate-500 uppercase mb-1">Breakpilot Interpretation</h5>
<p className="text-sm text-slate-700 bg-purple-50 p-3 rounded-lg border border-purple-100">
{req.breakpilot_interpretation}
</p>
</div>
)}
{req.implementation_details && (
<div>
<h5 className="text-xs font-medium text-slate-500 uppercase mb-1">Implementation</h5>
<p className="text-sm text-slate-700 bg-green-50 p-3 rounded-lg border border-green-100">
{req.implementation_details}
</p>
</div>
)}
{req.code_references && req.code_references.length > 0 && (
<div>
<h5 className="text-xs font-medium text-slate-500 uppercase mb-1">Code-Referenzen</h5>
<div className="space-y-1">
{req.code_references.map((ref, idx) => (
<div key={idx} className="text-sm font-mono bg-slate-100 p-2 rounded">
<span className="text-purple-600">{ref.file}</span>
{ref.line && <span className="text-slate-500">:{ref.line}</span>}
{ref.description && (
<span className="text-slate-600 ml-2">- {ref.description}</span>
)}
</div>
))}
</div>
</div>
)}
{req.evidence_description && (
<div>
<h5 className="text-xs font-medium text-slate-500 uppercase mb-1">Nachweis</h5>
<p className="text-sm text-slate-700">{req.evidence_description}</p>
</div>
)}
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm border border-purple-600 text-purple-600 rounded-lg hover:bg-purple-50">
Mit Claude interpretieren
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Summary */}
{requirements.length > 0 && (
<div className="bg-slate-50 rounded-lg p-4 text-sm text-slate-600">
<p>
<strong>{filteredRequirements.length}</strong> von <strong>{requirements.length}</strong> Anforderungen angezeigt
{statusFilter && <span> (Status: {STATUS_CONFIG[statusFilter]?.label})</span>}
{priorityFilter && <span> (Prioritaet: {PRIORITY_CONFIG[parseInt(priorityFilter)]?.label})</span>}
</p>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,712 +0,0 @@
'use client'
/**
* Risk Matrix Page
*
* Features:
* - Visual 5x5 risk matrix
* - Risk list with CRUD
* - Risk assessment / update
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
interface Risk {
id: string
risk_id: string
title: string
description: string
category: string
likelihood: number
impact: number
inherent_risk: string
mitigating_controls: string[] | null
residual_likelihood: number | null
residual_impact: number | null
residual_risk: string | null
owner: string
status: string
treatment_plan: string
}
const RISK_COLORS: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
critical: 'bg-red-500',
}
const RISK_BG_COLORS: Record<string, string> = {
low: 'bg-green-100 border-green-300',
medium: 'bg-yellow-100 border-yellow-300',
high: 'bg-orange-100 border-orange-300',
critical: 'bg-red-100 border-red-300',
}
const STATUS_OPTIONS = [
{ value: 'open', label: 'Offen' },
{ value: 'mitigated', label: 'Mitigiert' },
{ value: 'accepted', label: 'Akzeptiert' },
{ value: 'transferred', label: 'Transferiert' },
]
const CATEGORY_OPTIONS = [
{ value: 'data_breach', label: 'Datenschutzverletzung' },
{ value: 'compliance_gap', label: 'Compliance-Luecke' },
{ value: 'vendor_risk', label: 'Lieferantenrisiko' },
{ value: 'operational', label: 'Betriebsrisiko' },
{ value: 'technical', label: 'Technisches Risiko' },
{ value: 'legal', label: 'Rechtliches Risiko' },
]
const calculateRiskLevel = (likelihood: number, impact: number): string => {
const score = likelihood * impact
if (score >= 20) return 'critical'
if (score >= 12) return 'high'
if (score >= 6) return 'medium'
return 'low'
}
export default function RisksPage() {
const [risks, setRisks] = useState<Risk[]>([])
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<'matrix' | 'list'>('matrix')
const [selectedRisk, setSelectedRisk] = useState<Risk | null>(null)
const [editModalOpen, setEditModalOpen] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [formData, setFormData] = useState({
risk_id: '',
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [] as string[],
residual_likelihood: null as number | null,
residual_impact: null as number | null,
})
useEffect(() => {
loadRisks()
}, [])
const loadRisks = async () => {
setLoading(true)
try {
const res = await fetch(`/api/admin/compliance/risks`)
if (res.ok) {
const data = await res.json()
setRisks(data.risks || [])
}
} catch (error) {
console.error('Failed to load risks:', error)
} finally {
setLoading(false)
}
}
const openCreateModal = () => {
setFormData({
risk_id: `RISK-${String(risks.length + 1).padStart(3, '0')}`,
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [],
residual_likelihood: null,
residual_impact: null,
})
setCreateModalOpen(true)
}
const openEditModal = (risk: Risk) => {
setSelectedRisk(risk)
setFormData({
risk_id: risk.risk_id,
title: risk.title,
description: risk.description || '',
category: risk.category,
likelihood: risk.likelihood,
impact: risk.impact,
owner: risk.owner || '',
treatment_plan: risk.treatment_plan || '',
status: risk.status,
mitigating_controls: risk.mitigating_controls || [],
residual_likelihood: risk.residual_likelihood,
residual_impact: risk.residual_impact,
})
setEditModalOpen(true)
}
const handleCreate = async () => {
setSaving(true)
try {
const res = await fetch(`/api/admin/compliance/risks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
risk_id: formData.risk_id,
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
mitigating_controls: formData.mitigating_controls,
}),
})
if (res.ok) {
setCreateModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Create failed:', error)
alert('Fehler beim Erstellen')
} finally {
setSaving(false)
}
}
const handleUpdate = async () => {
if (!selectedRisk) return
setSaving(true)
try {
const res = await fetch(`/api/admin/compliance/risks/${selectedRisk.risk_id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
status: formData.status,
mitigating_controls: formData.mitigating_controls,
residual_likelihood: formData.residual_likelihood,
residual_impact: formData.residual_impact,
}),
})
if (res.ok) {
setEditModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Update failed:', error)
alert('Fehler beim Aktualisieren')
} finally {
setSaving(false)
}
}
// Build matrix data structure
const buildMatrix = () => {
const matrix: Record<number, Record<number, Risk[]>> = {}
for (let l = 1; l <= 5; l++) {
matrix[l] = {}
for (let i = 1; i <= 5; i++) {
matrix[l][i] = []
}
}
risks.forEach((risk) => {
if (matrix[risk.likelihood] && matrix[risk.likelihood][risk.impact]) {
matrix[risk.likelihood][risk.impact].push(risk)
}
})
return matrix
}
// Statistics
const stats = {
total: risks.length,
critical: risks.filter(r => r.inherent_risk === 'critical').length,
high: risks.filter(r => r.inherent_risk === 'high').length,
medium: risks.filter(r => r.inherent_risk === 'medium').length,
low: risks.filter(r => r.inherent_risk === 'low').length,
open: risks.filter(r => r.status === 'open').length,
mitigated: risks.filter(r => r.status === 'mitigated').length,
}
const renderMatrix = () => {
const matrix = buildMatrix()
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Risk Matrix (Likelihood x Impact)</h3>
<div className="overflow-x-auto">
<div className="inline-block">
{/* Column headers (Impact) */}
<div className="flex ml-16">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="w-24 text-center text-sm font-medium text-slate-500 pb-2">
Impact {i}
</div>
))}
</div>
{/* Matrix rows */}
{[5, 4, 3, 2, 1].map((likelihood) => (
<div key={likelihood} className="flex items-center">
<div className="w-16 text-sm font-medium text-slate-500 text-right pr-2">
L{likelihood}
</div>
{[1, 2, 3, 4, 5].map((impact) => {
const level = calculateRiskLevel(likelihood, impact)
const cellRisks = matrix[likelihood][impact]
return (
<div
key={impact}
className={`w-24 h-20 border m-0.5 rounded flex flex-col items-center justify-center ${RISK_BG_COLORS[level]}`}
>
{cellRisks.length > 0 && (
<div className="flex flex-wrap gap-1 justify-center">
{cellRisks.map((r) => (
<button
key={r.id}
onClick={() => openEditModal(r)}
className={`px-2 py-0.5 text-xs font-medium rounded text-white ${RISK_COLORS[r.inherent_risk] || 'bg-slate-500'} hover:opacity-80`}
title={r.title}
>
{r.risk_id}
</button>
))}
</div>
)}
</div>
)
})}
</div>
))}
</div>
</div>
{/* Legend */}
<div className="flex gap-4 mt-6 pt-4 border-t">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded" />
<span className="text-sm text-slate-600">Low (1-5)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-500 rounded" />
<span className="text-sm text-slate-600">Medium (6-11)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-orange-500 rounded" />
<span className="text-sm text-slate-600">High (12-19)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-500 rounded" />
<span className="text-sm text-slate-600">Critical (20-25)</span>
</div>
</div>
</div>
)
}
const renderList = () => (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">L x I</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Risiko</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{risks.map((risk) => (
<tr key={risk.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-primary-600">{risk.risk_id}</span>
</td>
<td className="px-4 py-3">
<div>
<p className="font-medium text-slate-900">{risk.title}</p>
{risk.description && (
<p className="text-sm text-slate-500 truncate max-w-md">{risk.description}</p>
)}
</div>
</td>
<td className="px-4 py-3 text-center">
<span className="text-sm text-slate-600">
{CATEGORY_OPTIONS.find((c) => c.value === risk.category)?.label || risk.category}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-mono">{risk.likelihood} x {risk.impact}</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs font-medium rounded-full text-white ${RISK_COLORS[risk.inherent_risk] || 'bg-slate-500'}`}>
{risk.inherent_risk}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
risk.status === 'mitigated' ? 'bg-green-100 text-green-700' :
risk.status === 'accepted' ? 'bg-blue-100 text-blue-700' :
risk.status === 'transferred' ? 'bg-purple-100 text-purple-700' :
'bg-slate-100 text-slate-700'
}`}>
{STATUS_OPTIONS.find(s => s.value === risk.status)?.label || risk.status}
</span>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => openEditModal(risk)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
const renderForm = (isCreate: boolean) => (
<div className="space-y-4">
{isCreate && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Risk ID</label>
<input
type="text"
value={formData.risk_id}
onChange={(e) => setFormData({ ...formData, risk_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{CATEGORY_OPTIONS.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Likelihood (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.likelihood}
onChange={(e) => setFormData({ ...formData, likelihood: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.likelihood}</span>
<span>5</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Impact (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.impact}
onChange={(e) => setFormData({ ...formData, impact: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.impact}</span>
<span>5</span>
</div>
</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-600">
Berechnetes Risiko:{' '}
<span className={`font-medium px-2 py-0.5 rounded text-white ${RISK_COLORS[calculateRiskLevel(formData.likelihood, formData.impact)]}`}>
{calculateRiskLevel(formData.likelihood, formData.impact).toUpperCase()} ({formData.likelihood * formData.impact})
</span>
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verantwortlich</label>
<input
type="text"
value={formData.owner}
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
placeholder="z.B. CISO, DSB"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Behandlungsplan</label>
<textarea
value={formData.treatment_plan}
onChange={(e) => setFormData({ ...formData, treatment_plan: e.target.value })}
placeholder="Massnahmen zur Risikominderung..."
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
)
return (
<div className="min-h-screen bg-slate-50 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Risk Matrix</h1>
<p className="text-slate-600">Risikobewertung & Management</p>
</div>
<Link
href="/compliance/hub"
className="flex items-center gap-2 text-slate-600 hover:text-slate-800"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Compliance Hub
</Link>
</div>
{/* Page Purpose */}
<PagePurpose
title="Risk Matrix"
purpose="Die Risikomatrix visualisiert alle identifizierten Compliance- und Sicherheitsrisiken nach Eintrittswahrscheinlichkeit und Auswirkung. Hier werden Risiken bewertet, Behandlungsplaene erstellt und der Mitigationsstatus verfolgt."
audience={['CISO', 'DSB', 'Compliance Officer', 'Management']}
gdprArticles={['Art. 32 (Risikobewertung)', 'Art. 35 (DSFA)']}
architecture={{
services: ['Python Backend (FastAPI)', 'compliance_risks Modul'],
databases: ['PostgreSQL (compliance_risks Table)'],
}}
relatedPages={[
{ name: 'Controls', href: '/compliance/controls', description: 'Massnahmen zur Risikominderung' },
{ name: 'Audit Checklist', href: '/compliance/audit-checklist', description: 'Anforderungen pruefen' },
]}
/>
{/* Statistics Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 border border-slate-200">
<p className="text-sm text-slate-500">Gesamt</p>
<p className="text-2xl font-bold text-slate-900">{stats.total}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-red-200">
<p className="text-sm text-red-600">Critical</p>
<p className="text-2xl font-bold text-red-700">{stats.critical}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-orange-200">
<p className="text-sm text-orange-600">High</p>
<p className="text-2xl font-bold text-orange-700">{stats.high}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-yellow-200">
<p className="text-sm text-yellow-600">Medium</p>
<p className="text-2xl font-bold text-yellow-700">{stats.medium}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-green-200">
<p className="text-sm text-green-600">Low</p>
<p className="text-2xl font-bold text-green-700">{stats.low}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200">
<p className="text-sm text-slate-500">Offen</p>
<p className="text-2xl font-bold text-slate-700">{stats.open}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-blue-200">
<p className="text-sm text-blue-600">Mitigiert</p>
<p className="text-2xl font-bold text-blue-700">{stats.mitigated}</p>
</div>
</div>
{/* View Toggle & Actions */}
<div className="flex flex-wrap items-center gap-4 mb-6">
{/* View Toggle */}
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => setViewMode('matrix')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'matrix' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
Matrix
</button>
<button
onClick={() => setViewMode('list')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'list' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
Liste
</button>
</div>
<div className="flex-1" />
<button
onClick={openCreateModal}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Risiko hinzufuegen
</button>
</div>
{/* Content */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : risks.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" 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>
<p className="text-slate-500 mb-4">Keine Risiken erfasst</p>
<button
onClick={openCreateModal}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Erstes Risiko hinzufuegen
</button>
</div>
) : viewMode === 'matrix' ? (
renderMatrix()
) : (
renderList()
)}
{/* Create Modal */}
{createModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b">
<h3 className="text-lg font-semibold text-slate-900">Neues Risiko</h3>
</div>
<div className="p-6">
{renderForm(true)}
</div>
<div className="p-6 border-t bg-slate-50 flex justify-end gap-3">
<button
onClick={() => setCreateModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={saving}
>
Abbrechen
</button>
<button
onClick={handleCreate}
disabled={saving}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Erstellen...' : 'Erstellen'}
</button>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editModalOpen && selectedRisk && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b">
<h3 className="text-lg font-semibold text-slate-900">Risiko bearbeiten</h3>
<p className="text-sm text-slate-500 font-mono">{selectedRisk.risk_id}</p>
</div>
<div className="p-6">
{renderForm(false)}
</div>
<div className="p-6 border-t bg-slate-50 flex justify-end gap-3">
<button
onClick={() => setEditModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={saving}
>
Abbrechen
</button>
<button
onClick={handleUpdate}
disabled={saving}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,413 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
interface AuditLogEntry {
id: string
action: string
entity_type: string
entity_id?: string
old_value?: any
new_value?: any
user_email?: string
created_at: string
}
interface BlockedContentEntry {
id: string
url: string
domain: string
block_reason: string
rule_id?: string
details?: any
created_at: string
}
interface AuditTabProps {
apiBase: string
}
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
create: { label: 'Erstellt', color: 'bg-green-100 text-green-700' },
update: { label: 'Aktualisiert', color: 'bg-blue-100 text-blue-700' },
delete: { label: 'Geloescht', color: 'bg-red-100 text-red-700' },
}
const ENTITY_LABELS: Record<string, string> = {
source_policy: 'Policy',
allowed_source: 'Quelle',
operation_permission: 'Operation',
pii_rule: 'PII-Regel',
}
const BLOCK_REASON_LABELS: Record<string, { label: string; color: string }> = {
not_whitelisted: { label: 'Nicht in Whitelist', color: 'bg-amber-100 text-amber-700' },
pii_detected: { label: 'PII erkannt', color: 'bg-red-100 text-red-700' },
license_violation: { label: 'Lizenzverletzung', color: 'bg-orange-100 text-orange-700' },
training_forbidden: { label: 'Training verboten', color: 'bg-slate-800 text-white' },
}
export function AuditTab({ apiBase }: AuditTabProps) {
const [activeView, setActiveView] = useState<'changes' | 'blocked'>('changes')
// Audit logs
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([])
const [auditLoading, setAuditLoading] = useState(true)
const [auditTotal, setAuditTotal] = useState(0)
// Blocked content
const [blockedContent, setBlockedContent] = useState<BlockedContentEntry[]>([])
const [blockedLoading, setBlockedLoading] = useState(true)
const [blockedTotal, setBlockedTotal] = useState(0)
// Filters
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [entityFilter, setEntityFilter] = useState('')
// Export
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (activeView === 'changes') {
fetchAuditLogs()
} else {
fetchBlockedContent()
}
}, [activeView, dateFrom, dateTo, entityFilter])
const fetchAuditLogs = async () => {
try {
setAuditLoading(true)
const params = new URLSearchParams()
if (dateFrom) params.append('from', dateFrom)
if (dateTo) params.append('to', dateTo)
if (entityFilter) params.append('entity_type', entityFilter)
params.append('limit', '100')
const res = await fetch(`${apiBase}/v1/admin/policy-audit?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setAuditLogs(data.logs || [])
setAuditTotal(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setAuditLoading(false)
}
}
const fetchBlockedContent = async () => {
try {
setBlockedLoading(true)
const params = new URLSearchParams()
if (dateFrom) params.append('from', dateFrom)
if (dateTo) params.append('to', dateTo)
params.append('limit', '100')
const res = await fetch(`${apiBase}/v1/admin/blocked-content?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setBlockedContent(data.blocked || [])
setBlockedTotal(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setBlockedLoading(false)
}
}
const exportReport = async () => {
try {
setExporting(true)
const params = new URLSearchParams()
if (dateFrom) params.append('from', dateFrom)
if (dateTo) params.append('to', dateTo)
params.append('format', 'download')
const res = await fetch(`${apiBase}/v1/admin/compliance-report?${params}`)
if (!res.ok) throw new Error('Fehler beim Export')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `compliance-report-${new Date().toISOString().split('T')[0]}.json`
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 {
setExporting(false)
}
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* View Toggle & Filters */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex gap-2">
<button
onClick={() => setActiveView('changes')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeView === 'changes'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Aenderungshistorie
</button>
<button
onClick={() => setActiveView('blocked')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeView === 'blocked'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Blockierte URLs
</button>
</div>
<div className="flex-1 flex flex-wrap gap-4 items-center">
<div className="flex items-center gap-2">
<label className="text-sm text-slate-600">Von:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-slate-600">Bis:</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
{activeView === 'changes' && (
<select
value={entityFilter}
onChange={(e) => setEntityFilter(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
>
<option value="">Alle Typen</option>
<option value="source_policy">Policies</option>
<option value="allowed_source">Quellen</option>
<option value="operation_permission">Operations</option>
<option value="pii_rule">PII-Regeln</option>
</select>
)}
<button
onClick={exportReport}
disabled={exporting}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 flex items-center gap-2 ml-auto"
>
<svg className="w-4 h-4" 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>
{exporting ? 'Exportiere...' : 'JSON Export'}
</button>
</div>
</div>
{/* Changes View */}
{activeView === 'changes' && (
<>
{auditLoading ? (
<div className="text-center py-12 text-slate-500">Lade Audit-Log...</div>
) : auditLogs.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-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>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Eintraege vorhanden</h3>
<p className="text-sm text-slate-500">
Aenderungen werden hier protokolliert.
</p>
</div>
) : (
<div 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 text-sm text-slate-600">
{auditTotal} Eintraege gesamt
</div>
<div className="divide-y divide-slate-100">
{auditLogs.map((log) => {
const actionConfig = ACTION_LABELS[log.action] || { label: log.action, color: 'bg-slate-100 text-slate-700' }
return (
<div key={log.id} className="px-4 py-4 hover:bg-slate-50">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${actionConfig.color}`}>
{actionConfig.label}
</span>
<span className="text-sm text-slate-700">
{ENTITY_LABELS[log.entity_type] || log.entity_type}
</span>
{log.entity_id && (
<code className="text-xs bg-slate-100 px-2 py-1 rounded text-slate-500">
{log.entity_id.substring(0, 8)}...
</code>
)}
</div>
<div className="text-xs text-slate-500">
{formatDate(log.created_at)}
</div>
</div>
{log.user_email && (
<div className="mt-2 text-xs text-slate-500">
Benutzer: {log.user_email}
</div>
)}
{(log.old_value || log.new_value) && (
<div className="mt-2 flex gap-4 text-xs">
{log.old_value && (
<div className="flex-1 p-2 bg-red-50 rounded">
<div className="text-red-600 font-medium mb-1">Vorher:</div>
<pre className="text-red-700 overflow-x-auto">
{typeof log.old_value === 'string' ? log.old_value : JSON.stringify(log.old_value, null, 2)}
</pre>
</div>
)}
{log.new_value && (
<div className="flex-1 p-2 bg-green-50 rounded">
<div className="text-green-600 font-medium mb-1">Nachher:</div>
<pre className="text-green-700 overflow-x-auto">
{typeof log.new_value === 'string' ? log.new_value : JSON.stringify(log.new_value, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
</>
)}
{/* Blocked Content View */}
{activeView === 'blocked' && (
<>
{blockedLoading ? (
<div className="text-center py-12 text-slate-500">Lade blockierte URLs...</div>
) : blockedContent.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-green-300 mb-4" 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>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine blockierten URLs</h3>
<p className="text-sm text-slate-500">
Alle gecrawlten URLs waren in der Whitelist.
</p>
</div>
) : (
<div 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 text-sm text-slate-600">
{blockedTotal} blockierte URLs gesamt
</div>
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">URL</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Domain</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Grund</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Zeitpunkt</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{blockedContent.map((entry) => {
const reasonConfig = BLOCK_REASON_LABELS[entry.block_reason] || {
label: entry.block_reason,
color: 'bg-slate-100 text-slate-700',
}
return (
<tr key={entry.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<div className="text-sm text-slate-700 truncate max-w-md" title={entry.url}>
{entry.url}
</div>
</td>
<td className="px-4 py-3">
<code className="text-xs bg-slate-100 px-2 py-1 rounded">{entry.domain}</code>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${reasonConfig.color}`}>
{reasonConfig.label}
</span>
</td>
<td className="px-4 py-3 text-xs text-slate-500">
{formatDate(entry.created_at)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
{/* Auditor Info Box */}
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-blue-800">Fuer Auditoren</h3>
<p className="text-sm text-blue-700 mt-1">
Dieses Audit-Log ist unveraenderlich und protokolliert alle Aenderungen an der Quellen-Policy.
Jeder Eintrag enthaelt:
</p>
<ul className="text-sm text-blue-700 mt-2 list-disc list-inside">
<li>Zeitstempel der Aenderung</li>
<li>Art der Aenderung (Erstellen/Aendern/Loeschen)</li>
<li>Betroffene Entitaet und ID</li>
<li>Vorheriger und neuer Wert</li>
<li>E-Mail des Benutzers (falls angemeldet)</li>
</ul>
<p className="text-sm text-blue-600 mt-2 font-medium">
Der JSON-Export ist fuer die externe Pruefung und Archivierung geeignet.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,271 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
interface OperationPermission {
id: string
source_id: string
operation: string
is_allowed: boolean
requires_citation: boolean
notes?: string
}
interface SourceWithOperations {
id: string
domain: string
name: string
license: string
is_active: boolean
operations: OperationPermission[]
}
interface OperationsMatrixTabProps {
apiBase: string
}
const OPERATIONS = [
{ id: 'lookup', name: 'Lookup', description: 'Inhalt anzeigen/durchsuchen', icon: '🔍' },
{ id: 'rag', name: 'RAG', description: 'Retrieval Augmented Generation', icon: '🤖' },
{ id: 'training', name: 'Training', description: 'KI-Training (VERBOTEN)', icon: '🚫' },
{ id: 'export', name: 'Export', description: 'Daten exportieren', icon: '📤' },
]
export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
const [sources, setSources] = useState<SourceWithOperations[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [updating, setUpdating] = useState<string | null>(null)
useEffect(() => {
fetchMatrix()
}, [])
const fetchMatrix = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/operations-matrix`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setSources(data.sources || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const togglePermission = async (
source: SourceWithOperations,
operationId: string,
field: 'is_allowed' | 'requires_citation'
) => {
// Find the permission
const permission = source.operations.find((op) => op.operation === operationId)
if (!permission) return
// Block enabling training
if (operationId === 'training' && field === 'is_allowed' && !permission.is_allowed) {
setError('Training mit externen Daten ist VERBOTEN und kann nicht aktiviert werden.')
return
}
const updateId = `${permission.id}-${field}`
setUpdating(updateId)
try {
const newValue = !permission[field]
const res = await fetch(`${apiBase}/v1/admin/operations/${permission.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: newValue }),
})
if (!res.ok) {
const errData = await res.json()
throw new Error(errData.message || errData.error || 'Fehler beim Aktualisieren')
}
fetchMatrix()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setUpdating(null)
}
}
if (loading) {
return <div className="text-center py-12 text-slate-500">Lade Operations-Matrix...</div>
}
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Legend */}
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
<h3 className="font-medium text-slate-900 mb-3">Legende</h3>
<div className="flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-green-100 text-green-700 rounded">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</span>
<span className="text-slate-600">Erlaubt</span>
</div>
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-red-100 text-red-700 rounded">
<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>
</span>
<span className="text-slate-600">Verboten</span>
</div>
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-amber-100 text-amber-700 rounded text-xs">
Cite
</span>
<span className="text-slate-600">Zitation erforderlich</span>
</div>
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-slate-800 text-white rounded">
<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>
</span>
<span className="text-slate-600">System-gesperrt (Training)</span>
</div>
</div>
</div>
{/* Matrix Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-x-auto">
<table className="w-full min-w-[800px]">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Quelle</th>
{OPERATIONS.map((op) => (
<th key={op.id} className="text-center px-4 py-3">
<div className="flex flex-col items-center gap-1">
<span className="text-lg">{op.icon}</span>
<span className="text-xs font-medium text-slate-500 uppercase">{op.name}</span>
<span className="text-xs text-slate-400 font-normal">{op.description}</span>
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sources.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
Keine Quellen vorhanden
</td>
</tr>
) : (
sources.map((source) => (
<tr key={source.id} className={`hover:bg-slate-50 ${!source.is_active ? 'opacity-50' : ''}`}>
<td className="px-4 py-3">
<div>
<div className="font-medium text-slate-800">{source.name}</div>
<code className="text-xs text-slate-500">{source.domain}</code>
</div>
</td>
{OPERATIONS.map((op) => {
const permission = source.operations.find((p) => p.operation === op.id)
const isTraining = op.id === 'training'
const isAllowed = permission?.is_allowed ?? false
const requiresCitation = permission?.requires_citation ?? false
const isUpdating = updating === `${permission?.id}-is_allowed` || updating === `${permission?.id}-requires_citation`
return (
<td key={op.id} className="px-4 py-3 text-center">
<div className="flex flex-col items-center gap-2">
{/* Is Allowed Toggle */}
<button
onClick={() => togglePermission(source, op.id, 'is_allowed')}
disabled={isTraining || isUpdating || !source.is_active}
className={`w-10 h-10 flex items-center justify-center rounded transition-colors ${
isTraining
? 'bg-slate-800 text-white cursor-not-allowed'
: isAllowed
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-red-100 text-red-700 hover:bg-red-200'
} ${isUpdating ? 'opacity-50' : ''}`}
title={isTraining ? 'Training ist system-weit gesperrt' : isAllowed ? 'Klicken zum Deaktivieren' : 'Klicken zum Aktivieren'}
>
{isTraining ? (
<svg className="w-5 h-5" 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>
) : isAllowed ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<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>
{/* Citation Required Toggle (only for allowed non-training ops) */}
{isAllowed && !isTraining && (
<button
onClick={() => togglePermission(source, op.id, 'requires_citation')}
disabled={isUpdating || !source.is_active}
className={`px-2 py-1 text-xs rounded transition-colors ${
requiresCitation
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
} ${isUpdating ? 'opacity-50' : ''}`}
title={requiresCitation ? 'Zitation erforderlich - Klicken zum Aendern' : 'Klicken um Zitation zu erfordern'}
>
{requiresCitation ? 'Cite ✓' : 'Cite'}
</button>
)}
</div>
</td>
)
})}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Training Warning */}
<div className="mt-6 bg-red-50 border border-red-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<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 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>
<h3 className="font-semibold text-red-800">Training-Operation: System-gesperrt</h3>
<p className="text-sm text-red-700 mt-1">
Das Training von KI-Modellen mit gecrawlten externen Daten ist aufgrund von Urheberrechts- und
Datenschutzbestimmungen grundsaetzlich verboten. Diese Einschraenkung ist im System hart kodiert
und kann nicht ueber diese Oberflaeche geaendert werden.
</p>
<p className="text-sm text-red-600 mt-2 font-medium">
Ausnahmen erfordern eine schriftliche Genehmigung des DSB und eine rechtliche Pruefung.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,562 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
interface PIIRule {
id: string
name: string
rule_type: string
pattern: string
severity: string
is_active: boolean
created_at: string
updated_at: string
}
interface PIIMatch {
rule_id: string
rule_name: string
rule_type: string
severity: string
match: string
start_index: number
end_index: number
}
interface PIITestResult {
has_pii: boolean
matches: PIIMatch[]
should_block: boolean
block_level: string
}
interface PIIRulesTabProps {
apiBase: string
onUpdate?: () => void
}
const RULE_TYPES = [
{ value: 'regex', label: 'Regex (Muster)' },
{ value: 'keyword', label: 'Keyword (Stichwort)' },
]
const SEVERITIES = [
{ value: 'warn', label: 'Warnung', color: 'bg-amber-100 text-amber-700' },
{ value: 'redact', label: 'Schwärzen', color: 'bg-orange-100 text-orange-700' },
{ value: 'block', label: 'Blockieren', color: 'bg-red-100 text-red-700' },
]
export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const [rules, setRules] = useState<PIIRule[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Test panel
const [testText, setTestText] = useState('')
const [testResult, setTestResult] = useState<PIITestResult | null>(null)
const [testing, setTesting] = useState(false)
// Edit modal
const [editingRule, setEditingRule] = useState<PIIRule | null>(null)
const [isNewRule, setIsNewRule] = useState(false)
const [saving, setSaving] = useState(false)
// New rule form
const [newRule, setNewRule] = useState({
name: '',
rule_type: 'regex',
pattern: '',
severity: 'block',
is_active: true,
})
useEffect(() => {
fetchRules()
}, [])
const fetchRules = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setRules(data.rules || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const createRule = async () => {
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newRule),
})
if (!res.ok) throw new Error('Fehler beim Erstellen')
setNewRule({
name: '',
rule_type: 'regex',
pattern: '',
severity: 'block',
is_active: true,
})
setIsNewRule(false)
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const updateRule = async () => {
if (!editingRule) return
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${editingRule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingRule),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren')
setEditingRule(null)
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const deleteRule = async (id: string) => {
if (!confirm('Regel wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
try {
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${id}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Fehler beim Loeschen')
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const toggleRuleStatus = async (rule: PIIRule) => {
try {
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${rule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !rule.is_active }),
})
if (!res.ok) throw new Error('Fehler beim Aendern des Status')
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const runTest = async () => {
if (!testText) return
try {
setTesting(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: testText }),
})
if (!res.ok) throw new Error('Fehler beim Testen')
const data = await res.json()
setTestResult(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setTesting(false)
}
}
const getSeverityBadge = (severity: string) => {
const config = SEVERITIES.find((s) => s.value === severity)
return (
<span className={`px-2 py-1 rounded text-xs font-medium ${config?.color || 'bg-slate-100 text-slate-700'}`}>
{config?.label || severity}
</span>
)
}
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Test Panel */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<h3 className="font-semibold text-slate-900 mb-4">PII-Test</h3>
<p className="text-sm text-slate-600 mb-4">
Testen Sie, ob ein Text personenbezogene Daten (PII) enthaelt.
</p>
<textarea
value={testText}
onChange={(e) => setTestText(e.target.value)}
placeholder="Geben Sie hier einen Text zum Testen ein...
Beispiel:
Kontaktieren Sie mich unter max.mustermann@example.com oder
rufen Sie mich an unter +49 170 1234567.
Meine IBAN ist DE89 3704 0044 0532 0130 00."
rows={6}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
/>
<div className="flex justify-between items-center mt-4">
<button
onClick={() => {
setTestText('')
setTestResult(null)
}}
className="text-sm text-slate-500 hover:text-slate-700"
>
Zuruecksetzen
</button>
<button
onClick={runTest}
disabled={testing || !testText}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{testing ? 'Teste...' : 'Testen'}
</button>
</div>
{/* Test Results */}
{testResult && (
<div className={`mt-4 p-4 rounded-lg ${testResult.should_block ? 'bg-red-50 border border-red-200' : testResult.has_pii ? 'bg-amber-50 border border-amber-200' : 'bg-green-50 border border-green-200'}`}>
<div className="flex items-center gap-2 mb-3">
{testResult.should_block ? (
<>
<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-red-800">Blockiert - Kritische PII gefunden</span>
</>
) : testResult.has_pii ? (
<>
<svg className="w-5 h-5 text-amber-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-amber-800">Warnung - PII gefunden ({testResult.matches.length} Treffer)</span>
</>
) : (
<>
<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="M5 13l4 4L19 7" />
</svg>
<span className="font-medium text-green-800">Keine PII gefunden</span>
</>
)}
</div>
{testResult.matches.length > 0 && (
<div className="space-y-2">
{testResult.matches.map((match, idx) => (
<div key={idx} className="flex items-center gap-3 text-sm bg-white bg-opacity-50 rounded px-3 py-2">
{getSeverityBadge(match.severity)}
<span className="text-slate-700 font-medium">{match.rule_name}</span>
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
{match.match.length > 30 ? match.match.substring(0, 30) + '...' : match.match}
</code>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Rules List Header */}
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-slate-900">PII-Erkennungsregeln</h3>
<button
onClick={() => setIsNewRule(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 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="M12 4v16m8-8H4" />
</svg>
Neue Regel
</button>
</div>
{/* Rules Table */}
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Regeln...</div>
) : rules.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-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>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Regeln vorhanden</h3>
<p className="text-sm text-slate-500">
Fuegen Sie PII-Erkennungsregeln hinzu.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Muster</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Severity</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rules.map((rule) => (
<tr key={rule.id} className="hover:bg-slate-50">
<td className="px-4 py-3 text-sm font-medium text-slate-800">{rule.name}</td>
<td className="px-4 py-3">
<span className="text-xs bg-slate-100 text-slate-700 px-2 py-1 rounded">
{rule.rule_type}
</span>
</td>
<td className="px-4 py-3">
<code className="text-xs bg-slate-100 px-2 py-1 rounded max-w-xs truncate block">
{rule.pattern.length > 40 ? rule.pattern.substring(0, 40) + '...' : rule.pattern}
</code>
</td>
<td className="px-4 py-3">{getSeverityBadge(rule.severity)}</td>
<td className="px-4 py-3">
<button
onClick={() => toggleRuleStatus(rule)}
className={`text-xs px-2 py-1 rounded ${
rule.is_active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{rule.is_active ? 'Aktiv' : 'Inaktiv'}
</button>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setEditingRule(rule)}
className="text-purple-600 hover:text-purple-700 mr-3"
>
Bearbeiten
</button>
<button
onClick={() => deleteRule(rule.id)}
className="text-red-600 hover:text-red-700"
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* New Rule Modal */}
{isNewRule && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neue PII-Regel</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newRule.name}
onChange={(e) => setNewRule({ ...newRule, name: e.target.value })}
placeholder="z.B. Deutsche Telefonnummern"
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">Typ *</label>
<select
value={newRule.rule_type}
onChange={(e) => setNewRule({ ...newRule, rule_type: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{RULE_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster *</label>
<textarea
value={newRule.pattern}
onChange={(e) => setNewRule({ ...newRule, pattern: e.target.value })}
placeholder={newRule.rule_type === 'regex' ? 'Regex-Muster, z.B. (?:\\+49|0)[\\s.-]?\\d{2,4}...' : 'Keywords getrennt durch Komma, z.B. password,secret,api_key'}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Severity *</label>
<select
value={newRule.severity}
onChange={(e) => setNewRule({ ...newRule, severity: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{SEVERITIES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setIsNewRule(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={createRule}
disabled={saving || !newRule.name || !newRule.pattern}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Erstellen'}
</button>
</div>
</div>
</div>
)}
{/* Edit Rule Modal */}
{editingRule && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">PII-Regel bearbeiten</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={editingRule.name}
onChange={(e) => setEditingRule({ ...editingRule, name: e.target.value })}
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">Typ</label>
<select
value={editingRule.rule_type}
onChange={(e) => setEditingRule({ ...editingRule, rule_type: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{RULE_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster *</label>
<textarea
value={editingRule.pattern}
onChange={(e) => setEditingRule({ ...editingRule, pattern: e.target.value })}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Severity</label>
<select
value={editingRule.severity}
onChange={(e) => setEditingRule({ ...editingRule, severity: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{SEVERITIES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="edit_is_active"
checked={editingRule.is_active}
onChange={(e) => setEditingRule({ ...editingRule, is_active: e.target.checked })}
className="w-4 h-4 text-purple-600"
/>
<label htmlFor="edit_is_active" className="text-sm text-slate-700">
Aktiv
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setEditingRule(null)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={updateRule}
disabled={saving}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,525 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
interface AllowedSource {
id: string
policy_id: string
domain: string
name: string
license: string
legal_basis?: string
citation_template?: string
trust_boost: number
is_active: boolean
created_at: string
updated_at: string
}
interface SourcesTabProps {
apiBase: string
onUpdate?: () => void
}
const LICENSES = [
{ value: 'DL-DE-BY-2.0', label: 'Datenlizenz Deutschland' },
{ value: 'CC-BY', label: 'Creative Commons BY' },
{ value: 'CC-BY-SA', label: 'Creative Commons BY-SA' },
{ value: 'CC0', label: 'Public Domain' },
{ value: '§5 UrhG', label: 'Amtliche Werke (§5 UrhG)' },
]
const BUNDESLAENDER = [
{ value: '', label: 'Bundesebene' },
{ value: 'NI', label: 'Niedersachsen' },
{ value: 'BY', label: 'Bayern' },
{ value: 'BW', label: 'Baden-Wuerttemberg' },
{ value: 'NW', label: 'Nordrhein-Westfalen' },
{ value: 'HE', label: 'Hessen' },
{ value: 'SN', label: 'Sachsen' },
{ value: 'BE', label: 'Berlin' },
{ value: 'HH', label: 'Hamburg' },
]
export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
const [sources, setSources] = useState<AllowedSource[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filters
const [searchTerm, setSearchTerm] = useState('')
const [licenseFilter, setLicenseFilter] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
// Edit modal
const [editingSource, setEditingSource] = useState<AllowedSource | null>(null)
const [isNewSource, setIsNewSource] = useState(false)
const [saving, setSaving] = useState(false)
// New source form
const [newSource, setNewSource] = useState({
domain: '',
name: '',
license: 'DL-DE-BY-2.0',
legal_basis: '',
citation_template: '',
trust_boost: 0.5,
is_active: true,
policy_id: '', // Will be set from policies
})
useEffect(() => {
fetchSources()
}, [licenseFilter, statusFilter])
const fetchSources = async () => {
try {
setLoading(true)
const params = new URLSearchParams()
if (licenseFilter) params.append('license', licenseFilter)
if (statusFilter !== 'all') params.append('active_only', statusFilter === 'active' ? 'true' : 'false')
const res = await fetch(`${apiBase}/v1/admin/sources?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setSources(data.sources || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const createSource = async () => {
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/sources`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSource),
})
if (!res.ok) throw new Error('Fehler beim Erstellen')
setNewSource({
domain: '',
name: '',
license: 'DL-DE-BY-2.0',
legal_basis: '',
citation_template: '',
trust_boost: 0.5,
is_active: true,
policy_id: '',
})
setIsNewSource(false)
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const updateSource = async () => {
if (!editingSource) return
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/sources/${editingSource.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingSource),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren')
setEditingSource(null)
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const deleteSource = async (id: string) => {
if (!confirm('Quelle wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
try {
const res = await fetch(`${apiBase}/v1/admin/sources/${id}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Fehler beim Loeschen')
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const toggleSourceStatus = async (source: AllowedSource) => {
try {
const res = await fetch(`${apiBase}/v1/admin/sources/${source.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !source.is_active }),
})
if (!res.ok) throw new Error('Fehler beim Aendern des Status')
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const filteredSources = sources.filter((source) => {
if (searchTerm) {
const term = searchTerm.toLowerCase()
if (!source.domain.toLowerCase().includes(term) && !source.name.toLowerCase().includes(term)) {
return false
}
}
return true
})
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Filters & Actions */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex-1">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Domain oder Name suchen..."
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<select
value={licenseFilter}
onChange={(e) => setLicenseFilter(e.target.value)}
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Alle Lizenzen</option>
{LICENSES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="all">Alle Status</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
<button
onClick={() => setIsNewSource(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 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="M12 4v16m8-8H4" />
</svg>
Neue Quelle
</button>
</div>
{/* Sources Table */}
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Quellen...</div>
) : filteredSources.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-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>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Quellen gefunden</h3>
<p className="text-sm text-slate-500">
Fuegen Sie neue Quellen zur Whitelist hinzu.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Domain</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Lizenz</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Trust</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredSources.map((source) => (
<tr key={source.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<code className="text-sm bg-slate-100 px-2 py-1 rounded">{source.domain}</code>
</td>
<td className="px-4 py-3 text-sm text-slate-700">{source.name}</td>
<td className="px-4 py-3">
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">
{source.license}
</span>
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{(source.trust_boost * 100).toFixed(0)}%
</td>
<td className="px-4 py-3">
<button
onClick={() => toggleSourceStatus(source)}
className={`text-xs px-2 py-1 rounded ${
source.is_active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{source.is_active ? 'Aktiv' : 'Inaktiv'}
</button>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setEditingSource(source)}
className="text-purple-600 hover:text-purple-700 mr-3"
>
Bearbeiten
</button>
<button
onClick={() => deleteSource(source.id)}
className="text-red-600 hover:text-red-700"
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* New Source Modal */}
{isNewSource && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neue Quelle hinzufuegen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Domain *</label>
<input
type="text"
value={newSource.domain}
onChange={(e) => setNewSource({ ...newSource, domain: e.target.value })}
placeholder="z.B. nibis.de"
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">Name *</label>
<input
type="text"
value={newSource.name}
onChange={(e) => setNewSource({ ...newSource, name: e.target.value })}
placeholder="z.B. NiBiS Bildungsserver"
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">Lizenz *</label>
<select
value={newSource.license}
onChange={(e) => setNewSource({ ...newSource, license: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{LICENSES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage</label>
<input
type="text"
value={newSource.legal_basis}
onChange={(e) => setNewSource({ ...newSource, legal_basis: e.target.value })}
placeholder="z.B. §5 UrhG (Amtliche Werke)"
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">Trust Boost</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={newSource.trust_boost}
onChange={(e) => setNewSource({ ...newSource, trust_boost: parseFloat(e.target.value) })}
className="w-full"
/>
<div className="text-xs text-slate-500 text-right">
{(newSource.trust_boost * 100).toFixed(0)}%
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setIsNewSource(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={createSource}
disabled={saving || !newSource.domain || !newSource.name}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Erstellen'}
</button>
</div>
</div>
</div>
)}
{/* Edit Source Modal */}
{editingSource && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Quelle bearbeiten</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Domain</label>
<input
type="text"
value={editingSource.domain}
disabled
className="w-full px-4 py-2 border border-slate-200 rounded-lg bg-slate-50 text-slate-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={editingSource.name}
onChange={(e) => setEditingSource({ ...editingSource, name: e.target.value })}
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">Lizenz *</label>
<select
value={editingSource.license}
onChange={(e) => setEditingSource({ ...editingSource, license: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{LICENSES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage</label>
<input
type="text"
value={editingSource.legal_basis || ''}
onChange={(e) => setEditingSource({ ...editingSource, legal_basis: e.target.value })}
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">Zitiervorlage</label>
<input
type="text"
value={editingSource.citation_template || ''}
onChange={(e) => setEditingSource({ ...editingSource, citation_template: e.target.value })}
placeholder="Quelle: {source}, {title}, {date}"
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">Trust Boost</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={editingSource.trust_boost}
onChange={(e) => setEditingSource({ ...editingSource, trust_boost: parseFloat(e.target.value) })}
className="w-full"
/>
<div className="text-xs text-slate-500 text-right">
{(editingSource.trust_boost * 100).toFixed(0)}%
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_active"
checked={editingSource.is_active}
onChange={(e) => setEditingSource({ ...editingSource, is_active: e.target.checked })}
className="w-4 h-4 text-purple-600"
/>
<label htmlFor="is_active" className="text-sm text-slate-700">
Aktiv
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setEditingSource(null)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={updateSource}
disabled={saving}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,804 +0,0 @@
'use client'
/**
* Source Policy Management Page
*
* Whitelist-based data source management for edu-search-service.
* For auditors: Full audit trail for all changes.
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { SourcesTab } from './components/SourcesTab'
import { OperationsMatrixTab } from './components/OperationsMatrixTab'
import { PIIRulesTab } from './components/PIIRulesTab'
import { AuditTab } from './components/AuditTab'
// API base URL for edu-search-service
// Uses nginx HTTPS proxy on port 8089 when accessed remotely
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'
}
// Use nginx HTTPS proxy on port 8089 (proxies to edu-search-service: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 [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(() => {
// Set API base on client side - only runs in browser
const base = getApiBase()
setApiBase(base)
}, [])
useEffect(() => {
// Only fetch when apiBase has been set by the first 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')
// Set default stats on error
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>
{/* Page Purpose */}
<PagePurpose
title="Quellen-Policy"
purpose="Whitelist-basiertes Datenquellen-Management fuer das Bildungssuch-System. Nur offizielle Open-Data-Portale und amtliche Quellen (§5 UrhG). Training mit externen Daten ist VERBOTEN. Fuer Auditoren pruefbar mit vollstaendigem Audit-Trail."
audience={['DSB', 'Compliance Officer', 'Auditor']}
gdprArticles={[
'Art. 5 (Rechtmaessigkeit)',
'Art. 6 (Rechtsgrundlage)',
'Art. 24 (Verantwortung)',
]}
architecture={{
services: ['edu-search-service (Go)', 'PostgreSQL'],
databases: ['source_policies', 'allowed_sources', 'pii_rules', 'policy_audit_log'],
}}
relatedPages={[
{ name: 'Audit Report', href: '/compliance/audit-report', description: 'Compliance-Berichte' },
{ name: 'Controls', href: '/compliance/controls', description: 'Technische Kontrollen' },
{ name: 'Education Search', href: '/education/edu-search', description: 'Bildungsquellen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<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 mb-6 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' && (
<DashboardTab stats={stats} loading={loading} apiBase={apiBase} />
)}
{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>
)
}
// Dashboard Tab Component
function DashboardTab({
stats,
loading,
apiBase,
}: {
stats: PolicyStats | null
loading: boolean
apiBase: string
}) {
const [complianceCheck, setComplianceCheck] = useState({
url: '',
operation: 'lookup',
})
const [checkResult, setCheckResult] = useState<any>(null)
const [checking, setChecking] = useState(false)
const runComplianceCheck = async () => {
if (!complianceCheck.url) return
try {
setChecking(true)
const res = await fetch(`${apiBase}/v1/admin/check-compliance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(complianceCheck),
})
const data = await res.json()
setCheckResult(data)
} catch (err) {
setCheckResult({ error: 'Fehler bei der Pruefung' })
} finally {
setChecking(false)
}
}
if (loading) {
return <div className="text-center py-12 text-slate-500">Lade Dashboard...</div>
}
return (
<div className="space-y-6">
{/* Important Notice */}
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<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>
<h3 className="font-semibold text-red-800">Training mit externen Daten: VERBOTEN</h3>
<p className="text-sm text-red-700 mt-1">
Gemaess unserer Datenschutz-Policy ist das Training von KI-Modellen mit gecrawlten Daten
strengstens untersagt. Diese Einschraenkung kann nicht ueber die UI geaendert werden.
</p>
</div>
</div>
</div>
{/* Quick Compliance Check */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Schnell-Pruefung</h3>
<p className="text-sm text-slate-600 mb-4">
Pruefen Sie, ob eine URL in der Whitelist enthalten ist und welche Operationen erlaubt sind.
</p>
<div className="flex flex-col md:flex-row gap-4">
<input
type="url"
value={complianceCheck.url}
onChange={(e) => setComplianceCheck({ ...complianceCheck, url: e.target.value })}
placeholder="https://nibis.de/beispiel-seite"
className="flex-1 px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<select
value={complianceCheck.operation}
onChange={(e) => setComplianceCheck({ ...complianceCheck, operation: e.target.value })}
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="lookup">Lookup (Anzeigen)</option>
<option value="rag">RAG (Retrieval)</option>
<option value="export">Export</option>
<option value="training">Training</option>
</select>
<button
onClick={runComplianceCheck}
disabled={checking || !complianceCheck.url}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{checking ? 'Pruefe...' : 'Pruefen'}
</button>
</div>
{checkResult && (
<div className={`mt-4 p-4 rounded-lg ${checkResult.is_allowed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<div className="flex items-center gap-2 mb-2">
{checkResult.is_allowed ? (
<>
<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="M5 13l4 4L19 7" />
</svg>
<span className="font-medium text-green-800">Erlaubt</span>
</>
) : (
<>
<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="M6 18L18 6M6 6l12 12" />
</svg>
<span className="font-medium text-red-800">
Blockiert: {checkResult.block_reason || 'Nicht in Whitelist'}
</span>
</>
)}
</div>
{checkResult.source && (
<div className="text-sm text-slate-600">
<p><strong>Quelle:</strong> {checkResult.source.name}</p>
<p><strong>Lizenz:</strong> {checkResult.license}</p>
{checkResult.requires_citation && (
<p className="text-amber-600">Zitation erforderlich</p>
)}
</div>
)}
</div>
)}
</div>
{/* Operations Matrix by Source Type */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Zulaessige Operationen nach Quellentyp</h3>
<p className="text-sm text-slate-600 mb-4">
Uebersicht welche Operationen fuer welche Datenquellen erlaubt sind.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="text-left py-2 px-3 font-medium text-slate-700">Quelle / Typ</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Daten</th>
<th className="text-center py-2 px-2 font-medium text-slate-700">Lookup</th>
<th className="text-center py-2 px-2 font-medium text-slate-700">RAG</th>
<th className="text-center py-2 px-2 font-medium text-slate-700">Training</th>
<th className="text-center py-2 px-2 font-medium text-slate-700">Export</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Rechtsgrundlage</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Auflagen / Controls</th>
</tr>
</thead>
<tbody>
{[
{ source: 'Landes-Open-Data-Portale (alle Laender)', data: 'SMD', lookup: 'yes', rag: 'yes', training: 'no', export: 'warn', basis: 'DL-DE-BY-2.0', note: 'Namensnennung, Quellenlink, Zweckbindung' },
{ source: 'Landes-Open-Data-Portale', data: 'PBD', lookup: 'no', rag: 'no', training: 'no', export: 'no', basis: '—', note: 'Technisch filtern (Schema-Block)' },
{ source: 'Regelwerke / Schulordnungen (Ministerien)', data: 'DOK', lookup: 'yes', rag: 'yes', training: 'warn', export: 'warn', basis: 'UrhG §5 / CC / DL', note: 'Nur amtliche Texte, Versions-Hash' },
{ source: 'GovData', data: 'SMD', lookup: 'yes', rag: 'yes', training: 'no', export: 'warn', basis: 'DL-DE-BY-2.0', note: 'Bundesweiter Fallback' },
{ source: 'Einzelschul-Websites', data: 'SMD', lookup: 'warn', rag: 'no', training: 'no', export: 'no', basis: '§60d greift nicht', note: 'Nur manuell, kein Crawling' },
{ source: 'Private Schulverzeichnisse', data: 'SMD', lookup: 'no', rag: 'no', training: 'no', export: 'no', basis: 'Datenbankrecht', note: 'Nicht zulaessig' },
{ source: 'Vom Lehrer eingegebene Daten', data: 'SMD', lookup: 'yes', rag: 'yes', training: 'warn', export: 'warn', basis: 'Art. 6(1)b DSGVO', note: 'Zweckbindung, Namespace' },
{ source: 'Vom Lehrer hochgeladene Dokumente', data: 'DOK', lookup: 'yes', rag: 'yes', training: 'no', export: 'no', basis: 'Art. 6(1)b DSGVO', note: 'Kein Training, nur Session-RAG' },
].map((row, idx) => (
<tr key={idx} className={`border-b border-slate-100 hover:bg-slate-50 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/30'}`}>
<td className="py-2 px-3 font-medium text-slate-800 text-xs">{row.source}</td>
<td className="py-2 px-3">
<span className={`px-1.5 py-0.5 rounded text-xs ${
row.data === 'SMD' ? 'bg-blue-100 text-blue-700' : row.data === 'PBD' ? 'bg-red-100 text-red-700' : 'bg-purple-100 text-purple-700'
}`}>{row.data}</span>
</td>
<td className="py-2 px-2 text-center">{row.lookup === 'yes' ? '✅' : row.lookup === 'warn' ? '⚠️' : '❌'}</td>
<td className="py-2 px-2 text-center">{row.rag === 'yes' ? '✅' : row.rag === 'warn' ? '⚠️' : '❌'}</td>
<td className="py-2 px-2 text-center">{row.training === 'yes' ? '✅' : row.training === 'warn' ? '⚠️' : '❌'}</td>
<td className="py-2 px-2 text-center">{row.export === 'yes' ? '✅' : row.export === 'warn' ? '⚠️' : '❌'}</td>
<td className="py-2 px-3 text-xs">
<span className={`px-1.5 py-0.5 rounded ${
row.basis === '—' ? 'bg-slate-100 text-slate-500' :
row.basis.includes('DSGVO') ? 'bg-blue-100 text-blue-700' :
row.basis.includes('DL-DE') ? 'bg-green-100 text-green-700' :
row.basis.includes('UrhG') || row.basis.includes('CC') ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
}`}>{row.basis}</span>
</td>
<td className="py-2 px-3 text-slate-600 text-xs">{row.note}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Legend and Explanation */}
<div className="mt-4 p-4 bg-slate-50 rounded-lg">
<h4 className="font-medium text-slate-800 mb-3">Geltungsbereich der Matrix</h4>
{/* Datenarten */}
<div className="mb-4">
<div className="text-sm font-medium text-slate-700 mb-2">Datenarten</div>
<div className="flex flex-wrap gap-3 text-xs">
<span className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded">SMD</span>
<span className="text-slate-600">= Schul-Metadaten (Name, Nummer, Schulform, Ort, Traeger)</span>
</span>
<span className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded">PBD</span>
<span className="text-slate-600">= Personenbezogene Daten (Leitung, E-Mail, Telefon)</span>
</span>
<span className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded">DOK</span>
<span className="text-slate-600">= Regelwerke / Ordnungen / Lehrplaene</span>
</span>
</div>
</div>
{/* Verarbeitungsarten mit aufklappbarer Erklärung */}
<details className="group">
<summary className="cursor-pointer text-sm font-medium text-slate-700 mb-2 flex items-center gap-2 hover:text-purple-600">
<svg className="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
Verarbeitungsarten (Details anzeigen)
</summary>
<div className="ml-6 mt-2 space-y-3 text-sm">
<div className="p-3 bg-white rounded border border-slate-200">
<div className="font-medium text-slate-800 flex items-center gap-2">
<span className="text-green-600">Lookup</span>
<span className="text-slate-400">=</span>
<span className="text-slate-600">Auswahl / Validierung / Anzeige</span>
</div>
<p className="text-xs text-slate-500 mt-1">
Daten werden abgerufen und dem Nutzer angezeigt, z.B. bei der Schulauswahl im Onboarding
oder zur Validierung eingegebener Schulnummern. Keine dauerhafte Speicherung oder Weiterverarbeitung.
</p>
</div>
<div className="p-3 bg-white rounded border border-slate-200">
<div className="font-medium text-slate-800 flex items-center gap-2">
<span className="text-blue-600">RAG</span>
<span className="text-slate-400">=</span>
<span className="text-slate-600">Retrieval-Index (Kontext, Zitierquelle)</span>
</div>
<p className="text-xs text-slate-500 mt-1">
Daten werden in einen Vektor-Index aufgenommen und koennen als Kontext fuer KI-Antworten
herangezogen werden. Die Quelle wird zitiert. Keine Veraenderung der Modelgewichte.
</p>
</div>
<div className="p-3 bg-white rounded border border-slate-200">
<div className="font-medium text-slate-800 flex items-center gap-2">
<span className="text-red-600">Training</span>
<span className="text-slate-400">=</span>
<span className="text-slate-600">Modellanpassung / Fine-Tuning</span>
</div>
<p className="text-xs text-slate-500 mt-1">
Daten fliessen in das Training oder Fine-Tuning eines KI-Modells ein und veraendern
dessen Gewichte permanent. <strong className="text-red-600">Grundsaetzlich VERBOTEN</strong> fuer
externe Daten gemaess unserer Datenschutz-Policy.
</p>
</div>
<div className="p-3 bg-white rounded border border-slate-200">
<div className="font-medium text-slate-800 flex items-center gap-2">
<span className="text-amber-600">Export</span>
<span className="text-slate-400">=</span>
<span className="text-slate-600">Weitergabe / Download / API</span>
</div>
<p className="text-xs text-slate-500 mt-1">
Daten werden an Dritte weitergegeben, zum Download bereitgestellt oder ueber eine API
ausgegeben. Erfordert Pruefung der Lizenzbedingungen und ggf. Namensnennung.
</p>
</div>
</div>
</details>
</div>
</div>
{/* KI Use-Case Risk Matrix */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">KI-Use-Case Risikomatrix</h3>
<p className="text-sm text-slate-600 mb-4">
Zulaessigkeit von KI-Anwendungsfaellen nach Datenquelle.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="text-left py-2 px-3 font-medium text-slate-700">KI-Use-Case</th>
<th className="text-center py-2 px-3 font-medium text-slate-700">Open-Data SMD</th>
<th className="text-center py-2 px-3 font-medium text-slate-700">Regelwerke</th>
<th className="text-center py-2 px-3 font-medium text-slate-700">Lehrer-Uploads</th>
<th className="text-center py-2 px-3 font-medium text-slate-700">Risiko</th>
</tr>
</thead>
<tbody>
{[
{ useCase: 'Schul-Auswahl / Onboarding', openData: 'yes', rules: 'na', uploads: 'na', risk: 'low' },
{ useCase: 'Erwartungshorizont-Suche', openData: 'na', rules: 'yes', uploads: 'warn', risk: 'medium' },
{ useCase: 'Klausur-Korrektur (RAG)', openData: 'na', rules: 'warn', uploads: 'yes', risk: 'medium' },
{ useCase: 'Modell-Training', openData: 'no', rules: 'warn', uploads: 'no', risk: 'high' },
{ useCase: 'Auto-Schulerkennung', openData: 'no', rules: 'no', uploads: 'no', risk: 'high' },
].map((row, idx) => (
<tr key={idx} className={`border-b border-slate-100 hover:bg-slate-50 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/30'}`}>
<td className="py-2 px-3 font-medium text-slate-800">{row.useCase}</td>
<td className="py-2 px-3 text-center">{row.openData === 'yes' ? '✅' : row.openData === 'warn' ? '⚠️' : row.openData === 'no' ? '❌' : '—'}</td>
<td className="py-2 px-3 text-center">{row.rules === 'yes' ? '✅' : row.rules === 'warn' ? '⚠️' : row.rules === 'no' ? '❌' : '—'}</td>
<td className="py-2 px-3 text-center">{row.uploads === 'yes' ? '✅' : row.uploads === 'warn' ? '⚠️' : row.uploads === 'no' ? '❌' : '—'}</td>
<td className="py-2 px-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
row.risk === 'low' ? 'bg-green-100 text-green-700' :
row.risk === 'medium' ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
}`}>
{row.risk === 'low' ? 'Niedrig' : row.risk === 'medium' ? 'Mittel' : 'Hoch'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Licenses Info */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Unterstuetzte Lizenzen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">DL-DE-BY-2.0</div>
<div className="text-xs text-slate-500 mt-1">Datenlizenz Deutschland - Namensnennung</div>
<div className="text-xs text-green-600 mt-2">Attribution erforderlich</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">CC-BY</div>
<div className="text-xs text-slate-500 mt-1">Creative Commons Attribution</div>
<div className="text-xs text-green-600 mt-2">Attribution erforderlich</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">CC-BY-SA</div>
<div className="text-xs text-slate-500 mt-1">CC Attribution-ShareAlike</div>
<div className="text-xs text-amber-600 mt-2">Attribution + ShareAlike</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">CC0</div>
<div className="text-xs text-slate-500 mt-1">Public Domain</div>
<div className="text-xs text-slate-400 mt-2">Keine Attribution noetig</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">§5 UrhG</div>
<div className="text-xs text-slate-500 mt-1">Amtliche Werke</div>
<div className="text-xs text-green-600 mt-2">Quellenangabe erforderlich</div>
</div>
</div>
</div>
{/* Technische Controls fuer Attribution */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Technische Controls fuer Attribution</h3>
<p className="text-sm text-slate-600 mb-4">
Massnahmen zur Sicherstellung der lizenzkonformen Quellenangabe im System.
</p>
<div className="space-y-3">
{[
{
id: 'CTRL-SRC-001',
name: 'Attribution bei Schulsuche',
description: 'Bei jedem Suchergebnis aus Open-Data-Portalen wird die Datenquelle, Lizenz und ein Link zum Bereitsteller angezeigt.',
status: 'implemented',
location: 'studio-v2/components/SchoolSearch.tsx',
},
{
id: 'CTRL-SRC-002',
name: 'Attribution bei RAG-Ergebnissen',
description: 'Pro EH-Vorschlag werden Dokumentname, Herausgeber und Lizenz angezeigt. Bei Einfuegen in Gutachten wird Zitation automatisch ergaenzt.',
status: 'implemented',
location: 'studio-v2/components/korrektur/EHSuggestionPanel.tsx',
},
{
id: 'CTRL-SRC-003',
name: 'Export-Attribution',
description: 'Bei PDF-Export wird ein Quellenverzeichnis am Ende eingefuegt. Bei Daten-Export werden Attribution-Metadaten mitgeliefert.',
status: 'planned',
location: 'klausur-service/export',
},
{
id: 'CTRL-SRC-004',
name: 'Attribution-Audit-Trail',
description: 'Logging welche Quellen fuer welche Outputs verwendet wurden. Nachweis fuer Auditoren ueber policy_audit_log.',
status: 'planned',
location: 'edu-search-service/internal/policy/audit.go',
},
].map((ctrl) => (
<div key={ctrl.id} className="p-4 border border-slate-200 rounded-lg hover:bg-slate-50">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-xs px-2 py-0.5 bg-purple-100 text-purple-700 rounded">{ctrl.id}</span>
<span className="font-medium text-slate-800">{ctrl.name}</span>
</div>
<p className="text-sm text-slate-600">{ctrl.description}</p>
<p className="text-xs text-slate-400 mt-1 font-mono">{ctrl.location}</p>
</div>
<span className={`flex-shrink-0 px-2 py-1 rounded text-xs font-medium ${
ctrl.status === 'implemented' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'
}`}>
{ctrl.status === 'implemented' ? 'Implementiert' : 'Geplant'}
</span>
</div>
</div>
))}
</div>
</div>
{/* Erlaubte Referenz-Domains */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Erlaubte Referenz-Domains (Audit-Dokumentation)</h3>
<p className="text-sm text-slate-600 mb-4">
Domains, auf die das System zu Referenz- und Compliance-Zwecken zugreifen darf.
Diese Zugriffe dienen ausschliesslich der rechtssicheren Klassifikation und Dokumentation.
</p>
<div className="space-y-3">
{[
{
domain: 'govdata.de',
reason: 'Der Zugriff auf govdata.de ist dauerhaft erlaubt, da es sich um ein amtliches Open-Data-Portal mit klarer Lizenz handelt. Die Nutzung erfolgt ausschliesslich zu Recherche- und Referenzzwecken, nicht fuer KI-Training.',
type: 'Datenquelle',
},
{
domain: 'creativecommons.org',
reason: 'Der Zugriff auf creativecommons.org ist dauerhaft erlaubt, da es sich um offizielle Lizenztexte handelt, die fuer die rechtssichere Klassifikation und Nutzung von Open-Data-Quellen erforderlich sind.',
type: 'Lizenz-Referenz',
},
{
domain: 'wiki.creativecommons.org',
reason: 'Der Zugriff auf wiki.creativecommons.org ist dauerhaft erlaubt, da es sich um offizielle Lizenzdokumentation handelt, die zur rechtssicheren Klassifikation von Datenquellen erforderlich ist.',
type: 'Lizenz-Dokumentation',
},
{
domain: 'gesetze-im-internet.de',
reason: 'Der Zugriff auf gesetze-im-internet.de ist dauerhaft erlaubt, da es sich um amtliche, urheberrechtsfreie Rechtsquellen (§5 UrhG) handelt, die zur rechtlichen Einordnung und Compliance-Dokumentation erforderlich sind.',
type: 'Rechtsquelle',
},
{
domain: 'nibis.de',
reason: 'Der Zugriff auf nibis.de (Niedersaechsischer Bildungsserver) ist dauerhaft erlaubt fuer den Abruf von Kerncurricula und Erwartungshorizonten. Die Nutzung erfolgt unter DL-DE-BY-2.0 mit Attribution.',
type: 'Bildungsquelle',
},
{
domain: 'kmk.org',
reason: 'Der Zugriff auf kmk.org (Kultusministerkonferenz) ist dauerhaft erlaubt, da KMK-Beschluesse als amtliche Werke nach §5 UrhG frei nutzbar sind. Quellenangabe erforderlich.',
type: 'Amtliche Quelle',
},
].map((item, idx) => (
<div key={item.domain} className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm font-medium text-slate-800">{item.domain}</span>
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">{item.type}</span>
</div>
<p className="text-sm text-slate-600 italic">&quot;{item.reason}&quot;</p>
</div>
</div>
</div>
))}
</div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
<strong>Fuer Auditoren:</strong> Diese Statements dokumentieren die rechtliche Grundlage fuer den Systemzugriff auf externe Domains.
Alle Zugriffe werden im Audit-Log protokolliert.
</div>
</div>
{/* Bundesweite Quellen */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Bundesweite Quellen</h3>
<p className="text-sm text-slate-600 mb-4">
Uebergreifende Open-Data-Portale und amtliche Quellen auf Bundesebene.
</p>
<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-700">Quelle</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Typ</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Lizenz</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Einsatz</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-2 px-3 font-medium text-slate-800">GovData</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">Bund-ODP</span>
</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">DL-DE-BY-2.0</span>
</td>
<td className="py-2 px-3 text-slate-600">Aggregation / Fallback</td>
</tr>
<tr className="hover:bg-slate-50">
<td className="py-2 px-3 font-medium text-slate-800">Statistische Landesaemter</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">Amtlich</span>
</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 rounded text-xs">variabel</span>
</td>
<td className="py-2 px-3 text-slate-600">Plausibilisierung</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Bundeslaender Open Data Portale */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Bundeslaender Open Data Portale</h3>
<p className="text-sm text-slate-600 mb-4">
Zulaessige Landes-Open-Data-Portale fuer Schulstammdaten und Bildungsinformationen.
</p>
<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-700">Bundesland</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Zulaessige Quelle</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Lizenz</th>
<th className="text-left py-2 px-3 font-medium text-slate-700 hidden md:table-cell">Hinweise</th>
</tr>
</thead>
<tbody>
{[
{ bl: 'BW', name: 'Baden-Wuerttemberg', source: 'Open Data Baden-Wuerttemberg', license: 'DL-DE-BY-2.0', note: 'Schulverzeichnisse ueber Ministerium / Kommunen' },
{ bl: 'BY', name: 'Bayern', source: 'Open Data Bayern', license: 'DL-DE-BY-2.0', note: 'Amtliche Schulnummern, Standorte' },
{ bl: 'BE', name: 'Berlin', source: 'Datenportal Berlin', license: 'CC-BY', note: 'Sehr gut gepflegte Schulstammdaten' },
{ bl: 'BB', name: 'Brandenburg', source: 'Daten Brandenburg', license: 'DL-DE-BY-2.0', note: 'Kommunale Ergaenzungen pruefen' },
{ bl: 'HB', name: 'Bremen', source: 'Open Data Bremen', license: 'CC-BY', note: 'Kleine Datenmenge, sauber' },
{ bl: 'HH', name: 'Hamburg', source: 'Transparenzportal Hamburg', license: 'DL-DE-BY-2.0', note: 'Sehr gute Metadaten' },
{ bl: 'HE', name: 'Hessen', source: 'Open Data Hessen', license: 'DL-DE-BY-2.0', note: 'Schultraegerdaten' },
{ bl: 'MV', name: 'Mecklenburg-Vorpommern', source: 'Open Data MV', license: 'DL-DE-BY-2.0', note: 'Teilweise CSV/Excel' },
{ bl: 'NI', name: 'Niedersachsen', source: 'Open Data Niedersachsen', license: 'DL-DE-BY-2.0', note: 'Ergaenzend: NIBIS nur Regelwerke, nicht Personen' },
{ bl: 'NW', name: 'Nordrhein-Westfalen', source: 'Open.NRW', license: 'DL-DE-BY-2.0', note: 'Umfangreich, kommunale Qualitaet pruefen' },
{ bl: 'RP', name: 'Rheinland-Pfalz', source: 'Open Data Rheinland-Pfalz', license: 'DL-DE-BY-2.0', note: 'Schulformen & Standorte' },
{ bl: 'SL', name: 'Saarland', source: 'Open Data Saarland', license: 'DL-DE-BY-2.0', note: 'Klein, aber zulaessig' },
{ bl: 'SN', name: 'Sachsen', source: 'Datenportal Sachsen', license: 'DL-DE-BY-2.0', note: 'Gute Pflege' },
{ bl: 'ST', name: 'Sachsen-Anhalt', source: 'Open Data Sachsen-Anhalt', license: 'DL-DE-BY-2.0', note: 'CSV/JSON verfuegbar' },
{ bl: 'SH', name: 'Schleswig-Holstein', source: 'Open Data Schleswig-Holstein', license: 'DL-DE-BY-2.0', note: 'Einheitliche IDs' },
{ bl: 'TH', name: 'Thueringen', source: 'Open Data Thueringen', license: 'DL-DE-BY-2.0', note: 'Kommunale Ergaenzungen' },
].map((item, idx) => (
<tr key={item.bl} className={`border-b border-slate-100 hover:bg-slate-50 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}`}>
<td className="py-2 px-3">
<span className="inline-flex items-center gap-2">
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs font-mono">{item.bl}</span>
<span className="text-slate-700 hidden sm:inline">{item.name}</span>
</span>
</td>
<td className="py-2 px-3 font-medium text-slate-800">{item.source}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
item.license === 'CC-BY'
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700'
}`}>
{item.license}
</span>
</td>
<td className="py-2 px-3 text-slate-500 text-xs hidden md:table-cell">{item.note}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
<strong>Hinweis:</strong> Alle Landes-ODP sind vom Typ &quot;Landes-ODP&quot; und erfordern Attribution gemaess der jeweiligen Lizenz.
</div>
</div>
</div>
)
}

View File

@@ -1,395 +0,0 @@
'use client'
/**
* TOM - Technische und Organisatorische Massnahmen
*
* Art. 32 DSGVO - Sicherheit der Verarbeitung
*/
import { useState } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface TOMCategory {
id: string
title: string
article: string
description: string
measures: {
name: string
description: string
status: 'implemented' | 'partial' | 'planned' | 'not_applicable'
evidence?: string
lastReview?: string
}[]
}
export default function TOMPage() {
const [expandedCategory, setExpandedCategory] = useState<string | null>('encryption')
const tomCategories: TOMCategory[] = [
{
id: 'encryption',
title: 'Verschluesselung',
article: 'Art. 32 Abs. 1 lit. a',
description: 'Pseudonymisierung und Verschluesselung personenbezogener Daten',
measures: [
{
name: 'TLS 1.3 fuer alle Verbindungen',
description: 'Alle HTTP-Verbindungen werden ueber HTTPS mit TLS 1.3 verschluesselt',
status: 'implemented',
evidence: 'SSL Labs A+ Rating, Nginx Config',
lastReview: '2024-12-01'
},
{
name: 'Verschluesselung ruhender Daten',
description: 'PostgreSQL-Datenbank mit AES-256 Verschluesselung (pgcrypto)',
status: 'implemented',
evidence: 'PostgreSQL Config, Encryption Keys in Vault',
lastReview: '2024-12-01'
},
{
name: 'E-Mail-Verschluesselung',
description: 'Optionale PGP-Verschluesselung fuer sensible E-Mails',
status: 'partial',
evidence: 'PGP-Keys verfuegbar, nicht fuer alle Empfaenger',
},
{
name: 'Backup-Verschluesselung',
description: 'Alle Backups werden mit AES-256 verschluesselt gespeichert',
status: 'implemented',
evidence: 'restic Backup Config',
lastReview: '2024-11-15'
},
]
},
{
id: 'access_control',
title: 'Zugriffskontrolle',
article: 'Art. 32 Abs. 1 lit. b',
description: 'Faehigkeit, Vertraulichkeit und Integritaet auf Dauer sicherzustellen',
measures: [
{
name: 'Role-Based Access Control (RBAC)',
description: 'Zugriff basierend auf Rollen: user, admin, data_protection_officer',
status: 'implemented',
evidence: 'consent-service/internal/middleware/auth.go',
lastReview: '2024-12-01'
},
{
name: 'Multi-Faktor-Authentifizierung',
description: '2FA fuer Admin-Zugaenge (TOTP)',
status: 'implemented',
evidence: 'Auth-Service Implementation',
lastReview: '2024-12-01'
},
{
name: 'Passwort-Policy',
description: 'Min. 12 Zeichen, Komplexitaetsanforderungen, bcrypt-Hashing',
status: 'implemented',
evidence: 'consent-service/internal/services/auth_service.go',
lastReview: '2024-12-01'
},
{
name: 'Session-Management',
description: 'JWT mit 24h Ablauf, Refresh-Token-Rotation',
status: 'implemented',
evidence: 'JWT Config, Token-Rotation Logic',
lastReview: '2024-12-01'
},
{
name: 'IP-Whitelisting Admin',
description: 'Admin-Zugang nur von definierten IP-Bereichen',
status: 'planned',
},
]
},
{
id: 'availability',
title: 'Verfuegbarkeit & Belastbarkeit',
article: 'Art. 32 Abs. 1 lit. b',
description: 'Faehigkeit, Verfuegbarkeit und Belastbarkeit der Systeme sicherzustellen',
measures: [
{
name: 'Automatische Backups',
description: 'Taegliche inkrementelle Backups, woechentliche Vollbackups',
status: 'implemented',
evidence: 'restic + cron Jobs',
lastReview: '2024-11-15'
},
{
name: 'Disaster Recovery Plan',
description: 'Dokumentierter Wiederherstellungsplan mit RTO < 4h',
status: 'partial',
evidence: 'DR-Dokumentation in Arbeit',
},
{
name: 'Health Monitoring',
description: 'Prometheus + Grafana fuer System-Monitoring',
status: 'implemented',
evidence: 'Monitoring Stack deployed',
lastReview: '2024-12-01'
},
{
name: 'Rate Limiting',
description: 'API Rate Limiting zum Schutz vor DDoS',
status: 'implemented',
evidence: 'Nginx Rate Limit Config',
lastReview: '2024-12-01'
},
]
},
{
id: 'restore',
title: 'Wiederherstellung',
article: 'Art. 32 Abs. 1 lit. c',
description: 'Rasche Wiederherstellung nach physischem oder technischem Zwischenfall',
measures: [
{
name: 'Backup-Restore-Tests',
description: 'Quartalsweise Tests der Backup-Wiederherstellung',
status: 'partial',
evidence: 'Letzter Test: 2024-10-15',
},
{
name: 'Dokumentierte Recovery-Prozeduren',
description: 'Schritt-fuer-Schritt Anleitungen fuer verschiedene Szenarien',
status: 'implemented',
evidence: 'docs/disaster-recovery/',
lastReview: '2024-11-01'
},
{
name: 'Redundante Datenhaltung',
description: 'Datenbank-Replikation auf zweitem Server',
status: 'planned',
},
]
},
{
id: 'review',
title: 'Regelmaessige Ueberpruefung',
article: 'Art. 32 Abs. 1 lit. d',
description: 'Verfahren zur regelmaessigen Ueberpruefung, Bewertung und Evaluierung',
measures: [
{
name: 'Security Audits',
description: 'Jaehrliche externe Security-Audits',
status: 'implemented',
evidence: 'Letzter Audit: 2024-09',
lastReview: '2024-09-15'
},
{
name: 'Penetration Tests',
description: 'Jaehrliche Penetrationstests durch externen Dienstleister',
status: 'partial',
evidence: 'Naechster Test geplant: Q1 2025',
},
{
name: 'Vulnerability Scanning',
description: 'Woechentliche automatisierte Schwachstellen-Scans',
status: 'implemented',
evidence: 'GitHub Dependabot + Trivy',
lastReview: '2024-12-01'
},
{
name: 'TOM-Review',
description: 'Jaehrliche Ueberpruefung aller TOMs',
status: 'implemented',
evidence: 'Diese Seite',
lastReview: '2024-12-01'
},
]
},
{
id: 'logging',
title: 'Protokollierung & Audit-Trail',
article: 'Art. 32 Abs. 2',
description: 'Nachweis der Einhaltung durch Protokollierung',
measures: [
{
name: 'Zugriffs-Logging',
description: 'Protokollierung aller Zugriffe auf personenbezogene Daten',
status: 'implemented',
evidence: 'consent-service Audit-Logs',
lastReview: '2024-12-01'
},
{
name: 'Aenderungs-Historie',
description: 'Vollstaendige Historie aller Datenänderungen',
status: 'implemented',
evidence: 'audit_logs Tabelle in PostgreSQL',
lastReview: '2024-12-01'
},
{
name: 'Admin-Aktionen-Log',
description: 'Protokollierung aller administrativen Aktionen',
status: 'implemented',
evidence: 'Admin Action Logger',
lastReview: '2024-12-01'
},
{
name: 'Log-Aufbewahrung',
description: 'Logs werden 2 Jahre aufbewahrt, dann automatisch geloescht',
status: 'implemented',
evidence: 'Log Retention Policy',
lastReview: '2024-11-01'
},
]
},
]
const getStatusBadge = (status: string) => {
switch (status) {
case 'implemented':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Umgesetzt</span>
case 'partial':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Teilweise</span>
case 'planned':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Geplant</span>
case 'not_applicable':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">N/A</span>
default:
return null
}
}
const calculateCategoryScore = (category: TOMCategory) => {
const total = category.measures.length
const implemented = category.measures.filter(m => m.status === 'implemented').length
const partial = category.measures.filter(m => m.status === 'partial').length
return Math.round(((implemented + partial * 0.5) / total) * 100)
}
const calculateOverallScore = () => {
let totalMeasures = 0
let implementedScore = 0
tomCategories.forEach(cat => {
cat.measures.forEach(m => {
totalMeasures++
if (m.status === 'implemented') implementedScore += 1
else if (m.status === 'partial') implementedScore += 0.5
})
})
return Math.round((implementedScore / totalMeasures) * 100)
}
return (
<div>
<PagePurpose
title="Technische & Organisatorische Massnahmen (TOMs)"
purpose="Dokumentation aller Sicherheitsmassnahmen gemaess Art. 32 DSGVO. Diese Seite dient als Nachweis fuer Auditoren und den DSB."
audience={['DSB', 'IT-Sicherheit', 'Auditoren', 'Geschaeftsfuehrung']}
gdprArticles={['Art. 32 (Sicherheit der Verarbeitung)']}
architecture={{
services: ['consent-service (Go)', 'backend (Python)', 'Nginx', 'PostgreSQL'],
databases: ['PostgreSQL (verschluesselt)', 'MinIO (Backups)'],
}}
relatedPages={[
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management-System' },
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
{ name: 'Audit', href: '/compliance/audit', description: 'Audit-Dokumentation' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Overall Score */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">TOM-Umsetzungsgrad</h2>
<p className="text-sm text-slate-500 mt-1">Gesamtfortschritt aller technischen und organisatorischen Massnahmen</p>
</div>
<div className="text-right">
<div className={`text-4xl font-bold ${calculateOverallScore() >= 80 ? 'text-green-600' : calculateOverallScore() >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
{calculateOverallScore()}%
</div>
<div className="text-sm text-slate-500">Umgesetzt</div>
</div>
</div>
<div className="mt-4 h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${calculateOverallScore() >= 80 ? 'bg-green-500' : calculateOverallScore() >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${calculateOverallScore()}%` }}
/>
</div>
</div>
{/* TOM Categories */}
<div className="space-y-4">
{tomCategories.map((category) => (
<div key={category.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setExpandedCategory(expandedCategory === category.id ? null : category.id)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center text-lg font-bold ${
calculateCategoryScore(category) >= 80 ? 'bg-green-100 text-green-700' :
calculateCategoryScore(category) >= 50 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{calculateCategoryScore(category)}%
</div>
<div className="text-left">
<h3 className="font-semibold text-slate-900">{category.title}</h3>
<p className="text-sm text-slate-500">{category.article} - {category.description}</p>
</div>
</div>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedCategory === category.id ? '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>
{expandedCategory === category.id && (
<div className="px-6 pb-6 border-t border-slate-100">
<div className="mt-4 space-y-3">
{category.measures.map((measure, idx) => (
<div key={idx} className="p-4 bg-slate-50 rounded-lg">
<div className="flex items-start justify-between mb-2">
<h4 className="font-medium text-slate-900">{measure.name}</h4>
{getStatusBadge(measure.status)}
</div>
<p className="text-sm text-slate-600 mb-2">{measure.description}</p>
{(measure.evidence || measure.lastReview) && (
<div className="flex flex-wrap gap-4 text-xs text-slate-500">
{measure.evidence && (
<span>Nachweis: <span className="font-mono">{measure.evidence}</span></span>
)}
{measure.lastReview && (
<span>Letzte Pruefung: {measure.lastReview}</span>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
{/* Info */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex 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>
<h4 className="font-semibold text-purple-900">Dokumentationspflicht</h4>
<p className="text-sm text-purple-800 mt-1">
Gemaess Art. 32 Abs. 1 DSGVO muessen geeignete technische und organisatorische Massnahmen
implementiert werden, um ein dem Risiko angemessenes Schutzniveau zu gewaehrleisten.
Diese Dokumentation dient als Nachweis fuer Aufsichtsbehoerden.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,334 +0,0 @@
'use client'
/**
* VVT - Verarbeitungsverzeichnis
*
* Art. 30 DSGVO - Verzeichnis von Verarbeitungstaetigkeiten
*/
import { useState } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface ProcessingActivity {
id: string
name: string
purpose: string
legalBasis: string
legalBasisDetail: string
categories: string[]
recipients: string[]
thirdCountryTransfer: boolean
thirdCountryDetails?: string
retentionPeriod: string
technicalMeasures: string[]
lastReview: string
status: 'active' | 'inactive' | 'review_needed'
}
export default function VVTPage() {
const [expandedActivity, setExpandedActivity] = useState<string | null>('user_accounts')
const [filterStatus, setFilterStatus] = useState<string>('all')
const processingActivities: ProcessingActivity[] = [
{
id: 'user_accounts',
name: 'Nutzerkontenverwaltung',
purpose: 'Bereitstellung und Verwaltung von Benutzerkonten fuer die Plattform-Nutzung',
legalBasis: 'Art. 6 Abs. 1 lit. b DSGVO',
legalBasisDetail: 'Vertragserfuellung - Notwendig zur Bereitstellung des Dienstes',
categories: ['Name', 'E-Mail-Adresse', 'Passwort (gehasht)', 'Profilbild (optional)'],
recipients: ['Keine externen Empfaenger'],
thirdCountryTransfer: false,
retentionPeriod: '3 Jahre nach Kontolöschung',
technicalMeasures: ['Verschluesselung', 'Zugriffskontrolle', 'Audit-Logging'],
lastReview: '2024-12-01',
status: 'active'
},
{
id: 'consent_management',
name: 'Einwilligungsverwaltung',
purpose: 'Verwaltung und Dokumentation von Einwilligungen gemaess DSGVO',
legalBasis: 'Art. 6 Abs. 1 lit. c DSGVO',
legalBasisDetail: 'Rechtliche Verpflichtung - Nachweis der Einwilligung',
categories: ['Benutzer-ID', 'Einwilligungstyp', 'Zeitstempel', 'IP-Adresse', 'Version'],
recipients: ['DSB (Datenschutzbeauftragter)', 'Aufsichtsbehoerden bei Anfrage'],
thirdCountryTransfer: false,
retentionPeriod: '6 Jahre nach Widerruf (Nachweispflicht)',
technicalMeasures: ['Unveraenderbarkeit', 'Zeitstempel', 'Audit-Trail'],
lastReview: '2024-12-01',
status: 'active'
},
{
id: 'learning_analytics',
name: 'Lernfortschrittsanalyse',
purpose: 'Analyse des Lernfortschritts zur Verbesserung der Lernerfahrung',
legalBasis: 'Art. 6 Abs. 1 lit. a DSGVO',
legalBasisDetail: 'Einwilligung - Nutzer stimmt der Analyse explizit zu',
categories: ['Benutzer-ID', 'Lernaktivitaeten', 'Testergebnisse', 'Zeitaufwand'],
recipients: ['Lehrer (aggregiert)', 'Eltern (mit Einwilligung)'],
thirdCountryTransfer: false,
retentionPeriod: 'Bis zum Ende des Schuljahres + 1 Jahr',
technicalMeasures: ['Pseudonymisierung', 'Verschluesselung', 'Zugriffsbeschraenkung'],
lastReview: '2024-11-15',
status: 'active'
},
{
id: 'ai_processing',
name: 'KI-gestuetzte Verarbeitung',
purpose: 'Automatische Korrektur und Feedback-Generierung mittels KI',
legalBasis: 'Art. 6 Abs. 1 lit. a DSGVO',
legalBasisDetail: 'Einwilligung - Explizite Zustimmung zur KI-Verarbeitung',
categories: ['Benutzer-ID', 'Eingabetexte', 'Generierte Bewertungen'],
recipients: ['Ollama (lokal)', 'Optional: Cloud-LLM (mit Einwilligung)'],
thirdCountryTransfer: true,
thirdCountryDetails: 'OpenAI (USA) - nur bei expliziter Einwilligung, Standardvertragsklauseln',
retentionPeriod: 'Sofortige Loeschung nach Verarbeitung (keine Speicherung)',
technicalMeasures: ['Anonymisierung wo moeglich', 'Keine Speicherung bei Drittanbietern'],
lastReview: '2024-12-01',
status: 'review_needed'
},
{
id: 'support_requests',
name: 'Support-Anfragen',
purpose: 'Bearbeitung von Support- und Hilfe-Anfragen',
legalBasis: 'Art. 6 Abs. 1 lit. b DSGVO',
legalBasisDetail: 'Vertragserfuellung - Teil des Service-Angebots',
categories: ['Name', 'E-Mail', 'Anfrage-Inhalt', 'Anhaenge'],
recipients: ['Support-Team', 'Entwickler (bei technischen Problemen)'],
thirdCountryTransfer: false,
retentionPeriod: '2 Jahre nach Abschluss des Tickets',
technicalMeasures: ['Zugriffskontrolle', 'Verschluesselung'],
lastReview: '2024-10-01',
status: 'active'
},
{
id: 'newsletter',
name: 'Newsletter-Versand',
purpose: 'Information ueber Updates, Features und relevante Bildungsthemen',
legalBasis: 'Art. 6 Abs. 1 lit. a DSGVO',
legalBasisDetail: 'Einwilligung - Double-Opt-In Verfahren',
categories: ['E-Mail-Adresse', 'Anrede', 'Praeferenzen'],
recipients: ['E-Mail-Provider (Mailpit/SMTP)'],
thirdCountryTransfer: false,
retentionPeriod: 'Bis zum Widerruf',
technicalMeasures: ['Abmelde-Link in jeder E-Mail', 'Verschluesselung'],
lastReview: '2024-11-01',
status: 'active'
},
{
id: 'logging',
name: 'System-Logging',
purpose: 'Sicherheit, Fehleranalyse und Betrieb der Plattform',
legalBasis: 'Art. 6 Abs. 1 lit. f DSGVO',
legalBasisDetail: 'Berechtigtes Interesse - Sicherheit und Betrieb',
categories: ['IP-Adresse', 'Zeitstempel', 'Anfrage-Details', 'User-Agent'],
recipients: ['IT-Administratoren', 'Bei Sicherheitsvorfaellen: Behoerden'],
thirdCountryTransfer: false,
retentionPeriod: '90 Tage (Standard-Logs), 2 Jahre (Security-Logs)',
technicalMeasures: ['IP-Anonymisierung nach 7 Tagen', 'Zugriffsbeschraenkung'],
lastReview: '2024-12-01',
status: 'active'
},
]
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Aktiv</span>
case 'inactive':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">Inaktiv</span>
case 'review_needed':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Pruefung erforderlich</span>
default:
return null
}
}
const filteredActivities = filterStatus === 'all'
? processingActivities
: processingActivities.filter(a => a.status === filterStatus)
return (
<div>
<PagePurpose
title="Verarbeitungsverzeichnis (VVT)"
purpose="Verzeichnis aller Verarbeitungstaetigkeiten gemaess Art. 30 DSGVO. Dokumentiert Zweck, Rechtsgrundlage, Kategorien und Loeschfristen."
audience={['DSB', 'Auditoren', 'Aufsichtsbehoerden']}
gdprArticles={['Art. 30 (Verzeichnis von Verarbeitungstaetigkeiten)']}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management-System' },
{ name: 'TOMs', href: '/compliance/tom', description: 'Technische Massnahmen' },
{ name: 'DSFA', href: '/compliance/dsfa', description: 'Datenschutz-Folgenabschaetzung' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Header */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Verarbeitungstaetigkeiten</h2>
<p className="text-sm text-slate-500 mt-1">{processingActivities.length} dokumentierte Taetigkeiten</p>
</div>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700">
+ Neue Taetigkeit
</button>
</div>
{/* Filter */}
<div className="flex gap-2">
{[
{ value: 'all', label: 'Alle' },
{ value: 'active', label: 'Aktiv' },
{ value: 'review_needed', label: 'Pruefung erforderlich' },
{ value: 'inactive', label: 'Inaktiv' },
].map(filter => (
<button
key={filter.value}
onClick={() => setFilterStatus(filter.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
filterStatus === filter.value
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{filter.label}
</button>
))}
</div>
</div>
{/* Activities List */}
<div className="space-y-4">
{filteredActivities.map((activity) => (
<div key={activity.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setExpandedActivity(expandedActivity === activity.id ? null : activity.id)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="text-left">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-slate-900">{activity.name}</h3>
{getStatusBadge(activity.status)}
{activity.thirdCountryTransfer && (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
Drittland-Transfer
</span>
)}
</div>
<p className="text-sm text-slate-500 mt-1">{activity.purpose}</p>
</div>
</div>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedActivity === activity.id ? '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>
{expandedActivity === activity.id && (
<div className="px-6 pb-6 border-t border-slate-100">
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Rechtsgrundlage</h4>
<p className="font-semibold text-slate-900">{activity.legalBasis}</p>
<p className="text-sm text-slate-600">{activity.legalBasisDetail}</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Datenkategorien</h4>
<div className="flex flex-wrap gap-2">
{activity.categories.map((cat, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-700 rounded text-sm">
{cat}
</span>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Empfaenger</h4>
<ul className="text-sm text-slate-700 list-disc list-inside">
{activity.recipients.map((rec, idx) => (
<li key={idx}>{rec}</li>
))}
</ul>
</div>
</div>
{/* Right Column */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Loeschfrist</h4>
<p className="text-slate-700">{activity.retentionPeriod}</p>
</div>
{activity.thirdCountryTransfer && activity.thirdCountryDetails && (
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Drittland-Transfer</h4>
<p className="text-slate-700">{activity.thirdCountryDetails}</p>
</div>
)}
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Technische Massnahmen</h4>
<div className="flex flex-wrap gap-2">
{activity.technicalMeasures.map((measure, idx) => (
<span key={idx} className="px-2 py-1 bg-green-100 text-green-700 rounded text-sm">
{measure}
</span>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Letzte Pruefung</h4>
<p className="text-slate-700">{activity.lastReview}</p>
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-100 flex gap-2">
<button className="px-3 py-1.5 text-sm text-purple-600 hover:text-purple-700 font-medium">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-700">
PDF exportieren
</button>
</div>
</div>
)}
</div>
))}
</div>
{/* Info */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex 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>
<h4 className="font-semibold text-purple-900">Pflicht zur Fuehrung</h4>
<p className="text-sm text-purple-800 mt-1">
Gemaess Art. 30 DSGVO ist jeder Verantwortliche verpflichtet, ein Verzeichnis aller
Verarbeitungstaetigkeiten zu fuehren. Dieses Verzeichnis muss der Aufsichtsbehoerde
auf Anfrage zur Verfuegung gestellt werden.
</p>
</div>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -129,7 +129,7 @@ export default function DashboardPage() {
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">Neueste Datenschutzanfragen</h3>
<Link href="/compliance/dsr" className="text-sm text-primary-600 hover:text-primary-700">
<Link href="/sdk/dsr" className="text-sm text-primary-600 hover:text-primary-700">
Alle anzeigen
</Link>
</div>

View File

@@ -122,31 +122,31 @@ const ADMIN_SCREENS: ScreenDefinition[] = [
{ id: 'admin-backlog', name: 'Production Backlog', description: 'Go-Live Checkliste', category: 'overview', icon: '📝', url: '/backlog' },
{ id: 'admin-rbac', name: 'RBAC', description: 'Rollen & Berechtigungen', category: 'overview', icon: '👥', url: '/rbac' },
// === DSGVO (Violet #7c3aed) ===
{ id: 'admin-consent', name: 'Consent Verwaltung', description: 'Rechtliche Dokumente & Versionen', category: 'dsgvo', icon: '📄', url: '/dsgvo/consent' },
{ id: 'admin-dsr', name: 'Datenschutzanfragen', description: 'DSGVO Art. 15-21', category: 'dsgvo', icon: '🔒', url: '/dsgvo/dsr' },
{ id: 'admin-einwilligungen', name: 'Einwilligungen', description: 'Nutzer-Consent Uebersicht', category: 'dsgvo', icon: '', url: '/dsgvo/einwilligungen' },
{ id: 'admin-vvt', name: 'VVT', description: 'Verarbeitungsverzeichnis Art. 30', category: 'dsgvo', icon: '📋', url: '/dsgvo/vvt' },
{ id: 'admin-dsfa', name: 'DSFA', description: 'Datenschutz-Folgenabschaetzung', category: 'dsgvo', icon: '⚖️', url: '/dsgvo/dsfa' },
{ id: 'admin-tom', name: 'TOMs', description: 'Technische & Org. Massnahmen', category: 'dsgvo', icon: '🛡', url: '/dsgvo/tom' },
{ id: 'admin-loeschfristen', name: 'Loeschfristen', description: 'Aufbewahrung & Deadlines', category: 'dsgvo', icon: '🗑', url: '/dsgvo/loeschfristen' },
{ id: 'admin-advisory-board', name: 'Advisory Board', description: 'KI-Use-Case Pruefung', category: 'dsgvo', icon: '🧑‍⚖', url: '/dsgvo/advisory-board' },
{ id: 'admin-escalations', name: 'Eskalations-Queue', description: 'DSB Review & Freigabe', category: 'dsgvo', icon: '🚨', url: '/dsgvo/escalations' },
// === COMPLIANCE (Purple #9333ea) ===
{ id: 'admin-compliance-hub', name: 'Compliance Hub', description: 'Zentrales GRC Dashboard', category: 'compliance', icon: '✅', url: '/compliance/hub' },
{ id: 'admin-audit-checklist', name: 'Audit Checkliste', description: '476 Anforderungen pruefen', category: 'compliance', icon: '📋', url: '/compliance/audit-checklist' },
{ id: 'admin-requirements', name: 'Requirements', description: '558+ aus 19 Verordnungen', category: 'compliance', icon: '📜', url: '/compliance/requirements' },
{ id: 'admin-controls', name: 'Controls', description: '474 Control-Mappings', category: 'compliance', icon: '🎛️', url: '/compliance/controls' },
{ id: 'admin-evidence', name: 'Evidence', description: 'Nachweise & Dokumentation', category: 'compliance', icon: '📎', url: '/compliance/evidence' },
{ id: 'admin-risks', name: 'Risiken', description: 'Risk Matrix & Register', category: 'compliance', icon: '⚠️', url: '/compliance/risks' },
{ id: 'admin-audit-report', name: 'Audit Report', description: 'PDF Audit-Berichte', category: 'compliance', icon: '📊', url: '/compliance/audit-report' },
{ id: 'admin-modules', name: 'Service Registry', description: '30+ Service-Module', category: 'compliance', icon: '🔧', url: '/compliance/modules' },
{ id: 'admin-dsms', name: 'DSMS', description: 'Datenschutz-Management-System', category: 'compliance', icon: '🏛️', url: '/compliance/dsms' },
{ id: 'admin-compliance-workflow', name: 'Workflow', description: 'Freigabe-Workflows', category: 'compliance', icon: '🔄', url: '/compliance/workflow' },
{ id: 'admin-source-policy', name: 'Quellen-Policy', description: 'Datenquellen & Compliance', category: 'compliance', icon: '📚', url: '/compliance/source-policy' },
{ id: 'admin-ai-act', name: 'EU-AI-Act', description: 'KI-Risikoklassifizierung', category: 'compliance', icon: '🤖', url: '/compliance/ai-act' },
{ id: 'admin-obligations', name: 'Pflichten', description: 'NIS2, DSGVO, AI Act', category: 'compliance', icon: '⚡', url: '/compliance/obligations' },
// === COMPLIANCE SDK (Violet #8b5cf6) ===
// DSGVO - Datenschutz & Betroffenenrechte
{ id: 'admin-consent', name: 'Consent Verwaltung', description: 'Rechtliche Dokumente & Versionen', category: 'sdk', icon: '📄', url: '/sdk/consent-management' },
{ id: 'admin-dsr', name: 'Datenschutzanfragen', description: 'DSGVO Art. 15-21', category: 'sdk', icon: '🔒', url: '/sdk/dsr' },
{ id: 'admin-einwilligungen', name: 'Einwilligungen', description: 'Nutzer-Consent Uebersicht', category: 'sdk', icon: '', url: '/sdk/einwilligungen' },
{ id: 'admin-vvt', name: 'VVT', description: 'Verarbeitungsverzeichnis Art. 30', category: 'sdk', icon: '📋', url: '/sdk/vvt' },
{ id: 'admin-dsfa', name: 'DSFA', description: 'Datenschutz-Folgenabschaetzung', category: 'sdk', icon: '', url: '/sdk/dsfa' },
{ id: 'admin-tom', name: 'TOMs', description: 'Technische & Org. Massnahmen', category: 'sdk', icon: '🛡', url: '/sdk/tom' },
{ id: 'admin-loeschfristen', name: 'Loeschfristen', description: 'Aufbewahrung & Deadlines', category: 'sdk', icon: '🗑', url: '/sdk/loeschfristen' },
{ id: 'admin-advisory-board', name: 'Advisory Board', description: 'KI-Use-Case Pruefung', category: 'sdk', icon: '🧑‍⚖️', url: '/sdk/advisory-board' },
{ id: 'admin-escalations', name: 'Eskalations-Queue', description: 'DSB Review & Freigabe', category: 'sdk', icon: '🚨', url: '/sdk/escalations' },
// Compliance - Audit, GRC & Regulatorik
{ id: 'admin-compliance-hub', name: 'Compliance Hub', description: 'Zentrales GRC Dashboard', category: 'sdk', icon: '✅', url: '/sdk/compliance-hub' },
{ id: 'admin-audit-checklist', name: 'Audit Checkliste', description: '476 Anforderungen pruefen', category: 'sdk', icon: '📋', url: '/sdk/audit-checklist' },
{ id: 'admin-requirements', name: 'Requirements', description: '558+ aus 19 Verordnungen', category: 'sdk', icon: '📜', url: '/sdk/requirements' },
{ id: 'admin-controls', name: 'Controls', description: '474 Control-Mappings', category: 'sdk', icon: '🎛️', url: '/sdk/controls' },
{ id: 'admin-evidence', name: 'Evidence', description: 'Nachweise & Dokumentation', category: 'sdk', icon: '📎', url: '/sdk/evidence' },
{ id: 'admin-risks', name: 'Risiken', description: 'Risk Matrix & Register', category: 'sdk', icon: '⚠️', url: '/sdk/risks' },
{ id: 'admin-audit-report', name: 'Audit Report', description: 'PDF Audit-Berichte', category: 'sdk', icon: '📊', url: '/sdk/audit-report' },
{ id: 'admin-modules', name: 'Service Registry', description: '30+ Service-Module', category: 'sdk', icon: '🔧', url: '/sdk/modules' },
{ id: 'admin-dsms', name: 'DSMS', description: 'Datenschutz-Management-System', category: 'sdk', icon: '🏛️', url: '/sdk/dsms' },
{ id: 'admin-compliance-workflow', name: 'Workflow', description: 'Freigabe-Workflows', category: 'sdk', icon: '🔄', url: '/sdk/workflow' },
{ id: 'admin-source-policy', name: 'Quellen-Policy', description: 'Datenquellen & Compliance', category: 'sdk', icon: '📚', url: '/sdk/source-policy' },
{ id: 'admin-ai-act', name: 'EU-AI-Act', description: 'KI-Risikoklassifizierung', category: 'sdk', icon: '🤖', url: '/sdk/ai-act' },
{ id: 'admin-obligations', name: 'Pflichten', description: 'NIS2, DSGVO, AI Act', category: 'sdk', icon: '⚡', url: '/sdk/obligations' },
// === KI & AUTOMATISIERUNG (Teal #14b8a6) ===
{ id: 'admin-llm-compare', name: 'LLM Vergleich', description: 'KI-Provider Vergleich', category: 'ai', icon: '🤖', url: '/ai/llm-compare' },

View File

@@ -1,643 +0,0 @@
'use client'
/**
* UCCA - System Documentation Page
*
* Displays architecture documentation, auditor information,
* and transparency data for the UCCA compliance system.
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
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
}
interface LegalCorpusStats {
total_chunks: number
regulations: {
code: string
name: string
chunks: number
type: 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 [legalStats, setLegalStats] = useState<LegalCorpusStats | null>(null)
const [loading, setLoading] = useState(false)
// Fetch rules, patterns, and controls for transparency
useEffect(() => {
const fetchData = async () => {
setLoading(true)
try {
// Fetch rules
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 || '')
}
// Fetch patterns
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 || [])
}
// Fetch controls
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">
<div className="text-4xl mb-3">📋</div>
<h3 className="font-semibold text-slate-800 mb-2">Deterministische Regeln</h3>
<div className="text-3xl font-bold text-primary-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">
<div className="text-4xl mb-3">🏗</div>
<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">
<div className="text-4xl mb-3">🛡</div>
<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-primary-50 to-blue-50 rounded-xl border border-primary-200 p-6">
<h3 className="font-semibold text-primary-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-primary-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 flex items-center gap-2">
<span></span>
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>
{/* ASCII Diagram */}
<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/dsgvo/advisory-board │
└───────────────────────────────┬─────────────────────────────────────┘
│ HTTPS
┌─────────────────────────────────────────────────────────────────────┐
│ AI Compliance SDK (Go) │
│ Port 8090 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Policy Engine │ │
│ │ ┌───────────────────────────────────────────────────────┐ │ │
│ │ │ YAML-basierte Regeln (ucca_policy_v1.yaml) │ │ │
│ │ │ ~45 Regeln in 7 Kategorien │ │ │
│ │ │ Deterministisch - Kein LLM in Entscheidungslogik │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │ Controls │ │ Patterns │ │ Examples │ │ │
│ │ │ Library │ │ Library │ │ Library │ │ │
│ │ └────────────────┘ └────────────────┘ └────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ LLM Integration │ │ Legal RAG │──────┐ │
│ │ (nur Explain) │ │ Client │ │ │
│ └──────────────────┘ └──────────────────┘ │ │
└─────────────────────────────┬────────────────────┼──────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Datenschicht │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ PostgreSQL │ │ Qdrant │ │
│ │ (Assessments, │ │ (Legal Corpus, │ │
│ │ Escalations) │ │ 2,274 Chunks) │ │
│ └────────────────────┘ └────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
`}</pre>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<h4 className="font-medium text-blue-800 mb-2">Datenfluss</h4>
<ol className="text-sm text-blue-700 list-decimal list-inside space-y-1">
<li>Benutzer beschreibt Use Case im Frontend</li>
<li>Policy Engine evaluiert gegen alle Regeln</li>
<li>Ergebnis mit Controls + Patterns zurueck</li>
<li>Optional: LLM erklaert das Ergebnis</li>
<li>Bei Risiko: Automatische Eskalation</li>
</ol>
</div>
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<h4 className="font-medium text-green-800 mb-2">Sicherheitsmerkmale</h4>
<ul className="text-sm text-green-700 list-disc list-inside space-y-1">
<li>TLS 1.3 Verschluesselung</li>
<li>RBAC mit Tenant-Isolation</li>
<li>JWT-basierte Authentifizierung</li>
<li>Audit-Trail aller Aktionen</li>
<li>Keine Rohtext-Speicherung (nur Hash)</li>
</ul>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-800 text-lg mb-4">Eskalations-Workflow</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-medium text-slate-600">Level</th>
<th className="text-left py-2 px-3 font-medium text-slate-600">Ausloeser</th>
<th className="text-left py-2 px-3 font-medium text-slate-600">Pruefer</th>
<th className="text-left py-2 px-3 font-medium text-slate-600">SLA</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-slate-100 bg-green-50">
<td className="py-2 px-3 font-medium text-green-700">E0</td>
<td className="py-2 px-3 text-slate-600">Nur INFO-Regeln, Risiko &lt; 20</td>
<td className="py-2 px-3 text-slate-600">Automatisch</td>
<td className="py-2 px-3 text-slate-600">-</td>
</tr>
<tr className="border-b border-slate-100 bg-yellow-50">
<td className="py-2 px-3 font-medium text-yellow-700">E1</td>
<td className="py-2 px-3 text-slate-600">WARN-Regeln, Risiko 20-40</td>
<td className="py-2 px-3 text-slate-600">Team-Lead</td>
<td className="py-2 px-3 text-slate-600">24h / 72h</td>
</tr>
<tr className="border-b border-slate-100 bg-orange-50">
<td className="py-2 px-3 font-medium text-orange-700">E2</td>
<td className="py-2 px-3 text-slate-600">Art. 9 Daten, DSFA empfohlen, Risiko 40-60</td>
<td className="py-2 px-3 text-slate-600">DSB</td>
<td className="py-2 px-3 text-slate-600">8h / 48h</td>
</tr>
<tr className="bg-red-50">
<td className="py-2 px-3 font-medium text-red-700">E3</td>
<td className="py-2 px-3 text-slate-600">BLOCK-Regeln, Art. 22, Risiko &gt; 60</td>
<td className="py-2 px-3 text-slate-600">DSB + Legal</td>
<td className="py-2 px-3 text-slate-600">4h / 24h</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
)
const renderAuditorInfo = () => (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-800 text-lg mb-4">
Dokumentation fuer externe Auditoren
</h3>
<p className="text-slate-600 mb-4">
Diese Dokumentation erfuellt die Anforderungen nach Art. 30 DSGVO (Verzeichnis von
Verarbeitungstaetigkeiten) und dient als Grundlage fuer Audits nach Art. 32 DSGVO.
</p>
<div className="space-y-4">
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<h4 className="font-medium text-slate-800 mb-2">1. Zweck des Systems</h4>
<p className="text-sm text-slate-600">
UCCA ist ein Compliance-Pruefwerkzeug zur Bewertung geplanter KI-Anwendungsfaelle
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit. Es unterstuetzt Organisationen
bei der Einhaltung der DSGVO und des AI Acts.
</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 className="flex gap-4">
<a
href="/api/ucca/documentation/architecture.md"
download
className="flex-1 p-4 bg-primary-50 rounded-lg border border-primary-200 text-center hover:bg-primary-100"
>
<div className="text-2xl mb-2">📄</div>
<div className="font-medium text-primary-800">ARCHITECTURE.md herunterladen</div>
<div className="text-sm text-primary-600">Technische Dokumentation</div>
</a>
<a
href="/api/ucca/documentation/auditor.md"
download
className="flex-1 p-4 bg-green-50 rounded-lg border border-green-200 text-center hover:bg-green-100"
>
<div className="text-2xl mb-2">📋</div>
<div className="font-medium text-green-800">AUDITOR_DOCUMENTATION.md</div>
<div className="text-sm text-green-600">Art. 30 DSGVO konform</div>
</a>
</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">
{/* Group by category */}
{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-primary-100 text-primary-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-primary-100 text-primary-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-primary-100 text-primary-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; icon: string }[] = [
{ id: 'overview', label: 'Uebersicht', icon: '🏠' },
{ id: 'architecture', label: 'Architektur', icon: '🏗️' },
{ id: 'auditor', label: 'Fuer Auditoren', icon: '📋' },
{ id: 'rules', label: 'Regel-Katalog', icon: '📜' },
{ id: 'legal-corpus', label: 'Legal RAG', icon: '⚖️' },
]
// ============================================================================
// Main Render
// ============================================================================
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<PagePurpose
title="System-Dokumentation"
purpose="Transparente Dokumentation des UCCA-Systems fuer Entwickler, Auditoren und Datenschutzbeauftragte. Alle Regeln, Kontrollen und Architektur-Details sind hier einsehbar."
audience={['Entwickler', 'DSB', 'Externe Auditoren', 'Rechtsabteilung']}
gdprArticles={['Art. 30', 'Art. 32', 'Art. 35']}
collapsible={true}
defaultCollapsed={true}
/>
<Link
href="/dsgvo/advisory-board"
className="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-primary-600 border-b-2 border-primary-600 bg-primary-50'
: 'text-slate-600 hover:text-slate-800 hover:bg-slate-50'
}`}
>
<span>{tab.icon}</span>
{tab.label}
</button>
))}
</div>
<div className="p-6">
{activeTab === 'overview' && renderOverview()}
{activeTab === 'architecture' && renderArchitecture()}
{activeTab === 'auditor' && renderAuditorInfo()}
{activeTab === 'rules' && renderRulesTab()}
{activeTab === 'legal-corpus' && renderLegalCorpus()}
</div>
</div>
</div>
)
}

View File

@@ -1,992 +0,0 @@
/**
* UCCA Legal Metadata - Kompositorisches Bewertungssystem
*
* Jedes Feld trägt seine eigene Rechtsgrundlage.
* Das Ergebnis ist die Aggregation aller ausgewählten Felder.
* Bei Problemen werden Lösungsvorschläge angezeigt.
*/
// ============================================================================
// Types
// ============================================================================
export interface LegalReference {
article: string // z.B. "Art. 9 DSGVO"
title: string // z.B. "Besondere Kategorien personenbezogener Daten"
relevance: string // Warum relevant für dieses Feld
}
export interface RequiredControl {
id: string
title: string
description: string
effort: 'low' | 'medium' | 'high'
}
export interface FieldMetadata {
// Identifikation
id: string
label: string
labelSimple: string // Einfache Sprache
// Rechtliche Einordnung
legalRefs: LegalReference[]
// Risikobewertung
riskScore: number // 0-30 pro Feld
severity: 'INFO' | 'WARN' | 'BLOCK'
// Erforderliche Maßnahmen wenn ausgewählt
requiredControls: string[]
// Erklärungen
explanation: string // Fachsprache
explanationSimple: string // Einfache Sprache
// Hinweis für Nutzer
userHint?: string
}
export interface ProblemSolution {
id: string
title: string
description: string
// Was ändert sich wenn Lösung akzeptiert wird
removes_fields?: string[] // Diese Felder werden "entschärft"
adds_controls?: string[] // Diese Kontrollen werden hinzugefügt
new_risk_score?: number // Neuer Risiko-Beitrag (meist 0)
effort: 'low' | 'medium' | 'high'
// Frage an das Team
team_question: string
}
export interface Problem {
id: string
title: string
description: string
severity: 'WARN' | 'BLOCK'
// Welche Feld-Kombination löst das Problem aus
triggered_by: {
all_of?: string[] // Alle müssen ausgewählt sein
any_of?: string[] // Mindestens eins muss ausgewählt sein
none_of?: string[] // Keins darf ausgewählt sein (z.B. fehlende Einwilligung)
}
// Rechtliche Grundlage
legalRefs: LegalReference[]
// Mögliche Lösungen
solutions: ProblemSolution[]
}
// ============================================================================
// Erforderliche Kontrollen / Maßnahmen
// ============================================================================
export const CONTROLS: Record<string, RequiredControl> = {
explicit_consent: {
id: 'explicit_consent',
title: 'Ausdrückliche Einwilligung',
description: 'Betroffene müssen aktiv und informiert einwilligen (Opt-in, keine vorausgefüllten Checkboxen).',
effort: 'medium',
},
parental_consent: {
id: 'parental_consent',
title: 'Einwilligung der Erziehungsberechtigten',
description: 'Bei Minderjährigen muss die Einwilligung der Eltern/Erziehungsberechtigten eingeholt werden.',
effort: 'high',
},
age_verification: {
id: 'age_verification',
title: 'Altersverifikation',
description: 'Mechanismus zur Prüfung des Alters der Nutzer implementieren.',
effort: 'medium',
},
dsfa: {
id: 'dsfa',
title: 'Datenschutz-Folgenabschätzung (DSFA)',
description: 'Formale DSFA nach Art. 35 DSGVO durchführen und dokumentieren.',
effort: 'high',
},
human_in_the_loop: {
id: 'human_in_the_loop',
title: 'Menschliche Überprüfung (HITL)',
description: 'Jede automatisierte Entscheidung muss von einem Menschen überprüft werden können.',
effort: 'medium',
},
contestation_right: {
id: 'contestation_right',
title: 'Anfechtungsrecht',
description: 'Betroffene müssen automatisierte Entscheidungen anfechten können.',
effort: 'low',
},
data_minimization: {
id: 'data_minimization',
title: 'Datenminimierung',
description: 'Nur die unbedingt notwendigen Daten erheben und verarbeiten.',
effort: 'low',
},
anonymization: {
id: 'anonymization',
title: 'Anonymisierung',
description: 'Personenbezogene Daten vor der Verarbeitung anonymisieren.',
effort: 'medium',
},
pseudonymization: {
id: 'pseudonymization',
title: 'Pseudonymisierung',
description: 'Direkte Identifikatoren durch Pseudonyme ersetzen.',
effort: 'medium',
},
encryption: {
id: 'encryption',
title: 'Verschlüsselung',
description: 'Daten bei Übertragung und Speicherung verschlüsseln.',
effort: 'low',
},
access_logging: {
id: 'access_logging',
title: 'Zugriffs-Protokollierung',
description: 'Alle Zugriffe auf personenbezogene Daten protokollieren.',
effort: 'low',
},
retention_policy: {
id: 'retention_policy',
title: 'Löschkonzept',
description: 'Automatische Löschung nach definierter Aufbewahrungsfrist.',
effort: 'medium',
},
scc: {
id: 'scc',
title: 'Standardvertragsklauseln (SCC)',
description: 'EU-Standardvertragsklauseln mit Drittland-Anbieter abschließen.',
effort: 'medium',
},
tia: {
id: 'tia',
title: 'Transfer Impact Assessment',
description: 'Bewertung der Datenschutzrisiken bei Drittlandtransfer.',
effort: 'high',
},
purpose_limitation: {
id: 'purpose_limitation',
title: 'Zweckbindung dokumentieren',
description: 'Verarbeitungszweck klar definieren und dokumentieren.',
effort: 'low',
},
transparency: {
id: 'transparency',
title: 'Transparenz-Information',
description: 'Betroffene über die Verarbeitung informieren (Datenschutzerklärung).',
effort: 'low',
},
pixelization: {
id: 'pixelization',
title: 'Verpixelung/Unkenntlichmachung',
description: 'Identifizierende Merkmale (Gesichter, Kennzeichen) automatisch verpixeln.',
effort: 'medium',
},
no_training: {
id: 'no_training',
title: 'Kein KI-Training mit Daten',
description: 'Daten dürfen nur für Inferenz, nicht für Training verwendet werden.',
effort: 'low',
},
}
// ============================================================================
// Feld-Metadaten: Datentypen
// ============================================================================
export const DATA_TYPE_METADATA: Record<string, FieldMetadata> = {
personal_data: {
id: 'personal_data',
label: 'Personenbezogene Daten',
labelSimple: 'Namen, E-Mails, Adressen',
legalRefs: [
{ article: 'Art. 4(1) DSGVO', title: 'Definition personenbezogener Daten', relevance: 'Grundlegende Definition' },
{ article: 'Art. 6 DSGVO', title: 'Rechtmäßigkeit der Verarbeitung', relevance: 'Rechtsgrundlage erforderlich' },
],
riskScore: 10,
severity: 'INFO',
requiredControls: ['purpose_limitation', 'transparency'],
explanation: 'Personenbezogene Daten erfordern eine Rechtsgrundlage nach Art. 6 DSGVO.',
explanationSimple: 'Wenn Sie Daten verarbeiten, mit denen man Personen identifizieren kann, brauchen Sie einen guten Grund dafür.',
},
article_9_data: {
id: 'article_9_data',
label: 'Besondere Kategorien (Art. 9)',
labelSimple: 'Gesundheit, Religion, politische Meinung',
legalRefs: [
{ article: 'Art. 9 DSGVO', title: 'Besondere Kategorien personenbezogener Daten', relevance: 'Grundsätzliches Verarbeitungsverbot' },
{ article: 'Art. 9(2) DSGVO', title: 'Ausnahmen vom Verbot', relevance: 'Ausdrückliche Einwilligung oder andere Ausnahme erforderlich' },
],
riskScore: 25,
severity: 'WARN',
requiredControls: ['explicit_consent', 'dsfa', 'encryption'],
explanation: 'Besondere Kategorien personenbezogener Daten sind grundsätzlich verboten. Ausnahmen nur bei ausdrücklicher Einwilligung oder anderen Art. 9(2) Gründen.',
explanationSimple: 'Gesundheitsdaten, religiöse Überzeugungen und ähnlich sensible Daten dürfen nur in Ausnahmefällen verarbeitet werden.',
userHint: '⚠️ Hohes Risiko - DSFA wahrscheinlich erforderlich',
},
minor_data: {
id: 'minor_data',
label: 'Daten von Minderjährigen',
labelSimple: 'Daten von Kindern/Jugendlichen (unter 18)',
legalRefs: [
{ article: 'Art. 8 DSGVO', title: 'Bedingungen für die Einwilligung eines Kindes', relevance: 'Besondere Anforderungen an Einwilligung' },
{ article: 'ErwGr. 38', title: 'Besonderer Schutz für Kinder', relevance: 'Kinder verdienen besonderen Schutz' },
],
riskScore: 20,
severity: 'WARN',
requiredControls: ['parental_consent', 'age_verification', 'data_minimization'],
explanation: 'Daten von Minderjährigen erfordern besondere Schutzmaßnahmen. Bei Onlinediensten: Einwilligung ab 16 Jahren (in DE), darunter Elterneinwilligung.',
explanationSimple: 'Bei Kindern und Jugendlichen gelten strengere Regeln. Oft müssen die Eltern zustimmen.',
userHint: '👶 Besonderer Schutz für Minderjährige erforderlich',
},
license_plates: {
id: 'license_plates',
label: 'KFZ-Kennzeichen',
labelSimple: 'Auto-Kennzeichen',
legalRefs: [
{ article: 'Art. 4(1) DSGVO', title: 'Personenbezogene Daten', relevance: 'Kennzeichen ermöglichen Identifikation des Halters' },
{ article: 'Art. 6 DSGVO', title: 'Rechtmäßigkeit', relevance: 'Rechtsgrundlage für Verarbeitung erforderlich' },
],
riskScore: 15,
severity: 'WARN',
requiredControls: ['purpose_limitation', 'retention_policy'],
explanation: 'KFZ-Kennzeichen sind personenbezogene Daten, da sie die Identifikation des Halters ermöglichen.',
explanationSimple: 'Über ein Kennzeichen kann man den Fahrzeughalter herausfinden - daher sind es persönliche Daten.',
userHint: '🚗 Kennzeichen = personenbezogene Daten',
},
images: {
id: 'images',
label: 'Bilder von Personen',
labelSimple: 'Fotos mit erkennbaren Gesichtern',
legalRefs: [
{ article: 'Art. 4(14) DSGVO', title: 'Biometrische Daten', relevance: 'Gesichtsbilder können biometrische Daten sein' },
{ article: '§ 22 KUG', title: 'Recht am eigenen Bild', relevance: 'Einwilligung für Bildveröffentlichung' },
],
riskScore: 15,
severity: 'WARN',
requiredControls: ['explicit_consent', 'purpose_limitation'],
explanation: 'Bilder von Personen sind personenbezogene Daten. Bei Gesichtserkennung: biometrische Daten (Art. 9).',
explanationSimple: 'Fotos von Menschen brauchen deren Erlaubnis. Gesichtserkennung hat noch strengere Regeln.',
},
audio: {
id: 'audio',
label: 'Sprachaufnahmen',
labelSimple: 'Gespräche, Telefonate, Sprachnachrichten',
legalRefs: [
{ article: 'Art. 4(1) DSGVO', title: 'Personenbezogene Daten', relevance: 'Stimme ermöglicht Identifikation' },
{ article: '§ 201 StGB', title: 'Vertraulichkeit des Wortes', relevance: 'Heimliche Aufnahmen sind strafbar' },
],
riskScore: 15,
severity: 'WARN',
requiredControls: ['explicit_consent', 'transparency'],
explanation: 'Sprachaufnahmen sind personenbezogene Daten. Heimliche Aufnahmen können strafbar sein.',
explanationSimple: 'Gespräche aufzunehmen erfordert die Zustimmung aller Beteiligten.',
userHint: '🎤 Aufnahme nur mit Wissen der Betroffenen',
},
location_data: {
id: 'location_data',
label: 'Standortdaten',
labelSimple: 'GPS, Aufenthaltsorte, Bewegungsdaten',
legalRefs: [
{ article: 'Art. 4(1) DSGVO', title: 'Personenbezogene Daten', relevance: 'Standorte ermöglichen Profilbildung' },
{ article: 'ErwGr. 75', title: 'Risiken für Betroffene', relevance: 'Bewegungsprofile sind risikobehaftet' },
],
riskScore: 20,
severity: 'WARN',
requiredControls: ['explicit_consent', 'data_minimization', 'retention_policy'],
explanation: 'Standortdaten ermöglichen detaillierte Bewegungsprofile. Hohes Risiko für Betroffene.',
explanationSimple: 'Standortdaten zeigen, wo jemand wann war. Das ist sehr persönlich.',
},
biometric_data: {
id: 'biometric_data',
label: 'Biometrische Daten',
labelSimple: 'Fingerabdrücke, Gesichtserkennung',
legalRefs: [
{ article: 'Art. 9(1) DSGVO', title: 'Besondere Kategorien', relevance: 'Biometrische Daten zur Identifikation' },
{ article: 'Art. 4(14) DSGVO', title: 'Definition biometrischer Daten', relevance: 'Technische Verarbeitung physischer Merkmale' },
],
riskScore: 30,
severity: 'WARN',
requiredControls: ['explicit_consent', 'dsfa', 'encryption', 'access_logging'],
explanation: 'Biometrische Daten zur eindeutigen Identifikation fallen unter Art. 9 DSGVO.',
explanationSimple: 'Fingerabdrücke und Gesichtserkennung sind besonders geschützt.',
userHint: '⚠️ Art. 9 DSGVO - Besondere Kategorie',
},
financial_data: {
id: 'financial_data',
label: 'Finanzdaten',
labelSimple: 'Gehälter, Kontodaten, Kreditwürdigkeit',
legalRefs: [
{ article: 'Art. 6 DSGVO', title: 'Rechtmäßigkeit', relevance: 'Rechtsgrundlage erforderlich' },
{ article: '§ 31 BDSG', title: 'Schutz des Wirtschaftsverkehrs', relevance: 'Scoring-Regelungen' },
],
riskScore: 15,
severity: 'INFO',
requiredControls: ['encryption', 'access_logging', 'purpose_limitation'],
explanation: 'Finanzdaten erfordern besondere Sicherheitsmaßnahmen.',
explanationSimple: 'Kontodaten und Gehälter müssen besonders geschützt werden.',
},
employee_data: {
id: 'employee_data',
label: 'Mitarbeiterdaten',
labelSimple: 'Personalakten, Bewertungen, Gehälter',
legalRefs: [
{ article: '§ 26 BDSG', title: 'Beschäftigtendatenschutz', relevance: 'Besondere Regelungen für Arbeitsverhältnisse' },
{ article: 'Art. 88 DSGVO', title: 'Datenverarbeitung im Beschäftigungskontext', relevance: 'Nationale Regelungen möglich' },
],
riskScore: 15,
severity: 'INFO',
requiredControls: ['purpose_limitation', 'access_logging', 'transparency'],
explanation: 'Beschäftigtendaten unterliegen dem § 26 BDSG. Betriebsrat ggf. einzubinden.',
explanationSimple: 'Bei Mitarbeiterdaten gelten besondere Regeln. Der Betriebsrat hat Mitspracherecht.',
userHint: '👔 Betriebsrat einbinden',
},
customer_data: {
id: 'customer_data',
label: 'Kundendaten',
labelSimple: 'Bestellungen, Kontaktdaten, Kaufhistorie',
legalRefs: [
{ article: 'Art. 6(1)(b) DSGVO', title: 'Vertragserfüllung', relevance: 'Oft Rechtsgrundlage für Kundendaten' },
],
riskScore: 10,
severity: 'INFO',
requiredControls: ['transparency', 'retention_policy'],
explanation: 'Kundendaten können oft auf Basis der Vertragserfüllung verarbeitet werden.',
explanationSimple: 'Kundendaten brauchen Sie für Bestellungen - das ist meist erlaubt.',
},
public_data: {
id: 'public_data',
label: 'Nur öffentliche Daten',
labelSimple: 'Keine personenbezogenen Daten',
legalRefs: [
{ article: 'ErwGr. 26', title: 'Anonyme Informationen', relevance: 'DSGVO gilt nicht für anonyme Daten' },
],
riskScore: 0,
severity: 'INFO',
requiredControls: [],
explanation: 'Wenn keine personenbezogenen Daten verarbeitet werden, ist die DSGVO nicht anwendbar.',
explanationSimple: 'Ohne persönliche Daten gelten die strengen Regeln nicht.',
userHint: '✅ Geringes Risiko',
},
}
// ============================================================================
// Feld-Metadaten: Automatisierung
// ============================================================================
export const AUTOMATION_METADATA: Record<string, FieldMetadata> = {
assistive: {
id: 'assistive',
label: 'Assistierend (KI macht Vorschläge)',
labelSimple: 'KI macht Vorschläge, Mensch entscheidet',
legalRefs: [],
riskScore: 0,
severity: 'INFO',
requiredControls: [],
explanation: 'Bei assistierender KI bleibt der Mensch Entscheider. Art. 22 DSGVO nicht betroffen.',
explanationSimple: 'Die KI schlägt vor, Sie entscheiden. Das ist die sicherste Variante.',
userHint: '✅ Empfohlen',
},
semi_automated: {
id: 'semi_automated',
label: 'Teilautomatisiert (Mensch prüft)',
labelSimple: 'KI filtert vor, Mensch prüft',
legalRefs: [
{ article: 'ErwGr. 71', title: 'Profiling und automatisierte Entscheidungen', relevance: 'Menschliche Überprüfung empfohlen' },
],
riskScore: 10,
severity: 'INFO',
requiredControls: ['human_in_the_loop'],
explanation: 'Teilautomatisierung mit menschlicher Kontrolle ist meist unproblematisch.',
explanationSimple: 'Die KI arbeitet vor, aber ein Mensch schaut drüber.',
},
fully_automated: {
id: 'fully_automated',
label: 'Vollautomatisiert (keine menschliche Prüfung)',
labelSimple: 'KI entscheidet alleine',
legalRefs: [
{ article: 'Art. 22(1) DSGVO', title: 'Automatisierte Einzelentscheidungen', relevance: 'Grundsätzliches Verbot bei rechtlicher Wirkung' },
{ article: 'Art. 22(2) DSGVO', title: 'Ausnahmen', relevance: 'Erlaubt bei Vertrag, Gesetz oder Einwilligung' },
],
riskScore: 25,
severity: 'WARN',
requiredControls: ['human_in_the_loop', 'contestation_right', 'transparency'],
explanation: 'Vollautomatisierte Entscheidungen mit rechtlicher Wirkung sind nach Art. 22 DSGVO grundsätzlich verboten.',
explanationSimple: 'Wenn die KI alleine entscheidet und das Auswirkungen auf Menschen hat, ist das problematisch.',
userHint: '⚠️ Art. 22 DSGVO beachten',
},
}
// ============================================================================
// Feld-Metadaten: Zweck
// ============================================================================
export const PURPOSE_METADATA: Record<string, FieldMetadata> = {
customer_support: {
id: 'customer_support',
label: 'Kundenservice',
labelSimple: 'Fragen beantworten, Hilfe anbieten',
legalRefs: [
{ article: 'Art. 6(1)(b) DSGVO', title: 'Vertragserfüllung', relevance: 'Oft Rechtsgrundlage' },
],
riskScore: 5,
severity: 'INFO',
requiredControls: ['transparency'],
explanation: 'Kundenservice kann meist auf Vertragserfüllung gestützt werden.',
explanationSimple: 'Kunden zu helfen ist meist erlaubt.',
},
evaluation_scoring: {
id: 'evaluation_scoring',
label: 'Bewertung/Scoring von Personen',
labelSimple: 'Personen bewerten, Punkte vergeben, einstufen',
legalRefs: [
{ article: 'Art. 22 DSGVO', title: 'Automatisierte Einzelentscheidungen', relevance: 'Bei automatischem Scoring relevant' },
{ article: '§ 31 BDSG', title: 'Scoring', relevance: 'Besondere Regelungen für Scoring' },
],
riskScore: 20,
severity: 'WARN',
requiredControls: ['transparency', 'contestation_right', 'dsfa'],
explanation: 'Scoring von Personen unterliegt strengen Anforderungen. Bei automatisierten Entscheidungen: Art. 22.',
explanationSimple: 'Menschen zu bewerten oder einzustufen ist sensibel. Betroffene müssen das anfechten können.',
userHint: '⚠️ Scoring ist risikobehaftet',
},
decision_making: {
id: 'decision_making',
label: 'Automatisierte Entscheidungen',
labelSimple: 'Genehmigungen, Ablehnungen, Zugang',
legalRefs: [
{ article: 'Art. 22 DSGVO', title: 'Automatisierte Einzelentscheidungen', relevance: 'Kernartikel für automatisierte Entscheidungen' },
],
riskScore: 25,
severity: 'WARN',
requiredControls: ['human_in_the_loop', 'contestation_right', 'transparency'],
explanation: 'Automatisierte Entscheidungen mit rechtlicher Wirkung erfordern besondere Schutzmaßnahmen.',
explanationSimple: 'Wenn die KI über Menschen entscheidet (Kredit, Bewerbung, etc.), gelten strenge Regeln.',
userHint: '⚠️ Art. 22 DSGVO prüfen',
},
profiling: {
id: 'profiling',
label: 'Profiling',
labelSimple: 'Personenprofile erstellen, Verhalten analysieren',
legalRefs: [
{ article: 'Art. 4(4) DSGVO', title: 'Definition Profiling', relevance: 'Automatisierte Verarbeitung zur Bewertung' },
{ article: 'Art. 22 DSGVO', title: 'Automatisierte Entscheidungen einschl. Profiling', relevance: 'Bei Entscheidungen aufgrund von Profiling' },
],
riskScore: 20,
severity: 'WARN',
requiredControls: ['transparency', 'dsfa'],
explanation: 'Profiling ist die automatisierte Bewertung persönlicher Aspekte. Erfordert Transparenz und oft DSFA.',
explanationSimple: 'Profile über Menschen zu erstellen erfordert besondere Vorsicht.',
},
marketing: {
id: 'marketing',
label: 'Marketing/Werbung',
labelSimple: 'Werbung, Newsletter, Kampagnen',
legalRefs: [
{ article: 'Art. 6(1)(f) DSGVO', title: 'Berechtigte Interessen', relevance: 'Direktwerbung kann berechtigtes Interesse sein' },
{ article: '§ 7 UWG', title: 'Unzumutbare Belästigung', relevance: 'E-Mail-Werbung nur mit Einwilligung' },
],
riskScore: 10,
severity: 'INFO',
requiredControls: ['explicit_consent', 'transparency'],
explanation: 'E-Mail-Marketing erfordert i.d.R. Einwilligung (Opt-in).',
explanationSimple: 'Für Werbe-E-Mails brauchen Sie die Erlaubnis der Empfänger.',
},
analytics: {
id: 'analytics',
label: 'Analyse/Statistik',
labelSimple: 'Auswertungen, Berichte, Trends',
legalRefs: [
{ article: 'Art. 6(1)(f) DSGVO', title: 'Berechtigte Interessen', relevance: 'Analysen oft auf berechtigtes Interesse stützbar' },
],
riskScore: 5,
severity: 'INFO',
requiredControls: ['data_minimization'],
explanation: 'Statistische Analysen sind oft auf berechtigtes Interesse stützbar, wenn datenminimiert.',
explanationSimple: 'Auswertungen für interne Zwecke sind meist unproblematisch.',
},
research: {
id: 'research',
label: 'Forschung',
labelSimple: 'Wissenschaftliche Untersuchungen',
legalRefs: [
{ article: 'Art. 89 DSGVO', title: 'Garantien für Forschungszwecke', relevance: 'Privilegierung von Forschung' },
],
riskScore: 5,
severity: 'INFO',
requiredControls: ['data_minimization', 'pseudonymization'],
explanation: 'Forschung genießt gewisse Privilegien, erfordert aber Schutzmaßnahmen.',
explanationSimple: 'Forschung hat Sonderregeln, wenn die Daten geschützt werden.',
},
}
// ============================================================================
// Feld-Metadaten: Hosting
// ============================================================================
export const HOSTING_METADATA: Record<string, FieldMetadata> = {
eu: {
id: 'eu',
label: 'EU/EWR',
labelSimple: 'In Deutschland oder EU',
legalRefs: [],
riskScore: 0,
severity: 'INFO',
requiredControls: [],
explanation: 'Hosting in der EU ist datenschutzrechtlich unproblematisch.',
explanationSimple: 'Daten in Europa zu speichern ist die einfachste Lösung.',
userHint: '✅ Empfohlen',
},
third_country: {
id: 'third_country',
label: 'Drittland (außerhalb EU)',
labelSimple: 'USA, Schweiz, UK, andere',
legalRefs: [
{ article: 'Art. 44 DSGVO', title: 'Grundsatz für Übermittlung', relevance: 'Besondere Anforderungen an Drittlandtransfer' },
{ article: 'Art. 46 DSGVO', title: 'Geeignete Garantien', relevance: 'SCC oder andere Garantien erforderlich' },
],
riskScore: 15,
severity: 'WARN',
requiredControls: ['scc', 'tia'],
explanation: 'Drittlandtransfer erfordert zusätzliche Garantien (z.B. SCC) und ein Transfer Impact Assessment.',
explanationSimple: 'Daten außerhalb der EU zu speichern braucht extra Verträge und Prüfungen.',
userHint: '⚠️ Zusätzliche Maßnahmen erforderlich',
},
on_prem: {
id: 'on_prem',
label: 'On-Premise (eigene Server)',
labelSimple: 'Auf unseren eigenen Servern',
legalRefs: [],
riskScore: 0,
severity: 'INFO',
requiredControls: ['encryption'],
explanation: 'On-Premise bietet volle Kontrolle, erfordert aber eigene Sicherheitsmaßnahmen.',
explanationSimple: 'Eigene Server geben volle Kontrolle, aber Sie sind für die Sicherheit verantwortlich.',
},
}
// ============================================================================
// Feld-Metadaten: Modell-Nutzung
// ============================================================================
export const MODEL_USAGE_METADATA: Record<string, FieldMetadata> = {
rag: {
id: 'rag',
label: 'RAG (Dokumentensuche)',
labelSimple: 'KI durchsucht meine Dokumente',
legalRefs: [],
riskScore: 5,
severity: 'INFO',
requiredControls: [],
explanation: 'RAG-Ansätze sind datenschutzfreundlich, da keine Daten ins Modell fließen.',
explanationSimple: 'Die KI sucht in Ihren Dokumenten, lernt aber nicht daraus. Das ist sicher.',
userHint: '✅ Datenschutzfreundlich',
},
inference: {
id: 'inference',
label: 'Nur Inferenz',
labelSimple: 'KI nur nutzen, ohne eigene Daten',
legalRefs: [],
riskScore: 0,
severity: 'INFO',
requiredControls: [],
explanation: 'Reine Inferenz ohne Datenspeicherung ist unproblematisch.',
explanationSimple: 'Die KI nutzen ohne eigene Daten einzugeben ist sicher.',
userHint: '✅ Geringes Risiko',
},
finetune: {
id: 'finetune',
label: 'Fine-Tuning',
labelSimple: 'KI mit meinen Daten anpassen',
legalRefs: [
{ article: 'Art. 5(1)(b) DSGVO', title: 'Zweckbindung', relevance: 'Training ist neuer Zweck' },
],
riskScore: 20,
severity: 'WARN',
requiredControls: ['explicit_consent', 'purpose_limitation', 'no_training'],
explanation: 'Fine-Tuning mit personenbezogenen Daten erfordert eigene Rechtsgrundlage.',
explanationSimple: 'Wenn die KI aus Ihren Daten lernt, ist das ein eigener Verarbeitungsschritt.',
userHint: '⚠️ Eigene Rechtsgrundlage erforderlich',
},
training: {
id: 'training',
label: 'Vollständiges Training',
labelSimple: 'KI komplett mit meinen Daten trainieren',
legalRefs: [
{ article: 'Art. 5(1)(b) DSGVO', title: 'Zweckbindung', relevance: 'Training ist neuer Zweck' },
{ article: 'Art. 6 DSGVO', title: 'Rechtmäßigkeit', relevance: 'Eigene Rechtsgrundlage erforderlich' },
],
riskScore: 25,
severity: 'WARN',
requiredControls: ['explicit_consent', 'dsfa', 'purpose_limitation'],
explanation: 'KI-Training mit personenbezogenen Daten ist ein eigenständiger Verarbeitungszweck.',
explanationSimple: 'Die KI komplett mit Ihren Daten zu trainieren braucht klare Einwilligung.',
userHint: '⚠️ Hohes Risiko',
},
}
// ============================================================================
// Probleme & Lösungen
// ============================================================================
export const PROBLEMS: Problem[] = [
// KFZ-Kennzeichen ohne Einwilligung
{
id: 'license_plates_no_consent',
title: 'KFZ-Kennzeichen ohne Einwilligung',
description: 'Sie möchten KFZ-Kennzeichen verarbeiten, aber haben keine Einwilligung der Fahrzeughalter.',
severity: 'BLOCK',
triggered_by: {
all_of: ['license_plates'],
none_of: ['explicit_consent_obtained'],
},
legalRefs: [
{ article: 'Art. 6 DSGVO', title: 'Rechtmäßigkeit', relevance: 'Keine Rechtsgrundlage vorhanden' },
],
solutions: [
{
id: 'pixelize_plates',
title: 'Kennzeichen automatisch verpixeln',
description: 'Die Kennzeichen werden vor der Speicherung automatisch unkenntlich gemacht. Dadurch sind es keine personenbezogenen Daten mehr.',
removes_fields: ['license_plates'],
adds_controls: ['pixelization'],
new_risk_score: 0,
effort: 'medium',
team_question: 'Ist das Projekt auch mit verpixelten Kennzeichen (nicht lesbar) sinnvoll?',
},
{
id: 'obtain_consent',
title: 'Einwilligung einholen',
description: 'Die Fahrzeughalter um Einwilligung bitten (z.B. bei Parkhausbetreibern mit Dauerparker-Verträgen).',
adds_controls: ['explicit_consent'],
new_risk_score: 10,
effort: 'high',
team_question: 'Können Sie die Einwilligung der Fahrzeughalter einholen?',
},
],
},
// Gesichtserkennung ohne Einwilligung
{
id: 'biometrics_no_consent',
title: 'Biometrische Daten ohne Einwilligung',
description: 'Sie möchten biometrische Daten (z.B. Gesichtserkennung) verarbeiten, aber haben keine ausdrückliche Einwilligung.',
severity: 'BLOCK',
triggered_by: {
all_of: ['biometric_data'],
none_of: ['explicit_consent_obtained'],
},
legalRefs: [
{ article: 'Art. 9(1) DSGVO', title: 'Verarbeitungsverbot', relevance: 'Biometrische Daten sind besondere Kategorie' },
{ article: 'Art. 9(2)(a) DSGVO', title: 'Ausdrückliche Einwilligung', relevance: 'Einwilligung als Ausnahme' },
],
solutions: [
{
id: 'anonymize_faces',
title: 'Gesichter automatisch verpixeln/anonymisieren',
description: 'Gesichter werden vor der Speicherung automatisch unkenntlich gemacht.',
removes_fields: ['biometric_data'],
adds_controls: ['pixelization'],
new_risk_score: 0,
effort: 'medium',
team_question: 'Funktioniert Ihr Projekt auch ohne erkennbare Gesichter?',
},
{
id: 'explicit_biometric_consent',
title: 'Ausdrückliche Einwilligung einholen',
description: 'Betroffene müssen aktiv und informiert in die Gesichtserkennung einwilligen.',
adds_controls: ['explicit_consent', 'dsfa'],
new_risk_score: 20,
effort: 'high',
team_question: 'Können Sie eine ausdrückliche Einwilligung aller Betroffenen sicherstellen?',
},
],
},
// Minderjährige + automatisiertes Scoring
{
id: 'minor_automated_scoring',
title: 'Automatisiertes Scoring von Minderjährigen',
description: 'Sie möchten Minderjährige automatisiert bewerten oder einstufen. Das ist besonders problematisch.',
severity: 'BLOCK',
triggered_by: {
all_of: ['minor_data', 'evaluation_scoring', 'fully_automated'],
},
legalRefs: [
{ article: 'Art. 22(1) DSGVO', title: 'Verbot automatisierter Entscheidungen', relevance: 'Grundsätzliches Verbot' },
{ article: 'Art. 8 DSGVO', title: 'Schutz von Kindern', relevance: 'Besonderer Schutz für Minderjährige' },
],
solutions: [
{
id: 'add_human_review',
title: 'Menschliche Überprüfung einführen',
description: 'Jede Bewertung wird von einem Menschen geprüft bevor sie wirksam wird.',
removes_fields: ['fully_automated'],
adds_controls: ['human_in_the_loop'],
new_risk_score: 15,
effort: 'medium',
team_question: 'Können Sie sicherstellen, dass ein Mensch jede Bewertung prüft?',
},
{
id: 'remove_scoring',
title: 'Auf Scoring verzichten',
description: 'Statt Scoring nur informative Auswertungen ohne Entscheidungscharakter.',
removes_fields: ['evaluation_scoring'],
new_risk_score: 10,
effort: 'low',
team_question: 'Funktioniert Ihr Projekt auch ohne Bewertung/Scoring der Minderjährigen?',
},
],
},
// Drittland + sensible Daten
{
id: 'third_country_sensitive',
title: 'Sensible Daten im Drittland',
description: 'Sie möchten besonders sensible Daten außerhalb der EU verarbeiten. Das erfordert umfangreiche Schutzmaßnahmen.',
severity: 'WARN',
triggered_by: {
all_of: ['third_country'],
any_of: ['article_9_data', 'biometric_data', 'minor_data'],
},
legalRefs: [
{ article: 'Art. 44 DSGVO', title: 'Drittlandtransfer', relevance: 'Besondere Anforderungen' },
{ article: 'Art. 9 DSGVO', title: 'Sensible Daten', relevance: 'Zusätzlicher Schutz erforderlich' },
],
solutions: [
{
id: 'move_to_eu',
title: 'Hosting in der EU',
description: 'Wählen Sie einen Anbieter mit Rechenzentren in der EU.',
removes_fields: ['third_country'],
new_risk_score: 0,
effort: 'medium',
team_question: 'Können Sie zu einem EU-Anbieter wechseln?',
},
{
id: 'implement_safeguards',
title: 'Umfangreiche Schutzmaßnahmen implementieren',
description: 'SCC, TIA, zusätzliche technische Maßnahmen implementieren.',
adds_controls: ['scc', 'tia', 'encryption'],
new_risk_score: 15,
effort: 'high',
team_question: 'Können Sie die erforderlichen Verträge und Maßnahmen umsetzen?',
},
],
},
// KI-Training mit personenbezogenen Daten
{
id: 'training_with_pii',
title: 'KI-Training mit personenbezogenen Daten',
description: 'Sie möchten ein KI-Modell mit personenbezogenen Daten trainieren. Das erfordert besondere Rechtsgrundlagen.',
severity: 'WARN',
triggered_by: {
all_of: ['training'],
any_of: ['personal_data', 'article_9_data', 'employee_data', 'customer_data'],
},
legalRefs: [
{ article: 'Art. 5(1)(b) DSGVO', title: 'Zweckbindung', relevance: 'Training ist eigener Zweck' },
{ article: 'Art. 6 DSGVO', title: 'Rechtmäßigkeit', relevance: 'Eigene Rechtsgrundlage erforderlich' },
],
solutions: [
{
id: 'use_rag_instead',
title: 'RAG statt Training verwenden',
description: 'Statt Training: Dokumente in Vektordatenbank ablegen und bei Anfragen durchsuchen.',
removes_fields: ['training'],
new_risk_score: 5,
effort: 'low',
team_question: 'Reicht es, wenn die KI Ihre Dokumente durchsuchen kann statt daraus zu lernen?',
},
{
id: 'anonymize_training_data',
title: 'Trainingsdaten anonymisieren',
description: 'Personenbezogene Daten vor dem Training vollständig anonymisieren.',
adds_controls: ['anonymization'],
new_risk_score: 5,
effort: 'high',
team_question: 'Können die Trainingsdaten vor dem Training anonymisiert werden?',
},
{
id: 'get_training_consent',
title: 'Einwilligung für Training einholen',
description: 'Betroffene explizit um Einwilligung für das KI-Training bitten.',
adds_controls: ['explicit_consent', 'dsfa'],
new_risk_score: 15,
effort: 'high',
team_question: 'Können Sie die Einwilligung aller Betroffenen für das KI-Training einholen?',
},
],
},
]
// ============================================================================
// Hilfsfunktionen
// ============================================================================
/**
* Aggregiert alle ausgewählten Felder und berechnet das Ergebnis
*/
export function evaluateSelection(selection: {
dataTypes: string[]
automation: string
purposes: string[]
hosting: string
modelUsage: string[]
acceptedSolutions: string[]
}): {
totalRiskScore: number
allLegalRefs: LegalReference[]
allRequiredControls: string[]
problems: Problem[]
severity: 'INFO' | 'WARN' | 'BLOCK'
} {
const allLegalRefs: LegalReference[] = []
const allRequiredControls: Set<string> = new Set()
let totalRiskScore = 0
let maxSeverity: 'INFO' | 'WARN' | 'BLOCK' = 'INFO'
// Aggregiere Datentypen
for (const dt of selection.dataTypes) {
const meta = DATA_TYPE_METADATA[dt]
if (meta) {
totalRiskScore += meta.riskScore
allLegalRefs.push(...meta.legalRefs)
meta.requiredControls.forEach(c => allRequiredControls.add(c))
if (meta.severity === 'BLOCK') maxSeverity = 'BLOCK'
else if (meta.severity === 'WARN' && maxSeverity !== 'BLOCK') maxSeverity = 'WARN'
}
}
// Aggregiere Automatisierung
const autoMeta = AUTOMATION_METADATA[selection.automation]
if (autoMeta) {
totalRiskScore += autoMeta.riskScore
allLegalRefs.push(...autoMeta.legalRefs)
autoMeta.requiredControls.forEach(c => allRequiredControls.add(c))
if (autoMeta.severity === 'BLOCK') maxSeverity = 'BLOCK'
else if (autoMeta.severity === 'WARN' && maxSeverity !== 'BLOCK') maxSeverity = 'WARN'
}
// Aggregiere Zwecke
for (const p of selection.purposes) {
const meta = PURPOSE_METADATA[p]
if (meta) {
totalRiskScore += meta.riskScore
allLegalRefs.push(...meta.legalRefs)
meta.requiredControls.forEach(c => allRequiredControls.add(c))
if (meta.severity === 'BLOCK') maxSeverity = 'BLOCK'
else if (meta.severity === 'WARN' && maxSeverity !== 'BLOCK') maxSeverity = 'WARN'
}
}
// Aggregiere Hosting
const hostMeta = HOSTING_METADATA[selection.hosting]
if (hostMeta) {
totalRiskScore += hostMeta.riskScore
allLegalRefs.push(...hostMeta.legalRefs)
hostMeta.requiredControls.forEach(c => allRequiredControls.add(c))
if (hostMeta.severity === 'BLOCK') maxSeverity = 'BLOCK'
else if (hostMeta.severity === 'WARN' && maxSeverity !== 'BLOCK') maxSeverity = 'WARN'
}
// Aggregiere Model Usage
for (const mu of selection.modelUsage) {
const meta = MODEL_USAGE_METADATA[mu]
if (meta) {
totalRiskScore += meta.riskScore
allLegalRefs.push(...meta.legalRefs)
meta.requiredControls.forEach(c => allRequiredControls.add(c))
if (meta.severity === 'BLOCK') maxSeverity = 'BLOCK'
else if (meta.severity === 'WARN' && maxSeverity !== 'BLOCK') maxSeverity = 'WARN'
}
}
// Finde zutreffende Probleme
const allSelectedFields = [
...selection.dataTypes,
selection.automation,
...selection.purposes,
selection.hosting,
...selection.modelUsage,
]
const triggeredProblems = PROBLEMS.filter(problem => {
// Prüfe all_of: alle müssen ausgewählt sein
if (problem.triggered_by.all_of) {
if (!problem.triggered_by.all_of.every(f => allSelectedFields.includes(f))) {
return false
}
}
// Prüfe any_of: mindestens eins muss ausgewählt sein
if (problem.triggered_by.any_of) {
if (!problem.triggered_by.any_of.some(f => allSelectedFields.includes(f))) {
return false
}
}
// Prüfe none_of: keins darf ausgewählt sein (außer durch Lösung)
if (problem.triggered_by.none_of) {
const hasNoneOf = problem.triggered_by.none_of.some(f =>
allSelectedFields.includes(f) || selection.acceptedSolutions.includes(f)
)
if (hasNoneOf) {
return false
}
}
// Prüfe ob Problem durch akzeptierte Lösung gelöst wurde
const isSolved = problem.solutions.some(solution =>
selection.acceptedSolutions.includes(solution.id)
)
return !isSolved
})
// Probleme beeinflussen Severity
for (const problem of triggeredProblems) {
if (problem.severity === 'BLOCK') maxSeverity = 'BLOCK'
else if (problem.severity === 'WARN' && maxSeverity !== 'BLOCK') maxSeverity = 'WARN'
}
// Dedupliziere Legal Refs
const uniqueLegalRefs = allLegalRefs.filter((ref, index, self) =>
index === self.findIndex(r => r.article === ref.article)
)
return {
totalRiskScore: Math.min(totalRiskScore, 100),
allLegalRefs: uniqueLegalRefs,
allRequiredControls: Array.from(allRequiredControls),
problems: triggeredProblems,
severity: maxSeverity,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,648 +0,0 @@
'use client'
/**
* Consent Admin Panel
*
* Admin interface for managing:
* - Documents (AGB, Privacy, etc.)
* - Document Versions
* - Email Templates
* - GDPR Processes (Art. 15-21)
* - Statistics
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// 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 ConsentPage() {
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(() => {
// Get token from localStorage
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 (err) {
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 (err) {
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 = [
// Onboarding
{ 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' },
// Security
{ 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' },
// Consent & Legal
{ 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' },
// Data Subject Rights (GDPR)
{ 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' },
// Account Lifecycle
{ 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',
status: 'active'
},
{
article: '16',
title: 'Recht auf Berichtigung',
description: 'Recht auf Berichtigung unrichtiger personenbezogener Daten',
actions: ['Daten bearbeiten', 'Aenderungshistorie fuehren', 'Benachrichtigung senden'],
sla: '30 Tage',
status: 'active'
},
{
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',
status: 'active'
},
{
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',
status: 'active'
},
{
article: '19',
title: 'Mitteilungspflicht',
description: 'Pflicht zur Mitteilung von Berichtigung, Loeschung oder Einschraenkung an Empfaenger',
actions: ['Empfaenger ermitteln', 'Mitteilungen versenden', 'Protokollierung'],
sla: 'Unverzueglich',
status: 'active'
},
{
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',
status: 'active'
},
{
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',
status: 'active'
},
]
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>
{/* Page Purpose */}
<PagePurpose
title="Consent Verwaltung"
purpose="Verwalten Sie rechtliche Dokumente (AGB, Datenschutz, Cookie-Richtlinien) und deren Versionen. Jede Einwilligung eines Benutzers basiert auf diesen Dokumenten und muss nachvollziehbar sein."
audience={['DSB', 'Entwickler', 'Compliance Officer']}
gdprArticles={['Art. 7 (Einwilligung)', 'Art. 13/14 (Informationspflichten)']}
architecture={{
services: ['consent-service (Go)', 'backend (Python)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'DSR-Verwaltung', href: '/compliance/dsr', description: 'Datenschutzanfragen bearbeiten' },
{ name: 'DSGVO-Audit', href: '/compliance/audit', description: 'Audit-Dokumentation erstellen' },
{ name: 'Workflow', href: '/compliance/workflow', description: 'Freigabe-Prozesse konfigurieren' },
]}
collapsible={true}
defaultCollapsed={false}
/>
{/* Token Input */}
{!authToken && (
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
<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 className="mb-6">
<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`}>
{category.key === 'onboarding' && ''}
{category.key === 'security' && ''}
{category.key === 'consent' && ''}
{category.key === 'gdpr' && ''}
{category.key === 'lifecycle' && ''}
</div>
<div>
<h4 className="font-medium text-slate-900">{template.name}</h4>
<p className="text-sm text-slate-500">{template.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorschau
</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* GDPR Processes Tab - Articles 15-21 */}
{activeTab === 'gdpr' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">DSGVO Betroffenenrechte</h2>
<p className="text-sm text-slate-500 mt-1">Artikel 15-21 Prozesse und Vorlagen</p>
</div>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ DSR Anfrage erstellen
</button>
</div>
{/* Info Banner */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<span className="text-2xl">*</span>
<div>
<h4 className="font-medium text-purple-900">Data Subject Rights (DSR)</h4>
<p className="text-sm text-purple-700 mt-1">
Hier verwalten Sie alle DSGVO-Anfragen. Jeder Artikel hat definierte Prozesse, SLAs und automatisierte Workflows.
</p>
</div>
</div>
</div>
{/* GDPR Process Cards */}
<div className="space-y-4">
{gdprProcesses.map((process) => (
<div
key={process.article}
className="border border-slate-200 rounded-xl p-5 hover:border-purple-300 transition-colors bg-white"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center font-bold text-lg">
{process.article}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-slate-900">{process.title}</h3>
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
</div>
<p className="text-sm text-slate-600 mb-3">{process.description}</p>
{/* Actions */}
<div className="flex flex-wrap gap-2 mb-3">
{process.actions.map((action, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
{action}
</span>
))}
</div>
{/* SLA */}
<div className="flex items-center gap-4 text-sm">
<span className="text-slate-500">
SLA: <span className="font-medium text-slate-700">{process.sla}</span>
</span>
<span className="text-slate-300">|</span>
<span className="text-slate-500">
Offene Anfragen: <span className="font-medium text-slate-700">0</span>
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<button className="px-3 py-1.5 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg">
Anfragen
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorlage
</button>
</div>
</div>
</div>
))}
</div>
{/* DSR Request Statistics */}
<div className="mt-8 pt-6 border-t border-slate-200">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-4">DSR Uebersicht</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-slate-900">0</div>
<div className="text-xs text-slate-500 mt-1">Offen</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-700">0</div>
<div className="text-xs text-slate-500 mt-1">Erledigt</div>
</div>
<div className="bg-yellow-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-yellow-700">0</div>
<div className="text-xs text-slate-500 mt-1">In Bearbeitung</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-red-700">0</div>
<div className="text-xs text-slate-500 mt-1">Ueberfaellig</div>
</div>
</div>
</div>
</div>
)}
{/* Stats Tab */}
{activeTab === 'stats' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Statistiken</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Aktive Zustimmungen</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Dokumente</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Offene DSR-Anfragen</div>
</div>
</div>
<div className="border border-slate-200 rounded-lg p-6">
<h3 className="font-semibold text-slate-900 mb-4">Zustimmungsrate nach Dokument</h3>
<div className="text-center py-8 text-slate-500">
Noch keine Daten verfuegbar
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,737 +0,0 @@
'use client'
/**
* DSFA - Datenschutz-Folgenabschätzung
*
* Art. 35 DSGVO - Datenschutz-Folgenabschätzung
*
* Migriert auf SDK API: /sdk/v1/dsgvo/dsfa
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface DSFARisk {
id: string
category: string // confidentiality, integrity, availability, rights_freedoms
description: string
likelihood: string // low, medium, high
impact: string // low, medium, high
risk_level: string // low, medium, high, very_high
affected_data?: string[]
}
interface DSFAMitigation {
id: string
risk_id: string
description: string
type: string // technical, organizational, legal
status: string // planned, in_progress, implemented, verified
implemented_at?: string
residual_risk: string // low, medium, high
responsible_party: string
}
interface DSFA {
id: string
tenant_id: string
namespace_id?: string
processing_activity_id?: string
name: string
description: string
processing_description: string
necessity_assessment: string
proportionality_assessment: string
risks: DSFARisk[]
mitigations: DSFAMitigation[]
dpo_consulted: boolean
dpo_opinion?: string
authority_consulted: boolean
authority_reference?: string
status: string // draft, in_progress, completed, approved, rejected
overall_risk_level: string // low, medium, high, very_high
conclusion: string
created_at: string
updated_at: string
created_by: string
approved_by?: string
approved_at?: string
}
export default function DSFAPage() {
const [dsfas, setDsfas] = useState<DSFA[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedProject, setExpandedProject] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'projects' | 'methodology'>('projects')
const [showCreateModal, setShowCreateModal] = useState(false)
const [newDsfa, setNewDsfa] = useState({
name: '',
description: '',
processing_description: '',
necessity_assessment: '',
proportionality_assessment: '',
overall_risk_level: 'medium',
status: 'draft',
conclusion: ''
})
useEffect(() => {
loadDSFAs()
}, [])
async function loadDSFAs() {
setLoading(true)
setError(null)
try {
const res = await fetch('/sdk/v1/dsgvo/dsfa', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
setDsfas(data.dsfas || [])
if ((data.dsfas || []).length > 0) {
setExpandedProject(data.dsfas[0].id)
}
} catch (err) {
console.error('Failed to load DSFAs:', err)
setError('Fehler beim Laden der DSFAs')
} finally {
setLoading(false)
}
}
async function createDSFA() {
try {
const res = await fetch('/sdk/v1/dsgvo/dsfa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(newDsfa)
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
setShowCreateModal(false)
setNewDsfa({
name: '',
description: '',
processing_description: '',
necessity_assessment: '',
proportionality_assessment: '',
overall_risk_level: 'medium',
status: 'draft',
conclusion: ''
})
loadDSFAs()
} catch (err) {
console.error('Failed to create DSFA:', err)
alert('Fehler beim Erstellen der DSFA')
}
}
async function deleteDSFA(id: string) {
if (!confirm('DSFA wirklich löschen?')) return
try {
const res = await fetch(`/sdk/v1/dsgvo/dsfa/${id}`, {
method: 'DELETE',
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
loadDSFAs()
} catch (err) {
console.error('Failed to delete DSFA:', err)
alert('Fehler beim Löschen')
}
}
async function exportDSFA(id: string) {
try {
const res = await fetch(`/sdk/v1/dsgvo/dsfa/${id}/export`, {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dsfa-export.json`
a.click()
window.URL.revokeObjectURL(url)
} catch (err) {
console.error('Export failed:', err)
alert('Export fehlgeschlagen')
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Abgeschlossen</span>
case 'approved':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800">Genehmigt</span>
case 'in_progress':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">In Bearbeitung</span>
case 'draft':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">Entwurf</span>
case 'rejected':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Abgelehnt</span>
default:
return null
}
}
const getRiskBadge = (level: string) => {
switch (level) {
case 'very_high':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Sehr hoch</span>
case 'high':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">Hoch</span>
case 'medium':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Mittel</span>
case 'low':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Niedrig</span>
default:
return null
}
}
const getCategoryLabel = (cat: string) => {
const labels: Record<string, string> = {
'confidentiality': 'Vertraulichkeit',
'integrity': 'Integrität',
'availability': 'Verfügbarkeit',
'rights_freedoms': 'Rechte der Betroffenen',
}
return labels[cat] || cat
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-500">Lade DSFAs...</div>
</div>
)
}
return (
<div>
<PagePurpose
title="Datenschutz-Folgenabschätzung (DSFA)"
purpose="Systematische Risikoanalyse für Verarbeitungen mit hohem Risiko gemäß Art. 35 DSGVO. Dokumentiert Risiken, Maßnahmen und DSB-Freigaben."
audience={['DSB', 'Projektleiter', 'Entwickler', 'Geschäftsführung']}
gdprArticles={['Art. 35 (Datenschutz-Folgenabschätzung)', 'Art. 36 (Vorherige Konsultation)']}
architecture={{
services: ['AI Compliance SDK (Go)', 'PostgreSQL'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'VVT', href: '/dsgvo/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'TOMs', href: '/dsgvo/tom', description: 'Technische Maßnahmen' },
{ name: 'DSR', href: '/dsgvo/dsr', description: 'Betroffenenrechte' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4 text-red-700">
{error}
</div>
)}
{/* Tabs */}
<div className="flex items-center justify-between mb-6">
<div className="flex gap-2">
{[
{ id: 'projects', label: 'DSFA-Projekte' },
{ id: 'methodology', label: 'Methodik' },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-primary-600 text-white'
: 'bg-white text-slate-700 border border-slate-200 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'projects' && (
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
+ Neue DSFA
</button>
)}
</div>
{/* Projects Tab */}
{activeTab === 'projects' && (
<div className="space-y-6">
{/* Statistics */}
<div className="grid grid-cols-1 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-slate-900">{dsfas.length}</div>
<div className="text-sm text-slate-500">DSFA-Projekte</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">
{dsfas.filter(d => d.status === 'completed' || d.status === 'approved').length}
</div>
<div className="text-sm text-slate-500">Abgeschlossen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-blue-600">
{dsfas.filter(d => d.status === 'in_progress').length}
</div>
<div className="text-sm text-slate-500">In Bearbeitung</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-orange-600">
{dsfas.filter(d => d.overall_risk_level === 'high' || d.overall_risk_level === 'very_high').length}
</div>
<div className="text-sm text-slate-500">Hohes Risiko</div>
</div>
</div>
{/* DSFA List */}
{dsfas.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
<div className="text-slate-400 text-4xl mb-4"></div>
<h3 className="text-lg font-medium text-slate-800 mb-2">Keine DSFAs vorhanden</h3>
<p className="text-slate-500 mb-4">Erstellen Sie eine Datenschutz-Folgenabschätzung für Verarbeitungen mit hohem Risiko.</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
Erste DSFA erstellen
</button>
</div>
) : (
<div className="space-y-4">
{dsfas.map(dsfa => (
<div key={dsfa.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setExpandedProject(expandedProject === dsfa.id ? null : dsfa.id)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="text-left">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-slate-900">{dsfa.name}</h3>
{getStatusBadge(dsfa.status)}
{getRiskBadge(dsfa.overall_risk_level)}
{dsfa.dpo_consulted && (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
DSB-Konsultation
</span>
)}
</div>
<p className="text-sm text-slate-500 mt-1">{dsfa.description}</p>
</div>
</div>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedProject === dsfa.id ? '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>
{expandedProject === dsfa.id && (
<div className="px-6 pb-6 border-t border-slate-100">
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left: Assessments */}
<div className="space-y-4">
{dsfa.processing_description && (
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Verarbeitungsbeschreibung</h4>
<p className="text-sm text-slate-700 bg-slate-50 rounded-lg p-3">{dsfa.processing_description}</p>
</div>
)}
{dsfa.necessity_assessment && (
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Notwendigkeitsbewertung</h4>
<p className="text-sm text-slate-700 bg-slate-50 rounded-lg p-3">{dsfa.necessity_assessment}</p>
</div>
)}
{dsfa.conclusion && (
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Fazit</h4>
<p className="text-sm text-slate-700 bg-slate-50 rounded-lg p-3">{dsfa.conclusion}</p>
</div>
)}
</div>
{/* Right: Meta */}
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Erstellt</h4>
<p className="text-slate-700">{new Date(dsfa.created_at).toLocaleDateString('de-DE')}</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Aktualisiert</h4>
<p className="text-slate-700">{new Date(dsfa.updated_at).toLocaleDateString('de-DE')}</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">DSB-Konsultation</h4>
<p className={dsfa.dpo_consulted ? 'text-green-600 font-medium' : 'text-yellow-600'}>
{dsfa.dpo_consulted ? 'Ja' : 'Ausstehend'}
</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Aufsichtsbehörde</h4>
<p className={dsfa.authority_consulted ? 'text-green-600 font-medium' : 'text-slate-500'}>
{dsfa.authority_consulted ? 'Konsultiert' : 'Nicht konsultiert'}
</p>
</div>
</div>
{dsfa.dpo_opinion && (
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">DSB-Stellungnahme</h4>
<p className="text-sm text-slate-700 bg-slate-50 rounded-lg p-3">{dsfa.dpo_opinion}</p>
</div>
)}
</div>
</div>
{/* Risks */}
{dsfa.risks && dsfa.risks.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-medium text-slate-500 mb-3">Identifizierte Risiken</h4>
<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-500">Kategorie</th>
<th className="text-left py-2 px-3 font-medium text-slate-500">Beschreibung</th>
<th className="text-left py-2 px-3 font-medium text-slate-500">Risiko</th>
</tr>
</thead>
<tbody>
{dsfa.risks.map(risk => (
<tr key={risk.id} className="border-b border-slate-100">
<td className="py-2 px-3 font-medium text-slate-900">{getCategoryLabel(risk.category)}</td>
<td className="py-2 px-3 text-slate-600">{risk.description}</td>
<td className="py-2 px-3">{getRiskBadge(risk.risk_level)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Mitigations */}
{dsfa.mitigations && dsfa.mitigations.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-slate-500 mb-3">Maßnahmen</h4>
<div className="space-y-2">
{dsfa.mitigations.map(mit => (
<div key={mit.id} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div>
<span className="text-sm text-slate-900">{mit.description}</span>
<div className="flex gap-2 mt-1">
<span className={`text-xs px-2 py-0.5 rounded ${
mit.type === 'technical' ? 'bg-blue-100 text-blue-700' :
mit.type === 'organizational' ? 'bg-purple-100 text-purple-700' :
'bg-slate-100 text-slate-600'
}`}>
{mit.type === 'technical' ? 'Technisch' : mit.type === 'organizational' ? 'Organisatorisch' : 'Rechtlich'}
</span>
{getStatusBadge(mit.status)}
</div>
</div>
<div className="text-right">
<div className="text-xs text-slate-500">Restrisiko</div>
{getRiskBadge(mit.residual_risk)}
</div>
</div>
))}
</div>
</div>
)}
<div className="mt-4 pt-4 border-t border-slate-100 flex gap-2">
<button
onClick={() => exportDSFA(dsfa.id)}
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-700 border border-slate-300 rounded-lg"
>
Exportieren
</button>
<button
onClick={() => deleteDSFA(dsfa.id)}
className="px-3 py-1.5 text-sm text-red-600 hover:text-red-700 border border-red-300 rounded-lg"
>
Löschen
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Methodology Tab */}
{activeTab === 'methodology' && (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">DSFA-Prozess nach Art. 35 DSGVO</h2>
<div className="space-y-6">
{[
{
step: 1,
title: 'Schwellwertanalyse',
description: 'Prüfung ob eine DSFA erforderlich ist anhand der Kriterien aus Art. 35 Abs. 3 und der DSK-Positivliste.',
details: ['Verarbeitung besonderer Kategorien (Art. 9)?', 'Systematisches Profiling?', 'Neue Technologien im Einsatz?', 'Daten von Minderjährigen?']
},
{
step: 2,
title: 'Beschreibung der Verarbeitung',
description: 'Systematische Beschreibung der geplanten Verarbeitungsvorgänge und Zwecke.',
details: ['Art, Umfang, Umstände der Verarbeitung', 'Zweck der Verarbeitung', 'Betroffene Personengruppen', 'Verantwortlichkeiten']
},
{
step: 3,
title: 'Notwendigkeit & Verhältnismäßigkeit',
description: 'Bewertung ob die Verarbeitung notwendig und verhältnismäßig ist.',
details: ['Rechtsgrundlage vorhanden?', 'Zweckbindung eingehalten?', 'Datenminimierung beachtet?', 'Speicherbegrenzung definiert?']
},
{
step: 4,
title: 'Risikobewertung',
description: 'Systematische Bewertung der Risiken für Rechte und Freiheiten der Betroffenen.',
details: ['Risiken identifizieren', 'Eintrittswahrscheinlichkeit bewerten', 'Schwere der Auswirkungen bewerten', 'Risiko-Score berechnen']
},
{
step: 5,
title: 'Abhilfemaßnahmen',
description: 'Definition von Maßnahmen zur Eindämmung der identifizierten Risiken.',
details: ['Technische Maßnahmen (TOMs)', 'Organisatorische Maßnahmen', 'Restrisiko-Bewertung', 'Implementierungsplan']
},
{
step: 6,
title: 'DSB-Konsultation',
description: 'Einholung der Stellungnahme des Datenschutzbeauftragten.',
details: ['DSFA dem DSB vorlegen', 'Stellungnahme dokumentieren', 'Ggf. Anpassungen vornehmen', 'Freigabe erteilen']
},
{
step: 7,
title: 'Vorherige Konsultation (Art. 36)',
description: 'Bei verbleibendem hohen Risiko: Konsultation der Aufsichtsbehörde.',
details: ['Nur bei hohem Restrisiko erforderlich', 'Aufsichtsbehörde hat 8 Wochen zur Prüfung', 'Dokumentation der Konsultation', 'Umsetzung der Auflagen']
}
].map(item => (
<div key={item.step} className="flex gap-4">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center font-bold">
{item.step}
</div>
<div className="flex-grow">
<h3 className="font-semibold text-slate-900">{item.title}</h3>
<p className="text-sm text-slate-600 mt-1">{item.description}</p>
<ul className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-slate-500">
{item.details.map((detail, idx) => (
<li key={idx} className="flex items-center gap-1">
<span className="text-primary-400"></span> {detail}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
{/* When is DSFA required */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Wann ist eine DSFA erforderlich?</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<h3 className="font-medium text-slate-700">Art. 35 Abs. 3 - Pflichtfälle:</h3>
<ul className="space-y-2 text-sm text-slate-600">
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
Systematische Bewertung persönlicher Aspekte (Profiling)
</li>
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
Umfangreiche Verarbeitung besonderer Kategorien (Art. 9)
</li>
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
Systematische Überwachung öffentlicher Bereiche
</li>
</ul>
</div>
<div className="space-y-3">
<h3 className="font-medium text-slate-700">Zusätzliche Kriterien (DSK-Liste):</h3>
<ul className="space-y-2 text-sm text-slate-600">
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Verarbeitung von Daten Minderjähriger
</li>
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Einsatz neuer Technologien (z.B. KI)
</li>
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Zusammenführung von Datensätzen
</li>
<li className="flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
Automatisierte Entscheidungsfindung
</li>
</ul>
</div>
</div>
</div>
</div>
)}
{/* Info */}
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" 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>
<h4 className="font-semibold text-yellow-900">Wichtiger Hinweis</h4>
<p className="text-sm text-yellow-800 mt-1">
Eine DSFA ist <strong>vor</strong> Beginn der Verarbeitung durchzuführen. Bei wesentlichen Änderungen
an bestehenden Verarbeitungen muss die DSFA aktualisiert werden. Die Dokumentation muss
der Aufsichtsbehörde auf Anfrage vorgelegt werden können.
</p>
</div>
</div>
</div>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<h3 className="text-lg font-semibold text-slate-900">Neue DSFA erstellen</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newDsfa.name}
onChange={(e) => setNewDsfa({ ...newDsfa, name: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. KI-gestützte Korrektur und Bewertung"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung *</label>
<textarea
value={newDsfa.description}
onChange={(e) => setNewDsfa({ ...newDsfa, description: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-20"
placeholder="Kurze Beschreibung der zu bewertenden Verarbeitung..."
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verarbeitungsbeschreibung</label>
<textarea
value={newDsfa.processing_description}
onChange={(e) => setNewDsfa({ ...newDsfa, processing_description: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-24"
placeholder="Detaillierte Beschreibung der Verarbeitungsvorgänge..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Risikostufe</label>
<select
value={newDsfa.overall_risk_level}
onChange={(e) => setNewDsfa({ ...newDsfa, overall_risk_level: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
<option value="very_high">Sehr hoch</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select
value={newDsfa.status}
onChange={(e) => setNewDsfa({ ...newDsfa, status: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="draft">Entwurf</option>
<option value="in_progress">In Bearbeitung</option>
<option value="completed">Abgeschlossen</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Notwendigkeitsbewertung</label>
<textarea
value={newDsfa.necessity_assessment}
onChange={(e) => setNewDsfa({ ...newDsfa, necessity_assessment: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-20"
placeholder="Warum ist die Verarbeitung notwendig und verhältnismäßig?"
/>
</div>
</div>
<div className="p-6 border-t border-slate-200 flex justify-end gap-3">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Abbrechen
</button>
<button
onClick={createDSFA}
disabled={!newDsfa.name || !newDsfa.description}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
DSFA erstellen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,711 +0,0 @@
'use client'
/**
* DSR (Data Subject Requests) Admin Page
*
* GDPR Article 15-21 Request Management
*
* Migriert auf SDK API: /sdk/v1/dsgvo/dsr
*/
import { useEffect, useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface DSRRequest {
id: string
tenant_id: string
namespace_id?: string
request_type: string // access, rectification, erasure, restriction, portability, objection
status: string // received, verified, in_progress, completed, rejected, extended
subject_name: string
subject_email: string
subject_identifier?: string
request_description: string
request_channel: string // email, form, phone, letter
received_at: string
verified_at?: string
verification_method?: string
deadline_at: string
extended_deadline_at?: string
extension_reason?: string
completed_at?: string
response_sent: boolean
response_sent_at?: string
response_method?: string
rejection_reason?: string
notes?: string
affected_systems?: string[]
assigned_to?: string
created_at: string
updated_at: string
}
interface DSRStats {
total: number
received: number
in_progress: number
completed: number
overdue: number
}
export default function DSRPage() {
const [requests, setRequests] = useState<DSRRequest[]>([])
const [stats, setStats] = useState<DSRStats | null>(null)
const [loading, setLoading] = useState(true)
const [selectedRequest, setSelectedRequest] = useState<DSRRequest | null>(null)
const [filter, setFilter] = useState<string>('all')
const [error, setError] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [newRequest, setNewRequest] = useState({
request_type: 'access',
subject_name: '',
subject_email: '',
subject_identifier: '',
request_description: '',
request_channel: 'email',
notes: ''
})
useEffect(() => {
loadRequests()
}, [])
async function loadRequests() {
setLoading(true)
setError(null)
try {
const res = await fetch('/sdk/v1/dsgvo/dsr', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
const allRequests = data.dsrs || []
setRequests(allRequests)
// Calculate stats
const now = new Date()
setStats({
total: allRequests.length,
received: allRequests.filter((r: DSRRequest) => r.status === 'received' || r.status === 'verified').length,
in_progress: allRequests.filter((r: DSRRequest) => r.status === 'in_progress').length,
completed: allRequests.filter((r: DSRRequest) => r.status === 'completed').length,
overdue: allRequests.filter((r: DSRRequest) => {
const deadline = r.extended_deadline_at ? new Date(r.extended_deadline_at) : new Date(r.deadline_at)
return deadline < now && r.status !== 'completed' && r.status !== 'rejected'
}).length,
})
} catch (err) {
console.error('Failed to load DSRs:', err)
setError('Fehler beim Laden der Anfragen')
} finally {
setLoading(false)
}
}
async function createRequest() {
try {
const res = await fetch('/sdk/v1/dsgvo/dsr', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(newRequest)
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
setShowCreateModal(false)
setNewRequest({
request_type: 'access',
subject_name: '',
subject_email: '',
subject_identifier: '',
request_description: '',
request_channel: 'email',
notes: ''
})
loadRequests()
} catch (err) {
console.error('Failed to create DSR:', err)
alert('Fehler beim Erstellen der Anfrage')
}
}
async function updateStatus(id: string, status: string) {
try {
const res = await fetch(`/sdk/v1/dsgvo/dsr/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify({ status })
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
setSelectedRequest(null)
loadRequests()
} catch (err) {
console.error('Failed to update DSR:', err)
alert('Fehler beim Aktualisieren')
}
}
async function exportDSRs(format: 'csv' | 'json') {
try {
const res = await fetch(`/sdk/v1/dsgvo/export/dsr?format=${format}`, {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dsr-export.${format}`
a.click()
window.URL.revokeObjectURL(url)
} catch (err) {
console.error('Export failed:', err)
alert('Export fehlgeschlagen')
}
}
// Get status badge color
const getStatusColor = (status: string) => {
switch (status) {
case 'received':
return 'bg-slate-100 text-slate-800'
case 'verified':
return 'bg-yellow-100 text-yellow-800'
case 'in_progress':
return 'bg-blue-100 text-blue-800'
case 'completed':
return 'bg-green-100 text-green-800'
case 'rejected':
return 'bg-red-100 text-red-800'
case 'extended':
return 'bg-orange-100 text-orange-800'
default:
return 'bg-slate-100 text-slate-800'
}
}
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
'received': 'Eingegangen',
'verified': 'Verifiziert',
'in_progress': 'In Bearbeitung',
'completed': 'Abgeschlossen',
'rejected': 'Abgelehnt',
'extended': 'Verlängert'
}
return labels[status] || status
}
// Get request type label
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
'access': 'Auskunft (Art. 15)',
'rectification': 'Berichtigung (Art. 16)',
'erasure': 'Löschung (Art. 17)',
'restriction': 'Einschränkung (Art. 18)',
'portability': 'Datenübertragbarkeit (Art. 20)',
'objection': 'Widerspruch (Art. 21)',
}
return labels[type] || type
}
const getChannelLabel = (channel: string) => {
const labels: Record<string, string> = {
'email': 'E-Mail',
'form': 'Formular',
'phone': 'Telefon',
'letter': 'Brief',
}
return labels[channel] || channel
}
// Filter requests
const filteredRequests = requests.filter(r => {
if (filter === 'all') return true
if (filter === 'overdue') {
const deadline = r.extended_deadline_at ? new Date(r.extended_deadline_at) : new Date(r.deadline_at)
return deadline < new Date() && r.status !== 'completed' && r.status !== 'rejected'
}
if (filter === 'open') {
return r.status === 'received' || r.status === 'verified'
}
return r.status === filter
})
// Format date
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
// Check if overdue
const isOverdue = (request: DSRRequest) => {
const deadline = request.extended_deadline_at ? new Date(request.extended_deadline_at) : new Date(request.deadline_at)
return deadline < new Date() && request.status !== 'completed' && request.status !== 'rejected'
}
// Calculate days until deadline
const daysUntilDeadline = (request: DSRRequest) => {
const deadline = request.extended_deadline_at ? new Date(request.extended_deadline_at) : new Date(request.deadline_at)
const now = new Date()
const diff = Math.ceil((deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
return diff
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-500">Lade Anfragen...</div>
</div>
)
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Datenschutzanfragen (DSR)"
purpose="Verwalten Sie alle Betroffenenanfragen nach DSGVO Art. 15-21. Hier bearbeiten Sie Auskunfts-, Lösch- und Berichtigungsanfragen mit automatischer Fristüberwachung."
audience={['DSB', 'Compliance Officer', 'Support']}
gdprArticles={[
'Art. 15 (Auskunftsrecht)',
'Art. 16 (Berichtigung)',
'Art. 17 (Löschung)',
'Art. 18 (Einschränkung)',
'Art. 20 (Datenübertragbarkeit)',
'Art. 21 (Widerspruch)',
]}
architecture={{
services: ['AI Compliance SDK (Go)', 'PostgreSQL'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'VVT', href: '/dsgvo/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'Löschfristen', href: '/dsgvo/loeschfristen', description: 'Aufbewahrungsfristen' },
{ name: 'TOM', href: '/dsgvo/tom', description: 'Technische Maßnahmen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* Header Actions */}
<div className="flex items-center justify-between mb-6">
<div></div>
<div className="flex items-center gap-3">
<button
onClick={() => exportDSRs('csv')}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
CSV Export
</button>
<button
onClick={() => exportDSRs('json')}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
JSON Export
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
+ Neue Anfrage
</button>
</div>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{stats.total}</div>
<div className="text-sm text-slate-500">Gesamt</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-yellow-600">{stats.received}</div>
<div className="text-sm text-slate-500">Offen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-blue-600">{stats.in_progress}</div>
<div className="text-sm text-slate-500">In Bearbeitung</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
<div className="text-sm text-slate-500">Abgeschlossen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className={`text-2xl font-bold ${stats.overdue > 0 ? 'text-red-600' : 'text-slate-400'}`}>
{stats.overdue}
</div>
<div className="text-sm text-slate-500">Überfällig</div>
</div>
</div>
)}
{/* Filter Tabs */}
<div className="flex gap-2 mb-4 overflow-x-auto">
{[
{ value: 'all', label: 'Alle' },
{ value: 'open', label: 'Offen' },
{ value: 'in_progress', label: 'In Bearbeitung' },
{ value: 'completed', label: 'Abgeschlossen' },
{ value: 'overdue', label: 'Überfällig' },
].map((tab) => (
<button
key={tab.value}
onClick={() => setFilter(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
filter === tab.value
? 'bg-primary-600 text-white'
: 'bg-white text-slate-700 border border-slate-200 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Requests Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<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-left text-xs font-medium text-slate-500 uppercase">Betroffener</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kanal</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Frist</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredRequests.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">
Keine Anfragen gefunden
</td>
</tr>
) : (
filteredRequests.map((request) => (
<tr key={request.id} className={isOverdue(request) ? 'bg-red-50' : ''}>
<td className="px-4 py-3 text-sm text-slate-700">{getTypeLabel(request.request_type)}</td>
<td className="px-4 py-3">
<div className="text-sm text-slate-900">{request.subject_name}</div>
<div className="text-xs text-slate-500">{request.subject_email}</div>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(request.status)}`}>
{getStatusLabel(request.status)}
</span>
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{getChannelLabel(request.request_channel)}
</td>
<td className="px-4 py-3">
<div className={`text-sm ${isOverdue(request) ? 'text-red-600 font-medium' : 'text-slate-700'}`}>
{formatDate(request.extended_deadline_at || request.deadline_at)}
</div>
{request.status !== 'completed' && request.status !== 'rejected' && (
<div className={`text-xs ${daysUntilDeadline(request) < 0 ? 'text-red-500' : daysUntilDeadline(request) <= 7 ? 'text-orange-500' : 'text-slate-400'}`}>
{daysUntilDeadline(request) < 0
? `${Math.abs(daysUntilDeadline(request))} Tage überfällig`
: `${daysUntilDeadline(request)} Tage verbleibend`}
</div>
)}
</td>
<td className="px-4 py-3">
<button
onClick={() => setSelectedRequest(request)}
className="text-primary-600 hover:text-primary-700 text-sm font-medium"
>
Details
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Detail Modal */}
{selectedRequest && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">
{getTypeLabel(selectedRequest.request_type)}
</h3>
<button
onClick={() => setSelectedRequest(null)}
className="text-slate-400 hover:text-slate-600"
>
<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>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-slate-500">Betroffener</div>
<div className="font-medium text-slate-900">{selectedRequest.subject_name}</div>
<div className="text-sm text-slate-500">{selectedRequest.subject_email}</div>
</div>
<div>
<div className="text-sm text-slate-500">Status</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(selectedRequest.status)}`}>
{getStatusLabel(selectedRequest.status)}
</span>
</div>
<div>
<div className="text-sm text-slate-500">Eingegangen am</div>
<div className="font-medium text-slate-900">{formatDate(selectedRequest.received_at)}</div>
</div>
<div>
<div className="text-sm text-slate-500">Frist</div>
<div className={`font-medium ${isOverdue(selectedRequest) ? 'text-red-600' : 'text-slate-900'}`}>
{formatDate(selectedRequest.extended_deadline_at || selectedRequest.deadline_at)}
{selectedRequest.extended_deadline_at && (
<span className="text-xs text-orange-600 ml-2">(verlängert)</span>
)}
</div>
</div>
<div>
<div className="text-sm text-slate-500">Kanal</div>
<div className="font-medium text-slate-900">{getChannelLabel(selectedRequest.request_channel)}</div>
</div>
{selectedRequest.subject_identifier && (
<div>
<div className="text-sm text-slate-500">Kunden-ID</div>
<div className="font-medium text-slate-900 font-mono">{selectedRequest.subject_identifier}</div>
</div>
)}
</div>
{selectedRequest.request_description && (
<div>
<div className="text-sm text-slate-500 mb-1">Beschreibung</div>
<div className="bg-slate-50 rounded-lg p-3 text-sm text-slate-700">
{selectedRequest.request_description}
</div>
</div>
)}
{selectedRequest.notes && (
<div>
<div className="text-sm text-slate-500 mb-1">Notizen</div>
<div className="bg-slate-50 rounded-lg p-3 text-sm text-slate-700">
{selectedRequest.notes}
</div>
</div>
)}
{selectedRequest.affected_systems && selectedRequest.affected_systems.length > 0 && (
<div>
<div className="text-sm text-slate-500 mb-1">Betroffene Systeme</div>
<div className="flex flex-wrap gap-2">
{selectedRequest.affected_systems.map((sys, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">
{sys}
</span>
))}
</div>
</div>
)}
<div className="flex gap-2 pt-4 border-t border-slate-200">
{selectedRequest.status === 'received' && (
<button
onClick={() => updateStatus(selectedRequest.id, 'verified')}
className="px-4 py-2 bg-yellow-600 text-white rounded-lg text-sm font-medium hover:bg-yellow-700"
>
Verifizieren
</button>
)}
{(selectedRequest.status === 'received' || selectedRequest.status === 'verified') && (
<button
onClick={() => updateStatus(selectedRequest.id, 'in_progress')}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700"
>
Bearbeitung starten
</button>
)}
{selectedRequest.status === 'in_progress' && (
<>
<button
onClick={() => updateStatus(selectedRequest.id, 'completed')}
className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700"
>
Abschließen
</button>
<button
onClick={() => updateStatus(selectedRequest.id, 'rejected')}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700"
>
Ablehnen
</button>
</>
)}
</div>
</div>
</div>
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<h3 className="text-lg font-semibold text-slate-900">Neue Anfrage erfassen</h3>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ *</label>
<select
value={newRequest.request_type}
onChange={(e) => setNewRequest({ ...newRequest, request_type: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="access">Auskunft (Art. 15)</option>
<option value="rectification">Berichtigung (Art. 16)</option>
<option value="erasure">Löschung (Art. 17)</option>
<option value="restriction">Einschränkung (Art. 18)</option>
<option value="portability">Datenübertragbarkeit (Art. 20)</option>
<option value="objection">Widerspruch (Art. 21)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kanal</label>
<select
value={newRequest.request_channel}
onChange={(e) => setNewRequest({ ...newRequest, request_channel: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="email">E-Mail</option>
<option value="form">Formular</option>
<option value="phone">Telefon</option>
<option value="letter">Brief</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name des Betroffenen *</label>
<input
type="text"
value={newRequest.subject_name}
onChange={(e) => setNewRequest({ ...newRequest, subject_name: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="Max Mustermann"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail *</label>
<input
type="email"
value={newRequest.subject_email}
onChange={(e) => setNewRequest({ ...newRequest, subject_email: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="max@example.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kunden-ID (optional)</label>
<input
type="text"
value={newRequest.subject_identifier}
onChange={(e) => setNewRequest({ ...newRequest, subject_identifier: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. CUST-12345"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung der Anfrage</label>
<textarea
value={newRequest.request_description}
onChange={(e) => setNewRequest({ ...newRequest, request_description: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-24"
placeholder="Was genau wird angefragt..."
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Interne Notizen</label>
<textarea
value={newRequest.notes}
onChange={(e) => setNewRequest({ ...newRequest, notes: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-20"
placeholder="Interne Anmerkungen..."
/>
</div>
</div>
<div className="p-6 border-t border-slate-200 flex justify-end gap-3">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Abbrechen
</button>
<button
onClick={createRequest}
disabled={!newRequest.subject_name || !newRequest.subject_email}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Anfrage erfassen
</button>
</div>
</div>
</div>
)}
{/* Info Box */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<h4 className="font-semibold text-purple-900 mb-2">DSGVO-Fristen</h4>
<ul className="text-sm text-purple-800 space-y-1">
<li>Art. 15 (Auskunft): 1 Monat, verlängerbar auf 3 Monate</li>
<li>Art. 16 (Berichtigung): Unverzüglich</li>
<li>Art. 17 (Löschung): Unverzüglich</li>
<li>Art. 18 (Einschränkung): Unverzüglich</li>
<li>Art. 20 (Datenübertragbarkeit): 1 Monat</li>
<li>Art. 21 (Widerspruch): Unverzüglich</li>
</ul>
</div>
</div>
)
}

View File

@@ -1,498 +0,0 @@
'use client'
/**
* Einwilligungsverwaltung - User Consent Management
*
* Zentrale Uebersicht aller Nutzer-Einwilligungen aus:
* - Website
* - App
* - PWA
*
* Kategorien: Marketing, Statistik, Cookies, Rechtliche Dokumente
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
const API_BASE = '/api/admin/consent'
type Tab = 'overview' | 'documents' | 'cookies' | 'marketing' | 'audit'
interface ConsentStats {
total_users: number
consented_users: number
consent_rate: number
pending_consents: number
}
interface AuditEntry {
id: string
user_id: string
action: string
entity_type: string
entity_id: string
details: Record<string, unknown>
ip_address: string
created_at: string
}
interface ConsentSummary {
category: string
total: number
accepted: number
declined: number
pending: number
rate: number
}
export default function EinwilligungenPage() {
const [activeTab, setActiveTab] = useState<Tab>('overview')
const [stats, setStats] = useState<ConsentStats | null>(null)
const [auditLog, setAuditLog] = useState<AuditEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [authToken, setAuthToken] = useState<string>('')
useEffect(() => {
const token = localStorage.getItem('bp_admin_token')
if (token) {
setAuthToken(token)
}
}, [])
useEffect(() => {
if (activeTab === 'overview') {
loadStats()
} else if (activeTab === 'audit') {
loadAuditLog()
}
}, [activeTab, authToken])
async function loadStats() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/stats`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setStats(data)
} else {
setError('Fehler beim Laden der Statistiken')
}
} catch {
setError('Verbindungsfehler')
} finally {
setLoading(false)
}
}
async function loadAuditLog() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/audit-log?limit=50`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setAuditLog(data.entries || [])
} else {
setError('Fehler beim Laden des Audit-Logs')
}
} catch {
setError('Verbindungsfehler')
} finally {
setLoading(false)
}
}
// Mock data for consent summary (in production, this comes from API)
const consentSummary: ConsentSummary[] = [
{ category: 'AGB', total: 1250, accepted: 1248, declined: 0, pending: 2, rate: 99.8 },
{ category: 'Datenschutz', total: 1250, accepted: 1245, declined: 3, pending: 2, rate: 99.6 },
{ category: 'Cookies (Notwendig)', total: 1250, accepted: 1250, declined: 0, pending: 0, rate: 100 },
{ category: 'Cookies (Analyse)', total: 1250, accepted: 892, declined: 358, pending: 0, rate: 71.4 },
{ category: 'Cookies (Marketing)', total: 1250, accepted: 456, declined: 794, pending: 0, rate: 36.5 },
{ category: 'Newsletter', total: 1250, accepted: 312, declined: 938, pending: 0, rate: 25.0 },
]
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'documents', label: 'Dokumenten-Consents' },
{ id: 'cookies', label: 'Cookie-Consents' },
{ id: 'marketing', label: 'Marketing-Consents' },
{ id: 'audit', label: 'Audit-Trail' },
]
const getActionLabel = (action: string) => {
const labels: Record<string, string> = {
'consent_given': 'Zustimmung erteilt',
'consent_withdrawn': 'Zustimmung widerrufen',
'cookie_consent_updated': 'Cookie-Einstellungen aktualisiert',
'data_access': 'Datenzugriff',
'data_export_requested': 'Datenexport angefordert',
'data_deletion_requested': 'Loeschung angefordert',
'account_suspended': 'Account gesperrt',
'account_restored': 'Account wiederhergestellt',
}
return labels[action] || action
}
const getActionColor = (action: string) => {
if (action.includes('given') || action.includes('restored')) return 'bg-green-100 text-green-700'
if (action.includes('withdrawn') || action.includes('deleted') || action.includes('suspended')) return 'bg-red-100 text-red-700'
return 'bg-blue-100 text-blue-700'
}
return (
<div>
<PagePurpose
title="Einwilligungsverwaltung"
purpose="Zentrale Uebersicht aller Nutzer-Einwilligungen. Hier sehen Sie alle Zustimmungen zu rechtlichen Dokumenten, Cookies, Marketing und Statistik - erfasst ueber Website, App und PWA."
audience={['DSB', 'Compliance Officer', 'Marketing']}
gdprArticles={['Art. 6 (Rechtmaessigkeit)', 'Art. 7 (Einwilligung)', 'Art. 21 (Widerspruch)']}
architecture={{
services: ['consent-service (Go)'],
databases: ['PostgreSQL (user_consents, cookie_consents)'],
}}
relatedPages={[
{ name: 'Consent Dokumente', href: '/compliance/consent', description: 'Rechtliche Dokumente verwalten' },
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management' },
{ name: 'DSR', href: '/compliance/dsr', description: 'Betroffenenanfragen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{stats?.total_users || 1250}</div>
<div className="text-sm text-slate-500">Registrierte Nutzer</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats?.consented_users || 1245}</div>
<div className="text-sm text-slate-500">Mit Zustimmung</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-yellow-600">{stats?.pending_consents || 5}</div>
<div className="text-sm text-slate-500">Ausstehend</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-purple-600">{stats?.consent_rate?.toFixed(1) || 99.6}%</div>
<div className="text-sm text-slate-500">Zustimmungsrate</div>
</div>
</div>
{/* Tabs */}
<div className="mb-6">
<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>
{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>
)}
{/* Content */}
<div className="bg-white rounded-xl border border-slate-200">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Consent-Uebersicht nach Kategorie</h2>
<div className="space-y-4">
{consentSummary.map((item) => (
<div key={item.category} className="border border-slate-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium text-slate-900">{item.category}</h3>
<span className={`px-2 py-1 rounded text-xs font-medium ${
item.rate >= 90 ? 'bg-green-100 text-green-700' :
item.rate >= 50 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{item.rate}% Zustimmung
</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden mb-3">
<div
className={`h-full ${item.rate >= 90 ? 'bg-green-500' : item.rate >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${item.rate}%` }}
/>
</div>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-slate-500">Gesamt:</span>
<span className="ml-1 font-medium">{item.total}</span>
</div>
<div>
<span className="text-green-600">Akzeptiert:</span>
<span className="ml-1 font-medium">{item.accepted}</span>
</div>
<div>
<span className="text-red-600">Abgelehnt:</span>
<span className="ml-1 font-medium">{item.declined}</span>
</div>
<div>
<span className="text-yellow-600">Ausstehend:</span>
<span className="ml-1 font-medium">{item.pending}</span>
</div>
</div>
</div>
))}
</div>
{/* Export Button */}
<div className="mt-6 pt-6 border-t border-slate-200">
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium">
Consent-Report exportieren (CSV)
</button>
</div>
</div>
)}
{/* 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">Dokumenten-Einwilligungen</h2>
<div className="flex gap-2">
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="">Alle Dokumente</option>
<option value="terms">AGB</option>
<option value="privacy">Datenschutz</option>
<option value="cookies">Cookies</option>
</select>
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="withdrawn">Widerrufen</option>
</select>
</div>
</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">Nutzer-ID</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Dokument</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Version</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Status</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Datum</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Quelle</th>
</tr>
</thead>
<tbody>
{/* Sample data - in production, this comes from API */}
{[
{ id: 'usr_123', doc: 'AGB', version: 'v2.1.0', status: 'active', date: '2024-12-15', source: 'Website' },
{ id: 'usr_124', doc: 'Datenschutz', version: 'v3.0.0', status: 'active', date: '2024-12-15', source: 'App' },
{ id: 'usr_125', doc: 'AGB', version: 'v2.1.0', status: 'withdrawn', date: '2024-12-14', source: 'PWA' },
].map((consent, idx) => (
<tr key={idx} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4 font-mono text-sm">{consent.id}</td>
<td className="py-3 px-4">{consent.doc}</td>
<td className="py-3 px-4 text-sm text-slate-500">{consent.version}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${
consent.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{consent.status === 'active' ? 'Aktiv' : 'Widerrufen'}
</span>
</td>
<td className="py-3 px-4 text-sm text-slate-500">{consent.date}</td>
<td className="py-3 px-4">
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">{consent.source}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Cookies Tab */}
{activeTab === 'cookies' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Cookie-Einwilligungen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ name: 'Notwendige Cookies', key: 'necessary', mandatory: true, rate: 100, description: 'Erforderlich fuer Grundfunktionen' },
{ name: 'Funktionale Cookies', key: 'functional', mandatory: false, rate: 82.3, description: 'Verbesserte Nutzererfahrung' },
{ name: 'Analyse Cookies', key: 'analytics', mandatory: false, rate: 71.4, description: 'Anonyme Nutzungsstatistiken' },
{ name: 'Marketing Cookies', key: 'marketing', mandatory: false, rate: 36.5, description: 'Personalisierte Werbung' },
].map((category) => (
<div key={category.key} className="border border-slate-200 rounded-xl p-5">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-slate-900">{category.name}</h3>
<p className="text-sm text-slate-500">{category.description}</p>
</div>
{category.mandatory && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-medium">Pflicht</span>
)}
</div>
<div className="flex items-center gap-4">
<div className="flex-grow h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${category.rate >= 80 ? 'bg-green-500' : category.rate >= 50 ? 'bg-yellow-500' : 'bg-orange-500'}`}
style={{ width: `${category.rate}%` }}
/>
</div>
<span className="text-lg font-bold text-slate-900">{category.rate}%</span>
</div>
</div>
))}
</div>
<div className="mt-6 p-4 bg-slate-50 rounded-lg">
<h4 className="font-medium text-slate-900 mb-2">Cookie-Banner Einstellungen</h4>
<p className="text-sm text-slate-600">
Das Cookie-Banner wird auf allen Plattformen (Website, App, PWA) einheitlich angezeigt.
Nutzer koennen ihre Praeferenzen jederzeit in den Einstellungen aendern.
</p>
</div>
</div>
)}
{/* Marketing Tab */}
{activeTab === 'marketing' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Marketing-Einwilligungen</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
{[
{ name: 'E-Mail Newsletter', rate: 25.0, total: 1250, subscribed: 312 },
{ name: 'Push-Benachrichtigungen', rate: 45.2, total: 1250, subscribed: 565 },
{ name: 'Personalisierte Werbung', rate: 18.5, total: 1250, subscribed: 231 },
].map((channel) => (
<div key={channel.name} className="bg-white border border-slate-200 rounded-xl p-5">
<h3 className="font-semibold text-slate-900 mb-2">{channel.name}</h3>
<div className="text-3xl font-bold text-purple-600 mb-1">{channel.rate}%</div>
<div className="text-sm text-slate-500">{channel.subscribed} von {channel.total} Nutzern</div>
<div className="mt-4 h-2 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-purple-500" style={{ width: `${channel.rate}%` }} />
</div>
</div>
))}
</div>
<div className="border border-slate-200 rounded-lg p-4">
<h4 className="font-medium text-slate-900 mb-3">Opt-Out Anfragen (letzte 30 Tage)</h4>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xl font-bold text-slate-900">23</div>
<div className="text-xs text-slate-500">Newsletter</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xl font-bold text-slate-900">45</div>
<div className="text-xs text-slate-500">Push</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<div className="text-xl font-bold text-slate-900">12</div>
<div className="text-xs text-slate-500">Werbung</div>
</div>
</div>
</div>
</div>
)}
{/* Audit Tab */}
{activeTab === 'audit' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Consent Audit-Trail</h2>
<div className="flex gap-2">
<select className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="">Alle Aktionen</option>
<option value="consent_given">Zustimmung erteilt</option>
<option value="consent_withdrawn">Zustimmung widerrufen</option>
<option value="cookie_consent_updated">Cookie aktualisiert</option>
</select>
<button
onClick={loadAuditLog}
className="px-3 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm hover:bg-slate-200"
>
Aktualisieren
</button>
</div>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Audit-Log...</div>
) : (
<div className="space-y-3">
{(auditLog.length > 0 ? auditLog : [
// Sample data
{ id: '1', user_id: 'usr_123', action: 'consent_given', entity_type: 'document', entity_id: 'doc_agb', details: {}, ip_address: '192.168.1.1', created_at: '2024-12-15T10:30:00Z' },
{ id: '2', user_id: 'usr_124', action: 'cookie_consent_updated', entity_type: 'cookie', entity_id: 'analytics', details: {}, ip_address: '192.168.1.2', created_at: '2024-12-15T10:25:00Z' },
{ id: '3', user_id: 'usr_125', action: 'consent_withdrawn', entity_type: 'document', entity_id: 'doc_newsletter', details: {}, ip_address: '192.168.1.3', created_at: '2024-12-15T10:20:00Z' },
]).map((entry) => (
<div key={entry.id} className="border border-slate-200 rounded-lg p-4 hover:bg-slate-50">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${getActionColor(entry.action)}`}>
{getActionLabel(entry.action)}
</span>
<span className="font-mono text-sm text-slate-600">{entry.user_id}</span>
</div>
<span className="text-sm text-slate-400">
{new Date(entry.created_at).toLocaleString('de-DE')}
</span>
</div>
<div className="mt-2 text-sm text-slate-500">
<span className="text-slate-400">Entity:</span> {entry.entity_type} / {entry.entity_id}
<span className="mx-2 text-slate-300">|</span>
<span className="text-slate-400">IP:</span> {entry.ip_address}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* GDPR Notice */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex 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>
<h4 className="font-semibold text-purple-900">DSGVO-Hinweis</h4>
<p className="text-sm text-purple-800 mt-1">
Alle Einwilligungen werden revisionssicher gespeichert und koennen jederzeit nachgewiesen werden.
Nutzer koennen ihre Einwilligungen gemaess Art. 7 Abs. 3 DSGVO jederzeit widerrufen.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,864 +0,0 @@
'use client'
/**
* Escalation Queue Page
*
* DSB Review & Approval Workflow for UCCA Assessments
* Implements E0-E3 escalation levels with SLA tracking
*
* API: /sdk/v1/ucca/escalations
*/
import { useEffect, useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// Types
interface Escalation {
id: string
tenant_id: string
assessment_id: string
escalation_level: 'E0' | 'E1' | 'E2' | 'E3'
escalation_reason: string
assigned_to?: string
assigned_role?: string
assigned_at?: string
status: 'pending' | 'assigned' | 'in_review' | 'approved' | 'rejected' | 'returned'
reviewer_id?: string
reviewer_notes?: string
reviewed_at?: string
decision?: 'approve' | 'reject' | 'modify' | 'escalate'
decision_notes?: string
decision_at?: string
conditions?: string[]
created_at: string
updated_at: string
due_date?: string
notification_sent: boolean
// Joined fields
assessment_title?: string
assessment_feasibility?: string
assessment_risk_score?: number
assessment_domain?: string
}
interface EscalationHistory {
id: string
escalation_id: string
action: string
old_status?: string
new_status?: string
old_level?: string
new_level?: string
actor_id: string
actor_role?: string
notes?: string
created_at: string
}
interface EscalationStats {
total_pending: number
total_in_review: number
total_approved: number
total_rejected: number
by_level: Record<string, number>
overdue_sla: number
approaching_sla: number
avg_resolution_hours: number
}
interface DSBPoolMember {
id: string
tenant_id: string
user_id: string
user_name: string
user_email: string
role: string
is_active: boolean
max_concurrent_reviews: number
current_reviews: number
created_at: string
updated_at: string
}
// Constants
const LEVEL_CONFIG = {
E0: { label: 'Auto-Approve', color: 'bg-green-100 text-green-800', description: 'Automatische Freigabe' },
E1: { label: 'Team-Lead', color: 'bg-blue-100 text-blue-800', description: 'Team-Lead Review erforderlich' },
E2: { label: 'DSB', color: 'bg-yellow-100 text-yellow-800', description: 'DSB-Konsultation erforderlich' },
E3: { label: 'DSB + Legal', color: 'bg-red-100 text-red-800', description: 'DSB + Rechtsabteilung erforderlich' },
}
const STATUS_CONFIG = {
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-800' },
assigned: { label: 'Zugewiesen', color: 'bg-blue-100 text-blue-800' },
in_review: { label: 'In Prüfung', color: 'bg-yellow-100 text-yellow-800' },
approved: { label: 'Genehmigt', color: 'bg-green-100 text-green-800' },
rejected: { label: 'Abgelehnt', color: 'bg-red-100 text-red-800' },
returned: { label: 'Zurückgegeben', color: 'bg-orange-100 text-orange-800' },
}
export default function EscalationsPage() {
const [escalations, setEscalations] = useState<Escalation[]>([])
const [stats, setStats] = useState<EscalationStats | null>(null)
const [dsbPool, setDsbPool] = useState<DSBPoolMember[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filters
const [statusFilter, setStatusFilter] = useState<string>('pending')
const [levelFilter, setLevelFilter] = useState<string>('')
const [myReviewsOnly, setMyReviewsOnly] = useState(false)
// Selected escalation for detail view
const [selectedEscalation, setSelectedEscalation] = useState<Escalation | null>(null)
const [escalationHistory, setEscalationHistory] = useState<EscalationHistory[]>([])
// Decision modal
const [showDecisionModal, setShowDecisionModal] = useState(false)
const [decisionForm, setDecisionForm] = useState({
decision: 'approve' as 'approve' | 'reject' | 'modify' | 'escalate',
decision_notes: '',
conditions: [] as string[],
})
const [newCondition, setNewCondition] = useState('')
// DSB Pool modal
const [showDSBPoolModal, setShowDSBPoolModal] = useState(false)
const [newMember, setNewMember] = useState({
user_id: '',
user_name: '',
user_email: '',
role: 'dsb',
max_concurrent_reviews: 10,
})
// Load data
useEffect(() => {
loadEscalations()
loadStats()
loadDSBPool()
}, [statusFilter, levelFilter, myReviewsOnly])
async function loadEscalations() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
if (statusFilter && statusFilter !== 'all') params.append('status', statusFilter)
if (levelFilter) params.append('level', levelFilter)
if (myReviewsOnly) params.append('my_reviews', 'true')
const res = await fetch(`/sdk/v1/ucca/escalations?${params}`, {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setEscalations(data.escalations || [])
} catch (err) {
console.error('Failed to load escalations:', err)
setError('Fehler beim Laden der Eskalationen')
} finally {
setLoading(false)
}
}
async function loadStats() {
try {
const res = await fetch('/sdk/v1/ucca/escalations/stats', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setStats(data)
} catch (err) {
console.error('Failed to load stats:', err)
}
}
async function loadDSBPool() {
try {
const res = await fetch('/sdk/v1/ucca/dsb-pool', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setDsbPool(data.members || [])
} catch (err) {
console.error('Failed to load DSB pool:', err)
}
}
async function loadEscalationDetail(id: string) {
try {
const res = await fetch(`/sdk/v1/ucca/escalations/${id}`, {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setSelectedEscalation(data.escalation)
setEscalationHistory(data.history || [])
} catch (err) {
console.error('Failed to load escalation detail:', err)
}
}
async function startReview(id: string) {
try {
const res = await fetch(`/sdk/v1/ucca/escalations/${id}/review`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
loadEscalations()
if (selectedEscalation?.id === id) {
loadEscalationDetail(id)
}
} catch (err) {
console.error('Failed to start review:', err)
alert('Fehler beim Starten der Prüfung')
}
}
async function submitDecision() {
if (!selectedEscalation) return
try {
const res = await fetch(`/sdk/v1/ucca/escalations/${selectedEscalation.id}/decide`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(decisionForm)
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setShowDecisionModal(false)
setDecisionForm({ decision: 'approve', decision_notes: '', conditions: [] })
loadEscalations()
loadStats()
setSelectedEscalation(null)
} catch (err) {
console.error('Failed to submit decision:', err)
alert('Fehler beim Speichern der Entscheidung')
}
}
async function assignEscalation(escalationId: string, userId: string) {
try {
const res = await fetch(`/sdk/v1/ucca/escalations/${escalationId}/assign`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify({ assigned_to: userId })
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
loadEscalations()
if (selectedEscalation?.id === escalationId) {
loadEscalationDetail(escalationId)
}
} catch (err) {
console.error('Failed to assign escalation:', err)
alert('Fehler bei der Zuweisung')
}
}
async function addDSBPoolMember() {
try {
const res = await fetch('/sdk/v1/ucca/dsb-pool', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(newMember)
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setShowDSBPoolModal(false)
setNewMember({ user_id: '', user_name: '', user_email: '', role: 'dsb', max_concurrent_reviews: 10 })
loadDSBPool()
} catch (err) {
console.error('Failed to add DSB pool member:', err)
alert('Fehler beim Hinzufügen')
}
}
function addCondition() {
if (newCondition.trim()) {
setDecisionForm(prev => ({
...prev,
conditions: [...prev.conditions, newCondition.trim()]
}))
setNewCondition('')
}
}
function removeCondition(index: number) {
setDecisionForm(prev => ({
...prev,
conditions: prev.conditions.filter((_, i) => i !== index)
}))
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function isOverdue(dueDate?: string) {
if (!dueDate) return false
return new Date(dueDate) < new Date()
}
function getTimeRemaining(dueDate?: string) {
if (!dueDate) return null
const now = new Date()
const due = new Date(dueDate)
const diff = due.getTime() - now.getTime()
if (diff < 0) return 'Überfällig'
const hours = Math.floor(diff / (1000 * 60 * 60))
if (hours < 24) return `${hours}h verbleibend`
const days = Math.floor(hours / 24)
return `${days}d verbleibend`
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Eskalations-Queue</h1>
<p className="text-gray-600 mt-1">DSB Review & Freigabe-Workflow für UCCA Assessments</p>
</div>
<button
onClick={() => setShowDSBPoolModal(true)}
className="px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors"
>
DSB-Pool verwalten
</button>
</div>
<PagePurpose
title="Eskalations-Queue"
purpose="Verwaltung von Eskalationen aus dem Advisory Board. DSB und Team-Leads prüfen risikoreiche Use-Cases (E1-E3) und erteilen Freigaben oder Ablehnungen mit Auflagen."
audience={['DSB', 'Team-Leads', 'Legal']}
gdprArticles={['Art. 5', 'Art. 22', 'Art. 35', 'Art. 36']}
/>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div className="bg-white rounded-lg border p-4">
<div className="text-2xl font-bold text-gray-900">{stats.total_pending}</div>
<div className="text-sm text-gray-600">Ausstehend</div>
</div>
<div className="bg-white rounded-lg border p-4">
<div className="text-2xl font-bold text-yellow-600">{stats.total_in_review}</div>
<div className="text-sm text-gray-600">In Prüfung</div>
</div>
<div className="bg-white rounded-lg border p-4">
<div className="text-2xl font-bold text-green-600">{stats.total_approved}</div>
<div className="text-sm text-gray-600">Genehmigt</div>
</div>
<div className="bg-white rounded-lg border p-4">
<div className="text-2xl font-bold text-red-600">{stats.total_rejected}</div>
<div className="text-sm text-gray-600">Abgelehnt</div>
</div>
<div className="bg-white rounded-lg border p-4">
<div className="text-2xl font-bold text-red-600">{stats.overdue_sla}</div>
<div className="text-sm text-gray-600">SLA überschritten</div>
</div>
<div className="bg-white rounded-lg border p-4">
<div className="text-2xl font-bold text-orange-600">{stats.approaching_sla}</div>
<div className="text-sm text-gray-600">SLA gefährdet</div>
</div>
</div>
)}
{/* Level Distribution */}
{stats && stats.by_level && (
<div className="bg-white rounded-lg border p-4">
<h3 className="font-medium text-gray-900 mb-3">Verteilung nach Eskalationsstufe</h3>
<div className="flex gap-4">
{Object.entries(LEVEL_CONFIG).map(([level, config]) => (
<div key={level} className="flex items-center gap-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${config.color}`}>
{level}
</span>
<span className="text-gray-600">{stats.by_level[level] || 0}</span>
</div>
))}
</div>
</div>
)}
{/* Filters */}
<div className="bg-white rounded-lg border p-4">
<div className="flex flex-wrap gap-4 items-center">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="pending">Ausstehend</option>
<option value="assigned">Zugewiesen</option>
<option value="in_review">In Prüfung</option>
<option value="approved">Genehmigt</option>
<option value="rejected">Abgelehnt</option>
<option value="returned">Zurückgegeben</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Level</label>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="">Alle</option>
<option value="E1">E1 - Team-Lead</option>
<option value="E2">E2 - DSB</option>
<option value="E3">E3 - DSB + Legal</option>
</select>
</div>
<div className="flex items-center gap-2 pt-6">
<input
type="checkbox"
id="myReviews"
checked={myReviewsOnly}
onChange={(e) => setMyReviewsOnly(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="myReviews" className="text-sm text-gray-700">Nur meine Zuweisungen</label>
</div>
<div className="ml-auto pt-6">
<button
onClick={loadEscalations}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
Aktualisieren
</button>
</div>
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Escalation List */}
<div className="lg:col-span-2 space-y-4">
{loading ? (
<div className="bg-white rounded-lg border p-8 text-center text-gray-500">
Laden...
</div>
) : escalations.length === 0 ? (
<div className="bg-white rounded-lg border p-8 text-center text-gray-500">
Keine Eskalationen gefunden
</div>
) : (
escalations.map((esc) => (
<div
key={esc.id}
onClick={() => loadEscalationDetail(esc.id)}
className={`bg-white rounded-lg border p-4 cursor-pointer hover:border-violet-300 transition-colors ${
selectedEscalation?.id === esc.id ? 'border-violet-500 ring-2 ring-violet-200' : ''
} ${isOverdue(esc.due_date) && esc.status !== 'approved' && esc.status !== 'rejected' ? 'border-red-300 bg-red-50' : ''}`}
>
<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 rounded text-xs font-medium ${LEVEL_CONFIG[esc.escalation_level].color}`}>
{esc.escalation_level}
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${STATUS_CONFIG[esc.status].color}`}>
{STATUS_CONFIG[esc.status].label}
</span>
{esc.due_date && (
<span className={`text-xs ${isOverdue(esc.due_date) ? 'text-red-600 font-medium' : 'text-gray-500'}`}>
{getTimeRemaining(esc.due_date)}
</span>
)}
</div>
<h3 className="font-medium text-gray-900">
{esc.assessment_title || `Assessment ${esc.assessment_id.slice(0, 8)}`}
</h3>
<p className="text-sm text-gray-600 mt-1 line-clamp-2">{esc.escalation_reason}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span>Erstellt: {formatDate(esc.created_at)}</span>
{esc.assessment_risk_score !== undefined && (
<span>Risk: {esc.assessment_risk_score}/100</span>
)}
{esc.assessment_domain && (
<span>Domain: {esc.assessment_domain}</span>
)}
</div>
</div>
{esc.status === 'pending' && (
<button
onClick={(e) => {
e.stopPropagation()
startReview(esc.id)
}}
className="px-3 py-1 text-sm bg-violet-100 text-violet-700 rounded hover:bg-violet-200 transition-colors"
>
Review starten
</button>
)}
</div>
</div>
))
)}
</div>
{/* Detail Panel */}
<div className="lg:col-span-1">
{selectedEscalation ? (
<div className="bg-white rounded-lg border p-4 sticky top-6 space-y-4">
<h3 className="font-semibold text-gray-900">Detail</h3>
{/* Status & Level */}
<div className="flex gap-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${LEVEL_CONFIG[selectedEscalation.escalation_level].color}`}>
{selectedEscalation.escalation_level} - {LEVEL_CONFIG[selectedEscalation.escalation_level].label}
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${STATUS_CONFIG[selectedEscalation.status].color}`}>
{STATUS_CONFIG[selectedEscalation.status].label}
</span>
</div>
{/* Reason */}
<div>
<div className="text-sm font-medium text-gray-700">Grund</div>
<div className="text-sm text-gray-600 mt-1">{selectedEscalation.escalation_reason}</div>
</div>
{/* SLA */}
{selectedEscalation.due_date && (
<div>
<div className="text-sm font-medium text-gray-700">SLA Deadline</div>
<div className={`text-sm mt-1 ${isOverdue(selectedEscalation.due_date) ? 'text-red-600 font-medium' : 'text-gray-600'}`}>
{formatDate(selectedEscalation.due_date)}
{isOverdue(selectedEscalation.due_date) && ' (Überfällig!)'}
</div>
</div>
)}
{/* Assignment */}
<div>
<div className="text-sm font-medium text-gray-700">Zugewiesen an</div>
{selectedEscalation.assigned_to ? (
<div className="text-sm text-gray-600 mt-1">
{selectedEscalation.assigned_role || 'Unbekannt'}
</div>
) : (
<div className="mt-2">
<select
onChange={(e) => {
if (e.target.value) {
assignEscalation(selectedEscalation.id, e.target.value)
}
}}
className="w-full border rounded px-2 py-1 text-sm"
defaultValue=""
>
<option value="">Reviewer auswählen...</option>
{dsbPool.filter(m => m.is_active).map(member => (
<option key={member.user_id} value={member.user_id}>
{member.user_name} ({member.role}) - {member.current_reviews}/{member.max_concurrent_reviews}
</option>
))}
</select>
</div>
)}
</div>
{/* Decision */}
{selectedEscalation.decision && (
<div>
<div className="text-sm font-medium text-gray-700">Entscheidung</div>
<div className="text-sm text-gray-600 mt-1">
{selectedEscalation.decision === 'approve' && '✅ Genehmigt'}
{selectedEscalation.decision === 'reject' && '❌ Abgelehnt'}
{selectedEscalation.decision === 'modify' && '🔄 Änderungen erforderlich'}
{selectedEscalation.decision === 'escalate' && '⬆️ Eskaliert'}
</div>
{selectedEscalation.decision_notes && (
<div className="text-sm text-gray-500 mt-1">{selectedEscalation.decision_notes}</div>
)}
{selectedEscalation.conditions && selectedEscalation.conditions.length > 0 && (
<div className="mt-2">
<div className="text-xs font-medium text-gray-700">Auflagen:</div>
<ul className="list-disc list-inside text-xs text-gray-600">
{selectedEscalation.conditions.map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Actions */}
{(selectedEscalation.status === 'assigned' || selectedEscalation.status === 'in_review') && (
<div className="pt-4 border-t">
<button
onClick={() => setShowDecisionModal(true)}
className="w-full px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors"
>
Entscheidung treffen
</button>
</div>
)}
{/* History */}
{escalationHistory.length > 0 && (
<div className="pt-4 border-t">
<div className="text-sm font-medium text-gray-700 mb-2">Verlauf</div>
<div className="space-y-2 max-h-48 overflow-y-auto">
{escalationHistory.map((h) => (
<div key={h.id} className="text-xs border-l-2 border-gray-200 pl-2">
<div className="text-gray-900">{h.action}</div>
{h.notes && <div className="text-gray-500">{h.notes}</div>}
<div className="text-gray-400">{formatDate(h.created_at)}</div>
</div>
))}
</div>
</div>
)}
{/* Link to Assessment */}
<div className="pt-4 border-t">
<a
href={`/dsgvo/advisory-board?assessment=${selectedEscalation.assessment_id}`}
className="text-sm text-violet-600 hover:text-violet-800"
>
Assessment anzeigen
</a>
</div>
</div>
) : (
<div className="bg-gray-50 rounded-lg border border-dashed p-8 text-center text-gray-500">
Wählen Sie eine Eskalation aus, um Details zu sehen
</div>
)}
</div>
</div>
{/* Decision Modal */}
{showDecisionModal && selectedEscalation && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold mb-4">Entscheidung für {selectedEscalation.escalation_level}</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Entscheidung</label>
<select
value={decisionForm.decision}
onChange={(e) => setDecisionForm(prev => ({ ...prev, decision: e.target.value as any }))}
className="w-full border rounded-lg px-3 py-2"
>
<option value="approve"> Genehmigen</option>
<option value="reject"> Ablehnen</option>
<option value="modify">🔄 Änderungen erforderlich</option>
<option value="escalate"> Weiter eskalieren</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Begründung</label>
<textarea
value={decisionForm.decision_notes}
onChange={(e) => setDecisionForm(prev => ({ ...prev, decision_notes: e.target.value }))}
rows={3}
className="w-full border rounded-lg px-3 py-2"
placeholder="Begründung für die Entscheidung..."
/>
</div>
{(decisionForm.decision === 'approve' || decisionForm.decision === 'modify') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Auflagen (optional)</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newCondition}
onChange={(e) => setNewCondition(e.target.value)}
placeholder="Neue Auflage eingeben..."
className="flex-1 border rounded-lg px-3 py-2 text-sm"
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCondition())}
/>
<button
onClick={addCondition}
className="px-3 py-2 bg-gray-100 rounded-lg hover:bg-gray-200"
>
+
</button>
</div>
{decisionForm.conditions.length > 0 && (
<ul className="space-y-1">
{decisionForm.conditions.map((c, i) => (
<li key={i} className="flex items-center justify-between bg-gray-50 px-2 py-1 rounded text-sm">
<span>{c}</span>
<button
onClick={() => removeCondition(i)}
className="text-red-500 hover:text-red-700"
>
×
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setShowDecisionModal(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Abbrechen
</button>
<button
onClick={submitDecision}
className="px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700"
>
Entscheidung speichern
</button>
</div>
</div>
</div>
)}
{/* DSB Pool Modal */}
{showDSBPoolModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4">
<h3 className="text-lg font-semibold mb-4">DSB-Pool verwalten</h3>
{/* Current Members */}
<div className="mb-6">
<h4 className="text-sm font-medium text-gray-700 mb-2">Aktuelle Mitglieder</h4>
{dsbPool.length === 0 ? (
<p className="text-sm text-gray-500">Keine Mitglieder im Pool</p>
) : (
<div className="border rounded-lg divide-y">
{dsbPool.map(member => (
<div key={member.id} className="p-3 flex items-center justify-between">
<div>
<div className="font-medium">{member.user_name}</div>
<div className="text-sm text-gray-500">{member.user_email}</div>
</div>
<div className="flex items-center gap-4">
<span className={`px-2 py-1 rounded text-xs ${
member.role === 'dsb' ? 'bg-violet-100 text-violet-800' :
member.role === 'team_lead' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{member.role}
</span>
<span className="text-sm text-gray-600">
{member.current_reviews}/{member.max_concurrent_reviews} Reviews
</span>
<span className={`w-2 h-2 rounded-full ${member.is_active ? 'bg-green-500' : 'bg-gray-300'}`} />
</div>
</div>
))}
</div>
)}
</div>
{/* Add New Member */}
<div className="border-t pt-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Neues Mitglied hinzufügen</h4>
<div className="grid grid-cols-2 gap-4">
<input
type="text"
value={newMember.user_name}
onChange={(e) => setNewMember(prev => ({ ...prev, user_name: e.target.value }))}
placeholder="Name"
className="border rounded-lg px-3 py-2"
/>
<input
type="email"
value={newMember.user_email}
onChange={(e) => setNewMember(prev => ({ ...prev, user_email: e.target.value }))}
placeholder="E-Mail"
className="border rounded-lg px-3 py-2"
/>
<select
value={newMember.role}
onChange={(e) => setNewMember(prev => ({ ...prev, role: e.target.value }))}
className="border rounded-lg px-3 py-2"
>
<option value="dsb">DSB</option>
<option value="deputy_dsb">Stellv. DSB</option>
<option value="team_lead">Team-Lead</option>
<option value="legal">Legal</option>
</select>
<input
type="number"
value={newMember.max_concurrent_reviews}
onChange={(e) => setNewMember(prev => ({ ...prev, max_concurrent_reviews: parseInt(e.target.value) || 10 }))}
placeholder="Max Reviews"
className="border rounded-lg px-3 py-2"
/>
</div>
<button
onClick={addDSBPoolMember}
disabled={!newMember.user_name || !newMember.user_email}
className="mt-4 px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Hinzufügen
</button>
</div>
<div className="flex justify-end mt-6">
<button
onClick={() => setShowDSBPoolModal(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Schließen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,542 +0,0 @@
'use client'
/**
* Löschfristen - Data Retention Management
*
* Art. 17 DSGVO - Recht auf Löschung
* Art. 5 Abs. 1 lit. e DSGVO - Speicherbegrenzung
*
* Migriert auf SDK API: /sdk/v1/dsgvo/retention-policies
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface RetentionPolicy {
id: string
tenant_id: string
namespace_id?: string
name: string
description: string
data_category: string
retention_period_days: number
retention_period_text: string
legal_basis: string
legal_reference?: string
deletion_method: string // automatic, manual, anonymization
deletion_procedure?: string
exception_criteria?: string
applicable_systems?: string[]
responsible_person: string
responsible_department: string
status: string // draft, active, archived
last_review_at?: string
next_review_at?: string
created_at: string
updated_at: string
}
export default function LoeschfristenPage() {
const [policies, setPolicies] = useState<RetentionPolicy[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [newPolicy, setNewPolicy] = useState({
name: '',
description: '',
data_category: '',
retention_period_days: 365,
retention_period_text: '1 Jahr',
legal_basis: 'legal_requirement',
legal_reference: '',
deletion_method: 'automatic',
deletion_procedure: '',
responsible_person: '',
responsible_department: '',
status: 'draft'
})
useEffect(() => {
loadPolicies()
}, [])
async function loadPolicies() {
setLoading(true)
setError(null)
try {
const res = await fetch('/sdk/v1/dsgvo/retention-policies', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
setPolicies(data.policies || [])
} catch (err) {
console.error('Failed to load retention policies:', err)
setError('Fehler beim Laden der Löschfristen')
} finally {
setLoading(false)
}
}
async function createPolicy() {
try {
const res = await fetch('/sdk/v1/dsgvo/retention-policies', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(newPolicy)
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
setShowCreateModal(false)
setNewPolicy({
name: '',
description: '',
data_category: '',
retention_period_days: 365,
retention_period_text: '1 Jahr',
legal_basis: 'legal_requirement',
legal_reference: '',
deletion_method: 'automatic',
deletion_procedure: '',
responsible_person: '',
responsible_department: '',
status: 'draft'
})
loadPolicies()
} catch (err) {
console.error('Failed to create policy:', err)
alert('Fehler beim Erstellen der Löschfrist')
}
}
async function exportPolicies(format: 'csv' | 'json') {
try {
const res = await fetch(`/sdk/v1/dsgvo/export/retention?format=${format}`, {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `loeschfristen-export.${format}`
a.click()
window.URL.revokeObjectURL(url)
} catch (err) {
console.error('Export failed:', err)
alert('Export fehlgeschlagen')
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Aktiv</span>
case 'draft':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Entwurf</span>
case 'archived':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">Archiviert</span>
default:
return null
}
}
const getDeletionMethodBadge = (method: string) => {
switch (method) {
case 'automatic':
return <span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Auto-Löschung</span>
case 'manual':
return <span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">Manuell</span>
case 'anonymization':
return <span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">Anonymisierung</span>
default:
return null
}
}
const getLegalBasisLabel = (basis: string) => {
const labels: Record<string, string> = {
'legal_requirement': 'Gesetzliche Pflicht',
'consent': 'Einwilligung',
'legitimate_interest': 'Berechtigtes Interesse',
'contract': 'Vertragserfüllung',
}
return labels[basis] || basis
}
// Group policies by status
const activePolicies = policies.filter(p => p.status === 'active')
const draftPolicies = policies.filter(p => p.status === 'draft')
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-500">Lade Löschfristen...</div>
</div>
)
}
return (
<div>
<PagePurpose
title="Löschfristen & Datenaufbewahrung"
purpose="Verwaltung von Aufbewahrungsfristen und automatischen Löschungen gemäß DSGVO Art. 5 (Speicherbegrenzung) und Art. 17 (Recht auf Löschung)."
audience={['DSB', 'IT-Admin', 'Compliance Officer']}
gdprArticles={['Art. 5 Abs. 1 lit. e (Speicherbegrenzung)', 'Art. 17 (Recht auf Löschung)']}
architecture={{
services: ['AI Compliance SDK (Go)', 'PostgreSQL'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'VVT', href: '/dsgvo/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'DSR', href: '/dsgvo/dsr', description: 'Löschanfragen' },
{ name: 'TOM', href: '/dsgvo/tom', description: 'Technische Maßnahmen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4 text-red-700">
{error}
</div>
)}
{/* Header Actions */}
<div className="flex items-center justify-between mb-6">
<div></div>
<div className="flex items-center gap-3">
<button
onClick={() => exportPolicies('csv')}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
CSV Export
</button>
<button
onClick={() => exportPolicies('json')}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
JSON Export
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
+ Neue Löschfrist
</button>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{policies.length}</div>
<div className="text-sm text-slate-500">Löschfristen gesamt</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{activePolicies.length}</div>
<div className="text-sm text-slate-500">Aktive Richtlinien</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-yellow-600">{draftPolicies.length}</div>
<div className="text-sm text-slate-500">Entwürfe</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-purple-600">
{policies.filter(p => p.deletion_method === 'automatic').length}
</div>
<div className="text-sm text-slate-500">Auto-Löschung</div>
</div>
</div>
{/* Policies List */}
{policies.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
<div className="text-slate-400 text-4xl mb-4">🗑</div>
<h3 className="text-lg font-medium text-slate-800 mb-2">Keine Löschfristen definiert</h3>
<p className="text-slate-500 mb-4">Legen Sie Aufbewahrungsfristen für verschiedene Datenkategorien an.</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
Erste Löschfrist anlegen
</button>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200">
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Aufbewahrungsfristen</h2>
<div className="space-y-4">
{policies.map((policy) => (
<div key={policy.id} className="border border-slate-200 rounded-lg p-4 hover:border-primary-300 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-grow">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold text-slate-900">{policy.name}</h3>
{getStatusBadge(policy.status)}
{getDeletionMethodBadge(policy.deletion_method)}
</div>
{policy.description && (
<p className="text-sm text-slate-600 mb-3">{policy.description}</p>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-slate-500">Datenkategorie:</span>
<span className="ml-1 font-medium text-slate-700">{policy.data_category}</span>
</div>
<div>
<span className="text-slate-500">Frist:</span>
<span className="ml-1 font-medium text-slate-700">{policy.retention_period_text}</span>
<span className="text-slate-400 ml-1">({policy.retention_period_days} Tage)</span>
</div>
<div>
<span className="text-slate-500">Rechtsgrundlage:</span>
<span className="ml-1 text-slate-600">{getLegalBasisLabel(policy.legal_basis)}</span>
</div>
{policy.legal_reference && (
<div>
<span className="text-slate-500">Referenz:</span>
<span className="ml-1 text-slate-600 font-mono text-xs">{policy.legal_reference}</span>
</div>
)}
</div>
<div className="mt-3 flex flex-wrap gap-4 text-xs text-slate-500">
{policy.responsible_person && (
<span>Verantwortlich: {policy.responsible_person}</span>
)}
{policy.responsible_department && (
<span>Abteilung: {policy.responsible_department}</span>
)}
{policy.last_review_at && (
<span>Letzte Prüfung: {new Date(policy.last_review_at).toLocaleDateString('de-DE')}</span>
)}
{policy.next_review_at && (
<span>Nächste Prüfung: {new Date(policy.next_review_at).toLocaleDateString('de-DE')}</span>
)}
</div>
{policy.applicable_systems && policy.applicable_systems.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{policy.applicable_systems.map((sys, idx) => (
<span key={idx} className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
{sys}
</span>
))}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Info */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex 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>
<h4 className="font-semibold text-purple-900">Speicherbegrenzung (Art. 5)</h4>
<p className="text-sm text-purple-800 mt-1">
Personenbezogene Daten dürfen nur so lange gespeichert werden, wie es für die Zwecke
erforderlich ist. Die automatische Löschung stellt die Einhaltung dieser Vorgabe sicher.
</p>
</div>
</div>
</div>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<h3 className="text-lg font-semibold text-slate-900">Neue Löschfrist anlegen</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newPolicy.name}
onChange={(e) => setNewPolicy({ ...newPolicy, name: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. Aufbewahrung Nutzerkonten"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={newPolicy.description}
onChange={(e) => setNewPolicy({ ...newPolicy, description: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-20"
placeholder="Beschreibung der Löschfrist..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Datenkategorie *</label>
<input
type="text"
value={newPolicy.data_category}
onChange={(e) => setNewPolicy({ ...newPolicy, data_category: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. Nutzerdaten, Logs, Rechnungen"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select
value={newPolicy.status}
onChange={(e) => setNewPolicy({ ...newPolicy, status: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="draft">Entwurf</option>
<option value="active">Aktiv</option>
<option value="archived">Archiviert</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufbewahrungsfrist (Tage)</label>
<input
type="number"
value={newPolicy.retention_period_days}
onChange={(e) => setNewPolicy({ ...newPolicy, retention_period_days: parseInt(e.target.value) || 0 })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="365"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufbewahrungsfrist (Text)</label>
<input
type="text"
value={newPolicy.retention_period_text}
onChange={(e) => setNewPolicy({ ...newPolicy, retention_period_text: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. 3 Jahre, 10 Jahre nach Vertragsende"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage</label>
<select
value={newPolicy.legal_basis}
onChange={(e) => setNewPolicy({ ...newPolicy, legal_basis: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="legal_requirement">Gesetzliche Pflicht</option>
<option value="consent">Einwilligung</option>
<option value="legitimate_interest">Berechtigtes Interesse</option>
<option value="contract">Vertragserfüllung</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Gesetzliche Referenz</label>
<input
type="text"
value={newPolicy.legal_reference}
onChange={(e) => setNewPolicy({ ...newPolicy, legal_reference: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. § 147 AO, § 257 HGB"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Löschmethode</label>
<select
value={newPolicy.deletion_method}
onChange={(e) => setNewPolicy({ ...newPolicy, deletion_method: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="automatic">Automatische Löschung</option>
<option value="manual">Manuelle Löschung</option>
<option value="anonymization">Anonymisierung</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Löschprozedur</label>
<input
type="text"
value={newPolicy.deletion_procedure}
onChange={(e) => setNewPolicy({ ...newPolicy, deletion_procedure: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. Cron-Job, Skript"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verantwortliche Person</label>
<input
type="text"
value={newPolicy.responsible_person}
onChange={(e) => setNewPolicy({ ...newPolicy, responsible_person: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="Name"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Abteilung</label>
<input
type="text"
value={newPolicy.responsible_department}
onChange={(e) => setNewPolicy({ ...newPolicy, responsible_department: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. IT, Datenschutz"
/>
</div>
</div>
</div>
<div className="p-6 border-t border-slate-200 flex justify-end gap-3">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Abbrechen
</button>
<button
onClick={createPolicy}
disabled={!newPolicy.name || !newPolicy.data_category}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Löschfrist anlegen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,202 +0,0 @@
'use client'
/**
* DSGVO Dashboard - Übersicht aller Datenschutz-Module
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
interface ModuleCard {
id: string
title: string
description: string
href: string
icon: string
status: 'active' | 'coming_soon'
stats?: {
label: string
value: string | number
}
}
const modules: ModuleCard[] = [
{
id: 'consent',
title: 'Consent Management',
description: 'Dokumente, Versionen und Einwilligungen verwalten',
href: '/dsgvo/consent',
icon: '📋',
status: 'active',
},
{
id: 'dsr',
title: 'Betroffenenrechte (DSR)',
description: 'Art. 15-22 DSGVO: Auskunft, Löschung, Berichtigung',
href: '/dsgvo/dsr',
icon: '👤',
status: 'active',
},
{
id: 'einwilligungen',
title: 'Einwilligungen',
description: 'Übersicht aller erteilten Einwilligungen',
href: '/dsgvo/einwilligungen',
icon: '✅',
status: 'active',
},
{
id: 'vvt',
title: 'Verarbeitungsverzeichnis',
description: 'Art. 30 DSGVO: Dokumentation aller Verarbeitungstätigkeiten',
href: '/dsgvo/vvt',
icon: '📑',
status: 'active',
},
{
id: 'dsfa',
title: 'Datenschutz-Folgenabschätzung',
description: 'Art. 35 DSGVO: Risikobewertung für Verarbeitungen',
href: '/dsgvo/dsfa',
icon: '⚠️',
status: 'active',
},
{
id: 'tom',
title: 'TOM',
description: 'Art. 32 DSGVO: Technische und Organisatorische Maßnahmen',
href: '/dsgvo/tom',
icon: '🔒',
status: 'active',
},
{
id: 'loeschfristen',
title: 'Löschfristen',
description: 'Art. 17 DSGVO: Aufbewahrungsfristen und Löschkonzept',
href: '/dsgvo/loeschfristen',
icon: '🗑️',
status: 'active',
},
{
id: 'advisory-board',
title: 'Advisory Board',
description: 'KI-Use-Case Machbarkeits- und Compliance-Pruefung',
href: '/dsgvo/advisory-board',
icon: '🎯',
status: 'active',
},
]
export default function DSGVODashboard() {
const [stats, setStats] = useState<Record<string, any>>({})
const [loading, setLoading] = useState(true)
useEffect(() => {
// Load stats from SDK
async function loadStats() {
try {
const res = await fetch('/sdk/v1/dsgvo/stats', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (res.ok) {
const data = await res.json()
setStats(data)
}
} catch (err) {
console.error('Failed to load DSGVO stats:', err)
} finally {
setLoading(false)
}
}
loadStats()
}, [])
return (
<div className="space-y-6">
<PagePurpose
title="DSGVO Compliance"
purpose="Zentrale Übersicht aller DSGVO-Module für die vollständige Datenschutz-Konformität. Hier verwalten Sie Verarbeitungsverzeichnis, Betroffenenrechte, technische Maßnahmen und Löschfristen."
audience={['Datenschutzbeauftragter', 'Compliance Officer', 'IT-Leitung']}
gdprArticles={['Art. 15-22', 'Art. 30', 'Art. 32', 'Art. 35']}
collapsible={true}
defaultCollapsed={true}
/>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-2xl font-bold text-slate-800">
{loading ? '--' : (stats.processing_activities || 0)}
</div>
<div className="text-sm text-slate-500">Verarbeitungstätigkeiten</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-2xl font-bold text-slate-800">
{loading ? '--' : (stats.open_dsrs || 0)}
</div>
<div className="text-sm text-slate-500">Offene DSR-Anfragen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-2xl font-bold text-slate-800">
{loading ? '--' : (stats.toms_implemented || 0)}
</div>
<div className="text-sm text-slate-500">TOM implementiert</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-2xl font-bold text-slate-800">
{loading ? '--' : (stats.retention_policies || 0)}
</div>
<div className="text-sm text-slate-500">Löschfristen definiert</div>
</div>
</div>
{/* Module Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{modules.map((module) => (
<Link
key={module.id}
href={module.href}
className="group bg-white rounded-xl border border-slate-200 p-6 shadow-sm hover:shadow-md hover:border-primary-300 transition-all"
>
<div className="flex items-start gap-4">
<div className="text-3xl">{module.icon}</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-slate-800 group-hover:text-primary-600 transition-colors">
{module.title}
</h3>
{module.status === 'coming_soon' && (
<span className="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded">
Coming Soon
</span>
)}
</div>
<p className="text-sm text-slate-500 mt-1">{module.description}</p>
{module.stats && (
<div className="mt-3 text-sm">
<span className="text-slate-400">{module.stats.label}:</span>{' '}
<span className="text-slate-700 font-medium">{module.stats.value}</span>
</div>
)}
</div>
</div>
</Link>
))}
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<h4 className="text-blue-700 font-medium mb-2">SDK-Integration aktiv</h4>
<p className="text-sm text-blue-600">
Die DSGVO-Module sind in das AI Compliance SDK integriert.
Alle Datenschutz-Funktionen sind über eine einheitliche API verfügbar
und können von externen Systemen genutzt werden.
</p>
</div>
</div>
)
}

View File

@@ -1,602 +0,0 @@
'use client'
/**
* TOM - Technische und Organisatorische Maßnahmen
*
* Art. 32 DSGVO - Sicherheit der Verarbeitung
*
* Migriert auf SDK API: /sdk/v1/dsgvo/tom
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface TOM {
id: string
tenant_id: string
namespace_id?: string
category: string
subcategory?: string
name: string
description: string
type: string // technical, organizational
implementation_status: string // planned, in_progress, implemented, verified, not_applicable
implemented_at?: string
verified_at?: string
verified_by?: string
effectiveness_rating?: string // low, medium, high
documentation?: string
responsible_person: string
responsible_department: string
review_frequency: string // monthly, quarterly, annually
last_review_at?: string
next_review_at?: string
related_controls?: string[]
created_at: string
updated_at: string
}
interface CategoryGroup {
id: string
title: string
article: string
description: string
toms: TOM[]
}
const CATEGORY_META: Record<string, { title: string; article: string; description: string }> = {
access_control: {
title: 'Zugriffskontrolle',
article: 'Art. 32 Abs. 1 lit. b',
description: 'Fähigkeit, Vertraulichkeit und Integrität auf Dauer sicherzustellen'
},
encryption: {
title: 'Verschlüsselung',
article: 'Art. 32 Abs. 1 lit. a',
description: 'Pseudonymisierung und Verschlüsselung personenbezogener Daten'
},
pseudonymization: {
title: 'Pseudonymisierung',
article: 'Art. 32 Abs. 1 lit. a',
description: 'Verarbeitung ohne Zuordnung zu identifizierter Person'
},
availability: {
title: 'Verfügbarkeit & Belastbarkeit',
article: 'Art. 32 Abs. 1 lit. b',
description: 'Fähigkeit, Verfügbarkeit und Belastbarkeit der Systeme sicherzustellen'
},
resilience: {
title: 'Wiederherstellung',
article: 'Art. 32 Abs. 1 lit. c',
description: 'Rasche Wiederherstellung nach physischem oder technischem Zwischenfall'
},
monitoring: {
title: 'Protokollierung & Audit-Trail',
article: 'Art. 32 Abs. 2',
description: 'Nachweis der Einhaltung durch Protokollierung'
},
incident_response: {
title: 'Incident Response',
article: 'Art. 33/34',
description: 'Meldung von Verletzungen des Schutzes personenbezogener Daten'
},
review: {
title: 'Regelmäßige Überprüfung',
article: 'Art. 32 Abs. 1 lit. d',
description: 'Verfahren zur regelmäßigen Überprüfung, Bewertung und Evaluierung'
}
}
export default function TOMPage() {
const [toms, setToms] = useState<TOM[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedCategory, setExpandedCategory] = useState<string | null>('access_control')
const [showCreateModal, setShowCreateModal] = useState(false)
const [newTom, setNewTom] = useState({
category: 'access_control',
subcategory: '',
name: '',
description: '',
type: 'technical',
implementation_status: 'planned',
effectiveness_rating: 'medium',
documentation: '',
responsible_person: '',
responsible_department: '',
review_frequency: 'quarterly'
})
useEffect(() => {
loadTOMs()
}, [])
async function loadTOMs() {
setLoading(true)
setError(null)
try {
const res = await fetch('/sdk/v1/dsgvo/tom', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
setToms(data.toms || [])
} catch (err) {
console.error('Failed to load TOMs:', err)
setError('Fehler beim Laden der TOMs')
} finally {
setLoading(false)
}
}
async function createTOM() {
try {
const res = await fetch('/sdk/v1/dsgvo/tom', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(newTom)
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
setShowCreateModal(false)
setNewTom({
category: 'access_control',
subcategory: '',
name: '',
description: '',
type: 'technical',
implementation_status: 'planned',
effectiveness_rating: 'medium',
documentation: '',
responsible_person: '',
responsible_department: '',
review_frequency: 'quarterly'
})
loadTOMs()
} catch (err) {
console.error('Failed to create TOM:', err)
alert('Fehler beim Erstellen der Maßnahme')
}
}
async function exportTOMs(format: 'csv' | 'json') {
try {
const res = await fetch(`/sdk/v1/dsgvo/export/tom?format=${format}`, {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `tom-export.${format}`
a.click()
window.URL.revokeObjectURL(url)
} catch (err) {
console.error('Export failed:', err)
alert('Export fehlgeschlagen')
}
}
// Group TOMs by category
const categoryGroups: CategoryGroup[] = Object.entries(CATEGORY_META).map(([id, meta]) => ({
id,
...meta,
toms: toms.filter(t => t.category === id)
})).filter(group => group.toms.length > 0 || group.id === expandedCategory)
const getStatusBadge = (status: string) => {
switch (status) {
case 'implemented':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Umgesetzt</span>
case 'verified':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800">Verifiziert</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 'planned':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Geplant</span>
case 'not_applicable':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">N/A</span>
default:
return null
}
}
const getTypeBadge = (type: string) => {
if (type === 'technical') {
return <span className="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">Technisch</span>
}
return <span className="px-2 py-0.5 rounded text-xs bg-purple-50 text-purple-700">Organisatorisch</span>
}
const calculateCategoryScore = (categoryToms: TOM[]) => {
if (categoryToms.length === 0) return 0
const total = categoryToms.length
const implemented = categoryToms.filter(t => t.implementation_status === 'implemented' || t.implementation_status === 'verified').length
const inProgress = categoryToms.filter(t => t.implementation_status === 'in_progress').length
return Math.round(((implemented + inProgress * 0.5) / total) * 100)
}
const calculateOverallScore = () => {
if (toms.length === 0) return 0
let total = toms.length
let score = 0
toms.forEach(t => {
if (t.implementation_status === 'implemented' || t.implementation_status === 'verified') score += 1
else if (t.implementation_status === 'in_progress') score += 0.5
})
return Math.round((score / total) * 100)
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-500">Lade TOMs...</div>
</div>
)
}
return (
<div>
<PagePurpose
title="Technische & Organisatorische Maßnahmen (TOMs)"
purpose="Dokumentation aller Sicherheitsmaßnahmen gemäß Art. 32 DSGVO. Diese Seite dient als Nachweis für Auditoren und den DSB."
audience={['DSB', 'IT-Sicherheit', 'Auditoren', 'Geschäftsführung']}
gdprArticles={['Art. 32 (Sicherheit der Verarbeitung)']}
architecture={{
services: ['AI Compliance SDK (Go)', 'PostgreSQL'],
databases: ['PostgreSQL (verschlüsselt)'],
}}
relatedPages={[
{ name: 'DSMS', href: '/compliance/dsms', description: 'Datenschutz-Management-System' },
{ name: 'VVT', href: '/dsgvo/vvt', description: 'Verarbeitungsverzeichnis' },
{ name: 'DSFA', href: '/dsgvo/dsfa', description: 'Datenschutz-Folgenabschätzung' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4 text-red-700">
{error}
</div>
)}
{/* Header Actions */}
<div className="flex items-center justify-between mb-6">
<div></div>
<div className="flex items-center gap-3">
<button
onClick={() => exportTOMs('csv')}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
CSV Export
</button>
<button
onClick={() => exportTOMs('json')}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
JSON Export
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
+ Neue Maßnahme
</button>
</div>
</div>
{/* Overall Score */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">TOM-Umsetzungsgrad</h2>
<p className="text-sm text-slate-500 mt-1">Gesamtfortschritt aller technischen und organisatorischen Maßnahmen</p>
</div>
<div className="text-right">
<div className={`text-4xl font-bold ${calculateOverallScore() >= 80 ? 'text-green-600' : calculateOverallScore() >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
{calculateOverallScore()}%
</div>
<div className="text-sm text-slate-500">{toms.length} Maßnahmen</div>
</div>
</div>
<div className="mt-4 h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${calculateOverallScore() >= 80 ? 'bg-green-500' : calculateOverallScore() >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${calculateOverallScore()}%` }}
/>
</div>
</div>
{/* TOM Categories */}
{categoryGroups.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
<div className="text-slate-400 text-4xl mb-4">🔒</div>
<h3 className="text-lg font-medium text-slate-800 mb-2">Keine Maßnahmen erfasst</h3>
<p className="text-slate-500 mb-4">Legen Sie technische und organisatorische Maßnahmen an.</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
Erste Maßnahme anlegen
</button>
</div>
) : (
<div className="space-y-4">
{categoryGroups.map((category) => (
<div key={category.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setExpandedCategory(expandedCategory === category.id ? null : category.id)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center text-lg font-bold ${
calculateCategoryScore(category.toms) >= 80 ? 'bg-green-100 text-green-700' :
calculateCategoryScore(category.toms) >= 50 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{calculateCategoryScore(category.toms)}%
</div>
<div className="text-left">
<h3 className="font-semibold text-slate-900">{category.title}</h3>
<p className="text-sm text-slate-500">{category.article} - {category.description}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500">{category.toms.length} Maßnahmen</span>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedCategory === category.id ? '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>
</div>
</button>
{expandedCategory === category.id && (
<div className="px-6 pb-6 border-t border-slate-100">
<div className="mt-4 space-y-3">
{category.toms.length === 0 ? (
<div className="p-4 bg-slate-50 rounded-lg text-center text-slate-500">
Keine Maßnahmen in dieser Kategorie
</div>
) : (
category.toms.map((tom) => (
<div key={tom.id} className="p-4 bg-slate-50 rounded-lg">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<h4 className="font-medium text-slate-900">{tom.name}</h4>
{getTypeBadge(tom.type)}
</div>
{getStatusBadge(tom.implementation_status)}
</div>
<p className="text-sm text-slate-600 mb-3">{tom.description}</p>
<div className="flex flex-wrap gap-4 text-xs text-slate-500">
{tom.documentation && (
<span>Nachweis: <span className="font-mono">{tom.documentation}</span></span>
)}
{tom.responsible_person && (
<span>Verantwortlich: {tom.responsible_person}</span>
)}
{tom.responsible_department && (
<span>Abteilung: {tom.responsible_department}</span>
)}
{tom.last_review_at && (
<span>Letzte Prüfung: {new Date(tom.last_review_at).toLocaleDateString('de-DE')}</span>
)}
{tom.review_frequency && (
<span className="capitalize">
Prüfung: {tom.review_frequency === 'monthly' ? 'Monatlich' : tom.review_frequency === 'quarterly' ? 'Quartalsweise' : 'Jährlich'}
</span>
)}
</div>
{tom.effectiveness_rating && (
<div className="mt-2">
<span className={`text-xs px-2 py-0.5 rounded ${
tom.effectiveness_rating === 'high' ? 'bg-green-100 text-green-700' :
tom.effectiveness_rating === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
Wirksamkeit: {tom.effectiveness_rating === 'high' ? 'Hoch' : tom.effectiveness_rating === 'medium' ? 'Mittel' : 'Niedrig'}
</span>
</div>
)}
</div>
))
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Info */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex 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>
<h4 className="font-semibold text-purple-900">Dokumentationspflicht</h4>
<p className="text-sm text-purple-800 mt-1">
Gemäß Art. 32 Abs. 1 DSGVO müssen geeignete technische und organisatorische Maßnahmen
implementiert werden, um ein dem Risiko angemessenes Schutzniveau zu gewährleisten.
Diese Dokumentation dient als Nachweis für Aufsichtsbehörden.
</p>
</div>
</div>
</div>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<h3 className="text-lg font-semibold text-slate-900">Neue Maßnahme anlegen</h3>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie</label>
<select
value={newTom.category}
onChange={(e) => setNewTom({ ...newTom, category: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
{Object.entries(CATEGORY_META).map(([id, meta]) => (
<option key={id} value={id}>{meta.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={newTom.type}
onChange={(e) => setNewTom({ ...newTom, type: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="technical">Technisch</option>
<option value="organizational">Organisatorisch</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newTom.name}
onChange={(e) => setNewTom({ ...newTom, name: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. TLS 1.3 für alle Verbindungen"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung *</label>
<textarea
value={newTom.description}
onChange={(e) => setNewTom({ ...newTom, description: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2 h-24"
placeholder="Detaillierte Beschreibung der Maßnahme..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select
value={newTom.implementation_status}
onChange={(e) => setNewTom({ ...newTom, implementation_status: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="planned">Geplant</option>
<option value="in_progress">In Arbeit</option>
<option value="implemented">Umgesetzt</option>
<option value="verified">Verifiziert</option>
<option value="not_applicable">Nicht zutreffend</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Wirksamkeit</label>
<select
value={newTom.effectiveness_rating}
onChange={(e) => setNewTom({ ...newTom, effectiveness_rating: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verantwortliche Person</label>
<input
type="text"
value={newTom.responsible_person}
onChange={(e) => setNewTom({ ...newTom, responsible_person: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="Name der verantwortlichen Person"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Abteilung</label>
<input
type="text"
value={newTom.responsible_department}
onChange={(e) => setNewTom({ ...newTom, responsible_department: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. IT-Abteilung"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Prüfungsintervall</label>
<select
value={newTom.review_frequency}
onChange={(e) => setNewTom({ ...newTom, review_frequency: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value="monthly">Monatlich</option>
<option value="quarterly">Quartalsweise</option>
<option value="annually">Jährlich</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nachweis/Dokumentation</label>
<input
type="text"
value={newTom.documentation}
onChange={(e) => setNewTom({ ...newTom, documentation: e.target.value })}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
placeholder="z.B. SSL Labs Report, Config-Datei"
/>
</div>
</div>
</div>
<div className="p-6 border-t border-slate-200 flex justify-end gap-3">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Abbrechen
</button>
<button
onClick={createTOM}
disabled={!newTom.name || !newTom.description}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Maßnahme anlegen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,597 +0,0 @@
'use client'
/**
* VVT - Verarbeitungsverzeichnis
*
* Art. 30 DSGVO - Verzeichnis von Verarbeitungstaetigkeiten
* Integriert mit AI Compliance SDK
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
interface ProcessingActivity {
id: string
tenant_id: string
name: string
description: string
purpose: string
legal_basis: string
legal_basis_details: string
data_categories: string[]
data_subject_categories: string[]
recipients: string[]
third_country_transfer: boolean
transfer_safeguards: string
retention_period: string
dsfa_required: boolean
responsible_person: string
responsible_department: string
systems: string[]
status: string
created_at: string
updated_at: string
}
const LEGAL_BASES = [
{ value: 'consent', label: 'Art. 6 Abs. 1 lit. a - Einwilligung' },
{ value: 'contract', label: 'Art. 6 Abs. 1 lit. b - Vertragserfüllung' },
{ value: 'legal_obligation', label: 'Art. 6 Abs. 1 lit. c - Rechtliche Verpflichtung' },
{ value: 'vital_interests', label: 'Art. 6 Abs. 1 lit. d - Lebenswichtige Interessen' },
{ value: 'public_interest', label: 'Art. 6 Abs. 1 lit. e - Öffentliches Interesse' },
{ value: 'legitimate_interests', label: 'Art. 6 Abs. 1 lit. f - Berechtigtes Interesse' },
]
export default function VVTPage() {
const [activities, setActivities] = useState<ProcessingActivity[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedActivity, setExpandedActivity] = useState<string | null>(null)
const [filterStatus, setFilterStatus] = useState<string>('all')
const [showCreateModal, setShowCreateModal] = useState(false)
const [newActivity, setNewActivity] = useState<Partial<ProcessingActivity>>({
name: '',
purpose: '',
legal_basis: 'contract',
legal_basis_details: '',
data_categories: [],
data_subject_categories: [],
recipients: [],
third_country_transfer: false,
retention_period: '',
responsible_person: '',
responsible_department: '',
systems: [],
status: 'draft',
})
useEffect(() => {
loadActivities()
}, [])
async function loadActivities() {
setLoading(true)
setError(null)
try {
const res = await fetch('/sdk/v1/dsgvo/processing-activities', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
})
if (res.ok) {
const data = await res.json()
setActivities(data.processing_activities || [])
} else {
const errorData = await res.json().catch(() => ({}))
setError(errorData.error || 'Fehler beim Laden')
}
} catch (err) {
setError('Verbindungsfehler zum SDK')
} finally {
setLoading(false)
}
}
async function createActivity() {
try {
const res = await fetch('/sdk/v1/dsgvo/processing-activities', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
body: JSON.stringify(newActivity),
})
if (res.ok) {
setShowCreateModal(false)
setNewActivity({
name: '',
purpose: '',
legal_basis: 'contract',
data_categories: [],
status: 'draft',
})
loadActivities()
} else {
const errorData = await res.json().catch(() => ({}))
alert(errorData.error || 'Fehler beim Erstellen')
}
} catch (err) {
alert('Verbindungsfehler')
}
}
async function deleteActivity(id: string) {
if (!confirm('Verarbeitungstätigkeit wirklich löschen?')) return
try {
const res = await fetch(`/sdk/v1/dsgvo/processing-activities/${id}`, {
method: 'DELETE',
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
},
})
if (res.ok) {
loadActivities()
}
} catch (err) {
alert('Fehler beim Löschen')
}
}
async function exportVVT(format: 'csv' | 'json') {
window.open(`/sdk/v1/dsgvo/export/vvt?format=${format}`, '_blank')
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Aktiv</span>
case 'draft':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">Entwurf</span>
case 'under_review':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">In Prüfung</span>
case 'archived':
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">Archiviert</span>
default:
return <span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600">{status}</span>
}
}
const getLegalBasisLabel = (value: string) => {
const basis = LEGAL_BASES.find(b => b.value === value)
return basis?.label || value
}
const filteredActivities = filterStatus === 'all'
? activities
: activities.filter(a => a.status === filterStatus)
return (
<div>
<PagePurpose
title="Verarbeitungsverzeichnis (VVT)"
purpose="Verzeichnis aller Verarbeitungstätigkeiten gemäß Art. 30 DSGVO. Dokumentiert Zweck, Rechtsgrundlage, Kategorien und Löschfristen."
audience={['DSB', 'Auditoren', 'Aufsichtsbehörden']}
gdprArticles={['Art. 30 (Verzeichnis von Verarbeitungstätigkeiten)']}
architecture={{
services: ['AI Compliance SDK (Go)'],
databases: ['PostgreSQL'],
}}
relatedPages={[
{ name: 'TOM', href: '/dsgvo/tom', description: 'Technische Maßnahmen' },
{ name: 'DSFA', href: '/dsgvo/dsfa', description: 'Datenschutz-Folgenabschätzung' },
{ name: 'Löschfristen', href: '/dsgvo/loeschfristen', description: 'Aufbewahrungsfristen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Header */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Verarbeitungstätigkeiten</h2>
<p className="text-sm text-slate-500 mt-1">{activities.length} dokumentierte Tätigkeiten</p>
</div>
<div className="flex gap-2">
<button
onClick={() => exportVVT('csv')}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200"
>
CSV Export
</button>
<button
onClick={() => exportVVT('json')}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200"
>
JSON Export
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700"
>
+ Neue Tätigkeit
</button>
</div>
</div>
{/* Filter */}
<div className="flex gap-2">
{[
{ value: 'all', label: 'Alle' },
{ value: 'active', label: 'Aktiv' },
{ value: 'draft', label: 'Entwurf' },
{ value: 'under_review', label: 'In Prüfung' },
{ value: 'archived', label: 'Archiviert' },
].map(filter => (
<button
key={filter.value}
onClick={() => setFilterStatus(filter.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
filterStatus === filter.value
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{filter.label}
</button>
))}
</div>
</div>
{/* Loading / Error */}
{loading && (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<div className="animate-spin w-8 h-8 border-2 border-purple-600 border-t-transparent rounded-full mx-auto"></div>
<p className="mt-4 text-slate-500">Lade Verarbeitungstätigkeiten...</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
<p className="text-red-700">{error}</p>
<button onClick={loadActivities} className="mt-2 text-sm text-red-600 underline">
Erneut versuchen
</button>
</div>
)}
{/* Activities List */}
{!loading && !error && (
<div className="space-y-4">
{filteredActivities.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<p className="text-slate-500">Keine Verarbeitungstätigkeiten gefunden.</p>
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 text-purple-600 font-medium hover:underline"
>
Erste Tätigkeit anlegen
</button>
</div>
) : (
filteredActivities.map((activity) => (
<div key={activity.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setExpandedActivity(expandedActivity === activity.id ? null : activity.id)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="text-left">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-slate-900">{activity.name}</h3>
{getStatusBadge(activity.status)}
{activity.third_country_transfer && (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
Drittland-Transfer
</span>
)}
{activity.dsfa_required && (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
DSFA erforderlich
</span>
)}
</div>
<p className="text-sm text-slate-500 mt-1">{activity.purpose}</p>
</div>
</div>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedActivity === activity.id ? '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>
{expandedActivity === activity.id && (
<div className="px-6 pb-6 border-t border-slate-100">
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Rechtsgrundlage</h4>
<p className="font-semibold text-slate-900">{getLegalBasisLabel(activity.legal_basis)}</p>
{activity.legal_basis_details && (
<p className="text-sm text-slate-600">{activity.legal_basis_details}</p>
)}
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Datenkategorien</h4>
<div className="flex flex-wrap gap-2">
{(activity.data_categories || []).map((cat, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-700 rounded text-sm">
{cat}
</span>
))}
{(!activity.data_categories || activity.data_categories.length === 0) && (
<span className="text-slate-400 text-sm">Keine angegeben</span>
)}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Betroffene Kategorien</h4>
<div className="flex flex-wrap gap-2">
{(activity.data_subject_categories || []).map((cat, idx) => (
<span key={idx} className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-sm">
{cat}
</span>
))}
{(!activity.data_subject_categories || activity.data_subject_categories.length === 0) && (
<span className="text-slate-400 text-sm">Keine angegeben</span>
)}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Empfänger</h4>
{activity.recipients && activity.recipients.length > 0 ? (
<ul className="text-sm text-slate-700 list-disc list-inside">
{activity.recipients.map((rec, idx) => (
<li key={idx}>{rec}</li>
))}
</ul>
) : (
<span className="text-slate-400 text-sm">Keine externen Empfänger</span>
)}
</div>
</div>
{/* Right Column */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Löschfrist</h4>
<p className="text-slate-700">{activity.retention_period || 'Nicht definiert'}</p>
</div>
{activity.third_country_transfer && activity.transfer_safeguards && (
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Drittland-Schutzmaßnahmen</h4>
<p className="text-slate-700">{activity.transfer_safeguards}</p>
</div>
)}
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Verantwortlich</h4>
<p className="text-slate-700">
{activity.responsible_person || 'k.A.'}
{activity.responsible_department && ` (${activity.responsible_department})`}
</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Systeme</h4>
<div className="flex flex-wrap gap-2">
{(activity.systems || []).map((sys, idx) => (
<span key={idx} className="px-2 py-1 bg-green-100 text-green-700 rounded text-sm">
{sys}
</span>
))}
{(!activity.systems || activity.systems.length === 0) && (
<span className="text-slate-400 text-sm">Keine angegeben</span>
)}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-500 mb-1">Erstellt / Aktualisiert</h4>
<p className="text-slate-700 text-sm">
{new Date(activity.created_at).toLocaleDateString('de-DE')} / {new Date(activity.updated_at).toLocaleDateString('de-DE')}
</p>
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-100 flex gap-2">
<button className="px-3 py-1.5 text-sm text-purple-600 hover:text-purple-700 font-medium">
Bearbeiten
</button>
<button
onClick={() => deleteActivity(activity.id)}
className="px-3 py-1.5 text-sm text-red-600 hover:text-red-700"
>
Löschen
</button>
</div>
</div>
)}
</div>
))
)}
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<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-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neue Verarbeitungstätigkeit</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newActivity.name || ''}
onChange={(e) => setNewActivity({ ...newActivity, name: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="z.B. Benutzerverwaltung"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Zweck der Verarbeitung *</label>
<textarea
value={newActivity.purpose || ''}
onChange={(e) => setNewActivity({ ...newActivity, purpose: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={2}
placeholder="Beschreiben Sie den Zweck der Datenverarbeitung"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage *</label>
<select
value={newActivity.legal_basis || 'contract'}
onChange={(e) => setNewActivity({ ...newActivity, legal_basis: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
{LEGAL_BASES.map(basis => (
<option key={basis.value} value={basis.value}>{basis.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Details zur Rechtsgrundlage</label>
<input
type="text"
value={newActivity.legal_basis_details || ''}
onChange={(e) => setNewActivity({ ...newActivity, legal_basis_details: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Weitere Details zur Begründung"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Datenkategorien (kommasepariert)</label>
<input
type="text"
value={(newActivity.data_categories || []).join(', ')}
onChange={(e) => setNewActivity({ ...newActivity, data_categories: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Name, E-Mail, Adresse"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Betroffene (kommasepariert)</label>
<input
type="text"
value={(newActivity.data_subject_categories || []).join(', ')}
onChange={(e) => setNewActivity({ ...newActivity, data_subject_categories: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Kunden, Mitarbeiter, Lieferanten"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufbewahrungsfrist</label>
<input
type="text"
value={newActivity.retention_period || ''}
onChange={(e) => setNewActivity({ ...newActivity, retention_period: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="z.B. 10 Jahre (§ 147 AO)"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verantwortliche Person</label>
<input
type="text"
value={newActivity.responsible_person || ''}
onChange={(e) => setNewActivity({ ...newActivity, responsible_person: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Abteilung</label>
<input
type="text"
value={newActivity.responsible_department || ''}
onChange={(e) => setNewActivity({ ...newActivity, responsible_department: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="third_country"
checked={newActivity.third_country_transfer || false}
onChange={(e) => setNewActivity({ ...newActivity, third_country_transfer: e.target.checked })}
className="rounded"
/>
<label htmlFor="third_country" className="text-sm text-slate-700">Drittland-Transfer</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="dsfa_required"
checked={newActivity.dsfa_required || false}
onChange={(e) => setNewActivity({ ...newActivity, dsfa_required: e.target.checked })}
className="rounded"
/>
<label htmlFor="dsfa_required" className="text-sm text-slate-700">DSFA erforderlich</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 text-slate-700 hover:bg-slate-100 rounded-lg text-sm font-medium"
>
Abbrechen
</button>
<button
onClick={createActivity}
disabled={!newActivity.name || !newActivity.purpose}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 disabled:opacity-50"
>
Erstellen
</button>
</div>
</div>
</div>
)}
{/* Info */}
<div className="mt-6 bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex 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>
<h4 className="font-semibold text-purple-900">Pflicht zur Führung</h4>
<p className="text-sm text-purple-800 mt-1">
Gemäß Art. 30 DSGVO ist jeder Verantwortliche verpflichtet, ein Verzeichnis aller
Verarbeitungstätigkeiten zu führen. Dieses Verzeichnis muss der Aufsichtsbehörde
auf Anfrage zur Verfügung gestellt werden.
</p>
</div>
</div>
</div>
</div>
)
}

View File

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

View File

@@ -1,675 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
// Types
interface WizardStep {
number: number
id: string
title: string
subtitle: string
description: string
icon: string
is_required: boolean
is_completed: boolean
}
interface FormData {
[key: string]: any
}
const API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8080'
// Step icons mapping
const stepIcons: Record<string, React.ReactNode> = {
'document-text': (
<svg className="w-5 h-5" 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>
),
'academic-cap': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l9-5-9-5-9 5 9 5z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z" />
</svg>
),
'server': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
</svg>
),
'document-report': (
<svg className="w-5 h-5" 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>
),
'currency-euro': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.121 15.536c-1.171 1.952-3.07 1.952-4.242 0-1.172-1.953-1.172-5.119 0-7.072 1.171-1.952 3.07-1.952 4.242 0M8 10.5h4m-4 3h4m9-1.5a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
'calculator': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
),
'calendar': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
'document-download': (
<svg className="w-5 h-5" 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>
),
}
// Default wizard steps
const defaultSteps: WizardStep[] = [
{ number: 1, id: 'foerderprogramm', title: 'Foerderprogramm', subtitle: 'Programm & Grunddaten', description: 'Waehlen Sie das Foerderprogramm', icon: 'document-text', is_required: true, is_completed: false },
{ number: 2, id: 'schulinformationen', title: 'Schulinformationen', subtitle: 'Schule & Traeger', description: 'Angaben zur Schule', icon: 'academic-cap', is_required: true, is_completed: false },
{ number: 3, id: 'bestandsaufnahme', title: 'IT-Bestand', subtitle: 'Aktuelle Infrastruktur', description: 'IT-Bestandsaufnahme', icon: 'server', is_required: true, is_completed: false },
{ number: 4, id: 'projektbeschreibung', title: 'Projektbeschreibung', subtitle: 'Ziele & Didaktik', description: 'Projektziele beschreiben', icon: 'document-report', is_required: true, is_completed: false },
{ number: 5, id: 'investitionen', title: 'Investitionen', subtitle: 'Kostenaufstellung', description: 'Geplante Anschaffungen', icon: 'currency-euro', is_required: true, is_completed: false },
{ number: 6, id: 'finanzierungsplan', title: 'Finanzierung', subtitle: 'Budget & Eigenanteil', description: 'Finanzierungsplan', icon: 'calculator', is_required: true, is_completed: false },
{ number: 7, id: 'zeitplan', title: 'Zeitplan', subtitle: 'Laufzeit & Meilensteine', description: 'Projektlaufzeit planen', icon: 'calendar', is_required: true, is_completed: false },
{ number: 8, id: 'abschluss', title: 'Abschluss', subtitle: 'Dokumente & Pruefung', description: 'Zusammenfassung', icon: 'document-download', is_required: true, is_completed: false },
]
export default function FoerderantragWizardPage() {
const params = useParams()
const router = useRouter()
const applicationId = params.applicationId as string
const [currentStep, setCurrentStep] = useState(1)
const [steps, setSteps] = useState<WizardStep[]>(defaultSteps)
const [formData, setFormData] = useState<FormData>({})
const [isSaving, setIsSaving] = useState(false)
const [showAssistant, setShowAssistant] = useState(false)
const [assistantMessage, setAssistantMessage] = useState('')
const [assistantHistory, setAssistantHistory] = useState<{ role: string; content: string }[]>([])
const [isDemo, setIsDemo] = useState(false)
useEffect(() => {
// Check if this is a demo application
if (applicationId.startsWith('demo-')) {
setIsDemo(true)
}
loadApplication()
}, [applicationId])
const loadApplication = async () => {
// In production, load from API
// For demo, use mock data
}
const handleFieldChange = (fieldId: string, value: any) => {
setFormData(prev => ({
...prev,
[`step_${currentStep}`]: {
...prev[`step_${currentStep}`],
[fieldId]: value,
},
}))
}
const handleSaveStep = async () => {
setIsSaving(true)
try {
// Save step data
// Update step completion status
setSteps(prev => prev.map(s =>
s.number === currentStep ? { ...s, is_completed: true } : s
))
} finally {
setIsSaving(false)
}
}
const handleNextStep = async () => {
await handleSaveStep()
if (currentStep < 8) {
setCurrentStep(prev => prev + 1)
}
}
const handlePrevStep = () => {
if (currentStep > 1) {
setCurrentStep(prev => prev - 1)
}
}
const handleAskAssistant = async () => {
if (!assistantMessage.trim()) return
const userMessage = assistantMessage
setAssistantMessage('')
setAssistantHistory(prev => [...prev, { role: 'user', content: userMessage }])
// Simulate assistant response
setTimeout(() => {
const response = getAssistantResponse(userMessage, currentStep)
setAssistantHistory(prev => [...prev, { role: 'assistant', content: response }])
}, 1000)
}
const getAssistantResponse = (question: string, step: number): string => {
// Simple response logic - in production, this calls the LLM API
if (question.toLowerCase().includes('foerderquote')) {
return 'Die Foerderquote im DigitalPakt 2.0 betraegt in der Regel 90%. Das bedeutet, dass 10% der Kosten als Eigenanteil vom Schultraeger zu tragen sind. In einigen Bundeslaendern gibt es Sonderregelungen fuer finanzschwache Kommunen.'
}
if (question.toLowerCase().includes('mep') || question.toLowerCase().includes('medienentwicklungsplan')) {
return 'Der Medienentwicklungsplan (MEP) ist ein strategisches Dokument, das die paedagogischen und technischen Ziele der Schule fuer die Digitalisierung beschreibt. In den meisten Bundeslaendern ist ein MEP Voraussetzung fuer die Foerderung.'
}
if (question.toLowerCase().includes('foerderfahig')) {
return 'Foerderfahig sind unter anderem: Netzwerkinfrastruktur, WLAN, Praesentationstechnik, Endgeraete (mit Einschraenkungen), Server und lokale KI-Systeme. Nicht foerderfahig sind: Verbrauchsmaterial, laufende Betriebskosten und Cloud-Abonnements ohne lokale Alternative.'
}
return `Ich helfe Ihnen gerne bei Schritt ${step}. Haben Sie eine konkrete Frage zu den Feldern in diesem Abschnitt? Sie koennen mich auch nach Formulierungshilfen oder Erklaerungen zu Fachbegriffen fragen.`
}
const renderStepContent = () => {
const step = steps.find(s => s.number === currentStep)
if (!step) return null
switch (currentStep) {
case 1:
return <Step1Foerderprogramm formData={formData} onChange={handleFieldChange} />
case 2:
return <Step2Schulinformationen formData={formData} onChange={handleFieldChange} />
case 3:
return <Step3Bestandsaufnahme formData={formData} onChange={handleFieldChange} />
case 4:
return <Step4Projektbeschreibung formData={formData} onChange={handleFieldChange} />
case 5:
return <Step5Investitionen formData={formData} onChange={handleFieldChange} />
case 6:
return <Step6Finanzierungsplan formData={formData} onChange={handleFieldChange} />
case 7:
return <Step7Zeitplan formData={formData} onChange={handleFieldChange} />
case 8:
return <Step8Abschluss formData={formData} onChange={handleFieldChange} />
default:
return null
}
}
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-white border-b border-slate-200 sticky top-0 z-20">
<div className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href="/education/foerderantrag"
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
>
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="font-semibold text-slate-900">Foerderantrag bearbeiten</h1>
<p className="text-sm text-slate-500">
Schritt {currentStep} von {steps.length}: {steps.find(s => s.number === currentStep)?.title}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{isDemo && (
<span className="px-3 py-1 bg-amber-100 text-amber-700 text-sm font-medium rounded-full">
Demo-Modus
</span>
)}
<button
onClick={() => setShowAssistant(!showAssistant)}
className={`p-2 rounded-lg transition-colors ${showAssistant ? 'bg-blue-100 text-blue-600' : 'hover:bg-slate-100 text-slate-600'}`}
title="KI-Assistent"
>
<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>
</button>
<button
onClick={handleSaveStep}
disabled={isSaving}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg font-medium hover:bg-slate-200 disabled:opacity-50 transition-colors"
>
{isSaving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
{/* Progress Steps */}
<div className="px-6 pb-4 overflow-x-auto">
<div className="flex gap-1 min-w-max">
{steps.map((step) => (
<button
key={step.number}
onClick={() => setCurrentStep(step.number)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-all ${
currentStep === step.number
? 'bg-blue-600 text-white'
: step.is_completed
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
currentStep === step.number
? 'bg-white/20'
: step.is_completed
? 'bg-green-500 text-white'
: 'bg-slate-300 text-slate-600'
}`}>
{step.is_completed && currentStep !== step.number ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
) : (
step.number
)}
</span>
<span className="hidden md:block font-medium">{step.title}</span>
</button>
))}
</div>
</div>
</div>
{/* Main Content */}
<div className="flex">
{/* Form Area */}
<div className={`flex-1 p-6 transition-all ${showAssistant ? 'pr-96' : ''}`}>
<div className="max-w-3xl mx-auto">
{/* Step Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-blue-100 text-blue-600 flex items-center justify-center">
{stepIcons[steps.find(s => s.number === currentStep)?.icon || 'document-text']}
</div>
<div>
<h2 className="text-xl font-semibold text-slate-900">
{steps.find(s => s.number === currentStep)?.title}
</h2>
<p className="text-sm text-slate-500">
{steps.find(s => s.number === currentStep)?.description}
</p>
</div>
</div>
</div>
{/* Step Content */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
{renderStepContent()}
</div>
{/* Navigation */}
<div className="flex items-center justify-between mt-6">
<button
onClick={handlePrevStep}
disabled={currentStep === 1}
className="px-6 py-3 text-slate-600 hover:text-slate-900 disabled:opacity-50 disabled:cursor-not-allowed font-medium 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="M15 19l-7-7 7-7" />
</svg>
Zurueck
</button>
<button
onClick={handleNextStep}
className="px-6 py-3 bg-blue-600 text-white rounded-xl font-semibold hover:bg-blue-700 flex items-center gap-2 transition-colors"
>
{currentStep === 8 ? 'Abschliessen' : 'Weiter'}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
{/* Assistant Sidebar */}
{showAssistant && (
<div className="fixed right-0 top-0 h-full w-96 bg-white border-l border-slate-200 shadow-xl z-30 flex flex-col">
<div className="p-4 border-b border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center">
<svg className="w-4 h-4 text-white" 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>
<h3 className="font-semibold text-slate-900">KI-Assistent</h3>
<p className="text-xs text-slate-500">Ich helfe bei Fragen</p>
</div>
</div>
<button
onClick={() => setShowAssistant(false)}
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
>
<svg className="w-4 h-4 text-slate-500" 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>
{/* Chat History */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{assistantHistory.length === 0 && (
<div className="text-center py-8">
<p className="text-slate-500 text-sm">
Stellen Sie mir Fragen zum aktuellen Schritt oder bitten Sie um Formulierungshilfen.
</p>
<div className="mt-4 space-y-2">
{['Was ist foerderfahig?', 'Erklaere die Foerderquote', 'Was ist ein MEP?'].map((q) => (
<button
key={q}
onClick={() => {
setAssistantMessage(q)
setTimeout(handleAskAssistant, 100)
}}
className="block w-full text-left px-3 py-2 bg-slate-100 rounded-lg text-sm text-slate-700 hover:bg-slate-200 transition-colors"
>
{q}
</button>
))}
</div>
</div>
)}
{assistantHistory.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] p-3 rounded-xl text-sm ${
msg.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-700'
}`}
>
{msg.content}
</div>
</div>
))}
</div>
{/* Input */}
<div className="p-4 border-t border-slate-200">
<div className="flex gap-2">
<input
type="text"
value={assistantMessage}
onChange={(e) => setAssistantMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAskAssistant()}
placeholder="Frage stellen..."
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleAskAssistant}
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}
// Step Components (simplified for now)
function Step1Foerderprogramm({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<p className="text-slate-600">
Die Grunddaten wurden bereits beim Erstellen des Antrags festgelegt.
Sie koennen diese hier bei Bedarf anpassen.
</p>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-700">
Klicken Sie auf "Weiter" um mit den Schulinformationen fortzufahren.
</p>
</div>
</div>
)
}
function Step2Schulinformationen({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Schulname *</label>
<input
type="text"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. Gymnasium am Beispielweg"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Schulnummer *</label>
<input
type="text"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. 12345"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Anzahl Schueler</label>
<input
type="number"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. 850"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Anzahl Lehrkraefte</label>
<input
type="number"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. 65"
/>
</div>
</div>
</div>
)
}
function Step3Bestandsaufnahme({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<input type="checkbox" id="has_wlan" className="w-4 h-4 rounded border-slate-300" />
<label htmlFor="has_wlan" className="text-sm text-slate-700">WLAN vorhanden</label>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Internet-Bandbreite</label>
<select className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option>Unter 16 Mbit/s</option>
<option>16-50 Mbit/s</option>
<option>50-100 Mbit/s</option>
<option>100-250 Mbit/s</option>
<option>Ueber 250 Mbit/s</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Vorhandene Endgeraete</label>
<input
type="number"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Anzahl"
/>
</div>
</div>
)
}
function Step4Projektbeschreibung({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Kurzbeschreibung *</label>
<textarea
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="Beschreiben Sie Ihr Projekt in 2-3 Saetzen..."
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Projektziele *</label>
<textarea
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="Welche konkreten Ziele verfolgen Sie?"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Paedagogisches Konzept *</label>
<textarea
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="Wie wird die Technik im Unterricht eingesetzt?"
/>
</div>
</div>
)
}
function Step5Investitionen({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<p className="text-slate-600">
Listen Sie alle geplanten Investitionen auf. Der Wizard berechnet automatisch die Summen.
</p>
<div className="border border-slate-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-slate-700">Beschreibung</th>
<th className="px-4 py-2 text-left font-medium text-slate-700">Anzahl</th>
<th className="px-4 py-2 text-left font-medium text-slate-700">Einzelpreis</th>
<th className="px-4 py-2 text-left font-medium text-slate-700">Gesamt</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-slate-200">
<td className="px-4 py-2" colSpan={4}>
<button className="text-blue-600 hover:text-blue-700 font-medium text-sm 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="M12 4v16m8-8H4" />
</svg>
Position hinzufuegen
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
)
}
function Step6Finanzierungsplan({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Foerderquote</label>
<div className="flex items-center gap-4">
<input
type="range"
min="50"
max="100"
defaultValue="90"
className="flex-1"
/>
<span className="text-lg font-semibold text-slate-900">90%</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-sm text-slate-500">Gesamtkosten</div>
<div className="text-xl font-bold text-slate-900">0,00 EUR</div>
</div>
<div className="p-4 bg-blue-50 rounded-lg">
<div className="text-sm text-blue-600">Foerderbetrag</div>
<div className="text-xl font-bold text-blue-700">0,00 EUR</div>
</div>
</div>
</div>
)
}
function Step7Zeitplan({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Projektbeginn *</label>
<input
type="date"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Projektende *</label>
<input
type="date"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Meilensteine</label>
<p className="text-sm text-slate-500">Definieren Sie wichtige Projektmeilensteine</p>
</div>
</div>
)
}
function Step8Abschluss({ formData, onChange }: { formData: FormData; onChange: (id: string, value: any) => void }) {
return (
<div className="space-y-6">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 className="font-semibold text-green-800">Zusammenfassung</h3>
<p className="text-sm text-green-700 mt-1">
Pruefen Sie alle Angaben und laden Sie ggf. zusaetzliche Dokumente hoch.
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Datenschutzkonzept *</label>
<textarea
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder="Beschreiben Sie die Massnahmen zum Datenschutz..."
/>
</div>
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg">
<h3 className="font-semibold text-amber-800">Hinweis zur Traegerpruefung</h3>
<p className="text-sm text-amber-700 mt-1">
Der generierte Antrag ist ein antragsfaehiger ENTWURF.
Die finale Pruefung und Einreichung erfolgt durch den Schultraeger.
</p>
</div>
<div className="space-y-3">
<label className="flex items-center gap-3">
<input type="checkbox" className="w-4 h-4 rounded border-slate-300" />
<span className="text-sm text-slate-700">Ich bestaetige, dass alle Angaben nach bestem Wissen gemacht wurden</span>
</label>
<label className="flex items-center gap-3">
<input type="checkbox" className="w-4 h-4 rounded border-slate-300" />
<span className="text-sm text-slate-700">Ich habe verstanden, dass der Antrag vom Schultraeger geprueft werden muss</span>
</label>
</div>
</div>
)
}

View File

@@ -1,368 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
// Types
type FundingProgram = 'DIGITALPAKT_1' | 'DIGITALPAKT_2' | 'LANDESFOERDERUNG' | 'SCHULTRAEGER'
type FederalState = 'NI' | 'NRW' | 'BAY' | 'BW' | 'HE' | 'SN' | 'TH' | 'SA' | 'BB' | 'MV' | 'SH' | 'HH' | 'HB' | 'BE' | 'SL' | 'RP'
interface FormData {
title: string
funding_program: FundingProgram
federal_state: FederalState
preset_id: string
}
const API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8080'
const fundingPrograms = [
{ value: 'DIGITALPAKT_2', label: 'DigitalPakt 2.0', description: 'Foerderung digitaler Bildungsinfrastruktur (2025-2030)' },
{ value: 'DIGITALPAKT_1', label: 'DigitalPakt 1.0 (Restmittel)', description: 'Restmittel aus der ersten Phase' },
{ value: 'LANDESFOERDERUNG', label: 'Landesfoerderung', description: 'Landesspezifische Foerderprogramme' },
{ value: 'SCHULTRAEGER', label: 'Schultraegerfoerderung', description: 'Foerderung durch Schultraeger' },
]
const federalStates = [
{ value: 'NI', label: 'Niedersachsen', flag: 'NI' },
{ value: 'NRW', label: 'Nordrhein-Westfalen', flag: 'NRW' },
{ value: 'BAY', label: 'Bayern', flag: 'BAY' },
{ value: 'BW', label: 'Baden-Wuerttemberg', flag: 'BW' },
{ value: 'HE', label: 'Hessen', flag: 'HE' },
{ value: 'SN', label: 'Sachsen', flag: 'SN' },
{ value: 'TH', label: 'Thueringen', flag: 'TH' },
{ value: 'SA', label: 'Sachsen-Anhalt', flag: 'SA' },
{ value: 'BB', label: 'Brandenburg', flag: 'BB' },
{ value: 'MV', label: 'Mecklenburg-Vorpommern', flag: 'MV' },
{ value: 'SH', label: 'Schleswig-Holstein', flag: 'SH' },
{ value: 'HH', label: 'Hamburg', flag: 'HH' },
{ value: 'HB', label: 'Bremen', flag: 'HB' },
{ value: 'BE', label: 'Berlin', flag: 'BE' },
{ value: 'SL', label: 'Saarland', flag: 'SL' },
{ value: 'RP', label: 'Rheinland-Pfalz', flag: 'RP' },
]
const presets = [
{
id: 'breakpilot_basic',
name: 'BreakPilot Basis',
description: 'Lokale KI-Arbeitsstation fuer eine Schule',
budget: '~18.500 EUR',
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
color: 'blue',
},
{
id: 'breakpilot_cluster',
name: 'BreakPilot Schulverbund',
description: 'Zentrale KI-Infrastruktur fuer mehrere Schulen',
budget: '~68.500 EUR',
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
),
color: 'purple',
},
{
id: '',
name: 'Individuell',
description: 'Leerer Wizard fuer eigene Projekte',
budget: 'Flexibel',
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
),
color: 'slate',
},
]
export default function NewFoerderantragPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [formData, setFormData] = useState<FormData>({
title: '',
funding_program: 'DIGITALPAKT_2',
federal_state: 'NI',
preset_id: searchParams.get('preset') || '',
})
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Set preset from URL params
useEffect(() => {
const preset = searchParams.get('preset')
if (preset) {
setFormData(prev => ({ ...prev, preset_id: preset }))
// Auto-generate title based on preset
const presetInfo = presets.find(p => p.id === preset)
if (presetInfo && presetInfo.id) {
setFormData(prev => ({
...prev,
preset_id: preset,
title: `${presetInfo.name} - ${new Date().toLocaleDateString('de-DE')}`,
}))
}
}
}, [searchParams])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!formData.title.trim()) {
setError('Bitte geben Sie einen Projekttitel ein')
return
}
setIsSubmitting(true)
try {
// In production, this would call the API
// const response = await fetch(`${API_BASE}/sdk/v1/funding/applications`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(formData),
// })
// const data = await response.json()
// router.push(`/education/foerderantrag/${data.id}`)
// For now, redirect to mock ID
const mockId = 'demo-' + Date.now()
router.push(`/education/foerderantrag/${mockId}`)
} catch (err) {
setError('Fehler beim Erstellen des Antrags')
} finally {
setIsSubmitting(false)
}
}
const getPresetColorClasses = (color: string, isSelected: boolean) => {
const colors: Record<string, { border: string; bg: string; ring: string }> = {
blue: {
border: isSelected ? 'border-blue-500' : 'border-slate-200',
bg: isSelected ? 'bg-blue-50' : 'bg-white',
ring: 'ring-blue-500',
},
purple: {
border: isSelected ? 'border-purple-500' : 'border-slate-200',
bg: isSelected ? 'bg-purple-50' : 'bg-white',
ring: 'ring-purple-500',
},
slate: {
border: isSelected ? 'border-slate-500' : 'border-slate-200',
bg: isSelected ? 'bg-slate-50' : 'bg-white',
ring: 'ring-slate-500',
},
}
return colors[color] || colors.slate
}
return (
<div className="max-w-4xl mx-auto">
{/* Back Link */}
<Link
href="/education/foerderantrag"
className="inline-flex items-center gap-2 text-slate-600 hover:text-slate-900 mb-6"
>
<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
</Link>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-900">Neuen Foerderantrag starten</h1>
<p className="mt-2 text-slate-600">
Waehlen Sie das Foerderprogramm und Ihr Bundesland. Der Wizard fuehrt Sie durch alle weiteren Schritte.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Preset Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Schnellstart mit Preset (optional)
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{presets.map((preset) => {
const isSelected = formData.preset_id === preset.id
const colors = getPresetColorClasses(preset.color, isSelected)
return (
<button
key={preset.id || 'custom'}
type="button"
onClick={() => setFormData(prev => ({
...prev,
preset_id: preset.id,
title: preset.id ? `${preset.name} - ${new Date().toLocaleDateString('de-DE')}` : prev.title,
}))}
className={`relative p-4 rounded-xl border-2 text-left transition-all ${colors.border} ${colors.bg} ${isSelected ? 'ring-2 ' + colors.ring : ''}`}
>
{isSelected && (
<div className="absolute top-2 right-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="M5 13l4 4L19 7" />
</svg>
</div>
)}
<div className={`w-12 h-12 rounded-lg bg-${preset.color}-100 text-${preset.color}-600 flex items-center justify-center mb-3`}>
{preset.icon}
</div>
<h3 className="font-semibold text-slate-900">{preset.name}</h3>
<p className="text-sm text-slate-500 mt-1">{preset.description}</p>
<p className="text-sm font-medium text-slate-700 mt-2">{preset.budget}</p>
</button>
)
})}
</div>
</div>
{/* Funding Program */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Foerderprogramm *
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{fundingPrograms.map((program) => (
<label
key={program.value}
className={`relative flex items-start p-4 rounded-xl border-2 cursor-pointer transition-all ${
formData.funding_program === program.value
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<input
type="radio"
name="funding_program"
value={program.value}
checked={formData.funding_program === program.value}
onChange={(e) => setFormData(prev => ({ ...prev, funding_program: e.target.value as FundingProgram }))}
className="sr-only"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{program.label}</span>
{formData.funding_program === program.value && (
<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="M5 13l4 4L19 7" />
</svg>
)}
</div>
<p className="text-sm text-slate-500 mt-1">{program.description}</p>
</div>
</label>
))}
</div>
</div>
{/* Federal State */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Bundesland *
</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{federalStates.map((state) => (
<button
key={state.value}
type="button"
onClick={() => setFormData(prev => ({ ...prev, federal_state: state.value as FederalState }))}
className={`px-4 py-3 rounded-lg border-2 text-sm font-medium transition-all ${
formData.federal_state === state.value
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-slate-200 bg-white text-slate-700 hover:border-slate-300'
}`}
>
{state.label}
</button>
))}
</div>
<p className="mt-2 text-sm text-slate-500">
{formData.federal_state === 'NI' && 'Niedersachsen ist der Pilot-Standort mit optimaler Unterstuetzung.'}
</p>
</div>
{/* Project Title */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-slate-700 mb-2">
Projekttitel *
</label>
<input
type="text"
id="title"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
placeholder="z.B. Digitale Lernumgebung fuer differenzierten Unterricht"
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
maxLength={200}
/>
<p className="mt-2 text-sm text-slate-500">
Ein aussagekraeftiger Titel fuer Ihr Foerderprojekt (max. 200 Zeichen)
</p>
</div>
{/* Error */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-red-700">
{error}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between pt-4 border-t border-slate-200">
<Link
href="/education/foerderantrag"
className="px-6 py-3 text-slate-600 hover:text-slate-900 font-medium"
>
Abbrechen
</Link>
<button
type="submit"
disabled={isSubmitting}
className="px-8 py-3 bg-blue-600 text-white rounded-xl font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition-colors"
>
{isSubmitting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Wird erstellt...
</>
) : (
<>
Wizard starten
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</>
)}
</button>
</div>
</form>
{/* Help Box */}
<div className="mt-8 bg-amber-50 border border-amber-200 rounded-xl p-6">
<div className="flex gap-4">
<div className="flex-shrink-0">
<svg className="w-6 h-6 text-amber-600" 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>
<h3 className="font-semibold text-amber-800">KI-Assistent verfuegbar</h3>
<p className="mt-1 text-sm text-amber-700">
Im Wizard steht Ihnen ein KI-Assistent zur Seite, der bei Fragen hilft,
Formulierungen vorschlaegt und Sie durch den Antragsprozess fuehrt.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,365 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
// Types
interface FundingApplication {
id: string
application_number: string
title: string
funding_program: string
status: string
current_step: number
total_steps: number
requested_amount: number
school_profile?: {
name: string
federal_state: string
}
created_at: string
updated_at: string
}
interface Statistics {
total_applications: number
draft_count: number
submitted_count: number
approved_count: number
total_requested: number
total_approved: number
}
const API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8080'
// Status badge colors
const statusColors: Record<string, { bg: string; text: string; label: string }> = {
DRAFT: { bg: 'bg-slate-100', text: 'text-slate-700', label: 'Entwurf' },
IN_PROGRESS: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'In Bearbeitung' },
REVIEW: { bg: 'bg-amber-100', text: 'text-amber-700', label: 'Pruefung' },
SUBMITTED: { bg: 'bg-purple-100', text: 'text-purple-700', label: 'Eingereicht' },
APPROVED: { bg: 'bg-green-100', text: 'text-green-700', label: 'Genehmigt' },
REJECTED: { bg: 'bg-red-100', text: 'text-red-700', label: 'Abgelehnt' },
}
const programLabels: Record<string, string> = {
DIGITALPAKT_1: 'DigitalPakt 1.0',
DIGITALPAKT_2: 'DigitalPakt 2.0',
LANDESFOERDERUNG: 'Landesfoerderung',
SCHULTRAEGER: 'Schultraeger',
}
export default function FoerderantragPage() {
const [applications, setApplications] = useState<FundingApplication[]>([])
const [statistics, setStatistics] = useState<Statistics | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
setLoading(true)
// In production, these would be real API calls
// For now, we use mock data
setApplications([])
setStatistics({
total_applications: 0,
draft_count: 0,
submitted_count: 0,
approved_count: 0,
total_requested: 0,
total_approved: 0,
})
} catch (err) {
setError('Fehler beim Laden der Daten')
} finally {
setLoading(false)
}
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount)
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
return (
<div className="space-y-8">
{/* Page Purpose */}
<PagePurpose
title="Foerderantrag-Wizard"
purpose="Erstellen Sie antragsfaehige Foerderantraege fuer Schulen. Der Wizard fuehrt Sie Schritt fuer Schritt durch den Prozess und generiert alle erforderlichen Dokumente."
audience={['Schulleitung', 'IT-Beauftragte', 'Schultraeger']}
architecture={{
services: ['ai-compliance-sdk (Go)', 'LLM-Service (32B)'],
databases: ['PostgreSQL'],
}}
collapsible={true}
defaultCollapsed={true}
/>
{/* Hero Section */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-blue-600 via-blue-700 to-indigo-800 p-8 text-white">
<div className="absolute inset-0 bg-[url('/grid-pattern.svg')] opacity-10" />
<div className="relative z-10">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold">Foerderantrag-Wizard</h1>
<p className="mt-2 text-blue-100 max-w-2xl">
Erstellen Sie vollstaendige Foerderantraege fuer DigitalPakt 2.0 und Landesfoerderungen.
Der Wizard fuehrt Sie durch alle 8 Schritte und generiert antragsfaehige Dokumente.
</p>
<div className="mt-6 flex gap-4">
<Link
href="/education/foerderantrag/new"
className="inline-flex items-center gap-2 px-6 py-3 bg-white text-blue-700 rounded-xl font-semibold hover:bg-blue-50 transition-colors shadow-lg"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neuen Antrag starten
</Link>
</div>
</div>
<div className="hidden lg:block">
<svg className="w-32 h-32 text-blue-300 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} 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>
</div>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-blue-100 flex items-center justify-center">
<svg className="w-6 h-6 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>
<div className="text-2xl font-bold text-slate-900">{statistics?.total_applications || 0}</div>
<div className="text-sm text-slate-500">Antraege gesamt</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-amber-100 flex items-center justify-center">
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">{statistics?.draft_count || 0}</div>
<div className="text-sm text-slate-500">Entwuerfe</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-purple-100 flex items-center justify-center">
<svg className="w-6 h-6 text-purple-600" 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>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">{statistics?.submitted_count || 0}</div>
<div className="text-sm text-slate-500">Eingereicht</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-green-100 flex items-center justify-center">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-slate-900">{formatCurrency(statistics?.total_requested || 0)}</div>
<div className="text-sm text-slate-500">Beantragt</div>
</div>
</div>
</div>
</div>
{/* Quick Start Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Link
href="/education/foerderantrag/new?preset=breakpilot_basic"
className="group bg-white rounded-xl border-2 border-slate-200 p-6 hover:border-blue-400 hover:shadow-lg transition-all"
>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h3 className="font-semibold text-lg text-slate-900 group-hover:text-blue-600 transition-colors">
BreakPilot Basis
</h3>
<p className="text-sm text-slate-500 mt-1">
Lokale KI-Arbeitsstation fuer eine Schule. Vorausgefuellte Kostenplanung und Datenschutzkonzept.
</p>
<div className="mt-4 text-sm font-medium text-blue-600">
~18.500 EUR Foerdervolumen
</div>
</Link>
<Link
href="/education/foerderantrag/new?preset=breakpilot_cluster"
className="group bg-white rounded-xl border-2 border-slate-200 p-6 hover:border-blue-400 hover:shadow-lg transition-all"
>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500 to-pink-600 flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-white" 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>
<h3 className="font-semibold text-lg text-slate-900 group-hover:text-purple-600 transition-colors">
BreakPilot Schulverbund
</h3>
<p className="text-sm text-slate-500 mt-1">
Zentrale KI-Infrastruktur fuer mehrere Schulen eines Traegers.
</p>
<div className="mt-4 text-sm font-medium text-purple-600">
~68.500 EUR Foerdervolumen
</div>
</Link>
<Link
href="/education/foerderantrag/new"
className="group bg-white rounded-xl border-2 border-slate-200 p-6 hover:border-slate-400 hover:shadow-lg transition-all"
>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-slate-500 to-slate-700 flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</div>
<h3 className="font-semibold text-lg text-slate-900 group-hover:text-slate-700 transition-colors">
Individueller Antrag
</h3>
<p className="text-sm text-slate-500 mt-1">
Leerer Wizard fuer individuelle Projekte. Volle Flexibilitaet bei der Planung.
</p>
<div className="mt-4 text-sm font-medium text-slate-600">
Beliebiges Foerdervolumen
</div>
</Link>
</div>
{/* Applications List */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h2 className="font-semibold text-lg text-slate-900">Meine Antraege</h2>
<div className="flex items-center gap-2">
<select className="px-3 py-1.5 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Alle Status</option>
<option value="DRAFT">Entwurf</option>
<option value="SUBMITTED">Eingereicht</option>
<option value="APPROVED">Genehmigt</option>
</select>
</div>
</div>
{loading ? (
<div className="p-12 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-slate-500">Lade Antraege...</p>
</div>
) : applications.length === 0 ? (
<div className="p-12 text-center">
<svg className="w-16 h-16 text-slate-300 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} 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="mt-4 text-lg font-medium text-slate-900">Noch keine Antraege</h3>
<p className="mt-2 text-slate-500">
Starten Sie jetzt Ihren ersten Foerderantrag mit dem Wizard.
</p>
<Link
href="/education/foerderantrag/new"
className="mt-6 inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 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="M12 4v16m8-8H4" />
</svg>
Ersten Antrag erstellen
</Link>
</div>
) : (
<div className="divide-y divide-slate-100">
{applications.map((app) => {
const status = statusColors[app.status] || statusColors.DRAFT
return (
<Link
key={app.id}
href={`/education/foerderantrag/${app.id}`}
className="flex items-center gap-4 p-4 hover:bg-slate-50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<h3 className="font-medium text-slate-900 truncate">{app.title}</h3>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${status.bg} ${status.text}`}>
{status.label}
</span>
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-slate-500">
<span>{app.application_number}</span>
<span>{programLabels[app.funding_program] || app.funding_program}</span>
{app.school_profile?.name && (
<span>{app.school_profile.name}</span>
)}
</div>
</div>
<div className="text-right">
<div className="font-medium text-slate-900">{formatCurrency(app.requested_amount)}</div>
<div className="text-sm text-slate-500">Schritt {app.current_step}/{app.total_steps}</div>
</div>
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
)
})}
</div>
)}
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
<div className="flex gap-4">
<div className="flex-shrink-0">
<svg className="w-6 h-6 text-blue-600" 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>
<div>
<h3 className="font-semibold text-blue-800">Wichtiger Hinweis</h3>
<p className="mt-1 text-sm text-blue-700">
Der Wizard erstellt einen <strong>antragsfaehigen Entwurf</strong>. Die finale Pruefung und
Einreichung erfolgt durch den Schultraeger. Alle generierten Dokumente (Antragsschreiben,
Kostenplan, Datenschutzkonzept) koennen als ZIP heruntergeladen werden.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -226,7 +226,7 @@ export default function MiddlewareAdminPage() {
relatedPages={[
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
{ name: 'Mac Mini', href: '/infrastructure/mac-mini', description: 'Server-Monitoring' },
{ name: 'Controls', href: '/compliance/controls', description: 'Security Controls' },
{ name: 'Controls', href: '/sdk/controls', description: 'Security Controls' },
]}
collapsible={true}
defaultCollapsed={true}

View File

@@ -424,7 +424,7 @@ export default function SBOMPage() {
}}
relatedPages={[
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
{ name: 'Controls', href: '/compliance/controls', description: 'Security Controls' },
{ name: 'Controls', href: '/sdk/controls', description: 'Security Controls' },
]}
collapsible={true}
defaultCollapsed={true}

View File

@@ -299,7 +299,7 @@ export default function SecurityDashboardPage() {
relatedPages={[
{ name: 'SBOM', href: '/infrastructure/sbom', description: 'Software Bill of Materials' },
{ name: 'Middleware', href: '/infrastructure/middleware', description: 'API Gateway & Rate Limiting' },
{ name: 'Controls', href: '/compliance/controls', description: 'Security Controls' },
{ name: 'Controls', href: '/sdk/controls', description: 'Security Controls' },
]}
collapsible={true}
defaultCollapsed={true}

View File

@@ -334,7 +334,7 @@ export default function RBACPage() {
databases: ['compliance_tenants', 'compliance_namespaces', 'compliance_roles', 'compliance_llm_policies'],
}}
relatedPages={[
{ name: 'Audit Trail', href: '/compliance/audit-report', description: 'LLM-Operationen protokollieren' },
{ name: 'Audit Trail', href: '/sdk/audit-report', description: 'LLM-Operationen protokollieren' },
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider testen' },
]}
/>

View File

@@ -0,0 +1,670 @@
'use client'
/**
* Website Manager - CMS Dashboard
*
* Visual CMS dashboard for the BreakPilot website (macmini:3000).
* 60/40 split: Section cards with inline editors | Live iframe preview.
* Status bar, content stats, reset, save.
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import type {
WebsiteContent,
HeroContent,
FeatureContent,
FAQItem,
PricingPlan,
} from '@/lib/content-types'
const ADMIN_KEY = 'breakpilot-admin-2024'
// Section metadata for cards
const SECTIONS = [
{ key: 'hero', name: 'Hero Section', icon: '🎯', scrollTo: 'hero' },
{ key: 'features', name: 'Features', icon: '⚡', scrollTo: 'features' },
{ key: 'faq', name: 'FAQ', icon: '❓', scrollTo: 'faq' },
{ key: 'pricing', name: 'Pricing', icon: '💰', scrollTo: 'pricing' },
{ key: 'trust', name: 'Trust Indicators', icon: '🛡️', scrollTo: 'trust' },
{ key: 'testimonial', name: 'Testimonial', icon: '💬', scrollTo: 'trust' },
] as const
type SectionKey = (typeof SECTIONS)[number]['key']
// ─── Helpers ───────────────────────────────────────────────────────────────
function countWords(content: WebsiteContent): number {
const texts: string[] = []
// Hero
texts.push(content.hero.badge, content.hero.title, content.hero.titleHighlight1, content.hero.titleHighlight2, content.hero.subtitle, content.hero.ctaPrimary, content.hero.ctaSecondary, content.hero.ctaHint)
// Features
content.features.forEach(f => { texts.push(f.title, f.description) })
// FAQ
content.faq.forEach(f => { texts.push(f.question, ...f.answer) })
// Pricing
content.pricing.forEach(p => { texts.push(p.name, p.description, p.features.tasks, p.features.taskDescription, ...p.features.included) })
// Trust
texts.push(content.trust.item1.value, content.trust.item1.label, content.trust.item2.value, content.trust.item2.label, content.trust.item3.value, content.trust.item3.label)
// Testimonial
texts.push(content.testimonial.quote, content.testimonial.author, content.testimonial.role)
return texts.filter(Boolean).join(' ').split(/\s+/).filter(Boolean).length
}
function sectionComplete(content: WebsiteContent, section: SectionKey): boolean {
switch (section) {
case 'hero':
return !!(content.hero.title && content.hero.subtitle && content.hero.ctaPrimary)
case 'features':
return content.features.length > 0 && content.features.every(f => f.title && f.description)
case 'faq':
return content.faq.length > 0 && content.faq.every(f => f.question && f.answer.length > 0)
case 'pricing':
return content.pricing.length > 0 && content.pricing.every(p => p.name && p.price > 0)
case 'trust':
return !!(content.trust.item1.value && content.trust.item2.value && content.trust.item3.value)
case 'testimonial':
return !!(content.testimonial.quote && content.testimonial.author)
}
}
function sectionSummary(content: WebsiteContent, section: SectionKey): string {
switch (section) {
case 'hero':
return `"${content.hero.title} ${content.hero.titleHighlight1}"`.slice(0, 50)
case 'features':
return `${content.features.length} Features`
case 'faq':
return `${content.faq.length} Fragen`
case 'pricing':
return `${content.pricing.length} Plaene`
case 'trust':
return `${content.trust.item1.value}, ${content.trust.item2.value}, ${content.trust.item3.value}`
case 'testimonial':
return `"${content.testimonial.quote.slice(0, 40)}..."`
}
}
// ─── Main Component ────────────────────────────────────────────────────────
export default function WebsiteManagerPage() {
const [content, setContent] = useState<WebsiteContent | null>(null)
const [originalContent, setOriginalContent] = useState<WebsiteContent | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [expandedSection, setExpandedSection] = useState<SectionKey | null>(null)
const [websiteStatus, setWebsiteStatus] = useState<{ online: boolean; responseTime: number } | null>(null)
const iframeRef = useRef<HTMLIFrameElement>(null)
// Load content
useEffect(() => {
loadContent()
checkWebsiteStatus()
}, [])
// Auto-dismiss messages
useEffect(() => {
if (message) {
const t = setTimeout(() => setMessage(null), 4000)
return () => clearTimeout(t)
}
}, [message])
async function loadContent() {
try {
const res = await fetch('/api/website/content')
if (res.ok) {
const data = await res.json()
setContent(data)
setOriginalContent(JSON.parse(JSON.stringify(data)))
} else {
setMessage({ type: 'error', text: 'Fehler beim Laden des Contents' })
}
} catch {
setMessage({ type: 'error', text: 'Verbindungsfehler beim Laden' })
} finally {
setLoading(false)
}
}
async function checkWebsiteStatus() {
try {
const res = await fetch('/api/website/status')
if (res.ok) {
const data = await res.json()
setWebsiteStatus(data)
}
} catch {
setWebsiteStatus({ online: false, responseTime: 0 })
}
}
async function saveChanges() {
if (!content) return
setSaving(true)
setMessage(null)
try {
const res = await fetch('/api/website/content', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-admin-key': ADMIN_KEY },
body: JSON.stringify(content),
})
if (res.ok) {
setMessage({ type: 'success', text: 'Erfolgreich gespeichert!' })
setOriginalContent(JSON.parse(JSON.stringify(content)))
// Reload iframe to reflect changes
if (iframeRef.current) {
iframeRef.current.src = iframeRef.current.src
}
} else {
const err = await res.json()
setMessage({ type: 'error', text: err.error || 'Fehler beim Speichern' })
}
} catch {
setMessage({ type: 'error', text: 'Verbindungsfehler beim Speichern' })
} finally {
setSaving(false)
}
}
function resetContent() {
if (originalContent) {
setContent(JSON.parse(JSON.stringify(originalContent)))
setMessage({ type: 'success', text: 'Zurueckgesetzt auf letzten gespeicherten Stand' })
}
}
// Scroll iframe to section
const scrollPreview = useCallback((scrollTo: string) => {
if (!iframeRef.current?.contentWindow) return
try {
iframeRef.current.contentWindow.postMessage(
{ type: 'scrollTo', section: scrollTo },
'*'
)
} catch {
// cross-origin fallback
}
}, [])
function toggleSection(key: SectionKey) {
const newExpanded = expandedSection === key ? null : key
setExpandedSection(newExpanded)
if (newExpanded) {
const section = SECTIONS.find(s => s.key === newExpanded)
if (section) scrollPreview(section.scrollTo)
}
}
// ─── Render ────────────────────────────────────────────────────────────────
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="flex items-center gap-3 text-slate-500">
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Lade Website-Content...
</div>
</div>
)
}
if (!content) {
return (
<div className="flex items-center justify-center py-20">
<div className="text-red-600">Content konnte nicht geladen werden.</div>
</div>
)
}
const wordCount = countWords(content)
const completeSections = SECTIONS.filter(s => sectionComplete(content, s.key)).length
const completionPct = Math.round((completeSections / SECTIONS.length) * 100)
const hasChanges = JSON.stringify(content) !== JSON.stringify(originalContent)
return (
<div className="space-y-4">
{/* ── Status Bar ───────────────────────────────────────────────────── */}
<div className="bg-white rounded-xl border border-slate-200 px-5 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Website status */}
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${websiteStatus?.online ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm text-slate-700">
Website {websiteStatus?.online ? 'online' : 'offline'}
{websiteStatus?.online && websiteStatus.responseTime > 0 && (
<span className="text-slate-400 ml-1">({websiteStatus.responseTime}ms)</span>
)}
</span>
</div>
{/* Link */}
<a
href="https://macmini:3000"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-sky-600 hover:text-sky-700 flex items-center gap-1"
>
Zur Website
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<div className="flex items-center gap-3">
{message && (
<span className={`px-3 py-1 rounded-lg text-sm font-medium ${
message.type === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{message.text}
</span>
)}
<button
onClick={resetContent}
disabled={!hasChanges}
className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Reset
</button>
<button
onClick={saveChanges}
disabled={saving || !hasChanges}
className="px-5 py-2 text-sm font-medium text-white bg-sky-600 rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
{/* ── Stats Bar ────────────────────────────────────────────────────── */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Sektionen', value: `${SECTIONS.length}`, icon: '📄' },
{ label: 'Woerter', value: wordCount.toLocaleString('de-DE'), icon: '📝' },
{ label: 'Vollstaendig', value: `${completionPct}%`, icon: completionPct === 100 ? '✅' : '🔧' },
{ label: 'Aenderungen', value: hasChanges ? 'Ungespeichert' : 'Aktuell', icon: hasChanges ? '🟡' : '🟢' },
].map((stat) => (
<div key={stat.label} className="bg-white rounded-xl border border-slate-200 px-4 py-3 flex items-center gap-3">
<span className="text-xl">{stat.icon}</span>
<div>
<div className="text-sm font-semibold text-slate-900">{stat.value}</div>
<div className="text-xs text-slate-500">{stat.label}</div>
</div>
</div>
))}
</div>
{/* ── Main Layout: 60/40 ───────────────────────────────────────────── */}
<div className="grid grid-cols-5 gap-4" style={{ height: 'calc(100vh - 300px)' }}>
{/* ── Left: Section Cards (3/5 = 60%) ──────────────────────────── */}
<div className="col-span-3 overflow-y-auto pr-1 space-y-3">
{SECTIONS.map((section) => {
const isExpanded = expandedSection === section.key
const isComplete = sectionComplete(content, section.key)
return (
<div
key={section.key}
className={`bg-white rounded-xl border transition-all ${
isExpanded ? 'border-sky-300 shadow-md' : 'border-slate-200 hover:border-slate-300'
}`}
>
{/* Card Header */}
<button
onClick={() => toggleSection(section.key)}
className="w-full px-5 py-4 flex items-center justify-between text-left"
>
<div className="flex items-center gap-3">
<span className="text-xl">{section.icon}</span>
<div>
<div className="font-medium text-slate-900">{section.name}</div>
<div className="text-xs text-slate-500 mt-0.5">{sectionSummary(content, section.key)}</div>
</div>
</div>
<div className="flex items-center gap-3">
{isComplete ? (
<span className="w-6 h-6 rounded-full bg-green-100 text-green-600 flex items-center justify-center text-xs">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
</span>
) : (
<span className="w-6 h-6 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center text-xs">!</span>
)}
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{/* Inline Editor */}
{isExpanded && (
<div className="px-5 pb-5 border-t border-slate-100 pt-4">
{section.key === 'hero' && <HeroEditor content={content} setContent={setContent} />}
{section.key === 'features' && <FeaturesEditor content={content} setContent={setContent} />}
{section.key === 'faq' && <FAQEditor content={content} setContent={setContent} />}
{section.key === 'pricing' && <PricingEditor content={content} setContent={setContent} />}
{section.key === 'trust' && <TrustEditor content={content} setContent={setContent} />}
{section.key === 'testimonial' && <TestimonialEditor content={content} setContent={setContent} />}
</div>
)}
</div>
)
})}
</div>
{/* ── Right: Live Preview (2/5 = 40%) ──────────────────────────── */}
<div className="col-span-2 bg-white rounded-xl border border-slate-200 overflow-hidden flex flex-col">
{/* Preview Header */}
<div className="bg-slate-50 border-b border-slate-200 px-4 py-2.5 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
<div className="w-2.5 h-2.5 rounded-full bg-yellow-400" />
<div className="w-2.5 h-2.5 rounded-full bg-green-400" />
</div>
<span className="text-xs text-slate-500 ml-2">macmini:3000</span>
</div>
<button
onClick={() => { if (iframeRef.current) iframeRef.current.src = iframeRef.current.src }}
className="p-1 text-slate-400 hover:text-slate-600 rounded transition-colors"
title="Preview neu laden"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
{/* iframe */}
<div className="flex-1 relative bg-slate-100">
<iframe
ref={iframeRef}
src="https://macmini:3000/?preview=true"
className="absolute inset-0 w-full h-full border-0"
style={{
width: '166.67%',
height: '166.67%',
transform: 'scale(0.6)',
transformOrigin: 'top left',
}}
title="Website Preview"
sandbox="allow-same-origin allow-scripts"
/>
</div>
</div>
</div>
</div>
)
}
// ─── Section Editors ─────────────────────────────────────────────────────────
interface EditorProps {
content: WebsiteContent
setContent: React.Dispatch<React.SetStateAction<WebsiteContent | null>>
}
const inputCls = 'w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors'
const labelCls = 'block text-xs font-medium text-slate-600 mb-1'
// ─── Hero Editor ─────────────────────────────────────────────────────────────
function HeroEditor({ content, setContent }: EditorProps) {
function update(field: keyof HeroContent, value: string) {
setContent(c => c ? { ...c, hero: { ...c.hero, [field]: value } } : c)
}
return (
<div className="grid gap-3">
<div>
<label className={labelCls}>Badge</label>
<input className={inputCls} value={content.hero.badge} onChange={e => update('badge', e.target.value)} />
</div>
<div>
<label className={labelCls}>Titel</label>
<input className={inputCls} value={content.hero.title} onChange={e => update('title', e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Highlight 1</label>
<input className={inputCls} value={content.hero.titleHighlight1} onChange={e => update('titleHighlight1', e.target.value)} />
</div>
<div>
<label className={labelCls}>Highlight 2</label>
<input className={inputCls} value={content.hero.titleHighlight2} onChange={e => update('titleHighlight2', e.target.value)} />
</div>
</div>
<div>
<label className={labelCls}>Untertitel</label>
<textarea className={inputCls} rows={2} value={content.hero.subtitle} onChange={e => update('subtitle', e.target.value)} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelCls}>CTA Primaer</label>
<input className={inputCls} value={content.hero.ctaPrimary} onChange={e => update('ctaPrimary', e.target.value)} />
</div>
<div>
<label className={labelCls}>CTA Sekundaer</label>
<input className={inputCls} value={content.hero.ctaSecondary} onChange={e => update('ctaSecondary', e.target.value)} />
</div>
<div>
<label className={labelCls}>CTA Hinweis</label>
<input className={inputCls} value={content.hero.ctaHint} onChange={e => update('ctaHint', e.target.value)} />
</div>
</div>
</div>
)
}
// ─── Features Editor ─────────────────────────────────────────────────────────
function FeaturesEditor({ content, setContent }: EditorProps) {
function update(index: number, field: keyof FeatureContent, value: string) {
setContent(c => {
if (!c) return c
const features = [...c.features]
features[index] = { ...features[index], [field]: value }
return { ...c, features }
})
}
return (
<div className="space-y-3">
{content.features.map((feature, i) => (
<div key={feature.id} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="grid grid-cols-6 gap-2">
<div>
<label className={labelCls}>Icon</label>
<input className={`${inputCls} text-center text-lg`} value={feature.icon} onChange={e => update(i, 'icon', e.target.value)} />
</div>
<div className="col-span-5">
<label className={labelCls}>Titel</label>
<input className={inputCls} value={feature.title} onChange={e => update(i, 'title', e.target.value)} />
</div>
</div>
<div>
<label className={labelCls}>Beschreibung</label>
<textarea className={inputCls} rows={2} value={feature.description} onChange={e => update(i, 'description', e.target.value)} />
</div>
</div>
))}
</div>
)
}
// ─── FAQ Editor ──────────────────────────────────────────────────────────────
function FAQEditor({ content, setContent }: EditorProps) {
function updateItem(index: number, field: 'question' | 'answer', value: string) {
setContent(c => {
if (!c) return c
const faq = [...c.faq]
if (field === 'answer') {
faq[index] = { ...faq[index], answer: value.split('\n') }
} else {
faq[index] = { ...faq[index], question: value }
}
return { ...c, faq }
})
}
function addItem() {
setContent(c => c ? { ...c, faq: [...c.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }] } : c)
}
function removeItem(index: number) {
setContent(c => c ? { ...c, faq: c.faq.filter((_, i) => i !== index) } : c)
}
return (
<div className="space-y-3">
{content.faq.map((item, i) => (
<div key={i} className="bg-slate-50 rounded-lg p-3 space-y-2 relative group">
<button
onClick={() => removeItem(i)}
className="absolute top-2 right-2 p-1 text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
title="Entfernen"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div>
<label className={labelCls}>Frage {i + 1}</label>
<input className={inputCls} value={item.question} onChange={e => updateItem(i, 'question', e.target.value)} />
</div>
<div>
<label className={labelCls}>Antwort</label>
<textarea className={`${inputCls} font-mono`} rows={3} value={item.answer.join('\n')} onChange={e => updateItem(i, 'answer', e.target.value)} />
</div>
</div>
))}
<button onClick={addItem} className="w-full py-2 border-2 border-dashed border-slate-300 rounded-lg text-sm text-slate-500 hover:border-sky-400 hover:text-sky-600 transition-colors">
+ Frage hinzufuegen
</button>
</div>
)
}
// ─── Pricing Editor ──────────────────────────────────────────────────────────
function PricingEditor({ content, setContent }: EditorProps) {
function update(index: number, field: string, value: string | number | boolean) {
setContent(c => {
if (!c) return c
const pricing = [...c.pricing]
if (field === 'price') {
pricing[index] = { ...pricing[index], price: Number(value) }
} else if (field === 'popular') {
pricing[index] = { ...pricing[index], popular: Boolean(value) }
} else if (field.startsWith('features.')) {
const sub = field.replace('features.', '')
if (sub === 'included' && typeof value === 'string') {
pricing[index] = { ...pricing[index], features: { ...pricing[index].features, included: value.split('\n') } }
} else {
pricing[index] = { ...pricing[index], features: { ...pricing[index].features, [sub]: value } }
}
} else {
pricing[index] = { ...pricing[index], [field]: value }
}
return { ...c, pricing }
})
}
return (
<div className="space-y-4">
{content.pricing.map((plan, i) => (
<div key={plan.id} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-slate-800">{plan.name}</span>
{plan.popular && <span className="text-xs bg-sky-100 text-sky-700 px-1.5 py-0.5 rounded">Beliebt</span>}
</div>
<div className="grid grid-cols-4 gap-2">
<div>
<label className={labelCls}>Name</label>
<input className={inputCls} value={plan.name} onChange={e => update(i, 'name', e.target.value)} />
</div>
<div>
<label className={labelCls}>Preis (EUR)</label>
<input className={inputCls} type="number" step="0.01" value={plan.price} onChange={e => update(i, 'price', e.target.value)} />
</div>
<div>
<label className={labelCls}>Intervall</label>
<input className={inputCls} value={plan.interval} onChange={e => update(i, 'interval', e.target.value)} />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={plan.popular || false} onChange={e => update(i, 'popular', e.target.checked)} className="w-4 h-4 text-sky-600 rounded" />
<span className="text-xs text-slate-600">Beliebt</span>
</label>
</div>
</div>
<div>
<label className={labelCls}>Beschreibung</label>
<input className={inputCls} value={plan.description} onChange={e => update(i, 'description', e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelCls}>Aufgaben</label>
<input className={inputCls} value={plan.features.tasks} onChange={e => update(i, 'features.tasks', e.target.value)} />
</div>
<div>
<label className={labelCls}>Aufgaben-Beschreibung</label>
<input className={inputCls} value={plan.features.taskDescription} onChange={e => update(i, 'features.taskDescription', e.target.value)} />
</div>
</div>
<div>
<label className={labelCls}>Features (eine pro Zeile)</label>
<textarea className={`${inputCls} font-mono`} rows={3} value={plan.features.included.join('\n')} onChange={e => update(i, 'features.included', e.target.value)} />
</div>
</div>
))}
</div>
)
}
// ─── Trust Editor ────────────────────────────────────────────────────────────
function TrustEditor({ content, setContent }: EditorProps) {
function update(key: 'item1' | 'item2' | 'item3', field: 'value' | 'label', val: string) {
setContent(c => c ? { ...c, trust: { ...c.trust, [key]: { ...c.trust[key], [field]: val } } } : c)
}
return (
<div className="grid grid-cols-3 gap-3">
{(['item1', 'item2', 'item3'] as const).map((key, i) => (
<div key={key} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div>
<label className={labelCls}>Wert {i + 1}</label>
<input className={inputCls} value={content.trust[key].value} onChange={e => update(key, 'value', e.target.value)} />
</div>
<div>
<label className={labelCls}>Label {i + 1}</label>
<input className={inputCls} value={content.trust[key].label} onChange={e => update(key, 'label', e.target.value)} />
</div>
</div>
))}
</div>
)
}
// ─── Testimonial Editor ──────────────────────────────────────────────────────
function TestimonialEditor({ content, setContent }: EditorProps) {
function update(field: 'quote' | 'author' | 'role', value: string) {
setContent(c => c ? { ...c, testimonial: { ...c.testimonial, [field]: value } } : c)
}
return (
<div className="space-y-3">
<div>
<label className={labelCls}>Zitat</label>
<textarea className={inputCls} rows={3} value={content.testimonial.quote} onChange={e => update('quote', e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Autor</label>
<input className={inputCls} value={content.testimonial.author} onChange={e => update('author', e.target.value)} />
</div>
<div>
<label className={labelCls}>Rolle</label>
<input className={inputCls} value={content.testimonial.role} onChange={e => update('role', e.target.value)} />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
'use client'
import { getCategoryById } from '@/lib/navigation'
import { ModuleCard } from '@/components/common/ModuleCard'
import { PagePurpose } from '@/components/common/PagePurpose'
export default function WebsitePage() {
const category = getCategoryById('website')
if (!category) {
return <div>Kategorie nicht gefunden</div>
}
return (
<div>
{/* Page Purpose */}
<PagePurpose
title={category.name}
purpose="Website Content & Management. Verwalten Sie Inhalte, Uebersetzungen und das CMS."
audience={['Content Manager', 'Entwickler']}
architecture={{
services: ['website (Next.js)'],
databases: [],
}}
collapsible={true}
defaultCollapsed={false}
/>
{/* Modules Grid */}
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{category.modules.map((module) => (
<ModuleCard key={module.id} module={module} category={category} />
))}
</div>
{/* Info Section */}
<div className="mt-8 bg-sky-50 border border-sky-200 rounded-xl p-6">
<h3 className="font-semibold text-sky-800 flex items-center gap-2">
<span>🌐</span>
Website CMS
</h3>
<p className="text-sm text-sky-700 mt-2">
Die BreakPilot Website wird ueber ein visuelles CMS verwaltet.
Inhalte koennen direkt bearbeitet und in mehrere Sprachen uebersetzt werden.
Aenderungen werden nach dem Speichern sofort auf der Website sichtbar.
</p>
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
/**
* Admin Panel for Website Content
* Uebersetzungen - Website Content Editor
*
* Allows editing all website texts:
* - Hero Section
@@ -29,7 +29,7 @@ const SECTION_MAP: Record<string, { selector: string; scrollTo: string }> = {
other: { selector: '#trust', scrollTo: 'trust' },
}
export default function ContentPage() {
export default function UebersetzungenPage() {
const [content, setContent] = useState<WebsiteContent | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -66,7 +66,7 @@ export default function ContentPage() {
async function loadContent() {
try {
const res = await fetch('/api/development/content')
const res = await fetch('/api/website/content')
if (res.ok) {
const data = await res.json()
setContent(data)
@@ -87,7 +87,7 @@ export default function ContentPage() {
setMessage(null)
try {
const res = await fetch('/api/development/content', {
const res = await fetch('/api/website/content', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -208,7 +208,7 @@ export default function ContentPage() {
{/* Toolbar */}
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-lg font-semibold text-slate-900">Website Content</h1>
<h1 className="text-lg font-semibold text-slate-900">Uebersetzungen</h1>
{/* Preview Toggle */}
<button
onClick={() => setShowPreview(!showPreview)}