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