Files
breakpilot-compliance/admin-compliance/app/sdk/einwilligungen/page.tsx
Benjamin Admin 215b95adfa
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
refactor: Admin-Layout komplett entfernt — SDK als einziges Layout
Kaputtes (admin) Layout geloescht (Role-Selection, 404-Sidebar, localhost-Dashboard).
SDK-Flow nach /sdk/sdk-flow verschoben. Route-Gruppe (sdk) aufgeloest.
Root-Seite redirected auf /sdk. ~25 ungenutzte Dateien/Verzeichnisse entfernt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:43:00 +01:00

834 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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
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
// =============================================================================
export default function EinwilligungenPage() {
const { state } = useSDK()
const [records, setRecords] = useState<ConsentRecord[]>([])
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
const matchesSearch = searchQuery === '' ||
record.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.identifier.toLowerCase().includes(searchQuery.toLowerCase())
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 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">{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>
{/* 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 */}
{(() => {
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>
)
})()}
{/* Detail Modal */}
{selectedRecord && (
<ConsentDetailModal
record={selectedRecord}
onClose={() => setSelectedRecord(null)}
onRevoke={handleRevoke}
/>
)}
</div>
)
}