392 lines
16 KiB
TypeScript
392 lines
16 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { gdprApi } from '../../services/api';
|
|
import type { DataDeletionRequest, DeletionRequestStatus } from '../../types';
|
|
import Card from '../../components/ui/Card';
|
|
import Button from '../../components/ui/Button';
|
|
import Select from '../../components/ui/Select';
|
|
import { ArrowLeft, FileText, Users, CheckCircle, Clock, XCircle, AlertTriangle, Download, X, ChevronRight } from 'lucide-react';
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: '', label: 'Alle Status' },
|
|
{ value: 'PENDING', label: 'Ausstehend' },
|
|
{ value: 'IN_PROGRESS', label: 'In Bearbeitung' },
|
|
{ value: 'COMPLETED', label: 'Abgeschlossen' },
|
|
{ value: 'PARTIALLY_COMPLETED', label: 'Teilweise abgeschlossen' },
|
|
{ value: 'REJECTED', label: 'Abgelehnt' },
|
|
];
|
|
|
|
function getStatusBadge(status: DeletionRequestStatus) {
|
|
switch (status) {
|
|
case 'PENDING':
|
|
return <span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-yellow-100 text-yellow-800"><Clock className="w-3 h-3" /> Ausstehend</span>;
|
|
case 'IN_PROGRESS':
|
|
return <span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800"><Clock className="w-3 h-3" /> In Bearbeitung</span>;
|
|
case 'COMPLETED':
|
|
return <span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800"><CheckCircle className="w-3 h-3" /> Abgeschlossen</span>;
|
|
case 'PARTIALLY_COMPLETED':
|
|
return <span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-orange-100 text-orange-800"><AlertTriangle className="w-3 h-3" /> Teilweise</span>;
|
|
case 'REJECTED':
|
|
return <span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-800"><XCircle className="w-3 h-3" /> Abgelehnt</span>;
|
|
default:
|
|
return <span className="px-2 py-1 rounded text-xs bg-gray-100">{status}</span>;
|
|
}
|
|
}
|
|
|
|
function formatDate(date: string): string {
|
|
return new Date(date).toLocaleDateString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
});
|
|
}
|
|
|
|
interface ProcessModalProps {
|
|
request: DataDeletionRequest;
|
|
onClose: () => void;
|
|
onProcess: (action: 'complete' | 'partial' | 'reject', reason?: string) => void;
|
|
isPending: boolean;
|
|
}
|
|
|
|
function ProcessModal({ request, onClose, onProcess, isPending }: ProcessModalProps) {
|
|
const [action, setAction] = useState<'complete' | 'partial' | 'reject'>('complete');
|
|
const [reason, setReason] = useState('');
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xl font-semibold">Löschanfrage bearbeiten</h2>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mb-4 p-4 bg-gray-50 rounded-lg">
|
|
<p className="text-sm text-gray-600">Kunde:</p>
|
|
<p className="font-medium">
|
|
{request.customer?.firstName} {request.customer?.lastName} ({request.customer?.customerNumber})
|
|
</p>
|
|
<p className="text-sm text-gray-600 mt-2">Quelle: {request.requestSource}</p>
|
|
<p className="text-sm text-gray-600">Angefordert: {formatDate(request.requestedAt)}</p>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Aktion</label>
|
|
<div className="space-y-2">
|
|
<label className="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
|
<input
|
|
type="radio"
|
|
name="action"
|
|
checked={action === 'complete'}
|
|
onChange={() => setAction('complete')}
|
|
className="text-blue-600"
|
|
/>
|
|
<div>
|
|
<div className="font-medium">Vollständig löschen</div>
|
|
<div className="text-sm text-gray-500">Alle Kundendaten werden anonymisiert</div>
|
|
</div>
|
|
</label>
|
|
<label className="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
|
<input
|
|
type="radio"
|
|
name="action"
|
|
checked={action === 'partial'}
|
|
onChange={() => setAction('partial')}
|
|
className="text-blue-600"
|
|
/>
|
|
<div>
|
|
<div className="font-medium">Teilweise löschen</div>
|
|
<div className="text-sm text-gray-500">Nur optionale Daten werden gelöscht (aktive Verträge bleiben)</div>
|
|
</div>
|
|
</label>
|
|
<label className="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
|
<input
|
|
type="radio"
|
|
name="action"
|
|
checked={action === 'reject'}
|
|
onChange={() => setAction('reject')}
|
|
className="text-blue-600"
|
|
/>
|
|
<div>
|
|
<div className="font-medium">Ablehnen</div>
|
|
<div className="text-sm text-gray-500">Löschanfrage kann nicht durchgeführt werden</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{(action === 'partial' || action === 'reject') && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Begründung {action === 'reject' && '*'}
|
|
</label>
|
|
<textarea
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
rows={3}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder="Grund für die teilweise Löschung/Ablehnung..."
|
|
required={action === 'reject'}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
|
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
|
<Button
|
|
onClick={() => onProcess(action, reason || undefined)}
|
|
disabled={isPending || (action === 'reject' && !reason)}
|
|
>
|
|
{isPending ? 'Verarbeite...' : 'Durchführen'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function GDPRDashboard() {
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const [statusFilter, setStatusFilter] = useState<DeletionRequestStatus | ''>('');
|
|
const [selectedRequest, setSelectedRequest] = useState<DataDeletionRequest | null>(null);
|
|
|
|
const { data: statsData } = useQuery({
|
|
queryKey: ['gdpr-stats'],
|
|
queryFn: () => gdprApi.getDashboardStats(),
|
|
});
|
|
|
|
const { data: requestsData, isLoading } = useQuery({
|
|
queryKey: ['deletion-requests', statusFilter],
|
|
queryFn: () => gdprApi.getDeletionRequests({ status: statusFilter || undefined }),
|
|
});
|
|
|
|
const { data: consentData } = useQuery({
|
|
queryKey: ['consent-overview'],
|
|
queryFn: () => gdprApi.getConsentOverview(),
|
|
});
|
|
|
|
const processMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: number; data: { processedBy: string; action: 'complete' | 'partial' | 'reject'; retentionReason?: string } }) =>
|
|
gdprApi.processDeletionRequest(id, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['deletion-requests'] });
|
|
queryClient.invalidateQueries({ queryKey: ['gdpr-stats'] });
|
|
setSelectedRequest(null);
|
|
},
|
|
});
|
|
|
|
const stats = statsData?.data;
|
|
const requests = requestsData?.data || [];
|
|
// Backend gibt ein Array mit { type, label, description, granted, withdrawn, pending } zurück
|
|
const consentsList: Array<{ type: string; label?: string; description?: string; granted: number; withdrawn: number; pending: number }> =
|
|
Array.isArray(consentData?.data) ? consentData.data : [];
|
|
|
|
const handleProcess = (action: 'complete' | 'partial' | 'reject', reason?: string) => {
|
|
if (!selectedRequest) return;
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
|
processMutation.mutate({
|
|
id: selectedRequest.id,
|
|
data: {
|
|
processedBy: user.email || 'System',
|
|
action,
|
|
retentionReason: reason,
|
|
},
|
|
});
|
|
};
|
|
|
|
const consentLabels: Record<string, string> = {
|
|
DATA_PROCESSING: 'Datenverarbeitung',
|
|
MARKETING_EMAIL: 'E-Mail-Marketing',
|
|
MARKETING_PHONE: 'Telefonmarketing',
|
|
DATA_SHARING_PARTNER: 'Datenweitergabe',
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center gap-4 mb-6">
|
|
<Button variant="ghost" onClick={() => navigate('/settings')}>
|
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
Zurück
|
|
</Button>
|
|
<h1 className="text-2xl font-bold">DSGVO-Dashboard</h1>
|
|
</div>
|
|
|
|
{/* Statistik-Kacheln */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
|
<Card>
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-3 bg-yellow-100 rounded-lg">
|
|
<Clock className="w-6 h-6 text-yellow-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold">{stats?.deletionRequests.pending ?? '-'}</div>
|
|
<div className="text-sm text-gray-500">Offene Löschanfragen</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-3 bg-green-100 rounded-lg">
|
|
<CheckCircle className="w-6 h-6 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold">{stats?.deletionRequests.completedLast30Days ?? '-'}</div>
|
|
<div className="text-sm text-gray-500">Gelöscht (30 Tage)</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-3 bg-purple-100 rounded-lg">
|
|
<Download className="w-6 h-6 text-purple-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold">{stats?.dataExports.last30Days ?? '-'}</div>
|
|
<div className="text-sm text-gray-500">Datenexporte (30 Tage)</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-3 bg-blue-100 rounded-lg">
|
|
<Users className="w-6 h-6 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold">{stats?.consents.granted ?? '-'}</div>
|
|
<div className="text-sm text-gray-500">Aktive Einwilligungen</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Consent-Übersicht */}
|
|
{consentsList.length > 0 && (
|
|
<Card className="mb-6">
|
|
<h2 className="text-lg font-semibold mb-4">Einwilligungen nach Typ</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{consentsList.map((item) => (
|
|
<div key={item.type} className="p-4 bg-gray-50 rounded-lg">
|
|
<div className="font-medium mb-2">{item.label || consentLabels[item.type] || item.type}</div>
|
|
<div className="flex gap-4 text-sm">
|
|
<div>
|
|
<span className="text-green-600 font-medium">{item.granted}</span>
|
|
<span className="text-gray-500 ml-1">erteilt</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-red-600 font-medium">{item.withdrawn}</span>
|
|
<span className="text-gray-500 ml-1">widerrufen</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-600 font-medium">{item.pending}</span>
|
|
<span className="text-gray-500 ml-1">ausstehend</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Löschanfragen */}
|
|
<Card>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-semibold">Löschanfragen</h2>
|
|
<Select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as DeletionRequestStatus | '')}
|
|
options={STATUS_OPTIONS}
|
|
className="w-48"
|
|
/>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="text-center py-8">Laden...</div>
|
|
) : requests.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-500">Keine Löschanfragen gefunden.</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b">
|
|
<th className="text-left py-3 px-4">Kunde</th>
|
|
<th className="text-left py-3 px-4">Status</th>
|
|
<th className="text-left py-3 px-4">Quelle</th>
|
|
<th className="text-left py-3 px-4">Angefordert</th>
|
|
<th className="text-left py-3 px-4">Bearbeitet</th>
|
|
<th className="text-center py-3 px-4"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{requests.map((request) => (
|
|
<tr key={request.id} className="border-b hover:bg-gray-50">
|
|
<td className="py-3 px-4">
|
|
{request.customer ? (
|
|
<div>
|
|
<div className="font-medium">{request.customer.firstName} {request.customer.lastName}</div>
|
|
<div className="text-xs text-gray-500">{request.customer.customerNumber}</div>
|
|
</div>
|
|
) : (
|
|
<span className="text-gray-400">Kunde #{request.customerId}</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-4">{getStatusBadge(request.status)}</td>
|
|
<td className="py-3 px-4">{request.requestSource}</td>
|
|
<td className="py-3 px-4">
|
|
<div>{formatDate(request.requestedAt)}</div>
|
|
<div className="text-xs text-gray-500">von {request.requestedBy}</div>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
{request.processedAt ? (
|
|
<div>
|
|
<div>{formatDate(request.processedAt)}</div>
|
|
<div className="text-xs text-gray-500">von {request.processedBy}</div>
|
|
</div>
|
|
) : (
|
|
<span className="text-gray-400">-</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-4 text-center">
|
|
{(request.status === 'PENDING' || request.status === 'IN_PROGRESS') && (
|
|
<Button variant="ghost" size="sm" onClick={() => setSelectedRequest(request)}>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
{request.proofDocument && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => window.open(`/api/uploads/${request.proofDocument}`, '_blank')}
|
|
title="Löschnachweis anzeigen"
|
|
>
|
|
<FileText className="w-4 h-4 text-blue-500" />
|
|
</Button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Bearbeitungs-Modal */}
|
|
{selectedRequest && (
|
|
<ProcessModal
|
|
request={selectedRequest}
|
|
onClose={() => setSelectedRequest(null)}
|
|
onProcess={handleProcess}
|
|
isPending={processMutation.isPending}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|