feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
@@ -1,17 +1,98 @@
|
||||
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 React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Info, Pencil, Trash2 } from 'lucide-react';
|
||||
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';
|
||||
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useCalendars, useUpdateCalendar, useDeleteCalendar, useCalendar, useCalendarStats } from '@/hooks/useCalendars';
|
||||
import { useExploreView } from '@/hooks/useExploreView';
|
||||
import { Calendar as CalendarType, CalendarListParams } from '@/types/api';
|
||||
import { getSavedPageSize } from '@/utils/tablePagination';
|
||||
import { formatStatusLabel } from '@/utils/statusLabels';
|
||||
import { ExploreListShell } from '@/components/explore/ExploreListShell';
|
||||
import { ExploreSearch } from '@/components/explore/ExploreSearch';
|
||||
import { ExploreDataView } from '@/components/explore/ExploreDataView';
|
||||
import { ExploreInsightsCollapse } from '@/components/explore/ExploreInsightsCollapse';
|
||||
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
|
||||
import type { DenseRowModel } from '@/components/explore/DenseRowList';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EntitySlideOver, type ExplorePreviewTarget } from '@/components/EntitySlideOver';
|
||||
|
||||
const TABLE_KEY = 'calendars';
|
||||
|
||||
const calendarTypeBadgeVariant = (type: string) => {
|
||||
if (type === 'commercial') return 'secondary' as const;
|
||||
return 'default' as const;
|
||||
};
|
||||
|
||||
const calendarStatusVariant = (status: string) => {
|
||||
if (status === 'active') return 'default' as const;
|
||||
if (status === 'frozen') return 'secondary' as const;
|
||||
return 'destructive' as const;
|
||||
};
|
||||
|
||||
interface EditFormValues {
|
||||
title?: string;
|
||||
description?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
category?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function calendarActions(
|
||||
handlers: {
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
},
|
||||
t: TFunction
|
||||
): RowAction[] {
|
||||
return [
|
||||
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
|
||||
{ label: t('common.edit'), icon: <Pencil className="h-4 w-4" />, onClick: handlers.onEdit },
|
||||
{
|
||||
label: t('common.delete'),
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
onClick: handlers.onDelete,
|
||||
destructive: true,
|
||||
separatorBefore: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const CalendarListPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useState<CalendarListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'created_at', order: 'desc' });
|
||||
const { viewMode, setViewMode } = useExploreView();
|
||||
const [preview, setPreview] = useState<ExplorePreviewTarget>(null);
|
||||
const [params, setParams] = useState<CalendarListParams>({
|
||||
limit: getSavedPageSize(TABLE_KEY),
|
||||
offset: 0,
|
||||
sort: 'created_at',
|
||||
order: 'desc',
|
||||
});
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const { data, isLoading } = useCalendars(params);
|
||||
const { data: stats } = useCalendarStats();
|
||||
const updateCalendar = useUpdateCalendar();
|
||||
@@ -22,301 +103,398 @@ const CalendarListPage: React.FC = () => {
|
||||
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 editForm = useForm<EditFormValues>();
|
||||
const originalCalendarRef = useRef<CalendarType | null>(null);
|
||||
|
||||
const validateEditForm = useCallback(() => {
|
||||
const title = editForm.getFieldValue('title');
|
||||
const status = editForm.getFieldValue('status');
|
||||
setEditHasErrors(!title || !status);
|
||||
}, [editForm]);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; calendarId: string | null }>({
|
||||
open: false,
|
||||
calendarId: null,
|
||||
});
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
setEditModal({ open: true, calendarId: id });
|
||||
};
|
||||
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 handleSaveEdit = editForm.handleSubmit((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;
|
||||
const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]);
|
||||
allKeys.forEach((key) => {
|
||||
const newVal = (values as Record<string, unknown>)[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;
|
||||
}
|
||||
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 }) }
|
||||
);
|
||||
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);
|
||||
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
|
||||
editForm.reset({
|
||||
title: clean(editingCalendar.title) as string,
|
||||
description: clean(editingCalendar.description) as string,
|
||||
type: clean(editingCalendar.type) as string,
|
||||
status: clean(editingCalendar.status) as string,
|
||||
category: clean(editingCalendar.category) as string,
|
||||
reason: clean(editingCalendar.reason) as string,
|
||||
});
|
||||
}
|
||||
}, [editingCalendar, editModal.open, editForm, validateEditForm]);
|
||||
}, [editingCalendar, editModal.open, editForm]);
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
Modal.confirm({
|
||||
title: 'Удалить календарь?',
|
||||
okText: 'Удалить',
|
||||
okType: 'danger',
|
||||
cancelText: 'Отмена',
|
||||
onOk: () => deleteCalendar.mutate(id),
|
||||
const handleDelete = (id: string) => setDeleteConfirm({ open: true, calendarId: id });
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteConfirm.calendarId) return;
|
||||
deleteCalendar.mutate(deleteConfirm.calendarId, {
|
||||
onSuccess: () => setDeleteConfirm({ open: false, calendarId: null }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleTableChange = (
|
||||
pagination: { current?: number; pageSize?: number },
|
||||
_filters: unknown,
|
||||
sorter: SorterResult<Calendar> | SorterResult<Calendar>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
}, pagination, TABLE_KEY));
|
||||
const applySearch = () => {
|
||||
setParams((prev) => ({ ...prev, q: searchDraft.trim() || undefined, offset: 0 }));
|
||||
};
|
||||
|
||||
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>
|
||||
const exploreStats = useMemo(() => {
|
||||
if (!stats) return [];
|
||||
const items = [{ label: t('common.total'), value: stats.total_calendars }];
|
||||
Object.entries(stats.calendars_by_status || {})
|
||||
.slice(0, 3)
|
||||
.forEach(([status, count]) => {
|
||||
items.push({ label: formatStatusLabel(status), value: count });
|
||||
});
|
||||
return items.slice(0, 4);
|
||||
}, [stats, t]);
|
||||
|
||||
const columns = useMemo<ColumnDef<CalendarType>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: t('common.title'),
|
||||
enableSorting: true,
|
||||
cell: ({ row, getValue }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="text-left font-medium text-primary hover:underline"
|
||||
onClick={() => setPreview({ type: 'calendar', id: row.original.id })}
|
||||
>
|
||||
{getValue<string>() || row.original.id}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: t('common.type'),
|
||||
size: 110,
|
||||
enableSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
const type = getValue<string>();
|
||||
return <Badge variant={calendarTypeBadgeVariant(type)}>{type}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('common.status'),
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
const status = getValue<string>();
|
||||
return <Badge variant={calendarStatusVariant(status)}>{formatStatusLabel(status)}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'owner_id',
|
||||
header: t('common.owner'),
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => {
|
||||
const ownerId = getValue<string>();
|
||||
return <Link to={`/users/${ownerId}`}>{ownerId}</Link>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rating',
|
||||
header: t('common.rating'),
|
||||
size: 90,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => `${row.original.rating_avg} (${row.original.rating_count})`,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
size: 48,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
return (
|
||||
<RowActionsMenu
|
||||
actions={calendarActions({
|
||||
onOpen: () => navigate(`/calendars/${record.id}`),
|
||||
onEdit: () => handleEdit(record.id),
|
||||
onDelete: () => handleDelete(record.id),
|
||||
}, t)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[navigate, t]
|
||||
);
|
||||
|
||||
const editTitle = editForm.watch('title');
|
||||
const editStatus = editForm.watch('status');
|
||||
const editHasErrors = !editTitle || !editStatus;
|
||||
|
||||
const denseRows = useMemo<DenseRowModel[]>(() => {
|
||||
return (data?.data ?? []).map((record) => ({
|
||||
id: record.id,
|
||||
title: record.title || record.id,
|
||||
subtitle: record.owner_id ? t('explore.ownerId', { id: record.owner_id }) : undefined,
|
||||
badges: (
|
||||
<>
|
||||
<Badge variant={calendarTypeBadgeVariant(record.type)}>{record.type}</Badge>
|
||||
<Badge variant={calendarStatusVariant(record.status)}>{formatStatusLabel(record.status)}</Badge>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
meta: `★ ${record.rating_avg} (${record.rating_count})`,
|
||||
onActivate: () => setPreview({ type: 'calendar', id: record.id }),
|
||||
actions: calendarActions({
|
||||
onOpen: () => navigate(`/calendars/${record.id}`),
|
||||
onEdit: () => handleEdit(record.id),
|
||||
onDelete: () => handleDelete(record.id),
|
||||
}, t),
|
||||
}));
|
||||
}, [data?.data, navigate, t]);
|
||||
|
||||
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={[
|
||||
<>
|
||||
<ExploreListShell
|
||||
title={t('calendars.title')}
|
||||
description={t('explore.descPreviewTitle')}
|
||||
stats={exploreStats}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
toolbar={
|
||||
<ExploreSearch
|
||||
value={searchDraft}
|
||||
onChange={setSearchDraft}
|
||||
onSubmit={applySearch}
|
||||
placeholder={t('explore.searchCalendars')}
|
||||
>
|
||||
<Select
|
||||
value={params.status ?? 'all'}
|
||||
onValueChange={(v) =>
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
status: v === 'all' ? undefined : (v as CalendarListParams['status']),
|
||||
offset: 0,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder={t('common.status')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('common.allStatuses')}</SelectItem>
|
||||
<SelectItem value="active">active</SelectItem>
|
||||
<SelectItem value="frozen">frozen</SelectItem>
|
||||
<SelectItem value="deleted">deleted</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={params.type ?? 'all'}
|
||||
onValueChange={(v) =>
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
type: v === 'all' ? undefined : (v as CalendarListParams['type']),
|
||||
offset: 0,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder={t('common.type')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('common.allTypes')}</SelectItem>
|
||||
<SelectItem value="personal">personal</SelectItem>
|
||||
<SelectItem value="commercial">commercial</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ExploreSearch>
|
||||
}
|
||||
>
|
||||
{stats && (
|
||||
<ExploreInsightsCollapse
|
||||
title={t('explore.tops')}
|
||||
tabs={[
|
||||
{
|
||||
title: 'Название',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (title: string, record) => (
|
||||
<Link to={`/calendars/${record.id}`}>{title || record.id}</Link>
|
||||
),
|
||||
id: 'rating',
|
||||
label: t('explore.byRating'),
|
||||
rows: (stats.top_calendars_by_rating ?? []).map((row) => ({
|
||||
id: row.id,
|
||||
primary: <Link to={`/calendars/${row.id}`}>{row.title || row.id}</Link>,
|
||||
value: row.rating_avg,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: 'reviews',
|
||||
label: t('explore.byReviews'),
|
||||
rows: (stats.top_calendars_by_reviews ?? []).map((row) => ({
|
||||
id: row.id,
|
||||
primary: <Link to={`/calendars/${row.id}`}>{row.title || row.id}</Link>,
|
||||
value: row.rating_avg,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: 'positive',
|
||||
label: t('explore.positive'),
|
||||
rows: (stats.top_calendars_by_positive_reviews ?? []).map((row) => ({
|
||||
id: row.id,
|
||||
primary: <Link to={`/calendars/${row.id}`}>{row.title || row.id}</Link>,
|
||||
value: row.rating_avg,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: 'negative',
|
||||
label: t('explore.negative'),
|
||||
rows: (stats.top_calendars_by_negative_reviews ?? []).map((row) => ({
|
||||
id: row.id,
|
||||
primary: <Link to={`/calendars/${row.id}`}>{row.title || row.id}</Link>,
|
||||
value: row.rating_avg,
|
||||
})),
|
||||
},
|
||||
{ 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={getTablePagination(params, data?.total)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<ExploreDataView
|
||||
viewMode={viewMode}
|
||||
columns={columns}
|
||||
data={data?.data ?? []}
|
||||
denseRows={denseRows}
|
||||
total={data?.total}
|
||||
isLoading={isLoading}
|
||||
tableKey={TABLE_KEY}
|
||||
params={params}
|
||||
onParamsChange={setParams}
|
||||
/>
|
||||
</ExploreListShell>
|
||||
|
||||
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, calendarId: null })}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('calendars.editTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{loadingCalendar ? (
|
||||
<div className="space-y-3 py-4">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<form id="edit-calendar-form" className="space-y-4" onSubmit={handleSaveEdit}>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.title')}</Label>
|
||||
<Input {...editForm.register('title', { required: true })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.description')}</Label>
|
||||
<Textarea rows={3} {...editForm.register('description')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.type')}</Label>
|
||||
<Controller
|
||||
name="type"
|
||||
control={editForm.control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('common.selectType')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="personal">personal</SelectItem>
|
||||
<SelectItem value="commercial">commercial</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.status')}</Label>
|
||||
<Controller
|
||||
name="status"
|
||||
control={editForm.control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('common.selectStatus')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">active</SelectItem>
|
||||
<SelectItem value="frozen">frozen</SelectItem>
|
||||
<SelectItem value="deleted">deleted</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.category')}</Label>
|
||||
<Input {...editForm.register('category')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.reason')}</Label>
|
||||
<Input {...editForm.register('reason')} />
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditModal({ open: false, calendarId: null })}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" form="edit-calendar-form" disabled={editHasErrors || updateCalendar.isPending}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, calendarId: null })}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('calendars.deleteTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, calendarId: null })}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleteCalendar.isPending}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<EntitySlideOver target={preview} onOpenChange={(open) => !open && setPreview(null)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user