190cca7e6b
Refs EventHub/EventHubFrontAdmin#27 Co-authored-by: Cursor <cursoragent@cursor.com>
328 lines
14 KiB
TypeScript
328 lines
14 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, Card, Col, Row, Statistic } from 'antd';
|
||
import { InfoCircleOutlined, EditOutlined, DeleteOutlined, CalendarOutlined } from '@ant-design/icons';
|
||
import { Link, useNavigate } from 'react-router-dom';
|
||
import { useCalendars, useUpdateCalendar, useDeleteCalendar, useCalendar, useCalendarStats } from '../../hooks/useCalendars';
|
||
import { Calendar, CalendarListParams } from '../../types/api';
|
||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||
|
||
const CalendarListPage: React.FC = () => {
|
||
const navigate = useNavigate();
|
||
const [params, setParams] = useState<CalendarListParams>({ limit: 20, offset: 0, sort: 'created_at', order: 'desc' });
|
||
const { data, isLoading } = useCalendars(params);
|
||
const { data: stats } = useCalendarStats();
|
||
const updateCalendar = useUpdateCalendar();
|
||
const deleteCalendar = useDeleteCalendar();
|
||
|
||
const [editModal, setEditModal] = useState<{ open: boolean; calendarId: string | null }>({
|
||
open: false,
|
||
calendarId: null,
|
||
});
|
||
const { data: editingCalendar, isLoading: loadingCalendar } = useCalendar(editModal.calendarId || '');
|
||
const [editForm] = Form.useForm();
|
||
const [editHasErrors, setEditHasErrors] = useState(true);
|
||
const originalCalendarRef = useRef<Calendar | null>(null);
|
||
|
||
const validateEditForm = useCallback(() => {
|
||
const title = editForm.getFieldValue('title');
|
||
const status = editForm.getFieldValue('status');
|
||
setEditHasErrors(!title || !status);
|
||
}, [editForm]);
|
||
|
||
const handleEdit = (id: string) => {
|
||
setEditModal({ open: true, calendarId: id });
|
||
};
|
||
|
||
const handleSaveEdit = () => {
|
||
editForm.validateFields().then((values) => {
|
||
if (!editModal.calendarId || !originalCalendarRef.current) return;
|
||
const original = originalCalendarRef.current;
|
||
const cleanedValues: Record<string, unknown> = {};
|
||
|
||
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 unknown as Record<string, unknown>)[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 unknown as Record<string, unknown>)[key];
|
||
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
|
||
})
|
||
);
|
||
|
||
updateCalendar.mutate(
|
||
{ id: editModal.calendarId, data: payload },
|
||
{ onSuccess: () => setEditModal({ open: false, calendarId: null }) }
|
||
);
|
||
});
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (editingCalendar && editModal.open) {
|
||
originalCalendarRef.current = editingCalendar;
|
||
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? undefined : val);
|
||
setTimeout(() => {
|
||
editForm.setFieldsValue({
|
||
title: clean(editingCalendar.title),
|
||
description: clean(editingCalendar.description),
|
||
type: clean(editingCalendar.type),
|
||
status: clean(editingCalendar.status),
|
||
category: clean(editingCalendar.category),
|
||
reason: clean(editingCalendar.reason),
|
||
});
|
||
validateEditForm();
|
||
}, 0);
|
||
}
|
||
}, [editingCalendar, editModal.open, editForm, validateEditForm]);
|
||
|
||
const handleDelete = (id: string) => {
|
||
Modal.confirm({
|
||
title: 'Удалить календарь?',
|
||
okText: 'Удалить',
|
||
okType: 'danger',
|
||
cancelText: 'Отмена',
|
||
onOk: () => deleteCalendar.mutate(id),
|
||
});
|
||
};
|
||
|
||
const handleTableChange = (
|
||
pagination: { current?: number; pageSize?: number },
|
||
_filters: unknown,
|
||
sorter: SorterResult<Calendar> | SorterResult<Calendar>[]
|
||
) => {
|
||
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) - 1) * (pagination.pageSize || prev.limit || 20),
|
||
limit: pagination.pageSize || prev.limit,
|
||
}));
|
||
};
|
||
|
||
const columns: ColumnsType<Calendar> = [
|
||
{
|
||
title: <InfoCircleOutlined />,
|
||
key: 'detail',
|
||
width: 48,
|
||
align: 'center',
|
||
render: (_, record) => (
|
||
<Tooltip title="Открыть страницу календаря">
|
||
<Button
|
||
icon={<InfoCircleOutlined />}
|
||
size="small"
|
||
type="link"
|
||
onClick={() => navigate(`/calendars/${record.id}`)}
|
||
/>
|
||
</Tooltip>
|
||
),
|
||
},
|
||
{
|
||
title: 'Название',
|
||
dataIndex: 'title',
|
||
key: 'title',
|
||
ellipsis: true,
|
||
sorter: true,
|
||
},
|
||
{
|
||
title: 'Тип',
|
||
dataIndex: 'type',
|
||
key: 'type',
|
||
width: 110,
|
||
sorter: true,
|
||
render: (type: string) => (
|
||
<Tag color={type === 'commercial' ? 'gold' : 'blue'}>{type}</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: 'owner_id',
|
||
key: 'owner_id',
|
||
render: (ownerId: string) => (
|
||
<Link to={`/users/${ownerId}`}>{ownerId}</Link>
|
||
),
|
||
},
|
||
{
|
||
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>
|
||
{stats && (
|
||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||
<Col xs={12} sm={6} md={4}>
|
||
<Card>
|
||
<Statistic title="Всего" value={stats.total_calendars} prefix={<CalendarOutlined />} />
|
||
</Card>
|
||
</Col>
|
||
{Object.entries(stats.calendars_by_status || {}).map(([status, count]) => (
|
||
<Col xs={12} sm={6} md={4} key={status}>
|
||
<Card>
|
||
<Statistic title={status} value={count} />
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
{Object.entries(stats.calendars_by_type || {}).map(([type, count]) => (
|
||
<Col xs={12} sm={6} md={4} key={`type-${type}`}>
|
||
<Card>
|
||
<Statistic title={`Тип: ${type}`} value={count} />
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
)}
|
||
{stats?.top_calendars_by_rating && stats.top_calendars_by_rating.length > 0 && (
|
||
<Card title="Топ календарей по рейтингу" style={{ marginBottom: 16 }}>
|
||
<Table
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="id"
|
||
dataSource={stats.top_calendars_by_rating}
|
||
columns={[
|
||
{
|
||
title: 'Название',
|
||
dataIndex: 'title',
|
||
key: 'title',
|
||
render: (title: string, record) => (
|
||
<Link to={`/calendars/${record.id}`}>{title || record.id}</Link>
|
||
),
|
||
},
|
||
{ title: 'Рейтинг', dataIndex: 'rating_avg', key: 'rating_avg' },
|
||
{ title: 'Отзывов', dataIndex: 'review_count', key: 'review_count' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
)}
|
||
<Space style={{ marginBottom: 16 }} wrap>
|
||
<Input.Search
|
||
placeholder="Поиск"
|
||
allowClear
|
||
onSearch={(q) => setParams(prev => ({ ...prev, q: q || undefined, offset: 0 }))}
|
||
style={{ width: 220 }}
|
||
/>
|
||
<Select
|
||
placeholder="Статус"
|
||
allowClear
|
||
style={{ width: 140 }}
|
||
onChange={(status) => setParams(prev => ({ ...prev, status, offset: 0 }))}
|
||
options={[
|
||
{ value: 'active', label: 'active' },
|
||
{ value: 'frozen', label: 'frozen' },
|
||
{ value: 'deleted', label: 'deleted' },
|
||
]}
|
||
/>
|
||
<Select
|
||
placeholder="Тип"
|
||
allowClear
|
||
style={{ width: 140 }}
|
||
onChange={(type) => setParams(prev => ({ ...prev, type, offset: 0 }))}
|
||
options={[
|
||
{ value: 'personal', label: 'personal' },
|
||
{ value: 'commercial', label: 'commercial' },
|
||
]}
|
||
/>
|
||
</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, calendarId: null })}
|
||
onOk={handleSaveEdit}
|
||
confirmLoading={updateCalendar.isPending}
|
||
destroyOnHidden
|
||
width={640}
|
||
okButtonProps={{ disabled: editHasErrors }}
|
||
>
|
||
{loadingCalendar ? (
|
||
<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="type">
|
||
<Select>
|
||
<Select.Option value="personal">personal</Select.Option>
|
||
<Select.Option value="commercial">commercial</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="frozen">frozen</Select.Option>
|
||
<Select.Option value="deleted">deleted</Select.Option>
|
||
</Select>
|
||
</Form.Item>
|
||
<Form.Item label="Категория" name="category">
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item label="Причина" name="reason">
|
||
<Input />
|
||
</Form.Item>
|
||
</Form>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default CalendarListPage;
|