Files
EventHubFrontAdmin/src/pages/audit/AuditPage.tsx
T
aleksey fd2c1f10ad
CI / test (push) Failing after 3m9s
CI / push-image (push) Has been skipped
CI / ci-done (push) Failing after 0s
feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
Refs EventHub/EventHubFrontAdmin#32
2026-07-14 17:58:33 +03:00

294 lines
12 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Link } from 'react-router-dom';
import { useAudit } from '@/hooks/useAudit';
import { useAdmin, useAdmins } from '@/hooks/useAdmins';
import { useUser } from '@/hooks/useUsers';
import { useEvent } from '@/hooks/useEvents';
import { AuditRecord, AuditListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { DataTable } from '@/components/data-table/DataTable';
import { ListPageHeader } from '@/components/ListPageHeader';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const TABLE_KEY = 'audit';
const ALL = '__all__';
const roleBadgeVariant = (role: string) => {
if (role === 'superadmin') return 'destructive' as const;
if (role === 'admin') return 'default' as const;
if (role === 'moderator') return 'secondary' as const;
return 'outline' as const;
};
const actionBadgeVariant = (action: string) => {
if (action === 'create') return 'success' as const;
if (action === 'delete') return 'destructive' as const;
if (action === 'update') return 'default' as const;
if (action === 'freeze' || action === 'block' || action === 'hide') return 'warning' as const;
return 'outline' as const;
};
const entityTypeBadgeVariant = (type: string) => {
if (type === 'user') return 'default' as const;
if (type === 'event') return 'success' as const;
if (type === 'review') return 'secondary' as const;
if (type === 'report') return 'destructive' as const;
return 'outline' as const;
};
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const toDateInput = (iso: string | undefined): string => {
if (!iso) return '';
return iso.slice(0, 10);
};
const dateToIso = (date: string, endOfDay = false): string | undefined => {
if (!date) return undefined;
const d = new Date(date);
if (endOfDay) d.setHours(23, 59, 59, 999);
else d.setHours(0, 0, 0, 0);
return d.toISOString();
};
const AdminCell: React.FC<{ adminId: string }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId);
if (loading) return <Skeleton className="h-4 w-24" />;
if (!admin) return <span>{adminId}</span>;
const name = admin.nickname && admin.nickname !== '-' ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{name || admin.id}</Link>;
};
const EntityNameCell: React.FC<{ entityType: string; entityId: string }> = ({ entityType, entityId }) => {
const { data: user, isLoading: loadingUser } = useUser(entityType === 'user' ? entityId : '');
const { data: event, isLoading: loadingEvent } = useEvent(entityType === 'event' ? entityId : '');
if (entityType === 'user') {
if (loadingUser) return <Skeleton className="h-4 w-24" />;
if (!user) return <span>{entityId}</span>;
const name = !isBadValue(user.nickname) ? user.nickname : user.email;
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
}
if (entityType === 'event') {
if (loadingEvent) return <Skeleton className="h-4 w-24" />;
if (!event) return <span>{entityId}</span>;
const name = !isBadValue(event.title) ? event.title : event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
if (entityType === 'review') {
return <Link to={`/reviews/${entityId}`}>{entityId}</Link>;
}
return <span>{entityId}</span>;
};
const AuditPage: React.FC = () => {
const { t } = useTranslation();
const [params, setParams] = useState<AuditListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'timestamp',
order: 'desc',
});
const { data, isLoading } = useAudit(params);
const { data: admins, isLoading: loadingAdmins } = useAdmins({ limit: 1000, offset: 0 });
const [uniqueActions, setUniqueActions] = useState<string[]>([]);
const [adminSearch, setAdminSearch] = useState('');
useEffect(() => {
if (data?.data) {
const actions = new Set(data.data.map((record) => record.action).filter(Boolean));
setUniqueActions((prev) => {
const merged = new Set([...prev, ...actions]);
return Array.from(merged).sort();
});
}
}, [data]);
const filteredAdmins = useMemo(() => {
const list = admins?.data || [];
if (!adminSearch.trim()) return list;
const q = adminSearch.toLowerCase();
return list.filter((a) => (a.email || a.id).toLowerCase().includes(q));
}, [admins, adminSearch]);
const columns = useMemo<ColumnDef<AuditRecord>[]>(
() => [
{
id: 'admin',
header: t('audit.admin'),
enableSorting: false,
cell: ({ row }) => <AdminCell adminId={row.original.admin_id} />,
},
{
accessorKey: 'role',
header: t('common.role'),
enableSorting: true,
cell: ({ getValue }) => {
const role = getValue<string>();
return <Badge variant={roleBadgeVariant(role)}>{role}</Badge>;
},
},
{
accessorKey: 'action',
header: t('audit.action'),
enableSorting: true,
cell: ({ getValue }) => {
const action = getValue<string>();
return <Badge variant={actionBadgeVariant(action)}>{action}</Badge>;
},
},
{
accessorKey: 'entity_type',
header: t('common.type'),
enableSorting: true,
cell: ({ getValue }) => {
const type = getValue<string>();
return <Badge variant={entityTypeBadgeVariant(type)}>{type}</Badge>;
},
},
{
id: 'entity_name',
header: t('audit.name'),
enableSorting: false,
cell: ({ row }) => (
<EntityNameCell entityType={row.original.entity_type} entityId={row.original.entity_id} />
),
},
{ accessorKey: 'timestamp', header: t('common.date'), enableSorting: true },
{ accessorKey: 'ip', header: t('common.ip'), enableSorting: false },
{
accessorKey: 'reason',
header: t('common.reason'),
enableSorting: false,
cell: ({ getValue }) => {
const reason = getValue<string>();
return <span className="line-clamp-2">{reason}</span>;
},
},
],
[t]
);
return (
<div className="space-y-6">
<ListPageHeader title={t('audit.title')} description={t('audit.description')} />
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-2">
<Label>{t('audit.searchAdmin')}</Label>
<Input
placeholder={t('audit.searchAdminPlaceholder')}
value={adminSearch}
onChange={(e) => setAdminSearch(e.target.value)}
className="w-[200px]"
/>
</div>
<div className="space-y-2">
<Label>{t('audit.admin')}</Label>
<Select
value={params.admin_id ?? ALL}
onValueChange={(val) =>
setParams((prev) => ({ ...prev, admin_id: val === ALL ? undefined : val, offset: 0 }))
}
disabled={loadingAdmins}
>
<SelectTrigger className="w-[250px]">
<SelectValue placeholder={t('common.selectAdmin')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('common.all')}</SelectItem>
{filteredAdmins.map((admin) => {
const label = admin.email || admin.id;
return (
<SelectItem key={admin.id} value={admin.id}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('audit.action')}</Label>
<Select
value={params.action ?? ALL}
onValueChange={(val) =>
setParams((prev) => ({ ...prev, action: val === ALL ? undefined : val, offset: 0 }))
}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder={t('audit.action')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('common.all')}</SelectItem>
{uniqueActions.map((action) => (
<SelectItem key={action} value={action}>
{action}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('common.dateFrom')}</Label>
<Input
type="date"
value={toDateInput(params.date_from)}
onChange={(e) =>
setParams((prev) => ({
...prev,
date_from: dateToIso(e.target.value, false),
offset: 0,
}))
}
className="w-[160px]"
/>
</div>
<div className="space-y-2">
<Label>{t('common.dateTo')}</Label>
<Input
type="date"
value={toDateInput(params.date_to)}
onChange={(e) =>
setParams((prev) => ({
...prev,
date_to: dateToIso(e.target.value, true),
offset: 0,
}))
}
className="w-[160px]"
/>
</div>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</div>
);
};
export default AuditPage;