From 92a730626d1f1c7a1a6878a6b644dd3266fa4aef Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:20:50 +0200 Subject: [PATCH] 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) --- .../_components/ConsentDetailModal.tsx | 247 ++++++ .../_components/ConsentRecordRow.tsx | 60 ++ .../_components/EinwilligungenNavTabs.tsx | 84 ++ .../einwilligungen/_components/Pagination.tsx | 55 ++ .../_components/RecordsTable.tsx | 48 ++ .../_components/SearchAndFilter.tsx | 54 ++ .../einwilligungen/_components/StatsGrid.tsx | 31 + .../sdk/einwilligungen/_hooks/useConsents.ts | 171 ++++ .../app/sdk/einwilligungen/_types.ts | 102 +++ .../app/sdk/einwilligungen/page.tsx | 801 +----------------- 10 files changed, 893 insertions(+), 760 deletions(-) create mode 100644 admin-compliance/app/sdk/einwilligungen/_components/ConsentDetailModal.tsx create mode 100644 admin-compliance/app/sdk/einwilligungen/_components/ConsentRecordRow.tsx create mode 100644 admin-compliance/app/sdk/einwilligungen/_components/EinwilligungenNavTabs.tsx create mode 100644 admin-compliance/app/sdk/einwilligungen/_components/Pagination.tsx create mode 100644 admin-compliance/app/sdk/einwilligungen/_components/RecordsTable.tsx create mode 100644 admin-compliance/app/sdk/einwilligungen/_components/SearchAndFilter.tsx create mode 100644 admin-compliance/app/sdk/einwilligungen/_components/StatsGrid.tsx create mode 100644 admin-compliance/app/sdk/einwilligungen/_hooks/useConsents.ts create mode 100644 admin-compliance/app/sdk/einwilligungen/_types.ts diff --git a/admin-compliance/app/sdk/einwilligungen/_components/ConsentDetailModal.tsx b/admin-compliance/app/sdk/einwilligungen/_components/ConsentDetailModal.tsx new file mode 100644 index 0000000..0db8ddf --- /dev/null +++ b/admin-compliance/app/sdk/einwilligungen/_components/ConsentDetailModal.tsx @@ -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 ( +
+
+ {/* Header */} +
+
+

Consent-Details

+

{record.email}

+
+ +
+ + {/* Content */} +
+ {/* User Info */} +
+
+
+ + Benutzerinformationen +
+
+
+ Name: + {record.firstName} {record.lastName} +
+
+ E-Mail: + {record.email} +
+
+ User-ID: + {record.identifier} +
+
+
+ +
+
+ + Consent-Status +
+
+
+ Typ: + + {typeLabels[record.consentType]} + +
+
+ Status: + + {statusLabels[record.status]} + +
+
+ Version: + v{record.currentVersion} +
+
+
+
+ + {/* Technical Details */} +
+
+ + Technische Details (letzter Consent) +
+
+
+
IP-Adresse
+
{record.ipAddress}
+
+
+
Quelle
+
{record.source ?? '—'}
+
+
+
User-Agent
+
{record.userAgent}
+
+
+
+ + {/* History Timeline */} +
+
+ + Consent-Historie + + {record.history.length} Einträge + +
+ +
+ {/* Timeline line */} +
+ +
+ {record.history.map((entry) => ( +
+ {/* Icon */} +
+ {actionIcons[entry.action]} +
+ + {/* Content */} +
+
+
+
{actionLabels[entry.action]}
+ {entry.documentTitle && ( +
{entry.documentTitle}
+ )} +
+
+
v{entry.version}
+
+
+ +
+
+ + {formatDateTime(entry.timestamp)} +
+
+ + {entry.ipAddress} +
+
+ +
+ Quelle: {entry.source} +
+ + {entry.notes && ( +
+ {entry.notes} +
+ )} + + {/* Expandable User-Agent */} +
+ + User-Agent anzeigen + +
+ {entry.userAgent} +
+
+
+
+ ))} +
+
+
+
+ + {/* Footer Actions */} +
+
+ Consent-ID: {record.id} +
+ +
+ {record.status === 'granted' && !showRevokeConfirm && ( + + )} + + {showRevokeConfirm && ( +
+ Wirklich widerrufen? + + +
+ )} + + +
+
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/einwilligungen/_components/ConsentRecordRow.tsx b/admin-compliance/app/sdk/einwilligungen/_components/ConsentRecordRow.tsx new file mode 100644 index 0000000..985ce58 --- /dev/null +++ b/admin-compliance/app/sdk/einwilligungen/_components/ConsentRecordRow.tsx @@ -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 ( + + +
{record.email}
+
{record.identifier}
+ + + + {typeLabels[record.consentType]} + + + + + {statusLabels[record.status]} + + + + {formatDate(record.grantedAt)} + + + {formatDate(record.withdrawnAt)} + + + v{record.currentVersion} + + +
+ + {record.history.length} +
+ + + + + + ) +} diff --git a/admin-compliance/app/sdk/einwilligungen/_components/EinwilligungenNavTabs.tsx b/admin-compliance/app/sdk/einwilligungen/_components/EinwilligungenNavTabs.tsx new file mode 100644 index 0000000..02ffc47 --- /dev/null +++ b/admin-compliance/app/sdk/einwilligungen/_components/EinwilligungenNavTabs.tsx @@ -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 ( +
+
+ {EINWILLIGUNGEN_TABS.map((tab) => { + const Icon = tab.icon + const isActive = pathname === tab.href + + return ( + + +
+
+ {tab.label} +
+
{tab.description}
+
+ + ) + })} +
+
+ ) +} diff --git a/admin-compliance/app/sdk/einwilligungen/_components/Pagination.tsx b/admin-compliance/app/sdk/einwilligungen/_components/Pagination.tsx new file mode 100644 index 0000000..4b3c869 --- /dev/null +++ b/admin-compliance/app/sdk/einwilligungen/_components/Pagination.tsx @@ -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 ( +
+

+ Zeige {totalRecords === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1}– + {Math.min(currentPage * PAGE_SIZE, totalRecords)} von {totalRecords} Einträgen +

+
+ + {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 ( + + ) + })} + +
+
+ ) +} diff --git a/admin-compliance/app/sdk/einwilligungen/_components/RecordsTable.tsx b/admin-compliance/app/sdk/einwilligungen/_components/RecordsTable.tsx new file mode 100644 index 0000000..a2dee2e --- /dev/null +++ b/admin-compliance/app/sdk/einwilligungen/_components/RecordsTable.tsx @@ -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 ( +
+
+ + + + + + + + + + + + + + + {records.map(record => ( + + ))} + +
NutzerTypStatusErteilt amWiderrufen amVersionHistorieAktion
+
+ + {records.length === 0 && ( +
+

Keine Einträge gefunden

+

Passen Sie die Suche oder den Filter an.

+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/einwilligungen/_components/SearchAndFilter.tsx b/admin-compliance/app/sdk/einwilligungen/_components/SearchAndFilter.tsx new file mode 100644 index 0000000..09f611a --- /dev/null +++ b/admin-compliance/app/sdk/einwilligungen/_components/SearchAndFilter.tsx @@ -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 ( +
+
+ + + + 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" + /> +
+
+ {FILTER_OPTIONS.map(f => ( + + ))} +
+
+ ) +} diff --git a/admin-compliance/app/sdk/einwilligungen/_components/StatsGrid.tsx b/admin-compliance/app/sdk/einwilligungen/_components/StatsGrid.tsx new file mode 100644 index 0000000..68a7d8e --- /dev/null +++ b/admin-compliance/app/sdk/einwilligungen/_components/StatsGrid.tsx @@ -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 ( +
+
+
Gesamt
+
{total}
+
+
+
Aktive Einwilligungen
+
{active}
+
+
+
Widerrufen
+
{revoked}
+
+
+
Versions-Updates
+
{versionUpdates}
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/einwilligungen/_hooks/useConsents.ts b/admin-compliance/app/sdk/einwilligungen/_hooks/useConsents.ts new file mode 100644 index 0000000..da8cf81 --- /dev/null +++ b/admin-compliance/app/sdk/einwilligungen/_hooks/useConsents.ts @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [currentPage, setCurrentPage] = useState(1) + const [totalRecords, setTotalRecords] = useState(0) + const [globalStats, setGlobalStats] = useState({ 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, + } +} diff --git a/admin-compliance/app/sdk/einwilligungen/_types.ts b/admin-compliance/app/sdk/einwilligungen/_types.ts new file mode 100644 index 0000000..9245a9f --- /dev/null +++ b/admin-compliance/app/sdk/einwilligungen/_types.ts @@ -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 = { + marketing: 'Marketing', + analytics: 'Analyse', + newsletter: 'Newsletter', + terms: 'AGB', + privacy: 'Datenschutz', + cookies: 'Cookies', +} + +export const typeColors: Record = { + 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 = { + 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 = { + granted: 'Erteilt', + withdrawn: 'Widerrufen', + pending: 'Ausstehend', +} + +export const actionLabels: Record = { + granted: 'Einwilligung erteilt', + withdrawn: 'Einwilligung widerrufen', + version_update: 'Neue Version akzeptiert', + renewed: 'Einwilligung erneuert', +} + +export const actionIcons: Record = { + 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 diff --git a/admin-compliance/app/sdk/einwilligungen/page.tsx b/admin-compliance/app/sdk/einwilligungen/page.tsx index d30b550..8f07199 100644 --- a/admin-compliance/app/sdk/einwilligungen/page.tsx +++ b/admin-compliance/app/sdk/einwilligungen/page.tsx @@ -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 ( -
-
- {EINWILLIGUNGEN_TABS.map((tab) => { - const Icon = tab.icon - const isActive = pathname === tab.href - - return ( - - -
-
- {tab.label} -
-
{tab.description}
-
- - ) - })} -
-
- ) -} - -// ============================================================================= -// 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 = { - marketing: 'Marketing', - analytics: 'Analyse', - newsletter: 'Newsletter', - terms: 'AGB', - privacy: 'Datenschutz', - cookies: 'Cookies', -} - -const typeColors: Record = { - 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 = { - granted: 'bg-green-100 text-green-700', - withdrawn: 'bg-red-100 text-red-700', - pending: 'bg-yellow-100 text-yellow-700', -} - -const statusLabels: Record = { - granted: 'Erteilt', - withdrawn: 'Widerrufen', - pending: 'Ausstehend', -} - -const actionLabels: Record = { - granted: 'Einwilligung erteilt', - withdrawn: 'Einwilligung widerrufen', - version_update: 'Neue Version akzeptiert', - renewed: 'Einwilligung erneuert', -} - -const actionIcons: Record = { - granted: , - withdrawn: , - version_update: , - renewed: , -} - -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 ( -
-
- {/* Header */} -
-
-

Consent-Details

-

{record.email}

-
- -
- - {/* Content */} -
- {/* User Info */} -
-
-
- - Benutzerinformationen -
-
-
- Name: - {record.firstName} {record.lastName} -
-
- E-Mail: - {record.email} -
-
- User-ID: - {record.identifier} -
-
-
- -
-
- - Consent-Status -
-
-
- Typ: - - {typeLabels[record.consentType]} - -
-
- Status: - - {statusLabels[record.status]} - -
-
- Version: - v{record.currentVersion} -
-
-
-
- - {/* Technical Details */} -
-
- - Technische Details (letzter Consent) -
-
-
-
IP-Adresse
-
{record.ipAddress}
-
-
-
Quelle
-
{record.source ?? '—'}
-
-
-
User-Agent
-
{record.userAgent}
-
-
-
- - {/* History Timeline */} -
-
- - Consent-Historie - - {record.history.length} Einträge - -
- -
- {/* Timeline line */} -
- -
- {record.history.map((entry, index) => ( -
- {/* Icon */} -
- {actionIcons[entry.action]} -
- - {/* Content */} -
-
-
-
{actionLabels[entry.action]}
- {entry.documentTitle && ( -
{entry.documentTitle}
- )} -
-
-
v{entry.version}
-
-
- -
-
- - {formatDateTime(entry.timestamp)} -
-
- - {entry.ipAddress} -
-
- -
- Quelle: {entry.source} -
- - {entry.notes && ( -
- {entry.notes} -
- )} - - {/* Expandable User-Agent */} -
- - User-Agent anzeigen - -
- {entry.userAgent} -
-
-
-
- ))} -
-
-
-
- - {/* Footer Actions */} -
-
- Consent-ID: {record.id} -
- -
- {record.status === 'granted' && !showRevokeConfirm && ( - - )} - - {showRevokeConfirm && ( -
- Wirklich widerrufen? - - -
- )} - - -
-
-
-
- ) -} - -// ============================================================================= -// TABLE ROW COMPONENT -// ============================================================================= - -interface ConsentRecordRowProps { - record: ConsentRecord - onShowDetails: (record: ConsentRecord) => void -} - -function ConsentRecordRow({ record, onShowDetails }: ConsentRecordRowProps) { - return ( - - -
{record.email}
-
{record.identifier}
- - - - {typeLabels[record.consentType]} - - - - - {statusLabels[record.status]} - - - - {formatDate(record.grantedAt)} - - - {formatDate(record.withdrawnAt)} - - - v{record.currentVersion} - - -
- - {record.history.length} -
- - - - - - ) -} - -// ============================================================================= -// 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([]) + const { + records, + currentPage, + setCurrentPage, + totalRecords, + globalStats, + handleRevoke, + } = useConsents() + const [filter, setFilter] = useState('all') const [searchQuery, setSearchQuery] = useState('') const [selectedRecord, setSelectedRecord] = useState(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() { {/* Stats */} -
-
-
Gesamt
-
{globalStats.total}
-
-
-
Aktive Einwilligungen
-
{globalStats.active}
-
-
-
Widerrufen
-
{globalStats.revoked}
-
-
-
Versions-Updates
-
{versionUpdates}
-
-
+ {/* Info Banner */}
@@ -702,123 +84,22 @@ export default function EinwilligungenPage() {
{/* Search and Filter */} -
-
- - - - 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" - /> -
-
- {['all', 'granted', 'withdrawn', 'terms', 'privacy', 'cookies', 'marketing', 'analytics'].map(f => ( - - ))} -
-
+ {/* Records Table */} -
-
- - - - - - - - - - - - - - - {filteredRecords.map(record => ( - - ))} - -
NutzerTypStatusErteilt amWiderrufen amVersionHistorieAktion
-
- - {filteredRecords.length === 0 && ( -
-

Keine Einträge gefunden

-

Passen Sie die Suche oder den Filter an.

-
- )} -
+ {/* Pagination */} - {(() => { - const totalPages = Math.ceil(totalRecords / PAGE_SIZE) - return ( -
-

- Zeige {totalRecords === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1}– - {Math.min(currentPage * PAGE_SIZE, totalRecords)} von {totalRecords} Einträgen -

-
- - {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 ( - - ) - })} - -
-
- ) - })()} + {/* Detail Modal */} {selectedRecord && (