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

This commit is contained in:
2026-05-23 20:43:21 +03:00
parent 36c95b71a4
commit f5961c5529
68 changed files with 10374 additions and 3 deletions
+345
View File
@@ -0,0 +1,345 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, InputNumber, message, Tooltip, Input, Spin } from 'antd';
import { InfoCircleOutlined, EditOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { useReviews, useUpdateReview, useBulkUpdateReviews, useReview } from '../../hooks/useReviews';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { Review, ReviewListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
const ReviewListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<ReviewListParams>({ limit: 20, offset: 0, sort: 'created_at', order: 'desc' });
const { data, isLoading } = useReviews(params);
const updateReview = useUpdateReview();
const bulkUpdate = useBulkUpdateReviews();
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
// Модальное окно для индивидуального редактирования
const [editModal, setEditModal] = useState<{ open: boolean; reviewId: string | null }>({
open: false,
reviewId: null,
});
const [editForm] = Form.useForm();
const { data: editingReview, isLoading: loadingReview } = useReview(editModal.reviewId || '');
// Модальное окно для массового изменения статуса с причиной
const [bulkStatusModal, setBulkStatusModal] = useState<{
open: boolean;
status: string;
}>({ open: false, status: 'hidden' });
const [bulkStatusForm] = Form.useForm();
// ---- Индивидуальное редактирование ----
const handleEdit = (id: string) => {
setEditModal({ open: true, reviewId: id });
};
const handleSaveEdit = () => {
editForm.validateFields().then((values) => {
if (!editModal.reviewId) return;
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateReview.mutate(
{ id: editModal.reviewId, data: payload },
{ onSuccess: () => setEditModal({ open: false, reviewId: null }) }
);
});
};
useEffect(() => {
if (editingReview && editModal.open) {
setTimeout(() => {
editForm.setFieldsValue({
status: editingReview.status,
reason: editingReview.reason,
comment: editingReview.comment,
rating: editingReview.rating,
});
}, 0);
}
}, [editingReview, editModal.open, editForm]);
// ---- Массовое изменение статуса ----
const openBulkStatusModal = (status: string) => {
if (selectedRowKeys.length === 0) {
message.warning('Выберите отзывы');
return;
}
setBulkStatusModal({ open: true, status });
bulkStatusForm.resetFields();
};
const handleBulkStatusSubmit = () => {
bulkStatusForm.validateFields().then((values) => {
const reason = values.reason ? values.reason.trim() : '';
const updates = selectedRowKeys.map(id => ({
id: id as string,
status: bulkStatusModal.status,
reason: reason || undefined, // если пусто, не передаем
}));
bulkUpdate.mutate(updates, {
onSuccess: () => {
setBulkStatusModal({ open: false, status: 'hidden' });
setSelectedRowKeys([]);
},
});
});
};
// ---- Таблица ----
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<Review> | SorterResult<Review>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current - 1) * pagination.pageSize) || 0,
limit: pagination.pageSize || prev.limit,
}));
};
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading: loading } = useUser(userId);
if (loading) return <Spin size="small" />;
if (!user) return <span>{userId}</span>;
const nick = user.nickname;
const email = user.email;
let name = '';
if (!isBadValue(nick)) {
name = nick;
} else if (!isBadValue(email)) {
name = email;
} else {
name = user.id;
}
return <Link to={`/users/${user.id}`}>{name}</Link>;
};
const TargetCell: React.FC<{ targetType: string; targetId: string }> = ({ targetType, targetId }) => {
const { data: event, isLoading: loading } = useEvent(targetType === 'event' ? targetId : '');
if (targetType === 'event') {
if (loading) return <Spin size="small" />;
if (event) {
const name = event.title || event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
return <span>{targetId}</span>;
}
return <span>{targetId}</span>;
};
const typeColors: Record<string, string> = {
calendar: 'green',
event: 'blue',
review: 'purple',
};
const columns: ColumnsType<Review> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу отзыва">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/reviews/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Пользователь',
key: 'user',
ellipsis: true,
render: (_, record) => <UserCell userId={record.user_id} />,
},
{
title: 'Тип',
dataIndex: 'target_type',
key: 'target_type',
width: 100,
sorter: true,
render: (type: string) => (
<Tag color={typeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Цель',
key: 'target',
ellipsis: true,
render: (_, record) => <TargetCell targetType={record.target_type} targetId={record.target_id} />,
},
{
title: 'Оценка',
dataIndex: 'rating',
key: 'rating',
width: 140,
sorter: true,
render: (rating: number) => '⭐'.repeat(rating),
},
{
title: 'Комментарий',
dataIndex: 'comment',
key: 'comment',
ellipsis: true,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'visible' ? 'green' : status === 'hidden' ? 'orange' : 'red'}>
{status}
</Tag>
),
},
{
title: 'Лайки / Дизлайки',
key: 'likes',
width: 120,
render: (_, record) => {
const likes = isBadValue(record.likes) ? 0 : record.likes;
const dislikes = isBadValue(record.dislikes) ? 0 : record.dislikes;
return `${likes} / ${dislikes}`;
},
},
{
title: '',
key: 'actions',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record.id)}
/>
</Tooltip>
),
},
];
return (
<div>
<h2>Отзывы</h2>
<Space style={{ marginBottom: 16 }}>
<Button onClick={() => openBulkStatusModal('hidden')} disabled={selectedRowKeys.length === 0}>
Скрыть выбранные
</Button>
<Button onClick={() => openBulkStatusModal('visible')} disabled={selectedRowKeys.length === 0}>
Показать выбранные
</Button>
<Button danger onClick={() => openBulkStatusModal('deleted')} disabled={selectedRowKeys.length === 0}>
Удалить выбранные
</Button>
</Space>
<Table
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
}}
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={{
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/>
{/* Модальное окно индивидуального редактирования */}
<Modal
title="Редактировать отзыв"
open={editModal.open}
onCancel={() => setEditModal({ open: false, reviewId: null })}
onOk={handleSaveEdit}
confirmLoading={updateReview.isPending}
destroyOnHidden
>
{loadingReview ? (
<Spin />
) : (
<Form form={editForm} layout="vertical" preserve={false}>
<Form.Item label="Оценка" name="rating">
<InputNumber min={1} max={5} />
</Form.Item>
<Form.Item label="Комментарий" name="comment">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item label="Статус" name="status">
<Select>
<Select.Option value="visible">visible</Select.Option>
<Select.Option value="hidden">hidden</Select.Option>
<Select.Option value="deleted">deleted</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="Причина"
name="reason"
rules={[
({ getFieldValue }) => ({
validator(_, value) {
const status = getFieldValue('status');
if ((status === 'hidden' || status === 'deleted') && (!value || value.trim() === '')) {
return Promise.reject(new Error('Укажите причину изменения статуса'));
}
return Promise.resolve();
},
}),
]}
>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
)}
</Modal>
{/* Модальное окно для массового изменения с причиной */}
<Modal
title={`Изменить статус на «${bulkStatusModal.status}» для ${selectedRowKeys.length} отзывов`}
open={bulkStatusModal.open}
onCancel={() => setBulkStatusModal({ open: false, status: 'hidden' })}
onOk={handleBulkStatusSubmit}
confirmLoading={bulkUpdate.isPending}
destroyOnHidden
>
<Form form={bulkStatusForm} layout="vertical" preserve={false}>
<Form.Item
label="Причина"
name="reason"
rules={[
{
required: bulkStatusModal.status === 'hidden' || bulkStatusModal.status === 'deleted',
message: 'Укажите причину',
},
]}
>
<Input.TextArea rows={3} placeholder="Общая причина для выбранных отзывов" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ReviewListPage;