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:
248
admin-v2/app/(sdk)/sdk/einwilligungen/catalog/page.tsx
Normal file
248
admin-v2/app/(sdk)/sdk/einwilligungen/catalog/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
763
admin-v2/app/(sdk)/sdk/einwilligungen/cookie-banner/page.tsx
Normal file
763
admin-v2/app/(sdk)/sdk/einwilligungen/cookie-banner/page.tsx
Normal 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"><head></code> oder vor dem
|
||||
schliessenden{' '}
|
||||
<code className="bg-amber-100 px-1 rounded"></body></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>
|
||||
)
|
||||
}
|
||||
931
admin-v2/app/(sdk)/sdk/einwilligungen/page.tsx
Normal file
931
admin-v2/app/(sdk)/sdk/einwilligungen/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
414
admin-v2/app/(sdk)/sdk/einwilligungen/privacy-policy/page.tsx
Normal file
414
admin-v2/app/(sdk)/sdk/einwilligungen/privacy-policy/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
482
admin-v2/app/(sdk)/sdk/einwilligungen/retention/page.tsx
Normal file
482
admin-v2/app/(sdk)/sdk/einwilligungen/retention/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user