Разработка админ-панели 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
+160
View File
@@ -0,0 +1,160 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Modal, Form, Input, Select, message } from 'antd';
import { EditOutlined } from '@ant-design/icons';
import { useAdmin, useUpdateAdmin } from '../../hooks/useAdmins';
import dayjs from 'dayjs';
const AdminDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: admin, isLoading } = useAdmin(id || '');
const updateAdmin = useUpdateAdmin();
const [editModal, setEditModal] = useState(false);
const [form] = Form.useForm();
// Очистка значения
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
// Заполнение формы при открытии
useEffect(() => {
if (editModal && admin) {
setTimeout(() => {
form.setFieldsValue({
nickname: clean(admin.nickname),
email: clean(admin.email),
role: clean(admin.role),
status: clean(admin.status),
timezone: clean(admin.timezone),
language: clean(admin.language),
phone: clean(admin.phone),
});
}, 0);
}
}, [editModal, admin, form]);
const handleSave = () => {
form.validateFields().then((values) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: id!, data: payload },
{
onSuccess: () => {
setEditModal(false);
message.success('Данные обновлены');
},
}
);
});
};
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 roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!admin) return <p>Администратор не найден</p>;
return (
<Card
title={`Администратор ${!isBadValue(admin.nickname) ? admin.nickname : admin.email || admin.id}`}
extra={
<Button icon={<EditOutlined />} onClick={() => setEditModal(true)}>
Редактировать
</Button>
}
>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="ID">{admin.id}</Descriptions.Item>
<Descriptions.Item label="Email">{admin.email}</Descriptions.Item>
<Descriptions.Item label="Ник">{isBadValue(admin.nickname) ? '-' : admin.nickname}</Descriptions.Item>
<Descriptions.Item label="Роль">
<Tag color={roleColors[admin.role] || 'default'}>{admin.role}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={admin.status === 'active' ? 'green' : 'red'}>{admin.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Часовой пояс">{isBadValue(admin.timezone) ? '-' : admin.timezone}</Descriptions.Item>
<Descriptions.Item label="Язык">{isBadValue(admin.language) ? '-' : admin.language}</Descriptions.Item>
<Descriptions.Item label="Телефон">{isBadValue(admin.phone) ? '-' : admin.phone}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">{isBadValue(admin.avatar_url) ? '-' : admin.avatar_url}</Descriptions.Item>
<Descriptions.Item label="Настройки">{isBadValue(admin.preferences) ? '-' : JSON.stringify(admin.preferences)}</Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(admin.last_login)}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(admin.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(admin.updated_at)}</Descriptions.Item>
</Descriptions>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/admins')}>Назад к списку</Button>
<Modal
title="Редактировать администратора"
open={editModal}
onCancel={() => setEditModal(false)}
onOk={handleSave}
confirmLoading={updateAdmin.isPending}
destroyOnHidden
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item name="nickname" label="Ник">
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
<Form.Item name="role" label="Роль">
<Select>
<Select.Option value="superadmin">superadmin</Select.Option>
<Select.Option value="admin">admin</Select.Option>
<Select.Option value="moderator">moderator</Select.Option>
<Select.Option value="support">support</Select.Option>
</Select>
</Form.Item>
<Form.Item name="status" label="Статус">
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="blocked">blocked</Select.Option>
</Select>
</Form.Item>
<Form.Item name="timezone" label="Часовой пояс">
<Select placeholder="Выберите пояс" allowClear>
<Select.Option value="UTC">UTC</Select.Option>
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="language" label="Язык">
<Select placeholder="Выберите язык" allowClear>
<Select.Option value="ru">Русский</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
<Form.Item name="phone" label="Телефон">
<Input placeholder="+7 (999) 123-45-67" />
</Form.Item>
</Form>
</Modal>
</Card>
);
};
export default AdminDetailPage;
+305
View File
@@ -0,0 +1,305 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Input, Select, Tooltip, Spin } from 'antd';
import { InfoCircleOutlined, EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAdmins, useCreateAdmin, useUpdateAdmin, useDeleteAdmin, useAdmin } from '../../hooks/useAdmins';
import { Admin, AdminListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
const AdminListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<AdminListParams>({ limit: 20, offset: 0, sort: 'email', order: 'asc' });
const { data, isLoading } = useAdmins(params);
const createAdmin = useCreateAdmin();
const updateAdmin = useUpdateAdmin();
const deleteAdmin = useDeleteAdmin();
// Модальное окно создания
const [createModal, setCreateModal] = useState(false);
const [createForm] = Form.useForm();
// Модальное окно редактирования
const [editModal, setEditModal] = useState<{ open: boolean; adminId: string | null }>({
open: false,
adminId: null,
});
const { data: editingAdmin, isLoading: loadingAdmin } = useAdmin(editModal.adminId || '');
const [editForm] = Form.useForm();
// Очистка значения от "undefined" и "-"
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
// Заполнение формы редактирования при загрузке данных
useEffect(() => {
if (editModal.open && editingAdmin) {
setTimeout(() => {
editForm.setFieldsValue({
nickname: clean(editingAdmin.nickname),
email: clean(editingAdmin.email),
role: clean(editingAdmin.role),
status: clean(editingAdmin.status),
timezone: clean(editingAdmin.timezone),
language: clean(editingAdmin.language),
phone: clean(editingAdmin.phone),
});
}, 0);
}
}, [editingAdmin, editModal.open, editForm]);
const handleCreate = () => {
createForm.validateFields().then((values) => {
createAdmin.mutate(values, {
onSuccess: () => {
setCreateModal(false);
createForm.resetFields();
},
});
});
};
const handleEdit = (admin: Admin) => {
// Не сбрасываем форму вручную, destroyOnHidden очистит её после предыдущего закрытия
setEditModal({ open: true, adminId: admin.id });
};
const handleUpdate = () => {
editForm.validateFields().then((values) => {
if (!editModal.adminId) return;
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: editModal.adminId, data: payload },
{ onSuccess: () => setEditModal({ open: false, adminId: null }) }
);
});
};
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить администратора?',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteAdmin.mutate(id),
});
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<Admin> | SorterResult<Admin>[]
) => {
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 roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
};
const columns: ColumnsType<Admin> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу администратора">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/admins/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Email',
dataIndex: 'email',
key: 'email',
sorter: true,
ellipsis: true,
},
{
title: 'Ник',
dataIndex: 'nickname',
key: 'nickname',
sorter: true,
ellipsis: true,
},
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
width: 120,
sorter: true,
render: (role: string) => (
<Tag color={roleColors[role] || 'default'}>{role}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : 'red'}>{status}</Tag>
),
},
{
title: 'Последний вход',
dataIndex: 'last_login',
key: 'last_login',
sorter: true,
},
{
title: 'Действия',
key: 'actions',
width: 80,
render: (_, record) => (
<Space>
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record)}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
/>
</Tooltip>
</Space>
),
},
];
return (
<div>
<h2>Администраторы</h2>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModal(true)}
style={{ marginBottom: 16 }}
>
Добавить администратора
</Button>
<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,
}}
/>
{/* Модальное окно создания */}
<Modal
title="Создать администратора"
open={createModal}
onCancel={() => setCreateModal(false)}
onOk={handleCreate}
confirmLoading={createAdmin.isPending}
destroyOnHidden
>
<Form form={createForm} layout="vertical" preserve={false}>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Form.Item name="role" label="Роль" rules={[{ required: true }]}>
<Select>
<Select.Option value="admin">admin</Select.Option>
<Select.Option value="moderator">moderator</Select.Option>
<Select.Option value="support">support</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
{/* Модальное окно редактирования */}
<Modal
title="Редактировать администратора"
open={editModal.open}
onCancel={() => setEditModal({ open: false, adminId: null })}
onOk={handleUpdate}
confirmLoading={updateAdmin.isPending}
destroyOnHidden
>
{loadingAdmin ? (
<Spin />
) : (
<Form form={editForm} layout="vertical" preserve={false}>
<Form.Item name="nickname" label="Ник">
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
<Form.Item name="role" label="Роль">
<Select>
<Select.Option value="superadmin">superadmin</Select.Option>
<Select.Option value="admin">admin</Select.Option>
<Select.Option value="moderator">moderator</Select.Option>
<Select.Option value="support">support</Select.Option>
</Select>
</Form.Item>
<Form.Item name="status" label="Статус">
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="blocked">blocked</Select.Option>
</Select>
</Form.Item>
<Form.Item name="timezone" label="Часовой пояс">
<Select placeholder="Выберите пояс" allowClear>
<Select.Option value="UTC">UTC</Select.Option>
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="language" label="Язык">
<Select placeholder="Выберите язык" allowClear>
<Select.Option value="ru">Русский</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
<Form.Item name="phone" label="Телефон">
<Input placeholder="+7 (999) 123-45-67" />
</Form.Item>
</Form>
)}
</Modal>
</div>
);
};
export default AdminListPage;
+244
View File
@@ -0,0 +1,244 @@
import React, { useState, useEffect } from 'react';
import { Table, Tag, Space, Select, DatePicker, Spin } from 'antd';
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 { useReview } from '../../hooks/useReviews';
import { AuditRecord, AuditListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
const { RangePicker } = DatePicker;
const AuditPage: React.FC = () => {
const [params, setParams] = useState<AuditListParams>({ limit: 20, 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[]>([]);
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 AdminCell: React.FC<{ adminId: string }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId);
if (loading) return <Spin size="small" />;
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 isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
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 : '');
const { data: review, isLoading: loadingReview } = useReview(entityType === 'review' ? entityId : '');
if (entityType === 'user') {
if (loadingUser) return <Spin size="small" />;
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 <Spin size="small" />;
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') {
if (loadingReview) return <Spin size="small" />;
// Для отзыва показываем ссылку на страницу отзыва
return <Link to={`/reviews/${entityId}`}>{entityId}</Link>;
}
// Для остальных типов (calendar, report, ticket, subscription, admin) пока просто ID
return <span>{entityId}</span>;
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<AuditRecord> | SorterResult<AuditRecord>[]
) => {
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 handleDateChange = (dates: any) => {
if (dates) {
setParams({
...params,
date_from: dates[0]?.toISOString(),
date_to: dates[1]?.toISOString(),
offset: 0,
});
} else {
setParams({ ...params, date_from: undefined, date_to: undefined, offset: 0 });
}
};
const roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
};
const actionColors: Record<string, string> = {
create: 'green',
update: 'blue',
delete: 'red',
freeze: 'orange',
unfreeze: 'cyan',
block: 'orange',
unblock: 'cyan',
hide: 'orange',
unhide: 'cyan',
login: 'geekblue',
logout: 'default',
};
const entityTypeColors: Record<string, string> = {
user: 'blue',
event: 'green',
calendar: 'orange',
review: 'purple',
report: 'red',
ticket: 'default',
subscription: 'cyan',
admin: 'magenta',
};
const columns: ColumnsType<AuditRecord> = [
{
title: 'Админ',
key: 'admin',
render: (_, record) => <AdminCell adminId={record.admin_id} />,
},
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
width: 120,
sorter: true,
render: (role: string) => (
<Tag color={roleColors[role] || 'default'}>{role}</Tag>
),
},
{
title: 'Действие',
dataIndex: 'action',
key: 'action',
width: 120,
sorter: true,
render: (action: string) => (
<Tag color={actionColors[action] || 'default'}>{action}</Tag>
),
},
{
title: 'Тип',
dataIndex: 'entity_type',
key: 'entity_type',
width: 120,
sorter: true,
render: (type: string) => (
<Tag color={entityTypeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Наименование',
key: 'entity_name',
render: (_, record) => (
<EntityNameCell entityType={record.entity_type} entityId={record.entity_id} />
),
},
{
title: 'Дата',
dataIndex: 'timestamp',
key: 'timestamp',
sorter: true,
},
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
{
title: 'Причина',
dataIndex: 'reason',
key: 'reason',
ellipsis: true,
},
];
return (
<div>
<h2>Аудит</h2>
<Space style={{ marginBottom: 16 }}>
<Select
placeholder="Администратор"
loading={loadingAdmins}
allowClear
onChange={(val) => setParams(prev => ({ ...prev, admin_id: val || undefined, offset: 0 }))}
style={{ width: 250 }}
showSearch
optionFilterProp="label"
>
{(admins?.data || []).map(admin => {
const label = admin.email || admin.id;
return (
<Select.Option key={admin.id} value={admin.id} label={label}>
{label}
</Select.Option>
);
})}
</Select>
<Select
placeholder="Действие"
allowClear
onChange={(val) => setParams(prev => ({ ...prev, action: val || undefined, offset: 0 }))}
style={{ width: 200 }}
>
{uniqueActions.map(action => (
<Select.Option key={action} value={action}>{action}</Select.Option>
))}
</Select>
<RangePicker
onChange={handleDateChange}
allowClear
/>
</Space>
<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 AuditPage;
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const onFinish = async (values: { email: string; password: string }) => {
console.log('Form submitted', values);
try {
await login(values.email, values.password);
navigate('/dashboard');
} catch (error) {
message.error('Ошибка входа');
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Card title="Вход" style={{ width: 400 }}>
<Form onFinish={onFinish} layout="vertical">
<Form.Item name="email" label="Email" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
Войти
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
};
export default LoginPage;
+133
View File
@@ -0,0 +1,133 @@
import React, { useState } from 'react';
import { Table, Button, Input, Space, Popconfirm, Tooltip, Spin } from 'antd';
import { SearchOutlined, DeleteOutlined } from '@ant-design/icons';
import { Link } from 'react-router-dom';
import { useBannedWords, useAddBannedWord, useRemoveBannedWord, BannedWordListParams } from '../../hooks/useBannedWords';
import { useAdmin } from '../../hooks/useAdmins';
import { BannedWord } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table';
import dayjs from 'dayjs';
const BannedWordsPage: React.FC = () => {
const [params, setParams] = useState<BannedWordListParams>({ limit: 20, offset: 0 });
const { data, isLoading } = useBannedWords(params);
const addWord = useAddBannedWord();
const removeWord = useRemoveBannedWord();
const [newWord, setNewWord] = useState('');
const handleAdd = () => {
if (newWord.trim()) {
addWord.mutate(newWord.trim(), {
onSuccess: () => setNewWord(''),
});
}
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<BannedWord> | SorterResult<BannedWord>[]
) => {
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 AddedByCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loadingAdmin } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loadingAdmin) return <Spin size="small" />;
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 columns: ColumnsType<BannedWord> = [
{
title: 'Слово',
dataIndex: 'word',
key: 'word',
sorter: true,
},
{
title: 'Кем добавлено',
dataIndex: 'added_by',
key: 'added_by',
render: (addedBy: string) => <AddedByCell adminId={addedBy} />,
},
{
title: 'Дата добавления',
dataIndex: 'added_at',
key: 'added_at',
sorter: true,
render: (date: string) => {
if (!date || date === '-' || date === 'undefined') return '-';
return dayjs(date).format('DD.MM.YYYY HH:mm');
},
},
{
title: 'Действия',
key: 'actions',
width: 80,
render: (_, record) => (
<Popconfirm
title="Удалить слово?"
onConfirm={() => removeWord.mutate(record.word)}
>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
/>
</Tooltip>
</Popconfirm>
),
},
];
return (
<div>
<h2>Бан-слова</h2>
<Space style={{ marginBottom: 16 }}>
<Input
placeholder="Новое слово"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
onPressEnter={handleAdd}
style={{ width: 200 }}
/>
<Button type="primary" onClick={handleAdd} loading={addWord.isPending}>
Добавить
</Button>
<Input.Search
placeholder="Поиск по слову"
onSearch={(value) => setParams(prev => ({ ...prev, q: value || undefined, offset: 0 }))}
style={{ width: 200 }}
allowClear
/>
</Space>
<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 BannedWordsPage;
+238
View File
@@ -0,0 +1,238 @@
import React from 'react';
import { Card, Col, Row, Statistic, Spin, Alert, Table, Tag } from 'antd';
import {
UserOutlined,
CalendarOutlined,
StarOutlined,
TeamOutlined,
WarningOutlined,
BugOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { useDashboardStats } from '../../hooks/useDashboard';
import type { ColumnsType } from 'antd/es/table';
import { AdminActivity } from '../../types/api';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
AreaChart,
Area,
} from 'recharts';
const DashboardPage: React.FC = () => {
const { data, isLoading, error } = useDashboardStats();
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (error) return <Alert type="error" message="Ошибка загрузки статистики" />;
if (!data) return null;
const roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
};
const adminColumns: ColumnsType<AdminActivity> = [
{ title: 'Email', dataIndex: 'email', key: 'email' },
{ title: 'Ник', dataIndex: 'nickname', key: 'nickname' },
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
render: (role: string) => (
<Tag color={roleColors[role] || 'default'}>{role}</Tag>
),
},
{ title: 'Действий', dataIndex: 'actions', key: 'actions' },
{ title: 'Последний вход', dataIndex: 'last_login', key: 'last_login' },
];
const eventsChartData = data.events_by_day?.map(item => ({
date: item.date,
events: item.count,
})) || [];
const registrationsChartData = data.registrations_by_day?.map(item => ({
date: item.date,
registrations: item.count,
})) || [];
return (
<div>
<h2>Общая статистика</h2>
<Row gutter={[16, 16]}>
{/* Пользователи с мини-графиком */}
<Col xs={24} sm={12} lg={6}>
<Card>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Statistic
title="Пользователи"
value={data.users_total}
prefix={<UserOutlined />}
style={{ flex: '0 0 auto', marginRight: 16 }}
/>
{registrationsChartData.length > 0 && (
<AreaChart
width={150}
height={50}
data={registrationsChartData}
margin={{ top: 5, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorRegistrations" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#52c41a" stopOpacity={0.4} />
<stop offset="95%" stopColor="#52c41a" stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis dataKey="date" hide />
<Tooltip labelFormatter={(label) => `Дата: ${label}`} />
<Area
type="monotone"
dataKey="registrations"
name="Регистрации"
stroke="#52c41a"
fill="url(#colorRegistrations)"
strokeWidth={2}
dot={false}
activeDot={{ r: 3, strokeWidth: 0 }}
/>
</AreaChart>
)}
</div>
</Card>
</Col>
{/* События с мини-графиком */}
<Col xs={24} sm={12} lg={6}>
<Card>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Statistic
title="События"
value={data.events_total}
prefix={<TeamOutlined />}
style={{ flex: '0 0 auto', marginRight: 16 }}
/>
{eventsChartData.length > 0 && (
<AreaChart
width={150}
height={50}
data={eventsChartData}
margin={{ top: 5, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorEvents" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#1890ff" stopOpacity={0.4} />
<stop offset="95%" stopColor="#1890ff" stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis dataKey="date" hide />
<Tooltip labelFormatter={(label) => `Дата: ${label}`} />
<Area
type="monotone"
dataKey="events"
name="События"
stroke="#1890ff"
fill="url(#colorEvents)"
strokeWidth={2}
dot={false}
activeDot={{ r: 3, strokeWidth: 0 }}
/>
</AreaChart>
)}
</div>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Отзывы" value={data.reviews_total} prefix={<StarOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Календари" value={data.calendars_total} prefix={<CalendarOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Жалобы" value={data.reports_total} prefix={<WarningOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Тикеты (всего)" value={data.tickets_total} prefix={<BugOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Открытых тикетов"
value={data.tickets_open}
prefix={<ClockCircleOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Среднее время решения (ч)"
value={data.avg_ticket_resolution_h}
precision={1}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 32 }}>
<Col xs={24} lg={12}>
<Card title="События по дням">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={eventsChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis allowDecimals={false} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="events" stroke="#1890ff" name="События" />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="Регистрации по дням">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={registrationsChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis allowDecimals={false} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="registrations" stroke="#52c41a" name="Регистрации" />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
</Row>
<h2 style={{ marginTop: 32 }}>Активность администраторов</h2>
<Table
columns={adminColumns}
dataSource={data.admin_activity}
rowKey="admin_id"
pagination={false}
size="small"
/>
</div>
);
};
export default DashboardPage;
+85
View File
@@ -0,0 +1,85 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space } from 'antd';
import { Link } from 'react-router-dom';
import { useEvent } from '../../hooks/useEvents';
import dayjs from 'dayjs';
const EventDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: event, isLoading } = useEvent(id || '');
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: any) => isBadValue(val) ? '-' : val;
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!event) return <p>Событие не найдено</p>;
// Подготовка сложных полей
const tagsStr = Array.isArray(event.tags) && event.tags.length > 0 ? event.tags.join(', ') : '-';
const recurrenceStr = event.recurrence ? `${(event.recurrence as any).freq || ''} (интервал: ${(event.recurrence as any).interval || ''})` : '-';
const locationStr = event.location ? JSON.stringify(event.location) : '-';
const attachmentsStr = !isBadValue(event.attachments) ? JSON.stringify(event.attachments) : '-';
const editHistoryStr = !isBadValue(event.edit_history) ? JSON.stringify(event.edit_history) : '-';
// ID-ссылки
const specialistLink = !isBadValue(event.specialist_id) ? (
<Link to={`/users/${event.specialist_id}`}>{event.specialist_id}</Link>
) : '-';
const calendarLink = !isBadValue(event.calendar_id) ? (
// Пока нет страницы календаря, просто ID, но можно обернуть в ссылку, если появится
<span>{event.calendar_id}</span>
) : '-';
const masterLink = !isBadValue(event.master_id) ? (
<Link to={`/events/${event.master_id}`}>{event.master_id}</Link>
) : '-';
return (
<Card title={`Событие ${event.title || event.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{event.id}</Descriptions.Item>
<Descriptions.Item label="Название">{event.title}</Descriptions.Item>
<Descriptions.Item label="Описание">{displayValue(event.description) || '-'}</Descriptions.Item>
<Descriptions.Item label="Тип">
<Tag color={event.event_type === 'single' ? 'blue' : event.event_type === 'recurring' ? 'purple' : 'default'}>
{event.event_type}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={event.status === 'active' ? 'green' : event.status === 'cancelled' ? 'red' : 'gray'}>
{event.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(event.reason)}</Descriptions.Item>
<Descriptions.Item label="Дата и время начала">{formatDate(event.start_time)}</Descriptions.Item>
<Descriptions.Item label="Продолжительность (мин)">{event.duration ?? '-'}</Descriptions.Item>
<Descriptions.Item label="Вместимость">{displayValue(event.capacity)}</Descriptions.Item>
<Descriptions.Item label="Специалист">{specialistLink}</Descriptions.Item>
<Descriptions.Item label="Календарь">{calendarLink}</Descriptions.Item>
<Descriptions.Item label="Мастер-событие">{masterLink}</Descriptions.Item>
<Descriptions.Item label="Онлайн-ссылка">{displayValue(event.online_link)}</Descriptions.Item>
<Descriptions.Item label="Теги">{tagsStr}</Descriptions.Item>
<Descriptions.Item label="Рейтинг">{event.rating_avg} ({event.rating_count} оценок)</Descriptions.Item>
<Descriptions.Item label="Вложения">{attachmentsStr}</Descriptions.Item>
<Descriptions.Item label="История изменений">{editHistoryStr}</Descriptions.Item>
<Descriptions.Item label="Повторение">{recurrenceStr}</Descriptions.Item>
<Descriptions.Item label="Локация">{locationStr}</Descriptions.Item>
<Descriptions.Item label="Является экземпляром">{event.is_instance ? 'Да' : 'Нет'}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(event.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(event.updated_at)}</Descriptions.Item>
</Descriptions>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/events')}>Назад к списку</Button>
</Card>
);
};
export default EventDetailPage;
+291
View File
@@ -0,0 +1,291 @@
import React, { useState, useEffect, useRef } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, message, Spin, Input, Tooltip, DatePicker, InputNumber } from 'antd';
import { InfoCircleOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { useEvents, useUpdateEvent, useDeleteEvent, useEvent } from '../../hooks/useEvents';
import { Event, EventListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs';
const EventListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<EventListParams>({ limit: 20, offset: 0, sort: 'id', order: 'asc' });
const { data, isLoading } = useEvents(params);
const updateEvent = useUpdateEvent();
const deleteEvent = useDeleteEvent();
const [editModal, setEditModal] = useState<{ open: boolean; eventId: string | null }>({
open: false,
eventId: null,
});
const { data: editingEvent, isLoading: loadingEvent } = useEvent(editModal.eventId || '');
const [editForm] = Form.useForm();
const [editHasErrors, setEditHasErrors] = useState(true);
const originalEventRef = useRef<Event | null>(null);
const handleEdit = (id: string) => {
setEditModal({ open: true, eventId: id });
};
const handleSaveEdit = () => {
editForm.validateFields().then((values) => {
if (!editModal.eventId || !originalEventRef.current) return;
const original = originalEventRef.current;
const cleanedValues: Record<string, any> = {};
const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]);
allKeys.forEach((key) => {
const newVal = values[key];
if (newVal === '' || newVal === undefined || newVal === null) {
const origVal = (original as any)[key];
if (origVal && origVal !== '-' && origVal !== '' && origVal !== undefined && origVal !== null) {
cleanedValues[key] = null;
}
return;
}
if (newVal === '-') return;
if (dayjs.isDayjs(newVal)) {
cleanedValues[key] = newVal.toISOString();
} else {
cleanedValues[key] = newVal;
}
});
const payload = Object.fromEntries(
Object.entries(cleanedValues).filter(([key, v]) => {
const origVal = (original as any)[key];
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
})
);
updateEvent.mutate(
{ id: editModal.eventId, data: payload },
{ onSuccess: () => setEditModal({ open: false, eventId: null }) }
);
});
};
useEffect(() => {
if (editingEvent && editModal.open) {
originalEventRef.current = editingEvent;
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
setTimeout(() => {
editForm.setFieldsValue({
title: clean(editingEvent.title),
description: clean(editingEvent.description),
event_type: clean(editingEvent.event_type),
status: clean(editingEvent.status),
start_time: clean(editingEvent.start_time) ? dayjs(editingEvent.start_time) : null,
duration: editingEvent.duration,
capacity: editingEvent.capacity,
specialist_id: clean(editingEvent.specialist_id),
calendar_id: clean(editingEvent.calendar_id),
online_link: clean(editingEvent.online_link),
tags: editingEvent.tags,
});
validateEditForm();
}, 0);
}
}, [editingEvent, editModal.open, editForm]);
const validateEditForm = () => {
const title = editForm.getFieldValue('title');
const status = editForm.getFieldValue('status');
setEditHasErrors(!title || !status);
};
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить событие?',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteEvent.mutate(id),
});
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<Event> | SorterResult<Event>[]
) => {
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 typeColors: Record<string, string> = {
single: 'blue',
recurring: 'purple',
};
const columns: ColumnsType<Event> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу события">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/events/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Название',
dataIndex: 'title',
key: 'title',
ellipsis: true,
sorter: true,
},
{
title: 'Тип',
dataIndex: 'event_type',
key: 'event_type',
width: 100,
sorter: true,
render: (type: string) => (
<Tag color={typeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : status === 'cancelled' ? 'red' : 'gray'}>
{status}
</Tag>
),
},
{
title: 'Начало',
dataIndex: 'start_time',
key: 'start_time',
width: 130,
sorter: true,
render: (time: string) => time ? dayjs(time).format('DD.MM.YYYY HH:mm') : '-',
},
{
title: 'Рейтинг',
key: 'rating',
width: 90,
render: (_, record) => `${record.rating_avg} (${record.rating_count})`,
},
{
title: 'Действия',
key: 'actions',
width: 100,
render: (_, record) => (
<Space>
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record.id)}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
/>
</Tooltip>
</Space>
),
},
];
return (
<div>
<h2>События</h2>
<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,
}}
/>
<Modal
title="Редактировать событие"
open={editModal.open}
onCancel={() => setEditModal({ open: false, eventId: null })}
onOk={handleSaveEdit}
confirmLoading={updateEvent.isPending}
destroyOnHidden
width={640}
okButtonProps={{ disabled: editHasErrors }}
>
{loadingEvent ? (
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
) : (
<Form form={editForm} layout="vertical" preserve={false} onValuesChange={validateEditForm}>
<Form.Item label="Название" name="title" rules={[{ required: true, message: 'Введите название' }]}>
<Input />
</Form.Item>
<Form.Item label="Описание" name="description">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item label="Тип" name="event_type">
<Select>
<Select.Option value="single">single</Select.Option>
<Select.Option value="recurring">recurring</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Статус" name="status" rules={[{ required: true, message: 'Выберите статус' }]}>
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="cancelled">cancelled</Select.Option>
<Select.Option value="completed">completed</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Дата и время начала" name="start_time">
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" />
</Form.Item>
<Form.Item label="Продолжительность (мин)" name="duration">
<InputNumber min={0} />
</Form.Item>
<Form.Item label="Вместимость" name="capacity">
<InputNumber min={0} />
</Form.Item>
<Form.Item label="Специалист (ID)" name="specialist_id">
<Input />
</Form.Item>
<Form.Item label="Календарь (ID)" name="calendar_id">
<Input />
</Form.Item>
<Form.Item label="Ссылка онлайн" name="online_link">
<Input />
</Form.Item>
<Form.Item label="Теги" name="tags">
<Select mode="tags" placeholder="Введите теги" />
</Form.Item>
</Form>
)}
</Modal>
</div>
);
};
export default EventListPage;
+201
View File
@@ -0,0 +1,201 @@
import React, { useState } from 'react';
import { Card, Descriptions, Spin, Button, Form, Input, Select, Tag, message, Space, Avatar } from 'antd';
import { UserOutlined } from '@ant-design/icons';
import { useAuthStore } from '../../store/authStore';
import { useUpdateAdmin } from '../../hooks/useAdmins';
import dayjs from 'dayjs';
const ProfilePage: React.FC = () => {
const { user } = useAuthStore();
const updateAdmin = useUpdateAdmin();
const [editing, setEditing] = useState(false);
const [form] = Form.useForm();
// Проверка, является ли текущий пользователь superadmin
const isSuperadmin = user?.role === 'superadmin';
// Заполняем форму при входе в режим редактирования
const startEditing = () => {
if (!user) return;
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
form.setFieldsValue({
nickname: clean(user.nickname),
email: clean(user.email),
timezone: clean(user.timezone),
language: clean(user.language),
phone: clean(user.phone),
avatar_url: clean(user.avatar_url),
preferences: user.preferences && !isBadValue(user.preferences) ? JSON.stringify(user.preferences) : '',
});
setEditing(true);
};
const handleSave = () => {
if (!user || !isSuperadmin) return;
form.validateFields().then((values) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
if (payload.preferences) {
try {
payload.preferences = JSON.parse(payload.preferences);
} catch {
message.error('Поле «Настройки» должно быть валидным JSON');
return;
}
}
updateAdmin.mutate(
{ id: user.id, data: payload },
{
onSuccess: () => {
message.success('Профиль обновлён');
setEditing(false);
},
}
);
});
};
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;
};
if (!user) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
return (
<Card title="Мой профиль">
{!editing ? (
<>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
<Avatar size={64} icon={<UserOutlined />} src={isBadValue(user.avatar_url) ? undefined : user.avatar_url} />
<div>
<h3>{!isBadValue(user.nickname) ? user.nickname : user.email}</h3>
<Tag
color={
user.role === 'superadmin'
? 'red'
: user.role === 'admin'
? 'blue'
: user.role === 'moderator'
? 'purple'
: 'cyan'
}
>
{user.role}
</Tag>
</div>
</div>
<Descriptions bordered column={1}>
<Descriptions.Item label="Email">{user.email}</Descriptions.Item>
<Descriptions.Item label="Ник">{isBadValue(user.nickname) ? '-' : user.nickname}</Descriptions.Item>
<Descriptions.Item label="Роль">
<Tag
color={
user.role === 'superadmin'
? 'red'
: user.role === 'admin'
? 'blue'
: user.role === 'moderator'
? 'purple'
: 'cyan'
}
>
{user.role}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={user.status === 'active' ? 'green' : 'red'}>{user.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Часовой пояс">{isBadValue(user.timezone) ? '-' : user.timezone}</Descriptions.Item>
<Descriptions.Item label="Язык">{isBadValue(user.language) ? '-' : user.language}</Descriptions.Item>
<Descriptions.Item label="Телефон">{isBadValue(user.phone) ? '-' : user.phone}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">
{isBadValue(user.avatar_url) ? '-' : <a href={user.avatar_url} target="_blank" rel="noreferrer">{user.avatar_url}</a>}
</Descriptions.Item>
<Descriptions.Item label="Настройки">
{isBadValue(user.preferences) ? '-' : <pre>{JSON.stringify(user.preferences, null, 2)}</pre>}
</Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(user.last_login)}</Descriptions.Item>
</Descriptions>
{isSuperadmin && (
<Button type="primary" style={{ marginTop: 16 }} onClick={startEditing}>
Редактировать
</Button>
)}
</>
) : (
<Form form={form} layout="vertical" onFinish={handleSave}>
<Form.Item label="Email" name="email">
<Input disabled />
</Form.Item>
<Form.Item label="Ник" name="nickname">
<Input />
</Form.Item>
<Form.Item label="Роль" name="role">
<Input disabled />
</Form.Item>
<Form.Item label="Статус" name="status">
<Input disabled />
</Form.Item>
<Form.Item name="timezone" label="Часовой пояс">
<Select placeholder="Выберите пояс" allowClear>
<Select.Option value="UTC">UTC</Select.Option>
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="language" label="Язык">
<Select placeholder="Выберите язык" allowClear>
<Select.Option value="ru">Русский</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
<Form.Item name="phone" label="Телефон">
<Input placeholder="+7 (999) 123-45-67" />
</Form.Item>
<Form.Item name="avatar_url" label="URL аватара">
<Input placeholder="https://example.com/avatar.jpg" />
</Form.Item>
<Form.Item
name="preferences"
label="Настройки (JSON)"
rules={[
{
validator: (_, value) => {
if (value && value.trim().length > 0) {
try {
JSON.parse(value);
} catch {
return Promise.reject(new Error('Невалидный JSON'));
}
}
return Promise.resolve();
},
},
]}
>
<Input.TextArea rows={4} placeholder='{"key": "value"}' />
</Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={updateAdmin.isPending}>
Сохранить
</Button>
<Button onClick={() => setEditing(false)}>Отмена</Button>
</Space>
</Form>
)}
</Card>
);
};
export default ProfilePage;
+98
View File
@@ -0,0 +1,98 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Modal } from 'antd';
import { Link } from 'react-router-dom';
import { useReport, useUpdateReport } from '../../hooks/useReports';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { useAdmin } from '../../hooks/useAdmins';
const ReportDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: report, isLoading: reportLoading } = useReport(id || '');
const updateReport = useUpdateReport();
const reporterId = report?.reporter_id;
const resolvedById = report?.resolved_by;
const targetType = report?.target_type;
const targetId = report?.target_id;
const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || '');
const { data: resolvedAdmin, isLoading: loadingResolver } = useAdmin(resolvedById || '');
const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetId || '');
const handleStatusChange = (status: 'reviewed' | 'dismissed') => {
Modal.confirm({
title: `Отметить как «${status === 'reviewed' ? 'Рассмотрено' : 'Отклонено'}»?`,
okText: 'Да',
cancelText: 'Отмена',
onOk: () => updateReport.mutate({ id: id!, data: { status } }),
});
};
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const getLink = (entity: any, type: 'user' | 'admin' | 'event') => {
if (!entity) return <Spin size="small" />;
let name = '';
if (type === 'event') {
name = !isBadValue(entity.title) ? entity.title : entity.id;
} else {
const nick = entity.nickname;
const email = entity.email;
if (!isBadValue(nick)) name = nick;
else if (!isBadValue(email)) name = email;
else name = entity.id;
}
const to = type === 'user' ? `/users/${entity.id}` : type === 'admin' ? `/admins/${entity.id}` : `/events/${entity.id}`;
return <Link to={to}>{name}</Link>;
};
const displayId = (val?: string | null) => (!val || isBadValue(val)) ? '-' : val;
const isValidId = (val?: string | null) => val && !isBadValue(val);
if (reportLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!report) return <p>Жалоба не найдена</p>;
return (
<Card title={`Жалоба ${report.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{report.id}</Descriptions.Item>
<Descriptions.Item label="Отправитель">
{loadingReporter && isValidId(reporterId) ? <Spin size="small" /> : (
reporter ? getLink(reporter, 'user') : displayId(reporterId)
)}
</Descriptions.Item>
<Descriptions.Item label="Тип цели">{report.target_type}</Descriptions.Item>
<Descriptions.Item label="Цель">
{targetType === 'event' && loadingEvent && isValidId(targetId) ? <Spin size="small" /> : (
targetType === 'event' && targetEvent ? getLink(targetEvent, 'event') : displayId(targetId)
)}
</Descriptions.Item>
<Descriptions.Item label="Причина">{report.reason}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={report.status === 'pending' ? 'orange' : report.status === 'reviewed' ? 'green' : 'default'}>
{report.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Создано">{report.created_at}</Descriptions.Item>
<Descriptions.Item label="Решено">{report.resolved_at || '-'}</Descriptions.Item>
<Descriptions.Item label="Кем решено">
{loadingResolver && isValidId(resolvedById) ? <Spin size="small" /> : (
resolvedAdmin ? getLink(resolvedAdmin, 'admin') : displayId(resolvedById)
)}
</Descriptions.Item>
</Descriptions>
{report.status === 'pending' && (
<Space style={{ marginTop: 16 }}>
<Button type="primary" onClick={() => handleStatusChange('reviewed')} loading={updateReport.isPending}>Рассмотрено</Button>
<Button danger onClick={() => handleStatusChange('dismissed')} loading={updateReport.isPending}>Отклонено</Button>
</Space>
)}
<Button style={{ marginTop: 16 }} onClick={() => navigate('/reports')}>Назад к списку</Button>
</Card>
);
};
export default ReportDetailPage;
+293
View File
@@ -0,0 +1,293 @@
import React, { useState } from 'react';
import { Table, Button, Tag, Modal, Descriptions, Tooltip, Spin, Space } from 'antd';
import { InfoCircleOutlined, EyeOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { useReports, useUpdateReport } from '../../hooks/useReports';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { useAdmin } from '../../hooks/useAdmins';
import { Report, ReportListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
const ReportListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<ReportListParams>({ limit: 20, offset: 0, sort: 'created_at', order: 'desc' });
const { data, isLoading } = useReports(params);
const updateReport = useUpdateReport();
const [detailModal, setDetailModal] = useState<{ open: boolean; report: Report | null }>({
open: false,
report: null,
});
const reporterId = detailModal.report?.reporter_id;
const resolvedById = detailModal.report?.resolved_by;
const targetType = detailModal.report?.target_type;
const targetId = detailModal.report?.target_id;
const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || '');
const { data: resolvedAdmin, isLoading: loadingResolver } = useAdmin(resolvedById || '');
const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetId || '');
const handleViewDetails = (report: Report) => {
setDetailModal({ open: true, report });
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<Report> | SorterResult<Report>[]
) => {
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 targetTypeColors: Record<string, string> = {
event: 'blue',
calendar: 'green',
review: 'purple',
};
// Резолвер отправителя
const ReporterCell: React.FC<{ reporterId: string }> = ({ reporterId }) => {
const { data: user, isLoading: loading } = useUser(reporterId);
if (loading) return <Spin size="small" />;
if (!user) return <span>{reporterId}</span>;
// Используем ту же логику, что и в renderLink
const nick = user.nickname;
const email = user.email;
let name = '';
if (!isBadValue(nick)) {
name = nick;
} else if (!isBadValue(email)) {
name = email;
} else {
name = user.id;
}
return <Link to={`/users/${user.id}`}>{name}</Link>;
};
// Резолвер цели
const TargetCell: React.FC<{ targetType: string; targetId: string }> = ({ targetType, targetId }) => {
const { data: event, isLoading: loading } = useEvent(targetType === 'event' ? targetId : '');
if (targetType === 'event') {
if (loading) return <Spin size="small" />;
if (event) {
const name = event.title || event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
return <span>{targetId}</span>;
}
return <span>{targetId}</span>;
};
const columns: ColumnsType<Report> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу жалобы">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/reports/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Отправитель',
key: 'reporter',
ellipsis: true,
render: (_, record) => <ReporterCell reporterId={record.reporter_id} />,
},
{
title: 'Тип',
dataIndex: 'target_type',
key: 'target_type',
width: 100,
sorter: true,
render: (type: string) => (
<Tag color={targetTypeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Цель',
key: 'target',
ellipsis: true,
render: (_, record) => <TargetCell targetType={record.target_type} targetId={record.target_id} />,
},
{
title: 'Причина',
dataIndex: 'reason',
key: 'reason',
ellipsis: true,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'pending' ? 'orange' : status === 'reviewed' ? 'green' : 'default'}>
{status}
</Tag>
),
},
{
title: 'Создано',
dataIndex: 'created_at',
key: 'created_at',
width: 120,
sorter: true,
},
{
title: <EyeOutlined />,
key: 'view',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Быстрый просмотр">
<Button
icon={<EyeOutlined />}
size="small"
onClick={() => handleViewDetails(record)}
/>
</Tooltip>
),
},
];
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const renderLink = (entity: any, type: 'user' | 'admin' | 'event') => {
if (!entity) return <Spin size="small" />;
let name = '';
if (type === 'event') {
name = !isBadValue(entity.title) ? entity.title : entity.id;
} else {
const nick = entity.nickname;
const email = entity.email;
if (!isBadValue(nick)) {
name = nick;
} else if (!isBadValue(email)) {
name = email;
} else {
name = entity.id;
}
}
const to =
type === 'user' ? `/users/${entity.id}` :
type === 'admin' ? `/admins/${entity.id}` :
`/events/${entity.id}`;
return <Link to={to}>{name}</Link>;
};
const displayId = (id?: string | null) => {
if (!id || isBadValue(id)) return '-';
return id;
};
const isValidId = (id?: string | null) => id && !isBadValue(id);
return (
<div>
<h2>Жалобы</h2>
<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,
}}
/>
<Modal
title={`Жалоба ${detailModal.report?.id || ''}`}
open={detailModal.open}
onCancel={() => setDetailModal({ open: false, report: null })}
footer={null}
width={600}
>
{detailModal.report && (
<>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="ID">{detailModal.report.id}</Descriptions.Item>
<Descriptions.Item label="Отправитель">
{loadingReporter && isValidId(reporterId) ? <Spin size="small" /> : (
reporter ? renderLink(reporter, 'user') : displayId(reporterId)
)}
</Descriptions.Item>
<Descriptions.Item label="Тип цели">{detailModal.report.target_type}</Descriptions.Item>
<Descriptions.Item label="Цель">
{targetType === 'event' && loadingEvent && isValidId(targetId) ? <Spin size="small" /> : (
targetType === 'event' && targetEvent ? renderLink(targetEvent, 'event') : displayId(targetId)
)}
</Descriptions.Item>
<Descriptions.Item label="Причина">{detailModal.report.reason}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={detailModal.report.status === 'pending' ? 'orange' : detailModal.report.status === 'reviewed' ? 'green' : 'default'}>
{detailModal.report.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Создано">{detailModal.report.created_at}</Descriptions.Item>
<Descriptions.Item label="Решено">{detailModal.report.resolved_at || '-'}</Descriptions.Item>
<Descriptions.Item label="Кем решено">
{loadingResolver && isValidId(resolvedById) ? <Spin size="small" /> : (
resolvedAdmin ? renderLink(resolvedAdmin, 'admin') : displayId(resolvedById)
)}
</Descriptions.Item>
</Descriptions>
{detailModal.report.status === 'pending' && (
<Space style={{ marginTop: 16 }}>
<Button
type="primary"
onClick={() => {
updateReport.mutate(
{ id: detailModal.report!.id, data: { status: 'reviewed' } },
{ onSuccess: () => setDetailModal({ open: false, report: null }) }
);
}}
loading={updateReport.isPending}
>
Рассмотрено
</Button>
<Button
danger
onClick={() => {
updateReport.mutate(
{ id: detailModal.report!.id, data: { status: 'dismissed' } },
{ onSuccess: () => setDetailModal({ open: false, report: null }) }
);
}}
loading={updateReport.isPending}
>
Отклонено
</Button>
</Space>
)}
</>
)}
</Modal>
</div>
);
};
export default ReportListPage;
+144
View File
@@ -0,0 +1,144 @@
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Modal, Form, Select, Input, message } from 'antd';
import { Link } from 'react-router-dom';
import { useReview, useUpdateReview } from '../../hooks/useReviews';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import dayjs from 'dayjs';
const ReviewDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: review, isLoading: reviewLoading } = useReview(id || '');
const updateReview = useUpdateReview();
const userId = review?.user_id;
const targetType = review?.target_type;
const targetId = review?.target_id;
const { data: user, isLoading: loadingUser } = useUser(userId || '');
const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetType === 'event' ? targetId || '' : '');
const [statusModal, setStatusModal] = useState<{
open: boolean;
newStatus: string;
}>({ open: false, newStatus: 'visible' });
const [statusForm] = Form.useForm();
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: any) => isBadValue(val) ? '-' : val;
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 (loadingUser) return <Spin size="small" />;
if (!user) return displayValue(userId);
const name = !isBadValue(user.nickname) ? user.nickname : user.email || user.id;
return <Link to={`/users/${user.id}`}>{name}</Link>;
};
const getTargetLink = () => {
if (targetType === 'event') {
if (loadingEvent) return <Spin size="small" />;
if (targetEvent) {
const name = targetEvent.title || targetEvent.id;
return <Link to={`/events/${targetEvent.id}`}>{name}</Link>;
}
return displayValue(targetId);
}
return displayValue(targetId);
};
// Открытие модалки смены статуса
const openStatusModal = (newStatus: string) => {
setStatusModal({ open: true, newStatus });
statusForm.resetFields();
// Предзаполняем причину, если была
const currentReason = review?.reason && !isBadValue(review.reason) ? review.reason : '';
statusForm.setFieldsValue({ reason: currentReason });
};
const handleStatusSubmit = () => {
statusForm.validateFields().then((values) => {
if (!review) return;
const payload: any = { status: statusModal.newStatus };
if (values.reason && values.reason.trim() !== '') {
payload.reason = values.reason;
}
updateReview.mutate(
{ id: review.id, data: payload },
{
onSuccess: () => {
setStatusModal({ open: false, newStatus: 'visible' });
message.success('Статус обновлён');
}
}
);
});
};
if (reviewLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!review) return <p>Отзыв не найден</p>;
return (
<Card title={`Отзыв ${review.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{review.id}</Descriptions.Item>
<Descriptions.Item label="Пользователь">{getUserLink()}</Descriptions.Item>
<Descriptions.Item label="Тип цели">
<Tag color={review.target_type === 'event' ? 'blue' : 'green'}>{review.target_type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Цель">{getTargetLink()}</Descriptions.Item>
<Descriptions.Item label="Оценка">{'⭐'.repeat(review.rating)} ({review.rating})</Descriptions.Item>
<Descriptions.Item label="Комментарий">{displayValue(review.comment)}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={review.status === 'visible' ? 'green' : review.status === 'hidden' ? 'orange' : 'red'}>
{review.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(review.reason)}</Descriptions.Item>
<Descriptions.Item label="Лайки">{isBadValue(review.likes) ? 0 : review.likes}</Descriptions.Item>
<Descriptions.Item label="Дизлайки">{isBadValue(review.dislikes) ? 0 : review.dislikes}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(review.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(review.updated_at)}</Descriptions.Item>
</Descriptions>
<Space style={{ marginTop: 16 }}>
<Button onClick={() => openStatusModal('visible')}>Показать</Button>
<Button onClick={() => openStatusModal('hidden')}>Скрыть</Button>
<Button danger onClick={() => openStatusModal('deleted')}>Удалить</Button>
</Space>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/reviews')}>Назад к списку</Button>
<Modal
title={`Изменить статус на «${statusModal.newStatus}»`}
open={statusModal.open}
onCancel={() => setStatusModal({ open: false, newStatus: 'visible' })}
onOk={handleStatusSubmit}
confirmLoading={updateReview.isPending}
destroyOnHidden
>
<Form form={statusForm} layout="vertical" preserve={false}>
<Form.Item
label="Причина"
name="reason"
rules={[
{
required: statusModal.newStatus !== 'visible',
message: 'Укажите причину',
},
]}
>
<Input.TextArea rows={3} placeholder="Введите причину изменения статуса" />
</Form.Item>
</Form>
</Modal>
</Card>
);
};
export default ReviewDetailPage;
+345
View File
@@ -0,0 +1,345 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, InputNumber, message, Tooltip, Input, Spin } from 'antd';
import { InfoCircleOutlined, EditOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { useReviews, useUpdateReview, useBulkUpdateReviews, useReview } from '../../hooks/useReviews';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { Review, ReviewListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
const ReviewListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<ReviewListParams>({ limit: 20, offset: 0, sort: 'created_at', order: 'desc' });
const { data, isLoading } = useReviews(params);
const updateReview = useUpdateReview();
const bulkUpdate = useBulkUpdateReviews();
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
// Модальное окно для индивидуального редактирования
const [editModal, setEditModal] = useState<{ open: boolean; reviewId: string | null }>({
open: false,
reviewId: null,
});
const [editForm] = Form.useForm();
const { data: editingReview, isLoading: loadingReview } = useReview(editModal.reviewId || '');
// Модальное окно для массового изменения статуса с причиной
const [bulkStatusModal, setBulkStatusModal] = useState<{
open: boolean;
status: string;
}>({ open: false, status: 'hidden' });
const [bulkStatusForm] = Form.useForm();
// ---- Индивидуальное редактирование ----
const handleEdit = (id: string) => {
setEditModal({ open: true, reviewId: id });
};
const handleSaveEdit = () => {
editForm.validateFields().then((values) => {
if (!editModal.reviewId) return;
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateReview.mutate(
{ id: editModal.reviewId, data: payload },
{ onSuccess: () => setEditModal({ open: false, reviewId: null }) }
);
});
};
useEffect(() => {
if (editingReview && editModal.open) {
setTimeout(() => {
editForm.setFieldsValue({
status: editingReview.status,
reason: editingReview.reason,
comment: editingReview.comment,
rating: editingReview.rating,
});
}, 0);
}
}, [editingReview, editModal.open, editForm]);
// ---- Массовое изменение статуса ----
const openBulkStatusModal = (status: string) => {
if (selectedRowKeys.length === 0) {
message.warning('Выберите отзывы');
return;
}
setBulkStatusModal({ open: true, status });
bulkStatusForm.resetFields();
};
const handleBulkStatusSubmit = () => {
bulkStatusForm.validateFields().then((values) => {
const reason = values.reason ? values.reason.trim() : '';
const updates = selectedRowKeys.map(id => ({
id: id as string,
status: bulkStatusModal.status,
reason: reason || undefined, // если пусто, не передаем
}));
bulkUpdate.mutate(updates, {
onSuccess: () => {
setBulkStatusModal({ open: false, status: 'hidden' });
setSelectedRowKeys([]);
},
});
});
};
// ---- Таблица ----
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<Review> | SorterResult<Review>[]
) => {
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 isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading: loading } = useUser(userId);
if (loading) return <Spin size="small" />;
if (!user) return <span>{userId}</span>;
const nick = user.nickname;
const email = user.email;
let name = '';
if (!isBadValue(nick)) {
name = nick;
} else if (!isBadValue(email)) {
name = email;
} else {
name = user.id;
}
return <Link to={`/users/${user.id}`}>{name}</Link>;
};
const TargetCell: React.FC<{ targetType: string; targetId: string }> = ({ targetType, targetId }) => {
const { data: event, isLoading: loading } = useEvent(targetType === 'event' ? targetId : '');
if (targetType === 'event') {
if (loading) return <Spin size="small" />;
if (event) {
const name = event.title || event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
return <span>{targetId}</span>;
}
return <span>{targetId}</span>;
};
const typeColors: Record<string, string> = {
calendar: 'green',
event: 'blue',
review: 'purple',
};
const columns: ColumnsType<Review> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу отзыва">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/reviews/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Пользователь',
key: 'user',
ellipsis: true,
render: (_, record) => <UserCell userId={record.user_id} />,
},
{
title: 'Тип',
dataIndex: 'target_type',
key: 'target_type',
width: 100,
sorter: true,
render: (type: string) => (
<Tag color={typeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Цель',
key: 'target',
ellipsis: true,
render: (_, record) => <TargetCell targetType={record.target_type} targetId={record.target_id} />,
},
{
title: 'Оценка',
dataIndex: 'rating',
key: 'rating',
width: 140,
sorter: true,
render: (rating: number) => '⭐'.repeat(rating),
},
{
title: 'Комментарий',
dataIndex: 'comment',
key: 'comment',
ellipsis: true,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'visible' ? 'green' : status === 'hidden' ? 'orange' : 'red'}>
{status}
</Tag>
),
},
{
title: 'Лайки / Дизлайки',
key: 'likes',
width: 120,
render: (_, record) => {
const likes = isBadValue(record.likes) ? 0 : record.likes;
const dislikes = isBadValue(record.dislikes) ? 0 : record.dislikes;
return `${likes} / ${dislikes}`;
},
},
{
title: '',
key: 'actions',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record.id)}
/>
</Tooltip>
),
},
];
return (
<div>
<h2>Отзывы</h2>
<Space style={{ marginBottom: 16 }}>
<Button onClick={() => openBulkStatusModal('hidden')} disabled={selectedRowKeys.length === 0}>
Скрыть выбранные
</Button>
<Button onClick={() => openBulkStatusModal('visible')} disabled={selectedRowKeys.length === 0}>
Показать выбранные
</Button>
<Button danger onClick={() => openBulkStatusModal('deleted')} disabled={selectedRowKeys.length === 0}>
Удалить выбранные
</Button>
</Space>
<Table
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
}}
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,
}}
/>
{/* Модальное окно индивидуального редактирования */}
<Modal
title="Редактировать отзыв"
open={editModal.open}
onCancel={() => setEditModal({ open: false, reviewId: null })}
onOk={handleSaveEdit}
confirmLoading={updateReview.isPending}
destroyOnHidden
>
{loadingReview ? (
<Spin />
) : (
<Form form={editForm} layout="vertical" preserve={false}>
<Form.Item label="Оценка" name="rating">
<InputNumber min={1} max={5} />
</Form.Item>
<Form.Item label="Комментарий" name="comment">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item label="Статус" name="status">
<Select>
<Select.Option value="visible">visible</Select.Option>
<Select.Option value="hidden">hidden</Select.Option>
<Select.Option value="deleted">deleted</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="Причина"
name="reason"
rules={[
({ getFieldValue }) => ({
validator(_, value) {
const status = getFieldValue('status');
if ((status === 'hidden' || status === 'deleted') && (!value || value.trim() === '')) {
return Promise.reject(new Error('Укажите причину изменения статуса'));
}
return Promise.resolve();
},
}),
]}
>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
)}
</Modal>
{/* Модальное окно для массового изменения с причиной */}
<Modal
title={`Изменить статус на «${bulkStatusModal.status}» для ${selectedRowKeys.length} отзывов`}
open={bulkStatusModal.open}
onCancel={() => setBulkStatusModal({ open: false, status: 'hidden' })}
onOk={handleBulkStatusSubmit}
confirmLoading={bulkUpdate.isPending}
destroyOnHidden
>
<Form form={bulkStatusForm} layout="vertical" preserve={false}>
<Form.Item
label="Причина"
name="reason"
rules={[
{
required: bulkStatusModal.status === 'hidden' || bulkStatusModal.status === 'deleted',
message: 'Укажите причину',
},
]}
>
<Input.TextArea rows={3} placeholder="Общая причина для выбранных отзывов" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ReviewListPage;
@@ -0,0 +1,263 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, DatePicker, Tooltip, Spin } from 'antd';
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { Link } from 'react-router-dom';
import { useSubscriptions, useUpdateSubscription, useDeleteSubscription, useSubscription } from '../../hooks/useSubscriptions';
import { useUser } from '../../hooks/useUsers';
import { Subscription, SubscriptionListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs';
const SubscriptionListPage: React.FC = () => {
const [params, setParams] = useState<SubscriptionListParams>({ limit: 20, offset: 0 });
const { data, isLoading } = useSubscriptions(params);
const updateSubscription = useUpdateSubscription();
const deleteSubscription = useDeleteSubscription();
const [editModal, setEditModal] = useState<{ open: boolean; subscriptionId: string | null }>({
open: false,
subscriptionId: null,
});
const { data: editingSubscription, isLoading: loadingSubscription } = useSubscription(editModal.subscriptionId || '');
const [form] = Form.useForm();
// Заполняем форму с задержкой, когда данные загружены
useEffect(() => {
if (editingSubscription && editModal.open) {
setTimeout(() => {
form.setFieldsValue({
plan: editingSubscription.plan,
status: editingSubscription.status,
trial_used: editingSubscription.trial_used,
expires_at: editingSubscription.expires_at ? dayjs(editingSubscription.expires_at) : null,
});
}, 0);
}
}, [editingSubscription, editModal.open, form]);
const handleEdit = (sub: Subscription) => {
// Сбрасываем форму перед открытием
form.resetFields();
setEditModal({ open: true, subscriptionId: sub.id });
};
const handleSave = () => {
form.validateFields().then((values) => {
if (!editModal.subscriptionId) return;
const payload = Object.fromEntries(
Object.entries(values)
.filter(([_, v]) => v !== '' && v !== undefined && v !== null)
.map(([key, val]) => {
if (dayjs.isDayjs(val)) return [key, val.toISOString()];
return [key, val];
})
);
updateSubscription.mutate(
{ id: editModal.subscriptionId, data: payload },
{ onSuccess: () => setEditModal({ open: false, subscriptionId: null }) }
);
});
};
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить подписку?',
content: 'Это действие нельзя отменить.',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteSubscription.mutate(id),
});
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<Subscription> | SorterResult<Subscription>[]
) => {
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 isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
// Компонент для резолвинга пользователя
const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading: loading } = useUser(userId);
if (loading) return <Spin size="small" />;
if (!user) return <span>{userId}</span>;
const name = !isBadValue(user.nickname) ? user.nickname : user.email;
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
};
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
const planColors: Record<string, string> = {
trial: 'cyan',
monthly: 'blue',
quarterly: 'green',
biannual: 'purple',
annual: 'orange',
};
const columns: ColumnsType<Subscription> = [
{
title: 'Пользователь',
key: 'user',
ellipsis: true,
render: (_, record) => <UserCell userId={record.user_id} />,
},
{
title: 'План',
dataIndex: 'plan',
key: 'plan',
width: 100,
sorter: true,
render: (plan: string) => (
<Tag color={planColors[plan] || 'default'}>{plan}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => {
const color =
status === 'active' ? 'green' :
status === 'expired' ? 'orange' : 'red';
return <Tag color={color}>{status}</Tag>;
},
},
{
title: 'Пробный',
dataIndex: 'trial_used',
key: 'trial_used',
width: 100,
render: (v: boolean) => (v ? 'Да' : 'Нет'),
},
{ title: 'Начало', dataIndex: 'started_at', key: 'started_at', render: (val) => formatDate(val) },
{ title: 'Окончание', dataIndex: 'expires_at', key: 'expires_at', render: (val) => formatDate(val) },
{
title: 'Действия',
key: 'actions',
width: 100,
render: (_, record) => (
<Space>
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record)}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
/>
</Tooltip>
</Space>
),
},
];
return (
<div>
<h2>Подписки</h2>
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="План"
onChange={(val) => setParams({ ...params, plan: val, offset: 0 })}
style={{ width: 150 }}
>
<Select.Option value="monthly">monthly</Select.Option>
<Select.Option value="quarterly">quarterly</Select.Option>
<Select.Option value="biannual">biannual</Select.Option>
<Select.Option value="annual">annual</Select.Option>
<Select.Option value="trial">trial</Select.Option>
</Select>
<Select
allowClear
placeholder="Статус"
onChange={(val) => setParams({ ...params, status: val, offset: 0 })}
style={{ width: 150 }}
>
<Select.Option value="active">active</Select.Option>
<Select.Option value="expired">expired</Select.Option>
<Select.Option value="cancelled">cancelled</Select.Option>
</Select>
</Space>
<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,
}}
/>
<Modal
title="Редактировать подписку"
open={editModal.open}
onCancel={() => setEditModal({ open: false, subscriptionId: null })}
onOk={handleSave}
confirmLoading={updateSubscription.isPending}
destroyOnHidden
>
{loadingSubscription ? (
<Spin />
) : (
<Form form={form} layout="vertical" preserve={false}>
<Form.Item label="План" name="plan" rules={[{ required: true }]}>
<Select>
<Select.Option value="monthly">monthly</Select.Option>
<Select.Option value="quarterly">quarterly</Select.Option>
<Select.Option value="biannual">biannual</Select.Option>
<Select.Option value="annual">annual</Select.Option>
<Select.Option value="trial">trial</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Статус" name="status" rules={[{ required: true }]}>
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="expired">expired</Select.Option>
<Select.Option value="cancelled">cancelled</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Пробный период использован" name="trial_used">
<Select>
<Select.Option value={true}>Да</Select.Option>
<Select.Option value={false}>Нет</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Дата окончания" name="expires_at">
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" style={{ width: '100%' }} />
</Form.Item>
</Form>
)}
</Modal>
</div>
);
};
export default SubscriptionListPage;
+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;
+56
View File
@@ -0,0 +1,56 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag } from 'antd';
import { useUser } from '../../hooks/useUsers';
import dayjs from 'dayjs';
const UserDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: user, isLoading } = useUser(id || '');
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: any) => (isBadValue(val) ? '-' : val);
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!user) return <p>Пользователь не найден</p>;
return (
<Card title={`Пользователь ${displayValue(user.nickname) !== '-' ? user.nickname : user.email || user.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{user.id}</Descriptions.Item>
<Descriptions.Item label="Email">{displayValue(user.email)}</Descriptions.Item>
<Descriptions.Item label="Ник">{displayValue(user.nickname)}</Descriptions.Item>
<Descriptions.Item label="Роль">
<Tag color={user.role === 'user' ? 'blue' : 'purple'}>{user.role}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={user.status === 'active' ? 'green' : user.status === 'frozen' ? 'orange' : 'red'}>
{user.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(user.reason)}</Descriptions.Item>
<Descriptions.Item label="Телефон">{displayValue(user.phone)}</Descriptions.Item>
<Descriptions.Item label="Язык">{displayValue(user.language)}</Descriptions.Item>
<Descriptions.Item label="Часовой пояс">{displayValue(user.timezone)}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">{displayValue(user.avatar_url)}</Descriptions.Item>
<Descriptions.Item label="Социальные ссылки">{displayValue(user.social_links)}</Descriptions.Item>
<Descriptions.Item label="Настройки">{displayValue(user.preferences)}</Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(user.last_login)}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(user.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(user.updated_at)}</Descriptions.Item>
</Descriptions>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/users')}>Назад к списку</Button>
</Card>
);
};
export default UserDetailPage;
+354
View File
@@ -0,0 +1,354 @@
import React, { useState, useEffect, useRef } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, message, Spin, Input, Tooltip } from 'antd';
import { InfoCircleOutlined, EditOutlined, LockOutlined, UnlockOutlined, DeleteOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useUsers, useUpdateUser, useDeleteUser, useUser } from '../../hooks/useUsers';
import { User, UserListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
const UserListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<UserListParams>({ limit: 20, offset: 0, sort: 'id', order: 'asc' });
const { data, isLoading } = useUsers(params);
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
// Редактирование
const [editModal, setEditModal] = useState<{ open: boolean; userId: string | null }>({
open: false,
userId: null,
});
const { data: editingUser, isLoading: loadingUser } = useUser(editModal.userId || '');
const [editForm] = Form.useForm();
const [editHasErrors, setEditHasErrors] = useState(true);
const originalUserRef = useRef<User | null>(null);
// Изменение статуса
const [statusModal, setStatusModal] = useState<{
open: boolean;
userId: string | null;
newStatus: string;
currentReason: string;
}>({
open: false,
userId: null,
newStatus: 'active',
currentReason: '',
});
const [statusForm] = Form.useForm();
// ==================== Редактирование ====================
const handleEdit = (id: string) => {
setEditModal({ open: true, userId: id });
};
const handleSaveEdit = () => {
editForm.validateFields().then((values) => {
if (!editModal.userId || !originalUserRef.current) return;
const original = originalUserRef.current;
const cleanedValues: Record<string, any> = {};
const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]);
allKeys.forEach((key) => {
const newVal = values[key];
if (newVal === '' || newVal === undefined || newVal === null) {
const origVal = (original as any)[key];
if (origVal && origVal !== '-' && origVal !== '' && origVal !== undefined && origVal !== null) {
cleanedValues[key] = null;
}
return;
}
if (newVal === '-') return;
cleanedValues[key] = newVal;
});
const payload = Object.fromEntries(
Object.entries(cleanedValues).filter(([key, v]) => {
const origVal = (original as any)[key];
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
})
);
updateUser.mutate(
{ id: editModal.userId, data: payload },
{ onSuccess: () => setEditModal({ open: false, userId: null }) }
);
});
};
useEffect(() => {
if (editingUser && editModal.open) {
originalUserRef.current = editingUser;
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
setTimeout(() => {
editForm.setFieldsValue({
email: clean(editingUser.email),
nickname: clean(editingUser.nickname),
role: clean(editingUser.role),
status: clean(editingUser.status),
reason: clean(editingUser.reason),
});
validateEditForm();
}, 0);
}
}, [editingUser, editModal.open, editForm]);
const validateEditForm = () => {
const role = editForm.getFieldValue('role');
const status = editForm.getFieldValue('status');
const hasErrors = !role || !status;
setEditHasErrors(hasErrors);
};
// ==================== Изменение статуса ====================
const handleStatusChange = (user: User) => {
if (user.status === 'deleted') return;
const newStatus = user.status === 'active' ? 'frozen' : 'active';
const currentReason = user.reason && user.reason !== '-' ? user.reason : '';
setStatusModal({
open: true,
userId: user.id,
newStatus,
currentReason,
});
};
useEffect(() => {
if (statusModal.open) {
setTimeout(() => {
statusForm.setFieldsValue({ reason: statusModal.currentReason });
}, 0);
}
}, [statusModal.open, statusModal.currentReason, statusForm]);
const handleStatusSave = () => {
statusForm.validateFields().then((values) => {
if (!statusModal.userId) return;
const payload: any = { status: statusModal.newStatus };
if (values.reason && values.reason.trim() !== '') {
payload.reason = values.reason;
}
updateUser.mutate(
{ id: statusModal.userId, data: payload },
{ onSuccess: () => setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' }) }
);
});
};
// ==================== Удаление ====================
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить пользователя?',
content: 'Это действие нельзя отменить. При необходимости предварительно укажите причину через редактирование.',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteUser.mutate(id),
});
};
// ==================== Таблица ====================
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<User> | SorterResult<User>[]
) => {
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<User> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу пользователя">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/users/${record.id}`)}
/>
</Tooltip>
),
},
{ title: 'Email', dataIndex: 'email', key: 'email', sorter: true, ellipsis: true },
{ title: 'Ник', dataIndex: 'nickname', key: 'nickname', sorter: true, ellipsis: true },
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
width: 100,
sorter: true,
render: (role: string) => {
const color = role === 'user' ? 'blue' : role === 'bot' ? 'purple' : 'default';
return <Tag color={color}>{role}</Tag>;
},
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : status === 'frozen' ? 'orange' : 'red'}>
{status}
</Tag>
),
},
{ title: 'Последний вход', dataIndex: 'last_login', key: 'last_login', sorter: true },
{
title: 'Действия',
key: 'actions',
width: 120,
render: (_, record) => {
const isDeleted = record.status === 'deleted';
return (
<Space>
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record.id)}
/>
</Tooltip>
<Tooltip title={record.status === 'active' ? 'Заморозить' : 'Разморозить'}>
<Button
icon={record.status === 'active' ? <LockOutlined /> : <UnlockOutlined />}
size="small"
onClick={() => handleStatusChange(record)}
disabled={isDeleted}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
disabled={isDeleted}
/>
</Tooltip>
</Space>
);
},
},
];
return (
<div>
<h2>Пользователи</h2>
<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,
}}
/>
{/* Модальное окно редактирования */}
<Modal
title="Редактировать пользователя"
open={editModal.open}
onCancel={() => setEditModal({ open: false, userId: null })}
onOk={handleSaveEdit}
confirmLoading={updateUser.isPending}
destroyOnHidden
okButtonProps={{ disabled: editHasErrors }}
>
{loadingUser ? (
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
) : (
<Form form={editForm} layout="vertical" preserve={false} onValuesChange={validateEditForm}>
<Form.Item label="Email" name="email">
<Input />
</Form.Item>
<Form.Item label="Ник" name="nickname">
<Input />
</Form.Item>
<Form.Item
label="Роль"
name="role"
rules={[{ required: true, message: 'Выберите роль' }]}
>
<Select placeholder="Выберите роль">
<Select.Option value="user">user</Select.Option>
<Select.Option value="bot">bot</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="Статус"
name="status"
rules={[{ required: true, message: 'Выберите статус' }]}
>
<Select placeholder="Выберите статус">
<Select.Option value="active">active</Select.Option>
<Select.Option value="frozen">frozen</Select.Option>
<Select.Option value="deleted">deleted</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="Причина"
name="reason"
rules={[
({ getFieldValue }) => ({
validator(_, value) {
const status = getFieldValue('status');
if (status && status !== 'active' && (!value || value.trim() === '')) {
return Promise.reject(new Error('Укажите причину изменения статуса'));
}
return Promise.resolve();
},
}),
]}
>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
)}
</Modal>
{/* Модальное окно изменения статуса */}
<Modal
title={`Изменить статус на «${statusModal.newStatus}»`}
open={statusModal.open}
onCancel={() => setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' })}
onOk={handleStatusSave}
confirmLoading={updateUser.isPending}
destroyOnHidden
>
<Form form={statusForm} layout="vertical" preserve={false}>
<Form.Item
label="Причина"
name="reason"
rules={[
{
required: statusModal.newStatus !== 'active',
message: 'Укажите причину',
},
]}
>
<Input.TextArea rows={3} placeholder="Введите причину изменения статуса" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UserListPage;