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

Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
2026-07-14 17:58:33 +03:00
parent de379e28bf
commit fd2c1f10ad
95 changed files with 12699 additions and 4410 deletions
+283 -135
View File
@@ -1,160 +1,308 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Modal, Form, Input, Select, message } from 'antd';
import { EditOutlined } from '@ant-design/icons';
import { useAdmin, useUpdateAdmin } from '../../hooks/useAdmins';
import { useTranslation } from 'react-i18next';
import { useForm, Controller } from 'react-hook-form';
import dayjs from 'dayjs';
import { ArrowLeft, Pencil } from 'lucide-react';
import { useAdmin, useUpdateAdmin } from '@/hooks/useAdmins';
import { formatDisplayValue } from '@/lib/utils';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? undefined : val);
const roleBadgeVariant = (role: string) => {
if (role === 'superadmin') return 'destructive' as const;
if (role === 'admin') return 'default' as const;
if (role === 'moderator') return 'secondary' as const;
return 'outline' as const;
};
interface AdminFormValues {
nickname?: string;
email?: string;
role?: string;
status?: string;
timezone?: string;
language?: string;
phone?: string;
}
const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => {
const text = formatDisplayValue(value);
if (text === '-') return <>{emptyLabel}</>;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return <pre className="overflow-auto rounded-md bg-muted p-2 text-xs">{text}</pre>;
}
return <>{text}</>;
};
const AdminDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: admin, isLoading } = useAdmin(id || '');
const updateAdmin = useUpdateAdmin();
const [editModal, setEditModal] = useState(false);
const [form] = Form.useForm();
// Очистка значения
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
// Заполнение формы при открытии
useEffect(() => {
if (editModal && admin) {
setTimeout(() => {
form.setFieldsValue({
nickname: clean(admin.nickname),
email: clean(admin.email),
role: clean(admin.role),
status: clean(admin.status),
timezone: clean(admin.timezone),
language: clean(admin.language),
phone: clean(admin.phone),
});
}, 0);
}
}, [editModal, admin, form]);
const handleSave = () => {
form.validateFields().then((values) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: id!, data: payload },
{
onSuccess: () => {
setEditModal(false);
message.success('Данные обновлены');
},
}
);
});
};
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const form = useForm<AdminFormValues>();
const emDash = t('common.emDash');
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
if (isBadValue(dateStr)) return emDash;
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
const roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
useEffect(() => {
if (editModal && admin) {
form.reset({
nickname: clean(admin.nickname) as string | undefined,
email: clean(admin.email) as string | undefined,
role: clean(admin.role) as string | undefined,
status: clean(admin.status) as string | undefined,
timezone: clean(admin.timezone) as string | undefined,
language: clean(admin.language) as string | undefined,
phone: clean(admin.phone) as string | undefined,
});
}
}, [editModal, admin, form]);
const onSave = (values: AdminFormValues) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: id!, data: payload },
{
onSuccess: () => {
setEditModal(false);
},
}
);
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!admin) return <p>Администратор не найден</p>;
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!admin) return <p className="text-muted-foreground">{t('admins.notFound')}</p>;
const titleName = !isBadValue(admin.nickname) ? admin.nickname : admin.email || admin.id;
return (
<Card
title={`Администратор ${!isBadValue(admin.nickname) ? admin.nickname : admin.email || admin.id}`}
extra={
<Button icon={<EditOutlined />} onClick={() => setEditModal(true)}>
Редактировать
</Button>
}
>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="ID">{admin.id}</Descriptions.Item>
<Descriptions.Item label="Email">{admin.email}</Descriptions.Item>
<Descriptions.Item label="Ник">{isBadValue(admin.nickname) ? '-' : admin.nickname}</Descriptions.Item>
<Descriptions.Item label="Роль">
<Tag color={roleColors[admin.role] || 'default'}>{admin.role}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={admin.status === 'active' ? 'green' : 'red'}>{admin.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Часовой пояс">{isBadValue(admin.timezone) ? '-' : admin.timezone}</Descriptions.Item>
<Descriptions.Item label="Язык">{isBadValue(admin.language) ? '-' : admin.language}</Descriptions.Item>
<Descriptions.Item label="Телефон">{isBadValue(admin.phone) ? '-' : admin.phone}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">{isBadValue(admin.avatar_url) ? '-' : admin.avatar_url}</Descriptions.Item>
<Descriptions.Item label="Настройки">{isBadValue(admin.preferences) ? '-' : JSON.stringify(admin.preferences)}</Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(admin.last_login)}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(admin.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(admin.updated_at)}</Descriptions.Item>
</Descriptions>
<>
<PageHeader
title={t('admins.detailTitle', { name: titleName })}
breadcrumbs={[
{ label: t('admins.title'), href: '/admins' },
{ label: String(titleName) },
]}
actions={
<>
<Button variant="outline" size="sm" onClick={() => setEditModal(true)}>
<Pencil className="h-4 w-4" />
{t('common.edit')}
</Button>
<Button variant="outline" onClick={() => navigate('/admins')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
</>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{admin.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.email')}</dt>
<dd>{admin.email}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.nickname')}</dt>
<dd>{isBadValue(admin.nickname) ? emDash : admin.nickname}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.role')}</dt>
<dd>
<Badge variant={roleBadgeVariant(admin.role)}>{admin.role}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={admin.status === 'active' ? 'success' : 'destructive'}>{admin.status}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.timezone')}</dt>
<dd>{isBadValue(admin.timezone) ? emDash : admin.timezone}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.language')}</dt>
<dd>{isBadValue(admin.language) ? emDash : admin.language}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.phone')}</dt>
<dd>{isBadValue(admin.phone) ? emDash : admin.phone}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.avatarUrl')}</dt>
<dd>{isBadValue(admin.avatar_url) ? emDash : admin.avatar_url}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('profile.preferencesJson')}</dt>
<dd><DetailValue value={admin.preferences} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.lastLogin')}</dt>
<dd>{formatDate(admin.last_login)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{formatDate(admin.created_at)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.updatedAt')}</dt>
<dd>{formatDate(admin.updated_at)}</dd>
</div>
</dl>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/admins')}>Назад к списку</Button>
</CardContent>
</Card>
<Modal
title="Редактировать администратора"
open={editModal}
onCancel={() => setEditModal(false)}
onOk={handleSave}
confirmLoading={updateAdmin.isPending}
destroyOnHidden
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item name="nickname" label="Ник">
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
<Form.Item name="role" label="Роль">
<Select>
<Select.Option value="superadmin">superadmin</Select.Option>
<Select.Option value="admin">admin</Select.Option>
<Select.Option value="moderator">moderator</Select.Option>
<Select.Option value="support">support</Select.Option>
</Select>
</Form.Item>
<Form.Item name="status" label="Статус">
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="blocked">blocked</Select.Option>
</Select>
</Form.Item>
<Form.Item name="timezone" label="Часовой пояс">
<Select placeholder="Выберите пояс" allowClear>
<Select.Option value="UTC">UTC</Select.Option>
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="language" label="Язык">
<Select placeholder="Выберите язык" allowClear>
<Select.Option value="ru">Русский</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
<Form.Item name="phone" label="Телефон">
<Input placeholder="+7 (999) 123-45-67" />
</Form.Item>
</Form>
</Modal>
</Card>
<Dialog open={editModal} onOpenChange={setEditModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admins.editTitle')}</DialogTitle>
</DialogHeader>
<form id="admin-edit-form" className="space-y-4" onSubmit={form.handleSubmit(onSave)}>
<div className="space-y-2">
<Label htmlFor="nickname">{t('common.nickname')}</Label>
<Input id="nickname" {...form.register('nickname')} />
</div>
<div className="space-y-2">
<Label htmlFor="email">{t('common.email')}</Label>
<Input id="email" {...form.register('email')} />
</div>
<div className="space-y-2">
<Label>{t('common.role')}</Label>
<Controller
name="role"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="superadmin">superadmin</SelectItem>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="moderator">moderator</SelectItem>
<SelectItem value="support">support</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.status')}</Label>
<Controller
name="status"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active">active</SelectItem>
<SelectItem value="blocked">blocked</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.timezone')}</Label>
<Controller
name="timezone"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue placeholder={t('common.selectTimezone')} /></SelectTrigger>
<SelectContent>
<SelectItem value="UTC">UTC</SelectItem>
<SelectItem value="Europe/Moscow">Europe/Moscow (Москва)</SelectItem>
<SelectItem value="Europe/London">Europe/London (Лондон)</SelectItem>
<SelectItem value="Europe/Berlin">Europe/Berlin (Берлин)</SelectItem>
<SelectItem value="America/New_York">America/New_York (Нью-Йорк)</SelectItem>
<SelectItem value="Asia/Tokyo">Asia/Tokyo (Токио)</SelectItem>
<SelectItem value="Asia/Shanghai">Asia/Shanghai (Шанхай)</SelectItem>
<SelectItem value="Australia/Sydney">Australia/Sydney (Сидней)</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>{t('common.language')}</Label>
<Controller
name="language"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue placeholder={t('common.selectLanguage')} /></SelectTrigger>
<SelectContent>
<SelectItem value="ru">{t('common.langRu')}</SelectItem>
<SelectItem value="en">{t('common.langEn')}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t('common.phone')}</Label>
<Input id="phone" placeholder="+7 (999) 123-45-67" {...form.register('phone')} />
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setEditModal(false)}>{t('common.cancel')}</Button>
<Button type="submit" form="admin-edit-form" disabled={updateAdmin.isPending}>
{updateAdmin.isPending ? t('common.saving') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default AdminDetailPage;
export default AdminDetailPage;