fix(admin-v2): Restore complete admin-v2 application
The admin-v2 application was incomplete in the repository. This commit restores all missing components: - Admin pages (76 pages): dashboard, ai, compliance, dsgvo, education, infrastructure, communication, development, onboarding, rbac - SDK pages (45 pages): tom, dsfa, vvt, loeschfristen, einwilligungen, vendor-compliance, tom-generator, dsr, and more - Developer portal (25 pages): API docs, SDK guides, frameworks - All components, lib files, hooks, and types - Updated package.json with all dependencies The issue was caused by incomplete initial repository state - the full admin-v2 codebase existed in backend/admin-v2 and docs-src/admin-v2 but was never fully synced to the main admin-v2 directory. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
643
admin-v2/app/(admin)/dsgvo/advisory-board/documentation/page.tsx
Normal file
643
admin-v2/app/(admin)/dsgvo/advisory-board/documentation/page.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
'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 < 20</td>
|
||||
<td className="py-2 px-3 text-slate-600">Automatisch</td>
|
||||
<td className="py-2 px-3 text-slate-600">-</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100 bg-yellow-50">
|
||||
<td className="py-2 px-3 font-medium text-yellow-700">E1</td>
|
||||
<td className="py-2 px-3 text-slate-600">WARN-Regeln, Risiko 20-40</td>
|
||||
<td className="py-2 px-3 text-slate-600">Team-Lead</td>
|
||||
<td className="py-2 px-3 text-slate-600">24h / 72h</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100 bg-orange-50">
|
||||
<td className="py-2 px-3 font-medium text-orange-700">E2</td>
|
||||
<td className="py-2 px-3 text-slate-600">Art. 9 Daten, DSFA empfohlen, Risiko 40-60</td>
|
||||
<td className="py-2 px-3 text-slate-600">DSB</td>
|
||||
<td className="py-2 px-3 text-slate-600">8h / 48h</td>
|
||||
</tr>
|
||||
<tr className="bg-red-50">
|
||||
<td className="py-2 px-3 font-medium text-red-700">E3</td>
|
||||
<td className="py-2 px-3 text-slate-600">BLOCK-Regeln, Art. 22, Risiko > 60</td>
|
||||
<td className="py-2 px-3 text-slate-600">DSB + Legal</td>
|
||||
<td className="py-2 px-3 text-slate-600">4h / 24h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderAuditorInfo = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">
|
||||
Dokumentation fuer externe Auditoren
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Diese Dokumentation erfuellt die Anforderungen nach Art. 30 DSGVO (Verzeichnis von
|
||||
Verarbeitungstaetigkeiten) und dient als Grundlage fuer Audits nach Art. 32 DSGVO.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">1. Zweck des Systems</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
UCCA ist ein Compliance-Pruefwerkzeug zur Bewertung geplanter KI-Anwendungsfaelle
|
||||
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit. 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>
|
||||
)
|
||||
}
|
||||
992
admin-v2/app/(admin)/dsgvo/advisory-board/legal-metadata.ts
Normal file
992
admin-v2/app/(admin)/dsgvo/advisory-board/legal-metadata.ts
Normal file
@@ -0,0 +1,992 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
1954
admin-v2/app/(admin)/dsgvo/advisory-board/page.tsx
Normal file
1954
admin-v2/app/(admin)/dsgvo/advisory-board/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
648
admin-v2/app/(admin)/dsgvo/consent/page.tsx
Normal file
648
admin-v2/app/(admin)/dsgvo/consent/page.tsx
Normal file
@@ -0,0 +1,648 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
737
admin-v2/app/(admin)/dsgvo/dsfa/page.tsx
Normal file
737
admin-v2/app/(admin)/dsgvo/dsfa/page.tsx
Normal file
@@ -0,0 +1,737 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
711
admin-v2/app/(admin)/dsgvo/dsr/page.tsx
Normal file
711
admin-v2/app/(admin)/dsgvo/dsr/page.tsx
Normal file
@@ -0,0 +1,711 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
498
admin-v2/app/(admin)/dsgvo/einwilligungen/page.tsx
Normal file
498
admin-v2/app/(admin)/dsgvo/einwilligungen/page.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
864
admin-v2/app/(admin)/dsgvo/escalations/page.tsx
Normal file
864
admin-v2/app/(admin)/dsgvo/escalations/page.tsx
Normal file
@@ -0,0 +1,864 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
542
admin-v2/app/(admin)/dsgvo/loeschfristen/page.tsx
Normal file
542
admin-v2/app/(admin)/dsgvo/loeschfristen/page.tsx
Normal file
@@ -0,0 +1,542 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
202
admin-v2/app/(admin)/dsgvo/page.tsx
Normal file
202
admin-v2/app/(admin)/dsgvo/page.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
602
admin-v2/app/(admin)/dsgvo/tom/page.tsx
Normal file
602
admin-v2/app/(admin)/dsgvo/tom/page.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
597
admin-v2/app/(admin)/dsgvo/vvt/page.tsx
Normal file
597
admin-v2/app/(admin)/dsgvo/vvt/page.tsx
Normal file
@@ -0,0 +1,597 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user