Разработка админ-панели EventHubFrontAdmin v1.0 #1

This commit is contained in:
2026-05-23 20:43:21 +03:00
parent 36c95b71a4
commit f5961c5529
68 changed files with 10374 additions and 3 deletions
+150
View File
@@ -0,0 +1,150 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Form, Select, Input, message } from 'antd';
import { Link } from 'react-router-dom';
import { useTicket, useUpdateTicket } from '../../hooks/useTickets';
import { useUser } from '../../hooks/useUsers';
import { useAdmin, useAdmins } from '../../hooks/useAdmins';
import dayjs from 'dayjs';
const TicketDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: ticket, isLoading } = useTicket(id || '');
const updateTicket = useUpdateTicket();
const [form] = Form.useForm();
const { data: admins, isLoading: loadingAdmins } = useAdmins({ limit: 1000, offset: 0 });
const reporterId = ticket?.reporter_id;
const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || '');
const assignedId = ticket?.assigned_to;
const { data: assignedAdmin, isLoading: loadingAssigned } = useAdmin(assignedId || '');
useEffect(() => {
if (ticket) {
form.setFieldsValue({
status: ticket.status,
assigned_to: !isBadValue(ticket.assigned_to) ? ticket.assigned_to : undefined,
resolution_note: !isBadValue(ticket.resolution_note) ? ticket.resolution_note : '',
});
}
}, [ticket, form]);
const handleSave = () => {
form.validateFields().then((values) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateTicket.mutate(
{ id: id!, data: payload },
{ onSuccess: () => navigate('/tickets') }
);
});
};
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
const getUserLink = () => {
if (loadingReporter) return <Spin size="small" />;
if (!reporter) return reporterId || '-';
const name = reporter.nickname && !isBadValue(reporter.nickname)
? reporter.nickname
: reporter.email;
return <Link to={`/users/${reporter.id}`}>{name || reporter.id}</Link>;
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!ticket) return <p>Тикет не найден</p>;
return (
<Card title={`Тикет ${ticket.id}`}>
<Descriptions bordered column={1} size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="ID">{ticket.id}</Descriptions.Item>
<Descriptions.Item label="Отправитель">{getUserLink()}</Descriptions.Item>
<Descriptions.Item label="Хеш ошибки">{ticket.error_hash}</Descriptions.Item>
<Descriptions.Item label="Сообщение">{ticket.error_message}</Descriptions.Item>
<Descriptions.Item label="Стектрейс">
<pre style={{ maxHeight: 200, overflow: 'auto' }}>
{isBadValue(ticket.stacktrace) ? '-' : ticket.stacktrace}
</pre>
</Descriptions.Item>
<Descriptions.Item label="Контекст">
{isBadValue(ticket.context) ? '-' : ticket.context}
</Descriptions.Item>
<Descriptions.Item label="Количество">{ticket.count}</Descriptions.Item>
<Descriptions.Item label="Первый раз">{formatDate(ticket.first_seen)}</Descriptions.Item>
<Descriptions.Item label="Последний раз">{formatDate(ticket.last_seen)}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={
ticket.status === 'open' ? 'red' :
ticket.status === 'in_progress' ? 'blue' :
ticket.status === 'resolved' ? 'green' : 'default'
}>
{ticket.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Назначен">
{loadingAssigned ? <Spin size="small" /> : (
assignedAdmin ? (
<Link to={`/admins/${assignedAdmin.id}`}>
{!isBadValue(assignedAdmin.nickname) ? assignedAdmin.nickname : assignedAdmin.email || assignedAdmin.id}
</Link>
) : (isBadValue(ticket.assigned_to) ? '-' : ticket.assigned_to)
)}
</Descriptions.Item>
<Descriptions.Item label="Решение">
{isBadValue(ticket.resolution_note) ? '-' : ticket.resolution_note}
</Descriptions.Item>
</Descriptions>
<Form form={form} layout="vertical" onFinish={handleSave}>
<Form.Item label="Статус" name="status">
<Select>
<Select.Option value="open">open</Select.Option>
<Select.Option value="in_progress">in_progress</Select.Option>
<Select.Option value="resolved">resolved</Select.Option>
<Select.Option value="closed">closed</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Назначить" name="assigned_to">
<Select
placeholder="Выберите администратора"
loading={loadingAdmins}
allowClear
showSearch
optionFilterProp="label"
>
{(admins || []).map((admin) => {
const label = `${admin.email}${admin.nickname && admin.nickname !== '-' ? ` ${admin.nickname}` : ''}`;
return (
<Select.Option key={admin.id} value={admin.id} label={label}>
{label}
</Select.Option>
);
})}
</Select>
</Form.Item>
<Form.Item label="Комментарий решения" name="resolution_note">
<Input.TextArea rows={3} />
</Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={updateTicket.isPending}>
Сохранить
</Button>
<Button onClick={() => navigate('/tickets')}>Назад к списку</Button>
</Space>
</Form>
</Card>
);
};
export default TicketDetailPage;
+158
View File
@@ -0,0 +1,158 @@
import React, { useState } from 'react';
import { Table, Button, Tag, Space, Popconfirm, Tooltip, Spin, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, DeleteOutlined, BugOutlined, ClockCircleOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
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';
const TicketListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<TicketListParams>({ limit: 20, 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 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 handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<Ticket> | SorterResult<Ticket>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current - 1) * pagination.pageSize) || 0,
limit: pagination.pageSize || prev.limit,
}));
};
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}>{status}</Tag>;
},
},
{
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
/>
</Tooltip>
</Popconfirm>
),
},
];
return (
<div>
<h2>Тикеты</h2>
{stats && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Card>
<Statistic title="Открыто" value={stats.open} prefix={<BugOutlined />} styles={{ content: { color: 'red' } }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic title="В работе" value={stats.in_progress} prefix={<ClockCircleOutlined />} styles={{ content: { color: 'blue' } }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic title="Решено" value={stats.resolved} prefix={<CheckCircleOutlined />} styles={{ content: { color: 'green' } }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic title="Закрыто" value={stats.closed} prefix={<CloseCircleOutlined />} />
</Card>
</Col>
</Row>
)}
<Table
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={{
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/>
</div>
);
};
export default TicketListPage;