feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user