376 lines
16 KiB
TypeScript
376 lines
16 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
||
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, Card, Col, Row, Statistic } from 'antd';
|
||
import { InfoCircleOutlined, EditOutlined, LockOutlined, UnlockOutlined, DeleteOutlined, UserOutlined } from '@ant-design/icons';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useUsers, useUpdateUser, useDeleteUser, useUser, useUserStats } 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 { data: stats } = useUserStats();
|
||
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>
|
||
{stats && (
|
||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||
<Col xs={12} sm={6} md={4}>
|
||
<Card>
|
||
<Statistic title="Всего" value={stats.total_users} prefix={<UserOutlined />} />
|
||
</Card>
|
||
</Col>
|
||
<Col xs={12} sm={6} md={4}>
|
||
<Card>
|
||
<Statistic title="Неподтверждённые" value={stats.pending_users} />
|
||
</Card>
|
||
</Col>
|
||
{Object.entries(stats.users_by_status || {}).map(([status, count]) => (
|
||
<Col xs={12} sm={6} md={4} key={status}>
|
||
<Card>
|
||
<Statistic title={status} value={count} />
|
||
</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,
|
||
}}
|
||
/>
|
||
|
||
{/* Модальное окно редактирования */}
|
||
<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; |