Files
bedolaga-cabinet/src/pages/AdminAuditLog.tsx
Fringg c3cf805827 refactor: load RBAC users from API for audit log user filter
Instead of extracting users from log entries, fetch all users
with RBAC roles via dedicated endpoint for complete user list.
2026-03-07 06:23:39 +03:00

926 lines
33 KiB
TypeScript

import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { rbacApi, AuditLogEntry, AuditLogFilters } from '@/api/rbac';
import { PermissionGate } from '@/components/auth/PermissionGate';
import { usePlatform } from '@/platform/hooks/usePlatform';
// === Icons ===
const BackIcon = () => (
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const DownloadIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
const FilterIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
/>
</svg>
);
const ChevronDownIcon = ({ className }: { className?: string }) => (
<svg
className={className || 'h-4 w-4'}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const RefreshIcon = ({ className }: { className?: string }) => (
<svg
className={className || 'h-4 w-4'}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182"
/>
</svg>
);
const SearchIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
// === Constants ===
const RESOURCE_TYPES = [
'users',
'tickets',
'stats',
'sales_stats',
'broadcasts',
'tariffs',
'promocodes',
'promo_groups',
'promo_offers',
'campaigns',
'partners',
'withdrawals',
'payments',
'payment_methods',
'servers',
'remnawave',
'traffic',
'settings',
'roles',
'audit_log',
'channels',
'ban_system',
'wheel',
'apps',
'email_templates',
'pinned_messages',
'landings',
'updates',
] as const;
const STATUS_OPTIONS = ['success', 'denied', 'error'] as const;
const PAGE_SIZE_OPTIONS = [20, 50, 100] as const;
const AUTO_REFRESH_INTERVAL = 30_000;
interface FiltersState {
userId: string;
action: string;
resource: string;
status: string;
dateFrom: string;
dateTo: string;
}
const INITIAL_FILTERS: FiltersState = {
userId: '',
action: '',
resource: '',
status: '',
dateFrom: '',
dateTo: '',
};
// === Utility functions ===
function translateAction(
action: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
t: any,
): string {
return action
.split(',')
.map((perm: string) => {
const trimmed = perm.trim();
const [section, act] = trimmed.split(':', 2);
if (!section || !act) return trimmed;
const sectionLabel = t(`admin.roles.form.permissionSections.${section}`, section) as string;
const actionLabel = t(`admin.roles.form.permissionActions.${act}`, act) as string;
return `${sectionLabel}: ${actionLabel}`;
})
.join(', ');
}
function formatRelativeTime(
dateString: string,
t: (key: string, opts?: Record<string, unknown>) => string,
): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return t('admin.auditLog.time.justNow');
if (diffMin < 60) return t('admin.auditLog.time.minutesAgo', { count: diffMin });
if (diffHour < 24) return t('admin.auditLog.time.hoursAgo', { count: diffHour });
if (diffDay < 30) return t('admin.auditLog.time.daysAgo', { count: diffDay });
return date.toLocaleDateString();
}
function formatAbsoluteTime(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleString();
}
// === Sub-components ===
interface StatusBadgeProps {
status: string;
label: string;
}
function StatusBadge({ status, label }: StatusBadgeProps) {
const colorMap: Record<string, string> = {
success: 'bg-success-500/20 text-success-400',
denied: 'bg-red-500/20 text-red-400',
error: 'bg-amber-500/20 text-amber-400',
};
return (
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${colorMap[status] || 'bg-dark-600 text-dark-300'}`}
>
{label}
</span>
);
}
interface MethodBadgeProps {
method: string;
}
function MethodBadge({ method }: MethodBadgeProps) {
const colorMap: Record<string, string> = {
GET: 'bg-blue-500/20 text-blue-400',
POST: 'bg-success-500/20 text-success-400',
PUT: 'bg-amber-500/20 text-amber-400',
PATCH: 'bg-amber-500/20 text-amber-400',
DELETE: 'bg-red-500/20 text-red-400',
};
return (
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 font-mono text-xs font-medium ${colorMap[method] || 'bg-dark-600 text-dark-300'}`}
>
{method}
</span>
);
}
interface LogEntryCardProps {
entry: AuditLogEntry;
isExpanded: boolean;
onToggle: () => void;
}
function LogEntryCard({ entry, isExpanded, onToggle }: LogEntryCardProps) {
const { t } = useTranslation();
const status = entry.status;
const method = entry.request_method?.toUpperCase() ?? null;
const requestPath = entry.request_path;
const userName = entry.user_first_name || entry.user_email || t('admin.auditLog.unknownUser');
return (
<div className="rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600">
{/* Main row */}
<button
type="button"
onClick={onToggle}
className="flex w-full flex-col gap-2 p-4 text-left sm:flex-row sm:items-center sm:gap-4"
aria-expanded={isExpanded}
>
{/* Timestamp */}
<div
className="shrink-0 text-sm text-dark-400"
title={formatAbsoluteTime(entry.created_at)}
>
{formatRelativeTime(entry.created_at, t)}
</div>
{/* User */}
<div className="min-w-0 shrink-0">
<span className="text-sm font-medium text-dark-200">{userName}</span>
</div>
{/* Action + status */}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<StatusBadge
status={status}
label={t(`admin.auditLog.status.${status}`, { defaultValue: status })}
/>
<span className="truncate text-sm font-medium text-dark-100">
{translateAction(entry.action, t)}
</span>
</div>
{/* Resource */}
<div className="flex shrink-0 items-center gap-2 text-sm text-dark-400">
<span>
{entry.resource_type
? t(`admin.roles.form.permissionSections.${entry.resource_type}`, entry.resource_type)
: null}
</span>
{entry.resource_id && (
<span className="rounded bg-dark-700 px-1.5 py-0.5 font-mono text-xs text-dark-300">
#{entry.resource_id}
</span>
)}
</div>
{/* Method badge */}
{method && (
<div className="shrink-0">
<MethodBadge method={method} />
</div>
)}
{/* IP */}
{entry.ip_address && (
<div className="shrink-0 font-mono text-xs text-dark-500">{entry.ip_address}</div>
)}
{/* Expand indicator */}
<ChevronDownIcon
className={`h-4 w-4 shrink-0 text-dark-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
/>
</button>
{/* Expanded details */}
{isExpanded && (
<div className="border-t border-dark-700 p-4">
<div className="grid gap-4 sm:grid-cols-2">
{/* User agent */}
{entry.user_agent && (
<div>
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.userAgent')}
</p>
<p className="break-all text-sm text-dark-300">{entry.user_agent}</p>
</div>
)}
{/* Request path */}
{requestPath && (
<div>
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.requestPath')}
</p>
<p className="break-all font-mono text-sm text-dark-300">{requestPath}</p>
</div>
)}
{/* IP Address */}
{entry.ip_address && (
<div>
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.ipAddress')}
</p>
<p className="font-mono text-sm text-dark-300">{entry.ip_address}</p>
</div>
)}
{/* Timestamp */}
<div>
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.timestamp')}
</p>
<p className="text-sm text-dark-300">{formatAbsoluteTime(entry.created_at)}</p>
</div>
{/* Before/after diff */}
{entry.details && 'before' in entry.details && entry.details.before != null && (
<div>
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.before')}
</p>
<pre className="max-h-40 overflow-auto rounded-lg bg-dark-900 p-2 text-xs text-dark-300">
{JSON.stringify(entry.details.before, null, 2)}
</pre>
</div>
)}
{entry.details && 'after' in entry.details && entry.details.after != null && (
<div>
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.after')}
</p>
<pre className="max-h-40 overflow-auto rounded-lg bg-dark-900 p-2 text-xs text-dark-300">
{JSON.stringify(entry.details.after, null, 2)}
</pre>
</div>
)}
{/* Query params */}
{entry.details &&
'query_params' in entry.details &&
entry.details.query_params != null && (
<div className="sm:col-span-2">
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.queryParams')}
</p>
<pre className="max-h-40 overflow-auto rounded-lg bg-dark-900 p-2 text-xs text-dark-300">
{JSON.stringify(entry.details.query_params, null, 2)}
</pre>
</div>
)}
{/* Request body */}
{entry.details &&
'request_body' in entry.details &&
entry.details.request_body != null && (
<div className="sm:col-span-2">
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.requestBody')}
</p>
<pre className="max-h-60 overflow-auto rounded-lg bg-dark-900 p-2 text-xs text-dark-300">
{JSON.stringify(entry.details.request_body, null, 2)}
</pre>
</div>
)}
</div>
{/* Full details JSON */}
{entry.details && (
<div className="mt-4">
<p className="mb-1 text-xs font-medium uppercase text-dark-500">
{t('admin.auditLog.details.fullDetails')}
</p>
<pre className="max-h-60 overflow-auto rounded-lg bg-dark-900 p-3 text-xs text-dark-300">
{JSON.stringify(entry.details, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
// === Main Page ===
export default function AdminAuditLog() {
const { t } = useTranslation();
const navigate = useNavigate();
const { capabilities } = usePlatform();
// Filter state
const [filters, setFilters] = useState<FiltersState>(INITIAL_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<FiltersState>(INITIAL_FILTERS);
const [filtersOpen, setFiltersOpen] = useState(false);
// Pagination
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState<number>(PAGE_SIZE_OPTIONS[0]);
// UI state
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [autoRefresh, setAutoRefresh] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
// Build query params
const queryParams = useMemo((): AuditLogFilters => {
const params: AuditLogFilters = {
limit: pageSize,
offset: page * pageSize,
};
const parsedUserId = parseInt(appliedFilters.userId, 10);
if (!isNaN(parsedUserId) && parsedUserId > 0) {
params.user_id = parsedUserId;
}
if (appliedFilters.action.trim()) {
params.action = appliedFilters.action.trim();
}
if (appliedFilters.resource) {
params.resource_type = appliedFilters.resource;
}
if (appliedFilters.status) {
params.status = appliedFilters.status;
}
if (appliedFilters.dateFrom) {
params.date_from = appliedFilters.dateFrom;
}
if (appliedFilters.dateTo) {
params.date_to = appliedFilters.dateTo;
}
return params;
}, [appliedFilters, page, pageSize]);
// Main query
const { data, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['admin-audit-log', queryParams],
queryFn: () => rbacApi.getAuditLog(queryParams),
refetchInterval: autoRefresh ? AUTO_REFRESH_INTERVAL : false,
});
// RBAC users for filter chips
const { data: rbacUsers } = useQuery({
queryKey: ['rbac-users'],
queryFn: () => rbacApi.getRbacUsers(),
staleTime: 5 * 60 * 1000,
});
const entries = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.ceil(total / pageSize);
// Handlers
const handleApplyFilters = useCallback(() => {
setAppliedFilters({ ...filters });
setPage(0);
setExpandedIds(new Set());
}, [filters]);
const handleClearFilters = useCallback(() => {
setFilters(INITIAL_FILTERS);
setAppliedFilters(INITIAL_FILTERS);
setPage(0);
setExpandedIds(new Set());
}, []);
const handleToggleExpand = useCallback((id: number) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const handleExport = useCallback(async () => {
setExporting(true);
try {
const exportParams: AuditLogFilters = {};
const exportUserId = parseInt(appliedFilters.userId, 10);
if (!isNaN(exportUserId) && exportUserId > 0) exportParams.user_id = exportUserId;
if (appliedFilters.action.trim()) exportParams.action = appliedFilters.action.trim();
if (appliedFilters.resource) exportParams.resource_type = appliedFilters.resource;
if (appliedFilters.status) exportParams.status = appliedFilters.status;
if (appliedFilters.dateFrom) exportParams.date_from = appliedFilters.dateFrom;
if (appliedFilters.dateTo) exportParams.date_to = appliedFilters.dateTo;
const blob = await rbacApi.exportAuditLog(exportParams);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
setExportError(t('admin.auditLog.exportError'));
} finally {
setExporting(false);
}
}, [appliedFilters, t]);
const handlePageSizeChange = useCallback((newSize: number) => {
setPageSize(newSize);
setPage(0);
}, []);
// Status is filtered server-side via query params
const filteredEntries = entries;
const hasActiveFilters = useMemo(() => {
return (
appliedFilters.userId.trim() !== '' ||
appliedFilters.action.trim() !== '' ||
appliedFilters.resource !== '' ||
appliedFilters.status !== '' ||
appliedFilters.dateFrom !== '' ||
appliedFilters.dateTo !== ''
);
}, [appliedFilters]);
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
aria-label={t('admin.auditLog.back')}
>
<BackIcon />
</button>
)}
<div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.auditLog.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.auditLog.subtitle')}</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Auto-refresh toggle */}
<button
onClick={() => setAutoRefresh((prev) => !prev)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm transition-colors ${
autoRefresh
? 'border-accent-500/50 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-400 hover:border-dark-600 hover:text-dark-300'
}`}
title={t('admin.auditLog.autoRefresh.tooltip')}
>
<RefreshIcon className={`h-4 w-4 ${isFetching && autoRefresh ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">{t('admin.auditLog.autoRefresh.label')}</span>
</button>
{/* Manual refresh */}
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-dark-700 bg-dark-800 text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-300 disabled:opacity-50"
title={t('admin.auditLog.refresh')}
>
<RefreshIcon className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
</button>
{/* Export */}
<PermissionGate permission="audit_log:export">
<div className="flex items-center gap-2">
{exportError && <p className="text-sm text-error-400">{exportError}</p>}
<button
onClick={() => {
setExportError(null);
handleExport();
}}
disabled={exporting}
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
<DownloadIcon />
<span className="hidden sm:inline">
{exporting ? t('admin.auditLog.exporting') : t('admin.auditLog.exportCsv')}
</span>
</button>
</div>
</PermissionGate>
</div>
</div>
{/* Filters bar */}
<div className="mb-4 rounded-xl border border-dark-700 bg-dark-800">
<button
type="button"
onClick={() => setFiltersOpen((prev) => !prev)}
className="flex w-full items-center justify-between p-4"
>
<div className="flex items-center gap-2">
<FilterIcon />
<span className="text-sm font-medium text-dark-200">
{t('admin.auditLog.filters.title')}
</span>
{hasActiveFilters && (
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs font-medium text-accent-400">
{t('admin.auditLog.filters.active')}
</span>
)}
</div>
<ChevronDownIcon
className={`h-5 w-5 text-dark-400 transition-transform ${filtersOpen ? 'rotate-180' : ''}`}
/>
</button>
{filtersOpen && (
<div className="border-t border-dark-700 p-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* User filter */}
{rbacUsers && rbacUsers.length > 0 && (
<div className="sm:col-span-2 lg:col-span-3">
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.auditLog.filters.user')}
</label>
<div className="flex flex-wrap gap-2">
{rbacUsers.map((ru) => {
const isSelected = filters.userId === String(ru.user_id);
const displayName =
ru.first_name || ru.email || ru.username || `#${ru.user_id}`;
return (
<button
key={ru.user_id}
type="button"
aria-pressed={isSelected}
onClick={() =>
setFilters((prev) => ({
...prev,
userId: isSelected ? '' : String(ru.user_id),
}))
}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-1 focus-visible:ring-offset-dark-900 ${
isSelected
? 'border-accent-500 bg-accent-500/20 text-accent-300'
: 'border-dark-600 bg-dark-900 text-dark-300 hover:border-dark-500 hover:text-dark-200'
}`}
>
<span
className={`flex h-4 w-4 items-center justify-center rounded border text-xs ${
isSelected
? 'border-accent-500 bg-accent-500 text-white'
: 'border-dark-500 bg-dark-800'
}`}
>
{isSelected && '✓'}
</span>
<span>{displayName}</span>
</button>
);
})}
</div>
</div>
)}
{/* Action search */}
<div>
<label
htmlFor="filter-action"
className="mb-1 block text-sm font-medium text-dark-300"
>
{t('admin.auditLog.filters.action')}
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
<SearchIcon />
</span>
<input
id="filter-action"
type="text"
value={filters.action}
onChange={(e) => setFilters((prev) => ({ ...prev, action: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 py-2 pl-9 pr-3 text-sm text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500"
placeholder={t('admin.auditLog.filters.actionPlaceholder')}
/>
</div>
</div>
{/* Resource type */}
<div>
<label
htmlFor="filter-resource"
className="mb-1 block text-sm font-medium text-dark-300"
>
{t('admin.auditLog.filters.resource')}
</label>
<select
id="filter-resource"
value={filters.resource}
onChange={(e) => setFilters((prev) => ({ ...prev, resource: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500"
>
<option value="">{t('admin.auditLog.filters.allResources')}</option>
{RESOURCE_TYPES.map((type) => (
<option key={type} value={type}>
{t(`admin.roles.form.permissionSections.${type}`, type)}
</option>
))}
</select>
</div>
{/* Status */}
<div>
<label
htmlFor="filter-status"
className="mb-1 block text-sm font-medium text-dark-300"
>
{t('admin.auditLog.filters.status')}
</label>
<select
id="filter-status"
value={filters.status}
onChange={(e) => setFilters((prev) => ({ ...prev, status: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500"
>
<option value="">{t('admin.auditLog.filters.allStatuses')}</option>
{STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{t(`admin.auditLog.status.${status}`)}
</option>
))}
</select>
</div>
{/* Date from */}
<div>
<label
htmlFor="filter-date-from"
className="mb-1 block text-sm font-medium text-dark-300"
>
{t('admin.auditLog.filters.dateFrom')}
</label>
<input
id="filter-date-from"
type="date"
value={filters.dateFrom}
onChange={(e) => setFilters((prev) => ({ ...prev, dateFrom: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500"
/>
</div>
{/* Date to */}
<div>
<label
htmlFor="filter-date-to"
className="mb-1 block text-sm font-medium text-dark-300"
>
{t('admin.auditLog.filters.dateTo')}
</label>
<input
id="filter-date-to"
type="date"
value={filters.dateTo}
onChange={(e) => setFilters((prev) => ({ ...prev, dateTo: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500"
/>
</div>
</div>
{/* Filter actions */}
<div className="mt-4 flex items-center gap-3">
<button
onClick={handleApplyFilters}
className="rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600"
>
{t('admin.auditLog.filters.apply')}
</button>
<button
onClick={handleClearFilters}
className="rounded-lg border border-dark-600 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:border-dark-500 hover:text-dark-200"
>
{t('admin.auditLog.filters.clear')}
</button>
</div>
</div>
)}
</div>
{/* Results summary */}
{!isLoading && !error && (
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-dark-400">
{t('admin.auditLog.totalEntries', { count: total })}
</p>
<div className="flex items-center gap-2">
<label htmlFor="page-size" className="text-sm text-dark-500">
{t('admin.auditLog.pagination.pageSize')}
</label>
<select
id="page-size"
value={pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200 outline-none focus:border-accent-500"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
</div>
)}
{/* Content */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : error ? (
<div className="py-12 text-center">
<p className="text-error-400">{t('admin.auditLog.errors.loadFailed')}</p>
<button
onClick={() => refetch()}
className="mt-3 text-sm text-accent-400 transition-colors hover:text-accent-300"
>
{t('admin.auditLog.errors.retry')}
</button>
</div>
) : filteredEntries.length === 0 ? (
<div className="py-12 text-center">
<p className="text-dark-400">{t('admin.auditLog.noEntries')}</p>
</div>
) : (
<div className="space-y-3">
{filteredEntries.map((entry) => (
<LogEntryCard
key={entry.id}
entry={entry}
isExpanded={expandedIds.has(entry.id)}
onToggle={() => handleToggleExpand(entry.id)}
/>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-6 flex items-center justify-center gap-2">
<button
onClick={() => setPage(0)}
disabled={page === 0}
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('admin.auditLog.pagination.first')}
>
&laquo;
</button>
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('admin.auditLog.pagination.previous')}
>
&lsaquo;
</button>
<span className="px-3 py-2 text-sm text-dark-300">
{t('admin.auditLog.pagination.pageOf', {
current: page + 1,
total: totalPages,
})}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('admin.auditLog.pagination.next')}
>
&rsaquo;
</button>
<button
onClick={() => setPage(totalPages - 1)}
disabled={page >= totalPages - 1}
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('admin.auditLog.pagination.last')}
>
&raquo;
</button>
</div>
)}
</div>
);
}