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:
BreakPilot Dev
2026-02-08 23:40:15 -08:00
parent f28244753f
commit 660295e218
385 changed files with 138126 additions and 3079 deletions

View File

@@ -0,0 +1,248 @@
'use client'
/**
* Datenpunktkatalog Seite
*
* Zeigt den vollstaendigen Katalog aller personenbezogenen Daten,
* die vom Unternehmen verarbeitet werden.
*/
import { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { DataPointCatalog } from '@/components/sdk/einwilligungen'
import {
EinwilligungenProvider,
useEinwilligungen,
} from '@/lib/sdk/einwilligungen/context'
import {
PREDEFINED_DATA_POINTS,
RETENTION_MATRIX,
} from '@/lib/sdk/einwilligungen/catalog/loader'
import {
DataPoint,
SupportedLanguage,
CATEGORY_METADATA,
RISK_LEVEL_STYLING,
LEGAL_BASIS_INFO,
} from '@/lib/sdk/einwilligungen/types'
import {
Plus,
Download,
Upload,
Filter,
BarChart3,
Shield,
FileText,
Cookie,
Clock,
ChevronRight,
} from 'lucide-react'
import Link from 'next/link'
// =============================================================================
// CATALOG CONTENT COMPONENT
// =============================================================================
function CatalogContent() {
const { state } = useSDK()
const {
allDataPoints,
selectedDataPointsData,
categoryStats,
riskStats,
legalBasisStats,
toggleDataPoint,
setActiveTab,
state: einwilligungenState,
} = useEinwilligungen()
const [language, setLanguage] = useState<SupportedLanguage>('de')
const [selectedIds, setSelectedIds] = useState<string[]>(
allDataPoints.map((dp) => dp.id)
)
// Stats
const totalDataPoints = allDataPoints.length
const customDataPoints = allDataPoints.filter((dp) => dp.isCustom).length
const highRiskCount = riskStats.HIGH || 0
const consentRequiredCount = allDataPoints.filter((dp) => dp.requiresExplicitConsent).length
const handleToggle = (id: string) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
)
}
const handleSelectAll = () => {
setSelectedIds(allDataPoints.map((dp) => dp.id))
}
const handleDeselectAll = () => {
setSelectedIds([])
}
const stepInfo = STEP_EXPLANATIONS['einwilligungen'] || {
title: 'Datenpunktkatalog',
description: 'Verwalten Sie alle personenbezogenen Daten, die Ihr Unternehmen verarbeitet.',
explanation: 'Der Datenpunktkatalog ist die Grundlage fuer Ihre Datenschutzerklaerung, den Cookie-Banner und die Loeschfristen.',
tips: [],
}
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="einwilligungen"
title="Datenpunktkatalog"
description="Verwalten Sie alle personenbezogenen Daten, die Ihr Unternehmen verarbeitet."
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<div className="flex items-center gap-2">
<button className="flex items-center gap-2 px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50">
<Upload className="w-4 h-4" />
Importieren
</button>
<button className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700">
<Download className="w-4 h-4" />
Exportieren
</button>
</div>
</StepHeader>
{/* Navigation Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Link
href="/sdk/einwilligungen/privacy-policy"
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-indigo-300 hover:shadow-md transition-all group"
>
<div className="flex items-center justify-between">
<div className="p-2 rounded-lg bg-indigo-100">
<FileText className="w-5 h-5 text-indigo-600" />
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-indigo-600 transition-colors" />
</div>
<div className="mt-3">
<div className="font-semibold text-slate-900">Datenschutzerklaerung</div>
<div className="text-sm text-slate-500">DSI aus Katalog generieren</div>
</div>
</Link>
<Link
href="/sdk/einwilligungen/cookie-banner"
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-indigo-300 hover:shadow-md transition-all group"
>
<div className="flex items-center justify-between">
<div className="p-2 rounded-lg bg-amber-100">
<Cookie className="w-5 h-5 text-amber-600" />
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-indigo-600 transition-colors" />
</div>
<div className="mt-3">
<div className="font-semibold text-slate-900">Cookie-Banner</div>
<div className="text-sm text-slate-500">Banner konfigurieren</div>
</div>
</Link>
<Link
href="/sdk/einwilligungen/retention"
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-indigo-300 hover:shadow-md transition-all group"
>
<div className="flex items-center justify-between">
<div className="p-2 rounded-lg bg-purple-100">
<Clock className="w-5 h-5 text-purple-600" />
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-indigo-600 transition-colors" />
</div>
<div className="mt-3">
<div className="font-semibold text-slate-900">Loeschfristen</div>
<div className="text-sm text-slate-500">Retention Matrix anzeigen</div>
</div>
</Link>
<Link
href="/sdk/einwilligungen"
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-indigo-300 hover:shadow-md transition-all group"
>
<div className="flex items-center justify-between">
<div className="p-2 rounded-lg bg-green-100">
<Shield className="w-5 h-5 text-green-600" />
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-indigo-600 transition-colors" />
</div>
<div className="mt-3">
<div className="font-semibold text-slate-900">Consent-Tracking</div>
<div className="text-sm text-slate-500">Einwilligungen verwalten</div>
</div>
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Datenpunkte gesamt</div>
<div className="text-2xl font-bold text-slate-900">{totalDataPoints}</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Ausgewaehlt</div>
<div className="text-2xl font-bold text-indigo-600">{selectedIds.length}</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Benutzerdefiniert</div>
<div className="text-2xl font-bold text-purple-600">{customDataPoints}</div>
</div>
<div className="bg-white rounded-xl border border-red-200 p-4">
<div className="text-sm text-red-600">Hohes Risiko</div>
<div className="text-2xl font-bold text-red-600">{highRiskCount}</div>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-4">
<div className="text-sm text-amber-600">Einwilligung erforderlich</div>
<div className="text-2xl font-bold text-amber-600">{consentRequiredCount}</div>
</div>
</div>
{/* Add Custom Data Point Button */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<select
value={language}
onChange={(e) => setLanguage(e.target.value as SupportedLanguage)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
<button className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700">
<Plus className="w-4 h-4" />
Datenpunkt hinzufuegen
</button>
</div>
{/* Catalog */}
<DataPointCatalog
dataPoints={allDataPoints}
selectedIds={selectedIds}
onToggle={handleToggle}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
language={language}
showFilters={true}
readOnly={false}
/>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function CatalogPage() {
return (
<EinwilligungenProvider>
<CatalogContent />
</EinwilligungenProvider>
)
}

View File

@@ -0,0 +1,763 @@
'use client'
/**
* Cookie Banner Configuration Page
*
* Konfiguriert den Cookie-Banner basierend auf dem Datenpunktkatalog.
*/
import { useState, useEffect, useMemo } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
EinwilligungenProvider,
useEinwilligungen,
} from '@/lib/sdk/einwilligungen/context'
import {
generateCookieBannerConfig,
generateEmbedCode,
DEFAULT_COOKIE_BANNER_TEXTS,
DEFAULT_COOKIE_BANNER_STYLING,
} from '@/lib/sdk/einwilligungen/generator/cookie-banner'
import {
CookieBannerConfig,
CookieBannerStyling,
CookieBannerTexts,
SupportedLanguage,
} from '@/lib/sdk/einwilligungen/types'
import {
Cookie,
Settings,
Palette,
Code,
Copy,
Check,
Eye,
ArrowLeft,
Monitor,
Smartphone,
Tablet,
ChevronDown,
ChevronRight,
ExternalLink,
} from 'lucide-react'
import Link from 'next/link'
// =============================================================================
// STYLING FORM
// =============================================================================
interface StylingFormProps {
styling: CookieBannerStyling
onChange: (styling: CookieBannerStyling) => void
}
function StylingForm({ styling, onChange }: StylingFormProps) {
const handleChange = (field: keyof CookieBannerStyling, value: string | number) => {
onChange({ ...styling, [field]: value })
}
return (
<div className="space-y-4">
{/* Position */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Position
</label>
<div className="grid grid-cols-3 gap-2">
{(['BOTTOM', 'TOP', 'CENTER'] as const).map((pos) => (
<button
key={pos}
onClick={() => handleChange('position', pos)}
className={`px-4 py-2 text-sm rounded-lg border transition-colors ${
styling.position === pos
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{pos === 'BOTTOM' ? 'Unten' : pos === 'TOP' ? 'Oben' : 'Zentriert'}
</button>
))}
</div>
</div>
{/* Theme */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Theme
</label>
<div className="grid grid-cols-2 gap-2">
{(['LIGHT', 'DARK'] as const).map((theme) => (
<button
key={theme}
onClick={() => handleChange('theme', theme)}
className={`px-4 py-2 text-sm rounded-lg border transition-colors ${
styling.theme === theme
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{theme === 'LIGHT' ? 'Hell' : 'Dunkel'}
</button>
))}
</div>
</div>
{/* Colors */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Primaerfarbe
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={styling.primaryColor}
onChange={(e) => handleChange('primaryColor', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={styling.primaryColor}
onChange={(e) => handleChange('primaryColor', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Sekundaerfarbe
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={styling.secondaryColor || '#f1f5f9'}
onChange={(e) => handleChange('secondaryColor', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={styling.secondaryColor || '#f1f5f9'}
onChange={(e) => handleChange('secondaryColor', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
</div>
</div>
{/* Border Radius & Max Width */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Eckenradius (px)
</label>
<input
type="number"
min={0}
max={32}
value={styling.borderRadius}
onChange={(e) => handleChange('borderRadius', parseInt(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">
Max. Breite (px)
</label>
<input
type="number"
min={320}
max={800}
value={styling.maxWidth}
onChange={(e) => handleChange('maxWidth', parseInt(e.target.value))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
</div>
</div>
)
}
// =============================================================================
// TEXTS FORM
// =============================================================================
interface TextsFormProps {
texts: CookieBannerTexts
language: SupportedLanguage
onChange: (texts: CookieBannerTexts) => void
}
function TextsForm({ texts, language, onChange }: TextsFormProps) {
const handleChange = (field: keyof CookieBannerTexts, value: string) => {
onChange({
...texts,
[field]: { ...texts[field], [language]: value },
})
}
const fields: { key: keyof CookieBannerTexts; label: string; multiline?: boolean }[] = [
{ key: 'title', label: 'Titel' },
{ key: 'description', label: 'Beschreibung', multiline: true },
{ key: 'acceptAll', label: 'Alle akzeptieren Button' },
{ key: 'rejectAll', label: 'Nur notwendige Button' },
{ key: 'customize', label: 'Einstellungen Button' },
{ key: 'save', label: 'Speichern Button' },
{ key: 'privacyPolicyLink', label: 'Datenschutz-Link Text' },
]
return (
<div className="space-y-4">
{fields.map(({ key, label, multiline }) => (
<div key={key}>
<label className="block text-sm font-medium text-slate-700 mb-1">
{label}
</label>
{multiline ? (
<textarea
value={texts[key][language]}
onChange={(e) => handleChange(key, e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
) : (
<input
type="text"
value={texts[key][language]}
onChange={(e) => handleChange(key, e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
)}
</div>
))}
</div>
)
}
// =============================================================================
// BANNER PREVIEW
// =============================================================================
interface BannerPreviewProps {
config: CookieBannerConfig | null
language: SupportedLanguage
device: 'desktop' | 'tablet' | 'mobile'
}
function BannerPreview({ config, language, device }: BannerPreviewProps) {
const [showDetails, setShowDetails] = useState(false)
if (!config) {
return (
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-xl">
<p className="text-slate-400">Konfiguration wird geladen...</p>
</div>
)
}
const isDark = config.styling.theme === 'DARK'
const bgColor = isDark ? '#1e293b' : config.styling.backgroundColor || '#ffffff'
const textColor = isDark ? '#f1f5f9' : config.styling.textColor || '#1e293b'
const deviceWidths = {
desktop: '100%',
tablet: '768px',
mobile: '375px',
}
return (
<div
className="border rounded-xl overflow-hidden"
style={{
maxWidth: deviceWidths[device],
margin: '0 auto',
}}
>
{/* Simulated Browser */}
<div className="bg-slate-100 h-8 flex items-center px-3 gap-2">
<div className="w-3 h-3 rounded-full bg-red-400" />
<div className="w-3 h-3 rounded-full bg-yellow-400" />
<div className="w-3 h-3 rounded-full bg-green-400" />
<div className="flex-1 bg-white rounded h-5 mx-4" />
</div>
{/* Page Content */}
<div className="relative bg-slate-50 min-h-[400px]">
{/* Placeholder Content */}
<div className="p-6 space-y-4">
<div className="h-4 bg-slate-200 rounded w-3/4" />
<div className="h-4 bg-slate-200 rounded w-1/2" />
<div className="h-32 bg-slate-200 rounded" />
<div className="h-4 bg-slate-200 rounded w-2/3" />
<div className="h-4 bg-slate-200 rounded w-1/2" />
</div>
{/* Overlay */}
<div className="absolute inset-0 bg-black/40" />
{/* Cookie Banner */}
<div
className={`absolute ${
config.styling.position === 'TOP'
? 'top-0 left-0 right-0'
: config.styling.position === 'CENTER'
? 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'
: 'bottom-0 left-0 right-0'
}`}
style={{
maxWidth: config.styling.maxWidth,
margin: config.styling.position === 'CENTER' ? '0' : '16px auto',
}}
>
<div
className="shadow-xl"
style={{
background: bgColor,
color: textColor,
borderRadius: config.styling.borderRadius,
padding: '20px',
}}
>
<h3 className="font-semibold text-lg mb-2">
{config.texts.title[language]}
</h3>
<p className="text-sm opacity-80 mb-4">
{config.texts.description[language]}
</p>
<div className="flex flex-wrap gap-2 mb-3">
<button
style={{ background: config.styling.secondaryColor }}
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
>
{config.texts.rejectAll[language]}
</button>
<button
onClick={() => setShowDetails(!showDetails)}
style={{ background: config.styling.secondaryColor }}
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
>
{config.texts.customize[language]}
</button>
<button
style={{ background: config.styling.primaryColor, color: 'white' }}
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
>
{config.texts.acceptAll[language]}
</button>
</div>
{showDetails && (
<div className="border-t pt-3 mt-3 space-y-2" style={{ borderColor: 'rgba(128,128,128,0.2)' }}>
{config.categories.map((cat) => (
<div key={cat.id} className="flex items-center justify-between py-2">
<div>
<div className="font-medium text-sm">{cat.name[language]}</div>
<div className="text-xs opacity-60">{cat.description[language]}</div>
</div>
<div
className={`w-10 h-6 rounded-full relative ${
cat.isRequired || cat.defaultEnabled
? ''
: 'opacity-50'
}`}
style={{
background: cat.isRequired || cat.defaultEnabled
? config.styling.primaryColor
: 'rgba(128,128,128,0.3)',
}}
>
<div
className="absolute top-1 w-4 h-4 bg-white rounded-full transition-all"
style={{
left: cat.isRequired || cat.defaultEnabled ? '20px' : '4px',
}}
/>
</div>
</div>
))}
<button
style={{ background: config.styling.primaryColor, color: 'white' }}
className="w-full px-4 py-2 rounded-lg text-sm font-medium mt-2"
>
{config.texts.save[language]}
</button>
</div>
)}
<a
href="#"
className="block text-xs mt-3"
style={{ color: config.styling.primaryColor }}
>
{config.texts.privacyPolicyLink[language]}
</a>
</div>
</div>
</div>
</div>
)
}
// =============================================================================
// EMBED CODE VIEWER
// =============================================================================
interface EmbedCodeViewerProps {
config: CookieBannerConfig | null
}
function EmbedCodeViewer({ config }: EmbedCodeViewerProps) {
const [activeTab, setActiveTab] = useState<'script' | 'html' | 'css' | 'js'>('script')
const [copied, setCopied] = useState(false)
const embedCode = useMemo(() => {
if (!config) return null
return generateEmbedCode(config, '/datenschutz')
}, [config])
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (!embedCode) {
return (
<div className="flex items-center justify-center h-48 bg-slate-100 rounded-xl">
<p className="text-slate-400">Embed-Code wird generiert...</p>
</div>
)
}
const tabs = [
{ id: 'script', label: 'Script-Tag', content: embedCode.scriptTag },
{ id: 'html', label: 'HTML', content: embedCode.html },
{ id: 'css', label: 'CSS', content: embedCode.css },
{ id: 'js', label: 'JavaScript', content: embedCode.js },
] as const
const currentContent = tabs.find((t) => t.id === activeTab)?.content || ''
return (
<div className="border border-slate-200 rounded-xl overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-slate-200 bg-slate-50">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-white text-indigo-600 border-b-2 border-indigo-600 -mb-px'
: 'text-slate-600 hover:text-slate-900'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Code */}
<div className="relative">
<pre className="p-4 bg-slate-900 text-slate-100 text-sm font-mono overflow-x-auto max-h-[400px]">
{currentContent}
</pre>
<button
onClick={() => copyToClipboard(currentContent)}
className="absolute top-3 right-3 flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-green-400" />
Kopiert
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
Kopieren
</>
)}
</button>
</div>
{/* Integration Instructions */}
{activeTab === 'script' && (
<div className="p-4 bg-amber-50 border-t border-amber-200">
<p className="text-sm text-amber-800">
<strong>Integration:</strong> Fuegen Sie den Script-Tag in den{' '}
<code className="bg-amber-100 px-1 rounded">&lt;head&gt;</code> oder vor dem
schliessenden{' '}
<code className="bg-amber-100 px-1 rounded">&lt;/body&gt;</code>-Tag ein.
</p>
</div>
)}
</div>
)
}
// =============================================================================
// CATEGORY LIST
// =============================================================================
interface CategoryListProps {
config: CookieBannerConfig | null
language: SupportedLanguage
}
function CategoryList({ config, language }: CategoryListProps) {
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
if (!config) return null
const toggleCategory = (id: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
return (
<div className="space-y-2">
{config.categories.map((cat) => {
const isExpanded = expandedCategories.has(cat.id)
return (
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
<button
onClick={() => toggleCategory(cat.id)}
className="w-full flex items-center justify-between p-4 hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-3">
<div
className={`w-3 h-3 rounded-full ${
cat.isRequired ? 'bg-green-500' : 'bg-amber-500'
}`}
/>
<div className="text-left">
<div className="font-medium text-slate-900">{cat.name[language]}</div>
<div className="text-sm text-slate-500">
{cat.cookies.length} Cookie(s) | {cat.dataPointIds.length} Datenpunkt(e)
</div>
</div>
</div>
<div className="flex items-center gap-2">
{cat.isRequired && (
<span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">
Erforderlich
</span>
)}
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-slate-400" />
) : (
<ChevronRight className="w-5 h-5 text-slate-400" />
)}
</div>
</button>
{isExpanded && (
<div className="px-4 pb-4 border-t border-slate-100 bg-slate-50">
<p className="text-sm text-slate-600 py-3">{cat.description[language]}</p>
{cat.cookies.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-semibold text-slate-500 uppercase">Cookies</h4>
<div className="space-y-1">
{cat.cookies.map((cookie, idx) => (
<div
key={idx}
className="flex items-center justify-between p-2 bg-white rounded border border-slate-200"
>
<div>
<span className="font-mono text-sm text-slate-700">{cookie.name}</span>
<span className="text-xs text-slate-400 ml-2">({cookie.provider})</span>
</div>
<span className="text-xs text-slate-500">{cookie.expiry}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
})}
</div>
)
}
// =============================================================================
// MAIN CONTENT
// =============================================================================
function CookieBannerContent() {
const { state } = useSDK()
const { allDataPoints } = useEinwilligungen()
const [styling, setStyling] = useState<CookieBannerStyling>(DEFAULT_COOKIE_BANNER_STYLING)
const [texts, setTexts] = useState<CookieBannerTexts>(DEFAULT_COOKIE_BANNER_TEXTS)
const [language, setLanguage] = useState<SupportedLanguage>('de')
const [activeTab, setActiveTab] = useState<'styling' | 'texts' | 'embed' | 'categories'>('styling')
const [device, setDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop')
const config = useMemo(() => {
return generateCookieBannerConfig(
state.tenantId || 'demo',
allDataPoints,
texts,
styling
)
}, [state.tenantId, allDataPoints, texts, styling])
const cookieDataPoints = useMemo(
() => allDataPoints.filter((dp) => dp.cookieCategory !== null),
[allDataPoints]
)
return (
<div className="space-y-6">
{/* Back Link */}
<Link
href="/sdk/einwilligungen/catalog"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
>
<ArrowLeft className="w-4 h-4" />
Zurueck zum Katalog
</Link>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Cookie-Banner Konfiguration</h1>
<p className="text-slate-600 mt-1">
Konfigurieren Sie Ihren DSGVO-konformen Cookie-Banner.
</p>
</div>
<div className="flex items-center gap-2">
<select
value={language}
onChange={(e) => setLanguage(e.target.value as SupportedLanguage)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Kategorien</div>
<div className="text-2xl font-bold text-slate-900">{config?.categories.length || 0}</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Cookie-Datenpunkte</div>
<div className="text-2xl font-bold text-indigo-600">{cookieDataPoints.length}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-4">
<div className="text-sm text-green-600">Erforderlich</div>
<div className="text-2xl font-bold text-green-600">
{config?.categories.filter((c) => c.isRequired).length || 0}
</div>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-4">
<div className="text-sm text-amber-600">Optional</div>
<div className="text-2xl font-bold text-amber-600">
{config?.categories.filter((c) => !c.isRequired).length || 0}
</div>
</div>
</div>
{/* Two Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left: Configuration */}
<div className="space-y-4">
{/* Tabs */}
<div className="flex border-b border-slate-200">
{[
{ id: 'styling', label: 'Design', icon: Palette },
{ id: 'texts', label: 'Texte', icon: Settings },
{ id: 'categories', label: 'Kategorien', icon: Cookie },
{ id: 'embed', label: 'Embed-Code', icon: Code },
].map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setActiveTab(id as typeof activeTab)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === id
? 'text-indigo-600 border-indigo-600'
: 'text-slate-600 border-transparent hover:text-slate-900'
}`}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
{/* Tab Content */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
{activeTab === 'styling' && (
<StylingForm styling={styling} onChange={setStyling} />
)}
{activeTab === 'texts' && (
<TextsForm texts={texts} language={language} onChange={setTexts} />
)}
{activeTab === 'categories' && (
<CategoryList config={config} language={language} />
)}
{activeTab === 'embed' && <EmbedCodeViewer config={config} />}
</div>
</div>
{/* Right: Preview */}
<div className="space-y-4">
{/* Device Selector */}
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900">Vorschau</h3>
<div className="flex items-center border border-slate-200 rounded-lg overflow-hidden">
{[
{ id: 'desktop', icon: Monitor },
{ id: 'tablet', icon: Tablet },
{ id: 'mobile', icon: Smartphone },
].map(({ id, icon: Icon }) => (
<button
key={id}
onClick={() => setDevice(id as typeof device)}
className={`p-2 ${
device === id
? 'bg-indigo-50 text-indigo-600'
: 'text-slate-400 hover:text-slate-600'
}`}
>
<Icon className="w-5 h-5" />
</button>
))}
</div>
</div>
{/* Preview */}
<BannerPreview config={config} language={language} device={device} />
</div>
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function CookieBannerPage() {
return (
<EinwilligungenProvider>
<CookieBannerContent />
</EinwilligungenProvider>
)
}

View File

@@ -0,0 +1,931 @@
'use client'
import React, { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
Database,
FileText,
Cookie,
Clock,
LayoutGrid,
X,
History,
Shield,
AlertTriangle,
CheckCircle,
XCircle,
Monitor,
Globe,
Calendar,
User,
FileCheck,
} from 'lucide-react'
// =============================================================================
// NAVIGATION TABS
// =============================================================================
const EINWILLIGUNGEN_TABS = [
{
id: 'overview',
label: 'Übersicht',
href: '/sdk/einwilligungen',
icon: LayoutGrid,
description: 'Consent-Tracking Dashboard',
},
{
id: 'catalog',
label: 'Datenpunktkatalog',
href: '/sdk/einwilligungen/catalog',
icon: Database,
description: '18 Kategorien, 128 Datenpunkte',
},
{
id: 'privacy-policy',
label: 'DSI Generator',
href: '/sdk/einwilligungen/privacy-policy',
icon: FileText,
description: 'Datenschutzinformation erstellen',
},
{
id: 'cookie-banner',
label: 'Cookie-Banner',
href: '/sdk/einwilligungen/cookie-banner',
icon: Cookie,
description: 'Cookie-Consent konfigurieren',
},
{
id: 'retention',
label: 'Löschmatrix',
href: '/sdk/einwilligungen/retention',
icon: Clock,
description: 'Aufbewahrungsfristen verwalten',
},
]
function EinwilligungenNavTabs() {
const pathname = usePathname()
return (
<div className="bg-white rounded-xl border border-gray-200 p-2 mb-6">
<div className="flex flex-wrap gap-2">
{EINWILLIGUNGEN_TABS.map((tab) => {
const Icon = tab.icon
const isActive = pathname === tab.href
return (
<Link
key={tab.id}
href={tab.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all ${
isActive
? 'bg-purple-100 text-purple-900 shadow-sm'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
<Icon className={`w-5 h-5 ${isActive ? 'text-purple-600' : 'text-gray-400'}`} />
<div>
<div className={`font-medium text-sm ${isActive ? 'text-purple-900' : ''}`}>
{tab.label}
</div>
<div className="text-xs text-gray-500">{tab.description}</div>
</div>
</Link>
)
})}
</div>
</div>
)
}
// =============================================================================
// TYPES
// =============================================================================
type ConsentType = 'marketing' | 'analytics' | 'newsletter' | 'terms' | 'privacy' | 'cookies'
type ConsentStatus = 'granted' | 'withdrawn' | 'pending'
type HistoryAction = 'granted' | 'withdrawn' | 'version_update' | 'renewed'
interface ConsentHistoryEntry {
id: string
action: HistoryAction
timestamp: Date
version: string
documentTitle?: string
ipAddress: string
userAgent: string
source: string
notes?: string
}
interface ConsentRecord {
id: string
odentifier: string
email: string
firstName?: string
lastName?: string
consentType: ConsentType
status: ConsentStatus
currentVersion: string
grantedAt: Date | null
withdrawnAt: Date | null
source: string
ipAddress: string
userAgent: string
history: ConsentHistoryEntry[]
}
// =============================================================================
// MOCK DATA WITH HISTORY
// =============================================================================
const mockRecords: ConsentRecord[] = [
{
id: 'c-1',
odentifier: 'usr-001',
email: 'max.mustermann@example.de',
firstName: 'Max',
lastName: 'Mustermann',
consentType: 'terms',
status: 'granted',
currentVersion: '2.1',
grantedAt: new Date('2024-01-15T10:23:45'),
withdrawnAt: null,
source: 'Website-Formular',
ipAddress: '192.168.1.45',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
history: [
{
id: 'h-1-1',
action: 'granted',
timestamp: new Date('2023-06-01T14:30:00'),
version: '1.0',
documentTitle: 'AGB Version 1.0',
ipAddress: '192.168.1.42',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0)',
source: 'App-Registrierung',
},
{
id: 'h-1-2',
action: 'version_update',
timestamp: new Date('2023-09-15T09:15:00'),
version: '1.5',
documentTitle: 'AGB Version 1.5 - DSGVO Update',
ipAddress: '192.168.1.43',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
source: 'E-Mail Bestätigung',
notes: 'Nutzer hat neuen AGB nach DSGVO-Anpassung zugestimmt',
},
{
id: 'h-1-3',
action: 'version_update',
timestamp: new Date('2024-01-15T10:23:45'),
version: '2.1',
documentTitle: 'AGB Version 2.1 - KI-Klauseln',
ipAddress: '192.168.1.45',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
source: 'Website-Formular',
notes: 'Zustimmung zu neuen KI-Nutzungsbedingungen',
},
],
},
{
id: 'c-2',
odentifier: 'usr-001',
email: 'max.mustermann@example.de',
firstName: 'Max',
lastName: 'Mustermann',
consentType: 'marketing',
status: 'granted',
currentVersion: '1.3',
grantedAt: new Date('2024-01-15T10:23:45'),
withdrawnAt: null,
source: 'Website-Formular',
ipAddress: '192.168.1.45',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
history: [
{
id: 'h-2-1',
action: 'granted',
timestamp: new Date('2024-01-15T10:23:45'),
version: '1.3',
ipAddress: '192.168.1.45',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
source: 'Website-Formular',
},
],
},
{
id: 'c-3',
odentifier: 'usr-002',
email: 'anna.schmidt@example.de',
firstName: 'Anna',
lastName: 'Schmidt',
consentType: 'newsletter',
status: 'withdrawn',
currentVersion: '1.2',
grantedAt: new Date('2023-11-20T16:45:00'),
withdrawnAt: new Date('2024-01-10T08:30:00'),
source: 'App',
ipAddress: '10.0.0.88',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
history: [
{
id: 'h-3-1',
action: 'granted',
timestamp: new Date('2023-11-20T16:45:00'),
version: '1.2',
ipAddress: '10.0.0.88',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
source: 'App',
},
{
id: 'h-3-2',
action: 'withdrawn',
timestamp: new Date('2024-01-10T08:30:00'),
version: '1.2',
ipAddress: '10.0.0.92',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2)',
source: 'Profil-Einstellungen',
notes: 'Nutzer hat Newsletter-Abo über Profil deaktiviert',
},
],
},
{
id: 'c-4',
odentifier: 'usr-003',
email: 'peter.meier@example.de',
firstName: 'Peter',
lastName: 'Meier',
consentType: 'privacy',
status: 'granted',
currentVersion: '3.0',
grantedAt: new Date('2024-01-20T11:00:00'),
withdrawnAt: null,
source: 'Cookie-Banner',
ipAddress: '172.16.0.55',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
history: [
{
id: 'h-4-1',
action: 'granted',
timestamp: new Date('2023-03-10T09:00:00'),
version: '2.0',
documentTitle: 'Datenschutzerklärung v2.0',
ipAddress: '172.16.0.50',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
source: 'Registrierung',
},
{
id: 'h-4-2',
action: 'version_update',
timestamp: new Date('2023-08-01T14:00:00'),
version: '2.5',
documentTitle: 'Datenschutzerklärung v2.5 - Cookie-Update',
ipAddress: '172.16.0.52',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
source: 'Cookie-Banner',
notes: 'Zustimmung nach Cookie-Richtlinien-Update',
},
{
id: 'h-4-3',
action: 'version_update',
timestamp: new Date('2024-01-20T11:00:00'),
version: '3.0',
documentTitle: 'Datenschutzerklärung v3.0 - AI Act Compliance',
ipAddress: '172.16.0.55',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
source: 'Cookie-Banner',
notes: 'Neue DSI mit AI Act Transparenzhinweisen',
},
],
},
{
id: 'c-5',
odentifier: 'usr-004',
email: 'lisa.weber@example.de',
firstName: 'Lisa',
lastName: 'Weber',
consentType: 'analytics',
status: 'granted',
currentVersion: '1.0',
grantedAt: new Date('2024-01-18T13:22:00'),
withdrawnAt: null,
source: 'Cookie-Banner',
ipAddress: '192.168.2.100',
userAgent: 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36',
history: [
{
id: 'h-5-1',
action: 'granted',
timestamp: new Date('2024-01-18T13:22:00'),
version: '1.0',
ipAddress: '192.168.2.100',
userAgent: 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36',
source: 'Cookie-Banner',
},
],
},
{
id: 'c-6',
odentifier: 'usr-005',
email: 'thomas.klein@example.de',
firstName: 'Thomas',
lastName: 'Klein',
consentType: 'cookies',
status: 'granted',
currentVersion: '1.8',
grantedAt: new Date('2024-01-22T09:15:00'),
withdrawnAt: null,
source: 'Cookie-Banner',
ipAddress: '10.1.0.200',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Safari/605.1.15',
history: [
{
id: 'h-6-1',
action: 'granted',
timestamp: new Date('2023-05-10T10:00:00'),
version: '1.0',
ipAddress: '10.1.0.150',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_0)',
source: 'Cookie-Banner',
},
{
id: 'h-6-2',
action: 'withdrawn',
timestamp: new Date('2023-08-20T15:30:00'),
version: '1.0',
ipAddress: '10.1.0.160',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_0)',
source: 'Cookie-Einstellungen',
notes: 'Nutzer hat alle Cookies abgelehnt',
},
{
id: 'h-6-3',
action: 'renewed',
timestamp: new Date('2024-01-22T09:15:00'),
version: '1.8',
ipAddress: '10.1.0.200',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Safari/605.1.15',
source: 'Cookie-Banner',
notes: 'Nutzer hat Cookies nach Banner-Redesign erneut akzeptiert',
},
],
},
]
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
const typeLabels: Record<ConsentType, string> = {
marketing: 'Marketing',
analytics: 'Analyse',
newsletter: 'Newsletter',
terms: 'AGB',
privacy: 'Datenschutz',
cookies: 'Cookies',
}
const typeColors: Record<ConsentType, string> = {
marketing: 'bg-purple-100 text-purple-700',
analytics: 'bg-blue-100 text-blue-700',
newsletter: 'bg-green-100 text-green-700',
terms: 'bg-yellow-100 text-yellow-700',
privacy: 'bg-orange-100 text-orange-700',
cookies: 'bg-pink-100 text-pink-700',
}
const statusColors: Record<ConsentStatus, string> = {
granted: 'bg-green-100 text-green-700',
withdrawn: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
}
const statusLabels: Record<ConsentStatus, string> = {
granted: 'Erteilt',
withdrawn: 'Widerrufen',
pending: 'Ausstehend',
}
const actionLabels: Record<HistoryAction, string> = {
granted: 'Einwilligung erteilt',
withdrawn: 'Einwilligung widerrufen',
version_update: 'Neue Version akzeptiert',
renewed: 'Einwilligung erneuert',
}
const actionIcons: Record<HistoryAction, React.ReactNode> = {
granted: <CheckCircle className="w-5 h-5 text-green-500" />,
withdrawn: <XCircle className="w-5 h-5 text-red-500" />,
version_update: <FileCheck className="w-5 h-5 text-blue-500" />,
renewed: <Shield className="w-5 h-5 text-purple-500" />,
}
function formatDateTime(date: Date): string {
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function formatDate(date: Date | null): string {
if (!date) return '-'
return date.toLocaleDateString('de-DE')
}
// =============================================================================
// DETAIL MODAL COMPONENT
// =============================================================================
interface ConsentDetailModalProps {
record: ConsentRecord
onClose: () => void
onRevoke: (recordId: string) => void
}
function ConsentDetailModal({ record, onClose, onRevoke }: ConsentDetailModalProps) {
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false)
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-purple-50 to-indigo-50">
<div>
<h2 className="text-xl font-bold text-gray-900">Consent-Details</h2>
<p className="text-sm text-gray-500">{record.email}</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/50 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* User Info */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center gap-3 mb-3">
<User className="w-5 h-5 text-gray-400" />
<span className="font-medium text-gray-700">Benutzerinformationen</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Name:</span>
<span className="font-medium">{record.firstName} {record.lastName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">E-Mail:</span>
<span className="font-medium">{record.email}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">User-ID:</span>
<span className="font-mono text-xs bg-gray-200 px-2 py-0.5 rounded">{record.odentifier}</span>
</div>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center gap-3 mb-3">
<Shield className="w-5 h-5 text-gray-400" />
<span className="font-medium text-gray-700">Consent-Status</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between items-center">
<span className="text-gray-500">Typ:</span>
<span className={`px-2 py-1 text-xs rounded-full ${typeColors[record.consentType]}`}>
{typeLabels[record.consentType]}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-500">Status:</span>
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[record.status]}`}>
{statusLabels[record.status]}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Version:</span>
<span className="font-mono font-medium">v{record.currentVersion}</span>
</div>
</div>
</div>
</div>
{/* Technical Details */}
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center gap-3 mb-3">
<Monitor className="w-5 h-5 text-gray-400" />
<span className="font-medium text-gray-700">Technische Details (letzter Consent)</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-gray-500 mb-1">IP-Adresse</div>
<div className="font-mono text-xs bg-white px-3 py-2 rounded border">{record.ipAddress}</div>
</div>
<div>
<div className="text-gray-500 mb-1">Quelle</div>
<div className="bg-white px-3 py-2 rounded border">{record.source}</div>
</div>
<div className="col-span-2">
<div className="text-gray-500 mb-1">User-Agent</div>
<div className="font-mono text-xs bg-white px-3 py-2 rounded border break-all">{record.userAgent}</div>
</div>
</div>
</div>
{/* History Timeline */}
<div>
<div className="flex items-center gap-3 mb-4">
<History className="w-5 h-5 text-purple-600" />
<span className="font-semibold text-gray-900">Consent-Historie</span>
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
{record.history.length} Einträge
</span>
</div>
<div className="relative">
{/* Timeline line */}
<div className="absolute left-[22px] top-0 bottom-0 w-0.5 bg-gray-200" />
<div className="space-y-4">
{record.history.map((entry, index) => (
<div key={entry.id} className="relative flex gap-4">
{/* Icon */}
<div className="relative z-10 bg-white p-1 rounded-full">
{actionIcons[entry.action]}
</div>
{/* Content */}
<div className="flex-1 bg-white rounded-xl border border-gray-200 p-4 shadow-sm">
<div className="flex items-start justify-between mb-2">
<div>
<div className="font-medium text-gray-900">{actionLabels[entry.action]}</div>
{entry.documentTitle && (
<div className="text-sm text-purple-600 font-medium">{entry.documentTitle}</div>
)}
</div>
<div className="text-right">
<div className="text-xs font-mono bg-gray-100 px-2 py-1 rounded">v{entry.version}</div>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 mb-2">
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDateTime(entry.timestamp)}
</div>
<div className="flex items-center gap-1">
<Globe className="w-3 h-3" />
{entry.ipAddress}
</div>
</div>
<div className="text-xs text-gray-500">
<span className="font-medium">Quelle:</span> {entry.source}
</div>
{entry.notes && (
<div className="mt-2 text-sm text-gray-600 bg-gray-50 rounded-lg px-3 py-2 border-l-2 border-purple-300">
{entry.notes}
</div>
)}
{/* Expandable User-Agent */}
<details className="mt-2">
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-600">
User-Agent anzeigen
</summary>
<div className="mt-1 font-mono text-xs text-gray-500 bg-gray-50 p-2 rounded break-all">
{entry.userAgent}
</div>
</details>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Footer Actions */}
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-between">
<div className="text-xs text-gray-500">
Consent-ID: <span className="font-mono">{record.id}</span>
</div>
<div className="flex items-center gap-3">
{record.status === 'granted' && !showRevokeConfirm && (
<button
onClick={() => setShowRevokeConfirm(true)}
className="flex items-center gap-2 px-4 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<AlertTriangle className="w-4 h-4" />
Widerrufen
</button>
)}
{showRevokeConfirm && (
<div className="flex items-center gap-2">
<span className="text-sm text-red-600">Wirklich widerrufen?</span>
<button
onClick={() => {
onRevoke(record.id)
onClose()
}}
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700"
>
Ja, widerrufen
</button>
<button
onClick={() => setShowRevokeConfirm(false)}
className="px-3 py-1.5 bg-gray-200 text-gray-700 text-sm rounded-lg hover:bg-gray-300"
>
Abbrechen
</button>
</div>
)}
<button
onClick={onClose}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Schließen
</button>
</div>
</div>
</div>
</div>
)
}
// =============================================================================
// TABLE ROW COMPONENT
// =============================================================================
interface ConsentRecordRowProps {
record: ConsentRecord
onShowDetails: (record: ConsentRecord) => void
}
function ConsentRecordRow({ record, onShowDetails }: ConsentRecordRowProps) {
return (
<tr className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="text-sm font-medium text-gray-900">{record.email}</div>
<div className="text-xs text-gray-500">{record.odentifier}</div>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${typeColors[record.consentType]}`}>
{typeLabels[record.consentType]}
</span>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[record.status]}`}>
{statusLabels[record.status]}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDate(record.grantedAt)}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDate(record.withdrawnAt)}
</td>
<td className="px-6 py-4">
<span className="font-mono text-xs bg-gray-100 px-2 py-0.5 rounded">v{record.currentVersion}</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
<div className="flex items-center gap-1">
<History className="w-3 h-3" />
{record.history.length}
</div>
</td>
<td className="px-6 py-4">
<button
onClick={() => onShowDetails(record)}
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
>
Details
</button>
</td>
</tr>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function EinwilligungenPage() {
const { state } = useSDK()
const [records, setRecords] = useState<ConsentRecord[]>(mockRecords)
const [filter, setFilter] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState('')
const [selectedRecord, setSelectedRecord] = useState<ConsentRecord | null>(null)
const filteredRecords = records.filter(record => {
const matchesFilter = filter === 'all' || record.consentType === filter || record.status === filter
const matchesSearch = searchQuery === '' ||
record.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.odentifier.toLowerCase().includes(searchQuery.toLowerCase())
return matchesFilter && matchesSearch
})
const grantedCount = records.filter(r => r.status === 'granted').length
const withdrawnCount = records.filter(r => r.status === 'withdrawn').length
const versionUpdates = records.reduce((acc, r) => acc + r.history.filter(h => h.action === 'version_update').length, 0)
const handleRevoke = (recordId: string) => {
setRecords(prev => prev.map(r => {
if (r.id === recordId) {
const now = new Date()
return {
...r,
status: 'withdrawn' as ConsentStatus,
withdrawnAt: now,
history: [
...r.history,
{
id: `h-${recordId}-${r.history.length + 1}`,
action: 'withdrawn' as HistoryAction,
timestamp: now,
version: r.currentVersion,
ipAddress: 'Admin-Portal',
userAgent: 'Admin Action',
source: 'Manueller Widerruf durch Admin',
notes: 'Widerruf über Admin-Portal durchgeführt',
},
],
}
}
return r
}))
}
const stepInfo = STEP_EXPLANATIONS['einwilligungen']
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="einwilligungen"
title={stepInfo.title}
description={stepInfo.description}
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export
</button>
</StepHeader>
{/* Navigation Tabs */}
<EinwilligungenNavTabs />
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Gesamt</div>
<div className="text-3xl font-bold text-gray-900">{records.length}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Aktive Einwilligungen</div>
<div className="text-3xl font-bold text-green-600">{grantedCount}</div>
</div>
<div className="bg-white rounded-xl border border-red-200 p-6">
<div className="text-sm text-red-600">Widerrufen</div>
<div className="text-3xl font-bold text-red-600">{withdrawnCount}</div>
</div>
<div className="bg-white rounded-xl border border-blue-200 p-6">
<div className="text-sm text-blue-600">Versions-Updates</div>
<div className="text-3xl font-bold text-blue-600">{versionUpdates}</div>
</div>
</div>
{/* Info Banner */}
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl border border-purple-200 p-4 flex items-start gap-3">
<History className="w-5 h-5 text-purple-600 mt-0.5" />
<div>
<div className="font-medium text-purple-900">Consent-Historie aktiviert</div>
<div className="text-sm text-purple-700">
Alle Änderungen an Einwilligungen werden protokolliert, inkl. Zustimmungen zu neuen Versionen von AGB, DSI und anderen Dokumenten.
Klicken Sie auf "Details" um die vollständige Historie eines Nutzers einzusehen.
</div>
</div>
</div>
{/* Search and Filter */}
<div className="flex items-center gap-4">
<div className="flex-1 relative">
<svg className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="E-Mail oder User-ID suchen..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
{['all', 'granted', 'withdrawn', 'terms', 'privacy', 'cookies', 'marketing', 'analytics'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filter === f
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f === 'all' ? 'Alle' :
f === 'granted' ? 'Erteilt' :
f === 'withdrawn' ? 'Widerrufen' :
f === 'terms' ? 'AGB' :
f === 'privacy' ? 'DSI' :
f === 'cookies' ? 'Cookies' :
f === 'marketing' ? 'Marketing' : 'Analyse'}
</button>
))}
</div>
</div>
{/* Records Table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nutzer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Erteilt am</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Widerrufen am</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Version</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Historie</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredRecords.map(record => (
<ConsentRecordRow
key={record.id}
record={record}
onShowDetails={setSelectedRecord}
/>
))}
</tbody>
</table>
</div>
{filteredRecords.length === 0 && (
<div className="p-12 text-center">
<h3 className="text-lg font-semibold text-gray-900">Keine Einträge gefunden</h3>
<p className="mt-2 text-gray-500">Passen Sie die Suche oder den Filter an.</p>
</div>
)}
</div>
{/* Pagination placeholder */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
Zeige {filteredRecords.length} von {records.length} Einträgen
</p>
<div className="flex items-center gap-2">
<button className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">
Zurück
</button>
<button className="px-3 py-1 text-sm text-white bg-purple-600 rounded-lg">1</button>
<button className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">2</button>
<button className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">3</button>
<button className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">
Weiter
</button>
</div>
</div>
{/* Detail Modal */}
{selectedRecord && (
<ConsentDetailModal
record={selectedRecord}
onClose={() => setSelectedRecord(null)}
onRevoke={handleRevoke}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,414 @@
'use client'
/**
* Privacy Policy Generator Seite
*
* Generiert Datenschutzerklaerungen aus dem Datenpunktkatalog.
*/
import { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { PrivacyPolicyPreview } from '@/components/sdk/einwilligungen'
import {
EinwilligungenProvider,
useEinwilligungen,
} from '@/lib/sdk/einwilligungen/context'
import {
PREDEFINED_DATA_POINTS,
} from '@/lib/sdk/einwilligungen/catalog/loader'
import {
generatePrivacyPolicy,
} from '@/lib/sdk/einwilligungen/generator/privacy-policy'
import {
CompanyInfo,
SupportedLanguage,
ExportFormat,
GeneratedPrivacyPolicy,
} from '@/lib/sdk/einwilligungen/types'
import {
Building2,
Mail,
Phone,
Globe,
User,
Save,
ArrowLeft,
} from 'lucide-react'
import Link from 'next/link'
// =============================================================================
// COMPANY INFO FORM
// =============================================================================
interface CompanyInfoFormProps {
companyInfo: CompanyInfo | null
onChange: (info: CompanyInfo) => void
}
function CompanyInfoForm({ companyInfo, onChange }: CompanyInfoFormProps) {
const [formData, setFormData] = useState<CompanyInfo>(
companyInfo || {
name: '',
address: '',
city: '',
postalCode: '',
country: 'Deutschland',
email: '',
phone: '',
website: '',
dpoName: '',
dpoEmail: '',
dpoPhone: '',
registrationNumber: '',
vatId: '',
}
)
const handleChange = (field: keyof CompanyInfo, value: string) => {
const updated = { ...formData, [field]: value }
setFormData(updated)
onChange(updated)
}
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-6">
<div className="flex items-center gap-3 border-b border-slate-200 pb-4">
<Building2 className="w-5 h-5 text-slate-400" />
<h3 className="font-semibold text-slate-900">Unternehmensdaten</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Company Name */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">
Firmenname *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Muster GmbH"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
/>
</div>
{/* Address */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">
Strasse & Hausnummer *
</label>
<input
type="text"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
placeholder="Musterstrasse 123"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
/>
</div>
{/* Postal Code */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
PLZ *
</label>
<input
type="text"
value={formData.postalCode}
onChange={(e) => handleChange('postalCode', e.target.value)}
placeholder="12345"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
/>
</div>
{/* City */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Stadt *
</label>
<input
type="text"
value={formData.city}
onChange={(e) => handleChange('city', e.target.value)}
placeholder="Musterstadt"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
/>
</div>
{/* Country */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Land
</label>
<input
type="text"
value={formData.country}
onChange={(e) => handleChange('country', e.target.value)}
placeholder="Deutschland"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
E-Mail *
</label>
<input
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
placeholder="datenschutz@example.de"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
/>
</div>
{/* Phone */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Telefon
</label>
<input
type="tel"
value={formData.phone || ''}
onChange={(e) => handleChange('phone', e.target.value)}
placeholder="+49 123 456789"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* Website */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Website
</label>
<input
type="url"
value={formData.website || ''}
onChange={(e) => handleChange('website', e.target.value)}
placeholder="https://example.de"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* Registration Number */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Handelsregister
</label>
<input
type="text"
value={formData.registrationNumber || ''}
onChange={(e) => handleChange('registrationNumber', e.target.value)}
placeholder="HRB 12345 Amtsgericht Musterstadt"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* VAT ID */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
USt-IdNr.
</label>
<input
type="text"
value={formData.vatId || ''}
onChange={(e) => handleChange('vatId', e.target.value)}
placeholder="DE123456789"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
{/* DPO Section */}
<div className="border-t border-slate-200 pt-6">
<div className="flex items-center gap-3 mb-4">
<User className="w-5 h-5 text-slate-400" />
<h4 className="font-medium text-slate-900">Datenschutzbeauftragter (optional)</h4>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Name
</label>
<input
type="text"
value={formData.dpoName || ''}
onChange={(e) => handleChange('dpoName', e.target.value)}
placeholder="Max Mustermann"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
E-Mail
</label>
<input
type="email"
value={formData.dpoEmail || ''}
onChange={(e) => handleChange('dpoEmail', e.target.value)}
placeholder="dsb@example.de"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Telefon
</label>
<input
type="tel"
value={formData.dpoPhone || ''}
onChange={(e) => handleChange('dpoPhone', e.target.value)}
placeholder="+49 123 456780"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
</div>
</div>
)
}
// =============================================================================
// PRIVACY POLICY CONTENT
// =============================================================================
function PrivacyPolicyContent() {
const { state } = useSDK()
const {
allDataPoints,
state: einwilligungenState,
} = useEinwilligungen()
const [companyInfo, setCompanyInfo] = useState<CompanyInfo | null>(null)
const [language, setLanguage] = useState<SupportedLanguage>('de')
const [format, setFormat] = useState<ExportFormat>('HTML')
const [policy, setPolicy] = useState<GeneratedPrivacyPolicy | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
const handleGenerate = async () => {
if (!companyInfo || !companyInfo.name || !companyInfo.email || !companyInfo.address) {
alert('Bitte fuellen Sie zuerst die Pflichtfelder (Firmenname, Adresse, E-Mail) aus.')
return
}
setIsGenerating(true)
try {
// Generate locally (could also call API)
const generatedPolicy = generatePrivacyPolicy(
state.tenantId || 'demo',
allDataPoints,
companyInfo,
language,
format
)
setPolicy(generatedPolicy)
} catch (error) {
console.error('Error generating policy:', error)
alert('Fehler beim Generieren der Datenschutzerklaerung')
} finally {
setIsGenerating(false)
}
}
const handleDownload = (downloadFormat: ExportFormat) => {
if (!policy?.content) return
const blob = new Blob([policy.content], {
type: downloadFormat === 'HTML' ? 'text/html' : 'text/markdown',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `datenschutzerklaerung-${language}.${downloadFormat === 'HTML' ? 'html' : 'md'}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
return (
<div className="space-y-6">
{/* Back Link */}
<Link
href="/sdk/einwilligungen/catalog"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
>
<ArrowLeft className="w-4 h-4" />
Zurueck zum Katalog
</Link>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">
Datenschutzerklaerung Generator
</h1>
<p className="text-slate-600 mt-1">
Generieren Sie eine DSGVO-konforme Datenschutzerklaerung aus Ihrem Datenpunktkatalog.
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Datenpunkte</div>
<div className="text-2xl font-bold text-slate-900">{allDataPoints.length}</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Kategorien</div>
<div className="text-2xl font-bold text-indigo-600">8</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Abschnitte</div>
<div className="text-2xl font-bold text-purple-600">9</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Sprachen</div>
<div className="text-2xl font-bold text-green-600">2</div>
</div>
</div>
{/* Two Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left: Company Info */}
<div>
<CompanyInfoForm companyInfo={companyInfo} onChange={setCompanyInfo} />
</div>
{/* Right: Preview */}
<div>
<PrivacyPolicyPreview
policy={policy}
isLoading={isGenerating}
language={language}
format={format}
onLanguageChange={setLanguage}
onFormatChange={setFormat}
onGenerate={handleGenerate}
onDownload={handleDownload}
/>
</div>
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function PrivacyPolicyPage() {
return (
<EinwilligungenProvider>
<PrivacyPolicyContent />
</EinwilligungenProvider>
)
}

View File

@@ -0,0 +1,482 @@
'use client'
/**
* Retention Matrix Page (Loeschfristen)
*
* Zeigt die Loeschfristen-Matrix fuer alle Datenpunkte nach Kategorien.
*/
import { useState, useMemo } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { RetentionMatrix } from '@/components/sdk/einwilligungen'
import {
EinwilligungenProvider,
useEinwilligungen,
} from '@/lib/sdk/einwilligungen/context'
import { RETENTION_MATRIX } from '@/lib/sdk/einwilligungen/catalog/loader'
import {
SupportedLanguage,
RETENTION_PERIOD_INFO,
DataPointCategory,
} from '@/lib/sdk/einwilligungen/types'
import {
Clock,
Calendar,
AlertTriangle,
Info,
Download,
Filter,
ArrowLeft,
BarChart3,
Shield,
Scale,
} from 'lucide-react'
import Link from 'next/link'
// =============================================================================
// RETENTION STATS
// =============================================================================
interface RetentionStatsProps {
stats: Record<string, number>
}
function RetentionStats({ stats }: RetentionStatsProps) {
const shortTerm = (stats['24_HOURS'] || 0) + (stats['30_DAYS'] || 0)
const mediumTerm = (stats['90_DAYS'] || 0) + (stats['12_MONTHS'] || 0)
const longTerm = (stats['24_MONTHS'] || 0) + (stats['36_MONTHS'] || 0)
const legalTerm = (stats['6_YEARS'] || 0) + (stats['10_YEARS'] || 0)
const variable = (stats['UNTIL_REVOCATION'] || 0) +
(stats['UNTIL_PURPOSE_FULFILLED'] || 0) +
(stats['UNTIL_ACCOUNT_DELETION'] || 0)
const total = shortTerm + mediumTerm + longTerm + legalTerm + variable
return (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="bg-green-50 rounded-xl p-4 border border-green-200">
<div className="flex items-center gap-2 text-green-600 mb-1">
<Clock className="w-4 h-4" />
<span className="text-sm font-medium">Kurzfristig</span>
</div>
<div className="text-2xl font-bold text-green-700">{shortTerm}</div>
<div className="text-xs text-green-600 mt-1">
30 Tage ({total > 0 ? Math.round((shortTerm / total) * 100) : 0}%)
</div>
</div>
<div className="bg-blue-50 rounded-xl p-4 border border-blue-200">
<div className="flex items-center gap-2 text-blue-600 mb-1">
<Calendar className="w-4 h-4" />
<span className="text-sm font-medium">Mittelfristig</span>
</div>
<div className="text-2xl font-bold text-blue-700">{mediumTerm}</div>
<div className="text-xs text-blue-600 mt-1">
90 Tage - 12 Monate ({total > 0 ? Math.round((mediumTerm / total) * 100) : 0}%)
</div>
</div>
<div className="bg-amber-50 rounded-xl p-4 border border-amber-200">
<div className="flex items-center gap-2 text-amber-600 mb-1">
<Calendar className="w-4 h-4" />
<span className="text-sm font-medium">Langfristig</span>
</div>
<div className="text-2xl font-bold text-amber-700">{longTerm}</div>
<div className="text-xs text-amber-600 mt-1">
2-3 Jahre ({total > 0 ? Math.round((longTerm / total) * 100) : 0}%)
</div>
</div>
<div className="bg-red-50 rounded-xl p-4 border border-red-200">
<div className="flex items-center gap-2 text-red-600 mb-1">
<Scale className="w-4 h-4" />
<span className="text-sm font-medium">Gesetzlich</span>
</div>
<div className="text-2xl font-bold text-red-700">{legalTerm}</div>
<div className="text-xs text-red-600 mt-1">
6-10 Jahre AO/HGB ({total > 0 ? Math.round((legalTerm / total) * 100) : 0}%)
</div>
</div>
<div className="bg-purple-50 rounded-xl p-4 border border-purple-200">
<div className="flex items-center gap-2 text-purple-600 mb-1">
<Shield className="w-4 h-4" />
<span className="text-sm font-medium">Variabel</span>
</div>
<div className="text-2xl font-bold text-purple-700">{variable}</div>
<div className="text-xs text-purple-600 mt-1">
Bis Widerruf/Zweck ({total > 0 ? Math.round((variable / total) * 100) : 0}%)
</div>
</div>
</div>
)
}
// =============================================================================
// LEGAL INFO PANEL
// =============================================================================
function LegalInfoPanel() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center gap-3 mb-4">
<Info className="w-5 h-5 text-indigo-500" />
<h3 className="font-semibold text-slate-900">Rechtliche Grundlagen</h3>
</div>
<div className="space-y-4 text-sm text-slate-600">
<div className="p-3 bg-slate-50 rounded-lg">
<h4 className="font-medium text-slate-900 mb-1">Art. 17 DSGVO - Loeschpflicht</h4>
<p>
Personenbezogene Daten muessen geloescht werden, sobald der Zweck der
Verarbeitung entfaellt und keine gesetzlichen Aufbewahrungspflichten
entgegenstehen.
</p>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<h4 className="font-medium text-slate-900 mb-1">§ 147 AO - Steuerliche Aufbewahrung</h4>
<p>
Buchungsbelege, Rechnungen und geschaeftsrelevante Unterlagen muessen
fuer <strong>10 Jahre</strong> aufbewahrt werden.
</p>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<h4 className="font-medium text-slate-900 mb-1">§ 257 HGB - Handelsrechtlich</h4>
<p>
Handelsbuecher und Inventare: <strong>10 Jahre</strong>. Handels- und
Geschaeftsbriefe: <strong>6 Jahre</strong>.
</p>
</div>
<div className="p-3 bg-amber-50 rounded-lg border border-amber-200">
<h4 className="font-medium text-amber-800 mb-1">Hinweis zur Umsetzung</h4>
<p className="text-amber-700">
Die Loeschfristen muessen technisch umgesetzt werden. Implementieren Sie
automatische Loeschprozesse oder Benachrichtigungen fuer manuelle Pruefungen.
</p>
</div>
</div>
</div>
)
}
// =============================================================================
// RETENTION TIMELINE
// =============================================================================
interface RetentionTimelineProps {
dataPoints: Array<{
id: string
code: string
name: { de: string; en: string }
retentionPeriod: string
}>
language: SupportedLanguage
}
function RetentionTimeline({ dataPoints, language }: RetentionTimelineProps) {
// Sort by retention period duration
const sortedDataPoints = useMemo(() => {
const getPeriodDays = (period: string): number => {
const info = RETENTION_PERIOD_INFO[period as keyof typeof RETENTION_PERIOD_INFO]
return info?.days ?? 99999
}
return [...dataPoints].sort((a, b) => {
return getPeriodDays(a.retentionPeriod) - getPeriodDays(b.retentionPeriod)
})
}, [dataPoints])
const getColorForPeriod = (period: string): string => {
const days = RETENTION_PERIOD_INFO[period as keyof typeof RETENTION_PERIOD_INFO]?.days
if (days === null) return 'bg-purple-500'
if (days <= 30) return 'bg-green-500'
if (days <= 365) return 'bg-blue-500'
if (days <= 1095) return 'bg-amber-500'
return 'bg-red-500'
}
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center gap-3 mb-4">
<BarChart3 className="w-5 h-5 text-indigo-500" />
<h3 className="font-semibold text-slate-900">Timeline der Loeschfristen</h3>
</div>
<div className="space-y-2">
{sortedDataPoints.slice(0, 15).map((dp) => {
const info = RETENTION_PERIOD_INFO[dp.retentionPeriod as keyof typeof RETENTION_PERIOD_INFO]
const maxDays = 3650 // 10 Jahre als Maximum
const width = info?.days !== null
? Math.min(100, ((info?.days || 0) / maxDays) * 100)
: 100
return (
<div key={dp.id} className="flex items-center gap-3">
<span className="w-10 text-xs font-mono text-slate-400 shrink-0">
{dp.code}
</span>
<div className="flex-1 min-w-0">
<div className="h-6 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${getColorForPeriod(dp.retentionPeriod)} rounded-full flex items-center justify-end pr-2`}
style={{ width: `${Math.max(width, 15)}%` }}
>
<span className="text-xs text-white font-medium truncate">
{info?.label[language]}
</span>
</div>
</div>
</div>
</div>
)
})}
{sortedDataPoints.length > 15 && (
<p className="text-xs text-slate-500 text-center pt-2">
+ {sortedDataPoints.length - 15} weitere Datenpunkte
</p>
)}
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 mt-4 pt-4 border-t border-slate-100 text-xs text-slate-600">
<span className="font-medium">Legende:</span>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-green-500" />
30 Tage
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-blue-500" />
90T-12M
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-amber-500" />
2-3 Jahre
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500" />
6-10 Jahre
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-purple-500" />
Variabel
</div>
</div>
</div>
)
}
// =============================================================================
// EXPORT OPTIONS
// =============================================================================
interface ExportOptionsProps {
onExport: (format: 'csv' | 'json' | 'pdf') => void
}
function ExportOptions({ onExport }: ExportOptionsProps) {
return (
<div className="flex items-center gap-2">
<button
onClick={() => onExport('csv')}
className="flex items-center gap-2 px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-700 hover:bg-slate-50"
>
<Download className="w-4 h-4" />
CSV
</button>
<button
onClick={() => onExport('json')}
className="flex items-center gap-2 px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-700 hover:bg-slate-50"
>
<Download className="w-4 h-4" />
JSON
</button>
<button
onClick={() => onExport('pdf')}
className="flex items-center gap-2 px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700"
>
<Download className="w-4 h-4" />
PDF
</button>
</div>
)
}
// =============================================================================
// MAIN CONTENT
// =============================================================================
function RetentionContent() {
const { state } = useSDK()
const { allDataPoints } = useEinwilligungen()
const [language, setLanguage] = useState<SupportedLanguage>('de')
const [filterCategory, setFilterCategory] = useState<DataPointCategory | 'ALL'>('ALL')
// Calculate stats
const stats = useMemo(() => {
const periodCounts: Record<string, number> = {}
for (const dp of allDataPoints) {
periodCounts[dp.retentionPeriod] = (periodCounts[dp.retentionPeriod] || 0) + 1
}
return periodCounts
}, [allDataPoints])
// Filter data points
const filteredDataPoints = useMemo(() => {
if (filterCategory === 'ALL') return allDataPoints
return allDataPoints.filter((dp) => dp.category === filterCategory)
}, [allDataPoints, filterCategory])
// Handle export
const handleExport = (format: 'csv' | 'json' | 'pdf') => {
if (format === 'csv') {
const headers = ['Code', 'Name', 'Kategorie', 'Loeschfrist', 'Rechtsgrundlage']
const rows = allDataPoints.map((dp) => [
dp.code,
dp.name[language],
dp.category,
RETENTION_PERIOD_INFO[dp.retentionPeriod as keyof typeof RETENTION_PERIOD_INFO]?.label[language] || dp.retentionPeriod,
dp.legalBasis,
])
const csv = [headers, ...rows].map((row) => row.join(';')).join('\n')
downloadFile(csv, 'loeschfristen.csv', 'text/csv')
} else if (format === 'json') {
const data = allDataPoints.map((dp) => ({
code: dp.code,
name: dp.name,
category: dp.category,
retentionPeriod: dp.retentionPeriod,
retentionLabel: RETENTION_PERIOD_INFO[dp.retentionPeriod as keyof typeof RETENTION_PERIOD_INFO]?.label,
legalBasis: dp.legalBasis,
}))
downloadFile(JSON.stringify(data, null, 2), 'loeschfristen.json', 'application/json')
} else {
alert('PDF-Export wird noch implementiert.')
}
}
const downloadFile = (content: string, filename: string, type: string) => {
const blob = new Blob([content], { type })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// 18 Kategorien (A-R)
const categories: Array<{ id: DataPointCategory | 'ALL'; label: string }> = [
{ id: 'ALL', label: 'Alle Kategorien' },
{ id: 'MASTER_DATA', label: 'A: Stammdaten' },
{ id: 'CONTACT_DATA', label: 'B: Kontaktdaten' },
{ id: 'AUTHENTICATION', label: 'C: Authentifizierung' },
{ id: 'CONSENT', label: 'D: Einwilligung' },
{ id: 'COMMUNICATION', label: 'E: Kommunikation' },
{ id: 'PAYMENT', label: 'F: Zahlung' },
{ id: 'USAGE_DATA', label: 'G: Nutzungsdaten' },
{ id: 'LOCATION', label: 'H: Standort' },
{ id: 'DEVICE_DATA', label: 'I: Geraetedaten' },
{ id: 'MARKETING', label: 'J: Marketing' },
{ id: 'ANALYTICS', label: 'K: Analyse' },
{ id: 'SOCIAL_MEDIA', label: 'L: Social Media' },
{ id: 'HEALTH_DATA', label: 'M: Gesundheit (Art. 9)' },
{ id: 'EMPLOYEE_DATA', label: 'N: Beschaeftigte (BDSG § 26)' },
{ id: 'CONTRACT_DATA', label: 'O: Vertraege' },
{ id: 'LOG_DATA', label: 'P: Protokolle' },
{ id: 'AI_DATA', label: 'Q: KI-Daten (AI Act)' },
{ id: 'SECURITY', label: 'R: Sicherheit' },
]
return (
<div className="space-y-6">
{/* Back Link */}
<Link
href="/sdk/einwilligungen/catalog"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
>
<ArrowLeft className="w-4 h-4" />
Zurueck zum Katalog
</Link>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Loeschfristen-Matrix</h1>
<p className="text-slate-600 mt-1">
Uebersicht aller Aufbewahrungsfristen gemaess DSGVO, AO und HGB.
</p>
</div>
<ExportOptions onExport={handleExport} />
</div>
{/* Stats */}
<RetentionStats stats={stats} />
{/* Filters */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value as DataPointCategory | 'ALL')}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.label}
</option>
))}
</select>
<select
value={language}
onChange={(e) => setLanguage(e.target.value as SupportedLanguage)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
<div className="text-sm text-slate-500">
{filteredDataPoints.length} Datenpunkte
</div>
</div>
{/* Two Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: Matrix */}
<div className="lg:col-span-2">
<RetentionMatrix
matrix={RETENTION_MATRIX}
dataPoints={filteredDataPoints}
language={language}
showDetails={true}
/>
</div>
{/* Right: Sidebar */}
<div className="space-y-6">
<RetentionTimeline dataPoints={filteredDataPoints} language={language} />
<LegalInfoPanel />
</div>
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function RetentionPage() {
return (
<EinwilligungenProvider>
<RetentionContent />
</EinwilligungenProvider>
)
}