feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
CI / test (push) Failing after 3m9s
CI / push-image (push) Has been skipped
CI / ci-done (push) Failing after 0s

Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
2026-07-14 17:58:33 +03:00
parent de379e28bf
commit fd2c1f10ad
95 changed files with 12699 additions and 4410 deletions
+283 -135
View File
@@ -1,160 +1,308 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Modal, Form, Input, Select, message } from 'antd';
import { EditOutlined } from '@ant-design/icons';
import { useAdmin, useUpdateAdmin } from '../../hooks/useAdmins';
import { useTranslation } from 'react-i18next';
import { useForm, Controller } from 'react-hook-form';
import dayjs from 'dayjs';
import { ArrowLeft, Pencil } from 'lucide-react';
import { useAdmin, useUpdateAdmin } from '@/hooks/useAdmins';
import { formatDisplayValue } from '@/lib/utils';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? undefined : val);
const roleBadgeVariant = (role: string) => {
if (role === 'superadmin') return 'destructive' as const;
if (role === 'admin') return 'default' as const;
if (role === 'moderator') return 'secondary' as const;
return 'outline' as const;
};
interface AdminFormValues {
nickname?: string;
email?: string;
role?: string;
status?: string;
timezone?: string;
language?: string;
phone?: string;
}
const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => {
const text = formatDisplayValue(value);
if (text === '-') return <>{emptyLabel}</>;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return <pre className="overflow-auto rounded-md bg-muted p-2 text-xs">{text}</pre>;
}
return <>{text}</>;
};
const AdminDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: admin, isLoading } = useAdmin(id || '');
const updateAdmin = useUpdateAdmin();
const [editModal, setEditModal] = useState(false);
const [form] = Form.useForm();
// Очистка значения
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
// Заполнение формы при открытии
useEffect(() => {
if (editModal && admin) {
setTimeout(() => {
form.setFieldsValue({
nickname: clean(admin.nickname),
email: clean(admin.email),
role: clean(admin.role),
status: clean(admin.status),
timezone: clean(admin.timezone),
language: clean(admin.language),
phone: clean(admin.phone),
});
}, 0);
}
}, [editModal, admin, form]);
const handleSave = () => {
form.validateFields().then((values) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: id!, data: payload },
{
onSuccess: () => {
setEditModal(false);
message.success('Данные обновлены');
},
}
);
});
};
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const form = useForm<AdminFormValues>();
const emDash = t('common.emDash');
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
if (isBadValue(dateStr)) return emDash;
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
const roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
useEffect(() => {
if (editModal && admin) {
form.reset({
nickname: clean(admin.nickname) as string | undefined,
email: clean(admin.email) as string | undefined,
role: clean(admin.role) as string | undefined,
status: clean(admin.status) as string | undefined,
timezone: clean(admin.timezone) as string | undefined,
language: clean(admin.language) as string | undefined,
phone: clean(admin.phone) as string | undefined,
});
}
}, [editModal, admin, form]);
const onSave = (values: AdminFormValues) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: id!, data: payload },
{
onSuccess: () => {
setEditModal(false);
},
}
);
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!admin) return <p>Администратор не найден</p>;
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!admin) return <p className="text-muted-foreground">{t('admins.notFound')}</p>;
const titleName = !isBadValue(admin.nickname) ? admin.nickname : admin.email || admin.id;
return (
<Card
title={`Администратор ${!isBadValue(admin.nickname) ? admin.nickname : admin.email || admin.id}`}
extra={
<Button icon={<EditOutlined />} onClick={() => setEditModal(true)}>
Редактировать
</Button>
}
>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="ID">{admin.id}</Descriptions.Item>
<Descriptions.Item label="Email">{admin.email}</Descriptions.Item>
<Descriptions.Item label="Ник">{isBadValue(admin.nickname) ? '-' : admin.nickname}</Descriptions.Item>
<Descriptions.Item label="Роль">
<Tag color={roleColors[admin.role] || 'default'}>{admin.role}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={admin.status === 'active' ? 'green' : 'red'}>{admin.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Часовой пояс">{isBadValue(admin.timezone) ? '-' : admin.timezone}</Descriptions.Item>
<Descriptions.Item label="Язык">{isBadValue(admin.language) ? '-' : admin.language}</Descriptions.Item>
<Descriptions.Item label="Телефон">{isBadValue(admin.phone) ? '-' : admin.phone}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">{isBadValue(admin.avatar_url) ? '-' : admin.avatar_url}</Descriptions.Item>
<Descriptions.Item label="Настройки">{isBadValue(admin.preferences) ? '-' : JSON.stringify(admin.preferences)}</Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(admin.last_login)}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(admin.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(admin.updated_at)}</Descriptions.Item>
</Descriptions>
<>
<PageHeader
title={t('admins.detailTitle', { name: titleName })}
breadcrumbs={[
{ label: t('admins.title'), href: '/admins' },
{ label: String(titleName) },
]}
actions={
<>
<Button variant="outline" size="sm" onClick={() => setEditModal(true)}>
<Pencil className="h-4 w-4" />
{t('common.edit')}
</Button>
<Button variant="outline" onClick={() => navigate('/admins')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
</>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{admin.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.email')}</dt>
<dd>{admin.email}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.nickname')}</dt>
<dd>{isBadValue(admin.nickname) ? emDash : admin.nickname}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.role')}</dt>
<dd>
<Badge variant={roleBadgeVariant(admin.role)}>{admin.role}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={admin.status === 'active' ? 'success' : 'destructive'}>{admin.status}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.timezone')}</dt>
<dd>{isBadValue(admin.timezone) ? emDash : admin.timezone}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.language')}</dt>
<dd>{isBadValue(admin.language) ? emDash : admin.language}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.phone')}</dt>
<dd>{isBadValue(admin.phone) ? emDash : admin.phone}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.avatarUrl')}</dt>
<dd>{isBadValue(admin.avatar_url) ? emDash : admin.avatar_url}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.preferencesJson')}</dt>
<dd><DetailValue value={admin.preferences} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.lastLogin')}</dt>
<dd>{formatDate(admin.last_login)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{formatDate(admin.created_at)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.updatedAt')}</dt>
<dd>{formatDate(admin.updated_at)}</dd>
</div>
</dl>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/admins')}>Назад к списку</Button>
</CardContent>
</Card>
<Modal
title="Редактировать администратора"
open={editModal}
onCancel={() => setEditModal(false)}
onOk={handleSave}
confirmLoading={updateAdmin.isPending}
destroyOnHidden
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item name="nickname" label="Ник">
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
<Form.Item name="role" label="Роль">
<Select>
<Select.Option value="superadmin">superadmin</Select.Option>
<Select.Option value="admin">admin</Select.Option>
<Select.Option value="moderator">moderator</Select.Option>
<Select.Option value="support">support</Select.Option>
</Select>
</Form.Item>
<Form.Item name="status" label="Статус">
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="blocked">blocked</Select.Option>
</Select>
</Form.Item>
<Form.Item name="timezone" label="Часовой пояс">
<Select placeholder="Выберите пояс" allowClear>
<Select.Option value="UTC">UTC</Select.Option>
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="language" label="Язык">
<Select placeholder="Выберите язык" allowClear>
<Select.Option value="ru">Русский</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
<Form.Item name="phone" label="Телефон">
<Input placeholder="+7 (999) 123-45-67" />
</Form.Item>
</Form>
</Modal>
</Card>
<Dialog open={editModal} onOpenChange={setEditModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admins.editTitle')}</DialogTitle>
</DialogHeader>
<form id="admin-edit-form" className="space-y-4" onSubmit={form.handleSubmit(onSave)}>
<div className="space-y-2">
<Label htmlFor="nickname">{t('common.nickname')}</Label>
<Input id="nickname" {...form.register('nickname')} />
</div>
<div className="space-y-2">
<Label htmlFor="email">{t('common.email')}</Label>
<Input id="email" {...form.register('email')} />
</div>
<div className="space-y-2">
<Label>{t('common.role')}</Label>
<Controller
name="role"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="superadmin">superadmin</SelectItem>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="moderator">moderator</SelectItem>
<SelectItem value="support">support</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active">active</SelectItem>
<SelectItem value="blocked">blocked</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.timezone')}</Label>
<Controller
name="timezone"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue placeholder={t('common.selectTimezone')} /></SelectTrigger>
<SelectContent>
<SelectItem value="UTC">UTC</SelectItem>
<SelectItem value="Europe/Moscow">Europe/Moscow (Москва)</SelectItem>
<SelectItem value="Europe/London">Europe/London (Лондон)</SelectItem>
<SelectItem value="Europe/Berlin">Europe/Berlin (Берлин)</SelectItem>
<SelectItem value="America/New_York">America/New_York (Нью-Йорк)</SelectItem>
<SelectItem value="Asia/Tokyo">Asia/Tokyo (Токио)</SelectItem>
<SelectItem value="Asia/Shanghai">Asia/Shanghai (Шанхай)</SelectItem>
<SelectItem value="Australia/Sydney">Australia/Sydney (Сидней)</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.language')}</Label>
<Controller
name="language"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue placeholder={t('common.selectLanguage')} /></SelectTrigger>
<SelectContent>
<SelectItem value="ru">{t('common.langRu')}</SelectItem>
<SelectItem value="en">{t('common.langEn')}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t('common.phone')}</Label>
<Input id="phone" placeholder="+7 (999) 123-45-67" {...form.register('phone')} />
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal(false)}>{t('common.cancel')}</Button>
<Button type="submit" form="admin-edit-form" disabled={updateAdmin.isPending}>
{updateAdmin.isPending ? t('common.saving') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default AdminDetailPage;
export default AdminDetailPage;
+416 -265
View File
@@ -1,301 +1,452 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Input, Select, Tooltip, Spin } from 'antd';
import { InfoCircleOutlined, EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Info, Pencil, Trash2, Plus } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useAdmins, useCreateAdmin, useUpdateAdmin, useDeleteAdmin, useAdmin } from '../../hooks/useAdmins';
import { Admin, AdminListParams } 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 { useAdmins, useCreateAdmin, useUpdateAdmin, useDeleteAdmin, useAdmin } from '@/hooks/useAdmins';
import { useExploreView } from '@/hooks/useExploreView';
import { Admin, AdminListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
import { ExploreDataView } from '@/components/explore/ExploreDataView';
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 { 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';
const TABLE_KEY = 'admins';
const roleBadgeVariant = (role: string) => {
if (role === 'superadmin') return 'destructive' as const;
if (role === 'admin') return 'default' as const;
if (role === 'moderator') return 'secondary' as const;
return 'outline' as const;
};
const statusBadgeVariant = (status: string) => (status === 'active' ? 'success' as const : 'destructive' as const);
interface CreateFormValues {
email: string;
password: string;
role: string;
}
interface EditFormValues {
nickname?: string;
email?: string;
role?: string;
status?: string;
timezone?: string;
language?: string;
phone?: string;
}
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? undefined : val);
function adminActions(
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 AdminListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<AdminListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'email', order: 'asc' });
const { viewMode, setViewMode } = useExploreView();
const [params, setParams] = useState<AdminListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'email',
order: 'asc',
});
const { data, isLoading } = useAdmins(params);
const createAdmin = useCreateAdmin();
const updateAdmin = useUpdateAdmin();
const deleteAdmin = useDeleteAdmin();
// Модальное окно создания
const [createModal, setCreateModal] = useState(false);
const [createForm] = Form.useForm();
const createForm = useForm<CreateFormValues>({ defaultValues: { role: 'admin' } });
// Модальное окно редактирования
const [editModal, setEditModal] = useState<{ open: boolean; adminId: string | null }>({
open: false,
adminId: null,
});
const { data: editingAdmin, isLoading: loadingAdmin } = useAdmin(editModal.adminId || '');
const [editForm] = Form.useForm();
const editForm = useForm<EditFormValues>();
// Очистка значения от "undefined" и "-"
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; adminId: string | null }>({
open: false,
adminId: null,
});
// Заполнение формы редактирования при загрузке данных
useEffect(() => {
if (editModal.open && editingAdmin) {
setTimeout(() => {
editForm.setFieldsValue({
nickname: clean(editingAdmin.nickname),
email: clean(editingAdmin.email),
role: clean(editingAdmin.role),
status: clean(editingAdmin.status),
timezone: clean(editingAdmin.timezone),
language: clean(editingAdmin.language),
phone: clean(editingAdmin.phone),
});
}, 0);
editForm.reset({
nickname: clean(editingAdmin.nickname) as string,
email: clean(editingAdmin.email) as string,
role: clean(editingAdmin.role) as string,
status: clean(editingAdmin.status) as string,
timezone: clean(editingAdmin.timezone) as string,
language: clean(editingAdmin.language) as string,
phone: clean(editingAdmin.phone) as string,
});
}
}, [editingAdmin, editModal.open, editForm]);
const handleCreate = () => {
createForm.validateFields().then((values) => {
createAdmin.mutate(values, {
onSuccess: () => {
setCreateModal(false);
createForm.resetFields();
useEffect(() => {
if (createModal) {
createForm.reset({ email: '', password: '', role: 'admin' });
}
}, [createModal, createForm]);
const handleCreate = createForm.handleSubmit((values) => {
createAdmin.mutate(values, {
onSuccess: () => {
setCreateModal(false);
createForm.reset();
},
});
});
const handleEdit = (admin: Admin) => setEditModal({ open: true, adminId: admin.id });
const handleUpdate = editForm.handleSubmit((values) => {
if (!editModal.adminId) return;
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: editModal.adminId, data: payload },
{ onSuccess: () => setEditModal({ open: false, adminId: null }) }
);
});
const handleDelete = (id: string) => setDeleteConfirm({ open: true, adminId: id });
const confirmDelete = () => {
if (!deleteConfirm.adminId) return;
deleteAdmin.mutate(deleteConfirm.adminId, {
onSuccess: () => setDeleteConfirm({ open: false, adminId: null }),
});
};
const exploreStats = useMemo(() => {
if (data?.total === undefined) return [];
return [{ label: t('common.total'), value: data.total }];
}, [data?.total, t]);
const columns = useMemo<ColumnDef<Admin>[]>(
() => [
{ accessorKey: 'email', header: t('common.email'), enableSorting: true },
{ accessorKey: 'nickname', header: t('common.nickname'), enableSorting: true },
{
accessorKey: 'role',
header: t('common.role'),
enableSorting: true,
cell: ({ getValue }) => {
const role = getValue<string>();
return <Badge variant={roleBadgeVariant(role)}>{role}</Badge>;
},
});
});
};
const handleEdit = (admin: Admin) => {
// Не сбрасываем форму вручную, destroyOnHidden очистит её после предыдущего закрытия
setEditModal({ open: true, adminId: admin.id });
};
const handleUpdate = () => {
editForm.validateFields().then((values) => {
if (!editModal.adminId) return;
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: editModal.adminId, data: payload },
{ onSuccess: () => setEditModal({ open: false, adminId: null }) }
);
});
};
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить администратора?',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteAdmin.mutate(id),
});
};
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<Admin> | SorterResult<Admin>[]
) => {
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 roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
};
const columns: ColumnsType<Admin> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу администратора">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/admins/${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: 120,
sorter: true,
render: (role: string) => (
<Tag color={roleColors[role] || 'default'}>{role}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : 'red'}>{status}</Tag>
),
},
{
title: 'Последний вход',
dataIndex: 'last_login',
key: 'last_login',
sorter: true,
},
{
title: 'Действия',
key: 'actions',
width: 80,
render: (_, record) => (
<Space>
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record)}
},
{
accessorKey: 'status',
header: t('common.status'),
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={statusBadgeVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{ accessorKey: 'last_login', header: t('common.lastLogin'), enableSorting: true },
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={adminActions({
onOpen: () => navigate(`/admins/${record.id}`),
onEdit: () => handleEdit(record),
onDelete: () => handleDelete(record.id),
}, t)}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
/>
</Tooltip>
</Space>
);
},
},
],
[navigate, t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => ({
id: record.id,
title: record.email,
subtitle: record.nickname && record.nickname !== '-' ? record.nickname : undefined,
badges: (
<>
<Badge variant={roleBadgeVariant(record.role)}>{record.role}</Badge>
<Badge variant={statusBadgeVariant(record.status)}>{formatStatusLabel(record.status)}</Badge>
</>
),
},
];
meta: record.last_login ? t('explore.loginAt', { date: record.last_login }) : undefined,
onActivate: () => navigate(`/admins/${record.id}`),
actions: adminActions({
onOpen: () => navigate(`/admins/${record.id}`),
onEdit: () => handleEdit(record),
onDelete: () => handleDelete(record.id),
}, t),
}));
}, [data?.data, navigate, t]);
return (
<div>
<h2>Администраторы</h2>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModal(true)}
style={{ marginBottom: 16 }}
<>
<ExploreListShell
title={t('admins.title')}
description={t('explore.descRowOpen')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
actions={
<Button onClick={() => setCreateModal(true)}>
<Plus className="mr-2 h-4 w-4" />
{t('admins.add')}
</Button>
}
>
Добавить администратора
</Button>
<Table
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
/>
<ExploreDataView
viewMode={viewMode}
columns={columns}
data={data?.data ?? []}
denseRows={denseRows}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</ExploreListShell>
{/* Модальное окно создания */}
<Modal
title="Создать администратора"
open={createModal}
onCancel={() => setCreateModal(false)}
onOk={handleCreate}
confirmLoading={createAdmin.isPending}
destroyOnHidden
>
<Form form={createForm} layout="vertical" preserve={false}>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Form.Item name="role" label="Роль" rules={[{ required: true }]}>
<Select>
<Select.Option value="admin">admin</Select.Option>
<Select.Option value="moderator">moderator</Select.Option>
<Select.Option value="support">support</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
<Dialog open={createModal} onOpenChange={setCreateModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admins.createTitle')}</DialogTitle>
</DialogHeader>
<form id="create-admin-form" className="space-y-4" onSubmit={handleCreate}>
<div className="space-y-2">
<Label>{t('common.email')}</Label>
<Input
type="email"
{...createForm.register('email', { required: true })}
/>
</div>
<div className="space-y-2">
<Label>{t('common.password')}</Label>
<Input
type="password"
{...createForm.register('password', { required: true })}
/>
</div>
<div className="space-y-2">
<Label>{t('common.role')}</Label>
<Controller
name="role"
control={createForm.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectRole')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="moderator">moderator</SelectItem>
<SelectItem value="support">support</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateModal(false)}>
{t('common.cancel')}
</Button>
<Button type="submit" form="create-admin-form" disabled={createAdmin.isPending}>
{t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Модальное окно редактирования */}
<Modal
title="Редактировать администратора"
open={editModal.open}
onCancel={() => setEditModal({ open: false, adminId: null })}
onOk={handleUpdate}
confirmLoading={updateAdmin.isPending}
destroyOnHidden
>
{loadingAdmin ? (
<Spin />
) : (
<Form form={editForm} layout="vertical" preserve={false}>
<Form.Item name="nickname" label="Ник">
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
<Form.Item name="role" label="Роль">
<Select>
<Select.Option value="superadmin">superadmin</Select.Option>
<Select.Option value="admin">admin</Select.Option>
<Select.Option value="moderator">moderator</Select.Option>
<Select.Option value="support">support</Select.Option>
</Select>
</Form.Item>
<Form.Item name="status" label="Статус">
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="blocked">blocked</Select.Option>
</Select>
</Form.Item>
<Form.Item name="timezone" label="Часовой пояс">
<Select placeholder="Выберите пояс" allowClear>
<Select.Option value="UTC">UTC</Select.Option>
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="language" label="Язык">
<Select placeholder="Выберите язык" allowClear>
<Select.Option value="ru">Русский</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
<Form.Item name="phone" label="Телефон">
<Input placeholder="+7 (999) 123-45-67" />
</Form.Item>
</Form>
)}
</Modal>
</div>
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, adminId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admins.editTitle')}</DialogTitle>
</DialogHeader>
{loadingAdmin ? (
<div className="space-y-3 py-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<form id="edit-admin-form" className="space-y-4" onSubmit={handleUpdate}>
<div className="space-y-2">
<Label>{t('common.nickname')}</Label>
<Input {...editForm.register('nickname')} />
</div>
<div className="space-y-2">
<Label>{t('common.email')}</Label>
<Input {...editForm.register('email')} />
</div>
<div className="space-y-2">
<Label>{t('common.role')}</Label>
<Controller
name="role"
control={editForm.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectRole')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="superadmin">superadmin</SelectItem>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="moderator">moderator</SelectItem>
<SelectItem value="support">support</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={editForm.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">active</SelectItem>
<SelectItem value="blocked">blocked</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.timezone')}</Label>
<Controller
name="timezone"
control={editForm.control}
render={({ field }) => (
<Select value={field.value ?? ''} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectTimezone')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="UTC">UTC</SelectItem>
<SelectItem value="Europe/Moscow">Europe/Moscow (Москва)</SelectItem>
<SelectItem value="Europe/London">Europe/London (Лондон)</SelectItem>
<SelectItem value="Europe/Berlin">Europe/Berlin (Берлин)</SelectItem>
<SelectItem value="America/New_York">America/New_York (Нью-Йорк)</SelectItem>
<SelectItem value="Asia/Tokyo">Asia/Tokyo (Токио)</SelectItem>
<SelectItem value="Asia/Shanghai">Asia/Shanghai (Шанхай)</SelectItem>
<SelectItem value="Australia/Sydney">Australia/Sydney (Сидней)</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.language')}</Label>
<Controller
name="language"
control={editForm.control}
render={({ field }) => (
<Select value={field.value ?? ''} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectLanguage')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ru">{t('common.langRu')}</SelectItem>
<SelectItem value="en">{t('common.langEn')}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.phone')}</Label>
<Input placeholder="+7 (999) 123-45-67" {...editForm.register('phone')} />
</div>
</form>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal({ open: false, adminId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-admin-form" disabled={updateAdmin.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, adminId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admins.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, adminId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteAdmin.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default AdminListPage;
export default AdminListPage;
+267 -210
View File
@@ -1,236 +1,293 @@
import React, { useState, useEffect } from 'react';
import { Table, Tag, Space, Select, DatePicker, Spin } from 'antd';
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Link } from 'react-router-dom';
import { useAudit } from '../../hooks/useAudit';
import { useAdmin, useAdmins } from '../../hooks/useAdmins';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { AuditRecord, AuditListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
const { RangePicker } = DatePicker;
import { useAudit } from '@/hooks/useAudit';
import { useAdmin, useAdmins } from '@/hooks/useAdmins';
import { useUser } from '@/hooks/useUsers';
import { useEvent } from '@/hooks/useEvents';
import { AuditRecord, AuditListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { DataTable } from '@/components/data-table/DataTable';
import { ListPageHeader } from '@/components/ListPageHeader';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const TABLE_KEY = 'audit';
const ALL = '__all__';
const roleBadgeVariant = (role: string) => {
if (role === 'superadmin') return 'destructive' as const;
if (role === 'admin') return 'default' as const;
if (role === 'moderator') return 'secondary' as const;
return 'outline' as const;
};
const actionBadgeVariant = (action: string) => {
if (action === 'create') return 'success' as const;
if (action === 'delete') return 'destructive' as const;
if (action === 'update') return 'default' as const;
if (action === 'freeze' || action === 'block' || action === 'hide') return 'warning' as const;
return 'outline' as const;
};
const entityTypeBadgeVariant = (type: string) => {
if (type === 'user') return 'default' as const;
if (type === 'event') return 'success' as const;
if (type === 'review') return 'secondary' as const;
if (type === 'report') return 'destructive' as const;
return 'outline' as const;
};
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const toDateInput = (iso: string | undefined): string => {
if (!iso) return '';
return iso.slice(0, 10);
};
const dateToIso = (date: string, endOfDay = false): string | undefined => {
if (!date) return undefined;
const d = new Date(date);
if (endOfDay) d.setHours(23, 59, 59, 999);
else d.setHours(0, 0, 0, 0);
return d.toISOString();
};
const AdminCell: React.FC<{ adminId: string }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId);
if (loading) return <Skeleton className="h-4 w-24" />;
if (!admin) return <span>{adminId}</span>;
const name = admin.nickname && admin.nickname !== '-' ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{name || admin.id}</Link>;
};
const EntityNameCell: React.FC<{ entityType: string; entityId: string }> = ({ entityType, entityId }) => {
const { data: user, isLoading: loadingUser } = useUser(entityType === 'user' ? entityId : '');
const { data: event, isLoading: loadingEvent } = useEvent(entityType === 'event' ? entityId : '');
if (entityType === 'user') {
if (loadingUser) return <Skeleton className="h-4 w-24" />;
if (!user) return <span>{entityId}</span>;
const name = !isBadValue(user.nickname) ? user.nickname : user.email;
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
}
if (entityType === 'event') {
if (loadingEvent) return <Skeleton className="h-4 w-24" />;
if (!event) return <span>{entityId}</span>;
const name = !isBadValue(event.title) ? event.title : event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
if (entityType === 'review') {
return <Link to={`/reviews/${entityId}`}>{entityId}</Link>;
}
return <span>{entityId}</span>;
};
const AuditPage: React.FC = () => {
const [params, setParams] = useState<AuditListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'timestamp', order: 'desc' });
const { t } = useTranslation();
const [params, setParams] = useState<AuditListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'timestamp',
order: 'desc',
});
const { data, isLoading } = useAudit(params);
const { data: admins, isLoading: loadingAdmins } = useAdmins({ limit: 1000, offset: 0 });
const [uniqueActions, setUniqueActions] = useState<string[]>([]);
const [adminSearch, setAdminSearch] = useState('');
useEffect(() => {
if (data?.data) {
const actions = new Set(data.data.map(record => record.action).filter(Boolean));
setUniqueActions(prev => {
const actions = new Set(data.data.map((record) => record.action).filter(Boolean));
setUniqueActions((prev) => {
const merged = new Set([...prev, ...actions]);
return Array.from(merged).sort();
});
}
}, [data]);
const AdminCell: React.FC<{ adminId: string }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId);
if (loading) return <Spin size="small" />;
if (!admin) return <span>{adminId}</span>;
const name = admin.nickname && admin.nickname !== '-' ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{name || admin.id}</Link>;
};
const filteredAdmins = useMemo(() => {
const list = admins?.data || [];
if (!adminSearch.trim()) return list;
const q = adminSearch.toLowerCase();
return list.filter((a) => (a.email || a.id).toLowerCase().includes(q));
}, [admins, adminSearch]);
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const EntityNameCell: React.FC<{ entityType: string; entityId: string }> = ({ entityType, entityId }) => {
const { data: user, isLoading: loadingUser } = useUser(entityType === 'user' ? entityId : '');
const { data: event, isLoading: loadingEvent } = useEvent(entityType === 'event' ? entityId : '');
if (entityType === 'user') {
if (loadingUser) return <Spin size="small" />;
if (!user) return <span>{entityId}</span>;
const name = !isBadValue(user.nickname) ? user.nickname : user.email;
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
}
if (entityType === 'event') {
if (loadingEvent) return <Spin size="small" />;
if (!event) return <span>{entityId}</span>;
const name = !isBadValue(event.title) ? event.title : event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
if (entityType === 'review') {
// для review показываем ссылку без загрузки, так как нет API для загрузки названия
return <Link to={`/reviews/${entityId}`}>{entityId}</Link>;
}
return <span>{entityId}</span>;
};
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<AuditRecord> | SorterResult<AuditRecord>[]
) => {
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 handleDateChange = (dates: any) => {
if (dates) {
setParams({
...params,
date_from: dates[0]?.toISOString(),
date_to: dates[1]?.toISOString(),
offset: 0,
});
} else {
setParams({ ...params, date_from: undefined, date_to: undefined, offset: 0 });
}
};
const roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
};
const actionColors: Record<string, string> = {
create: 'green',
update: 'blue',
delete: 'red',
freeze: 'orange',
unfreeze: 'cyan',
block: 'orange',
unblock: 'cyan',
hide: 'orange',
unhide: 'cyan',
login: 'geekblue',
logout: 'default',
};
const entityTypeColors: Record<string, string> = {
user: 'blue',
event: 'green',
calendar: 'orange',
review: 'purple',
report: 'red',
ticket: 'default',
subscription: 'cyan',
admin: 'magenta',
};
const columns: ColumnsType<AuditRecord> = [
{
title: 'Админ',
key: 'admin',
render: (_, record) => <AdminCell adminId={record.admin_id} />,
},
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
width: 120,
sorter: true,
render: (role: string) => (
<Tag color={roleColors[role] || 'default'}>{role}</Tag>
),
},
{
title: 'Действие',
dataIndex: 'action',
key: 'action',
width: 120,
sorter: true,
render: (action: string) => (
<Tag color={actionColors[action] || 'default'}>{action}</Tag>
),
},
{
title: 'Тип',
dataIndex: 'entity_type',
key: 'entity_type',
width: 120,
sorter: true,
render: (type: string) => (
<Tag color={entityTypeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Наименование',
key: 'entity_name',
render: (_, record) => (
<EntityNameCell entityType={record.entity_type} entityId={record.entity_id} />
),
},
{
title: 'Дата',
dataIndex: 'timestamp',
key: 'timestamp',
sorter: true,
},
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
{
title: 'Причина',
dataIndex: 'reason',
key: 'reason',
ellipsis: true,
},
];
const columns = useMemo<ColumnDef<AuditRecord>[]>(
() => [
{
id: 'admin',
header: t('audit.admin'),
enableSorting: false,
cell: ({ row }) => <AdminCell adminId={row.original.admin_id} />,
},
{
accessorKey: 'role',
header: t('common.role'),
enableSorting: true,
cell: ({ getValue }) => {
const role = getValue<string>();
return <Badge variant={roleBadgeVariant(role)}>{role}</Badge>;
},
},
{
accessorKey: 'action',
header: t('audit.action'),
enableSorting: true,
cell: ({ getValue }) => {
const action = getValue<string>();
return <Badge variant={actionBadgeVariant(action)}>{action}</Badge>;
},
},
{
accessorKey: 'entity_type',
header: t('common.type'),
enableSorting: true,
cell: ({ getValue }) => {
const type = getValue<string>();
return <Badge variant={entityTypeBadgeVariant(type)}>{type}</Badge>;
},
},
{
id: 'entity_name',
header: t('audit.name'),
enableSorting: false,
cell: ({ row }) => (
<EntityNameCell entityType={row.original.entity_type} entityId={row.original.entity_id} />
),
},
{ accessorKey: 'timestamp', header: t('common.date'), enableSorting: true },
{ accessorKey: 'ip', header: t('common.ip'), enableSorting: false },
{
accessorKey: 'reason',
header: t('common.reason'),
enableSorting: false,
cell: ({ getValue }) => {
const reason = getValue<string>();
return <span className="line-clamp-2">{reason}</span>;
},
},
],
[t]
);
return (
<div>
<h2>Аудит</h2>
<Space style={{ marginBottom: 16 }}>
<Select
placeholder="Администратор"
loading={loadingAdmins}
allowClear
onChange={(val) => setParams(prev => ({ ...prev, admin_id: val || undefined, offset: 0 }))}
style={{ width: 250 }}
showSearch
optionFilterProp="label"
>
{(admins?.data || []).map(admin => {
const label = admin.email || admin.id;
return (
<Select.Option key={admin.id} value={admin.id} label={label}>
{label}
</Select.Option>
);
})}
</Select>
<Select
placeholder="Действие"
allowClear
onChange={(val) => setParams(prev => ({ ...prev, action: val || undefined, offset: 0 }))}
style={{ width: 200 }}
>
{uniqueActions.map(action => (
<Select.Option key={action} value={action}>{action}</Select.Option>
))}
</Select>
<RangePicker
onChange={handleDateChange}
allowClear
/>
</Space>
<Table
<div className="space-y-6">
<ListPageHeader title={t('audit.title')} description={t('audit.description')} />
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-2">
<Label>{t('audit.searchAdmin')}</Label>
<Input
placeholder={t('audit.searchAdminPlaceholder')}
value={adminSearch}
onChange={(e) => setAdminSearch(e.target.value)}
className="w-[200px]"
/>
</div>
<div className="space-y-2">
<Label>{t('audit.admin')}</Label>
<Select
value={params.admin_id ?? ALL}
onValueChange={(val) =>
setParams((prev) => ({ ...prev, admin_id: val === ALL ? undefined : val, offset: 0 }))
}
disabled={loadingAdmins}
>
<SelectTrigger className="w-[250px]">
<SelectValue placeholder={t('common.selectAdmin')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('common.all')}</SelectItem>
{filteredAdmins.map((admin) => {
const label = admin.email || admin.id;
return (
<SelectItem key={admin.id} value={admin.id}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('audit.action')}</Label>
<Select
value={params.action ?? ALL}
onValueChange={(val) =>
setParams((prev) => ({ ...prev, action: val === ALL ? undefined : val, offset: 0 }))
}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder={t('audit.action')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('common.all')}</SelectItem>
{uniqueActions.map((action) => (
<SelectItem key={action} value={action}>
{action}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('common.dateFrom')}</Label>
<Input
type="date"
value={toDateInput(params.date_from)}
onChange={(e) =>
setParams((prev) => ({
...prev,
date_from: dateToIso(e.target.value, false),
offset: 0,
}))
}
className="w-[160px]"
/>
</div>
<div className="space-y-2">
<Label>{t('common.dateTo')}</Label>
<Input
type="date"
value={toDateInput(params.date_to)}
onChange={(e) =>
setParams((prev) => ({
...prev,
date_to: dateToIso(e.target.value, true),
offset: 0,
}))
}
className="w-[160px]"
/>
</div>
</div>
<DataTable
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
data={data?.data ?? []}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</div>
);
};
export default AuditPage;
export default AuditPage;
+70 -37
View File
@@ -1,18 +1,27 @@
import React from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../store/authStore';
import { loginSchema, LoginFormValues } from '../../schemas/loginSchema';
import { useAuthStore } from '@/store/authStore';
import { loginSchema, LoginFormValues } from '@/schemas/loginSchema';
import { getDefaultRouteForRole, type AdminRole } from '@/config/adminAccess';
import { notify } from '@/lib/notify';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const { t } = useTranslation();
const { control, handleSubmit, formState: { errors, isSubmitting } } = useForm<LoginFormValues>({
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' },
});
@@ -20,51 +29,75 @@ const LoginPage: React.FC = () => {
const onFinish = async (values: LoginFormValues) => {
try {
await login(values.email, values.password);
navigate('/dashboard');
const role = useAuthStore.getState().user?.role as AdminRole | undefined;
navigate(getDefaultRouteForRole(role));
} catch (error) {
if (isAxiosError(error)) {
const apiError = error.response?.data?.error;
if (typeof apiError === 'string') {
message.error(t(`login.${apiError}`, { defaultValue: apiError }));
notify.error(t(`login.${apiError}`, { defaultValue: apiError }));
return;
}
}
message.error(t('login.error'));
notify.error(t('login.error'));
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Card title={t('login.title')} style={{ width: 400 }}>
<Form layout="vertical" onFinish={handleSubmit(onFinish)}>
<Form.Item
label={t('login.email')}
validateStatus={errors.email ? 'error' : ''}
help={errors.email?.message}
>
<Controller
name="email"
control={control}
render={({ field }) => <Input {...field} autoComplete="username" />}
/>
</Form.Item>
<Form.Item
label={t('login.password')}
validateStatus={errors.password ? 'error' : ''}
help={errors.password?.message}
>
<Controller
name="password"
control={control}
render={({ field }) => <Input.Password {...field} autoComplete="current-password" />}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={isSubmitting}>
{t('login.submit')}
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_hsl(221_83%_53%_/_0.18),_transparent_55%),linear-gradient(160deg,_hsl(220_20%_97%)_0%,_hsl(210_40%_96%)_45%,_hsl(221_40%_92%)_100%)]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.35]"
style={{
backgroundImage:
'linear-gradient(hsl(221 20% 70% / 0.15) 1px, transparent 1px), linear-gradient(90deg, hsl(221 20% 70% / 0.15) 1px, transparent 1px)',
backgroundSize: '48px 48px',
maskImage: 'radial-gradient(ellipse at center, black 30%, transparent 75%)',
}}
/>
<Card className="relative z-10 w-full max-w-md border-border/80 shadow-lg shadow-slate-900/5 backdrop-blur-sm">
<CardHeader className="space-y-3">
<p className="text-3xl font-semibold tracking-tight text-foreground">EventHub</p>
<div>
<CardTitle className="text-xl">{t('login.title')}</CardTitle>
<CardDescription className="mt-1">{t('login.subtitle')}</CardDescription>
</div>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit(onFinish)}>
<div className="space-y-2">
<Label htmlFor="email">{t('login.email')}</Label>
<Controller
name="email"
control={control}
render={({ field }) => (
<Input id="email" type="email" autoComplete="username" {...field} />
)}
/>
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="password">{t('login.password')}</Label>
<Controller
name="password"
control={control}
render={({ field }) => (
<Input id="password" type="password" autoComplete="current-password" {...field} />
)}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? '…' : t('login.submit')}
</Button>
</Form.Item>
</Form>
</form>
</CardContent>
</Card>
</div>
);
+148 -100
View File
@@ -1,23 +1,65 @@
import React, { useState } from 'react';
import { Table, Button, Input, Space, Popconfirm, Tooltip, Spin } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Link } from 'react-router-dom';
import { useBannedWords, useAddBannedWord, useRemoveBannedWord } from '../../hooks/useBannedWords';
import { BannedWordListParams } from '../../api/bannedWordsApi';
import { useAdmin } from '../../hooks/useAdmins';
import { BannedWord } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { Trash2, Search } from 'lucide-react';
import { useBannedWords, useAddBannedWord, useRemoveBannedWord } from '@/hooks/useBannedWords';
import { BannedWordListParams } from '@/api/bannedWordsApi';
import { useAdmin } from '@/hooks/useAdmins';
import { BannedWord } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { DataTable } from '@/components/data-table/DataTable';
import { ListPageHeader } from '@/components/ListPageHeader';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
const TABLE_KEY = 'banned-words';
const formatDate = (date: string) => {
if (!date || date === '-' || date === 'undefined') return '-';
const d = new Date(date);
if (isNaN(d.getTime())) return date;
return d.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const AddedByCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loadingAdmin } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loadingAdmin) return <Skeleton className="h-4 w-24" />;
if (!admin) return <span>{adminId}</span>;
const name = admin.nickname && admin.nickname !== '-' ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{name || admin.id}</Link>;
};
const BannedWordsPage: React.FC = () => {
const [params, setParams] = useState<BannedWordListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0 });
const { t } = useTranslation();
const [params, setParams] = useState<BannedWordListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
});
const { data, isLoading } = useBannedWords(params);
const addWord = useAddBannedWord();
const removeWord = useRemoveBannedWord();
const [newWord, setNewWord] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; word: string | null }>({
open: false,
word: null,
});
const handleAdd = () => {
if (newWord.trim()) {
@@ -27,110 +69,116 @@ const BannedWordsPage: React.FC = () => {
}
};
// Обработчик изменения таблицы (сортировка/пагинация)
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<BannedWord> | SorterResult<BannedWord>[]
) => {
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 handleSearch = () => {
setParams((prev) => ({ ...prev, q: searchQuery.trim() || undefined, offset: 0 }));
};
// Обработчик поиска
const handleSearch = (value: string) => {
setParams(prev => ({ ...prev, q: value || undefined, offset: 0 }));
const handleDelete = (word: string) => setDeleteConfirm({ open: true, word });
const confirmDelete = () => {
if (!deleteConfirm.word) return;
removeWord.mutate(deleteConfirm.word, {
onSuccess: () => setDeleteConfirm({ open: false, word: null }),
});
};
// Компонент для отображения "Кем добавлено"
const AddedByCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loadingAdmin } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loadingAdmin) return <Spin size="small" />;
if (!admin) return <span>{adminId}</span>;
const name = admin.nickname && admin.nickname !== '-' ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{name || admin.id}</Link>;
};
const columns: ColumnsType<BannedWord> = [
{
title: 'Слово',
dataIndex: 'word',
key: 'word',
sorter: true,
},
{
title: 'Кем добавлено',
dataIndex: 'added_by',
key: 'added_by',
render: (addedBy: string) => <AddedByCell adminId={addedBy} />,
},
{
title: 'Дата добавления',
dataIndex: 'added_at',
key: 'added_at',
sorter: true,
render: (date: string) => {
if (!date || date === '-' || date === 'undefined') return '-';
return dayjs(date).format('DD.MM.YYYY HH:mm');
const columns = useMemo<ColumnDef<BannedWord>[]>(
() => [
{ accessorKey: 'word', header: t('common.word'), enableSorting: true },
{
accessorKey: 'added_by',
header: t('bannedWords.addedBy'),
enableSorting: false,
cell: ({ getValue }) => <AddedByCell adminId={getValue<string>()} />,
},
},
{
title: 'Действия',
key: 'actions',
width: 80,
render: (_, record) => (
<Popconfirm
title="Удалить слово?"
onConfirm={() => removeWord.mutate(record.word)}
>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
/>
</Tooltip>
</Popconfirm>
),
},
];
{
accessorKey: 'added_at',
header: t('bannedWords.addedAt'),
enableSorting: true,
cell: ({ getValue }) => formatDate(getValue<string>()),
},
{
id: 'actions',
header: t('common.actions'),
enableSorting: false,
cell: ({ row }) => (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
title={t('common.delete')}
onClick={() => handleDelete(row.original.word)}
>
<Trash2 className="h-4 w-4" />
</Button>
),
},
],
[t]
);
return (
<div>
<h2>Бан-слова</h2>
<Space style={{ marginBottom: 16 }}>
<div className="space-y-6">
<ListPageHeader
title={t('bannedWords.title')}
description={t('bannedWords.description')}
/>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Новое слово"
placeholder={t('bannedWords.placeholder')}
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
onPressEnter={handleAdd}
style={{ width: 200 }}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
className="w-[200px]"
/>
<Button type="primary" onClick={handleAdd} loading={addWord.isPending}>
Добавить
<Button onClick={handleAdd} disabled={addWord.isPending || !newWord.trim()}>
{t('common.add')}
</Button>
<Input.Search
placeholder="Поиск по слову"
onSearch={handleSearch}
style={{ width: 200 }}
allowClear
/>
</Space>
<Table
<div className="flex items-center gap-2">
<Input
placeholder={t('explore.searchWord')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="w-[200px]"
/>
<Button variant="outline" size="icon" className="h-9 w-9" onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
<DataTable
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
data={data?.data ?? []}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, word: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('bannedWords.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('bannedWords.deleteBody', { word: deleteConfirm.word })}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, word: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={removeWord.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default BannedWordsPage;
export default BannedWordsPage;
+158 -66
View File
@@ -1,66 +1,158 @@
import React from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag } from 'antd';
import { useCalendar } from '../../hooks/useCalendars';
import dayjs from 'dayjs';
const CalendarDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: calendar, isLoading } = useCalendar(id || '');
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: unknown) => (isBadValue(val) ? '-' : String(val));
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!calendar) return <p>Календарь не найден</p>;
const tagsStr = Array.isArray(calendar.tags) && calendar.tags.length > 0 ? calendar.tags.join(', ') : '-';
return (
<Card title={`Календарь ${calendar.title || calendar.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{calendar.id}</Descriptions.Item>
<Descriptions.Item label="Название">{calendar.title}</Descriptions.Item>
<Descriptions.Item label="Короткое имя">{displayValue(calendar.short_name)}</Descriptions.Item>
<Descriptions.Item label="Описание">{displayValue(calendar.description)}</Descriptions.Item>
<Descriptions.Item label="Тип">
<Tag color={calendar.type === 'commercial' ? 'gold' : 'blue'}>{calendar.type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={calendar.status === 'active' ? 'green' : calendar.status === 'frozen' ? 'orange' : 'red'}>
{calendar.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(calendar.reason)}</Descriptions.Item>
<Descriptions.Item label="Категория">{displayValue(calendar.category)}</Descriptions.Item>
<Descriptions.Item label="Владелец">
{!isBadValue(calendar.owner_id) ? (
<Link to={`/users/${calendar.owner_id}`}>{calendar.owner_id}</Link>
) : '-'}
</Descriptions.Item>
<Descriptions.Item label="Цвет">{displayValue(calendar.color)}</Descriptions.Item>
<Descriptions.Item label="Изображение">{displayValue(calendar.image_url)}</Descriptions.Item>
<Descriptions.Item label="Подтверждение">{displayValue(calendar.confirmation)}</Descriptions.Item>
<Descriptions.Item label="Теги">{tagsStr}</Descriptions.Item>
<Descriptions.Item label="Рейтинг">{calendar.rating_avg} ({calendar.rating_count})</Descriptions.Item>
<Descriptions.Item label="Настройки">
{calendar.settings ? JSON.stringify(calendar.settings) : '-'}
</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(calendar.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(calendar.updated_at)}</Descriptions.Item>
</Descriptions>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/calendars')}>Назад к списку</Button>
</Card>
);
};
export default CalendarDetailPage;
import React from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { ArrowLeft } from 'lucide-react';
import { useCalendar } from '@/hooks/useCalendars';
import { formatDisplayValue } from '@/lib/utils';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => {
const text = formatDisplayValue(value);
if (text === '-') return <>{emptyLabel}</>;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return <pre className="overflow-auto rounded-md bg-muted p-2 text-xs">{text}</pre>;
}
return <>{text}</>;
};
const CalendarDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: calendar, isLoading } = useCalendar(id || '');
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return t('common.emDash');
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!calendar) return <p className="text-muted-foreground">{t('calendars.notFound')}</p>;
const emDash = t('common.emDash');
const titleLabel = calendar.title || calendar.id;
return (
<>
<PageHeader
title={t('calendars.detailTitle', { name: titleLabel })}
breadcrumbs={[
{ label: t('calendars.title'), href: '/calendars' },
{ label: String(titleLabel) },
]}
actions={
<Button variant="outline" onClick={() => navigate('/calendars')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{calendar.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.title')}</dt>
<dd>{calendar.title}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.shortName')}</dt>
+456 -278
View File
@@ -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)} />
</>
);
};
+211 -176
View File
@@ -1,215 +1,250 @@
import React from 'react';
import { Card, Col, Row, Statistic, Spin, Alert, Button } from 'antd';
import {
UserOutlined,
CalendarOutlined,
StarOutlined,
TeamOutlined,
WarningOutlined,
BugOutlined,
ClockCircleOutlined,
DollarOutlined,
} from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useDashboardStats } from '../../hooks/useDashboard';
import { useAuthStore } from '../../store/authStore';
import DailyLineChartCard from '../../components/DailyLineChartCard';
import StatisticSparklineCard from '../../components/StatisticSparklineCard';
import {
Users,
Calendar,
Star,
UsersRound,
AlertTriangle,
Bug,
Clock,
CreditCard,
Inbox,
ArrowRight,
Activity,
} from 'lucide-react';
import { useDashboardStats } from '@/hooks/useDashboard';
import { useAuthStore } from '@/store/authStore';
import DailyLineChartCard from '@/components/DailyLineChartCard';
import StatisticSparklineCard from '@/components/StatisticSparklineCard';
import { KpiCard } from '@/components/ListPageHeader';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
function WorkCard({
title,
description,
href,
cta,
icon,
}: {
title: string;
description: string;
href: string;
cta: string;
icon: React.ReactNode;
}) {
return (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
<div className="rounded-md bg-primary/10 p-2 text-primary">{icon}</div>
<div className="space-y-1">
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
</CardHeader>
<CardContent>
<Button asChild>
<Link to={href}>
{cta}
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</CardContent>
</Card>
);
}
const DashboardPage: React.FC = () => {
const { t } = useTranslation();
const { data, isLoading, error } = useDashboardStats();
const role = useAuthStore((s) => s.user?.role);
const navigate = useNavigate();
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (error) return <Alert type="error" message="Ошибка загрузки статистики" />;
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28" />
))}
</div>
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{t('dashboard.loadStatsError')}</AlertDescription>
</Alert>
);
}
if (!data) return null;
if (role === 'moderator') {
return (
<div>
<h2>Модерация</h2>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Card><Statistic title="Рассмотрено" value={data.reports_reviewed ?? 0} prefix={<WarningOutlined />} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="Отклонено" value={data.reports_dismissed ?? 0} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="Ср. время (ч)" value={data.avg_report_resolution_h ?? 0} precision={1} prefix={<ClockCircleOutlined />} /></Card>
</Col>
</Row>
<Button type="primary" style={{ marginTop: 16 }} onClick={() => navigate('/reports')}>
Перейти к жалобам
</Button>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.moderation')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleModeration')}</p>
</div>
<WorkCard
title={t('dashboard.reportsInboxTitle')}
description={t('dashboard.reportsInboxDesc')}
href="/inbox/reports"
cta={t('common.openInbox')}
icon={<Inbox className="h-5 w-5" />}
/>
<div className="grid gap-4 sm:grid-cols-3">
<KpiCard
label={t('dashboard.pending')}
value={data.reports_pending ?? 0}
icon={<AlertTriangle className="h-4 w-4" />}
/>
<KpiCard label={t('dashboard.reviewed')} value={data.reports_reviewed ?? 0} />
<KpiCard
label={t('dashboard.avgTimeH')}
value={(data.avg_report_resolution_h ?? 0).toFixed(1)}
icon={<Clock className="h-4 w-4" />}
/>
</div>
</div>
);
}
if (role === 'support') {
return (
<div>
<h2>Поддержка</h2>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Card><Statistic title="Назначено открытых" value={data.tickets_assigned_open ?? 0} prefix={<BugOutlined />} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="Всего назначено" value={data.tickets_assigned_total ?? 0} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="Ср. решение (ч)" value={data.avg_ticket_resolution_h ?? 0} precision={1} prefix={<ClockCircleOutlined />} /></Card>
</Col>
</Row>
<Button type="primary" style={{ marginTop: 16 }} onClick={() => navigate('/tickets')}>
Перейти к тикетам
</Button>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.support')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleSupport')}</p>
</div>
<WorkCard
title={t('dashboard.ticketsInboxTitle')}
description={t('dashboard.ticketsInboxDesc')}
href="/inbox/tickets"
cta={t('common.openInbox')}
icon={<Bug className="h-5 w-5" />}
/>
<div className="grid gap-4 sm:grid-cols-3">
<KpiCard
label={t('dashboard.assignedOpen')}
value={data.tickets_assigned_open ?? 0}
icon={<Bug className="h-4 w-4" />}
/>
<KpiCard label={t('dashboard.assignedTotal')} value={data.tickets_assigned_total ?? 0} />
<KpiCard
label={t('dashboard.avgResolutionH')}
value={(data.avg_ticket_resolution_h ?? 0).toFixed(1)}
icon={<Clock className="h-4 w-4" />}
/>
</div>
</div>
);
}
const registrationsSparkline = data.registrations_by_day?.map((item) => ({
date: item.date,
value: item.count,
})) ?? [];
const eventsSparkline = data.events_by_day?.map((item) => ({
date: item.date,
value: item.count,
})) ?? [];
const registrationsSparkline =
data.registrations_by_day?.map((item) => ({ date: item.date, value: item.count })) ?? [];
const eventsSparkline = data.events_by_day?.map((item) => ({ date: item.date, value: item.count })) ?? [];
return (
<div>
<h2>Общая статистика</h2>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<StatisticSparklineCard
title="Пользователи"
value={data.users_total}
prefix={<UserOutlined />}
data={registrationsSparkline}
color="#52c41a"
gradientId="colorRegistrations"
/>
</Col>
<div className="space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.controlCenter')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleAdmin')}</p>
</div>
<Col xs={24} sm={12} lg={6}>
<StatisticSparklineCard
title="События"
value={data.events_total}
prefix={<TeamOutlined />}
data={eventsSparkline}
color="#1890ff"
gradientId="colorEvents"
/>
</Col>
<div className="grid gap-4 lg:grid-cols-2">
<WorkCard
title={t('dashboard.reportQueueTitle')}
description={t('dashboard.reportQueueDesc', {
count: data.reports_pending ?? data.reports_total ?? 0,
})}
href="/inbox/reports"
cta={t('dashboard.reportsInboxTitle')}
icon={<AlertTriangle className="h-5 w-5" />}
/>
<WorkCard
title={t('dashboard.ticketQueueTitle')}
description={t('dashboard.ticketQueueDesc', { count: data.tickets_open ?? 0 })}
href="/inbox/tickets"
cta={t('dashboard.ticketsInboxTitle')}
icon={<Bug className="h-5 w-5" />}
/>
</div>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Отзывы" value={data.reviews_total} prefix={<StarOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Календари" value={data.calendars_total} prefix={<CalendarOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Жалобы" value={data.reports_total} prefix={<WarningOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Тикеты (всего)" value={data.tickets_total} prefix={<BugOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Открытых тикетов"
value={data.tickets_open}
prefix={<ClockCircleOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Среднее время решения (ч)"
value={data.avg_ticket_resolution_h}
precision={1}
/>
</Card>
</Col>
<div>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{t('dashboard.quickOverview')}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Button variant="outline" className="justify-start gap-2 h-auto py-3" onClick={() => navigate('/users')}>
<Users className="h-4 w-4" /> {t('nav.users')}
</Button>
<Button variant="outline" className="justify-start gap-2 h-auto py-3" onClick={() => navigate('/events')}>
<Calendar className="h-4 w-4" /> {t('nav.events')}
</Button>
<Button variant="outline" className="justify-start gap-2 h-auto py-3" onClick={() => navigate('/monitoring')}>
<Activity className="h-4 w-4" /> {t('nav.monitoring')}
</Button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatisticSparklineCard
title={t('nav.users')}
value={data.users_total}
icon={<Users className="h-4 w-4" />}
data={registrationsSparkline}
color="hsl(142 71% 45%)"
gradientId="colorRegistrations"
/>
<StatisticSparklineCard
title={t('nav.events')}
value={data.events_total}
icon={<UsersRound className="h-4 w-4" />}
data={eventsSparkline}
color="hsl(221 83% 53%)"
gradientId="colorEvents"
/>
<KpiCard label={t('nav.reviews')} value={data.reviews_total} icon={<Star className="h-4 w-4" />} />
<KpiCard label={t('nav.calendars')} value={data.calendars_total} icon={<Calendar className="h-4 w-4" />} />
<KpiCard label={t('menu.reports')} value={data.reports_total} icon={<AlertTriangle className="h-4 w-4" />} />
<KpiCard label={t('dashboard.ticketsTotal')} value={data.tickets_total} icon={<Bug className="h-4 w-4" />} />
<KpiCard label={t('dashboard.ticketsOpen')} value={data.tickets_open} icon={<Clock className="h-4 w-4" />} />
<KpiCard label={t('dashboard.avgResolutionH')} value={(data.avg_ticket_resolution_h ?? 0).toFixed(1)} />
{data.pending_users_total !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Неподтверждённые" value={data.pending_users_total} prefix={<UserOutlined />} />
</Card>
</Col>
<KpiCard label={t('common.pendingUsers')} value={data.pending_users_total} icon={<Users className="h-4 w-4" />} />
)}
{data.reports_pending !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Жалобы: pending" value={data.reports_pending} />
</Card>
</Col>
)}
{data.reports_reviewed !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Жалобы: reviewed" value={data.reports_reviewed} />
</Card>
</Col>
)}
{data.reports_dismissed !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Жалобы: dismissed" value={data.reports_dismissed} />
</Card>
</Col>
<KpiCard
label={`${t('menu.reports')}: ${t('status.pending')}`}
value={data.reports_pending}
/>
)}
{data.subscriptions_total !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Подписки" value={data.subscriptions_total} prefix={<DollarOutlined />} />
</Card>
</Col>
<KpiCard label={t('menu.subscriptions')} value={data.subscriptions_total} icon={<CreditCard className="h-4 w-4" />} />
)}
{data.trial_subscriptions !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Пробные подписки" value={data.trial_subscriptions} />
</Card>
</Col>
<KpiCard label={t('dashboard.trialSubscriptions')} value={data.trial_subscriptions} />
)}
</Row>
</div>
<Row gutter={[16, 16]} style={{ marginTop: 32 }}>
<DailyLineChartCard title="События по дням" data={data.events_by_day} color="#1890ff" />
<DailyLineChartCard title="Регистрации по дням" data={data.registrations_by_day} color="#52c41a" />
<DailyLineChartCard title="Отзывы по дням" data={data.reviews_by_day} color="#faad14" />
<DailyLineChartCard title="Календари по дням" data={data.calendars_by_day} color="#722ed1" />
<DailyLineChartCard title="Жалобы по дням" data={data.reports_by_day} color="#ff4d4f" />
<DailyLineChartCard title="Тикеты по дням" data={data.tickets_by_day} color="#13c2c2" />
<DailyLineChartCard title="Подписки по дням" data={data.subscriptions_by_day} color="#eb2f96" />
<DailyLineChartCard title="Неподтверждённые по дням" data={data.pending_users_by_day} color="#fa8c16" />
</Row>
{role === 'superadmin' && (
<Alert
style={{ marginTop: 32 }}
type="info"
showIcon
message="Активность администраторов"
description={<>Подробный журнал действий в разделе <Link to="/audit">Аудит</Link>.</>}
/>
)}
<div className="grid gap-4 lg:grid-cols-2">
<DailyLineChartCard title={t('dashboard.eventsByDay')} data={data.events_by_day} color="hsl(221 83% 53%)" />
<DailyLineChartCard title={t('dashboard.registrationsByDay')} data={data.registrations_by_day} color="hsl(142 71% 45%)" />
<DailyLineChartCard title={t('dashboard.reviewsByDay')} data={data.reviews_by_day} color="hsl(38 92% 50%)" />
<DailyLineChartCard title={t('dashboard.reportsByDay')} data={data.reports_by_day} color="hsl(0 84% 60%)" />
<DailyLineChartCard title={t('dashboard.ticketsByDay')} data={data.tickets_by_day} color="hsl(188 78% 41%)" />
<DailyLineChartCard title={t('dashboard.subscriptionsByDay')} data={data.subscriptions_by_day} color="hsl(330 81% 60%)" />
</div>
</div>
);
};
+20 -14
View File
@@ -1,26 +1,32 @@
import React from 'react';
import { Button, Result } from 'antd';
import { useNavigate, useLocation } from 'react-router-dom';
import { getMenuSelectedKey, MENU_ITEMS } from '../../config/adminAccess';
import { useTranslation } from 'react-i18next';
import { getMenuSelectedKey, MENU_ITEMS } from '@/config/adminAccess';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
const ForbiddenPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const sectionKey = getMenuSelectedKey(location.pathname);
const sectionLabel =
MENU_ITEMS.find((item) => item.key === sectionKey)?.label ?? 'этот раздел';
const sectionItem = MENU_ITEMS.find((item) => item.key === sectionKey);
const sectionLabel = sectionItem ? t(sectionItem.label) : t('common.notFound');
return (
<Result
status="403"
title="403"
subTitle={`У вас нет доступа к разделу «${sectionLabel}».`}
extra={
<Button type="primary" onClick={() => navigate('/dashboard')}>
На дашборд
</Button>
}
/>
<div className="flex min-h-[50vh] items-center justify-center">
<Card className="max-w-md w-full text-center">
<CardHeader>
<CardTitle className="text-4xl font-bold">{t('errors.forbiddenTitle')}</CardTitle>
<CardDescription className="text-base">
{t('errors.forbiddenMessage', { section: sectionLabel })}
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => navigate('/dashboard')}>{t('common.toDashboard')}</Button>
</CardContent>
</Card>
</div>
);
};
+176 -63
View File
@@ -1,84 +1,197 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag } from 'antd';
import { Link } from 'react-router-dom';
import { useEvent } from '../../hooks/useEvents';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { ArrowLeft } from 'lucide-react';
import { useEvent } from '@/hooks/useEvents';
import { formatDisplayValue } from '@/lib/utils';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => {
const text = formatDisplayValue(value);
if (text === '-') return <>{emptyLabel}</>;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return <pre className="overflow-auto rounded-md bg-muted p-2 text-xs">{text}</pre>;
}
return <>{text}</>;
};
const EventDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: event, isLoading } = useEvent(id || '');
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: any) => isBadValue(val) ? '-' : val;
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
if (isBadValue(dateStr)) return t('common.emDash');
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!event) return <p>Событие не найдено</p>;
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
// Подготовка сложных полей
const tagsStr = Array.isArray(event.tags) && event.tags.length > 0 ? event.tags.join(', ') : '-';
const recurrenceStr = event.recurrence ? `${(event.recurrence as any).freq || ''} (интервал: ${(event.recurrence as any).interval || ''})` : '-';
const locationStr = event.location ? JSON.stringify(event.location) : '-';
const attachmentsStr = !isBadValue(event.attachments) ? JSON.stringify(event.attachments) : '-';
const editHistoryStr = !isBadValue(event.edit_history) ? JSON.stringify(event.edit_history) : '-';
if (!event) return <p className="text-muted-foreground">{t('events.notFound')}</p>;
// ID-ссылки
const specialistLink = !isBadValue(event.specialist_id) ? (
<Link to={`/users/${event.specialist_id}`}>{event.specialist_id}</Link>
) : '-';
const calendarLink = !isBadValue(event.calendar_id) ? (
<Link to={`/calendars/${event.calendar_id}`}>{event.calendar_id}</Link>
) : '-';
const masterLink = !isBadValue(event.master_id) ? (
<Link to={`/events/${event.master_id}`}>{event.master_id}</Link>
) : '-';
const emDash = t('common.emDash');
const recurrenceStr = event.recurrence
? t('events.recurrenceInterval', {
freq: (event.recurrence as { freq?: string }).freq || '',
interval: (event.recurrence as { interval?: string | number }).interval || '',
})
: null;
const entityLink = (path: string, label: string) => (
<Link to={path} className="text-primary hover:underline">{label}</Link>
);
const titleLabel = event.title || event.id;
return (
<Card title={`Событие ${event.title || event.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{event.id}</Descriptions.Item>
<Descriptions.Item label="Название">{event.title}</Descriptions.Item>
<Descriptions.Item label="Описание">{displayValue(event.description) || '-'}</Descriptions.Item>
<Descriptions.Item label="Тип">
<Tag color={event.event_type === 'single' ? 'blue' : event.event_type === 'recurring' ? 'purple' : 'default'}>
{event.event_type}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={event.status === 'active' ? 'green' : event.status === 'cancelled' ? 'red' : 'gray'}>
{event.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(event.reason)}</Descriptions.Item>
<Descriptions.Item label="Дата и время начала">{formatDate(event.start_time)}</Descriptions.Item>
<Descriptions.Item label="Продолжительность (мин)">{event.duration ?? '-'}</Descriptions.Item>
<Descriptions.Item label="Вместимость">{displayValue(event.capacity)}</Descriptions.Item>
<Descriptions.Item label="Специалист">{specialistLink}</Descriptions.Item>
<Descriptions.Item label="Календарь">{calendarLink}</Descriptions.Item>
<Descriptions.Item label="Мастер-событие">{masterLink}</Descriptions.Item>
<Descriptions.Item label="Онлайн-ссылка">{displayValue(event.online_link)}</Descriptions.Item>
<Descriptions.Item label="Теги">{tagsStr}</Descriptions.Item>
<Descriptions.Item label="Рейтинг">{event.rating_avg} ({event.rating_count} оценок)</Descriptions.Item>
<Descriptions.Item label="Вложения">{attachmentsStr}</Descriptions.Item>
<Descriptions.Item label="История изменений">{editHistoryStr}</Descriptions.Item>
<Descriptions.Item label="Повторение">{recurrenceStr}</Descriptions.Item>
<Descriptions.Item label="Локация">{locationStr}</Descriptions.Item>
<Descriptions.Item label="Является экземпляром">{event.is_instance ? 'Да' : 'Нет'}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(event.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(event.updated_at)}</Descriptions.Item>
</Descriptions>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/events')}>Назад к списку</Button>
<>
<PageHeader
title={t('events.detailTitle', { name: titleLabel })}
breadcrumbs={[
{ label: t('events.title'), href: '/events' },
{ label: String(titleLabel) },
]}
actions={
<Button variant="outline" onClick={() => navigate('/events')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{event.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.title')}</dt>
<dd>{event.title}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.description')}</dt>
<dd><DetailValue value={event.description} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.type')}</dt>
<dd>
<Badge variant={event.event_type === 'single' ? 'default' : event.event_type === 'recurring' ? 'secondary' : 'outline'}>
{event.event_type}
</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={event.status === 'active' ? 'success' : event.status === 'cancelled' ? 'destructive' : 'secondary'}>
{event.status}
</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.reason')}</dt>
<dd><DetailValue value={event.reason} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.startDatetime')}</dt>
<dd>{formatDate(event.start_time)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.durationMin')}</dt>
<dd>{event.duration ?? emDash}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.capacity')}</dt>
<dd><DetailValue value={event.capacity} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.specialistId')}</dt>
<dd>
{!isBadValue(event.specialist_id)
? entityLink(`/users/${event.specialist_id}`, String(event.specialist_id))
: emDash}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.calendarId')}</dt>
<dd>
{!isBadValue(event.calendar_id)
? entityLink(`/calendars/${event.calendar_id}`, String(event.calendar_id))
: emDash}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.masterEvent')}</dt>
<dd>
{!isBadValue(event.master_id)
? entityLink(`/events/${event.master_id}`, String(event.master_id))
: emDash}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.onlineLink')}</dt>
<dd><DetailValue value={event.online_link} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.tags')}</dt>
<dd><DetailValue value={event.tags} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.rating')}</dt>
<dd>{t('events.ratingWithCount', { avg: event.rating_avg, count: event.rating_count })}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.attachments')}</dt>
<dd><DetailValue value={event.attachments} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.editHistory')}</dt>
<dd><DetailValue value={event.edit_history} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.recurrence')}</dt>
<dd>{recurrenceStr ?? emDash}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.location')}</dt>
<dd><DetailValue value={event.location} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.isInstance')}</dt>
<dd>{event.is_instance ? t('common.yes') : t('common.no')}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{formatDate(event.created_at)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.updatedAt')}</dt>
<dd>{formatDate(event.updated_at)}</dd>
</div>
</dl>
</CardContent>
</Card>
</>
);
};
export default EventDetailPage;
export default EventDetailPage;
+462 -285
View File
@@ -1,18 +1,127 @@
import React, { useState, useEffect, useRef } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, DatePicker, InputNumber, 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 { useEvents, useUpdateEvent, useDeleteEvent, useEvent, useEventStats } from '../../hooks/useEvents';
import { Event, EventListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { useForm, Controller } from 'react-hook-form';
import { useEvents, useUpdateEvent, useDeleteEvent, useEvent, useEventStats } from '@/hooks/useEvents';
import { useExploreView } from '@/hooks/useExploreView';
import { Event, EventListParams } 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 = 'events';
function isoToDatetimeLocal(iso: string | undefined | null): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function datetimeLocalToIso(local: string): string {
if (!local) return '';
const d = new Date(local);
if (Number.isNaN(d.getTime())) return '';
return d.toISOString();
}
function formatDateTime(iso: string): string {
if (!iso) return '-';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const eventTypeBadgeVariant = (type: string) => {
if (type === 'single') return 'default' as const;
if (type === 'recurring') return 'secondary' as const;
return 'outline' as const;
};
const eventStatusVariant = (status: string) => {
if (status === 'active') return 'default' as const;
if (status === 'cancelled') return 'destructive' as const;
return 'secondary' as const;
};
interface EditFormValues {
title?: string;
description?: string;
event_type?: string;
status?: string;
start_time?: string;
duration?: number | string;
capacity?: number | string;
specialist_id?: string;
calendar_id?: string;
online_link?: string;
tags?: string;
}
function eventActions(
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 EventListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<EventListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'id', order: 'asc' });
const { viewMode, setViewMode } = useExploreView();
const [preview, setPreview] = useState<ExplorePreviewTarget>(null);
const [searchDraft, setSearchDraft] = useState('');
const [params, setParams] = useState<EventListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'id',
order: 'asc',
});
const { data, isLoading } = useEvents(params);
const { data: stats } = useEventStats();
const updateEvent = useUpdateEvent();
@@ -23,311 +132,379 @@ const EventListPage: React.FC = () => {
eventId: null,
});
const { data: editingEvent, isLoading: loadingEvent } = useEvent(editModal.eventId || '');
const [editForm] = Form.useForm();
const [editHasErrors, setEditHasErrors] = useState(true);
const editForm = useForm<EditFormValues>();
const originalEventRef = useRef<Event | null>(null);
const handleEdit = (id: string) => {
setEditModal({ open: true, eventId: id });
};
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; eventId: string | null }>({
open: false,
eventId: null,
});
const handleSaveEdit = () => {
editForm.validateFields().then((values) => {
if (!editModal.eventId || !originalEventRef.current) return;
const original = originalEventRef.current;
const cleanedValues: Record<string, any> = {};
const handleEdit = (id: string) => setEditModal({ open: true, eventId: id });
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;
const handleSaveEdit = editForm.handleSubmit((values) => {
if (!editModal.eventId || !originalEventRef.current) return;
const original = originalEventRef.current;
const cleanedValues: Record<string, unknown> = {};
const processedValues: Record<string, unknown> = {
...values,
start_time: values.start_time ? datetimeLocalToIso(values.start_time) : values.start_time,
tags: values.tags
? values.tags.split(',').map((t) => t.trim()).filter(Boolean)
: [],
duration: values.duration === '' || values.duration === undefined ? undefined : Number(values.duration),
capacity: values.capacity === '' || values.capacity === undefined ? undefined : Number(values.capacity),
};
const allKeys = new Set([...Object.keys(processedValues), ...Object.keys(original)]);
allKeys.forEach((key) => {
const newVal = processedValues[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;
if (dayjs.isDayjs(newVal)) {
cleanedValues[key] = newVal.toISOString();
} else {
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);
})
);
updateEvent.mutate(
{ id: editModal.eventId, data: payload },
{ onSuccess: () => setEditModal({ open: false, eventId: 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);
})
);
updateEvent.mutate(
{ id: editModal.eventId, data: payload },
{ onSuccess: () => setEditModal({ open: false, eventId: null }) }
);
});
useEffect(() => {
if (editingEvent && editModal.open) {
originalEventRef.current = editingEvent;
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
setTimeout(() => {
editForm.setFieldsValue({
title: clean(editingEvent.title),
description: clean(editingEvent.description),
event_type: clean(editingEvent.event_type),
status: clean(editingEvent.status),
start_time: clean(editingEvent.start_time) ? dayjs(editingEvent.start_time) : null,
duration: editingEvent.duration,
capacity: editingEvent.capacity,
specialist_id: clean(editingEvent.specialist_id),
calendar_id: clean(editingEvent.calendar_id),
online_link: clean(editingEvent.online_link),
tags: editingEvent.tags,
});
validateEditForm();
}, 0);
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
editForm.reset({
title: clean(editingEvent.title) as string,
description: clean(editingEvent.description) as string,
event_type: clean(editingEvent.event_type) as string,
status: clean(editingEvent.status) as string,
start_time: isoToDatetimeLocal(editingEvent.start_time),
duration: editingEvent.duration,
capacity: editingEvent.capacity ?? undefined,
specialist_id: clean(editingEvent.specialist_id) as string,
calendar_id: clean(editingEvent.calendar_id) as string,
online_link: clean(editingEvent.online_link) as string,
tags: (editingEvent.tags || []).join(', '),
});
}
}, [editingEvent, editModal.open, editForm]);
const validateEditForm = () => {
const title = editForm.getFieldValue('title');
const status = editForm.getFieldValue('status');
setEditHasErrors(!title || !status);
};
const handleDelete = (id: string) => setDeleteConfirm({ open: true, eventId: id });
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить событие?',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteEvent.mutate(id),
const confirmDelete = () => {
if (!deleteConfirm.eventId) return;
deleteEvent.mutate(deleteConfirm.eventId, {
onSuccess: () => setDeleteConfirm({ open: false, eventId: null }),
});
};
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<Event> | SorterResult<Event>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => mergeTablePagination({
const editTitle = editForm.watch('title');
const editStatus = editForm.watch('status');
const editHasErrors = !editTitle || !editStatus;
const exploreStats = useMemo(() => {
if (!stats) return [];
const items = [{ label: t('common.total'), value: stats.total_events }];
Object.entries(stats.events_by_status || {})
.slice(0, 3)
.forEach(([status, count]) => {
items.push({ label: formatStatusLabel(status), value: count });
});
return items.slice(0, 4);
}, [stats, t]);
const applySearch = () => {
setParams((prev) => ({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
q: searchDraft.trim() || undefined,
offset: 0,
}));
};
const typeColors: Record<string, string> = {
single: 'blue',
recurring: 'purple',
};
const columns = useMemo<ColumnDef<Event>[]>(
() => [
{
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: 'event', id: row.original.id })}
>
{getValue<string>() || row.original.id}
</button>
),
},
{
accessorKey: 'event_type',
header: t('common.type'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const type = getValue<string>();
return <Badge variant={eventTypeBadgeVariant(type)}>{type}</Badge>;
},
},
{
accessorKey: 'status',
header: t('common.status'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={eventStatusVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{
accessorKey: 'start_time',
header: t('common.start'),
size: 130,
enableSorting: true,
cell: ({ getValue }) => formatDateTime(getValue<string>()),
},
{
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={eventActions({
onOpen: () => navigate(`/events/${record.id}`),
onEdit: () => handleEdit(record.id),
onDelete: () => handleDelete(record.id),
}, t)}
/>
);
},
},
],
[navigate, t]
);
const columns: ColumnsType<Event> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу события">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/events/${record.id}`)}
/>
</Tooltip>
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => ({
id: record.id,
title: record.title || record.id,
subtitle: record.start_time ? formatDateTime(record.start_time) : undefined,
badges: (
<>
<Badge variant={eventTypeBadgeVariant(record.event_type)}>{record.event_type}</Badge>
<Badge variant={eventStatusVariant(record.status)}>{formatStatusLabel(record.status)}</Badge>
</>
),
},
{
title: 'Название',
dataIndex: 'title',
key: 'title',
ellipsis: true,
sorter: true,
},
{
title: 'Тип',
dataIndex: 'event_type',
key: 'event_type',
width: 100,
sorter: true,
render: (type: string) => (
<Tag color={typeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : status === 'cancelled' ? 'red' : 'gray'}>
{status}
</Tag>
),
},
{
title: 'Начало',
dataIndex: 'start_time',
key: 'start_time',
width: 130,
sorter: true,
render: (time: string) => time ? dayjs(time).format('DD.MM.YYYY HH:mm') : '-',
},
{
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: 'event', id: record.id }),
actions: eventActions({
onOpen: () => navigate(`/events/${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_events} prefix={<CalendarOutlined />} />
</Card>
</Col>
{Object.entries(stats.events_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.events_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_events_by_rating && stats.top_events_by_rating.length > 0 && (
<Card title="Топ событий по рейтингу" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={stats.top_events_by_rating}
columns={[
<>
<ExploreListShell
title={t('events.title')}
description={t('explore.descPreviewTitle')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
toolbar={
<ExploreSearch
value={searchDraft}
onChange={setSearchDraft}
onSubmit={applySearch}
placeholder={t('explore.searchEvents')}
/>
}
>
{stats?.top_events_by_rating && stats.top_events_by_rating.length > 0 && (
<ExploreInsightsCollapse
title={t('explore.tops')}
tabs={[
{
title: 'Название',
dataIndex: 'title',
key: 'title',
render: (title: string, record) => (
<Link to={`/events/${record.id}`}>{title || record.id}</Link>
),
id: 'rating',
label: t('explore.byRating'),
rows: stats.top_events_by_rating.map((row) => ({
id: row.id,
primary: <Link to={`/events/${row.id}`}>{row.title || row.id}</Link>,
value: (
<span>
{row.rating_avg}
{row.rating_count != null && (
<span className="ml-1 text-xs opacity-70">({row.rating_count})</span>
)}
</span>
),
})),
},
{ title: 'Рейтинг', dataIndex: 'rating_avg', key: 'rating_avg' },
{ title: 'Отзывов', dataIndex: 'review_count', key: 'review_count' },
]}
/>
</Card>
)}
<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, eventId: null })}
onOk={handleSaveEdit}
confirmLoading={updateEvent.isPending}
destroyOnHidden
width={640}
okButtonProps={{ disabled: editHasErrors }}
>
{loadingEvent ? (
<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="event_type">
<Select>
<Select.Option value="single">single</Select.Option>
<Select.Option value="recurring">recurring</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="cancelled">cancelled</Select.Option>
<Select.Option value="completed">completed</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Дата и время начала" name="start_time">
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" />
</Form.Item>
<Form.Item label="Продолжительность (мин)" name="duration">
<InputNumber min={0} />
</Form.Item>
<Form.Item label="Вместимость" name="capacity">
<InputNumber min={0} />
</Form.Item>
<Form.Item label="Специалист (ID)" name="specialist_id">
<Input />
</Form.Item>
<Form.Item label="Календарь (ID)" name="calendar_id">
<Input />
</Form.Item>
<Form.Item label="Ссылка онлайн" name="online_link">
<Input />
</Form.Item>
<Form.Item label="Теги" name="tags">
<Select mode="tags" placeholder="Введите теги" />
</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, eventId: null })}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('events.editTitle')}</DialogTitle>
</DialogHeader>
{loadingEvent ? (
<div className="space-y-3 py-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<form id="edit-event-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="event_type"
control={editForm.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectType')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="single">single</SelectItem>
<SelectItem value="recurring">recurring</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="cancelled">cancelled</SelectItem>
<SelectItem value="completed">completed</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('events.startDatetime')}</Label>
<Input type="datetime-local" {...editForm.register('start_time')} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('events.durationMin')}</Label>
<Input type="number" min={0} {...editForm.register('duration')} />
</div>
<div className="space-y-2">
<Label>{t('events.capacity')}</Label>
<Input type="number" min={0} {...editForm.register('capacity')} />
</div>
</div>
<div className="space-y-2">
<Label>{t('events.specialistId')}</Label>
<Input {...editForm.register('specialist_id')} />
</div>
<div className="space-y-2">
<Label>{t('events.calendarId')}</Label>
<Input {...editForm.register('calendar_id')} />
</div>
<div className="space-y-2">
<Label>{t('events.onlineLink')}</Label>
<Input {...editForm.register('online_link')} />
</div>
<div className="space-y-2">
<Label>{t('events.tags')}</Label>
<Input placeholder="tag1, tag2, tag3" {...editForm.register('tags')} />
</div>
</form>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal({ open: false, eventId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-event-form" disabled={editHasErrors || updateEvent.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, eventId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('events.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, eventId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteEvent.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<EntitySlideOver target={preview} onOpenChange={(open) => !open && setPreview(null)} />
</>
);
};
export default EventListPage;
export default EventListPage;
+280
View File
@@ -0,0 +1,280 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { useReports, useReport, useUpdateReport } from '@/hooks/useReports';
import { useUser } from '@/hooks/useUsers';
import { useEvent } from '@/hooks/useEvents';
import { selectAfterRemove, selectNeighborId, useInboxHotkeys } from '@/hooks/useInboxHotkeys';
import { formatStatusLabel } from '@/utils/statusLabels';
import { Report } from '@/types/api';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import { EntitySlideOver, type ExplorePreviewTarget } from '@/components/EntitySlideOver';
type Filter = 'pending' | 'all';
function reportBadgeVariant(status: Report['status']) {
if (status === 'pending') return 'warning' as const;
if (status === 'reviewed') return 'success' as const;
return 'secondary' as const;
}
const ReportInboxPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [filter, setFilter] = useState<Filter>('pending');
const selectedFromUrl = searchParams.get('selected') ?? '';
const [preview, setPreview] = useState<ExplorePreviewTarget>(null);
const listParams = useMemo(
() => ({
limit: 50,
offset: 0,
sort: 'created_at',
order: 'desc' as const,
...(filter === 'pending' ? { status: 'pending' as const } : {}),
}),
[filter]
);
const { data: listData, isLoading: listLoading } = useReports(listParams);
const items = listData?.data ?? [];
const ids = useMemo(() => items.map((r) => r.id), [items]);
const [selectedId, setSelectedId] = useState('');
useEffect(() => {
if (selectedFromUrl && items.some((r) => r.id === selectedFromUrl)) {
setSelectedId(selectedFromUrl);
return;
}
if (!selectedId && items.length > 0) {
setSelectedId(items[0].id);
}
if (selectedId && !items.some((r) => r.id === selectedId) && items.length > 0) {
setSelectedId(items[0].id);
}
}, [items, selectedFromUrl, selectedId]);
const selectReport = useCallback(
(id: string) => {
setSelectedId(id);
setSearchParams({ selected: id });
},
[setSearchParams]
);
const { data: report, isLoading: detailLoading } = useReport(selectedId);
const updateReport = useUpdateReport();
const reporterId = report?.reporter_id ?? '';
const targetType = report?.target_type;
const targetId = report?.target_id ?? '';
const { data: reporter } = useUser(reporterId);
const { data: targetEvent } = useEvent(targetType === 'event' ? targetId : '');
const advanceAfterAction = useCallback(
(currentId: string) => {
const nextId = selectAfterRemove(ids, currentId);
if (nextId) selectReport(nextId);
},
[ids, selectReport]
);
const handleStatus = useCallback(
(status: 'reviewed' | 'dismissed') => {
if (!selectedId) return;
const current = selectedId;
updateReport.mutate(
{ id: current, data: { status } },
{ onSuccess: () => advanceAfterAction(current) }
);
},
[selectedId, updateReport, advanceAfterAction]
);
useInboxHotkeys({
enabled: !preview,
onNext: () => {
const next = selectNeighborId(ids, selectedId, 1);
if (next) selectReport(next);
},
onPrev: () => {
const prev = selectNeighborId(ids, selectedId, -1);
if (prev) selectReport(prev);
},
onOpen: () => {
if (selectedId) navigate(`/reports/${selectedId}`);
},
onPrimary: () => {
if (report?.status === 'pending') handleStatus('reviewed');
},
onSecondary: () => {
if (report?.status === 'pending') handleStatus('dismissed');
},
});
return (
<div className="flex h-[calc(100vh-7rem)] min-h-[480px] flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.reportsTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('inbox.reportsHints')}</p>
</div>
<div className="ml-auto flex gap-2">
<Button variant={filter === 'pending' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('pending')}>
{t('inbox.pending')}
</Button>
<Button variant={filter === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('all')}>
{t('inbox.all')}
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-[minmax(260px,360px)_1fr]">
<Card className="flex min-h-0 flex-col overflow-hidden">
<CardHeader className="py-3">
<CardTitle className="text-base">{t('inbox.queue', { count: items.length })}</CardTitle>
</CardHeader>
<CardContent className="flex-1 p-0">
<ScrollArea className="h-full max-h-[calc(100vh-14rem)]">
{listLoading ? (
<div className="space-y-2 p-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : items.length === 0 ? (
<p className="p-6 text-center text-sm text-muted-foreground">{t('common.queueEmpty')}</p>
) : (
<div className="divide-y">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => selectReport(item.id)}
className={cn(
'w-full px-3 py-3 text-left transition-colors hover:bg-muted/60',
selectedId === item.id && 'bg-muted'
)}
>
<div className="flex items-start justify-between gap-2">
<span className="font-medium text-sm line-clamp-2">{item.reason}</span>
<Badge variant={reportBadgeVariant(item.status)} className="shrink-0">
{formatStatusLabel(item.status)}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{item.target_type} · {dayjs(item.created_at).format('DD.MM HH:mm')}
</p>
</button>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
<Card className="flex min-h-0 flex-col">
{!selectedId ? (
<CardContent className="flex flex-1 items-center justify-center text-muted-foreground">
{t('inbox.selectReport')}
</CardContent>
) : detailLoading ? (
<CardContent className="space-y-3 p-6">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-24 w-full" />
</CardContent>
) : report ? (
<>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-lg">{t('inbox.reportCard', { id: report.id })}</CardTitle>
<Badge variant={reportBadgeVariant(report.status)}>
{formatStatusLabel(report.status)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">{t('common.reason')}</dt>
<dd className="font-medium">{report.reason}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.targetType')}</dt>
<dd>{report.target_type}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.sender')}</dt>
<dd>
{reporter ? (
<button
type="button"
className="text-primary hover:underline"
onClick={() => setPreview({ type: 'user', id: reporter.id })}
>
{reporter.nickname || reporter.email || reporter.id}
</button>
) : (
report.reporter_id
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.target')}</dt>
<dd>
{targetType === 'event' && targetEvent ? (
<button
type="button"
className="text-primary hover:underline"
onClick={() => setPreview({ type: 'event', id: targetEvent.id })}
>
{targetEvent.title || targetEvent.id}
</button>
) : (
report.target_id
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{dayjs(report.created_at).format('DD.MM.YYYY HH:mm')}</dd>
</div>
</dl>
<Separator />
<Button variant="outline" asChild>
<Link to={`/reports/${report.id}`}>{t('inbox.fullCard')}</Link>
</Button>
</CardContent>
{report.status === 'pending' && (
<CardFooter className="gap-2 border-t bg-muted/30 p-4">
<Button onClick={() => handleStatus('reviewed')} disabled={updateReport.isPending}>
{t('common.reviewed')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">1</kbd>
</Button>
<Button variant="secondary" onClick={() => handleStatus('dismissed')} disabled={updateReport.isPending}>
{t('common.dismissed')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">2</kbd>
</Button>
</CardFooter>
)}
</>
) : (
<CardContent className="flex flex-1 items-center justify-center text-muted-foreground">
{t('reports.notFound')}
</CardContent>
)}
</Card>
</div>
<EntitySlideOver target={preview} onOpenChange={(open) => !open && setPreview(null)} />
</div>
);
};
export default ReportInboxPage;
+279
View File
@@ -0,0 +1,279 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { useTickets, useTicket, useUpdateTicket } from '@/hooks/useTickets';
import { useAuthStore } from '@/store/authStore';
import { selectAfterRemove, selectNeighborId, useInboxHotkeys } from '@/hooks/useInboxHotkeys';
import { formatStatusLabel } from '@/utils/statusLabels';
import { Ticket } from '@/types/api';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
type Filter = 'active' | 'mine' | 'all';
function ticketBadgeVariant(status: Ticket['status']) {
if (status === 'open') return 'warning' as const;
if (status === 'in_progress') return 'default' as const;
if (status === 'resolved' || status === 'closed') return 'success' as const;
return 'secondary' as const;
}
const TicketInboxPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [filter, setFilter] = useState<Filter>('active');
const selectedFromUrl = searchParams.get('selected') ?? '';
const meId = useAuthStore((s) => s.user?.id);
const listParams = useMemo(
() => ({
limit: 50,
offset: 0,
sort: 'last_seen',
order: 'desc' as const,
}),
[]
);
const { data: listData, isLoading: listLoading } = useTickets(listParams);
const allItems = listData?.data ?? [];
const items = useMemo(() => {
if (filter === 'all') return allItems;
if (filter === 'mine') {
return allItems.filter(
(t) =>
(t.status === 'open' || t.status === 'in_progress') &&
meId &&
t.assigned_to === meId
);
}
return allItems.filter((t) => t.status === 'open' || t.status === 'in_progress');
}, [allItems, filter, meId]);
const ids = useMemo(() => items.map((t) => t.id), [items]);
const [selectedId, setSelectedId] = useState('');
useEffect(() => {
if (selectedFromUrl && items.some((t) => t.id === selectedFromUrl)) {
setSelectedId(selectedFromUrl);
return;
}
if (!selectedId && items.length > 0) setSelectedId(items[0].id);
if (selectedId && !items.some((t) => t.id === selectedId) && items.length > 0) {
setSelectedId(items[0].id);
}
}, [items, selectedFromUrl, selectedId]);
const selectTicket = useCallback(
(id: string) => {
setSelectedId(id);
setSearchParams({ selected: id });
},
[setSearchParams]
);
const { data: ticket, isLoading: detailLoading } = useTicket(selectedId);
const updateTicket = useUpdateTicket();
const advanceAfterAction = useCallback(
(currentId: string) => {
const nextId = selectAfterRemove(ids, currentId);
if (nextId) selectTicket(nextId);
},
[ids, selectTicket]
);
const handleResolve = useCallback(() => {
if (!selectedId) return;
const current = selectedId;
updateTicket.mutate(
{ id: current, data: { status: 'resolved' } },
{ onSuccess: () => advanceAfterAction(current) }
);
}, [selectedId, updateTicket, advanceAfterAction]);
const handleInProgress = useCallback(() => {
if (!selectedId) return;
updateTicket.mutate({ id: selectedId, data: { status: 'in_progress' } });
}, [selectedId, updateTicket]);
useInboxHotkeys({
onNext: () => {
const next = selectNeighborId(ids, selectedId, 1);
if (next) selectTicket(next);
},
onPrev: () => {
const prev = selectNeighborId(ids, selectedId, -1);
if (prev) selectTicket(prev);
},
onOpen: () => {
if (selectedId) navigate(`/tickets/${selectedId}`);
},
onPrimary: () => {
if (ticket && (ticket.status === 'open' || ticket.status === 'in_progress')) {
handleResolve();
}
},
onSecondary: () => {
if (ticket?.status === 'open') handleInProgress();
},
});
return (
<div className="flex h-[calc(100vh-7rem)] min-h-[480px] flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.ticketsTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('inbox.ticketsHints')}</p>
</div>
<div className="ml-auto flex flex-wrap gap-2">
<Button variant={filter === 'active' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('active')}>
{t('inbox.active')}
</Button>
<Button variant={filter === 'mine' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('mine')}>
{t('inbox.mine')}
</Button>
<Button variant={filter === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('all')}>
{t('inbox.all')}
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-[minmax(260px,360px)_1fr]">
<Card className="flex min-h-0 flex-col overflow-hidden">
<CardHeader className="py-3">
<CardTitle className="text-base">{t('inbox.queue', { count: items.length })}</CardTitle>
</CardHeader>
<CardContent className="flex-1 p-0">
<ScrollArea className="h-full max-h-[calc(100vh-14rem)]">
{listLoading ? (
<div className="space-y-2 p-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : items.length === 0 ? (
<p className="p-6 text-center text-sm text-muted-foreground">{t('common.queueEmpty')}</p>
) : (
<div className="divide-y">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => selectTicket(item.id)}
className={cn(
'w-full px-3 py-3 text-left transition-colors hover:bg-muted/60',
selectedId === item.id && 'bg-muted'
)}
>
<div className="flex items-start justify-between gap-2">
<span className="font-medium text-sm line-clamp-2">
{item.error_message || item.id}
</span>
<Badge variant={ticketBadgeVariant(item.status)} className="shrink-0">
{formatStatusLabel(item.status)}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
×{item.count} · {dayjs(item.last_seen).format('DD.MM HH:mm')}
</p>
</button>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
<Card className="flex min-h-0 flex-col">
{!selectedId ? (
<CardContent className="flex flex-1 items-center justify-center text-muted-foreground">
{t('inbox.selectTicket')}
</CardContent>
) : detailLoading ? (
<CardContent className="space-y-3 p-6">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-24 w-full" />
</CardContent>
) : ticket ? (
<>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-lg line-clamp-2">
{ticket.error_message || ticket.id}
</CardTitle>
<Badge variant={ticketBadgeVariant(ticket.status)}>
{formatStatusLabel(ticket.status)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">ID</dt>
<dd className="font-mono text-xs">{ticket.id}</dd>
</div>
<div>
<dt className="text-muted-foreground">Hash</dt>
<dd className="font-mono text-xs truncate">{ticket.error_hash}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.count')}</dt>
<dd>{ticket.count}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.assigned')}</dt>
<dd>{ticket.assigned_to || t('common.unassigned')}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t('common.lastSeen')}</dt>
<dd>{dayjs(ticket.last_seen).format('DD.MM.YYYY HH:mm')}</dd>
</div>
</dl>
{ticket.stacktrace && (
<>
<Separator />
<div>
<p className="mb-1 text-sm text-muted-foreground">Stacktrace</p>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs">
{ticket.stacktrace}
</pre>
</div>
</>
)}
<Button variant="outline" asChild>
<Link to={`/tickets/${ticket.id}`}>{t('inbox.fullCard')}</Link>
</Button>
</CardContent>
{(ticket.status === 'open' || ticket.status === 'in_progress') && (
<CardFooter className="gap-2 border-t bg-muted/30 p-4">
{ticket.status === 'open' && (
<Button variant="secondary" onClick={handleInProgress} disabled={updateTicket.isPending}>
{t('common.inProgress')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">2</kbd>
</Button>
)}
<Button onClick={handleResolve} disabled={updateTicket.isPending}>
{t('common.resolved')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">1</kbd>
</Button>
</CardFooter>
)}
</>
) : (
<CardContent className="flex flex-1 items-center justify-center text-muted-foreground">
{t('tickets.notFound')}
</CardContent>
)}
</Card>
</div>
</div>
);
};
export default TicketInboxPage;
+302 -119
View File
@@ -1,20 +1,35 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, Col, Row, Select, Table, Tabs } from 'antd';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { useMetricsStore, NodeMetric } from '../../store/metricsStore';
import { monitoringApi } from '../../api/monitoringApi';
import AreaTrendChart from '../../components/AreaTrendChart';
import { Activity, Cpu, HardDrive, Radio } from 'lucide-react';
import { useMetricsStore, NodeMetric } from '@/store/metricsStore';
import { monitoringApi } from '@/api/monitoringApi';
import AreaTrendChart from '@/components/AreaTrendChart';
import { KpiCard } from '@/components/ListPageHeader';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
const NODE_COLORS = ['#1677ff', '#52c41a', '#fa8c16', '#eb2f96', '#722ed1', '#13c2c2'];
const TIME_RANGES = [
{ label: '5 мин', value: 5 },
{ label: '15 мин', value: 15 },
{ label: '30 мин', value: 30 },
{ label: '1 час', value: 60 },
];
const TIME_RANGE_KEYS = [
{ key: 'monitoring.m5', value: 5 },
{ key: 'monitoring.m15', value: 15 },
{ key: 'monitoring.m30', value: 30 },
{ key: 'monitoring.h1', value: 60 },
] as const;
const bytesToMb = (value?: number) => (value ?? 0) / 1048576;
const formatTime = (ts: string) => dayjs(ts).format('HH:mm:ss');
const buildChartData = (metrics: NodeMetric[]) =>
@@ -41,11 +56,133 @@ const buildChartData = (metrics: NodeMetric[]) =>
const MetricChart: React.FC<{
data: ReturnType<typeof buildChartData>;
lines: Array<{ key: string; color: string; name: string }>;
}> = ({ data, lines }) => (
<AreaTrendChart data={data} xKey="time" series={lines} />
);
}> = ({ data, lines }) => <AreaTrendChart data={data} xKey="time" series={lines} />;
const NodeTabContent: React.FC<{
color: string;
chartData: ReturnType<typeof buildChartData>;
}> = ({ color, chartData }) => {
const { t } = useTranslation();
return (
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('monitoring.cpuMemory')}</CardTitle>
</CardHeader>
<CardContent>
<MetricChart
data={chartData}
lines={[
{ key: 'cpu', color, name: 'CPU %' },
{ key: 'memoryMb', color: '#1890ff', name: 'Total memory' },
{ key: 'memoryAvailableMb', color: '#13c2c2', name: 'Available memory' },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('monitoring.memoryByType')}</CardTitle>
</CardHeader>
<CardContent>
<MetricChart
data={chartData}
lines={[
{ key: 'memoryProcessesMb', color: '#1677ff', name: 'Processes' },
{ key: 'memoryAtomMb', color: '#52c41a', name: 'Atom' },
{ key: 'memoryBinaryMb', color: '#fa8c16', name: 'Binary' },
{ key: 'memoryEtsMb', color: '#eb2f96', name: 'ETS' },
]}
/>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('monitoring.wsQueue')}</CardTitle>
</CardHeader>
<CardContent>
<MetricChart
data={chartData}
lines={[
{ key: 'ws', color: '#52c41a', name: 'WS' },
{ key: 'runQueue', color: '#fa8c16', name: 'Run queue' },
{ key: 'processCount', color: '#722ed1', name: 'Processes' },
{ key: 'activeSessions', color: '#1677ff', name: 'Sessions' },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('monitoring.mnesia')}</CardTitle>
</CardHeader>
<CardContent>
<MetricChart
data={chartData}
lines={[
{ key: 'mnesiaCommits', color: '#722ed1', name: 'Commits' },
{ key: 'mnesiaFailures', color: '#ff4d4f', name: 'Failures' },
]}
/>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">I/O</CardTitle>
</CardHeader>
<CardContent>
<MetricChart
data={chartData}
lines={[
{ key: 'ioIn', color: '#1677ff', name: 'IO in' },
{ key: 'ioOut', color: '#52c41a', name: 'IO out' },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('monitoring.mnesiaTables')}</CardTitle>
</CardHeader>
<CardContent>
{Object.keys(chartData.at(-1)?.tableSizes ?? {}).length === 0 ? (
<p className="text-sm text-muted-foreground">{t('monitoring.noTableData')}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('monitoring.table')}</TableHead>
<TableHead>{t('monitoring.records')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(chartData.at(-1)?.tableSizes ?? {}).map(([table, size]) => (
<TableRow key={table}>
<TableCell className="font-mono text-xs">{table}</TableCell>
<TableCell className="tabular-nums">{size}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
};
const MonitoringPage: React.FC = () => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const nodeFromUrl = searchParams.get('node') ?? '';
const allHistory = useMetricsStore((s) => s.allHistory);
const current = useMetricsStore((s) => s.current);
const setAllHistory = useMetricsStore((s) => s.setAllHistory);
@@ -53,6 +190,7 @@ const MonitoringPage: React.FC = () => {
const visibleHistory = useMetricsStore((s) => s.visibleHistory);
const [minutes, setMinutes] = useState(15);
const [loadingHistory, setLoadingHistory] = useState(false);
const [activeNode, setActiveNode] = useState(nodeFromUrl);
useEffect(() => {
let cancelled = false;
@@ -69,9 +207,7 @@ const MonitoringPage: React.FC = () => {
} catch {
// live WS дополняет историю; REST недоступен — не блокируем страницу
} finally {
if (!cancelled) {
setLoadingHistory(false);
}
if (!cancelled) setLoadingHistory(false);
}
};
@@ -91,6 +227,22 @@ const MonitoringPage: React.FC = () => {
return Array.from(nodes).sort();
}, [allHistory, current]);
useEffect(() => {
if (allNodes.length === 0) return;
if (nodeFromUrl && allNodes.includes(nodeFromUrl)) {
if (activeNode !== nodeFromUrl) setActiveNode(nodeFromUrl);
return;
}
if (!activeNode || !allNodes.includes(activeNode)) {
setActiveNode(allNodes[0]);
}
}, [allNodes, activeNode, nodeFromUrl]);
const selectNode = (node: string) => {
setActiveNode(node);
setSearchParams({ node }, { replace: true });
};
const metricsByNode = useMemo(() => {
const source = visibleHistory.length > 0 ? visibleHistory : allHistory;
const map = new Map<string, NodeMetric[]>();
@@ -102,117 +254,148 @@ const MonitoringPage: React.FC = () => {
return map;
}, [visibleHistory, allHistory]);
const tabItems = allNodes.map((node, idx) => {
const nodeMetrics = metricsByNode.get(node) || [];
const chartData = buildChartData(nodeMetrics);
const latestTableSizes = chartData.at(-1)?.tableSizes ?? {};
const color = NODE_COLORS[idx % NODE_COLORS.length];
const latestByNode = useMemo(() => {
const map = new Map<string, NodeMetric>();
if (current) map.set(current.node, current);
allHistory.forEach((m) => {
const prev = map.get(m.node);
if (!prev || dayjs(m.timestamp).isAfter(dayjs(prev.timestamp))) {
map.set(m.node, m);
}
});
return map;
}, [allHistory, current]);
return {
key: node,
label: <span style={{ color }}>{node}</span>,
children: (
<Row gutter={[16, 16]}>
<Col span={24}>
<Card title="CPU и память (МБ)">
<MetricChart
data={chartData}
lines={[
{ key: 'cpu', color, name: 'CPU %' },
{ key: 'memoryMb', color: '#1890ff', name: 'Память всего' },
{ key: 'memoryAvailableMb', color: '#13c2c2', name: 'Память доступно' },
]}
/>
</Card>
</Col>
<Col span={24}>
<Card title="Память по типам (МБ)">
<MetricChart
data={chartData}
lines={[
{ key: 'memoryProcessesMb', color: '#1677ff', name: 'Processes' },
{ key: 'memoryAtomMb', color: '#52c41a', name: 'Atom' },
{ key: 'memoryBinaryMb', color: '#fa8c16', name: 'Binary' },
{ key: 'memoryEtsMb', color: '#eb2f96', name: 'ETS' },
]}
/>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="WebSocket и очередь">
<MetricChart
data={chartData}
lines={[
{ key: 'ws', color: '#52c41a', name: 'WS' },
{ key: 'runQueue', color: '#fa8c16', name: 'Run queue' },
{ key: 'processCount', color: '#722ed1', name: 'Processes' },
{ key: 'activeSessions', color: '#1677ff', name: 'Sessions' },
]}
/>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="Mnesia">
<MetricChart
data={chartData}
lines={[
{ key: 'mnesiaCommits', color: '#722ed1', name: 'Commits' },
{ key: 'mnesiaFailures', color: '#ff4d4f', name: 'Failures' },
]}
/>
</Card>
</Col>
<Col span={24}>
<Card title="I/O">
<MetricChart
data={chartData}
lines={[
{ key: 'ioIn', color: '#1677ff', name: 'IO in' },
{ key: 'ioOut', color: '#52c41a', name: 'IO out' },
]}
/>
</Card>
</Col>
<Col span={24}>
<Card title="Размеры таблиц Mnesia (последняя точка)">
<Table
size="small"
pagination={false}
rowKey="table"
dataSource={Object.entries(latestTableSizes).map(([table, size]) => ({
table,
size,
}))}
columns={[
{ title: 'Таблица', dataIndex: 'table', key: 'table' },
{ title: 'Записей', dataIndex: 'size', key: 'size' },
]}
/>
</Card>
</Col>
</Row>
),
};
});
const summary = useMemo(() => {
const latests = Array.from(latestByNode.values());
if (latests.length === 0) {
return { online: 0, cpuPeak: 0, memAvg: 0 };
}
const cpuPeak = Math.max(...latests.map((m) => m.cpu_utilization ?? 0));
const memAvgs = latests.map((m) => {
const total = (m.memory_total ?? 0) + (m.memory_available ?? 0);
return total > 0 ? ((m.memory_total ?? 0) / total) * 100 : 0;
});
const memAvg = memAvgs.reduce((a, b) => a + b, 0) / memAvgs.length;
return { online: latests.length, cpuPeak, memAvg };
}, [latestByNode]);
const activeIdx = allNodes.indexOf(activeNode);
const activeColor = NODE_COLORS[activeIdx >= 0 ? activeIdx % NODE_COLORS.length : 0];
const activeChartData = buildChartData(metricsByNode.get(activeNode) || []);
const healthy = summary.online > 0 && summary.cpuPeak < 85;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>Мониторинг нод</h2>
<Select
value={minutes}
onChange={setMinutes}
options={TIME_RANGES}
style={{ width: 120 }}
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{t('monitoring.title')}</h1>
<Badge variant={healthy ? 'success' : summary.online === 0 ? 'secondary' : 'warning'}>
{summary.online === 0
? t('monitoring.noData')
: healthy
? t('monitoring.healthy', { count: summary.online })
: t('monitoring.load', { count: summary.online })}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{t('monitoring.subtitle', { minutes })}
</p>
</div>
<div className="flex flex-wrap gap-1.5">
{TIME_RANGE_KEYS.map((r) => (
<Button
key={r.value}
size="sm"
variant={minutes === r.value ? 'default' : 'outline'}
onClick={() => setMinutes(r.value)}
>
{t(r.key)}
</Button>
))}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<KpiCard label={t('monitoring.onlineNodes')} value={summary.online} icon={<Radio className="h-4 w-4" />} />
<KpiCard
label={t('monitoring.cpuPeak')}
value={`${summary.cpuPeak.toFixed(0)}%`}
icon={<Cpu className="h-4 w-4" />}
/>
<KpiCard
label={t('monitoring.memoryAvg')}
value={`${summary.memAvg.toFixed(0)}%`}
icon={<HardDrive className="h-4 w-4" />}
/>
</div>
{allNodes.length === 0 ? (
<Card loading={loadingHistory}>
{loadingHistory ? 'Загрузка метрик…' : 'Ожидание метрик по WebSocket…'}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="h-4 w-4" />
{t('monitoring.waitingTitle')}
</CardTitle>
<CardDescription>{t('monitoring.waitingDesc')}</CardDescription>
</CardHeader>
<CardContent>
{loadingHistory ? (
<div className="space-y-3">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
) : (
<p className="text-sm text-muted-foreground">{t('monitoring.noPoints')}</p>
)}
</CardContent>
</Card>
) : (
<Tabs items={tabItems} />
<>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('monitoring.node')}
</span>
{allNodes.map((node, idx) => {
const color = NODE_COLORS[idx % NODE_COLORS.length];
const isActive = node === activeNode;
return (
<button
key={node}
type="button"
onClick={() => selectNode(node)}
className={cn(
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-sm transition-colors',
isActive
? 'border-primary bg-primary/5 font-medium text-foreground ring-1 ring-primary/30'
: 'border-transparent bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: color }}
/>
{node}
</button>
);
})}
</div>
{activeNode && (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{t('monitoring.nodeNamed', { name: activeNode })}
</h2>
{loadingHistory && (
<span className="text-xs text-muted-foreground">{t('monitoring.refreshing')}</span>
)}
</div>
<NodeTabContent color={activeColor} chartData={activeChartData} />
</div>
)}
</>
)}
</div>
);
+304 -180
View File
@@ -1,202 +1,326 @@
import React, { useState } from 'react';
import { Card, Descriptions, Spin, Button, Form, Input, Select, Tag, message, Space, Avatar } from 'antd';
import { UserOutlined } from '@ant-design/icons';
import { useAuthStore } from '../../store/authStore';
import { useUpdateProfile } from '../../hooks/useProfile';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import dayjs from 'dayjs';
import { Pencil } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { useUpdateProfile } from '@/hooks/useProfile';
import { formatStatusLabel } from '@/utils/statusLabels';
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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { notify } from '@/lib/notify';
type ProfileFormValues = {
nickname?: string;
timezone?: string;
language?: string;
phone?: string;
avatar_url?: string;
preferences?: string;
};
const roleBadgeVariant = (role: string) => {
if (role === 'superadmin') return 'destructive' as const;
if (role === 'admin') return 'default' as const;
return 'secondary' as const;
};
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const ProfilePage: React.FC = () => {
const { t } = useTranslation();
const { user } = useAuthStore();
const updateProfile = useUpdateProfile();
const [editing, setEditing] = useState(false);
const [form] = Form.useForm();
// Редактирование своего профиля доступно всем ролям (PUT /v1/admin/me)
const handleSave = () => {
if (!user) return;
form.validateFields().then((values) => {
const allowed = ['nickname', 'timezone', 'language', 'phone', 'avatar_url', 'preferences'] as const;
const payload = Object.fromEntries(
Object.entries(values).filter(([key, v]) =>
(allowed as readonly string[]).includes(key) && v !== '' && v !== undefined && v !== null
)
);
if (payload.preferences) {
try {
payload.preferences = JSON.parse(payload.preferences as string);
} catch {
message.error('Поле «Настройки» должно быть валидным JSON');
return;
}
}
updateProfile.mutate(
payload,
{
onSuccess: () => {
setEditing(false);
},
}
);
});
};
const profileSchema = useMemo(
() =>
z.object({
nickname: z.string().optional(),
timezone: z.string().optional(),
language: z.string().optional(),
phone: z.string().optional(),
avatar_url: z.string().optional(),
preferences: z
.string()
.optional()
.refine(
(v) =>
!v ||
v.trim() === '' ||
(() => {
try {
JSON.parse(v);
return true;
} catch {
return false;
}
})(),
{ message: t('profile.invalidJson') }
),
}),
[t]
);
const startEditing = () => {
if (!user) return;
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
form.setFieldsValue({
nickname: clean(user.nickname),
email: clean(user.email),
timezone: clean(user.timezone),
language: clean(user.language),
phone: clean(user.phone),
avatar_url: clean(user.avatar_url),
preferences: user.preferences && !isBadValue(user.preferences) ? JSON.stringify(user.preferences) : '',
});
setEditing(true);
};
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
nickname: '',
timezone: '',
language: '',
phone: '',
avatar_url: '',
preferences: '',
},
});
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
if (isBadValue(dateStr)) return t('common.emDash');
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (!user) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
const startEditing = () => {
if (!user) return;
const clean = (val: unknown) => (isBadValue(val) ? '' : String(val));
form.reset({
nickname: clean(user.nickname),
timezone: clean(user.timezone),
language: clean(user.language),
phone: clean(user.phone),
avatar_url: clean(user.avatar_url),
preferences:
user.preferences && !isBadValue(user.preferences)
? JSON.stringify(user.preferences, null, 2)
: '',
});
setEditing(true);
};
const onSave = (values: ProfileFormValues) => {
if (!user) return;
const allowed = ['nickname', 'timezone', 'language', 'phone', 'avatar_url', 'preferences'] as const;
const payload: Record<string, unknown> = {};
for (const key of allowed) {
const v = values[key];
if (v !== '' && v !== undefined && v !== null) {
if (key === 'preferences') {
try {
payload.preferences = JSON.parse(v);
} catch {
notify.error(t('profile.preferencesInvalidJson'));
return;
}
} else {
payload[key] = v;
}
}
}
updateProfile.mutate(payload, { onSuccess: () => setEditing(false) });
};
if (!user) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const displayName = !isBadValue(user.nickname) ? user.nickname! : user.email;
const avatarSrc = !isBadValue(user.avatar_url) ? user.avatar_url! : undefined;
const emDash = t('common.emDash');
return (
<Card title="Мой профиль">
{!editing ? (
<>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
<Avatar
size={64}
icon={<UserOutlined />}
src={!isBadValue(user.avatar_url) ? user.avatar_url! : undefined}
/>
<div>
<h3>{!isBadValue(user.nickname) ? user.nickname : user.email}</h3>
<Tag
color={
user.role === 'superadmin'
? 'red'
: user.role === 'admin'
? 'blue'
: user.role === 'moderator'
? 'purple'
: 'cyan'
}
>
{user.role}
</Tag>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>{t('profile.title')}</CardTitle>
<CardDescription>{t('profile.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{!editing ? (
<>
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={avatarSrc} alt={displayName} />
<AvatarFallback>{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div>
<h3 className="text-lg font-semibold">{displayName}</h3>
<Badge variant={roleBadgeVariant(user.role)} className="mt-1">
{user.role}
</Badge>
</div>
</div>
</div>
<Descriptions bordered column={1}>
<Descriptions.Item label="Email">{user.email}</Descriptions.Item>
<Descriptions.Item label="Ник">{isBadValue(user.nickname) ? '-' : user.nickname}</Descriptions.Item>
<Descriptions.Item label="Роль">
<Tag
color={
user.role === 'superadmin'
? 'red'
: user.role === 'admin'
? 'blue'
: user.role === 'moderator'
? 'purple'
: 'cyan'
}
>
{user.role}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={user.status === 'active' ? 'green' : 'red'}>{user.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Часовой пояс">{isBadValue(user.timezone) ? '-' : user.timezone}</Descriptions.Item>
<Descriptions.Item label="Язык">{isBadValue(user.language) ? '-' : user.language}</Descriptions.Item>
<Descriptions.Item label="Телефон">{isBadValue(user.phone) ? '-' : user.phone}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">
{isBadValue(user.avatar_url) ? '-' : <a href={user.avatar_url || undefined} target="_blank" rel="noreferrer">{user.avatar_url}</a>}
</Descriptions.Item>
<Descriptions.Item label="Настройки">
{isBadValue(user.preferences) ? '-' : <pre>{JSON.stringify(user.preferences, null, 2)}</pre>}
</Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(user.last_login)}</Descriptions.Item>
</Descriptions>
<Button type="primary" style={{ marginTop: 16 }} onClick={startEditing}>
Редактировать
</Button>
</>
) : (
<Form form={form} layout="vertical" onFinish={handleSave}>
<Form.Item label="Email" name="email">
<Input disabled />
</Form.Item>
<Form.Item label="Ник" name="nickname">
<Input />
</Form.Item>
<Form.Item label="Роль" name="role">
<Input disabled />
</Form.Item>
<Form.Item label="Статус" name="status">
<Input disabled />
</Form.Item>
<Form.Item name="timezone" label="Часовой пояс">
<Select placeholder="Выберите пояс" allowClear>
<Select.Option value="UTC">UTC</Select.Option>
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="language" label="Язык">
<Select placeholder="Выберите язык" allowClear>
<Select.Option value="ru">Русский</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
<Form.Item name="phone" label="Телефон">
<Input placeholder="+7 (999) 123-45-67" />
</Form.Item>
<Form.Item name="avatar_url" label="URL аватара">
<Input placeholder="https://example.com/avatar.jpg" />
</Form.Item>
<Form.Item
name="preferences"
label="Настройки (JSON)"
rules={[
{
validator: (_, value) => {
if (value && value.trim().length > 0) {
try {
JSON.parse(value);
} catch {
return Promise.reject(new Error('Невалидный JSON'));
}
}
return Promise.resolve();
},
},
]}
>
<Input.TextArea rows={4} placeholder='{"key": "value"}' />
</Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={updateProfile.isPending}>
Сохранить
<Separator />
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.email')}</dt>
<dd>{user.email}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.nickname')}</dt>
<dd>{isBadValue(user.nickname) ? emDash : user.nickname}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={user.status === 'active' ? 'default' : 'destructive'}>
{formatStatusLabel(user.status)}
</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.timezone')}</dt>
<dd>{isBadValue(user.timezone) ? emDash : user.timezone}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.language')}</dt>
<dd>{isBadValue(user.language) ? emDash : user.language}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.phone')}</dt>
<dd>{isBadValue(user.phone) ? emDash : user.phone}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.avatarUrl')}</dt>
<dd>
{isBadValue(user.avatar_url) ? (
emDash
) : (
<a href={user.avatar_url!} target="_blank" rel="noreferrer" className="text-primary hover:underline">
{user.avatar_url}
</a>
)}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.preferences')}</dt>
<dd>
{isBadValue(user.preferences) ? (
emDash
) : (
<pre className="overflow-auto rounded-md bg-muted p-2 text-xs">
{JSON.stringify(user.preferences, null, 2)}
</pre>
)}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.lastLogin')}</dt>
<dd>{formatDate(user.last_login)}</dd>
</div>
</dl>
<Button onClick={startEditing}>
<Pencil className="h-4 w-4" />
{t('profile.edit')}
</Button>
<Button onClick={() => setEditing(false)}>Отмена</Button>
</Space>
</Form>
)}
</>
) : (
<form className="space-y-4" onSubmit={form.handleSubmit(onSave)}>
<div className="space-y-2">
<Label>{t('common.email')}</Label>
<Input value={user.email} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="nickname">{t('common.nickname')}</Label>
<Input id="nickname" {...form.register('nickname')} />
</div>
<div className="space-y-2">
<Label>{t('common.role')}</Label>
<Input value={user.role} disabled />
</div>
<div className="space-y-2">
<Label>{t('common.timezone')}</Label>
<Controller
name="timezone"
control={form.control}
render={({ field }) => (
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectTimezone')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="UTC">UTC</SelectItem>
<SelectItem value="Europe/Moscow">Europe/Moscow</SelectItem>
<SelectItem value="Europe/London">Europe/London</SelectItem>
<SelectItem value="Europe/Berlin">Europe/Berlin</SelectItem>
<SelectItem value="America/New_York">America/New_York</SelectItem>
<SelectItem value="Asia/Tokyo">Asia/Tokyo</SelectItem>
<SelectItem value="Asia/Shanghai">Asia/Shanghai</SelectItem>
<SelectItem value="Australia/Sydney">Australia/Sydney</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.language')}</Label>
<Controller
name="language"
control={form.control}
render={({ field }) => (
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectLanguage')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ru">{t('common.langRu')}</SelectItem>
<SelectItem value="en">{t('common.langEn')}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t('common.phone')}</Label>
<Input id="phone" placeholder="+7 (999) 123-45-67" {...form.register('phone')} />
</div>
<div className="space-y-2">
<Label htmlFor="avatar_url">{t('profile.avatarUrl')}</Label>
<Input id="avatar_url" placeholder="https://example.com/avatar.jpg" {...form.register('avatar_url')} />
</div>
<div className="space-y-2">
<Label htmlFor="preferences">{t('profile.preferencesJson')}</Label>
<Textarea id="preferences" rows={4} placeholder='{"key": "value"}' {...form.register('preferences')} />
{form.formState.errors.preferences && (
<p className="text-sm text-destructive">{form.formState.errors.preferences.message}</p>
)}
</div>
<div className="flex gap-2">
<Button type="submit" disabled={updateProfile.isPending}>
{updateProfile.isPending ? t('common.saving') : t('common.save')}
</Button>
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
{t('common.cancel')}
</Button>
</div>
</form>
)}
</CardContent>
</Card>
);
};
export default ProfilePage;
export default ProfilePage;
+172 -65
View File
@@ -1,98 +1,205 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Modal } from 'antd';
import { Link } from 'react-router-dom';
import { useReport, useUpdateReport } from '../../hooks/useReports';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { useAdmin } from '../../hooks/useAdmins';
import React, { useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft } from 'lucide-react';
import { useReport, useUpdateReport } from '@/hooks/useReports';
import { useUser } from '@/hooks/useUsers';
import { useEvent } from '@/hooks/useEvents';
import { useAdmin } from '@/hooks/useAdmins';
import { formatStatusLabel } from '@/utils/statusLabels';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayId = (val?: string | null, emDash = '—') => (!val || isBadValue(val)) ? emDash : val;
const isValidId = (val?: string | null) => val && !isBadValue(val);
const ReportDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: report, isLoading: reportLoading } = useReport(id || '');
const updateReport = useUpdateReport();
const [confirmStatus, setConfirmStatus] = useState<'reviewed' | 'dismissed' | null>(null);
const reporterId = report?.reporter_id;
const resolvedById = report?.resolved_by;
const targetType = report?.target_type;
const targetId = report?.target_id;
const emDash = t('common.emDash');
const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || '');
const { data: resolvedAdmin, isLoading: loadingResolver } = useAdmin(resolvedById || '');
const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetType === 'event' ? targetId || '' : '');
const handleStatusChange = (status: 'reviewed' | 'dismissed') => {
Modal.confirm({
title: `Отметить как «${status === 'reviewed' ? 'Рассмотрено' : 'Отклонено'}»?`,
okText: 'Да',
cancelText: 'Отмена',
onOk: () => updateReport.mutate({ id: id!, data: { status } }),
});
};
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const getLink = (entity: any, type: 'user' | 'admin' | 'event') => {
if (!entity) return <Spin size="small" />;
const getLink = (
entity: { id: string; title?: string | null; nickname?: string | null; email?: string | null },
type: 'user' | 'admin' | 'event'
) => {
let name = '';
if (type === 'event') {
name = !isBadValue(entity.title) ? entity.title : entity.id;
name = !isBadValue(entity.title) ? entity.title! : entity.id;
} else {
const nick = entity.nickname;
const email = entity.email;
if (!isBadValue(nick)) name = nick;
else if (!isBadValue(email)) name = email;
if (!isBadValue(nick)) name = nick!;
else if (!isBadValue(email)) name = email!;
else name = entity.id;
}
const to = type === 'user' ? `/users/${entity.id}` : type === 'admin' ? `/admins/${entity.id}` : `/events/${entity.id}`;
return <Link to={to}>{name}</Link>;
return <Link to={to} className="text-primary hover:underline">{name}</Link>;
};
const displayId = (val?: string | null) => (!val || isBadValue(val)) ? '-' : val;
const isValidId = (val?: string | null) => val && !isBadValue(val);
const handleConfirm = () => {
if (!confirmStatus || !id) return;
updateReport.mutate({ id, data: { status: confirmStatus } }, {
onSuccess: () => setConfirmStatus(null),
});
};
if (reportLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!report) return <p>Жалоба не найдена</p>;
if (reportLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (!report) return <p className="text-muted-foreground">{t('reports.notFound')}</p>;
const statusLabel = confirmStatus === 'reviewed'
? t('common.reviewed')
: t('common.dismissed');
return (
<Card title={`Жалоба ${report.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{report.id}</Descriptions.Item>
<Descriptions.Item label="Отправитель">
{loadingReporter && isValidId(reporterId) ? <Spin size="small" /> : (
reporter ? getLink(reporter, 'user') : displayId(reporterId)
<>
<PageHeader
title={t('reports.detailTitle', { id: report.id })}
breadcrumbs={[
{ label: t('reports.title'), href: '/reports' },
{ label: report.id },
]}
actions={
<Button variant="outline" onClick={() => navigate('/reports')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{report.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.sender')}</dt>
<dd>
{loadingReporter && isValidId(reporterId) ? (
<Skeleton className="h-4 w-24" />
) : reporter ? (
getLink(reporter, 'user')
) : (
displayId(reporterId, emDash)
)}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.targetType')}</dt>
<dd>{report.target_type}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.target')}</dt>
<dd>
{targetType === 'event' && loadingEvent && isValidId(targetId) ? (
<Skeleton className="h-4 w-24" />
) : targetType === 'event' && targetEvent ? (
getLink(targetEvent, 'event')
) : (
displayId(targetId, emDash)
)}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.reason')}</dt>
<dd>{report.reason}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={report.status === 'pending' ? 'warning' : report.status === 'reviewed' ? 'success' : 'secondary'}>
{formatStatusLabel(report.status)}
</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{report.created_at}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('reports.resolvedAt')}</dt>
<dd>{report.resolved_at || emDash}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('reports.resolvedBy')}</dt>
<dd>
{loadingResolver && isValidId(resolvedById) ? (
<Skeleton className="h-4 w-24" />
) : resolvedAdmin ? (
getLink(resolvedAdmin, 'admin')
) : (
displayId(resolvedById, emDash)
)}
</dd>
</div>
</dl>
{report.status === 'pending' && (
<div className="flex flex-wrap gap-2">
<Button onClick={() => setConfirmStatus('reviewed')} disabled={updateReport.isPending}>
{t('common.reviewed')}
</Button>
<Button variant="destructive" onClick={() => setConfirmStatus('dismissed')} disabled={updateReport.isPending}>
{t('common.dismissed')}
</Button>
</div>
)}
</Descriptions.Item>
<Descriptions.Item label="Тип цели">{report.target_type}</Descriptions.Item>
<Descriptions.Item label="Цель">
{targetType === 'event' && loadingEvent && isValidId(targetId) ? <Spin size="small" /> : (
targetType === 'event' && targetEvent ? getLink(targetEvent, 'event') : displayId(targetId)
)}
</Descriptions.Item>
<Descriptions.Item label="Причина">{report.reason}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={report.status === 'pending' ? 'orange' : report.status === 'reviewed' ? 'green' : 'default'}>
{report.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Создано">{report.created_at}</Descriptions.Item>
<Descriptions.Item label="Решено">{report.resolved_at || '-'}</Descriptions.Item>
<Descriptions.Item label="Кем решено">
{loadingResolver && isValidId(resolvedById) ? <Spin size="small" /> : (
resolvedAdmin ? getLink(resolvedAdmin, 'admin') : displayId(resolvedById)
)}
</Descriptions.Item>
</Descriptions>
{report.status === 'pending' && (
<Space style={{ marginTop: 16 }}>
<Button type="primary" onClick={() => handleStatusChange('reviewed')} loading={updateReport.isPending}>Рассмотрено</Button>
<Button danger onClick={() => handleStatusChange('dismissed')} loading={updateReport.isPending}>Отклонено</Button>
</Space>
)}
<Button style={{ marginTop: 16 }} onClick={() => navigate('/reports')}>Назад к списку</Button>
</Card>
</CardContent>
</Card>
<Dialog open={confirmStatus !== null} onOpenChange={(open) => !open && setConfirmStatus(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmMarkAs', { status: statusLabel })}</DialogTitle>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmStatus(null)}>
{t('common.cancel')}
</Button>
<Button onClick={handleConfirm} disabled={updateReport.isPending}>
{updateReport.isPending ? t('common.saving') : t('common.yes')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default ReportDetailPage;
export default ReportDetailPage;
+316 -273
View File
@@ -1,21 +1,110 @@
import React, { useState } from 'react';
import { Table, Button, Tag, Modal, Descriptions, Tooltip, Spin, Space, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, EyeOutlined, WarningOutlined } from '@ant-design/icons';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Eye, Info } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { useReports, useUpdateReport, useReportStats } from '../../hooks/useReports';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { useAdmin } from '../../hooks/useAdmins';
import { Report, ReportListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { formatStatusLabel } from '../../utils/statusLabels';
import { useReports, useUpdateReport, useReportStats } from '@/hooks/useReports';
import { useExploreView } from '@/hooks/useExploreView';
import { useUser } from '@/hooks/useUsers';
import { useEvent } from '@/hooks/useEvents';
import { useAdmin } from '@/hooks/useAdmins';
import { Report, ReportListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
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 { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
const TABLE_KEY = 'reports';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const targetTypeBadgeVariant = (type: string) => {
if (type === 'event') return 'default' as const;
if (type === 'calendar') return 'secondary' as const;
if (type === 'review') return 'outline' as const;
return 'outline' as const;
};
const reportStatusVariant = (status: string) => {
if (status === 'pending') return 'secondary' as const;
if (status === 'reviewed') return 'default' as const;
return 'outline' as const;
};
function reportActions(
handlers: { onOpen: () => void; onQuickView: () => void },
t: TFunction
): RowAction[] {
return [
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
{ label: t('common.quickView'), icon: <Eye className="h-4 w-4" />, onClick: handlers.onQuickView },
];
}
const ReporterCell: React.FC<{ reporterId: string }> = ({ reporterId }) => {
const { data: user, isLoading: loading } = useUser(reporterId);
if (loading) return <Skeleton className="h-4 w-24" />;
if (!user) return <span>{reporterId}</span>;
const isGoodString = (val: unknown): val is string => !isBadValue(val);
let name = '';
if (isGoodString(user.nickname)) {
name = user.nickname;
} else if (isGoodString(user.email)) {
name = user.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 <Skeleton className="h-4 w-24" />;
if (event) {
const name = event.title || event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
return <span>{targetId}</span>;
}
return <span>{targetId}</span>;
};
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
<dt className="font-medium text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-words">{children}</dd>
</>
);
}
const ReportListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<ReportListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'created_at', order: 'desc' });
const { viewMode, setViewMode } = useExploreView();
const [params, setParams] = useState<ReportListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'created_at',
order: 'desc',
});
const { data, isLoading } = useReports(params);
const { data: stats } = useReportStats();
const updateReport = useUpdateReport();
@@ -38,154 +127,17 @@ const ReportListPage: React.FC = () => {
setDetailModal({ open: true, report });
};
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<Report> | SorterResult<Report>[]
) => {
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 targetTypeColors: Record<string, string> = {
event: 'blue',
calendar: 'green',
review: 'purple',
};
// Резолвер отправителя
const ReporterCell: React.FC<{ reporterId: string }> = ({ reporterId }) => {
const { data: user, isLoading: loading } = useUser(reporterId);
if (loading) return <Spin size="small" />;
if (!user) return <span>{reporterId}</span>;
const isGoodString = (val: any): val is string => !isBadValue(val);
let name = '';
if (isGoodString(user.nickname)) {
name = user.nickname;
} else if (isGoodString(user.email)) {
name = user.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 columns: ColumnsType<Report> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу жалобы">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/reports/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Отправитель',
key: 'reporter',
ellipsis: true,
render: (_, record) => <ReporterCell reporterId={record.reporter_id} />,
},
{
title: 'Тип',
dataIndex: 'target_type',
key: 'target_type',
width: 100,
sorter: true,
render: (type: string) => (
<Tag color={targetTypeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Цель',
key: 'target',
ellipsis: true,
render: (_, record) => <TargetCell targetType={record.target_type} targetId={record.target_id} />,
},
{
title: 'Причина',
dataIndex: 'reason',
key: 'reason',
ellipsis: true,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'pending' ? 'orange' : status === 'reviewed' ? 'green' : 'default'}>
{formatStatusLabel(status)}
</Tag>
),
},
{
title: 'Создано',
dataIndex: 'created_at',
key: 'created_at',
width: 120,
sorter: true,
},
{
title: <EyeOutlined />,
key: 'view',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Быстрый просмотр">
<Button
icon={<EyeOutlined />}
size="small"
onClick={() => handleViewDetails(record)}
/>
</Tooltip>
),
},
];
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const renderLink = (entity: any, type: 'user' | 'admin' | 'event') => {
if (!entity) return <Spin size="small" />;
const renderLink = (entity: { id: string; nickname?: string | null; email?: string | null; title?: string | null }, type: 'user' | 'admin' | 'event') => {
let name = '';
if (type === 'event') {
name = !isBadValue(entity.title) ? entity.title : entity.id;
name = !isBadValue(entity.title) ? entity.title! : entity.id;
} else {
const nick = entity.nickname;
const email = entity.email;
if (!isBadValue(nick)) {
name = nick;
name = nick!;
} else if (!isBadValue(email)) {
name = email;
name = email!;
} else {
name = entity.id;
}
@@ -204,132 +156,223 @@ const ReportListPage: React.FC = () => {
const isValidId = (id?: string | null) => id && !isBadValue(id);
const exploreStats = useMemo(() => {
if (!stats) return [];
const items = [{ label: t('common.total'), value: stats.total_reports }];
Object.entries(stats.reports_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<Report>[]>(
() => [
{
id: 'reporter',
header: t('common.sender'),
enableSorting: false,
cell: ({ row }) => <ReporterCell reporterId={row.original.reporter_id} />,
},
{
accessorKey: 'target_type',
header: t('common.type'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const type = getValue<string>();
return <Badge variant={targetTypeBadgeVariant(type)}>{type}</Badge>;
},
},
{
id: 'target',
header: t('common.target'),
enableSorting: false,
cell: ({ row }) => (
<TargetCell targetType={row.original.target_type} targetId={row.original.target_id} />
),
},
{ accessorKey: 'reason', header: t('common.reason'), enableSorting: true },
{
accessorKey: 'status',
header: t('common.status'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={reportStatusVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{ accessorKey: 'created_at', header: t('common.createdAt'), size: 120, enableSorting: true },
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={reportActions({
onOpen: () => navigate(`/reports/${record.id}`),
onQuickView: () => handleViewDetails(record),
}, t)}
/>
);
},
},
],
[navigate, t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => ({
id: record.id,
title: record.reason,
subtitle: `${record.target_type} · ${record.target_id}`,
badges: (
<>
<Badge variant={targetTypeBadgeVariant(record.target_type)}>{record.target_type}</Badge>
<Badge variant={reportStatusVariant(record.status)}>
{formatStatusLabel(record.status)}
</Badge>
</>
),
meta: record.created_at,
actions: reportActions({
onOpen: () => navigate(`/reports/${record.id}`),
onQuickView: () => handleViewDetails(record),
}, 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_reports} prefix={<WarningOutlined />} />
</Card>
</Col>
{Object.entries(stats.reports_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.reports_by_target_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_targets_by_reports && stats.top_targets_by_reports.length > 0 && (
<Card title="Топ целей по жалобам" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey={(r) => `${r.target_type}-${r.target_id}`}
dataSource={stats.top_targets_by_reports}
columns={[
{ title: 'Тип', dataIndex: 'target_type', key: 'target_type' },
<>
<ExploreListShell
title={t('reports.title')}
description={t('explore.descActions')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
banner={{
text: t('inbox.bannerReports'),
to: '/inbox/reports',
actionLabel: t('common.openInbox'),
}}
>
{stats?.top_targets_by_reports && stats.top_targets_by_reports.length > 0 && (
<ExploreInsightsCollapse
title={t('explore.tops')}
tabs={[
{
title: 'ID цели',
dataIndex: 'target_id',
key: 'target_id',
render: (id: string, record) => (
<Link to={`/${record.target_type}s/${id}`}>{id}</Link>
),
id: 'reports',
label: t('explore.byReports'),
rows: stats.top_targets_by_reports.map((row) => ({
id: `${row.target_type}-${row.target_id}`,
primary: (
<Link to={`/${row.target_type}s/${row.target_id}`}>{row.target_id}</Link>
),
secondary: row.target_type,
value: row.report_count,
})),
},
{ title: 'Жалоб', dataIndex: 'report_count', key: 'report_count' },
]}
/>
</Card>
)}
<Table
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
/>
<Modal
title={`Жалоба ${detailModal.report?.id || ''}`}
open={detailModal.open}
onCancel={() => setDetailModal({ open: false, report: null })}
footer={null}
width={600}
>
{detailModal.report && (
<>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="ID">{detailModal.report.id}</Descriptions.Item>
<Descriptions.Item label="Отправитель">
{loadingReporter && isValidId(reporterId) ? <Spin size="small" /> : (
reporter ? renderLink(reporter, 'user') : displayId(reporterId)
)}
</Descriptions.Item>
<Descriptions.Item label="Тип цели">{detailModal.report.target_type}</Descriptions.Item>
<Descriptions.Item label="Цель">
{targetType === 'event' && loadingEvent && isValidId(targetId) ? <Spin size="small" /> : (
targetType === 'event' && targetEvent ? renderLink(targetEvent, 'event') : displayId(targetId)
)}
</Descriptions.Item>
<Descriptions.Item label="Причина">{detailModal.report.reason}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={detailModal.report.status === 'pending' ? 'orange' : detailModal.report.status === 'reviewed' ? 'green' : 'default'}>
{detailModal.report.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Создано">{detailModal.report.created_at}</Descriptions.Item>
<Descriptions.Item label="Решено">{detailModal.report.resolved_at || '-'}</Descriptions.Item>
<Descriptions.Item label="Кем решено">
{loadingResolver && isValidId(resolvedById) ? <Spin size="small" /> : (
resolvedAdmin ? renderLink(resolvedAdmin, 'admin') : displayId(resolvedById)
)}
</Descriptions.Item>
</Descriptions>
{detailModal.report.status === 'pending' && (
<Space style={{ marginTop: 16 }}>
<Button
type="primary"
onClick={() => {
updateReport.mutate(
{ id: detailModal.report!.id, data: { status: 'reviewed' } },
{ onSuccess: () => setDetailModal({ open: false, report: null }) }
);
}}
loading={updateReport.isPending}
>
Рассмотрено
</Button>
<Button
danger
onClick={() => {
updateReport.mutate(
{ id: detailModal.report!.id, data: { status: 'dismissed' } },
{ onSuccess: () => setDetailModal({ open: false, report: null }) }
);
}}
loading={updateReport.isPending}
>
Отклонено
</Button>
</Space>
)}
</>
)}
</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={detailModal.open} onOpenChange={(open) => !open && setDetailModal({ open: false, report: null })}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('reports.detailTitle', { id: detailModal.report?.id || '' })}</DialogTitle>
</DialogHeader>
{detailModal.report && (
<>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 text-sm">
<DetailRow label={t('reports.id')}>{detailModal.report.id}</DetailRow>
<DetailRow label={t('common.sender')}>
{loadingReporter && isValidId(reporterId) ? (
<Skeleton className="h-4 w-32" />
) : reporter ? (
renderLink(reporter, 'user')
) : (
displayId(reporterId)
)}
</DetailRow>
<DetailRow label={t('common.targetType')}>{detailModal.report.target_type}</DetailRow>
<DetailRow label={t('common.target')}>
{targetType === 'event' && loadingEvent && isValidId(targetId) ? (
<Skeleton className="h-4 w-32" />
) : targetType === 'event' && targetEvent ? (
renderLink(targetEvent, 'event')
) : (
displayId(targetId)
)}
</DetailRow>
<DetailRow label={t('common.reason')}>{detailModal.report.reason}</DetailRow>
<DetailRow label={t('common.status')}>
<Badge variant={reportStatusVariant(detailModal.report.status)}>
{formatStatusLabel(detailModal.report.status)}
</Badge>
</DetailRow>
<DetailRow label={t('common.createdAt')}>{detailModal.report.created_at}</DetailRow>
<DetailRow label={t('reports.resolvedAt')}>{detailModal.report.resolved_at || '-'}</DetailRow>
<DetailRow label={t('reports.resolvedBy')}>
{loadingResolver && isValidId(resolvedById) ? (
<Skeleton className="h-4 w-32" />
) : resolvedAdmin ? (
renderLink(resolvedAdmin, 'admin')
) : (
displayId(resolvedById)
)}
</DetailRow>
</dl>
{detailModal.report.status === 'pending' && (
<DialogFooter className="mt-4 sm:justify-start">
<Button
onClick={() => {
updateReport.mutate(
{ id: detailModal.report!.id, data: { status: 'reviewed' } },
{ onSuccess: () => setDetailModal({ open: false, report: null }) }
);
}}
disabled={updateReport.isPending}
>
{t('common.reviewed')}
</Button>
<Button
variant="destructive"
onClick={() => {
updateReport.mutate(
{ id: detailModal.report!.id, data: { status: 'dismissed' } },
{ onSuccess: () => setDetailModal({ open: false, report: null }) }
);
}}
disabled={updateReport.isPending}
>
{t('common.dismissed')}
</Button>
</DialogFooter>
)}
</>
)}
</DialogContent>
</Dialog>
</>
);
};
export default ReportListPage;
export default ReportListPage;
+191 -99
View File
@@ -1,13 +1,38 @@
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Modal, Form, Input, message } from 'antd';
import { Link } from 'react-router-dom';
import { useReview, useUpdateReview } from '../../hooks/useReviews';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import dayjs from 'dayjs';
import { ArrowLeft } from 'lucide-react';
import { useReview, useUpdateReview } from '@/hooks/useReviews';
import { useUser } from '@/hooks/useUsers';
import { useEvent } from '@/hooks/useEvents';
import { formatDisplayValue } from '@/lib/utils';
import { formatStatusLabel } from '@/utils/statusLabels';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
interface StatusFormValues {
reason?: string;
}
const ReviewDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: review, isLoading: reviewLoading } = useReview(id || '');
@@ -16,129 +41,196 @@ const ReviewDetailPage: React.FC = () => {
const userId = review?.user_id;
const targetType = review?.target_type;
const targetId = review?.target_id;
const emDash = t('common.emDash');
const { data: user, isLoading: loadingUser } = useUser(userId || '');
const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetType === 'event' ? targetId || '' : '');
const [statusModal, setStatusModal] = useState<{
open: boolean;
newStatus: string;
}>({ open: false, newStatus: 'visible' });
const [statusForm] = Form.useForm();
const [statusModal, setStatusModal] = useState<{ open: boolean; newStatus: string }>({
open: false,
newStatus: 'visible',
});
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: any) => isBadValue(val) ? '-' : val;
const statusForm = useForm<StatusFormValues>({ defaultValues: { reason: '' } });
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
if (isBadValue(dateStr)) return emDash;
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
const openStatusModal = (newStatus: string) => {
setStatusModal({ open: true, newStatus });
const currentReason = review?.reason && !isBadValue(review.reason) ? review.reason : '';
statusForm.reset({ reason: currentReason });
};
const handleStatusSubmit = (values: StatusFormValues) => {
if (!review) return;
const payload: { status: 'visible' | 'hidden' | 'deleted'; reason?: string } = {
status: statusModal.newStatus as 'visible' | 'hidden' | 'deleted',
};
if (values.reason?.trim()) payload.reason = values.reason.trim();
updateReview.mutate(
{ id: review.id, data: payload },
{
onSuccess: () => {
setStatusModal({ open: false, newStatus: 'visible' });
},
}
);
};
const getUserLink = () => {
if (loadingUser) return <Spin size="small" />;
if (!user) return displayValue(userId);
if (loadingUser) return <Skeleton className="h-4 w-24" />;
if (!user) return formatDisplayValue(userId) === '-' ? emDash : String(userId);
const name = !isBadValue(user.nickname) ? user.nickname : user.email || user.id;
return <Link to={`/users/${user.id}`}>{name}</Link>;
return <Link to={`/users/${user.id}`} className="text-primary hover:underline">{name}</Link>;
};
const getTargetLink = () => {
if (targetType === 'event') {
if (loadingEvent) return <Spin size="small" />;
if (loadingEvent) return <Skeleton className="h-4 w-24" />;
if (targetEvent) {
const name = targetEvent.title || targetEvent.id;
return <Link to={`/events/${targetEvent.id}`}>{name}</Link>;
return <Link to={`/events/${targetEvent.id}`} className="text-primary hover:underline">{name}</Link>;
}
return displayValue(targetId);
return formatDisplayValue(targetId) === '-' ? emDash : String(targetId);
}
return displayValue(targetId);
return formatDisplayValue(targetId) === '-' ? emDash : String(targetId);
};
// Открытие модалки смены статуса
const openStatusModal = (newStatus: string) => {
setStatusModal({ open: true, newStatus });
statusForm.resetFields();
// Предзаполняем причину, если была
const currentReason = review?.reason && !isBadValue(review.reason) ? review.reason : '';
statusForm.setFieldsValue({ reason: currentReason });
};
if (reviewLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const handleStatusSubmit = () => {
statusForm.validateFields().then((values) => {
if (!review) return;
const payload: any = { status: statusModal.newStatus };
if (values.reason && values.reason.trim() !== '') {
payload.reason = values.reason;
}
updateReview.mutate(
{ id: review.id, data: payload },
{
onSuccess: () => {
setStatusModal({ open: false, newStatus: 'visible' });
message.success('Статус обновлён');
}
}
);
});
};
if (!review) return <p className="text-muted-foreground">{t('reviews.notFound')}</p>;
if (reviewLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!review) return <p>Отзыв не найден</p>;
const reasonRequired = statusModal.newStatus !== 'visible';
return (
<Card title={`Отзыв ${review.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{review.id}</Descriptions.Item>
<Descriptions.Item label="Пользователь">{getUserLink()}</Descriptions.Item>
<Descriptions.Item label="Тип цели">
<Tag color={review.target_type === 'event' ? 'blue' : 'green'}>{review.target_type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Цель">{getTargetLink()}</Descriptions.Item>
<Descriptions.Item label="Оценка">{'⭐'.repeat(review.rating)} ({review.rating})</Descriptions.Item>
<Descriptions.Item label="Комментарий">{displayValue(review.comment)}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={review.status === 'visible' ? 'green' : review.status === 'hidden' ? 'orange' : 'red'}>
{review.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(review.reason)}</Descriptions.Item>
<Descriptions.Item label="Лайки">{isBadValue(review.likes) ? 0 : review.likes}</Descriptions.Item>
<Descriptions.Item label="Дизлайки">{isBadValue(review.dislikes) ? 0 : review.dislikes}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(review.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(review.updated_at)}</Descriptions.Item>
</Descriptions>
<Space style={{ marginTop: 16 }}>
<Button onClick={() => openStatusModal('visible')}>Показать</Button>
<Button onClick={() => openStatusModal('hidden')}>Скрыть</Button>
<Button danger onClick={() => openStatusModal('deleted')}>Удалить</Button>
</Space>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/reviews')}>Назад к списку</Button>
<>
<PageHeader
title={t('reviews.detailTitle', { id: review.id })}
breadcrumbs={[
{ label: t('reviews.title'), href: '/reviews' },
{ label: review.id },
]}
actions={
<Button variant="outline" onClick={() => navigate('/reviews')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{review.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.user')}</dt>
<dd>{getUserLink()}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.targetType')}</dt>
<dd>
<Badge variant={review.target_type === 'event' ? 'default' : 'success'}>{review.target_type}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.target')}</dt>
<dd>{getTargetLink()}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('reviews.score')}</dt>
<dd>{'⭐'.repeat(review.rating)} ({review.rating})</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.comment')}</dt>
<dd>{formatDisplayValue(review.comment) === '-' ? emDash : formatDisplayValue(review.comment)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={review.status === 'visible' ? 'success' : review.status === 'hidden' ? 'warning' : 'destructive'}>
{formatStatusLabel(review.status)}
</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.reason')}</dt>
<dd>{formatDisplayValue(review.reason) === '-' ? emDash : formatDisplayValue(review.reason)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.likes')}</dt>
<dd>{isBadValue(review.likes) ? 0 : review.likes}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.dislikes')}</dt>
<dd>{isBadValue(review.dislikes) ? 0 : review.dislikes}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{formatDate(review.created_at)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.updatedAt')}</dt>
<dd>{formatDate(review.updated_at)}</dd>
</div>
</dl>
<Modal
title={`Изменить статус на «${statusModal.newStatus}»`}
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => openStatusModal('visible')}>{t('common.show')}</Button>
<Button variant="outline" onClick={() => openStatusModal('hidden')}>{t('common.hide')}</Button>
<Button variant="destructive" onClick={() => openStatusModal('deleted')}>{t('common.delete')}</Button>
</div>
</CardContent>
</Card>
<Dialog
open={statusModal.open}
onCancel={() => setStatusModal({ open: false, newStatus: 'visible' })}
onOk={handleStatusSubmit}
confirmLoading={updateReview.isPending}
destroyOnHidden
onOpenChange={(open) => !open && setStatusModal({ open: false, newStatus: 'visible' })}
>
<Form form={statusForm} layout="vertical" preserve={false}>
<Form.Item
label="Причина"
name="reason"
rules={[
{
required: statusModal.newStatus !== 'visible',
message: 'Укажите причину',
},
]}
>
<Input.TextArea rows={3} placeholder="Введите причину изменения статуса" />
</Form.Item>
</Form>
</Modal>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.changeStatusTo', { status: formatStatusLabel(statusModal.newStatus) })}</DialogTitle>
</DialogHeader>
<form id="review-status-form" className="space-y-4" onSubmit={statusForm.handleSubmit(handleStatusSubmit)}>
<div className="space-y-2">
<Label htmlFor="reason">{t('common.reason')}</Label>
<Textarea
id="reason"
rows={3}
placeholder={t('common.enterReason')}
{...statusForm.register('reason', { required: reasonRequired })}
/>
{statusForm.formState.errors.reason && (
<p className="text-sm text-destructive">{t('reviews.reasonRequiredShort')}</p>
)}
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setStatusModal({ open: false, newStatus: 'visible' })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="review-status-form" disabled={updateReview.isPending}>
{updateReview.isPending ? t('common.saving') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default ReviewDetailPage;
export default ReviewDetailPage;
+522 -339
View File
@@ -1,388 +1,571 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, InputNumber, message, Tooltip, Input, Spin, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, EditOutlined, StarOutlined } from '@ant-design/icons';
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Link, useNavigate } from 'react-router-dom';
import { useReviews, useUpdateReview, useBulkUpdateReviews, useReview, useReviewStats } 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';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { formatStatusLabel } from '../../utils/statusLabels';
import { Info, Pencil } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import {
useReviews,
useUpdateReview,
useBulkUpdateReviews,
useReview,
useReviewStats,
} from '@/hooks/useReviews';
import { useExploreView } from '@/hooks/useExploreView';
import { useUser } from '@/hooks/useUsers';
import { useEvent } from '@/hooks/useEvents';
import { Review, ReviewListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { notify } from '@/lib/notify';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
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';
const TABLE_KEY = 'reviews';
const typeBadgeVariant = (type: string) => {
if (type === 'calendar') return 'success' as const;
if (type === 'event') return 'default' as const;
if (type === 'review') return 'secondary' as const;
return 'outline' as const;
};
const statusBadgeVariant = (status: string) => {
if (status === 'visible') return 'success' as const;
if (status === 'hidden') return 'warning' as const;
return 'destructive' as const;
};
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
interface EditFormValues {
status: string;
reason?: string;
comment?: string;
rating?: number;
}
interface BulkStatusFormValues {
reason?: string;
}
function reviewActions(
handlers: { onOpen: () => void; onEdit: () => 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 },
];
}
const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading: loading } = useUser(userId);
if (loading) return <Skeleton className="h-4 w-24" />;
if (!user) return <span>{userId}</span>;
const isGoodString = (val: unknown): val is string => !isBadValue(val);
let name = '';
if (isGoodString(user.nickname)) {
name = user.nickname;
} else if (isGoodString(user.email)) {
name = user.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 <Skeleton className="h-4 w-24" />;
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 ReviewListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<ReviewListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'created_at', order: 'desc' });
const { viewMode, setViewMode } = useExploreView();
const [params, setParams] = useState<ReviewListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'created_at',
order: 'desc',
});
const { data, isLoading } = useReviews(params);
const { data: stats } = useReviewStats();
const updateReview = useUpdateReview();
const bulkUpdate = useBulkUpdateReviews();
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
// Модальное окно для индивидуального редактирования
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 editForm = useForm<EditFormValues>();
// Модальное окно для массового изменения статуса с причиной
const [bulkStatusModal, setBulkStatusModal] = useState<{
open: boolean;
status: string;
}>({ open: false, status: 'hidden' });
const [bulkStatusForm] = Form.useForm();
const [bulkStatusModal, setBulkStatusModal] = useState<{ open: boolean; status: string }>({
open: false,
status: 'hidden',
});
const bulkStatusForm = useForm<BulkStatusFormValues>();
// ---- Индивидуальное редактирование ----
const handleEdit = (id: string) => {
setEditModal({ open: true, reviewId: id });
const pageIds = useMemo(() => (data?.data ?? []).map((r) => r.id), [data?.data]);
const allPageSelected = pageIds.length > 0 && pageIds.every((id) => selectedRowKeys.includes(id));
const toggleSelectAll = () => {
if (allPageSelected) {
setSelectedRowKeys((prev) => prev.filter((id) => !pageIds.includes(id)));
} else {
setSelectedRowKeys((prev) => [...new Set([...prev, ...pageIds])]);
}
};
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 }) }
);
});
const toggleRow = (id: string) => {
setSelectedRowKeys((prev) =>
prev.includes(id) ? prev.filter((k) => k !== id) : [...prev, id]
);
};
const handleEdit = (id: string) => setEditModal({ open: true, reviewId: id });
const handleSaveEdit = editForm.handleSubmit((values) => {
if (!editModal.reviewId) return;
const status = values.status;
if ((status === 'hidden' || status === 'deleted') && !values.reason?.trim()) {
editForm.setError('reason', { message: t('reviews.reasonRequired') });
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);
editForm.reset({
status: editingReview.status,
reason: editingReview.reason ?? undefined,
comment: editingReview.comment ?? undefined,
rating: editingReview.rating,
});
}
}, [editingReview, editModal.open, editForm]);
// ---- Массовое изменение статуса ----
const openBulkStatusModal = (status: string) => {
if (selectedRowKeys.length === 0) {
message.warning('Выберите отзывы');
notify.info(t('reviews.selectReviews'));
return;
}
setBulkStatusModal({ open: true, status });
bulkStatusForm.resetFields();
bulkStatusForm.reset();
};
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 => mergeTablePagination({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
};
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 isGoodString = (val: any): val is string => !isBadValue(val);
let name = '';
if (isGoodString(user.nickname)) {
name = user.nickname;
} else if (isGoodString(user.email)) {
name = user.email;
} else {
name = user.id;
const handleBulkStatusSubmit = bulkStatusForm.handleSubmit((values) => {
const reasonRequired =
bulkStatusModal.status === 'hidden' || bulkStatusModal.status === 'deleted';
if (reasonRequired && !values.reason?.trim()) {
bulkStatusForm.setError('reason', { message: t('reviews.reasonRequiredShort') });
return;
}
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'}>
{formatStatusLabel(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}`;
const reason = values.reason ? values.reason.trim() : '';
const updates = selectedRowKeys.map((id) => ({
id,
status: bulkStatusModal.status,
reason: reason || undefined,
}));
bulkUpdate.mutate(updates, {
onSuccess: () => {
setBulkStatusModal({ open: false, status: 'hidden' });
setSelectedRowKeys([]);
},
},
{
title: '',
key: 'actions',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record.id)}
});
});
const exploreStats = useMemo(() => {
if (!stats) return [];
const items = [{ label: t('common.total'), value: stats.total_reviews }];
Object.entries(stats.reviews_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<Review>[]>(
() => [
{
id: 'select',
header: () => (
<input
type="checkbox"
className="h-4 w-4 rounded border"
checked={allPageSelected}
onChange={toggleSelectAll}
aria-label={t('reviews.selectAllAria')}
/>
</Tooltip>
),
},
];
),
size: 40,
enableSorting: false,
cell: ({ row }) => (
<input
type="checkbox"
className="h-4 w-4 rounded border"
checked={selectedRowKeys.includes(row.original.id)}
onChange={() => toggleRow(row.original.id)}
aria-label={t('reviews.selectReviewAria', { id: row.original.id })}
/>
),
},
{
id: 'user',
header: t('common.user'),
enableSorting: false,
cell: ({ row }) => <UserCell userId={row.original.user_id} />,
},
{
accessorKey: 'target_type',
header: t('common.type'),
enableSorting: true,
cell: ({ getValue }) => {
const type = getValue<string>();
return <Badge variant={typeBadgeVariant(type)}>{type}</Badge>;
},
},
{
id: 'target',
header: t('common.target'),
enableSorting: false,
cell: ({ row }) => (
<TargetCell targetType={row.original.target_type} targetId={row.original.target_id} />
),
},
{
accessorKey: 'rating',
header: t('reviews.score'),
enableSorting: true,
cell: ({ getValue }) => '⭐'.repeat(getValue<number>()),
},
{
accessorKey: 'comment',
header: t('common.comment'),
enableSorting: false,
cell: ({ getValue }) => {
const comment = getValue<string>();
return <span className="line-clamp-2">{comment}</span>;
},
},
{
accessorKey: 'status',
header: t('common.status'),
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={statusBadgeVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{
id: 'likes',
header: t('reviews.likesDislikes'),
enableSorting: false,
cell: ({ row }) => {
const likes = isBadValue(row.original.likes) ? 0 : row.original.likes;
const dislikes = isBadValue(row.original.dislikes) ? 0 : row.original.dislikes;
return `${likes} / ${dislikes}`;
},
},
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={reviewActions({
onOpen: () => navigate(`/reviews/${record.id}`),
onEdit: () => handleEdit(record.id),
}, t)}
/>
);
},
},
],
[allPageSelected, selectedRowKeys, navigate, t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => {
const likes = isBadValue(record.likes) ? 0 : record.likes;
const dislikes = isBadValue(record.dislikes) ? 0 : record.dislikes;
const title = record.comment
? record.comment.length > 60
? `${record.comment.slice(0, 60)}`
: record.comment
: t('reviews.ratingValue', { rating: record.rating });
return {
id: record.id,
title,
subtitle: `${record.target_type} · ${record.target_id}`,
badges: (
<>
<Badge variant={typeBadgeVariant(record.target_type)}>{record.target_type}</Badge>
<Badge variant={statusBadgeVariant(record.status)}>
{formatStatusLabel(record.status)}
</Badge>
<span className="text-xs">{'⭐'.repeat(record.rating)}</span>
</>
),
meta: `${likes} / ${dislikes}`,
actions: reviewActions({
onOpen: () => navigate(`/reviews/${record.id}`),
onEdit: () => handleEdit(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_reviews} prefix={<StarOutlined />} />
</Card>
</Col>
{Object.entries(stats.reviews_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.reviews_by_target_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_targets_by_reviews && stats.top_targets_by_reviews.length > 0 && (
<Card title="Топ целей по отзывам" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey={(r) => `${r.target_type}-${r.target_id}`}
dataSource={stats.top_targets_by_reviews}
columns={[
{ title: 'Тип', dataIndex: 'target_type', key: 'target_type' },
<>
<ExploreListShell
title={t('reviews.title')}
description={t('explore.descBulk')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
>
{stats && (
<ExploreInsightsCollapse
title={t('explore.tops')}
tabs={[
{
title: 'ID цели',
dataIndex: 'target_id',
key: 'target_id',
render: (id: string, record) => (
<Link to={`/${record.target_type}s/${id}`}>{id}</Link>
),
id: 'all',
label: t('explore.allReviews'),
rows: (stats.top_targets_by_reviews ?? []).map((item) => ({
id: `${item.target_type}-${item.target_id}`,
primary: (
<Link to={`/${item.target_type}s/${item.target_id}`}>{item.target_id}</Link>
),
secondary: item.target_type,
value: item.review_count,
})),
},
{
id: 'positive',
label: t('explore.positive'),
rows: (stats.top_targets_by_positive_reviews ?? []).map((item) => ({
id: `${item.target_type}-${item.target_id}`,
primary: (
<Link to={`/${item.target_type}s/${item.target_id}`}>{item.target_id}</Link>
),
secondary: item.target_type,
value: item.review_count,
})),
},
{
id: 'negative',
label: t('explore.negative'),
rows: (stats.top_targets_by_negative_reviews ?? []).map((item) => ({
id: `${item.target_type}-${item.target_id}`,
primary: (
<Link to={`/${item.target_type}s/${item.target_id}`}>{item.target_id}</Link>
),
secondary: item.target_type,
value: item.review_count,
})),
},
{ title: 'Отзывов', dataIndex: 'review_count', key: 'review_count' },
]}
/>
</Card>
)}
<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={getTablePagination(params, data?.total)}
/>
{/* Модальное окно индивидуального редактирования */}
<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: 'Укажите причину',
},
]}
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => openBulkStatusModal('hidden')}
disabled={selectedRowKeys.length === 0}
>
<Input.TextArea rows={3} placeholder="Общая причина для выбранных отзывов" />
</Form.Item>
</Form>
</Modal>
</div>
{t('reviews.hideSelected')}
</Button>
<Button
variant="outline"
onClick={() => openBulkStatusModal('visible')}
disabled={selectedRowKeys.length === 0}
>
{t('reviews.showSelected')}
</Button>
<Button
variant="destructive"
onClick={() => openBulkStatusModal('deleted')}
disabled={selectedRowKeys.length === 0}
>
{t('reviews.deleteSelected')}
</Button>
</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, reviewId: null })}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('reviews.editTitle')}</DialogTitle>
</DialogHeader>
{loadingReview ? (
<div className="space-y-3 py-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<form id="edit-review-form" className="space-y-4" onSubmit={handleSaveEdit}>
<div className="space-y-2">
<Label>{t('reviews.score')}</Label>
<Input
type="number"
min={1}
max={5}
{...editForm.register('rating', { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label>{t('common.comment')}</Label>
<Textarea rows={3} {...editForm.register('comment')} />
</div>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={editForm.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="visible">visible</SelectItem>
<SelectItem value="hidden">hidden</SelectItem>
<SelectItem value="deleted">deleted</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.reason')}</Label>
<Textarea rows={2} {...editForm.register('reason')} />
{editForm.formState.errors.reason && (
<p className="text-sm text-destructive">
{editForm.formState.errors.reason.message}
</p>
)}
</div>
</form>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal({ open: false, reviewId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-review-form" disabled={updateReview.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={bulkStatusModal.open}
onOpenChange={(open) => !open && setBulkStatusModal({ open: false, status: 'hidden' })}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('reviews.bulkStatusTitle', {
status: bulkStatusModal.status,
count: selectedRowKeys.length,
})}
</DialogTitle>
</DialogHeader>
<form id="bulk-status-form" className="space-y-4" onSubmit={handleBulkStatusSubmit}>
<div className="space-y-2">
<Label>{t('common.reason')}</Label>
<Textarea
rows={3}
placeholder={t('reviews.bulkReasonPlaceholder')}
{...bulkStatusForm.register('reason')}
/>
{bulkStatusForm.formState.errors.reason && (
<p className="text-sm text-destructive">
{bulkStatusForm.formState.errors.reason.message}
</p>
)}
</div>
</form>
<DialogFooter>
<Button
variant="outline"
onClick={() => setBulkStatusModal({ open: false, status: 'hidden' })}
>
{t('common.cancel')}
</Button>
<Button type="submit" form="bulk-status-form" disabled={bulkUpdate.isPending}>
{t('common.apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default ReviewListPage;
export default ReviewListPage;
+445 -268
View File
@@ -1,18 +1,126 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, DatePicker, Tooltip, Spin, Card, Col, Row, Statistic } from 'antd';
import { EditOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Link } from 'react-router-dom';
import { useSubscriptions, useUpdateSubscription, useDeleteSubscription, useSubscription, useSubscriptionStats } from '../../hooks/useSubscriptions';
import { useUser } from '../../hooks/useUsers';
import { Subscription, SubscriptionListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { Pencil, Trash2 } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import {
useSubscriptions,
useUpdateSubscription,
useDeleteSubscription,
useSubscription,
useSubscriptionStats,
} from '@/hooks/useSubscriptions';
import { useExploreView } from '@/hooks/useExploreView';
import { useUser } from '@/hooks/useUsers';
import { Subscription, SubscriptionListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
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 { 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';
const TABLE_KEY = 'subscriptions';
const ALL = '__all__';
const planBadgeVariant = (plan: string) => {
if (plan === 'trial') return 'secondary' as const;
if (plan === 'monthly') return 'default' as const;
if (plan === 'quarterly') return 'success' as const;
if (plan === 'biannual') return 'secondary' as const;
return 'warning' as const;
};
const statusBadgeVariant = (status: string) => {
if (status === 'active') return 'success' as const;
if (status === 'expired') return 'warning' as const;
return 'destructive' as const;
};
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const toDatetimeLocal = (iso: string | null | undefined): string => {
if (!iso || iso === '-') return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
interface EditFormValues {
plan: string;
status: string;
trial_used: boolean;
expires_at: string;
}
function subscriptionActions(
handlers: { onEdit: () => void; onDelete: () => void },
t: TFunction
): RowAction[] {
return [
{ 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 UserCell: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading: loading } = useUser(userId);
if (loading) return <Skeleton className="h-4 w-24" />;
if (!user) return <span>{userId}</span>;
const name = !isBadValue(user.nickname) ? user.nickname : user.email;
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
};
const SubscriptionListPage: React.FC = () => {
const [params, setParams] = useState<SubscriptionListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0 });
const { t } = useTranslation();
const { viewMode, setViewMode } = useExploreView();
const [params, setParams] = useState<SubscriptionListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
});
const { data, isLoading } = useSubscriptions(params);
const { data: stats } = useSubscriptionStats();
const updateSubscription = useUpdateSubscription();
@@ -22,283 +130,352 @@ const SubscriptionListPage: React.FC = () => {
open: false,
subscriptionId: null,
});
const { data: editingSubscription, isLoading: loadingSubscription } = useSubscription(editModal.subscriptionId || '');
const [form] = Form.useForm();
const { data: editingSubscription, isLoading: loadingSubscription } = useSubscription(
editModal.subscriptionId || ''
);
const form = useForm<EditFormValues>();
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; subscriptionId: string | null }>({
open: false,
subscriptionId: null,
});
// Заполняем форму с задержкой, когда данные загружены
useEffect(() => {
if (editingSubscription && editModal.open) {
setTimeout(() => {
form.setFieldsValue({
plan: editingSubscription.plan,
status: editingSubscription.status,
trial_used: editingSubscription.trial_used,
expires_at: editingSubscription.expires_at ? dayjs(editingSubscription.expires_at) : null,
});
}, 0);
form.reset({
plan: editingSubscription.plan,
status: editingSubscription.status,
trial_used: editingSubscription.trial_used,
expires_at: toDatetimeLocal(editingSubscription.expires_at),
});
}
}, [editingSubscription, editModal.open, form]);
const handleEdit = (sub: Subscription) => {
// Сбрасываем форму перед открытием
form.resetFields();
setEditModal({ open: true, subscriptionId: sub.id });
};
const handleEdit = (sub: Subscription) => setEditModal({ open: true, subscriptionId: sub.id });
const handleSave = () => {
form.validateFields().then((values) => {
if (!editModal.subscriptionId) return;
const payload = Object.fromEntries(
Object.entries(values)
.filter(([_, v]) => v !== '' && v !== undefined && v !== null)
.map(([key, val]) => {
if (dayjs.isDayjs(val)) return [key, val.toISOString()];
return [key, val];
})
);
updateSubscription.mutate(
{ id: editModal.subscriptionId, data: payload },
{ onSuccess: () => setEditModal({ open: false, subscriptionId: null }) }
);
const handleSave = form.handleSubmit((values) => {
if (!editModal.subscriptionId) return;
const payload: Record<string, unknown> = {};
if (values.plan) payload.plan = values.plan;
if (values.status) payload.status = values.status;
if (values.trial_used !== undefined) payload.trial_used = values.trial_used;
if (values.expires_at) payload.expires_at = new Date(values.expires_at).toISOString();
updateSubscription.mutate(
{ id: editModal.subscriptionId, data: payload },
{ onSuccess: () => setEditModal({ open: false, subscriptionId: null }) }
);
});
const handleDelete = (id: string) => setDeleteConfirm({ open: true, subscriptionId: id });
const confirmDelete = () => {
if (!deleteConfirm.subscriptionId) return;
deleteSubscription.mutate(deleteConfirm.subscriptionId, {
onSuccess: () => setDeleteConfirm({ open: false, subscriptionId: null }),
});
};
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить подписку?',
content: 'Это действие нельзя отменить.',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteSubscription.mutate(id),
});
};
const exploreStats = useMemo(() => {
if (!stats) return [];
const items = [
{ label: t('common.total'), value: stats.total_subscriptions },
{ label: t('subscriptions.trialCount'), value: stats.trial_subscriptions },
];
Object.entries(stats.subscriptions_by_status || {})
.slice(0, 2)
.forEach(([status, count]) => {
items.push({ label: formatStatusLabel(status), value: count });
});
return items.slice(0, 4);
}, [stats, t]);
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<Subscription> | SorterResult<Subscription>[]
) => {
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 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 name = !isBadValue(user.nickname) ? user.nickname : user.email;
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
};
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
const planColors: Record<string, string> = {
trial: 'cyan',
monthly: 'blue',
quarterly: 'green',
biannual: 'purple',
annual: 'orange',
};
const columns: ColumnsType<Subscription> = [
{
title: 'Пользователь',
key: 'user',
ellipsis: true,
render: (_, record) => <UserCell userId={record.user_id} />,
},
{
title: 'План',
dataIndex: 'plan',
key: 'plan',
width: 100,
sorter: true,
render: (plan: string) => (
<Tag color={planColors[plan] || 'default'}>{plan}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => {
const color =
status === 'active' ? 'green' :
status === 'expired' ? 'orange' : 'red';
return <Tag color={color}>{status}</Tag>;
const columns = useMemo<ColumnDef<Subscription>[]>(
() => [
{
id: 'user',
header: t('common.user'),
enableSorting: false,
cell: ({ row }) => <UserCell userId={row.original.user_id} />,
},
},
{
title: 'Пробный',
dataIndex: 'trial_used',
key: 'trial_used',
width: 100,
render: (v: boolean) => (v ? 'Да' : 'Нет'),
},
{ title: 'Начало', dataIndex: 'started_at', key: 'started_at', render: (val) => formatDate(val) },
{ title: 'Окончание', dataIndex: 'expires_at', key: 'expires_at', render: (val) => formatDate(val) },
{
title: 'Действия',
key: 'actions',
width: 100,
render: (_, record) => (
<Space>
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record)}
{
accessorKey: 'plan',
header: t('common.plan'),
enableSorting: true,
cell: ({ getValue }) => {
const plan = getValue<string>();
return <Badge variant={planBadgeVariant(plan)}>{plan}</Badge>;
},
},
{
accessorKey: 'status',
header: t('common.status'),
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={statusBadgeVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{
accessorKey: 'trial_used',
header: t('subscriptions.trial'),
enableSorting: false,
cell: ({ getValue }) => (getValue<boolean>() ? t('common.yes') : t('common.no')),
},
{
accessorKey: 'started_at',
header: t('common.start'),
enableSorting: false,
cell: ({ getValue }) => formatDate(getValue<string>()),
},
{
accessorKey: 'expires_at',
header: t('common.expiresAt'),
enableSorting: false,
cell: ({ getValue }) => formatDate(getValue<string>()),
},
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={subscriptionActions({
onEdit: () => handleEdit(record),
onDelete: () => handleDelete(record.id),
}, t)}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
/>
</Tooltip>
</Space>
);
},
},
],
[t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => ({
id: record.id,
title: record.user_id,
subtitle: t('subscriptions.expiresUntil', { date: formatDate(record.expires_at) }),
badges: (
<>
<Badge variant={planBadgeVariant(record.plan)}>{record.plan}</Badge>
<Badge variant={statusBadgeVariant(record.status)}>{formatStatusLabel(record.status)}</Badge>
{record.trial_used && <Badge variant="outline">{t('subscriptions.trialBadge')}</Badge>}
</>
),
},
];
meta: formatDate(record.started_at),
actions: subscriptionActions({
onEdit: () => handleEdit(record),
onDelete: () => handleDelete(record.id),
}, t),
}));
}, [data?.data, 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_subscriptions} prefix={<DollarOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Пробные" value={stats.trial_subscriptions} />
</Card>
</Col>
{Object.entries(stats.subscriptions_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.subscriptions_by_plan || {}).map(([plan, count]) => (
<Col xs={12} sm={6} md={4} key={`plan-${plan}`}>
<Card>
<Statistic title={`План: ${plan}`} value={count} />
</Card>
</Col>
))}
</Row>
)}
{stats?.ending_paid_subscriptions && stats.ending_paid_subscriptions.length > 0 && (
<Card title="Заканчивающиеся платные подписки" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={stats.ending_paid_subscriptions}
columns={[
{ title: 'ID', dataIndex: 'id', key: 'id' },
{ title: 'Пользователь', dataIndex: 'user_id', key: 'user_id' },
{ title: 'План', dataIndex: 'plan', key: 'plan' },
{ title: 'Истекает', dataIndex: 'expires_at', key: 'expires_at' },
<>
<ExploreListShell
title={t('subscriptions.title')}
description={t('explore.descActions')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
toolbar={
<div className="flex flex-wrap gap-2">
<Select
value={params.plan ?? ALL}
onValueChange={(val) =>
setParams({
...params,
plan: val === ALL ? undefined : (val as SubscriptionListParams['plan']),
offset: 0,
})
}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder={t('common.plan')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('common.allPlans')}</SelectItem>
<SelectItem value="monthly">monthly</SelectItem>
<SelectItem value="quarterly">quarterly</SelectItem>
<SelectItem value="biannual">biannual</SelectItem>
<SelectItem value="annual">annual</SelectItem>
<SelectItem value="trial">trial</SelectItem>
</SelectContent>
</Select>
<Select
value={params.status ?? ALL}
onValueChange={(val) =>
setParams({
...params,
status: val === ALL ? undefined : (val as SubscriptionListParams['status']),
offset: 0,
})
}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder={t('common.status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('common.allStatuses')}</SelectItem>
<SelectItem value="active">active</SelectItem>
<SelectItem value="expired">expired</SelectItem>
<SelectItem value="cancelled">cancelled</SelectItem>
</SelectContent>
</Select>
</div>
}
>
{stats?.ending_paid_subscriptions && stats.ending_paid_subscriptions.length > 0 && (
<ExploreInsightsCollapse
title={t('explore.expiringSoon')}
tabs={[
{
id: 'ending',
label: t('explore.paid30d'),
rows: stats.ending_paid_subscriptions.map((sub) => ({
id: sub.id,
primary: <Link to={`/users/${sub.user_id}`}>{sub.user_id}</Link>,
secondary: `${sub.plan} · #${sub.id}`,
value: formatDate(sub.expires_at),
})),
},
]}
/>
</Card>
)}
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="План"
onChange={(val) => setParams({ ...params, plan: val, offset: 0 })}
style={{ width: 150 }}
>
<Select.Option value="monthly">monthly</Select.Option>
<Select.Option value="quarterly">quarterly</Select.Option>
<Select.Option value="biannual">biannual</Select.Option>
<Select.Option value="annual">annual</Select.Option>
<Select.Option value="trial">trial</Select.Option>
</Select>
<Select
allowClear
placeholder="Статус"
onChange={(val) => setParams({ ...params, status: val, offset: 0 })}
style={{ width: 150 }}
>
<Select.Option value="active">active</Select.Option>
<Select.Option value="expired">expired</Select.Option>
<Select.Option value="cancelled">cancelled</Select.Option>
</Select>
</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, subscriptionId: null })}
onOk={handleSave}
confirmLoading={updateSubscription.isPending}
destroyOnHidden
>
{loadingSubscription ? (
<Spin />
) : (
<Form form={form} layout="vertical" preserve={false}>
<Form.Item label="План" name="plan" rules={[{ required: true }]}>
<Select>
<Select.Option value="monthly">monthly</Select.Option>
<Select.Option value="quarterly">quarterly</Select.Option>
<Select.Option value="biannual">biannual</Select.Option>
<Select.Option value="annual">annual</Select.Option>
<Select.Option value="trial">trial</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Статус" name="status" rules={[{ required: true }]}>
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="expired">expired</Select.Option>
<Select.Option value="cancelled">cancelled</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Пробный период использован" name="trial_used">
<Select>
<Select.Option value={true}>Да</Select.Option>
<Select.Option value={false}>Нет</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Дата окончания" name="expires_at">
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" style={{ width: '100%' }} />
</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, subscriptionId: null })}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('subscriptions.editTitle')}</DialogTitle>
</DialogHeader>
{loadingSubscription ? (
<div className="space-y-3 py-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<form id="edit-subscription-form" className="space-y-4" onSubmit={handleSave}>
<div className="space-y-2">
<Label>{t('common.plan')}</Label>
<Controller
name="plan"
control={form.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectPlan')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">monthly</SelectItem>
<SelectItem value="quarterly">quarterly</SelectItem>
<SelectItem value="biannual">biannual</SelectItem>
<SelectItem value="annual">annual</SelectItem>
<SelectItem value="trial">trial</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={form.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="expired">expired</SelectItem>
<SelectItem value="cancelled">cancelled</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('subscriptions.trialUsed')}</Label>
<Controller
name="trial_used"
control={form.control}
render={({ field }) => (
<Select
value={String(field.value)}
onValueChange={(v) => field.onChange(v === 'true')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">{t('common.yes')}</SelectItem>
<SelectItem value="false">{t('common.no')}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.expiresAt')}</Label>
<Input type="datetime-local" {...form.register('expires_at')} />
</div>
</form>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal({ open: false, subscriptionId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-subscription-form" disabled={updateSubscription.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={deleteConfirm.open}
onOpenChange={(open) => !open && setDeleteConfirm({ open: false, subscriptionId: null })}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('subscriptions.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, subscriptionId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteSubscription.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default SubscriptionListPage;
export default SubscriptionListPage;
+217 -110
View File
@@ -1,19 +1,53 @@
import React, { useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Form, Select, Input } from 'antd';
import { Link } from 'react-router-dom';
import { useTicket, useUpdateTicket } from '../../hooks/useTickets';
import { useUser } from '../../hooks/useUsers';
import { useAdmin, useAdmins } from '../../hooks/useAdmins';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useForm, Controller } from 'react-hook-form';
import dayjs from 'dayjs';
import { ArrowLeft } from 'lucide-react';
import { useTicket, useUpdateTicket } from '@/hooks/useTickets';
import { useUser } from '@/hooks/useUsers';
import { useAdmin, useAdmins } from '@/hooks/useAdmins';
import { formatDisplayValue } from '@/lib/utils';
import { formatStatusLabel } from '@/utils/statusLabels';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const ticketStatusVariant = (status: string) => {
if (status === 'open') return 'destructive' as const;
if (status === 'in_progress') return 'default' as const;
if (status === 'resolved') return 'success' as const;
return 'secondary' as const;
};
interface TicketFormValues {
status: string;
assigned_to?: string;
resolution_note?: string;
}
const UNASSIGNED = '__none__';
const TicketDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: ticket, isLoading } = useTicket(id || '');
const updateTicket = useUpdateTicket();
const [form] = Form.useForm();
const { data: admins, isLoading: loadingAdmins } = useAdmins({ limit: 1000, offset: 0 });
const reporterId = ticket?.reporter_id;
@@ -22,129 +56,202 @@ const TicketDetailPage: React.FC = () => {
const assignedId = ticket?.assigned_to;
const { data: assignedAdmin, isLoading: loadingAssigned } = useAdmin(assignedId || '');
const form = useForm<TicketFormValues>({
defaultValues: { status: 'open', assigned_to: UNASSIGNED, resolution_note: '' },
});
const emDash = t('common.emDash');
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return emDash;
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
useEffect(() => {
if (ticket) {
form.setFieldsValue({
form.reset({
status: ticket.status,
assigned_to: !isBadValue(ticket.assigned_to) ? ticket.assigned_to : undefined,
assigned_to: !isBadValue(ticket.assigned_to) ? ticket.assigned_to : UNASSIGNED,
resolution_note: !isBadValue(ticket.resolution_note) ? ticket.resolution_note : '',
});
}
}, [ticket, form]);
const handleSave = () => {
form.validateFields().then((values) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateTicket.mutate(
{ id: id!, data: payload },
{ onSuccess: () => navigate('/tickets') }
);
});
};
const onSubmit = (values: TicketFormValues) => {
const payload: Record<string, unknown> = {};
if (values.status) payload.status = values.status;
if (values.assigned_to && values.assigned_to !== UNASSIGNED) payload.assigned_to = values.assigned_to;
if (values.resolution_note?.trim()) payload.resolution_note = values.resolution_note.trim();
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
updateTicket.mutate(
{ id: id!, data: payload },
{ onSuccess: () => navigate('/tickets') }
);
};
const getUserLink = () => {
if (loadingReporter) return <Spin size="small" />;
if (!reporter) return reporterId || '-';
if (loadingReporter) return <Skeleton className="h-4 w-24" />;
if (!reporter) return reporterId || emDash;
const name = reporter.nickname && !isBadValue(reporter.nickname)
? reporter.nickname
: reporter.email;
return <Link to={`/users/${reporter.id}`}>{name || reporter.id}</Link>;
return <Link to={`/users/${reporter.id}`} className="text-primary hover:underline">{name || reporter.id}</Link>;
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!ticket) return <p>Тикет не найден</p>;
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!ticket) return <p className="text-muted-foreground">{t('tickets.notFound')}</p>;
return (
<Card title={`Тикет ${ticket.id}`}>
<Descriptions bordered column={1} size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="ID">{ticket.id}</Descriptions.Item>
<Descriptions.Item label="Отправитель">{getUserLink()}</Descriptions.Item>
<Descriptions.Item label="Хеш ошибки">{ticket.error_hash}</Descriptions.Item>
<Descriptions.Item label="Сообщение">{ticket.error_message}</Descriptions.Item>
<Descriptions.Item label="Стектрейс">
<pre style={{ maxHeight: 200, overflow: 'auto' }}>
{isBadValue(ticket.stacktrace) ? '-' : ticket.stacktrace}
</pre>
</Descriptions.Item>
<Descriptions.Item label="Контекст">
{isBadValue(ticket.context) ? '-' : ticket.context}
</Descriptions.Item>
<Descriptions.Item label="Количество">{ticket.count}</Descriptions.Item>
<Descriptions.Item label="Первый раз">{formatDate(ticket.first_seen)}</Descriptions.Item>
<Descriptions.Item label="Последний раз">{formatDate(ticket.last_seen)}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={
ticket.status === 'open' ? 'red' :
ticket.status === 'in_progress' ? 'blue' :
ticket.status === 'resolved' ? 'green' : 'default'
}>
{ticket.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Назначен">
{loadingAssigned ? <Spin size="small" /> : (
assignedAdmin ? (
<Link to={`/admins/${assignedAdmin.id}`}>
{!isBadValue(assignedAdmin.nickname) ? assignedAdmin.nickname : assignedAdmin.email || assignedAdmin.id}
</Link>
) : (isBadValue(ticket.assigned_to) ? '-' : ticket.assigned_to)
)}
</Descriptions.Item>
<Descriptions.Item label="Решение">
{isBadValue(ticket.resolution_note) ? '-' : ticket.resolution_note}
</Descriptions.Item>
</Descriptions>
<Form form={form} layout="vertical" onFinish={handleSave}>
<Form.Item label="Статус" name="status">
<Select>
<Select.Option value="open">open</Select.Option>
<Select.Option value="in_progress">in_progress</Select.Option>
<Select.Option value="resolved">resolved</Select.Option>
<Select.Option value="closed">closed</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Назначить" name="assigned_to">
<Select
placeholder="Выберите администратора"
loading={loadingAdmins}
allowClear
showSearch
optionFilterProp="label"
>
{(admins?.data || []).map((admin) => {
const label = `${admin.email}${admin.nickname && admin.nickname !== '-' ? ` ${admin.nickname}` : ''}`;
return (
<Select.Option key={admin.id} value={admin.id} label={label}>
{label}
</Select.Option>
);
})}
</Select>
</Form.Item>
<Form.Item label="Комментарий решения" name="resolution_note">
<Input.TextArea rows={3} />
</Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={updateTicket.isPending}>
Сохранить
<>
<PageHeader
title={t('tickets.detailTitle', { id: ticket.id })}
breadcrumbs={[
{ label: t('tickets.title'), href: '/tickets' },
{ label: ticket.id },
]}
actions={
<Button variant="outline" onClick={() => navigate('/tickets')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
<Button onClick={() => navigate('/tickets')}>Назад к списку</Button>
</Space>
</Form>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{ticket.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.sender')}</dt>
<dd>{getUserLink()}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.errorHash')}</dt>
<dd>{ticket.error_hash}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.errorMessage')}</dt>
<dd>{ticket.error_message}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.stacktrace')}</dt>
<dd>
<pre className="max-h-[200px] overflow-auto rounded-md bg-muted p-2 text-xs">
{isBadValue(ticket.stacktrace) ? emDash : ticket.stacktrace}
</pre>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.context')}</dt>
<dd>{formatDisplayValue(ticket.context) === '-' ? emDash : formatDisplayValue(ticket.context)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.count')}</dt>
<dd>{ticket.count}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.firstSeen')}</dt>
<dd>{formatDate(ticket.first_seen)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.lastSeen')}</dt>
<dd>{formatDate(ticket.last_seen)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={ticketStatusVariant(ticket.status)}>{formatStatusLabel(ticket.status)}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.assigned')}</dt>
<dd>
{loadingAssigned ? (
<Skeleton className="h-4 w-24" />
) : assignedAdmin ? (
<Link to={`/admins/${assignedAdmin.id}`} className="text-primary hover:underline">
{!isBadValue(assignedAdmin.nickname) ? assignedAdmin.nickname : assignedAdmin.email || assignedAdmin.id}
</Link>
) : (
isBadValue(ticket.assigned_to) ? emDash : ticket.assigned_to
)}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.resolution')}</dt>
<dd>{isBadValue(ticket.resolution_note) ? emDash : ticket.resolution_note}</dd>
</div>
</dl>
<form className="space-y-4 border-t pt-6" onSubmit={form.handleSubmit(onSubmit)}>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">{formatStatusLabel('open')}</SelectItem>
<SelectItem value="in_progress">{formatStatusLabel('in_progress')}</SelectItem>
<SelectItem value="resolved">{formatStatusLabel('resolved')}</SelectItem>
<SelectItem value="closed">{formatStatusLabel('closed')}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.assign')}</Label>
<Controller
name="assigned_to"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} disabled={loadingAdmins}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectAdmin')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNASSIGNED}>{t('common.unassigned')}</SelectItem>
{(admins?.data || []).map((admin) => {
const label = `${admin.email}${admin.nickname && admin.nickname !== '-' ? ` ${admin.nickname}` : ''}`;
return (
<SelectItem key={admin.id} value={admin.id}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="resolution_note">{t('common.resolutionNote')}</Label>
<Textarea id="resolution_note" rows={3} {...form.register('resolution_note')} />
</div>
<Button type="submit" disabled={updateTicket.isPending}>
{updateTicket.isPending ? t('common.saving') : t('common.save')}
</Button>
</form>
</CardContent>
</Card>
</>
);
};
export default TicketDetailPage;
export default TicketDetailPage;
+210 -144
View File
@@ -1,165 +1,231 @@
import React, { useState } from 'react';
import { Table, Button, Tag, Popconfirm, Tooltip, Spin, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, DeleteOutlined, BugOutlined, ClockCircleOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Info, Trash2 } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { useTickets, useDeleteTicket, useTicketStats } from '../../hooks/useTickets';
import { useAdmin } from '../../hooks/useAdmins';
import { Ticket, TicketListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { formatStatusLabel } from '../../utils/statusLabels';
import { useTickets, useDeleteTicket, useTicketStats } from '@/hooks/useTickets';
import { useExploreView } from '@/hooks/useExploreView';
import { useAdmin } from '@/hooks/useAdmins';
import { Ticket, TicketListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
import { ExploreDataView } from '@/components/explore/ExploreDataView';
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
import type { DenseRowModel } from '@/components/explore/DenseRowList';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
const TABLE_KEY = 'tickets';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const ticketStatusVariant = (status: string) => {
if (status === 'open') return 'destructive' as const;
if (status === 'in_progress') return 'default' as const;
if (status === 'resolved') return 'secondary' as const;
return 'outline' as const;
};
function ticketActions(
handlers: { onOpen: () => void; onDelete: () => void },
t: TFunction
): RowAction[] {
return [
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
{
label: t('common.delete'),
icon: <Trash2 className="h-4 w-4" />,
onClick: handlers.onDelete,
destructive: true,
separatorBefore: true,
},
];
}
const AssignedCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loading) return <Skeleton className="h-4 w-20" />;
if (!admin) return <span>{adminId}</span>;
const name = !isBadValue(admin.nickname) ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{!isBadValue(name) ? name : admin.id}</Link>;
};
const TicketListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<TicketListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'last_seen', order: 'desc' });
const { viewMode, setViewMode } = useExploreView();
const [params, setParams] = useState<TicketListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'last_seen',
order: 'desc',
});
const { data, isLoading } = useTickets(params);
const deleteTicket = useDeleteTicket();
const { data: stats } = useTicketStats();
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; ticketId: string | null }>({
open: false,
ticketId: null,
});
// Резолвер администратора для колонки "Назначен"
const AssignedCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loading) return <Spin size="small" />;
if (!admin) return <span>{adminId}</span>;
const name = !isBadValue(admin.nickname) ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{!isBadValue(name) ? name : admin.id}</Link>;
const handleDelete = (id: string) => setDeleteConfirm({ open: true, ticketId: id });
const confirmDelete = () => {
if (!deleteConfirm.ticketId) return;
deleteTicket.mutate(deleteConfirm.ticketId, {
onSuccess: () => setDeleteConfirm({ open: false, ticketId: null }),
});
};
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<Ticket> | SorterResult<Ticket>[]
) => {
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 exploreStats = useMemo(() => {
if (!stats) return [];
return [
{ label: t('common.total'), value: stats.total_tickets },
{ label: formatStatusLabel('open'), value: stats.open },
{ label: formatStatusLabel('in_progress'), value: stats.in_progress },
{ label: formatStatusLabel('resolved'), value: stats.resolved },
];
}, [stats, t]);
const columns: ColumnsType<Ticket> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу тикета">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/tickets/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Ошибка',
dataIndex: 'error_message',
key: 'error_message',
ellipsis: true,
sorter: true,
render: (text: string) => isBadValue(text) ? '-' : text,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => {
const color =
status === 'open' ? 'red' :
status === 'in_progress' ? 'blue' :
status === 'resolved' ? 'green' : 'default';
return <Tag color={color}>{formatStatusLabel(status)}</Tag>;
const columns = useMemo<ColumnDef<Ticket>[]>(
() => [
{
accessorKey: 'error_message',
header: t('common.error'),
enableSorting: true,
cell: ({ getValue }) => {
const text = getValue<string>();
return isBadValue(text) ? '-' : text;
},
},
},
{
title: 'Назначен',
dataIndex: 'assigned_to',
key: 'assigned_to',
render: (assigned: string) => <AssignedCell adminId={assigned} />,
},
{ title: 'Повторов', dataIndex: 'count', key: 'count', width: 80, sorter: true },
{ title: 'Первый раз', dataIndex: 'first_seen', key: 'first_seen', sorter: true },
{ title: 'Последний', dataIndex: 'last_seen', key: 'last_seen', sorter: true },
{
title: 'Действия',
key: 'actions',
width: 80,
render: (_, record) => (
<Popconfirm
title="Удалить тикет?"
onConfirm={() => deleteTicket.mutate(record.id)}
>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
{
accessorKey: 'status',
header: t('common.status'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={ticketStatusVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{
accessorKey: 'assigned_to',
header: t('common.assigned'),
enableSorting: false,
cell: ({ getValue }) => <AssignedCell adminId={getValue<string | null>()} />,
},
{ accessorKey: 'count', header: t('common.count'), size: 80, enableSorting: true },
{ accessorKey: 'first_seen', header: t('common.firstSeen'), enableSorting: true },
{ accessorKey: 'last_seen', header: t('common.lastSeen'), enableSorting: true },
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={ticketActions({
onOpen: () => navigate(`/tickets/${record.id}`),
onDelete: () => handleDelete(record.id),
}, t)}
/>
</Tooltip>
</Popconfirm>
),
},
];
);
},
},
],
[navigate, t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => {
const errorText = isBadValue(record.error_message) ? record.id : record.error_message;
const title =
typeof errorText === 'string' && errorText.length > 80
? `${errorText.slice(0, 80)}`
: errorText;
return {
id: record.id,
title,
subtitle: record.assigned_to && record.assigned_to !== '-' ? record.assigned_to : undefined,
badges: (
<Badge variant={ticketStatusVariant(record.status)}>
{formatStatusLabel(record.status)}
</Badge>
),
meta: `×${record.count} · ${record.last_seen}`,
onActivate: () => navigate(`/tickets/${record.id}`),
actions: ticketActions({
onOpen: () => navigate(`/tickets/${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_tickets} prefix={<BugOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Открыто" value={stats.open} styles={{ content: { color: 'red' } }} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="В работе" value={stats.in_progress} prefix={<ClockCircleOutlined />} styles={{ content: { color: 'blue' } }} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Решено" value={stats.resolved} prefix={<CheckCircleOutlined />} styles={{ content: { color: 'green' } }} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Закрыто" value={stats.closed} prefix={<CloseCircleOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Всего ошибок" value={stats.total_errors} />
</Card>
</Col>
</Row>
)}
<Table
columns={columns}
dataSource={data?.data}
rowKey="id"
loading={isLoading}
onChange={handleTableChange}
pagination={getTablePagination(params, data?.total)}
/>
</div>
<>
<ExploreListShell
title={t('tickets.title')}
description={
stats
? t('tickets.statsHint', { closed: stats.closed, errors: stats.total_errors })
: t('explore.descActions')
}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
banner={{
text: t('inbox.bannerTickets'),
to: '/inbox/tickets',
actionLabel: t('common.openInbox'),
}}
>
<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={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, ticketId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('tickets.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, ticketId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteTicket.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default TicketListPage;
export default TicketListPage;
+153 -37
View File
@@ -1,56 +1,172 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag } from 'antd';
import { useUser } from '../../hooks/useUsers';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { ArrowLeft } from 'lucide-react';
import { useUser } from '@/hooks/useUsers';
import { formatDisplayValue } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { PageHeader } from '@/components/PageHeader';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const roleBadgeVariant = (role: string) => {
if (role === 'user') return 'default' as const;
if (role === 'bot') return 'secondary' as const;
return 'outline' as const;
};
const statusBadgeVariant = (status: string) => {
if (status === 'active') return 'default' as const;
if (status === 'frozen') return 'warning' as const;
return 'destructive' as const;
};
const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => {
const text = formatDisplayValue(value);
if (text === '-') return <>{emptyLabel}</>;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return <pre className="overflow-auto rounded-md bg-muted p-2 text-xs">{text}</pre>;
}
return <>{text}</>;
};
const UserDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: user, isLoading } = useUser(id || '');
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: any) => (isBadValue(val) ? '-' : val);
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
if (isBadValue(dateStr)) return t('common.emDash');
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!user) return <p>Пользователь не найден</p>;
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!user) return <p className="text-muted-foreground">{t('users.notFound')}</p>;
const titleName = !isBadValue(user.nickname) ? user.nickname : user.email || user.id;
const emDash = t('common.emDash');
return (
<Card title={`Пользователь ${displayValue(user.nickname) !== '-' ? user.nickname : user.email || user.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{user.id}</Descriptions.Item>
<Descriptions.Item label="Email">{displayValue(user.email)}</Descriptions.Item>
<Descriptions.Item label="Ник">{displayValue(user.nickname)}</Descriptions.Item>
<Descriptions.Item label="Роль">
<Tag color={user.role === 'user' ? 'blue' : 'purple'}>{user.role}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={user.status === 'active' ? 'green' : user.status === 'frozen' ? 'orange' : 'red'}>
{user.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(user.reason)}</Descriptions.Item>
<Descriptions.Item label="Телефон">{displayValue(user.phone)}</Descriptions.Item>
<Descriptions.Item label="Язык">{displayValue(user.language)}</Descriptions.Item>
<Descriptions.Item label="Часовой пояс">{displayValue(user.timezone)}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">{displayValue(user.avatar_url)}</Descriptions.Item>
<Descriptions.Item label="Социальные ссылки">{displayValue(user.social_links)}</Descriptions.Item>
<Descriptions.Item label="Настройки">{displayValue(user.preferences)}</Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(user.last_login)}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(user.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(user.updated_at)}</Descriptions.Item>
</Descriptions>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/users')}>Назад к списку</Button>
</Card>
<div>
<PageHeader
title={String(titleName)}
description={`ID ${user.id}`}
breadcrumbs={[
{ label: t('users.title'), href: '/users' },
{ label: String(titleName) },
]}
actions={
<Button variant="outline" onClick={() => navigate('/users')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
}
/>
<Card>
<CardContent className="space-y-6 pt-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{user.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.email')}</dt>
<dd>
<DetailValue value={user.email} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.nickname')}</dt>
<dd>
<DetailValue value={user.nickname} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.role')}</dt>
<dd>
<Badge variant={roleBadgeVariant(user.role)}>{user.role}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={statusBadgeVariant(user.status)}>{user.status}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.reason')}</dt>
<dd>
<DetailValue value={user.reason} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.phone')}</dt>
<dd>
<DetailValue value={user.phone} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.language')}</dt>
<dd>
<DetailValue value={user.language} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.timezone')}</dt>
<dd>
<DetailValue value={user.timezone} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.avatarUrl')}</dt>
<dd>
<DetailValue value={user.avatar_url} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.socialLinks')}</dt>
<dd>
<DetailValue value={user.social_links} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.preferencesJson')}</dt>
<dd>
<DetailValue value={user.preferences} emptyLabel={emDash} />
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.lastLogin')}</dt>
<dd>{formatDate(user.last_login)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{formatDate(user.created_at)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.updatedAt')}</dt>
<dd>{formatDate(user.updated_at)}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
);
};
export default UserDetailPage;
export default UserDetailPage;
+426 -313
View File
@@ -1,380 +1,493 @@
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 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, Lock, Unlock, Trash2 } from 'lucide-react';
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';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { formatStatusLabel } from '../../utils/statusLabels';
import { useForm, Controller } from 'react-hook-form';
import { useUsers, useUpdateUser, useDeleteUser, useUser, useUserStats } from '@/hooks/useUsers';
import { useExploreView } from '@/hooks/useExploreView';
import { User, UserListParams } 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 { 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 = 'users';
const roleBadgeClass = (role: string) => {
if (role === 'user') return 'default';
if (role === 'bot') return 'secondary';
return 'outline';
};
const statusBadgeVariant = (status: string) => {
if (status === 'active') return 'default' as const;
if (status === 'frozen') return 'secondary' as const;
return 'destructive' as const;
};
interface EditFormValues {
email?: string;
nickname?: string;
role?: string;
status?: string;
reason?: string;
}
interface StatusFormValues {
reason?: string;
}
function userActions(
record: User,
handlers: {
onOpen: () => void;
onEdit: () => void;
onStatus: () => void;
onDelete: () => void;
},
t: TFunction
): RowAction[] {
const isDeleted = record.status === 'deleted';
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: record.status === 'active' ? t('common.freeze') : t('common.unfreeze'),
icon: record.status === 'active' ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />,
onClick: handlers.onStatus,
disabled: isDeleted,
},
{
label: t('common.delete'),
icon: <Trash2 className="h-4 w-4" />,
onClick: handlers.onDelete,
disabled: isDeleted,
destructive: true,
separatorBefore: true,
},
];
}
const UserListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useState<UserListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'id', order: 'asc' });
const { viewMode, setViewMode } = useExploreView();
const [preview, setPreview] = useState<ExplorePreviewTarget>(null);
const [searchDraft, setSearchDraft] = useState('');
const [params, setParams] = useState<UserListParams>({
limit: getSavedPageSize(TABLE_KEY),
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 editForm = useForm<EditFormValues>();
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 = useForm<StatusFormValues>();
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; userId: string | null }>({
open: false,
userId: null,
newStatus: 'active',
currentReason: '',
});
const [statusForm] = Form.useForm();
// ==================== Редактирование ====================
const handleEdit = (id: string) => {
setEditModal({ open: true, userId: id });
};
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 handleSaveEdit = editForm.handleSubmit((values) => {
if (!editModal.userId || !originalUserRef.current) return;
const original = originalUserRef.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 any)[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 any)[key];
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
})
);
updateUser.mutate(
{ id: editModal.userId, data: payload },
{ onSuccess: () => setEditModal({ open: false, userId: 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);
})
);
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);
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
editForm.reset({
email: clean(editingUser.email) as string,
nickname: clean(editingUser.nickname) as string,
role: clean(editingUser.role) as string,
status: clean(editingUser.status) as string,
reason: clean(editingUser.reason) as string,
});
}
}, [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,
});
setStatusModal({ open: true, userId: user.id, newStatus, currentReason });
};
useEffect(() => {
if (statusModal.open) {
setTimeout(() => {
statusForm.setFieldsValue({ reason: statusModal.currentReason });
}, 0);
statusForm.reset({ reason: statusModal.currentReason });
}
}, [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 handleStatusSave = statusForm.handleSubmit((values) => {
if (!statusModal.userId) return;
const payload: Record<string, string> = { status: statusModal.newStatus };
if (values.reason?.trim()) payload.reason = values.reason.trim();
updateUser.mutate(
{ id: statusModal.userId, data: payload },
{ onSuccess: () => setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' }) }
);
});
const handleDelete = (id: string) => setDeleteConfirm({ open: true, userId: id });
const confirmDelete = () => {
if (!deleteConfirm.userId) return;
deleteUser.mutate(deleteConfirm.userId, {
onSuccess: () => setDeleteConfirm({ open: false, userId: null }),
});
};
// ==================== Удаление ====================
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить пользователя?',
content: 'Это действие нельзя отменить. При необходимости предварительно укажите причину через редактирование.',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteUser.mutate(id),
});
};
const editRole = editForm.watch('role');
const editStatus = editForm.watch('status');
const editHasErrors = !editRole || !editStatus;
// ==================== Таблица ====================
const handleTableChange = (
pagination: any,
_filters: any,
sorter: SorterResult<User> | SorterResult<User>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => mergeTablePagination({
const exploreStats = useMemo(() => {
if (!stats) return [];
const items = [
{ label: t('common.total'), value: stats.total_users },
{ label: t('common.pendingUsers'), value: stats.pending_users },
];
Object.entries(stats.users_by_status || {})
.slice(0, 2)
.forEach(([status, count]) => {
items.push({ label: formatStatusLabel(status), value: count });
});
return items.slice(0, 4);
}, [stats, t]);
const applySearch = () => {
setParams((prev) => ({
...prev,
sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
}, pagination, TABLE_KEY));
q: searchDraft.trim() || undefined,
offset: 0,
}));
};
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>;
const columns = useMemo<ColumnDef<User>[]>(
() => [
{
accessorKey: 'nickname',
header: t('common.user'),
enableSorting: true,
cell: ({ row, getValue }) => {
const nick = getValue<string | null>();
const label = nick || row.original.email || row.original.id;
return (
<button
type="button"
className="text-left font-medium text-primary hover:underline"
onClick={() => setPreview({ type: 'user', id: row.original.id })}
>
{label}
</button>
);
},
},
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : status === 'frozen' ? 'orange' : 'red'}>
{formatStatusLabel(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>
);
{ accessorKey: 'email', header: t('common.email'), enableSorting: true },
{
accessorKey: 'role',
header: t('common.role'),
enableSorting: true,
cell: ({ getValue }) => {
const role = getValue<string>();
return <Badge variant={roleBadgeClass(role) as 'default'}>{role}</Badge>;
},
},
},
];
{
accessorKey: 'status',
header: t('common.status'),
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={statusBadgeVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{ accessorKey: 'last_login', header: t('common.lastLogin'), enableSorting: true },
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={userActions(record, {
onOpen: () => navigate(`/users/${record.id}`),
onEdit: () => handleEdit(record.id),
onStatus: () => handleStatusChange(record),
onDelete: () => handleDelete(record.id),
}, t)}
/>
);
},
},
],
[navigate, t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => {
const title = record.nickname || record.email || record.id;
return {
id: record.id,
title,
subtitle: record.email && record.nickname ? record.email : undefined,
badges: (
<>
<Badge variant={roleBadgeClass(record.role) as 'default'}>{record.role}</Badge>
<Badge variant={statusBadgeVariant(record.status)}>{formatStatusLabel(record.status)}</Badge>
</>
),
meta: record.last_login ? t('explore.loginAt', { date: record.last_login }) : undefined,
onActivate: () => setPreview({ type: 'user', id: record.id }),
actions: userActions(record, {
onOpen: () => navigate(`/users/${record.id}`),
onEdit: () => handleEdit(record.id),
onStatus: () => handleStatusChange(record),
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_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>
))}
{Object.entries(stats.users_by_role || {}).map(([role, count]) => (
<Col xs={12} sm={6} md={4} key={`role-${role}`}>
<Card>
<Statistic title={`Роль: ${role}`} value={count} />
</Card>
</Col>
))}
</Row>
)}
<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, userId: null })}
onOk={handleSaveEdit}
confirmLoading={updateUser.isPending}
destroyOnHidden
okButtonProps={{ disabled: editHasErrors }}
<>
<ExploreListShell
title={t('users.title')}
description={t('explore.descPreviewName')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
toolbar={
<ExploreSearch
value={searchDraft}
onChange={setSearchDraft}
onSubmit={applySearch}
placeholder={t('explore.searchUsers')}
/>
}
>
{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>
<ExploreDataView
viewMode={viewMode}
columns={columns}
data={data?.data ?? []}
denseRows={denseRows}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</ExploreListShell>
{/* Модальное окно изменения статуса */}
<Modal
title={`Изменить статус на «${statusModal.newStatus}»`}
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, userId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('users.editTitle')}</DialogTitle>
</DialogHeader>
{loadingUser ? (
<div className="space-y-3 py-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : (
<form id="edit-user-form" className="space-y-4" onSubmit={handleSaveEdit}>
<div className="space-y-2">
<Label>{t('common.email')}</Label>
<Input {...editForm.register('email')} />
</div>
<div className="space-y-2">
<Label>{t('common.nickname')}</Label>
<Input {...editForm.register('nickname')} />
</div>
<div className="space-y-2">
<Label>{t('common.role')}</Label>
<Controller
name="role"
control={editForm.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectRole')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">user</SelectItem>
<SelectItem value="bot">bot</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.reason')}</Label>
<Textarea rows={3} {...editForm.register('reason')} />
</div>
</form>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal({ open: false, userId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-user-form" disabled={editHasErrors || updateUser.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={statusModal.open}
onCancel={() => setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' })}
onOk={handleStatusSave}
confirmLoading={updateUser.isPending}
destroyOnHidden
onOpenChange={(open) =>
!open && setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' })
}
>
<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>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('users.statusTitle', { status: statusModal.newStatus })}</DialogTitle>
</DialogHeader>
<form id="status-form" className="space-y-4" onSubmit={handleStatusSave}>
<div className="space-y-2">
<Label>{t('common.reason')}</Label>
<Textarea
rows={3}
placeholder={t('common.enterReason')}
{...statusForm.register('reason', { required: statusModal.newStatus !== 'active' })}
/>
</div>
</form>
<DialogFooter>
<Button
variant="outline"
onClick={() =>
setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' })
}
>
{t('common.cancel')}
</Button>
<Button type="submit" form="status-form" disabled={updateUser.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, userId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('users.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('users.deleteHint')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, userId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteUser.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<EntitySlideOver target={preview} onOpenChange={(open) => !open && setPreview(null)} />
</>
);
};
export default UserListPage;
export default UserListPage;