Разработка админ-панели EventHubFrontAdmin v1.0 #1
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user