refactor(admin): split einwilligungen page.tsx into colocated components

Extract nav tabs, detail modal, table row, stats grid, search/filter,
records table, pagination, and data-loading hook into _components/ and
_hooks/. page.tsx reduced from 833 to 114 LOC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-15 08:20:50 +02:00
parent cc3a9a37dc
commit 92a730626d
10 changed files with 893 additions and 760 deletions

View File

@@ -0,0 +1,247 @@
'use client'
import { useState } from 'react'
import {
X,
History,
Shield,
AlertTriangle,
Monitor,
Globe,
Calendar,
User,
} from 'lucide-react'
import {
ConsentRecord,
typeColors,
typeLabels,
statusColors,
statusLabels,
actionLabels,
actionIcons,
formatDateTime,
} from '../_types'
interface ConsentDetailModalProps {
record: ConsentRecord
onClose: () => void
onRevoke: (recordId: string) => void
}
export 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.identifier}</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) => (
<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>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
import { History } from 'lucide-react'
import {
ConsentRecord,
typeColors,
typeLabels,
statusColors,
statusLabels,
formatDate,
} from '../_types'
interface ConsentRecordRowProps {
record: ConsentRecord
onShowDetails: (record: ConsentRecord) => void
}
export 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.identifier}</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>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
Database,
FileText,
Cookie,
Clock,
LayoutGrid,
} from 'lucide-react'
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',
},
]
export 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>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import { PAGE_SIZE } from '../_types'
interface PaginationProps {
currentPage: number
totalRecords: number
onPageChange: (page: number) => void
}
export function Pagination({ currentPage, totalRecords, onPageChange }: PaginationProps) {
const totalPages = Math.ceil(totalRecords / PAGE_SIZE)
return (
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
Zeige {totalRecords === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1}
{Math.min(currentPage * PAGE_SIZE, totalRecords)} von {totalRecords} Einträgen
</p>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50"
>
Zurück
</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const page = Math.max(1, Math.min(currentPage - 2, totalPages - 4)) + i
if (page < 1 || page > totalPages) return null
return (
<button
key={page}
onClick={() => onPageChange(page)}
className={`px-3 py-1 text-sm rounded-lg ${
page === currentPage
? 'text-white bg-purple-600'
: 'text-gray-600 bg-gray-100 hover:bg-gray-200'
}`}
>
{page}
</button>
)
})}
<button
onClick={() => onPageChange(Math.min(totalPages || 1, currentPage + 1))}
disabled={currentPage >= (totalPages || 1)}
className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50"
>
Weiter
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
'use client'
import { ConsentRecord } from '../_types'
import { ConsentRecordRow } from './ConsentRecordRow'
interface RecordsTableProps {
records: ConsentRecord[]
onShowDetails: (record: ConsentRecord) => void
}
export function RecordsTable({ records, onShowDetails }: RecordsTableProps) {
return (
<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">
{records.map(record => (
<ConsentRecordRow
key={record.id}
record={record}
onShowDetails={onShowDetails}
/>
))}
</tbody>
</table>
</div>
{records.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>
)
}

View File

@@ -0,0 +1,54 @@
'use client'
interface SearchAndFilterProps {
searchQuery: string
onSearchChange: (v: string) => void
filter: string
onFilterChange: (v: string) => void
}
const FILTER_OPTIONS = ['all', 'granted', 'withdrawn', 'terms', 'privacy', 'cookies', 'marketing', 'analytics']
function filterLabel(f: string): string {
return f === 'all' ? 'Alle' :
f === 'granted' ? 'Erteilt' :
f === 'withdrawn' ? 'Widerrufen' :
f === 'terms' ? 'AGB' :
f === 'privacy' ? 'DSI' :
f === 'cookies' ? 'Cookies' :
f === 'marketing' ? 'Marketing' : 'Analyse'
}
export function SearchAndFilter({ searchQuery, onSearchChange, filter, onFilterChange }: SearchAndFilterProps) {
return (
<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) => onSearchChange(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">
{FILTER_OPTIONS.map(f => (
<button
key={f}
onClick={() => onFilterChange(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'
}`}
>
{filterLabel(f)}
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
interface StatsGridProps {
total: number
active: number
revoked: number
versionUpdates: number
}
export function StatsGrid({ total, active, revoked, versionUpdates }: StatsGridProps) {
return (
<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">{total}</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">{active}</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">{revoked}</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>
)
}

View File

@@ -0,0 +1,171 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import {
ConsentRecord,
ConsentStatus,
ConsentType,
HistoryAction,
PAGE_SIZE,
} from '../_types'
interface GlobalStats {
total: number
active: number
revoked: number
}
export function useConsents() {
const [records, setRecords] = useState<ConsentRecord[]>([])
const [isLoading, setIsLoading] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
const [totalRecords, setTotalRecords] = useState(0)
const [globalStats, setGlobalStats] = useState<GlobalStats>({ total: 0, active: 0, revoked: 0 })
const loadConsents = useCallback(async (page: number) => {
setIsLoading(true)
try {
const offset = (page - 1) * PAGE_SIZE
const listResponse = await fetch(
`/api/sdk/v1/einwilligungen/consent?limit=${PAGE_SIZE}&offset=${offset}`
)
if (listResponse.ok) {
const listData = await listResponse.json()
setTotalRecords(listData.total ?? 0)
if (listData.consents?.length > 0) {
const mapped: ConsentRecord[] = listData.consents.map((c: {
id: string
user_id: string
data_point_id: string
granted: boolean
granted_at: string
revoked_at?: string
consent_version?: string
source?: string
ip_address?: string
user_agent?: string
history?: Array<{
id: string
action: string
created_at: string
consent_version?: string
ip_address?: string
user_agent?: string
source?: string
}>
}) => ({
id: c.id,
identifier: c.user_id,
email: c.user_id,
consentType: (c.data_point_id as ConsentType) || 'privacy',
status: (c.revoked_at ? 'withdrawn' : 'granted') as ConsentStatus,
currentVersion: c.consent_version || '1.0',
grantedAt: c.granted_at ? new Date(c.granted_at) : null,
withdrawnAt: c.revoked_at ? new Date(c.revoked_at) : null,
source: c.source ?? null,
ipAddress: c.ip_address ?? '',
userAgent: c.user_agent ?? '',
history: (c.history ?? []).map(h => ({
id: h.id,
action: h.action as HistoryAction,
timestamp: new Date(h.created_at),
version: h.consent_version || '1.0',
ipAddress: h.ip_address ?? '',
userAgent: h.user_agent ?? '',
source: h.source ?? '',
})),
}))
setRecords(mapped)
} else {
setRecords([])
}
}
} catch {
// Backend nicht erreichbar, leere Liste anzeigen
} finally {
setIsLoading(false)
}
}, [])
const loadStats = useCallback(async () => {
try {
const res = await fetch('/api/sdk/v1/einwilligungen/consent?stats=true')
if (res.ok) {
const data = await res.json()
const s = data.statistics
if (s) {
setGlobalStats({
total: s.total_consents ?? 0,
active: s.active_consents ?? 0,
revoked: s.revoked_consents ?? 0,
})
setTotalRecords(s.total_consents ?? 0)
}
}
} catch {
// Statistiken nicht erreichbar — lokale Werte behalten
}
}, [])
useEffect(() => {
loadStats()
}, [loadStats])
useEffect(() => { loadConsents(currentPage) }, [currentPage, loadConsents])
const handleRevoke = useCallback(async (recordId: string) => {
try {
const response = await fetch('/api/sdk/v1/einwilligungen/consent', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consentId: recordId, action: 'revoke' }),
})
if (response.ok) {
const now = new Date()
setRecords(prev => prev.map(r => {
if (r.id === recordId) {
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
}))
loadStats()
}
} catch {
// Fallback: update local state even if API call fails
const now = new Date()
setRecords(prev => prev.map(r => {
if (r.id === recordId) {
return { ...r, status: 'withdrawn' as ConsentStatus, withdrawnAt: now }
}
return r
}))
}
}, [loadStats])
return {
records,
isLoading,
currentPage,
setCurrentPage,
totalRecords,
globalStats,
handleRevoke,
}
}

View File

@@ -0,0 +1,102 @@
import React from 'react'
import {
CheckCircle,
XCircle,
FileCheck,
Shield,
} from 'lucide-react'
export type ConsentType = 'marketing' | 'analytics' | 'newsletter' | 'terms' | 'privacy' | 'cookies'
export type ConsentStatus = 'granted' | 'withdrawn' | 'pending'
export type HistoryAction = 'granted' | 'withdrawn' | 'version_update' | 'renewed'
export interface ConsentHistoryEntry {
id: string
action: HistoryAction
timestamp: Date
version: string
documentTitle?: string
ipAddress: string
userAgent: string
source: string
notes?: string
}
export interface ConsentRecord {
id: string
identifier: string
email: string
firstName?: string
lastName?: string
consentType: ConsentType
status: ConsentStatus
currentVersion: string
grantedAt: Date | null
withdrawnAt: Date | null
source: string | null
ipAddress: string
userAgent: string
history: ConsentHistoryEntry[]
}
export const typeLabels: Record<ConsentType, string> = {
marketing: 'Marketing',
analytics: 'Analyse',
newsletter: 'Newsletter',
terms: 'AGB',
privacy: 'Datenschutz',
cookies: 'Cookies',
}
export 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',
}
export 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',
}
export const statusLabels: Record<ConsentStatus, string> = {
granted: 'Erteilt',
withdrawn: 'Widerrufen',
pending: 'Ausstehend',
}
export const actionLabels: Record<HistoryAction, string> = {
granted: 'Einwilligung erteilt',
withdrawn: 'Einwilligung widerrufen',
version_update: 'Neue Version akzeptiert',
renewed: 'Einwilligung erneuert',
}
export const actionIcons: Record<HistoryAction, React.ReactNode> = {
granted: React.createElement(CheckCircle, { className: 'w-5 h-5 text-green-500' }),
withdrawn: React.createElement(XCircle, { className: 'w-5 h-5 text-red-500' }),
version_update: React.createElement(FileCheck, { className: 'w-5 h-5 text-blue-500' }),
renewed: React.createElement(Shield, { className: 'w-5 h-5 text-purple-500' }),
}
export 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',
})
}
export function formatDate(date: Date | null): string {
if (!date) return '-'
return date.toLocaleDateString('de-DE')
}
export const PAGE_SIZE = 50

View File

@@ -1,594 +1,31 @@
'use client'
import React, { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import { useState } from 'react'
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'
import { History } 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
identifier: string
email: string
firstName?: string
lastName?: string
consentType: ConsentType
status: ConsentStatus
currentVersion: string
grantedAt: Date | null
withdrawnAt: Date | null
source: string | null
ipAddress: string
userAgent: string
history: ConsentHistoryEntry[]
}
// =============================================================================
// 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.identifier}</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.identifier}</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
// =============================================================================
import { ConsentRecord } from './_types'
import { useConsents } from './_hooks/useConsents'
import { EinwilligungenNavTabs } from './_components/EinwilligungenNavTabs'
import { StatsGrid } from './_components/StatsGrid'
import { SearchAndFilter } from './_components/SearchAndFilter'
import { RecordsTable } from './_components/RecordsTable'
import { Pagination } from './_components/Pagination'
import { ConsentDetailModal } from './_components/ConsentDetailModal'
export default function EinwilligungenPage() {
const { state } = useSDK()
const [records, setRecords] = useState<ConsentRecord[]>([])
const {
records,
currentPage,
setCurrentPage,
totalRecords,
globalStats,
handleRevoke,
} = useConsents()
const [filter, setFilter] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState('')
const [selectedRecord, setSelectedRecord] = useState<ConsentRecord | null>(null)
const [isLoading, setIsLoading] = useState(true)
const PAGE_SIZE = 50
const [currentPage, setCurrentPage] = useState(1)
const [totalRecords, setTotalRecords] = useState(0)
const [globalStats, setGlobalStats] = useState({ total: 0, active: 0, revoked: 0 })
const loadConsents = React.useCallback(async (page: number) => {
setIsLoading(true)
try {
const offset = (page - 1) * PAGE_SIZE
const listResponse = await fetch(
`/api/sdk/v1/einwilligungen/consent?limit=${PAGE_SIZE}&offset=${offset}`
)
if (listResponse.ok) {
const listData = await listResponse.json()
setTotalRecords(listData.total ?? 0)
if (listData.consents?.length > 0) {
const mapped: ConsentRecord[] = listData.consents.map((c: {
id: string
user_id: string
data_point_id: string
granted: boolean
granted_at: string
revoked_at?: string
consent_version?: string
source?: string
ip_address?: string
user_agent?: string
history?: Array<{
id: string
action: string
created_at: string
consent_version?: string
ip_address?: string
user_agent?: string
source?: string
}>
}) => ({
id: c.id,
identifier: c.user_id,
email: c.user_id,
consentType: (c.data_point_id as ConsentType) || 'privacy',
status: (c.revoked_at ? 'withdrawn' : 'granted') as ConsentStatus,
currentVersion: c.consent_version || '1.0',
grantedAt: c.granted_at ? new Date(c.granted_at) : null,
withdrawnAt: c.revoked_at ? new Date(c.revoked_at) : null,
source: c.source ?? null,
ipAddress: c.ip_address ?? '',
userAgent: c.user_agent ?? '',
history: (c.history ?? []).map(h => ({
id: h.id,
action: h.action as HistoryAction,
timestamp: new Date(h.created_at),
version: h.consent_version || '1.0',
ipAddress: h.ip_address ?? '',
userAgent: h.user_agent ?? '',
source: h.source ?? '',
})),
}))
setRecords(mapped)
} else {
setRecords([])
}
}
} catch {
// Backend nicht erreichbar, leere Liste anzeigen
} finally {
setIsLoading(false)
}
}, [])
const loadStats = React.useCallback(async () => {
try {
const res = await fetch('/api/sdk/v1/einwilligungen/consent?stats=true')
if (res.ok) {
const data = await res.json()
const s = data.statistics
if (s) {
setGlobalStats({
total: s.total_consents ?? 0,
active: s.active_consents ?? 0,
revoked: s.revoked_consents ?? 0,
})
setTotalRecords(s.total_consents ?? 0)
}
}
} catch {
// Statistiken nicht erreichbar — lokale Werte behalten
}
}, [])
React.useEffect(() => {
loadStats()
}, [loadStats])
React.useEffect(() => { loadConsents(currentPage) }, [currentPage, loadConsents])
const filteredRecords = records.filter(record => {
const matchesFilter = filter === 'all' || record.consentType === filter || record.status === filter
@@ -598,53 +35,10 @@ export default function EinwilligungenPage() {
return matchesFilter && matchesSearch
})
const versionUpdates = records.reduce((acc, r) => acc + r.history.filter(h => h.action === 'version_update').length, 0)
const handleRevoke = async (recordId: string) => {
try {
const response = await fetch('/api/sdk/v1/einwilligungen/consent', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consentId: recordId, action: 'revoke' }),
})
if (response.ok) {
const now = new Date()
setRecords(prev => prev.map(r => {
if (r.id === recordId) {
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
}))
loadStats()
}
} catch {
// Fallback: update local state even if API call fails
const now = new Date()
setRecords(prev => prev.map(r => {
if (r.id === recordId) {
return { ...r, status: 'withdrawn' as ConsentStatus, withdrawnAt: now }
}
return r
}))
}
}
const versionUpdates = records.reduce(
(acc, r) => acc + r.history.filter(h => h.action === 'version_update').length,
0,
)
const stepInfo = STEP_EXPLANATIONS['einwilligungen']
@@ -670,24 +64,12 @@ export default function EinwilligungenPage() {
<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">{globalStats.total}</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">{globalStats.active}</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">{globalStats.revoked}</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>
<StatsGrid
total={globalStats.total}
active={globalStats.active}
revoked={globalStats.revoked}
versionUpdates={versionUpdates}
/>
{/* 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">
@@ -702,123 +84,22 @@ export default function EinwilligungenPage() {
</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>
<SearchAndFilter
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filter={filter}
onFilterChange={setFilter}
/>
{/* 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>
<RecordsTable records={filteredRecords} onShowDetails={setSelectedRecord} />
{/* Pagination */}
{(() => {
const totalPages = Math.ceil(totalRecords / PAGE_SIZE)
return (
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
Zeige {totalRecords === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1}
{Math.min(currentPage * PAGE_SIZE, totalRecords)} von {totalRecords} Einträgen
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50"
>
Zurück
</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const page = Math.max(1, Math.min(currentPage - 2, totalPages - 4)) + i
if (page < 1 || page > totalPages) return null
return (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`px-3 py-1 text-sm rounded-lg ${
page === currentPage
? 'text-white bg-purple-600'
: 'text-gray-600 bg-gray-100 hover:bg-gray-200'
}`}
>
{page}
</button>
)
})}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages || 1, p + 1))}
disabled={currentPage >= (totalPages || 1)}
className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50"
>
Weiter
</button>
</div>
</div>
)
})()}
<Pagination
currentPage={currentPage}
totalRecords={totalRecords}
onPageChange={setCurrentPage}
/>
{/* Detail Modal */}
{selectedRecord && (