complete new audit system

This commit is contained in:
2026-03-21 18:23:54 +01:00
parent 38b3b7da73
commit fd55742c57
159 changed files with 2841 additions and 736 deletions
@@ -1,5 +1,6 @@
import { useState, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { formatDate } from '../../utils/dateFormat';
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, FileText, Download, AlertTriangle, Check, Eye } from 'lucide-react';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
@@ -93,7 +94,7 @@ export default function InvoicesSection({
{/* Collapsed view - show latest invoice */}
{!isExpanded && sortedInvoices.length > 0 && (
<div className="text-sm text-gray-600">
Letzte: {new Date(sortedInvoices[0].invoiceDate).toLocaleDateString('de-DE')} - {invoiceTypeLabels[sortedInvoices[0].invoiceType]}
Letzte: {formatDate(sortedInvoices[0].invoiceDate)} - {invoiceTypeLabels[sortedInvoices[0].invoiceType]}
</div>
)}
@@ -108,7 +109,7 @@ export default function InvoicesSection({
<div className="flex items-center gap-4">
<div>
<div className="text-sm font-medium">
{new Date(invoice.invoiceDate).toLocaleDateString('de-DE')}
{formatDate(invoice.invoiceDate)}
</div>
<div className="text-xs text-gray-500">
{invoiceTypeLabels[invoice.invoiceType]}
@@ -3,6 +3,7 @@ import { Search, FileText } from 'lucide-react';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { contractApi, cachedEmailApi, CachedEmail } from '../../services/api';
import { formatDate } from '../../utils/dateFormat';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
interface AssignToContractModalProps {
@@ -69,9 +70,9 @@ export default function AssignToContractModal({
}
};
const formatDate = (dateStr?: string) => {
const formatDateOrDash = (dateStr?: string) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('de-DE');
return formatDate(dateStr);
};
return (
@@ -147,7 +148,7 @@ export default function AssignToContractModal({
{contract.provider && ` - ${contract.provider.name}`}
</div>
<div className="text-xs text-gray-500">
Start: {formatDate(contract.startDate)}
Start: {formatDateOrDash(contract.startDate)}
</div>
</div>
</div>
@@ -36,6 +36,7 @@ import {
ExternalLink,
CheckCircle2,
} from 'lucide-react';
import { formatDate } from '../../utils/dateFormat';
import type { CockpitContract, CockpitUrgencyLevel, ContractType } from '../../types';
const typeIcons: Record<ContractType, typeof Zap> = {
@@ -570,7 +571,7 @@ export default function ContractCockpit() {
</Badge>
)}
<p className="text-xs text-gray-500 mt-1">
{new Date(alert.expiryDate).toLocaleDateString('de-DE')}
{formatDate(alert.expiryDate)}
</p>
</div>
</div>
@@ -623,7 +624,7 @@ export default function ContractCockpit() {
</div>
<p className="text-xs text-gray-600">
Zähler {reading.meter.meterNumber} <strong>{reading.value} {reading.unit}</strong> am{' '}
{new Date(reading.readingDate).toLocaleDateString('de-DE')}
{formatDate(reading.readingDate)}
{reading.notes && ` ${reading.notes}`}
</p>
</div>
+11 -10
View File
@@ -16,6 +16,7 @@ import FileUpload from '../../components/ui/FileUpload';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield } from 'lucide-react';
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import { formatDate } from '../../utils/dateFormat';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter } from '../../types';
const typeLabels: Record<ContractType, string> = {
@@ -273,8 +274,8 @@ function MeterReadingsSection({
className="flex justify-between items-center text-sm group py-1 border-b border-gray-200 last:border-0"
>
<span className="text-gray-500 flex items-center gap-1">
{new Date(reading.readingDate).toLocaleDateString('de-DE')}
<CopyButton value={new Date(reading.readingDate).toLocaleDateString('de-DE')} />
{formatDate(reading.readingDate)}
<CopyButton value={formatDate(reading.readingDate)} />
</span>
<div className="flex items-center gap-2">
<span className="font-mono flex items-center gap-1">
@@ -315,7 +316,7 @@ function MeterReadingsSection({
{!isExpanded && readings.length > 0 && (
<p className="text-sm text-gray-500">
Letzter Stand: {sortedReadings[0].value.toLocaleString('de-DE')} {sortedReadings[0].unit} ({new Date(sortedReadings[0].readingDate).toLocaleDateString('de-DE')})
Letzter Stand: {sortedReadings[0].value.toLocaleString('de-DE')} {sortedReadings[0].unit} ({formatDate(sortedReadings[0].readingDate)})
</p>
)}
@@ -1033,8 +1034,8 @@ function ContractTaskItem({
<p className="text-xs text-gray-400">
{subtask.createdBy && `${subtask.createdBy}`}
{isSubtaskCompleted
? `Erledigt am ${subtask.completedAt ? new Date(subtask.completedAt).toLocaleDateString('de-DE') : new Date(subtask.updatedAt).toLocaleDateString('de-DE')}`
: new Date(subtask.createdAt).toLocaleDateString('de-DE')}
? `Erledigt am ${subtask.completedAt ? formatDate(subtask.completedAt) : formatDate(subtask.updatedAt)}`
: formatDate(subtask.createdAt)}
</p>
</div>
</div>
@@ -1077,8 +1078,8 @@ function ContractTaskItem({
<p className="text-xs text-gray-400 mt-1">
{task.createdBy && `${task.createdBy}`}
{isCompleted
? `Erledigt am ${task.completedAt ? new Date(task.completedAt).toLocaleDateString('de-DE') : '-'}`
: new Date(task.createdAt).toLocaleDateString('de-DE')}
? `Erledigt am ${task.completedAt ? formatDate(task.completedAt) : '-'}`
: formatDate(task.createdAt)}
</p>
{/* Subtasks */}
@@ -1713,7 +1714,7 @@ export default function ContractDetail() {
{c.nextReviewDate && new Date(c.nextReviewDate) > new Date() && (
<div className="flex items-center gap-1 px-2 py-1 bg-amber-100 text-amber-800 rounded-full text-xs">
<BellOff className="w-3 h-3" />
<span>Zurückgestellt bis {new Date(c.nextReviewDate).toLocaleDateString('de-DE')}</span>
<span>Zurückgestellt bis {formatDate(c.nextReviewDate)}</span>
{hasPermission('contracts:update') && (
<button
onClick={() => setShowUnsnoozeConfirm(true)}
@@ -2616,7 +2617,7 @@ export default function ContractDetail() {
{c.internetDetails.installationDate && (
<div>
<dt className="text-sm text-gray-500">Installation</dt>
<dd>{new Date(c.internetDetails.installationDate).toLocaleDateString('de-DE')}</dd>
<dd>{formatDate(c.internetDetails.installationDate)}</dd>
</div>
)}
{c.internetDetails.homeId && (
@@ -2891,7 +2892,7 @@ export default function ContractDetail() {
<div>
<dt className="text-sm text-gray-500">Erstzulassung</dt>
<dd>
{new Date(c.carInsuranceDetails.firstRegistration).toLocaleDateString('de-DE')}
{formatDate(c.carInsuranceDetails.firstRegistration)}
</dd>
</div>
)}
@@ -9,6 +9,7 @@ import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import type { ContractType } from '../../types';
import { formatDate } from '../../utils/dateFormat';
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft } from 'lucide-react';
// Contract types are now loaded dynamically from the database
@@ -724,7 +725,7 @@ export default function ContractForm() {
{...register('previousContractId')}
options={predecessorContracts.map((c) => ({
value: c.id,
label: `${c.contractNumber} (${c.type}${c.startDate ? ` - ${new Date(c.startDate).toLocaleDateString('de-DE')}` : ''})`,
label: `${c.contractNumber} (${c.type}${c.startDate ? ` - ${formatDate(c.startDate)}` : ''})`,
}))}
placeholder="Keinen Vorgänger auswählen"
/>
@@ -12,6 +12,7 @@ import Badge from '../../components/ui/Badge';
import CopyButton from '../../components/ui/CopyButton';
import { Plus, Search, Eye, Edit, Trash2, User, Users, ChevronDown, ChevronRight, Info, X, ShieldAlert } from 'lucide-react';
import { gdprApi } from '../../services/api';
import { formatDate } from '../../utils/dateFormat';
import type { Contract, ContractType, ContractStatus } from '../../types';
const typeLabels: Record<ContractType, string> = {
@@ -307,9 +308,9 @@ export default function ContractList() {
)}
{contract.startDate && (
<p className={`text-sm text-gray-500 ${isPredecessor ? 'ml-6' : ''}`}>
Beginn: {new Date(contract.startDate).toLocaleDateString('de-DE')}
Beginn: {formatDate(contract.startDate)}
{contract.endDate &&
` | Ende: ${new Date(contract.endDate).toLocaleDateString('de-DE')}`}
` | Ende: ${formatDate(contract.endDate)}`}
</p>
)}
</div>
@@ -491,7 +492,7 @@ export default function ContractList() {
</td>
<td className="py-3 px-4">
{contract.startDate
? new Date(contract.startDate).toLocaleDateString('de-DE')
? formatDate(contract.startDate)
: '-'}
</td>
<td className="py-3 px-4 text-right">
@@ -15,6 +15,7 @@ import Select from '../../components/ui/Select';
import FileUpload from '../../components/ui/FileUpload';
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft } from 'lucide-react';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import { formatDate } from '../../utils/dateFormat';
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types';
export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) {
@@ -887,7 +888,7 @@ function BankCardsTab({
)}
{card.expiryDate && (
<p className="text-sm text-gray-500">
Gültig bis: {new Date(card.expiryDate).toLocaleDateString('de-DE')}
Gültig bis: {formatDate(card.expiryDate)}
</p>
)}
@@ -1378,8 +1379,8 @@ function MetersTab({
{(isExpanded ? sortedReadings : sortedReadings.slice(0, 3)).map((reading) => (
<div key={reading.id} className="flex justify-between items-center text-sm group">
<span className="text-gray-500 flex items-center gap-1">
{new Date(reading.readingDate).toLocaleDateString('de-DE')}
<CopyButton value={new Date(reading.readingDate).toLocaleDateString('de-DE')} />
{formatDate(reading.readingDate)}
<CopyButton value={formatDate(reading.readingDate)} />
</span>
<div className="flex items-center gap-2">
<span className="font-mono flex items-center gap-1">
@@ -1664,9 +1665,9 @@ function ContractsTab({
)}
{contract.startDate && (
<p className={`text-sm text-gray-500 ${isPredecessor ? 'ml-6' : ''}`}>
Beginn: {new Date(contract.startDate).toLocaleDateString('de-DE')}
Beginn: {formatDate(contract.startDate)}
{contract.endDate &&
` | Ende: ${new Date(contract.endDate).toLocaleDateString('de-DE')}`}
` | Ende: ${formatDate(contract.endDate)}`}
</p>
)}
</div>
@@ -3630,8 +3631,8 @@ const CONSENT_TYPE_LABELS: Record<ConsentType, { label: string; description: str
description: 'Grundlegende Verarbeitung personenbezogener Daten zur Vertragserfüllung',
},
MARKETING_EMAIL: {
label: 'E-Mail-Marketing',
description: 'Zusendung von Werbung und Angeboten per E-Mail',
label: 'Elektronisches Marketing',
description: 'Zusendung von Werbung und Angeboten über elektronische Kommunikationswege (E-Mail, Messenger etc.)',
},
MARKETING_PHONE: {
label: 'Telefonmarketing',
@@ -64,17 +64,17 @@ export default function CustomerForm() {
// Only include the fields that can be updated - exclude relations and read-only fields
const submitData: any = {
type: data.type,
salutation: data.salutation || undefined,
salutation: data.salutation || '',
firstName: data.firstName,
lastName: data.lastName,
companyName: data.companyName || undefined,
email: data.email || undefined,
phone: data.phone || undefined,
mobile: data.mobile || undefined,
taxNumber: data.taxNumber || undefined,
commercialRegisterNumber: data.commercialRegisterNumber || undefined,
notes: data.notes || undefined,
birthPlace: data.birthPlace || undefined,
companyName: data.companyName || '',
email: data.email || '',
phone: data.phone || '',
mobile: data.mobile || '',
taxNumber: data.taxNumber || '',
commercialRegisterNumber: data.commercialRegisterNumber || '',
notes: data.notes || '',
birthPlace: data.birthPlace || '',
};
// Handle birthDate - convert non-empty string to ISO string, or null to clear
+2 -2
View File
@@ -18,8 +18,8 @@ const CONSENT_TYPE_LABELS: Record<ConsentType, { label: string; description: str
description: 'Grundlegende Verarbeitung personenbezogener Daten zur Vertragserfüllung',
},
MARKETING_EMAIL: {
label: 'E-Mail-Marketing',
description: 'Zusendung von Werbung und Angeboten per E-Mail',
label: 'Elektronisches Marketing',
description: 'Zusendung von Werbung und Angeboten über elektronische Kommunikationswege (E-Mail, Messenger etc.)',
},
MARKETING_PHONE: {
label: 'Telefonmarketing',
+2 -1
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { publicApi } from '../../services/api';
import { formatDate } from '../../utils/dateFormat';
import { Shield, CheckCircle2, FileDown, Loader2 } from 'lucide-react';
export default function ConsentPage() {
@@ -108,7 +109,7 @@ export default function ConsentPage() {
<span>{c.label}</span>
{c.grantedAt && (
<span className="text-green-500">
(am {new Date(c.grantedAt).toLocaleDateString('de-DE')})
(am {formatDate(c.grantedAt)})
</span>
)}
</div>
+131 -59
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { auditLogApi, AuditLogSearchParams } from '../../services/api';
import type { AuditLog, AuditAction, AuditSensitivity } from '../../types';
@@ -7,7 +7,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, Download, Eye, Shield, ShieldCheck, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { ArrowLeft, Download, Eye, Shield, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react';
const ACTION_OPTIONS = [
{ value: '', label: 'Alle Aktionen' },
@@ -82,6 +82,82 @@ function getSensitivityIcon(sensitivity: AuditSensitivity) {
}
}
const RESOURCE_TYPE_LABELS: Record<string, string> = {
Customer: 'Kunde',
Contract: 'Vertrag',
BankCard: 'Bankverbindung',
IdentityDocument: 'Ausweis',
Address: 'Adresse',
Meter: 'Zähler',
MeterReading: 'Zählerstand',
User: 'Benutzer',
CustomerConsent: 'Einwilligung',
RepresentativeAuthorization: 'Vollmacht',
ContractTask: 'Aufgabe',
EnergyContractDetails: 'Strom/Gas-Details',
EmailProviderConfig: 'E-Mail-Provider',
AppSetting: 'Einstellung',
GDPR: 'Datenschutz',
Authentication: 'Anmeldung',
};
const FIELD_LABELS: Record<string, string> = {
firstName: 'Vorname', lastName: 'Nachname', email: 'E-Mail', phone: 'Telefon', mobile: 'Mobil',
salutation: 'Anrede', companyName: 'Firma', type: 'Typ', notes: 'Notizen',
street: 'Straße', houseNumber: 'Hausnummer', postalCode: 'PLZ', city: 'Stadt', country: 'Land',
meterNumber: 'Zählernummer', location: 'Standort', tariffModel: 'Tarifmodell',
isActive: 'Aktiv', isDefault: 'Standard',
status: 'Status', source: 'Quelle',
contractNumber: 'Vertragsnummer', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
portalUsername: 'Portal-Benutzername', portalEnabled: 'Portal aktiv',
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis', bonus: 'Bonus',
name: 'Name', domain: 'Domain', apiUrl: 'API-URL',
documentNumber: 'Dokumentennummer', expiryDate: 'Ablaufdatum',
iban: 'IBAN', bic: 'BIC', bankName: 'Bank', accountHolder: 'Kontoinhaber',
isGranted: 'Vollmacht erteilt', documentPath: 'Dokument-Pfad',
value: 'Zählerstand', valueNt: 'NT-Zählerstand', readingDate: 'Ablesedatum',
privacyPolicyPath: 'Datenschutz-PDF',
};
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '-';
if (typeof value === 'boolean') return value ? 'Ja' : 'Nein';
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
try { return new Date(value).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); } catch { return value; }
}
return String(value);
}
function renderChangeDiff(before: Record<string, unknown>, after: Record<string, unknown>) {
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
const changes: { field: string; from: unknown; to: unknown }[] = [];
for (const key of allKeys) {
// Technische Felder überspringen
if (['id', 'createdAt', 'updatedAt', 'passwordEncrypted', 'portalPasswordEncrypted', 'portalPasswordHash', 'systemEmailPasswordEncrypted'].includes(key)) continue;
const fromVal = before[key];
const toVal = after[key];
if (JSON.stringify(fromVal) !== JSON.stringify(toVal)) {
changes.push({ field: key, from: fromVal, to: toVal });
}
}
if (changes.length === 0) {
return <p className="text-sm text-gray-500 italic">Keine sichtbaren Änderungen</p>;
}
return changes.map(({ field, from, to }) => (
<div key={field} className="flex items-start gap-3 p-2 bg-gray-50 rounded text-sm">
<span className="font-medium text-gray-700 min-w-[150px]">
{FIELD_LABELS[field] || field}
</span>
<span className="text-red-600 line-through">{formatValue(from)}</span>
<span className="text-gray-400"></span>
<span className="text-green-700 font-medium">{formatValue(to)}</span>
</div>
));
}
interface DetailModalProps {
log: AuditLog;
onClose: () => void;
@@ -126,7 +202,7 @@ function DetailModal({ log, onClose }: DetailModalProps) {
</div>
<div>
<dt className="text-sm text-gray-500">Ressource</dt>
<dd className="font-medium">{log.resourceType} {log.resourceId && `#${log.resourceId}`}</dd>
<dd className="font-medium">{RESOURCE_TYPE_LABELS[log.resourceType] || log.resourceType} {log.resourceId && `#${log.resourceId}`}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Endpoint</dt>
@@ -161,36 +237,33 @@ function DetailModal({ log, onClose }: DetailModalProps) {
{(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>
{before && after ? (
<div className="space-y-2">
{renderChangeDiff(before, after)}
</div>
) : (
<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">Daten</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>
@@ -215,19 +288,6 @@ export default function AuditLogs() {
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;
@@ -239,18 +299,30 @@ export default function AuditLogs() {
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);
if (format === 'csv') {
// CSV direkt als Download
const token = localStorage.getItem('token');
const params = new URLSearchParams();
params.set('format', 'csv');
if (filters.action) params.set('action', filters.action);
if (filters.sensitivity) params.set('sensitivity', filters.sensitivity);
if (filters.resourceType) params.set('resourceType', filters.resourceType);
if (filters.startDate) params.set('startDate', filters.startDate);
if (filters.endDate) params.set('endDate', filters.endDate);
window.open(`/api/audit-logs/export?${params}&token=${token}`, '_blank');
} else {
const result = await auditLogApi.export({ ...filters, format });
const blob = new Blob(
[JSON.stringify(result.data, null, 2)],
{ type: 'application/json' }
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
} catch (error) {
alert(`Export fehlgeschlagen: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
}
@@ -312,11 +384,11 @@ export default function AuditLogs() {
</Button>
<Button variant="secondary" onClick={() => handleExport('json')}>
<Download className="w-4 h-4 mr-2" />
Export
JSON
</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 variant="secondary" onClick={() => handleExport('csv')}>
<Download className="w-4 h-4 mr-2" />
CSV
</Button>
</div>
</div>
+5 -1
View File
@@ -1263,7 +1263,11 @@ export const auditLogApi = {
return res.data;
},
verifyIntegrity: async () => {
const res = await api.post<ApiResponse<{ valid: boolean; errors: string[] }>>('/audit-logs/verify');
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; message: string }>>('/audit-logs/verify');
return res.data;
},
rehash: async () => {
const res = await api.post<ApiResponse<{ rehashedCount: number }>>('/audit-logs/rehash');
return res.data;
},
getRetentionPolicies: async () => {
+25
View File
@@ -0,0 +1,25 @@
/**
* Formatiert ein Datum als dd.mm.yyyy (immer mit führenden Nullen)
*/
export function formatDate(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
/**
* Formatiert ein Datum als dd.mm.yyyy hh:mm
*/
export function formatDateTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}