gdpr audit implemented, email log, vollmachten, pdf delete cancel data privacy and vollmachten, removed message no id card in engergy car, and other contracts that are not telecom contracts, added insert counter for engery
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { auditLogApi, AuditLogSearchParams } from '../../services/api';
|
||||
import type { AuditLog, AuditAction, AuditSensitivity } from '../../types';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
import { ArrowLeft, Download, Eye, Shield, ShieldCheck, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
{ value: '', label: 'Alle Aktionen' },
|
||||
{ value: 'CREATE', label: 'Erstellt' },
|
||||
{ value: 'READ', label: 'Gelesen' },
|
||||
{ value: 'UPDATE', label: 'Aktualisiert' },
|
||||
{ value: 'DELETE', label: 'Gelöscht' },
|
||||
{ value: 'EXPORT', label: 'Exportiert' },
|
||||
{ value: 'ANONYMIZE', label: 'Anonymisiert' },
|
||||
{ value: 'LOGIN', label: 'Login' },
|
||||
{ value: 'LOGOUT', label: 'Logout' },
|
||||
{ value: 'LOGIN_FAILED', label: 'Login fehlgeschlagen' },
|
||||
];
|
||||
|
||||
const SENSITIVITY_OPTIONS = [
|
||||
{ value: '', label: 'Alle Stufen' },
|
||||
{ value: 'LOW', label: 'Niedrig' },
|
||||
{ value: 'MEDIUM', label: 'Mittel' },
|
||||
{ value: 'HIGH', label: 'Hoch' },
|
||||
{ value: 'CRITICAL', label: 'Kritisch' },
|
||||
];
|
||||
|
||||
const RESOURCE_OPTIONS = [
|
||||
{ value: '', label: 'Alle Ressourcen' },
|
||||
{ value: 'Customer', label: 'Kunden' },
|
||||
{ value: 'Contract', label: 'Verträge' },
|
||||
{ value: 'User', label: 'Benutzer' },
|
||||
{ value: 'BankCard', label: 'Bankdaten' },
|
||||
{ value: 'IdentityDocument', label: 'Ausweisdokumente' },
|
||||
{ value: 'Authentication', label: 'Authentifizierung' },
|
||||
{ value: 'CustomerConsent', label: 'Einwilligungen' },
|
||||
{ value: 'GDPR', label: 'DSGVO' },
|
||||
];
|
||||
|
||||
function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getActionColor(action: AuditAction): string {
|
||||
switch (action) {
|
||||
case 'CREATE': return 'bg-green-100 text-green-800';
|
||||
case 'READ': return 'bg-blue-100 text-blue-800';
|
||||
case 'UPDATE': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'DELETE': return 'bg-red-100 text-red-800';
|
||||
case 'EXPORT': return 'bg-purple-100 text-purple-800';
|
||||
case 'ANONYMIZE': return 'bg-orange-100 text-orange-800';
|
||||
case 'LOGIN': return 'bg-teal-100 text-teal-800';
|
||||
case 'LOGOUT': return 'bg-gray-100 text-gray-800';
|
||||
case 'LOGIN_FAILED': return 'bg-red-200 text-red-900';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
}
|
||||
|
||||
function getSensitivityIcon(sensitivity: AuditSensitivity) {
|
||||
switch (sensitivity) {
|
||||
case 'LOW': return <Shield className="w-4 h-4 text-gray-400" />;
|
||||
case 'MEDIUM': return <Shield className="w-4 h-4 text-blue-500" />;
|
||||
case 'HIGH': return <ShieldAlert className="w-4 h-4 text-orange-500" />;
|
||||
case 'CRITICAL': return <ShieldAlert className="w-4 h-4 text-red-500" />;
|
||||
default: return <Shield className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
}
|
||||
|
||||
interface DetailModalProps {
|
||||
log: AuditLog;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function DetailModal({ log, onClose }: DetailModalProps) {
|
||||
const parseChanges = (changes: string | undefined): Record<string, unknown> | null => {
|
||||
if (!changes) return null;
|
||||
try {
|
||||
return JSON.parse(changes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const before = parseChanges(log.changesBefore);
|
||||
const after = parseChanges(log.changesAfter);
|
||||
|
||||
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-3xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold">Audit-Log Details</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Zeitpunkt</dt>
|
||||
<dd className="font-medium">{formatDate(log.createdAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Benutzer</dt>
|
||||
<dd className="font-medium">{log.userEmail}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Aktion</dt>
|
||||
<dd><span className={`px-2 py-1 rounded text-xs font-medium ${getActionColor(log.action)}`}>{log.action}</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Ressource</dt>
|
||||
<dd className="font-medium">{log.resourceType} {log.resourceId && `#${log.resourceId}`}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Endpoint</dt>
|
||||
<dd className="font-mono text-sm">{log.httpMethod} {log.endpoint}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">IP-Adresse</dt>
|
||||
<dd className="font-mono text-sm">{log.ipAddress}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Sensitivität</dt>
|
||||
<dd className="flex items-center gap-2">{getSensitivityIcon(log.sensitivity)} {log.sensitivity}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Dauer</dt>
|
||||
<dd>{log.durationMs ? `${log.durationMs}ms` : '-'}</dd>
|
||||
</div>
|
||||
{log.resourceLabel && (
|
||||
<div className="col-span-2">
|
||||
<dt className="text-sm text-gray-500">Ressource-Bezeichnung</dt>
|
||||
<dd className="font-medium">{log.resourceLabel}</dd>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.changesEncrypted && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg text-sm">
|
||||
Die Änderungsdaten sind verschlüsselt und können nur mit dem Encryption-Key eingesehen werden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(before || after) && !log.changesEncrypted && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-medium mb-3">Änderungen</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{before && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">Vorher</h4>
|
||||
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto max-h-64">
|
||||
{JSON.stringify(before, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{after && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">Nachher</h4>
|
||||
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto max-h-64">
|
||||
{JSON.stringify(after, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.hash && (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<h3 className="font-medium mb-2">Integrität</h3>
|
||||
<div className="text-xs font-mono bg-gray-50 p-2 rounded break-all">
|
||||
<div><span className="text-gray-500">Hash:</span> {log.hash}</div>
|
||||
{log.previousHash && <div className="mt-1"><span className="text-gray-500">Vorheriger:</span> {log.previousHash}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end mt-6 pt-4 border-t">
|
||||
<Button variant="secondary" onClick={onClose}>Schließen</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuditLogs() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(1);
|
||||
const [filters, setFilters] = useState<AuditLogSearchParams>({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
});
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||
|
||||
const { data: logsData, isLoading, refetch } = useQuery({
|
||||
queryKey: ['audit-logs', { ...filters, page }],
|
||||
queryFn: () => auditLogApi.search({ ...filters, page }),
|
||||
});
|
||||
|
||||
const verifyMutation = useMutation({
|
||||
mutationFn: () => auditLogApi.verifyIntegrity(),
|
||||
onSuccess: (result) => {
|
||||
if (result.data?.valid) {
|
||||
alert('Hash-Kette ist intakt. Keine Manipulationen festgestellt.');
|
||||
} else {
|
||||
alert(`Integritätsfehler gefunden:\n${result.data?.errors?.join('\n')}`);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(`Fehler bei der Integritätsprüfung: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
|
||||
},
|
||||
});
|
||||
|
||||
const logs = logsData?.data || [];
|
||||
const pagination = logsData?.pagination;
|
||||
|
||||
const handleFilterChange = (key: keyof AuditLogSearchParams, value: string) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value || undefined }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleExport = async (format: 'json' | 'csv') => {
|
||||
try {
|
||||
const result = await auditLogApi.export({ ...filters, format });
|
||||
const data = result.data;
|
||||
const blob = new Blob(
|
||||
[format === 'json' ? JSON.stringify(data, null, 2) : ''],
|
||||
{ type: format === 'json' ? 'application/json' : 'text/csv' }
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.${format}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
alert(`Export fehlgeschlagen: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
|
||||
}
|
||||
};
|
||||
|
||||
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">Audit-Protokoll</h1>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<Card className="mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||
<Input
|
||||
placeholder="Suche..."
|
||||
value={filters.search || ''}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<Select
|
||||
value={filters.action || ''}
|
||||
onChange={(e) => handleFilterChange('action', e.target.value)}
|
||||
options={ACTION_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
value={filters.resourceType || ''}
|
||||
onChange={(e) => handleFilterChange('resourceType', e.target.value)}
|
||||
options={RESOURCE_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
value={filters.sensitivity || ''}
|
||||
onChange={(e) => handleFilterChange('sensitivity', e.target.value as AuditSensitivity)}
|
||||
options={SENSITIVITY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||
<Input
|
||||
type="date"
|
||||
label="Von"
|
||||
value={filters.startDate || ''}
|
||||
onChange={(e) => handleFilterChange('startDate', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
label="Bis"
|
||||
value={filters.endDate || ''}
|
||||
onChange={(e) => handleFilterChange('endDate', e.target.value)}
|
||||
/>
|
||||
<div></div>
|
||||
<div className="flex gap-2 justify-end items-end">
|
||||
<Button variant="secondary" onClick={() => refetch()}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Aktualisieren
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => handleExport('json')}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => verifyMutation.mutate()} disabled={verifyMutation.isPending}>
|
||||
<ShieldCheck className="w-4 h-4 mr-2" />
|
||||
{verifyMutation.isPending ? 'Prüfe...' : 'Integrität'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tabelle */}
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">Laden...</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">Keine Audit-Logs 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">Zeitpunkt</th>
|
||||
<th className="text-left py-3 px-4">Benutzer</th>
|
||||
<th className="text-left py-3 px-4">Aktion</th>
|
||||
<th className="text-left py-3 px-4">Ressource</th>
|
||||
<th className="text-left py-3 px-4">IP</th>
|
||||
<th className="text-center py-3 px-4"></th>
|
||||
<th className="text-center py-3 px-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 whitespace-nowrap">{formatDate(log.createdAt)}</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="truncate max-w-[200px]" title={log.userEmail}>
|
||||
{log.userEmail}
|
||||
</div>
|
||||
{log.userRole && <div className="text-xs text-gray-500">{log.userRole}</div>}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getActionColor(log.action)}`}>
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div>{log.resourceType}</div>
|
||||
{log.resourceLabel && (
|
||||
<div className="text-xs text-gray-500 truncate max-w-[200px]" title={log.resourceLabel}>
|
||||
{log.resourceLabel}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono text-xs">{log.ipAddress}</td>
|
||||
<td className="py-3 px-4 text-center">{getSensitivityIcon(log.sensitivity)}</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedLog(log)}>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.min(pagination.totalPages, p + 1))}
|
||||
disabled={page === pagination.totalPages}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedLog && (
|
||||
<DetailModal log={selectedLog} onClose={() => setSelectedLog(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import TiptapLink from '@tiptap/extension-link';
|
||||
import { gdprApi } from '../../services/api';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Card from '../../components/ui/Card';
|
||||
import { ArrowLeft, Save, Eye, Bold, Italic, List, ListOrdered, Heading1, Heading2, Heading3, Link as LinkIcon, Undo, Redo, Type } from 'lucide-react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
const DEFAULT_TEMPLATE = `<h1>Vollmacht</h1>
|
||||
|
||||
<p>Hiermit bevollmächtige ich,</p>
|
||||
|
||||
<p><strong>{{vollmachtgeber_vorname}} {{vollmachtgeber_nachname}}</strong><br>
|
||||
Kundennummer: {{vollmachtgeber_kundennummer}}</p>
|
||||
|
||||
<p>den/die</p>
|
||||
|
||||
<p><strong>{{bevollmaechtigter_vorname}} {{bevollmaechtigter_nachname}}</strong><br>
|
||||
Kundennummer: {{bevollmaechtigter_kundennummer}}</p>
|
||||
|
||||
<p>mich in allen Angelegenheiten rund um meine Telekommunikationsverträge bei der Firma Hacker-Net Telekommunikation – Stefan Hacker zu vertreten. Dies umfasst insbesondere:</p>
|
||||
|
||||
<ul>
|
||||
<li>Einsicht in meine Vertragsdaten, Rechnungen und Kundendaten</li>
|
||||
<li>Kommunikation mit dem Kundenservice in meinem Namen</li>
|
||||
<li>Entgegennahme von Informationen zu meinen Verträgen</li>
|
||||
</ul>
|
||||
|
||||
<p>Diese Vollmacht gilt bis auf Widerruf. Ich kann sie jederzeit schriftlich oder über das Kundenportal widerrufen.</p>
|
||||
|
||||
<h2>Datenschutzhinweis</h2>
|
||||
|
||||
<p>Mit der Erteilung dieser Vollmacht erkläre ich mich damit einverstanden, dass die oben genannte bevollmächtigte Person Zugriff auf meine bei Hacker-Net Telekommunikation gespeicherten personenbezogenen Daten erhält. Dies geschieht auf Grundlage meiner ausdrücklichen Einwilligung gemäß Art. 6 Abs. 1 lit. a DSGVO.</p>
|
||||
|
||||
<p>Ich bin darüber informiert, dass ich diese Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen kann.</p>
|
||||
|
||||
<p> </p>
|
||||
|
||||
<p>Oldenburg, den {{datum}}</p>
|
||||
|
||||
<p> </p>
|
||||
|
||||
<p>_______________________________<br>
|
||||
Unterschrift des Vollmachtgebers</p>
|
||||
|
||||
<p style="color: #9ca3af; font-size: 12px; margin-top: 32px;">
|
||||
Hacker-Net Telekommunikation – Stefan Hacker<br>
|
||||
Am Wunderburgpark 5b, 26135 Oldenburg<br>
|
||||
info@hacker-net.de
|
||||
</p>`;
|
||||
|
||||
const PLACEHOLDERS = [
|
||||
{ key: '{{vollmachtgeber_vorname}}', label: 'Vorname (Vollmachtgeber)' },
|
||||
{ key: '{{vollmachtgeber_nachname}}', label: 'Nachname (Vollmachtgeber)' },
|
||||
{ key: '{{vollmachtgeber_kundennummer}}', label: 'Kundennr. (Vollmachtgeber)' },
|
||||
{ key: '{{bevollmaechtigter_vorname}}', label: 'Vorname (Bevollmächtigter)' },
|
||||
{ key: '{{bevollmaechtigter_nachname}}', label: 'Nachname (Bevollmächtigter)' },
|
||||
{ key: '{{bevollmaechtigter_kundennummer}}', label: 'Kundennr. (Bevollmächtigter)' },
|
||||
{ key: '{{datum}}', label: 'Aktuelles Datum' },
|
||||
];
|
||||
|
||||
function MenuBar({ editor }: { editor: ReturnType<typeof useEditor> }) {
|
||||
if (!editor) return null;
|
||||
|
||||
const setLink = useCallback(() => {
|
||||
const previousUrl = editor.getAttributes('link').href;
|
||||
const url = window.prompt('URL eingeben:', previousUrl);
|
||||
if (url === null) return;
|
||||
if (url === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}, [editor]);
|
||||
|
||||
const btnClass = (active: boolean) =>
|
||||
`p-1.5 rounded hover:bg-gray-200 transition-colors ${active ? 'bg-gray-200 text-blue-600' : 'text-gray-600'}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1 p-2 border-b bg-gray-50">
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleBold().run()} className={btnClass(editor.isActive('bold'))} title="Fett">
|
||||
<Bold className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleItalic().run()} className={btnClass(editor.isActive('italic'))} title="Kursiv">
|
||||
<Italic className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} className={btnClass(editor.isActive('heading', { level: 1 }))} title="Überschrift 1">
|
||||
<Heading1 className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} className={btnClass(editor.isActive('heading', { level: 2 }))} title="Überschrift 2">
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} className={btnClass(editor.isActive('heading', { level: 3 }))} title="Überschrift 3">
|
||||
<Heading3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => editor.chain().focus().setParagraph().run()} className={btnClass(editor.isActive('paragraph'))} title="Absatz">
|
||||
<Type className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleBulletList().run()} className={btnClass(editor.isActive('bulletList'))} title="Aufzählung">
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleOrderedList().run()} className={btnClass(editor.isActive('orderedList'))} title="Nummerierung">
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||
<button type="button" onClick={setLink} className={btnClass(editor.isActive('link'))} title="Link">
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||
<button type="button" onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} className="p-1.5 rounded hover:bg-gray-200 text-gray-600 disabled:opacity-30" title="Rückgängig">
|
||||
<Undo className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} className="p-1.5 rounded hover:bg-gray-200 text-gray-600 disabled:opacity-30" title="Wiederherstellen">
|
||||
<Redo className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthorizationTemplateEditor() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const { data: templateData, isLoading } = useQuery({
|
||||
queryKey: ['authorization-template'],
|
||||
queryFn: () => gdprApi.getAuthorizationTemplate(),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (html: string) => gdprApi.updateAuthorizationTemplate(html),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['authorization-template'] });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
// Wenn noch keine Vorlage gespeichert: Default-Template verwenden
|
||||
const initialContent = templateData?.data?.html || DEFAULT_TEMPLATE;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
TiptapLink.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' },
|
||||
}),
|
||||
],
|
||||
content: initialContent,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm max-w-none p-4 min-h-[400px] focus:outline-none',
|
||||
},
|
||||
},
|
||||
}, [initialContent]);
|
||||
|
||||
const insertPlaceholder = (key: string) => {
|
||||
if (editor) {
|
||||
editor.chain().focus().insertContent(key).run();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (editor) {
|
||||
saveMutation.mutate(editor.getHTML());
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
if (confirm('Vorlage auf den Standardtext zurücksetzen? Alle Änderungen gehen verloren.')) {
|
||||
editor?.commands.setContent(DEFAULT_TEMPLATE);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-gray-500">Laden...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<RouterLink to="/settings">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</RouterLink>
|
||||
<h1 className="text-2xl font-bold flex-1">Vollmacht-Vorlage bearbeiten</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetToDefault}
|
||||
>
|
||||
Standardtext laden
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
{showPreview ? 'Editor' : 'Vorschau'}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saveMutation.isPending}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saved ? 'Gespeichert!' : saveMutation.isPending ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Editor / Vorschau */}
|
||||
<div className="lg:col-span-3">
|
||||
{showPreview ? (
|
||||
<Card>
|
||||
<div
|
||||
className="prose prose-sm max-w-none p-4"
|
||||
dangerouslySetInnerHTML={{ __html: editor?.getHTML() || '' }}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="border rounded-lg bg-white overflow-hidden">
|
||||
<MenuBar editor={editor} />
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Platzhalter-Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card title="Platzhalter">
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Klicken Sie auf einen Platzhalter, um ihn an der Cursorposition einzufügen.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{PLACEHOLDERS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => insertPlaceholder(p.key)}
|
||||
className="w-full text-left px-3 py-2 text-sm bg-gray-50 border rounded hover:bg-blue-50 hover:border-blue-300 transition-colors"
|
||||
>
|
||||
<span className="font-mono text-blue-600 text-xs">{p.key}</span>
|
||||
<br />
|
||||
<span className="text-gray-600">{p.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{saveMutation.isError && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
Fehler beim Speichern. Bitte versuchen Sie es erneut.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { appSettingsApi } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Button from '../../components/ui/Button';
|
||||
import { ArrowLeft, Clock, AlertTriangle, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { ArrowLeft, Clock, AlertTriangle, AlertCircle, CheckCircle, CreditCard } from 'lucide-react';
|
||||
|
||||
export default function DeadlineSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -18,6 +18,8 @@ export default function DeadlineSettings() {
|
||||
const [criticalDays, setCriticalDays] = useState('14');
|
||||
const [warningDays, setWarningDays] = useState('42');
|
||||
const [okDays, setOkDays] = useState('90');
|
||||
const [docCriticalDays, setDocCriticalDays] = useState('30');
|
||||
const [docWarningDays, setDocWarningDays] = useState('90');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,6 +27,8 @@ export default function DeadlineSettings() {
|
||||
setCriticalDays(settingsData.data.deadlineCriticalDays || '14');
|
||||
setWarningDays(settingsData.data.deadlineWarningDays || '42');
|
||||
setOkDays(settingsData.data.deadlineOkDays || '90');
|
||||
setDocCriticalDays(settingsData.data.documentExpiryCriticalDays || '30');
|
||||
setDocWarningDays(settingsData.data.documentExpiryWarningDays || '90');
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [settingsData]);
|
||||
@@ -39,7 +43,7 @@ export default function DeadlineSettings() {
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
// Validierung
|
||||
// Validierung Vertragsfristen
|
||||
const critical = parseInt(criticalDays);
|
||||
const warning = parseInt(warningDays);
|
||||
const ok = parseInt(okDays);
|
||||
@@ -54,10 +58,26 @@ export default function DeadlineSettings() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validierung Ausweis-Fristen
|
||||
const docCrit = parseInt(docCriticalDays);
|
||||
const docWarn = parseInt(docWarningDays);
|
||||
|
||||
if (isNaN(docCrit) || isNaN(docWarn)) {
|
||||
alert('Bitte gültige Zahlen für Ausweis-Fristen eingeben');
|
||||
return;
|
||||
}
|
||||
|
||||
if (docCrit >= docWarn) {
|
||||
alert('Ausweis-Fristen: Kritisch muss kleiner als Warnung sein');
|
||||
return;
|
||||
}
|
||||
|
||||
updateMutation.mutate({
|
||||
deadlineCriticalDays: criticalDays,
|
||||
deadlineWarningDays: warningDays,
|
||||
deadlineOkDays: okDays,
|
||||
documentExpiryCriticalDays: docCriticalDays,
|
||||
documentExpiryWarningDays: docWarningDays,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -163,19 +183,79 @@ export default function DeadlineSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t flex justify-between items-center">
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<p className="text-sm text-gray-500">
|
||||
Beispiel: Bei 14/42/90 Tagen wird eine Frist die in 10 Tagen abläuft rot,
|
||||
eine in 30 Tagen gelb, und eine in 60 Tagen grün markiert.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? 'Speichere...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Ausweis-Ablauffristen" className="mt-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<CreditCard className="w-5 h-5 text-gray-500" />
|
||||
<p className="text-gray-600">
|
||||
Ab wann ablaufende Ausweise im Cockpit als Warnung oder kritisch angezeigt werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Kritisch (Rot) */}
|
||||
<div className="flex items-center gap-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<AlertCircle className="w-8 h-8 text-red-500 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<label className="block font-medium text-red-800 mb-1">
|
||||
Kritisch (Rot)
|
||||
</label>
|
||||
<p className="text-sm text-red-600 mb-2">
|
||||
Ausweise die in weniger als X Tagen ablaufen werden rot markiert
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={docCriticalDays}
|
||||
onChange={(e) => handleChange(setDocCriticalDays, e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-red-700">Tage</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warnung (Gelb) */}
|
||||
<div className="flex items-center gap-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<label className="block font-medium text-yellow-800 mb-1">
|
||||
Warnung (Gelb)
|
||||
</label>
|
||||
<p className="text-sm text-yellow-600 mb-2">
|
||||
Ausweise die in weniger als X Tagen ablaufen werden gelb markiert
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={docWarningDays}
|
||||
onChange={(e) => handleChange(setDocWarningDays, e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-yellow-700">Tage</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? 'Speichere...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { emailLogApi, EmailLog } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import { ArrowLeft, CheckCircle2, XCircle, Mail, Server, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
const CONTEXT_LABELS: Record<string, string> = {
|
||||
'consent-link': 'Datenschutz-Link',
|
||||
'authorization-request': 'Vollmacht-Anfrage',
|
||||
'customer-email': 'Kunden-E-Mail',
|
||||
};
|
||||
|
||||
export default function EmailLogs() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [contextFilter, setContextFilter] = useState<string>('');
|
||||
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
|
||||
|
||||
const { data: statsData } = useQuery({
|
||||
queryKey: ['email-log-stats'],
|
||||
queryFn: () => emailLogApi.getStats(),
|
||||
});
|
||||
|
||||
const { data: logsData, isLoading } = useQuery({
|
||||
queryKey: ['email-logs', page, search, statusFilter, contextFilter],
|
||||
queryFn: () => emailLogApi.getLogs({
|
||||
page,
|
||||
limit: 30,
|
||||
success: statusFilter || undefined,
|
||||
search: search || undefined,
|
||||
context: contextFilter || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const stats = statsData?.data;
|
||||
const logs = logsData?.data || [];
|
||||
const pagination = logsData?.pagination;
|
||||
|
||||
const formatDate = (date: string) =>
|
||||
new Date(date).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
|
||||
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">E-Mail-Versandlog</h1>
|
||||
</div>
|
||||
|
||||
{/* Statistiken */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.total}</p>
|
||||
<p className="text-sm text-gray-500">Gesamt</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-green-600">{stats.success}</p>
|
||||
<p className="text-sm text-gray-500">Erfolgreich</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-red-600">{stats.failed}</p>
|
||||
<p className="text-sm text-gray-500">Fehlgeschlagen</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-blue-600">{stats.last24h}</p>
|
||||
<p className="text-sm text-gray-500">Letzte 24h</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<Card className="mb-6">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
placeholder="Suche (Absender, Empfänger, Betreff...)"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="true">Erfolgreich</option>
|
||||
<option value="false">Fehlgeschlagen</option>
|
||||
</select>
|
||||
<select
|
||||
value={contextFilter}
|
||||
onChange={(e) => { setContextFilter(e.target.value); setPage(1); }}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="consent-link">Datenschutz-Link</option>
|
||||
<option value="authorization-request">Vollmacht-Anfrage</option>
|
||||
<option value="customer-email">Kunden-E-Mail</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Log-Tabelle */}
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">Keine E-Mail-Logs vorhanden.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Status</th>
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Zeitpunkt</th>
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Typ</th>
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Von</th>
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">An</th>
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Betreff</th>
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">SMTP</th>
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 px-3">
|
||||
{log.success ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-xs text-gray-500 whitespace-nowrap">
|
||||
{formatDate(log.sentAt)}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">
|
||||
{CONTEXT_LABELS[log.context] || log.context}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-sm truncate max-w-[150px]">{log.fromAddress}</td>
|
||||
<td className="py-2 px-3 text-sm truncate max-w-[150px]">{log.toAddress}</td>
|
||||
<td className="py-2 px-3 text-sm truncate max-w-[200px]">{log.subject}</td>
|
||||
<td className="py-2 px-3 text-xs text-gray-500 whitespace-nowrap">
|
||||
{log.smtpServer}:{log.smtpPort}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<button
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="text-blue-600 hover:underline text-xs"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page >= pagination.totalPages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Detail-Modal */}
|
||||
{selectedLog && (
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={() => setSelectedLog(null)}
|
||||
title="E-Mail-Log Details"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedLog.success ? (
|
||||
<Badge variant="success">Erfolgreich</Badge>
|
||||
) : (
|
||||
<Badge variant="danger">Fehlgeschlagen</Badge>
|
||||
)}
|
||||
<span className="text-sm text-gray-500">{formatDate(selectedLog.sentAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* E-Mail-Details */}
|
||||
<div className="border rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Mail className="w-4 h-4 text-gray-400 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="grid grid-cols-[80px_1fr] gap-1 text-sm">
|
||||
<span className="text-gray-500">Von:</span>
|
||||
<span>{selectedLog.fromAddress}</span>
|
||||
<span className="text-gray-500">An:</span>
|
||||
<span>{selectedLog.toAddress}</span>
|
||||
<span className="text-gray-500">Betreff:</span>
|
||||
<span>{selectedLog.subject}</span>
|
||||
<span className="text-gray-500">Typ:</span>
|
||||
<span>{CONTEXT_LABELS[selectedLog.context] || selectedLog.context}</span>
|
||||
{selectedLog.triggeredBy && (
|
||||
<>
|
||||
<span className="text-gray-500">Ausgelöst von:</span>
|
||||
<span>{selectedLog.triggeredBy}</span>
|
||||
</>
|
||||
)}
|
||||
{selectedLog.messageId && (
|
||||
<>
|
||||
<span className="text-gray-500">Message-ID:</span>
|
||||
<span className="font-mono text-xs break-all">{selectedLog.messageId}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SMTP-Details */}
|
||||
<div className="border rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Server className="w-4 h-4 text-gray-400 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium mb-2">SMTP-Verbindung</h4>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-1 text-sm">
|
||||
<span className="text-gray-500">Server:</span>
|
||||
<span className="font-mono text-xs">{selectedLog.smtpServer}:{selectedLog.smtpPort}</span>
|
||||
<span className="text-gray-500">Verschlüsselung:</span>
|
||||
<span>{selectedLog.smtpEncryption}</span>
|
||||
<span className="text-gray-500">Benutzer:</span>
|
||||
<span className="font-mono text-xs">{selectedLog.smtpUser}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SMTP-Antwort */}
|
||||
{selectedLog.smtpResponse && (
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium mb-2">SMTP-Antwort</h4>
|
||||
<pre className="text-xs bg-gray-50 p-3 rounded overflow-x-auto whitespace-pre-wrap font-mono text-gray-700">
|
||||
{selectedLog.smtpResponse}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fehlermeldung */}
|
||||
{selectedLog.errorMessage && (
|
||||
<div className="border border-red-200 rounded-lg p-4 bg-red-50">
|
||||
<h4 className="text-sm font-medium text-red-800 mb-2">Fehlermeldung</h4>
|
||||
<pre className="text-xs whitespace-pre-wrap font-mono text-red-700">
|
||||
{selectedLog.errorMessage}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
import { ArrowLeft, Plus, Edit, Trash2, Check, X, Wifi, WifiOff, Eye, EyeOff } from 'lucide-react';
|
||||
import { ArrowLeft, Plus, Edit, Trash2, Check, X, Wifi, WifiOff, Eye, EyeOff, Mail } from 'lucide-react';
|
||||
|
||||
const PROVIDER_TYPES = [
|
||||
{ value: 'PLESK', label: 'Plesk' },
|
||||
@@ -36,6 +36,9 @@ interface ProviderFormData {
|
||||
imapEncryption: MailEncryption;
|
||||
smtpEncryption: MailEncryption;
|
||||
allowSelfSignedCerts: boolean;
|
||||
// System-E-Mail
|
||||
systemEmailAddress: string;
|
||||
systemEmailPassword: string;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
@@ -52,6 +55,8 @@ const emptyForm: ProviderFormData = {
|
||||
imapEncryption: 'SSL',
|
||||
smtpEncryption: 'SSL',
|
||||
allowSelfSignedCerts: false,
|
||||
systemEmailAddress: '',
|
||||
systemEmailPassword: '',
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
};
|
||||
@@ -69,6 +74,7 @@ export default function EmailProviders() {
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<ProviderFormData>(emptyForm);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showSystemPassword, setShowSystemPassword] = useState(false);
|
||||
const [modalTestResult, setModalTestResult] = useState<TestResult | null>(null);
|
||||
const [isTestingInModal, setIsTestingInModal] = useState(false);
|
||||
// Test-Status pro Provider in der Liste
|
||||
@@ -128,6 +134,8 @@ export default function EmailProviders() {
|
||||
imapEncryption: config.imapEncryption ?? 'SSL',
|
||||
smtpEncryption: config.smtpEncryption ?? 'SSL',
|
||||
allowSelfSignedCerts: config.allowSelfSignedCerts ?? false,
|
||||
systemEmailAddress: config.systemEmailAddress || '',
|
||||
systemEmailPassword: '', // Passwort wird nicht geladen
|
||||
isActive: config.isActive,
|
||||
isDefault: config.isDefault,
|
||||
});
|
||||
@@ -222,6 +230,7 @@ export default function EmailProviders() {
|
||||
imapEncryption: formData.imapEncryption,
|
||||
smtpEncryption: formData.smtpEncryption,
|
||||
allowSelfSignedCerts: formData.allowSelfSignedCerts,
|
||||
systemEmailAddress: formData.systemEmailAddress,
|
||||
isActive: formData.isActive,
|
||||
isDefault: formData.isDefault,
|
||||
};
|
||||
@@ -231,6 +240,11 @@ export default function EmailProviders() {
|
||||
data.password = formData.password;
|
||||
}
|
||||
|
||||
// System-E-Mail-Passwort nur senden wenn eingegeben
|
||||
if (formData.systemEmailPassword) {
|
||||
(data as any).systemEmailPassword = formData.systemEmailPassword;
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, data });
|
||||
} else {
|
||||
@@ -324,6 +338,10 @@ export default function EmailProviders() {
|
||||
<dt className="text-gray-500">Standard-Weiterleitung</dt>
|
||||
<dd className="truncate">{config.defaultForwardEmail || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">System-E-Mail</dt>
|
||||
<dd className="truncate">{config.systemEmailAddress || <span className="text-amber-500">nicht konfiguriert</span>}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* Test-Ergebnis für diesen Provider */}
|
||||
@@ -547,6 +565,46 @@ export default function EmailProviders() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System-E-Mail */}
|
||||
<div className="pt-4 border-t">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Mail className="w-4 h-4 text-gray-500" />
|
||||
<h4 className="text-sm font-medium text-gray-700">System-E-Mail für automatischen Versand</h4>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Diese E-Mail-Adresse wird für automatisierte Nachrichten verwendet (z.B. DSGVO Consent-Links). Der Kunden-E-Mail-Client wird davon nicht beeinflusst.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="System-E-Mail-Adresse"
|
||||
value={formData.systemEmailAddress}
|
||||
onChange={(e) => setFormData({ ...formData, systemEmailAddress: e.target.value })}
|
||||
placeholder="info@stressfrei-wechseln.de"
|
||||
type="email"
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{editingId ? 'Neues Passwort (leer = beibehalten)' : 'Passwort'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSystemPassword ? 'text' : 'password'}
|
||||
value={formData.systemEmailPassword}
|
||||
onChange={(e) => setFormData({ ...formData, systemEmailPassword: e.target.value })}
|
||||
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSystemPassword(!showSystemPassword)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showSystemPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import { gdprApi } from '../../services/api';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Card from '../../components/ui/Card';
|
||||
import { ArrowLeft, Save, Eye, Bold, Italic, List, ListOrdered, Heading1, Heading2, Heading3, Link as LinkIcon, Undo, Redo, Type } from 'lucide-react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
const PLACEHOLDERS = [
|
||||
{ key: '{{vorname}}', label: 'Vorname' },
|
||||
{ key: '{{nachname}}', label: 'Nachname' },
|
||||
{ key: '{{kundennummer}}', label: 'Kundennummer' },
|
||||
{ key: '{{anrede}}', label: 'Anrede' },
|
||||
{ key: '{{email}}', label: 'E-Mail' },
|
||||
{ key: '{{datum}}', label: 'Aktuelles Datum' },
|
||||
];
|
||||
|
||||
function MenuBar({ editor }: { editor: ReturnType<typeof useEditor> }) {
|
||||
if (!editor) return null;
|
||||
|
||||
const setLink = useCallback(() => {
|
||||
const previousUrl = editor.getAttributes('link').href;
|
||||
const url = window.prompt('URL eingeben:', previousUrl);
|
||||
if (url === null) return;
|
||||
if (url === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}, [editor]);
|
||||
|
||||
const btnClass = (active: boolean) =>
|
||||
`p-1.5 rounded hover:bg-gray-200 transition-colors ${active ? 'bg-gray-200 text-blue-600' : 'text-gray-600'}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1 p-2 border-b bg-gray-50">
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleBold().run()} className={btnClass(editor.isActive('bold'))} title="Fett">
|
||||
<Bold className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleItalic().run()} className={btnClass(editor.isActive('italic'))} title="Kursiv">
|
||||
<Italic className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} className={btnClass(editor.isActive('heading', { level: 1 }))} title="Überschrift 1">
|
||||
<Heading1 className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} className={btnClass(editor.isActive('heading', { level: 2 }))} title="Überschrift 2">
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} className={btnClass(editor.isActive('heading', { level: 3 }))} title="Überschrift 3">
|
||||
<Heading3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => editor.chain().focus().setParagraph().run()} className={btnClass(editor.isActive('paragraph'))} title="Absatz">
|
||||
<Type className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleBulletList().run()} className={btnClass(editor.isActive('bulletList'))} title="Aufzählung">
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => editor.chain().focus().toggleOrderedList().run()} className={btnClass(editor.isActive('orderedList'))} title="Nummerierung">
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||
<button type="button" onClick={setLink} className={btnClass(editor.isActive('link'))} title="Link">
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||
<button type="button" onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} className="p-1.5 rounded hover:bg-gray-200 text-gray-600 disabled:opacity-30" title="Rückgängig">
|
||||
<Undo className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} className="p-1.5 rounded hover:bg-gray-200 text-gray-600 disabled:opacity-30" title="Wiederherstellen">
|
||||
<Redo className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PrivacyPolicyEditor() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const { data: policyData, isLoading } = useQuery({
|
||||
queryKey: ['privacy-policy'],
|
||||
queryFn: () => gdprApi.getPrivacyPolicy(),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (html: string) => gdprApi.updatePrivacyPolicy(html),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['privacy-policy'] });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' },
|
||||
}),
|
||||
],
|
||||
content: policyData?.data?.html || '',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm max-w-none p-4 min-h-[400px] focus:outline-none',
|
||||
},
|
||||
},
|
||||
}, [policyData?.data?.html]);
|
||||
|
||||
const insertPlaceholder = (key: string) => {
|
||||
if (editor) {
|
||||
editor.chain().focus().insertContent(key).run();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (editor) {
|
||||
saveMutation.mutate(editor.getHTML());
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-gray-500">Laden...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<RouterLink to="/settings">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</RouterLink>
|
||||
<h1 className="text-2xl font-bold flex-1">Datenschutzerklärung bearbeiten</h1>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
{showPreview ? 'Editor' : 'Vorschau'}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saveMutation.isPending}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saved ? 'Gespeichert!' : saveMutation.isPending ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Editor / Vorschau */}
|
||||
<div className="lg:col-span-3">
|
||||
{showPreview ? (
|
||||
<Card>
|
||||
<div
|
||||
className="prose prose-sm max-w-none p-4"
|
||||
dangerouslySetInnerHTML={{ __html: editor?.getHTML() || '' }}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="border rounded-lg bg-white overflow-hidden">
|
||||
<MenuBar editor={editor} />
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Platzhalter-Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card title="Platzhalter">
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Klicken Sie auf einen Platzhalter, um ihn an der Cursorposition einzufügen.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{PLACEHOLDERS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => insertPlaceholder(p.key)}
|
||||
className="w-full text-left px-3 py-2 text-sm bg-gray-50 border rounded hover:bg-blue-50 hover:border-blue-300 transition-colors"
|
||||
>
|
||||
<span className="font-mono text-blue-600 text-xs">{p.key}</span>
|
||||
<br />
|
||||
<span className="text-gray-600">{p.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{saveMutation.isError && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
Fehler beim Speichern. Bitte versuchen Sie es erneut.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user