Files
opencrm/frontend/src/pages/settings/GDPRDashboard.tsx
T

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>
);
}