746 lines
24 KiB
TypeScript
746 lines
24 KiB
TypeScript
/**
|
||
* AuditLog — Admin page
|
||
*
|
||
* Displays the immutable audit trail with filtering, pagination, and CSV export.
|
||
* Uses server-side pagination via the DataGrid's paginationMode="server" prop.
|
||
*
|
||
* Required packages (add to frontend/package.json dependencies):
|
||
* "@mui/x-data-grid": "^6.x || ^7.x"
|
||
* "@mui/x-date-pickers": "^6.x || ^7.x"
|
||
* "date-fns": "^3.x"
|
||
* "@date-io/date-fns": "^3.x" (adapter for MUI date pickers)
|
||
*
|
||
* Install:
|
||
* npm install @mui/x-data-grid @mui/x-date-pickers date-fns @date-io/date-fns
|
||
*
|
||
* Route registration in App.tsx:
|
||
* import AuditLog from './pages/admin/AuditLog';
|
||
* // Inside <Routes>:
|
||
* <Route
|
||
* path="/admin/audit-log"
|
||
* element={
|
||
* <ProtectedRoute requireRole="admin">
|
||
* <AuditLog />
|
||
* </ProtectedRoute>
|
||
* }
|
||
* />
|
||
*/
|
||
|
||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Alert,
|
||
Autocomplete,
|
||
Box,
|
||
Button,
|
||
Chip,
|
||
CircularProgress,
|
||
Container,
|
||
Dialog,
|
||
DialogContent,
|
||
DialogTitle,
|
||
Divider,
|
||
IconButton,
|
||
Paper,
|
||
Skeleton,
|
||
Stack,
|
||
TextField,
|
||
Tooltip,
|
||
Typography,
|
||
} from '@mui/material';
|
||
import {
|
||
DataGrid,
|
||
GridColDef,
|
||
GridPaginationModel,
|
||
GridRenderCellParams,
|
||
GridRowParams,
|
||
} from '@mui/x-data-grid';
|
||
import { de } from 'date-fns/locale';
|
||
import { format, parseISO } from 'date-fns';
|
||
import CloseIcon from '@mui/icons-material/Close';
|
||
import DownloadIcon from '@mui/icons-material/Download';
|
||
import FilterAltIcon from '@mui/icons-material/FilterAlt';
|
||
import DashboardLayout from '../../components/dashboard/DashboardLayout';
|
||
import { api } from '../../services/api';
|
||
import { fromGermanDate } from '../../utils/dateInput';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Types — mirror the backend AuditLogEntry interface
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type AuditAction =
|
||
| 'CREATE' | 'UPDATE' | 'DELETE'
|
||
| 'LOGIN' | 'LOGOUT' | 'EXPORT'
|
||
| 'PERMISSION_DENIED' | 'PASSWORD_CHANGE' | 'ROLE_CHANGE';
|
||
|
||
type AuditResourceType =
|
||
| 'MEMBER' | 'INCIDENT' | 'VEHICLE' | 'EQUIPMENT'
|
||
| 'QUALIFICATION' | 'USER' | 'SYSTEM';
|
||
|
||
interface AuditLogEntry {
|
||
id: string;
|
||
user_id: string | null;
|
||
user_email: string | null;
|
||
action: AuditAction;
|
||
resource_type: AuditResourceType;
|
||
resource_id: string | null;
|
||
old_value: Record<string, unknown> | null;
|
||
new_value: Record<string, unknown> | null;
|
||
ip_address: string | null;
|
||
user_agent: string | null;
|
||
metadata: Record<string, unknown>;
|
||
created_at: string; // ISO string from JSON
|
||
}
|
||
|
||
interface AuditLogPage {
|
||
entries: AuditLogEntry[];
|
||
total: number;
|
||
page: number;
|
||
pages: number;
|
||
}
|
||
|
||
interface AuditFilters {
|
||
userId?: string;
|
||
action?: AuditAction[];
|
||
resourceType?: AuditResourceType[];
|
||
dateFrom?: string;
|
||
dateTo?: string;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ALL_ACTIONS: AuditAction[] = [
|
||
'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT',
|
||
'EXPORT', 'PERMISSION_DENIED', 'PASSWORD_CHANGE', 'ROLE_CHANGE',
|
||
];
|
||
|
||
const ALL_RESOURCE_TYPES: AuditResourceType[] = [
|
||
'MEMBER', 'INCIDENT', 'VEHICLE', 'EQUIPMENT',
|
||
'QUALIFICATION', 'USER', 'SYSTEM',
|
||
];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Action chip colour map
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ACTION_COLORS: Record<AuditAction, 'success' | 'primary' | 'error' | 'warning' | 'default' | 'info'> = {
|
||
CREATE: 'success',
|
||
UPDATE: 'primary',
|
||
DELETE: 'error',
|
||
LOGIN: 'info',
|
||
LOGOUT: 'default',
|
||
EXPORT: 'warning',
|
||
PERMISSION_DENIED:'error',
|
||
PASSWORD_CHANGE: 'warning',
|
||
ROLE_CHANGE: 'warning',
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Utility helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function formatTimestamp(isoString: string): string {
|
||
try {
|
||
return format(parseISO(isoString), 'dd.MM.yyyy HH:mm:ss', { locale: de });
|
||
} catch {
|
||
return isoString;
|
||
}
|
||
}
|
||
|
||
function truncate(value: string | null | undefined, maxLength = 24): string {
|
||
if (!value) return '—';
|
||
return value.length > maxLength ? value.substring(0, maxLength) + '…' : value;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// JSON diff viewer (simple before / after display)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface JsonDiffViewerProps {
|
||
oldValue: Record<string, unknown> | null;
|
||
newValue: Record<string, unknown> | null;
|
||
}
|
||
|
||
const JsonDiffViewer: React.FC<JsonDiffViewerProps> = ({ oldValue, newValue }) => {
|
||
if (!oldValue && !newValue) {
|
||
return <Typography variant="body2" color="text.secondary">Keine Datenaenderung aufgezeichnet.</Typography>;
|
||
}
|
||
|
||
const allKeys = Array.from(
|
||
new Set([
|
||
...Object.keys(oldValue ?? {}),
|
||
...Object.keys(newValue ?? {}),
|
||
])
|
||
).sort();
|
||
|
||
return (
|
||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||
{oldValue && (
|
||
<Box sx={{ flex: 1, minWidth: 240 }}>
|
||
<Typography variant="overline" color="error.main">Vorher</Typography>
|
||
<Paper
|
||
variant="outlined"
|
||
sx={{
|
||
p: 1.5, mt: 0.5,
|
||
backgroundColor: 'error.light',
|
||
fontFamily: 'monospace',
|
||
fontSize: '0.75rem',
|
||
overflowX: 'auto',
|
||
maxHeight: 320,
|
||
}}
|
||
>
|
||
<pre style={{ margin: 0 }}>
|
||
{JSON.stringify(oldValue, null, 2)}
|
||
</pre>
|
||
</Paper>
|
||
</Box>
|
||
)}
|
||
{newValue && (
|
||
<Box sx={{ flex: 1, minWidth: 240 }}>
|
||
<Typography variant="overline" color="success.main">Nachher</Typography>
|
||
<Paper
|
||
variant="outlined"
|
||
sx={{
|
||
p: 1.5, mt: 0.5,
|
||
backgroundColor: 'success.light',
|
||
fontFamily: 'monospace',
|
||
fontSize: '0.75rem',
|
||
overflowX: 'auto',
|
||
maxHeight: 320,
|
||
}}
|
||
>
|
||
<pre style={{ margin: 0 }}>
|
||
{JSON.stringify(newValue, null, 2)}
|
||
</pre>
|
||
</Paper>
|
||
</Box>
|
||
)}
|
||
{/* Highlight changed fields */}
|
||
{oldValue && newValue && allKeys.length > 0 && (
|
||
<Box sx={{ width: '100%', mt: 1 }}>
|
||
<Typography variant="overline">Geaenderte Felder</Typography>
|
||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
|
||
{allKeys.map((key) => {
|
||
const changed =
|
||
JSON.stringify((oldValue as Record<string, unknown>)[key]) !==
|
||
JSON.stringify((newValue as Record<string, unknown>)[key]);
|
||
if (!changed) return null;
|
||
return (
|
||
<Chip
|
||
key={key}
|
||
label={key}
|
||
size="small"
|
||
color="warning"
|
||
variant="outlined"
|
||
/>
|
||
);
|
||
})}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Entry detail dialog
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface EntryDialogProps {
|
||
entry: AuditLogEntry | null;
|
||
onClose: () => void;
|
||
showIp: boolean;
|
||
}
|
||
|
||
const EntryDialog: React.FC<EntryDialogProps> = ({ entry, onClose, showIp }) => {
|
||
if (!entry) return null;
|
||
|
||
return (
|
||
<Dialog open={!!entry} onClose={onClose} maxWidth="md" fullWidth>
|
||
<DialogTitle>
|
||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||
<Typography variant="h6">
|
||
Audit-Eintrag — {entry.action} / {entry.resource_type}
|
||
</Typography>
|
||
<IconButton onClick={onClose} size="small" aria-label="Schliessen">
|
||
<CloseIcon />
|
||
</IconButton>
|
||
</Stack>
|
||
</DialogTitle>
|
||
<DialogContent dividers>
|
||
<Stack spacing={2}>
|
||
<Box>
|
||
<Typography variant="overline">Metadaten</Typography>
|
||
<Box
|
||
sx={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'auto 1fr',
|
||
gap: '4px 16px',
|
||
mt: 0.5,
|
||
}}
|
||
>
|
||
{[
|
||
['Zeitpunkt', formatTimestamp(entry.created_at)],
|
||
['Benutzer', entry.user_email ?? '—'],
|
||
['Benutzer-ID', entry.user_id ?? '—'],
|
||
['Aktion', entry.action],
|
||
['Ressourcentyp', entry.resource_type],
|
||
['Ressourcen-ID', entry.resource_id ?? '—'],
|
||
...(showIp ? [['IP-Adresse', entry.ip_address ?? '—']] : []),
|
||
].map(([label, value]) => (
|
||
<React.Fragment key={label}>
|
||
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
||
{label}:
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ wordBreak: 'break-all' }}>
|
||
{value}
|
||
</Typography>
|
||
</React.Fragment>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
|
||
{Object.keys(entry.metadata ?? {}).length > 0 && (
|
||
<Box>
|
||
<Typography variant="overline">Zusatzdaten</Typography>
|
||
<Paper variant="outlined" sx={{ p: 1, mt: 0.5, fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||
<pre style={{ margin: 0 }}>{JSON.stringify(entry.metadata, null, 2)}</pre>
|
||
</Paper>
|
||
</Box>
|
||
)}
|
||
|
||
<Divider />
|
||
|
||
<Box>
|
||
<Typography variant="overline">Datenaenderung</Typography>
|
||
<Box sx={{ mt: 1 }}>
|
||
<JsonDiffViewer oldValue={entry.old_value} newValue={entry.new_value} />
|
||
</Box>
|
||
</Box>
|
||
</Stack>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filter panel
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface FilterPanelProps {
|
||
filters: AuditFilters;
|
||
onChange: (f: AuditFilters) => void;
|
||
onReset: () => void;
|
||
}
|
||
|
||
const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, onReset }) => {
|
||
return (
|
||
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
|
||
<Stack spacing={2}>
|
||
<Typography variant="subtitle2" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||
<FilterAltIcon fontSize="small" />
|
||
Filter
|
||
</Typography>
|
||
|
||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||
{/* Date range */}
|
||
<TextField
|
||
label="Von"
|
||
size="small"
|
||
placeholder="TT.MM.JJJJ"
|
||
value={filters.dateFrom ?? ''}
|
||
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
|
||
sx={{ minWidth: 160 }}
|
||
InputLabelProps={{ shrink: true }}
|
||
/>
|
||
<TextField
|
||
label="Bis"
|
||
size="small"
|
||
placeholder="TT.MM.JJJJ"
|
||
value={filters.dateTo ?? ''}
|
||
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
|
||
sx={{ minWidth: 160 }}
|
||
InputLabelProps={{ shrink: true }}
|
||
/>
|
||
|
||
{/* Action multi-select */}
|
||
<Autocomplete
|
||
multiple
|
||
options={ALL_ACTIONS}
|
||
value={filters.action ?? []}
|
||
onChange={(_, value) => onChange({ ...filters, action: value as AuditAction[] })}
|
||
renderInput={(params) => (
|
||
<TextField {...params} label="Aktionen" size="small" sx={{ minWidth: 200 }} />
|
||
)}
|
||
renderTags={(value, getTagProps) =>
|
||
value.map((option, index) => (
|
||
<Chip
|
||
{...getTagProps({ index })}
|
||
key={option}
|
||
label={option}
|
||
size="small"
|
||
color={ACTION_COLORS[option]}
|
||
/>
|
||
))
|
||
}
|
||
/>
|
||
|
||
{/* Resource type multi-select */}
|
||
<Autocomplete
|
||
multiple
|
||
options={ALL_RESOURCE_TYPES}
|
||
value={filters.resourceType ?? []}
|
||
onChange={(_, value) => onChange({ ...filters, resourceType: value as AuditResourceType[] })}
|
||
renderInput={(params) => (
|
||
<TextField {...params} label="Ressourcentypen" size="small" sx={{ minWidth: 200 }} />
|
||
)}
|
||
renderTags={(value, getTagProps) =>
|
||
value.map((option, index) => (
|
||
<Chip {...getTagProps({ index })} key={option} label={option} size="small" />
|
||
))
|
||
}
|
||
/>
|
||
|
||
<Button variant="outlined" size="small" onClick={onReset} sx={{ alignSelf: 'flex-end' }}>
|
||
Zuruecksetzen
|
||
</Button>
|
||
</Box>
|
||
</Stack>
|
||
</Paper>
|
||
);
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main page component
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const DEFAULT_FILTERS: AuditFilters = {
|
||
action: [],
|
||
resourceType: [],
|
||
dateFrom: '',
|
||
dateTo: '',
|
||
};
|
||
|
||
const AuditLog: React.FC = () => {
|
||
// Grid state
|
||
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
|
||
page: 0, // DataGrid is 0-based
|
||
pageSize: 25,
|
||
});
|
||
const [rowCount, setRowCount] = useState(0);
|
||
const [rows, setRows] = useState<AuditLogEntry[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Filters
|
||
const [filters, setFilters] = useState<AuditFilters>(DEFAULT_FILTERS);
|
||
const [appliedFilters, setApplied]= useState<AuditFilters>(DEFAULT_FILTERS);
|
||
|
||
// Detail dialog
|
||
const [selectedEntry, setSelected]= useState<AuditLogEntry | null>(null);
|
||
|
||
// Export state
|
||
const [exporting, setExporting] = useState(false);
|
||
|
||
// The admin always sees IPs — toggle this based on role check if needed
|
||
const showIp = true;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Data fetching
|
||
// -------------------------------------------------------------------------
|
||
|
||
const fetchData = useCallback(async (
|
||
pagination: GridPaginationModel,
|
||
f: AuditFilters,
|
||
) => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const params: Record<string, string> = {
|
||
page: String(pagination.page + 1), // convert 0-based to 1-based
|
||
pageSize: String(pagination.pageSize),
|
||
};
|
||
|
||
if (f.dateFrom) {
|
||
const iso = fromGermanDate(f.dateFrom);
|
||
if (iso) params.dateFrom = new Date(iso).toISOString();
|
||
}
|
||
if (f.dateTo) {
|
||
const iso = fromGermanDate(f.dateTo);
|
||
if (iso) params.dateTo = new Date(iso + 'T23:59:59').toISOString();
|
||
}
|
||
if (f.action && f.action.length > 0) {
|
||
params.action = f.action.join(',');
|
||
}
|
||
if (f.resourceType && f.resourceType.length > 0) {
|
||
params.resourceType = f.resourceType.join(',');
|
||
}
|
||
if (f.userId) params.userId = f.userId;
|
||
|
||
const queryString = new URLSearchParams(params).toString();
|
||
const response = await api.get<{ success: boolean; data: AuditLogPage }>(
|
||
`/admin/audit-log?${queryString}`
|
||
);
|
||
|
||
setRows(response.data.data.entries);
|
||
setRowCount(response.data.data.total);
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||
setError(`Audit-Log konnte nicht geladen werden: ${msg}`);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// Fetch when pagination or applied filters change
|
||
useEffect(() => {
|
||
fetchData(paginationModel, appliedFilters);
|
||
}, [paginationModel, appliedFilters, fetchData]);
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Filter handlers
|
||
// -------------------------------------------------------------------------
|
||
|
||
const handleApplyFilters = () => {
|
||
setApplied(filters);
|
||
setPaginationModel((prev) => ({ ...prev, page: 0 }));
|
||
};
|
||
|
||
const handleResetFilters = () => {
|
||
setFilters(DEFAULT_FILTERS);
|
||
setApplied(DEFAULT_FILTERS);
|
||
setPaginationModel((prev) => ({ ...prev, page: 0 }));
|
||
};
|
||
|
||
// -------------------------------------------------------------------------
|
||
// CSV export
|
||
// -------------------------------------------------------------------------
|
||
|
||
const handleExport = async () => {
|
||
setExporting(true);
|
||
try {
|
||
const params: Record<string, string> = {};
|
||
if (appliedFilters.dateFrom) {
|
||
const iso = fromGermanDate(appliedFilters.dateFrom);
|
||
if (iso) params.dateFrom = new Date(iso).toISOString();
|
||
}
|
||
if (appliedFilters.dateTo) {
|
||
const iso = fromGermanDate(appliedFilters.dateTo);
|
||
if (iso) params.dateTo = new Date(iso + 'T23:59:59').toISOString();
|
||
}
|
||
if (appliedFilters.action && appliedFilters.action.length > 0) {
|
||
params.action = appliedFilters.action.join(',');
|
||
}
|
||
if (appliedFilters.resourceType && appliedFilters.resourceType.length > 0) {
|
||
params.resourceType = appliedFilters.resourceType.join(',');
|
||
}
|
||
|
||
const queryString = new URLSearchParams(params).toString();
|
||
const response = await api.get<Blob>(
|
||
`/admin/audit-log/export?${queryString}`,
|
||
{ responseType: 'blob' }
|
||
);
|
||
|
||
const url = URL.createObjectURL(response.data);
|
||
const filename = `audit_log_${format(new Date(), 'yyyy-MM-dd_HH-mm')}.csv`;
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
} catch {
|
||
setError('CSV-Export fehlgeschlagen. Bitte versuchen Sie es erneut.');
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Column definitions
|
||
// -------------------------------------------------------------------------
|
||
|
||
const columns: GridColDef<AuditLogEntry>[] = useMemo(() => [
|
||
{
|
||
field: 'created_at',
|
||
headerName: 'Zeitpunkt',
|
||
width: 160,
|
||
valueFormatter: (value: string) => formatTimestamp(value),
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'user_email',
|
||
headerName: 'Benutzer',
|
||
flex: 1,
|
||
minWidth: 160,
|
||
renderCell: (params: GridRenderCellParams<AuditLogEntry>) =>
|
||
params.value ? (
|
||
<Tooltip title={params.row.user_id ?? ''}>
|
||
<Typography variant="body2" noWrap>{params.value}</Typography>
|
||
</Tooltip>
|
||
) : (
|
||
<Typography variant="body2" color="text.disabled">—</Typography>
|
||
),
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'action',
|
||
headerName: 'Aktion',
|
||
width: 160,
|
||
renderCell: (params: GridRenderCellParams<AuditLogEntry>) => (
|
||
<Chip
|
||
label={params.value as string}
|
||
size="small"
|
||
color={ACTION_COLORS[params.value as AuditAction] ?? 'default'}
|
||
/>
|
||
),
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'resource_type',
|
||
headerName: 'Ressourcentyp',
|
||
width: 140,
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'resource_id',
|
||
headerName: 'Ressourcen-ID',
|
||
width: 130,
|
||
renderCell: (params: GridRenderCellParams<AuditLogEntry>) => (
|
||
<Tooltip title={params.value ?? ''}>
|
||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||
{truncate(params.value, 12)}
|
||
</Typography>
|
||
</Tooltip>
|
||
),
|
||
sortable: false,
|
||
},
|
||
...(showIp ? [{
|
||
field: 'ip_address',
|
||
headerName: 'IP-Adresse',
|
||
width: 140,
|
||
renderCell: (params: GridRenderCellParams<AuditLogEntry>) => (
|
||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||
{params.value ?? '—'}
|
||
</Typography>
|
||
),
|
||
sortable: false,
|
||
} as GridColDef<AuditLogEntry>] : []),
|
||
], [showIp]);
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Loading skeleton
|
||
// -------------------------------------------------------------------------
|
||
|
||
if (loading && rows.length === 0) {
|
||
return (
|
||
<DashboardLayout>
|
||
<Container maxWidth="xl">
|
||
<Skeleton variant="text" width={300} height={48} sx={{ mb: 2 }} />
|
||
<Skeleton variant="rectangular" height={80} sx={{ mb: 2, borderRadius: 1 }} />
|
||
<Skeleton variant="rectangular" height={400} sx={{ borderRadius: 1 }} />
|
||
</Container>
|
||
</DashboardLayout>
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Render
|
||
// -------------------------------------------------------------------------
|
||
|
||
return (
|
||
<DashboardLayout>
|
||
<Container maxWidth="xl">
|
||
{/* Header */}
|
||
<Stack
|
||
direction="row"
|
||
alignItems="center"
|
||
justifyContent="space-between"
|
||
sx={{ mb: 3 }}
|
||
>
|
||
<Box>
|
||
<Typography variant="h4">Audit-Protokoll</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
DSGVO Art. 5(2) — Unveraenderliches Protokoll aller Datenzugriffe
|
||
</Typography>
|
||
</Box>
|
||
<Button
|
||
variant="contained"
|
||
startIcon={exporting ? <CircularProgress size={16} color="inherit" /> : <DownloadIcon />}
|
||
disabled={exporting}
|
||
onClick={handleExport}
|
||
>
|
||
CSV-Export
|
||
</Button>
|
||
</Stack>
|
||
|
||
{/* Error */}
|
||
{error && (
|
||
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>
|
||
{error}
|
||
</Alert>
|
||
)}
|
||
|
||
{/* Filter panel */}
|
||
<FilterPanel
|
||
filters={filters}
|
||
onChange={setFilters}
|
||
onReset={handleResetFilters}
|
||
/>
|
||
|
||
{/* Apply filters button */}
|
||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||
<Button variant="outlined" size="small" onClick={handleApplyFilters}>
|
||
Filter anwenden
|
||
</Button>
|
||
</Box>
|
||
|
||
{/* Data grid */}
|
||
<Paper variant="outlined" sx={{ width: '100%' }}>
|
||
<DataGrid<AuditLogEntry>
|
||
rows={rows}
|
||
columns={columns}
|
||
rowCount={rowCount}
|
||
loading={loading}
|
||
paginationMode="server"
|
||
paginationModel={paginationModel}
|
||
onPaginationModelChange={setPaginationModel}
|
||
pageSizeOptions={[25, 50, 100]}
|
||
disableRowSelectionOnClick={false}
|
||
onRowClick={(params: GridRowParams<AuditLogEntry>) =>
|
||
setSelected(params.row)
|
||
}
|
||
sx={{
|
||
border: 'none',
|
||
'& .MuiDataGrid-row': { cursor: 'pointer' },
|
||
'& .MuiDataGrid-row:hover': {
|
||
backgroundColor: 'action.hover',
|
||
},
|
||
}}
|
||
localeText={{
|
||
noRowsLabel: 'Keine Eintraege gefunden',
|
||
MuiTablePagination: {
|
||
labelRowsPerPage: 'Eintraege pro Seite:',
|
||
labelDisplayedRows: ({ from, to, count }) =>
|
||
`${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}`,
|
||
},
|
||
}}
|
||
autoHeight
|
||
/>
|
||
</Paper>
|
||
|
||
{/* Detail dialog */}
|
||
<EntryDialog
|
||
entry={selectedEntry}
|
||
onClose={() => setSelected(null)}
|
||
showIp={showIp}
|
||
/>
|
||
</Container>
|
||
</DashboardLayout>
|
||
);
|
||
};
|
||
|
||
export default AuditLog;
|