feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
CI / test (push) Failing after 3m9s
CI / push-image (push) Has been skipped
CI / ci-done (push) Failing after 0s

Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
2026-07-14 17:58:33 +03:00
parent de379e28bf
commit fd2c1f10ad
95 changed files with 12699 additions and 4410 deletions
+210 -144
View File
@@ -1,165 +1,231 @@
import React, { useState } from 'react';
import { Table, Button, Tag, Popconfirm, Tooltip, Spin, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, DeleteOutlined, BugOutlined, ClockCircleOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Info, Trash2 } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { useTickets, useDeleteTicket, useTicketStats } from '../../hooks/useTickets';
import { useAdmin } from '../../hooks/useAdmins';
import { Ticket, TicketListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { formatStatusLabel } from '../../utils/statusLabels';
import { useTickets, useDeleteTicket, useTicketStats } from '@/hooks/useTickets';
import { useExploreView } from '@/hooks/useExploreView';
import { useAdmin } from '@/hooks/useAdmins';
import { Ticket, TicketListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
import { ExploreDataView } from '@/components/explore/ExploreDataView';
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
import type { DenseRowModel } from '@/components/explore/DenseRowList';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
const TABLE_KEY = 'tickets';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const ticketStatusVariant = (status: string) => {
if (status === 'open') return 'destructive' as const;
if (status === 'in_progress') return 'default' as const;
if (status === 'resolved') return 'secondary' as const;
return 'outline' as const;
};
function ticketActions(
handlers: { onOpen: () => void; onDelete: () => void },
t: TFunction
): RowAction[] {
return [
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
{
label: t('common.delete'),
icon: <Trash2 className="h-4 w-4" />,
onClick: handlers.onDelete,
destructive: true,
separatorBefore: true,
},
];
}
const AssignedCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loading) return <Skeleton className="h-4 w-20" />;
if (!admin) return <span>{adminId}</span>;
const name = !isBadValue(admin.nickname) ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{!isBadValue(name) ? name : admin.id}</Link>;
};
const TicketListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<TicketListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'last_seen', order: 'desc' });
const { viewMode, setViewMode } = useExploreView();
const [params, setParams] = useState<TicketListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'last_seen',
order: 'desc',
});
const { data, isLoading } = useTickets(params);
const deleteTicket = useDeleteTicket();
const { data: stats } = useTicketStats();
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; ticketId: string | null }>({
open: false,
ticketId: null,
});
// Резолвер администратора для колонки "Назначен"
const AssignedCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loading) return <Spin size="small" />;
if (!admin) return <span>{adminId}</span>;
const name = !isBadValue(admin.nickname) ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{!isBadValue(name) ? name : admin.id}</Link>;
const handleDelete = (id: string) => setDeleteConfirm({ open: true, ticketId: id });
const confirmDelete = () => {
if (!deleteConfirm.ticketId) return;
deleteTicket.mutate(deleteConfirm.ticketId, {
onSuccess: () => setDeleteConfirm({ open: false, ticketId: null }),
});
};
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<Ticket> | SorterResult<Ticket>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => mergeTablePagination({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
};
const exploreStats = useMemo(() => {
if (!stats) return [];
return [
{ label: t('common.total'), value: stats.total_tickets },
{ label: formatStatusLabel('open'), value: stats.open },
{ label: formatStatusLabel('in_progress'), value: stats.in_progress },
{ label: formatStatusLabel('resolved'), value: stats.resolved },
];
}, [stats, t]);
const columns: ColumnsType<Ticket> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу тикета">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/tickets/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Ошибка',
dataIndex: 'error_message',
key: 'error_message',
ellipsis: true,
sorter: true,
render: (text: string) => isBadValue(text) ? '-' : text,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => {
const color =
status === 'open' ? 'red' :
status === 'in_progress' ? 'blue' :
status === 'resolved' ? 'green' : 'default';
return <Tag color={color}>{formatStatusLabel(status)}</Tag>;
const columns = useMemo<ColumnDef<Ticket>[]>(
() => [
{
accessorKey: 'error_message',
header: t('common.error'),
enableSorting: true,
cell: ({ getValue }) => {
const text = getValue<string>();
return isBadValue(text) ? '-' : text;
},
},
},
{
title: 'Назначен',
dataIndex: 'assigned_to',
key: 'assigned_to',
render: (assigned: string) => <AssignedCell adminId={assigned} />,
},
{ title: 'Повторов', dataIndex: 'count', key: 'count', width: 80, sorter: true },
{ title: 'Первый раз', dataIndex: 'first_seen', key: 'first_seen', sorter: true },
{ title: 'Последний', dataIndex: 'last_seen', key: 'last_seen', sorter: true },
{
title: 'Действия',
key: 'actions',
width: 80,
render: (_, record) => (
<Popconfirm
title="Удалить тикет?"
onConfirm={() => deleteTicket.mutate(record.id)}
>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
{
accessorKey: 'status',
header: t('common.status'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={ticketStatusVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{
accessorKey: 'assigned_to',
header: t('common.assigned'),
enableSorting: false,
cell: ({ getValue }) => <AssignedCell adminId={getValue<string | null>()} />,
},
{ accessorKey: 'count', header: t('common.count'), size: 80, enableSorting: true },
{ accessorKey: 'first_seen', header: t('common.firstSeen'), enableSorting: true },
{ accessorKey: 'last_seen', header: t('common.lastSeen'), enableSorting: true },
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={ticketActions({
onOpen: () => navigate(`/tickets/${record.id}`),
onDelete: () => handleDelete(record.id),
}, t)}
/>
</Tooltip>
</Popconfirm>
),
},
];
);
},
},
],
[navigate, t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => {
const errorText = isBadValue(record.error_message) ? record.id : record.error_message;
const title =
typeof errorText === 'string' && errorText.length > 80
? `${errorText.slice(0, 80)}`
: errorText;
return {
id: record.id,
title,
subtitle: record.assigned_to && record.assigned_to !== '-' ? record.assigned_to : undefined,
badges: (
<Badge variant={ticketStatusVariant(record.status)}>
{formatStatusLabel(record.status)}
</Badge>
),
meta: `×${record.count} · ${record.last_seen}`,
onActivate: () => navigate(`/tickets/${record.id}`),
actions: ticketActions({
onOpen: () => navigate(`/tickets/${record.id}`),
onDelete: () => handleDelete(record.id),
}, t),
};
});
}, [data?.data, navigate, t]);
return (
<div>
<h2>Тикеты</h2>
{stats && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Всего тикетов" value={stats.total_tickets} prefix={<BugOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Открыто" value={stats.open} styles={{ content: { color: 'red' } }} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="В работе" value={stats.in_progress} prefix={<ClockCircleOutlined />} styles={{ content: { color: 'blue' } }} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Решено" value={stats.resolved} prefix={<CheckCircleOutlined />} styles={{ content: { color: 'green' } }} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Закрыто" value={stats.closed} prefix={<CloseCircleOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Всего ошибок" value={stats.total_errors} />
</Card>
</Col>
</Row>
)}
<Table
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
/>
</div>
<>
<ExploreListShell
title={t('tickets.title')}
description={
stats
? t('tickets.statsHint', { closed: stats.closed, errors: stats.total_errors })
: t('explore.descActions')
}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
banner={{
text: t('inbox.bannerTickets'),
to: '/inbox/tickets',
actionLabel: t('common.openInbox'),
}}
>
<ExploreDataView
viewMode={viewMode}
columns={columns}
data={data?.data ?? []}
denseRows={denseRows}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</ExploreListShell>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, ticketId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('tickets.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, ticketId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteTicket.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default TicketListPage;
export default TicketListPage;