-
}
- src={!isBadValue(user.avatar_url) ? user.avatar_url! : undefined}
- />
-
-
{!isBadValue(user.nickname) ? user.nickname : user.email}
-
- {user.role}
-
+
+
+ {t('profile.title')}
+ {t('profile.description')}
+
+
+ {!editing ? (
+ <>
+
+
+
+ {displayName.slice(0, 2).toUpperCase()}
+
+
+
{displayName}
+
+ {user.role}
+
+
-
-
- {user.email}
- {isBadValue(user.nickname) ? '-' : user.nickname}
-
-
- {user.role}
-
-
-
- {user.status}
-
- {isBadValue(user.timezone) ? '-' : user.timezone}
- {isBadValue(user.language) ? '-' : user.language}
- {isBadValue(user.phone) ? '-' : user.phone}
-
- {isBadValue(user.avatar_url) ? '-' : {user.avatar_url}}
-
-
- {isBadValue(user.preferences) ? '-' : {JSON.stringify(user.preferences, null, 2)}}
-
- {formatDate(user.last_login)}
-
-
- >
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{
- if (value && value.trim().length > 0) {
- try {
- JSON.parse(value);
- } catch {
- return Promise.reject(new Error('Невалидный JSON'));
- }
- }
- return Promise.resolve();
- },
- },
- ]}
- >
-
-
-
-
-
- )}
+ >
+ ) : (
+
+ )}
+
);
};
-export default ProfilePage;
\ No newline at end of file
+export default ProfilePage;
diff --git a/src/pages/reports/ReportDetailPage.tsx b/src/pages/reports/ReportDetailPage.tsx
index a44250c..afe3ca1 100644
--- a/src/pages/reports/ReportDetailPage.tsx
+++ b/src/pages/reports/ReportDetailPage.tsx
@@ -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
;
+ 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
{name};
+ return
{name};
};
- 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
;
- if (!report) return
Жалоба не найдена
;
+ if (reportLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (!report) return
{t('reports.notFound')}
;
+
+ const statusLabel = confirmStatus === 'reviewed'
+ ? t('common.reviewed')
+ : t('common.dismissed');
return (
-
-
- {report.id}
-
- {loadingReporter && isValidId(reporterId) ? : (
- reporter ? getLink(reporter, 'user') : displayId(reporterId)
+ <>
+ navigate('/reports')}>
+
+ {t('common.backToList')}
+
+ }
+ />
+
+
+
+
+
- {t('common.id')}
+ - {report.id}
+
+
+
- {t('common.sender')}
+ -
+ {loadingReporter && isValidId(reporterId) ? (
+
+ ) : reporter ? (
+ getLink(reporter, 'user')
+ ) : (
+ displayId(reporterId, emDash)
+ )}
+
+
+
+
- {t('common.targetType')}
+ - {report.target_type}
+
+
+
- {t('common.target')}
+ -
+ {targetType === 'event' && loadingEvent && isValidId(targetId) ? (
+
+ ) : targetType === 'event' && targetEvent ? (
+ getLink(targetEvent, 'event')
+ ) : (
+ displayId(targetId, emDash)
+ )}
+
+
+
+
- {t('common.reason')}
+ - {report.reason}
+
+
+
- {t('common.status')}
+ -
+
+ {formatStatusLabel(report.status)}
+
+
+
+
+
- {t('common.createdAt')}
+ - {report.created_at}
+
+
+
- {t('reports.resolvedAt')}
+ - {report.resolved_at || emDash}
+
+
+
- {t('reports.resolvedBy')}
+ -
+ {loadingResolver && isValidId(resolvedById) ? (
+
+ ) : resolvedAdmin ? (
+ getLink(resolvedAdmin, 'admin')
+ ) : (
+ displayId(resolvedById, emDash)
+ )}
+
+
+
+
+ {report.status === 'pending' && (
+
+ setConfirmStatus('reviewed')} disabled={updateReport.isPending}>
+ {t('common.reviewed')}
+
+ setConfirmStatus('dismissed')} disabled={updateReport.isPending}>
+ {t('common.dismissed')}
+
+
)}
-
- {report.target_type}
-
- {targetType === 'event' && loadingEvent && isValidId(targetId) ? : (
- targetType === 'event' && targetEvent ? getLink(targetEvent, 'event') : displayId(targetId)
- )}
-
- {report.reason}
-
-
- {report.status}
-
-
- {report.created_at}
- {report.resolved_at || '-'}
-
- {loadingResolver && isValidId(resolvedById) ? : (
- resolvedAdmin ? getLink(resolvedAdmin, 'admin') : displayId(resolvedById)
- )}
-
-
- {report.status === 'pending' && (
-
- handleStatusChange('reviewed')} loading={updateReport.isPending}>Рассмотрено
- handleStatusChange('dismissed')} loading={updateReport.isPending}>Отклонено
-
- )}
- navigate('/reports')}>Назад к списку
-
+
+
+
+
+
+ >
);
};
-export default ReportDetailPage;
\ No newline at end of file
+export default ReportDetailPage;
diff --git a/src/pages/reports/ReportListPage.tsx b/src/pages/reports/ReportListPage.tsx
index 6f3afd9..d0732a9 100644
--- a/src/pages/reports/ReportListPage.tsx
+++ b/src/pages/reports/ReportListPage.tsx
@@ -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:
, onClick: handlers.onOpen },
+ { label: t('common.quickView'), icon:
, onClick: handlers.onQuickView },
+ ];
+}
+
+const ReporterCell: React.FC<{ reporterId: string }> = ({ reporterId }) => {
+ const { data: user, isLoading: loading } = useUser(reporterId);
+ if (loading) return
;
+ if (!user) return
{reporterId};
+
+ 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
{name};
+};
+
+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
;
+ if (event) {
+ const name = event.title || event.id;
+ return
{name};
+ }
+ return
{targetId};
+ }
+ return
{targetId};
+};
+
+function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+ <>
+
{label}
+
{children}
+ >
+ );
+}
+
const ReportListPage: React.FC = () => {
+ const { t } = useTranslation();
const navigate = useNavigate();
- const [params, setParams] = useState
({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'created_at', order: 'desc' });
+ const { viewMode, setViewMode } = useExploreView();
+ const [params, setParams] = useState({
+ 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 | SorterResult[]
- ) => {
- 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 = {
- event: 'blue',
- calendar: 'green',
- review: 'purple',
- };
-
- // Резолвер отправителя
- const ReporterCell: React.FC<{ reporterId: string }> = ({ reporterId }) => {
- const { data: user, isLoading: loading } = useUser(reporterId);
- if (loading) return ;
- if (!user) return {reporterId};
-
- 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 {name};
- };
-
- // Резолвер цели
- 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 ;
- if (event) {
- const name = event.title || event.id;
- return {name};
- }
- return {targetId};
- }
- return {targetId};
- };
-
- const columns: ColumnsType = [
- {
- title: ,
- key: 'detail',
- width: 48,
- align: 'center',
- render: (_, record) => (
-
- }
- size="small"
- type="link"
- onClick={() => navigate(`/reports/${record.id}`)}
- />
-
- ),
- },
- {
- title: 'Отправитель',
- key: 'reporter',
- ellipsis: true,
- render: (_, record) => ,
- },
- {
- title: 'Тип',
- dataIndex: 'target_type',
- key: 'target_type',
- width: 100,
- sorter: true,
- render: (type: string) => (
- {type}
- ),
- },
- {
- title: 'Цель',
- key: 'target',
- ellipsis: true,
- render: (_, record) => ,
- },
- {
- title: 'Причина',
- dataIndex: 'reason',
- key: 'reason',
- ellipsis: true,
- },
- {
- title: 'Статус',
- dataIndex: 'status',
- key: 'status',
- width: 100,
- sorter: true,
- render: (status: string) => (
-
- {formatStatusLabel(status)}
-
- ),
- },
- {
- title: 'Создано',
- dataIndex: 'created_at',
- key: 'created_at',
- width: 120,
- sorter: true,
- },
- {
- title: ,
- key: 'view',
- width: 48,
- align: 'center',
- render: (_, record) => (
-
- }
- size="small"
- onClick={() => handleViewDetails(record)}
- />
-
- ),
- },
- ];
-
- const isBadValue = (val: any) =>
- val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
-
- const renderLink = (entity: any, type: 'user' | 'admin' | 'event') => {
- if (!entity) return ;
+ 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[]>(
+ () => [
+ {
+ id: 'reporter',
+ header: t('common.sender'),
+ enableSorting: false,
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: 'target_type',
+ header: t('common.type'),
+ size: 100,
+ enableSorting: true,
+ cell: ({ getValue }) => {
+ const type = getValue();
+ return {type};
+ },
+ },
+ {
+ id: 'target',
+ header: t('common.target'),
+ enableSorting: false,
+ cell: ({ row }) => (
+
+ ),
+ },
+ { accessorKey: 'reason', header: t('common.reason'), enableSorting: true },
+ {
+ accessorKey: 'status',
+ header: t('common.status'),
+ size: 100,
+ enableSorting: true,
+ cell: ({ getValue }) => {
+ const status = getValue();
+ return {formatStatusLabel(status)};
+ },
+ },
+ { 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 (
+ navigate(`/reports/${record.id}`),
+ onQuickView: () => handleViewDetails(record),
+ }, t)}
+ />
+ );
+ },
+ },
+ ],
+ [navigate, t]
+ );
+
+ const denseRows = useMemo(() => {
+ return (data?.data ?? []).map((record) => ({
+ id: record.id,
+ title: record.reason,
+ subtitle: `${record.target_type} · ${record.target_id}`,
+ badges: (
+ <>
+ {record.target_type}
+
+ {formatStatusLabel(record.status)}
+
+ >
+ ),
+ meta: record.created_at,
+ actions: reportActions({
+ onOpen: () => navigate(`/reports/${record.id}`),
+ onQuickView: () => handleViewDetails(record),
+ }, t),
+ }));
+ }, [data?.data, navigate, t]);
+
return (
-
-
Жалобы
- {stats && (
-
-
-
- } />
-
-
- {Object.entries(stats.reports_by_status || {}).map(([status, count]) => (
-
-
-
-
-
- ))}
- {Object.entries(stats.reports_by_target_type || {}).map(([type, count]) => (
-
-
-
-
-
- ))}
-
- )}
- {stats?.top_targets_by_reports && stats.top_targets_by_reports.length > 0 && (
-
- `${r.target_type}-${r.target_id}`}
- dataSource={stats.top_targets_by_reports}
- columns={[
- { title: 'Тип', dataIndex: 'target_type', key: 'target_type' },
+ <>
+
+ {stats?.top_targets_by_reports && stats.top_targets_by_reports.length > 0 && (
+ (
- {id}
- ),
+ id: 'reports',
+ label: t('explore.byReports'),
+ rows: stats.top_targets_by_reports.map((row) => ({
+ id: `${row.target_type}-${row.target_id}`,
+ primary: (
+ {row.target_id}
+ ),
+ secondary: row.target_type,
+ value: row.report_count,
+ })),
},
- { title: 'Жалоб', dataIndex: 'report_count', key: 'report_count' },
]}
/>
-
- )}
-
-
- setDetailModal({ open: false, report: null })}
- footer={null}
- width={600}
- >
- {detailModal.report && (
- <>
-
- {detailModal.report.id}
-
- {loadingReporter && isValidId(reporterId) ? : (
- reporter ? renderLink(reporter, 'user') : displayId(reporterId)
- )}
-
- {detailModal.report.target_type}
-
- {targetType === 'event' && loadingEvent && isValidId(targetId) ? : (
- targetType === 'event' && targetEvent ? renderLink(targetEvent, 'event') : displayId(targetId)
- )}
-
- {detailModal.report.reason}
-
-
- {detailModal.report.status}
-
-
- {detailModal.report.created_at}
- {detailModal.report.resolved_at || '-'}
-
- {loadingResolver && isValidId(resolvedById) ? : (
- resolvedAdmin ? renderLink(resolvedAdmin, 'admin') : displayId(resolvedById)
- )}
-
-
- {detailModal.report.status === 'pending' && (
-
- {
- updateReport.mutate(
- { id: detailModal.report!.id, data: { status: 'reviewed' } },
- { onSuccess: () => setDetailModal({ open: false, report: null }) }
- );
- }}
- loading={updateReport.isPending}
- >
- Рассмотрено
-
- {
- updateReport.mutate(
- { id: detailModal.report!.id, data: { status: 'dismissed' } },
- { onSuccess: () => setDetailModal({ open: false, report: null }) }
- );
- }}
- loading={updateReport.isPending}
- >
- Отклонено
-
-
- )}
- >
)}
-
-
+
+
+
+
+
+ >
);
};
-export default ReportListPage;
\ No newline at end of file
+export default ReportListPage;
diff --git a/src/pages/reviews/ReviewDetailPage.tsx b/src/pages/reviews/ReviewDetailPage.tsx
index c39a188..a50ab86 100644
--- a/src/pages/reviews/ReviewDetailPage.tsx
+++ b/src/pages/reviews/ReviewDetailPage.tsx
@@ -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({ 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 ;
- if (!user) return displayValue(userId);
+ if (loadingUser) return ;
+ if (!user) return formatDisplayValue(userId) === '-' ? emDash : String(userId);
const name = !isBadValue(user.nickname) ? user.nickname : user.email || user.id;
- return {name};
+ return {name};
};
const getTargetLink = () => {
if (targetType === 'event') {
- if (loadingEvent) return ;
+ if (loadingEvent) return ;
if (targetEvent) {
const name = targetEvent.title || targetEvent.id;
- return {name};
+ return {name};
}
- 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 (
+
+
+
+
+ );
+ }
- 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 {t('reviews.notFound')}
;
- if (reviewLoading) return ;
- if (!review) return Отзыв не найден
;
+ const reasonRequired = statusModal.newStatus !== 'visible';
return (
-
-
- {review.id}
- {getUserLink()}
-
- {review.target_type}
-
- {getTargetLink()}
- {'⭐'.repeat(review.rating)} ({review.rating})
- {displayValue(review.comment)}
-
-
- {review.status}
-
-
- {displayValue(review.reason)}
- {isBadValue(review.likes) ? 0 : review.likes}
- {isBadValue(review.dislikes) ? 0 : review.dislikes}
- {formatDate(review.created_at)}
- {formatDate(review.updated_at)}
-
-
- openStatusModal('visible')}>Показать
- openStatusModal('hidden')}>Скрыть
- openStatusModal('deleted')}>Удалить
-
- navigate('/reviews')}>Назад к списку
+ <>
+ navigate('/reviews')}>
+
+ {t('common.backToList')}
+
+ }
+ />
+
+
+
+
+
- {t('common.id')}
+ - {review.id}
+
+
+
- {t('common.user')}
+ - {getUserLink()}
+
+
+
- {t('common.targetType')}
+ -
+ {review.target_type}
+
+
+
+
- {t('common.target')}
+ - {getTargetLink()}
+
+
+
- {t('reviews.score')}
+ - {'⭐'.repeat(review.rating)} ({review.rating})
+
+
+
- {t('common.comment')}
+ - {formatDisplayValue(review.comment) === '-' ? emDash : formatDisplayValue(review.comment)}
+
+
+
- {t('common.status')}
+ -
+
+ {formatStatusLabel(review.status)}
+
+
+
+
+
- {t('common.reason')}
+ - {formatDisplayValue(review.reason) === '-' ? emDash : formatDisplayValue(review.reason)}
+
+
+
- {t('common.likes')}
+ - {isBadValue(review.likes) ? 0 : review.likes}
+
+
+
- {t('common.dislikes')}
+ - {isBadValue(review.dislikes) ? 0 : review.dislikes}
+
+
+
- {t('common.createdAt')}
+ - {formatDate(review.created_at)}
+
+
+
- {t('common.updatedAt')}
+ - {formatDate(review.updated_at)}
+
+
-
+ openStatusModal('visible')}>{t('common.show')}
+ openStatusModal('hidden')}>{t('common.hide')}
+ openStatusModal('deleted')}>{t('common.delete')}
+
+
+
+
+
+
+
+
+ {t('common.changeStatusTo', { status: formatStatusLabel(statusModal.newStatus) })}
+
+
+
+ setStatusModal({ open: false, newStatus: 'visible' })}>
+ {t('common.cancel')}
+
+
+ {updateReview.isPending ? t('common.saving') : t('common.save')}
+
+
+
+
+ >
);
};
-export default ReviewDetailPage;
\ No newline at end of file
+export default ReviewDetailPage;
diff --git a/src/pages/reviews/ReviewListPage.tsx b/src/pages/reviews/ReviewListPage.tsx
index 30ea93c..0939d0d 100644
--- a/src/pages/reviews/ReviewListPage.tsx
+++ b/src/pages/reviews/ReviewListPage.tsx
@@ -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: , onClick: handlers.onOpen },
+ { label: t('common.edit'), icon: , onClick: handlers.onEdit },
+ ];
+}
+
+const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
+ const { data: user, isLoading: loading } = useUser(userId);
+ if (loading) return ;
+ if (!user) return {userId};
+
+ 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 {name};
+};
+
+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 ;
+ if (event) {
+ const name = event.title || event.id;
+ return {name};
+ }
+ return {targetId};
+ }
+ return {targetId};
+};
+
const ReviewListPage: React.FC = () => {
+ const { t } = useTranslation();
const navigate = useNavigate();
- const [params, setParams] = useState({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'created_at', order: 'desc' });
+ const { viewMode, setViewMode } = useExploreView();
+ const [params, setParams] = useState({
+ 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([]);
+ const [selectedRowKeys, setSelectedRowKeys] = useState([]);
- // Модальное окно для индивидуального редактирования
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();
- // Модальное окно для массового изменения статуса с причиной
- 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();
- // ---- Индивидуальное редактирование ----
- 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 | SorterResult[]
- ) => {
- 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 ;
- if (!user) return {userId};
-
- 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 {name};
- };
-
- 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 ;
- if (event) {
- const name = event.title || event.id;
- return {name};
- }
- return {targetId};
- }
- return {targetId};
- };
-
- const typeColors: Record = {
- calendar: 'green',
- event: 'blue',
- review: 'purple',
- };
-
- const columns: ColumnsType = [
- {
- title: ,
- key: 'detail',
- width: 48,
- align: 'center',
- render: (_, record) => (
-
- }
- size="small"
- type="link"
- onClick={() => navigate(`/reviews/${record.id}`)}
- />
-
- ),
- },
- {
- title: 'Пользователь',
- key: 'user',
- ellipsis: true,
- render: (_, record) => ,
- },
- {
- title: 'Тип',
- dataIndex: 'target_type',
- key: 'target_type',
- width: 100,
- sorter: true,
- render: (type: string) => (
- {type}
- ),
- },
- {
- title: 'Цель',
- key: 'target',
- ellipsis: true,
- render: (_, record) => ,
- },
- {
- 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) => (
-
- {formatStatusLabel(status)}
-
- ),
- },
- {
- 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) => (
-
- }
- 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[]>(
+ () => [
+ {
+ id: 'select',
+ header: () => (
+
-
- ),
- },
- ];
+ ),
+ size: 40,
+ enableSorting: false,
+ cell: ({ row }) => (
+ toggleRow(row.original.id)}
+ aria-label={t('reviews.selectReviewAria', { id: row.original.id })}
+ />
+ ),
+ },
+ {
+ id: 'user',
+ header: t('common.user'),
+ enableSorting: false,
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: 'target_type',
+ header: t('common.type'),
+ enableSorting: true,
+ cell: ({ getValue }) => {
+ const type = getValue();
+ return {type};
+ },
+ },
+ {
+ id: 'target',
+ header: t('common.target'),
+ enableSorting: false,
+ cell: ({ row }) => (
+
+ ),
+ },
+ {
+ accessorKey: 'rating',
+ header: t('reviews.score'),
+ enableSorting: true,
+ cell: ({ getValue }) => '⭐'.repeat(getValue()),
+ },
+ {
+ accessorKey: 'comment',
+ header: t('common.comment'),
+ enableSorting: false,
+ cell: ({ getValue }) => {
+ const comment = getValue();
+ return {comment};
+ },
+ },
+ {
+ accessorKey: 'status',
+ header: t('common.status'),
+ enableSorting: true,
+ cell: ({ getValue }) => {
+ const status = getValue();
+ return {formatStatusLabel(status)};
+ },
+ },
+ {
+ 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 (
+ navigate(`/reviews/${record.id}`),
+ onEdit: () => handleEdit(record.id),
+ }, t)}
+ />
+ );
+ },
+ },
+ ],
+ [allPageSelected, selectedRowKeys, navigate, t]
+ );
+
+ const denseRows = useMemo(() => {
+ 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: (
+ <>
+ {record.target_type}
+
+ {formatStatusLabel(record.status)}
+
+ {'⭐'.repeat(record.rating)}
+ >
+ ),
+ meta: `${likes} / ${dislikes}`,
+ actions: reviewActions({
+ onOpen: () => navigate(`/reviews/${record.id}`),
+ onEdit: () => handleEdit(record.id),
+ }, t),
+ };
+ });
+ }, [data?.data, navigate, t]);
return (
-
-
Отзывы
- {stats && (
-
-
-
- } />
-
-
- {Object.entries(stats.reviews_by_status || {}).map(([status, count]) => (
-
-
-
-
-
- ))}
- {Object.entries(stats.reviews_by_target_type || {}).map(([type, count]) => (
-
-
-
-
-
- ))}
-
- )}
- {stats?.top_targets_by_reviews && stats.top_targets_by_reviews.length > 0 && (
-
- `${r.target_type}-${r.target_id}`}
- dataSource={stats.top_targets_by_reviews}
- columns={[
- { title: 'Тип', dataIndex: 'target_type', key: 'target_type' },
+ <>
+
+ {stats && (
+ (
- {id}
- ),
+ id: 'all',
+ label: t('explore.allReviews'),
+ rows: (stats.top_targets_by_reviews ?? []).map((item) => ({
+ id: `${item.target_type}-${item.target_id}`,
+ primary: (
+ {item.target_id}
+ ),
+ 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: (
+ {item.target_id}
+ ),
+ 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: (
+ {item.target_id}
+ ),
+ secondary: item.target_type,
+ value: item.review_count,
+ })),
},
- { title: 'Отзывов', dataIndex: 'review_count', key: 'review_count' },
]}
/>
-
- )}
-
- openBulkStatusModal('hidden')} disabled={selectedRowKeys.length === 0}>
- Скрыть выбранные
-
- openBulkStatusModal('visible')} disabled={selectedRowKeys.length === 0}>
- Показать выбранные
-
- openBulkStatusModal('deleted')} disabled={selectedRowKeys.length === 0}>
- Удалить выбранные
-
-
- setSelectedRowKeys(keys),
- }}
- columns={columns}
- dataSource={data?.data}
- rowKey="id"
- loading={isLoading}
- onChange={handleTableChange}
- pagination={getTablePagination(params, data?.total)}
- />
-
- {/* Модальное окно индивидуального редактирования */}
- setEditModal({ open: false, reviewId: null })}
- onOk={handleSaveEdit}
- confirmLoading={updateReview.isPending}
- destroyOnHidden
- >
- {loadingReview ? (
-
- ) : (
-
-
-
-
-
-
-
-
-
- ({
- validator(_, value) {
- const status = getFieldValue('status');
- if ((status === 'hidden' || status === 'deleted') && (!value || value.trim() === '')) {
- return Promise.reject(new Error('Укажите причину изменения статуса'));
- }
- return Promise.resolve();
- },
- }),
- ]}
- >
-
-
-
)}
-
- {/* Модальное окно для массового изменения с причиной */}
- setBulkStatusModal({ open: false, status: 'hidden' })}
- onOk={handleBulkStatusSubmit}
- confirmLoading={bulkUpdate.isPending}
- destroyOnHidden
- >
-
+ openBulkStatusModal('hidden')}
+ disabled={selectedRowKeys.length === 0}
>
-
-
-
-
-
+ {t('reviews.hideSelected')}
+
+ openBulkStatusModal('visible')}
+ disabled={selectedRowKeys.length === 0}
+ >
+ {t('reviews.showSelected')}
+
+ openBulkStatusModal('deleted')}
+ disabled={selectedRowKeys.length === 0}
+ >
+ {t('reviews.deleteSelected')}
+
+
+
+
+
+
+
+
+
+ >
);
};
-export default ReviewListPage;
\ No newline at end of file
+export default ReviewListPage;
diff --git a/src/pages/subscriptions/SubscriptionListPage.tsx b/src/pages/subscriptions/SubscriptionListPage.tsx
index b9ee178..17d308c 100644
--- a/src/pages/subscriptions/SubscriptionListPage.tsx
+++ b/src/pages/subscriptions/SubscriptionListPage.tsx
@@ -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: , onClick: handlers.onEdit },
+ {
+ label: t('common.delete'),
+ icon: ,
+ onClick: handlers.onDelete,
+ destructive: true,
+ separatorBefore: true,
+ },
+ ];
+}
+
+const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
+ const { data: user, isLoading: loading } = useUser(userId);
+ if (loading) return ;
+ if (!user) return {userId};
+ const name = !isBadValue(user.nickname) ? user.nickname : user.email;
+ return {!isBadValue(name) ? name : user.id};
+};
const SubscriptionListPage: React.FC = () => {
- const [params, setParams] = useState({ limit: getSavedPageSize(TABLE_KEY), offset: 0 });
+ const { t } = useTranslation();
+ const { viewMode, setViewMode } = useExploreView();
+ const [params, setParams] = useState({
+ 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();
+
+ 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 = {};
+ 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 | SorterResult[]
- ) => {
- 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 ;
- if (!user) return {userId};
- const name = !isBadValue(user.nickname) ? user.nickname : user.email;
- return {!isBadValue(name) ? name : user.id};
- };
-
- 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 = {
- trial: 'cyan',
- monthly: 'blue',
- quarterly: 'green',
- biannual: 'purple',
- annual: 'orange',
- };
-
- const columns: ColumnsType = [
- {
- title: 'Пользователь',
- key: 'user',
- ellipsis: true,
- render: (_, record) => ,
- },
- {
- title: 'План',
- dataIndex: 'plan',
- key: 'plan',
- width: 100,
- sorter: true,
- render: (plan: string) => (
- {plan}
- ),
- },
- {
- title: 'Статус',
- dataIndex: 'status',
- key: 'status',
- width: 100,
- sorter: true,
- render: (status: string) => {
- const color =
- status === 'active' ? 'green' :
- status === 'expired' ? 'orange' : 'red';
- return {status};
+ const columns = useMemo[]>(
+ () => [
+ {
+ id: 'user',
+ header: t('common.user'),
+ enableSorting: false,
+ cell: ({ row }) => ,
},
- },
- {
- 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) => (
-
-
- }
- size="small"
- onClick={() => handleEdit(record)}
+ {
+ accessorKey: 'plan',
+ header: t('common.plan'),
+ enableSorting: true,
+ cell: ({ getValue }) => {
+ const plan = getValue();
+ return {plan};
+ },
+ },
+ {
+ accessorKey: 'status',
+ header: t('common.status'),
+ enableSorting: true,
+ cell: ({ getValue }) => {
+ const status = getValue();
+ return {formatStatusLabel(status)};
+ },
+ },
+ {
+ accessorKey: 'trial_used',
+ header: t('subscriptions.trial'),
+ enableSorting: false,
+ cell: ({ getValue }) => (getValue() ? t('common.yes') : t('common.no')),
+ },
+ {
+ accessorKey: 'started_at',
+ header: t('common.start'),
+ enableSorting: false,
+ cell: ({ getValue }) => formatDate(getValue()),
+ },
+ {
+ accessorKey: 'expires_at',
+ header: t('common.expiresAt'),
+ enableSorting: false,
+ cell: ({ getValue }) => formatDate(getValue()),
+ },
+ {
+ id: 'actions',
+ header: '',
+ size: 48,
+ enableSorting: false,
+ cell: ({ row }) => {
+ const record = row.original;
+ return (
+ handleEdit(record),
+ onDelete: () => handleDelete(record.id),
+ }, t)}
/>
-
-
- }
- size="small"
- danger
- onClick={() => handleDelete(record.id)}
- />
-
-
+ );
+ },
+ },
+ ],
+ [t]
+ );
+
+ const denseRows = useMemo(() => {
+ return (data?.data ?? []).map((record) => ({
+ id: record.id,
+ title: record.user_id,
+ subtitle: t('subscriptions.expiresUntil', { date: formatDate(record.expires_at) }),
+ badges: (
+ <>
+ {record.plan}
+ {formatStatusLabel(record.status)}
+ {record.trial_used && {t('subscriptions.trialBadge')}}
+ >
),
- },
- ];
+ meta: formatDate(record.started_at),
+ actions: subscriptionActions({
+ onEdit: () => handleEdit(record),
+ onDelete: () => handleDelete(record.id),
+ }, t),
+ }));
+ }, [data?.data, t]);
return (
-
-
Подписки
- {stats && (
-
-
-
- } />
-
-
-
-
-
-
-
- {Object.entries(stats.subscriptions_by_status || {}).map(([status, count]) => (
-
-
-
-
-
- ))}
- {Object.entries(stats.subscriptions_by_plan || {}).map(([plan, count]) => (
-
-
-
-
-
- ))}
-
- )}
- {stats?.ending_paid_subscriptions && stats.ending_paid_subscriptions.length > 0 && (
-
-
+
+
+
+
+ }
+ >
+ {stats?.ending_paid_subscriptions && stats.ending_paid_subscriptions.length > 0 && (
+ ({
+ id: sub.id,
+ primary: {sub.user_id},
+ secondary: `${sub.plan} · #${sub.id}`,
+ value: formatDate(sub.expires_at),
+ })),
+ },
]}
/>
-
- )}
-
-
-
-
-
-
- setEditModal({ open: false, subscriptionId: null })}
- onOk={handleSave}
- confirmLoading={updateSubscription.isPending}
- destroyOnHidden
- >
- {loadingSubscription ? (
-
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
-
)}
-
-
+
+
+
+
+
+
+
+ >
);
};
-export default SubscriptionListPage;
\ No newline at end of file
+export default SubscriptionListPage;
diff --git a/src/pages/tickets/TicketDetailPage.tsx b/src/pages/tickets/TicketDetailPage.tsx
index 9cc121c..e032ae5 100644
--- a/src/pages/tickets/TicketDetailPage.tsx
+++ b/src/pages/tickets/TicketDetailPage.tsx
@@ -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({
+ 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 = {};
+ 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 ;
- if (!reporter) return reporterId || '-';
+ if (loadingReporter) return ;
+ if (!reporter) return reporterId || emDash;
const name = reporter.nickname && !isBadValue(reporter.nickname)
? reporter.nickname
: reporter.email;
- return {name || reporter.id};
+ return {name || reporter.id};
};
- if (isLoading) return ;
- if (!ticket) return Тикет не найден
;
+ if (isLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (!ticket) return {t('tickets.notFound')}
;
return (
-
-
- {ticket.id}
- {getUserLink()}
- {ticket.error_hash}
- {ticket.error_message}
-
-
- {isBadValue(ticket.stacktrace) ? '-' : ticket.stacktrace}
-
-
-
- {isBadValue(ticket.context) ? '-' : ticket.context}
-
- {ticket.count}
- {formatDate(ticket.first_seen)}
- {formatDate(ticket.last_seen)}
-
-
- {ticket.status}
-
-
-
- {loadingAssigned ? : (
- assignedAdmin ? (
-
- {!isBadValue(assignedAdmin.nickname) ? assignedAdmin.nickname : assignedAdmin.email || assignedAdmin.id}
-
- ) : (isBadValue(ticket.assigned_to) ? '-' : ticket.assigned_to)
- )}
-
-
- {isBadValue(ticket.resolution_note) ? '-' : ticket.resolution_note}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Сохранить
+ <>
+ navigate('/tickets')}>
+
+ {t('common.backToList')}
- navigate('/tickets')}>Назад к списку
-
-
+ }
+ />
+
+
+
+
+
- {t('common.id')}
+ - {ticket.id}
+
+
+
- {t('common.sender')}
+ - {getUserLink()}
+
+
+
- {t('common.errorHash')}
+ - {ticket.error_hash}
+
+
+
- {t('common.errorMessage')}
+ - {ticket.error_message}
+
+
+
- {t('common.stacktrace')}
+
-
+
+ {isBadValue(ticket.stacktrace) ? emDash : ticket.stacktrace}
+
+
+
+
+
- {t('common.context')}
+ - {formatDisplayValue(ticket.context) === '-' ? emDash : formatDisplayValue(ticket.context)}
+
+
+
- {t('common.count')}
+ - {ticket.count}
+
+
+
- {t('common.firstSeen')}
+ - {formatDate(ticket.first_seen)}
+
+
+
- {t('common.lastSeen')}
+ - {formatDate(ticket.last_seen)}
+
+
+
- {t('common.status')}
+ -
+ {formatStatusLabel(ticket.status)}
+
+
+
+
- {t('common.assigned')}
+ -
+ {loadingAssigned ? (
+
+ ) : assignedAdmin ? (
+
+ {!isBadValue(assignedAdmin.nickname) ? assignedAdmin.nickname : assignedAdmin.email || assignedAdmin.id}
+
+ ) : (
+ isBadValue(ticket.assigned_to) ? emDash : ticket.assigned_to
+ )}
+
+
+
+
- {t('common.resolution')}
+ - {isBadValue(ticket.resolution_note) ? emDash : ticket.resolution_note}
+
+
+
+
+
+ >
);
};
-export default TicketDetailPage;
\ No newline at end of file
+export default TicketDetailPage;
diff --git a/src/pages/tickets/TicketListPage.tsx b/src/pages/tickets/TicketListPage.tsx
index 9b032ee..82428b8 100644
--- a/src/pages/tickets/TicketListPage.tsx
+++ b/src/pages/tickets/TicketListPage.tsx
@@ -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: , onClick: handlers.onOpen },
+ {
+ label: t('common.delete'),
+ icon: ,
+ 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 -;
+ if (loading) return ;
+ if (!admin) return {adminId};
+ const name = !isBadValue(admin.nickname) ? admin.nickname : admin.email;
+ return {!isBadValue(name) ? name : admin.id};
+};
+
const TicketListPage: React.FC = () => {
+ const { t } = useTranslation();
const navigate = useNavigate();
- const [params, setParams] = useState({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'last_seen', order: 'desc' });
+ const { viewMode, setViewMode } = useExploreView();
+ const [params, setParams] = useState({
+ 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 -;
- if (loading) return ;
- if (!admin) return {adminId};
- const name = !isBadValue(admin.nickname) ? admin.nickname : admin.email;
- return {!isBadValue(name) ? name : admin.id};
+ 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 | SorterResult[]
- ) => {
- 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 = [
- {
- title: ,
- key: 'detail',
- width: 48,
- align: 'center',
- render: (_, record) => (
-
- }
- size="small"
- type="link"
- onClick={() => navigate(`/tickets/${record.id}`)}
- />
-
- ),
- },
- {
- 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 {formatStatusLabel(status)};
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: 'error_message',
+ header: t('common.error'),
+ enableSorting: true,
+ cell: ({ getValue }) => {
+ const text = getValue();
+ return isBadValue(text) ? '-' : text;
+ },
},
- },
- {
- title: 'Назначен',
- dataIndex: 'assigned_to',
- key: 'assigned_to',
- render: (assigned: string) => ,
- },
- { 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) => (
- deleteTicket.mutate(record.id)}
- >
-
- }
- size="small"
- danger
+ {
+ accessorKey: 'status',
+ header: t('common.status'),
+ size: 100,
+ enableSorting: true,
+ cell: ({ getValue }) => {
+ const status = getValue();
+ return {formatStatusLabel(status)};
+ },
+ },
+ {
+ accessorKey: 'assigned_to',
+ header: t('common.assigned'),
+ enableSorting: false,
+ cell: ({ getValue }) => ()} />,
+ },
+ { 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 (
+ navigate(`/tickets/${record.id}`),
+ onDelete: () => handleDelete(record.id),
+ }, t)}
/>
-
-
- ),
- },
- ];
+ );
+ },
+ },
+ ],
+ [navigate, t]
+ );
+
+ const denseRows = useMemo(() => {
+ 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: (
+
+ {formatStatusLabel(record.status)}
+
+ ),
+ 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 (
-
-
Тикеты
- {stats && (
-
-
-
- } />
-
-
-
-
-
-
-
-
-
- } styles={{ content: { color: 'blue' } }} />
-
-
-
-
- } styles={{ content: { color: 'green' } }} />
-
-
-
-
- } />
-
-
-
-
-
-
-
-
- )}
-
-
+ <>
+
+
+
+
+
+ >
);
};
-export default TicketListPage;
\ No newline at end of file
+export default TicketListPage;
diff --git a/src/pages/users/UserDetailPage.tsx b/src/pages/users/UserDetailPage.tsx
index 7bbfac9..5f63dfd 100644
--- a/src/pages/users/UserDetailPage.tsx
+++ b/src/pages/users/UserDetailPage.tsx
@@ -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 {text};
+ }
+ 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 ;
- if (!user) return Пользователь не найден
;
+ if (isLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (!user) return {t('users.notFound')}
;
+
+ const titleName = !isBadValue(user.nickname) ? user.nickname : user.email || user.id;
+ const emDash = t('common.emDash');
return (
-
-
- {user.id}
- {displayValue(user.email)}
- {displayValue(user.nickname)}
-
- {user.role}
-
-
-
- {user.status}
-
-
- {displayValue(user.reason)}
- {displayValue(user.phone)}
- {displayValue(user.language)}
- {displayValue(user.timezone)}
- {displayValue(user.avatar_url)}
- {displayValue(user.social_links)}
- {displayValue(user.preferences)}
- {formatDate(user.last_login)}
- {formatDate(user.created_at)}
- {formatDate(user.updated_at)}
-
- navigate('/users')}>Назад к списку
-
+
+
navigate('/users')}>
+
+ {t('common.backToList')}
+
+ }
+ />
+
+
+
+
+
- {t('common.id')}
+ - {user.id}
+
+
+
- {t('common.email')}
+ -
+
+
+
+
+
- {t('common.nickname')}
+ -
+
+
+
+
+
- {t('common.role')}
+ -
+ {user.role}
+
+
+
+
- {t('common.status')}
+ -
+ {user.status}
+
+
+
+
- {t('common.reason')}
+ -
+
+
+
+
+
- {t('common.phone')}
+ -
+
+
+
+
+
- {t('common.language')}
+ -
+
+
+
+
+
- {t('common.timezone')}
+ -
+
+
+
+
+
- {t('profile.avatarUrl')}
+ -
+
+
+
+
+
- {t('common.socialLinks')}
+ -
+
+
+
+
+
- {t('profile.preferencesJson')}
+ -
+
+
+
+
+
- {t('common.lastLogin')}
+ - {formatDate(user.last_login)}
+
+
+
- {t('common.createdAt')}
+ - {formatDate(user.created_at)}
+
+
+
- {t('common.updatedAt')}
+ - {formatDate(user.updated_at)}
+
+
+
+
+
);
};
-export default UserDetailPage;
\ No newline at end of file
+export default UserDetailPage;
diff --git a/src/pages/users/UserListPage.tsx b/src/pages/users/UserListPage.tsx
index eada6d5..2a25f3a 100644
--- a/src/pages/users/UserListPage.tsx
+++ b/src/pages/users/UserListPage.tsx
@@ -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: , onClick: handlers.onOpen },
+ { label: t('common.edit'), icon: , onClick: handlers.onEdit },
+ {
+ label: record.status === 'active' ? t('common.freeze') : t('common.unfreeze'),
+ icon: record.status === 'active' ? : ,
+ onClick: handlers.onStatus,
+ disabled: isDeleted,
+ },
+ {
+ label: t('common.delete'),
+ icon: ,
+ onClick: handlers.onDelete,
+ disabled: isDeleted,
+ destructive: true,
+ separatorBefore: true,
+ },
+ ];
+}
+
const UserListPage: React.FC = () => {
+ const { t } = useTranslation();
const navigate = useNavigate();
- const [params, setParams] = useState({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'id', order: 'asc' });
+ const { viewMode, setViewMode } = useExploreView();
+ const [preview, setPreview] = useState(null);
+ const [searchDraft, setSearchDraft] = useState('');
+ const [params, setParams] = useState({
+ 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();
const originalUserRef = useRef(null);
- // Изменение статуса
const [statusModal, setStatusModal] = useState<{
open: boolean;
userId: string | null;
newStatus: string;
currentReason: string;
- }>({
+ }>({ open: false, userId: null, newStatus: 'active', currentReason: '' });
+ const statusForm = useForm();
+
+ 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 = {};
+ const handleSaveEdit = editForm.handleSubmit((values) => {
+ if (!editModal.userId || !originalUserRef.current) return;
+ const original = originalUserRef.current;
+ const cleanedValues: Record = {};
- 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)[key];
+ if (newVal === '' || newVal === undefined || newVal === null) {
+ const origVal = (original as unknown as Record)[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)[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 = { 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 | SorterResult[]
- ) => {
- 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 = [
- {
- title: ,
- key: 'detail',
- width: 48,
- align: 'center',
- render: (_, record) => (
-
- }
- size="small"
- type="link"
- onClick={() => navigate(`/users/${record.id}`)}
- />
-
- ),
- },
- { 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 {role};
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: 'nickname',
+ header: t('common.user'),
+ enableSorting: true,
+ cell: ({ row, getValue }) => {
+ const nick = getValue();
+ const label = nick || row.original.email || row.original.id;
+ return (
+ setPreview({ type: 'user', id: row.original.id })}
+ >
+ {label}
+
+ );
+ },
},
- },
- {
- title: 'Статус',
- dataIndex: 'status',
- key: 'status',
- width: 100,
- sorter: true,
- render: (status: string) => (
-
- {formatStatusLabel(status)}
-
- ),
- },
- { title: 'Последний вход', dataIndex: 'last_login', key: 'last_login', sorter: true },
- {
- title: 'Действия',
- key: 'actions',
- width: 120,
- render: (_, record) => {
- const isDeleted = record.status === 'deleted';
- return (
-
-
- }
- size="small"
- onClick={() => handleEdit(record.id)}
- />
-
-
- : }
- size="small"
- onClick={() => handleStatusChange(record)}
- disabled={isDeleted}
- />
-
-
- }
- size="small"
- danger
- onClick={() => handleDelete(record.id)}
- disabled={isDeleted}
- />
-
-
- );
+ { accessorKey: 'email', header: t('common.email'), enableSorting: true },
+ {
+ accessorKey: 'role',
+ header: t('common.role'),
+ enableSorting: true,
+ cell: ({ getValue }) => {
+ const role = getValue();
+ return {role};
+ },
},
- },
- ];
+ {
+ accessorKey: 'status',
+ header: t('common.status'),
+ enableSorting: true,
+ cell: ({ getValue }) => {
+ const status = getValue();
+ return {formatStatusLabel(status)};
+ },
+ },
+ { accessorKey: 'last_login', header: t('common.lastLogin'), enableSorting: true },
+ {
+ id: 'actions',
+ header: '',
+ size: 48,
+ enableSorting: false,
+ cell: ({ row }) => {
+ const record = row.original;
+ return (
+ navigate(`/users/${record.id}`),
+ onEdit: () => handleEdit(record.id),
+ onStatus: () => handleStatusChange(record),
+ onDelete: () => handleDelete(record.id),
+ }, t)}
+ />
+ );
+ },
+ },
+ ],
+ [navigate, t]
+ );
+
+ const denseRows = useMemo(() => {
+ 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: (
+ <>
+ {record.role}
+ {formatStatusLabel(record.status)}
+ >
+ ),
+ 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 (
-
-
Пользователи
- {stats && (
-
-
-
- } />
-
-
-
-
-
-
-
- {Object.entries(stats.users_by_status || {}).map(([status, count]) => (
-
-
-
-
-
- ))}
- {Object.entries(stats.users_by_role || {}).map(([role, count]) => (
-
-
-
-
-
- ))}
-
- )}
-
-
- {/* Модальное окно редактирования */}
-
setEditModal({ open: false, userId: null })}
- onOk={handleSaveEdit}
- confirmLoading={updateUser.isPending}
- destroyOnHidden
- okButtonProps={{ disabled: editHasErrors }}
+ <>
+
+ }
>
- {loadingUser ? (
-
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
- ({
- validator(_, value) {
- const status = getFieldValue('status');
- if (status && status !== 'active' && (!value || value.trim() === '')) {
- return Promise.reject(new Error('Укажите причину изменения статуса'));
- }
- return Promise.resolve();
- },
- }),
- ]}
- >
-
-
-
- )}
-
+
+
- {/* Модальное окно изменения статуса */}
-
!open && setEditModal({ open: false, userId: null })}>
+
+
+ {t('users.editTitle')}
+
+ {loadingUser ? (
+
+
+
+
+ ) : (
+
+ )}
+
+ setEditModal({ open: false, userId: null })}>
+ {t('common.cancel')}
+
+
+ {t('common.save')}
+
+
+
+
+
+
-
+
+
+ {t('users.statusTitle', { status: statusModal.newStatus })}
+
+
+
+
+ setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' })
+ }
+ >
+ {t('common.cancel')}
+
+
+ {t('common.save')}
+
+
+
+
+
+
+
+ !open && setPreview(null)} />
+ >
);
};
-export default UserListPage;
\ No newline at end of file
+export default UserListPage;
diff --git a/src/types/api.ts b/src/types/api.ts
index 39ad43e..c337c8b 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -34,7 +34,15 @@ export interface CalendarRankItem {
id: string;
title: string;
rating_avg: number;
- review_count: number;
+ /** Calendars may expose this; events use rating_count from API */
+ review_count?: number;
+}
+
+export interface EventRankItem {
+ id: string;
+ title: string;
+ rating_avg: number;
+ rating_count?: number;
}
export interface CalendarStats {
@@ -51,7 +59,7 @@ export interface EventStats {
total_events: number;
events_by_type: Record;
events_by_status: Record;
- top_events_by_rating: CalendarRankItem[];
+ top_events_by_rating: EventRankItem[];
}
export interface UserStats {
diff --git a/src/utils/statusLabels.ts b/src/utils/statusLabels.ts
index 739283c..a6cc901 100644
--- a/src/utils/statusLabels.ts
+++ b/src/utils/statusLabels.ts
@@ -1,38 +1,9 @@
-const STATUS_LABELS: Record> = {
- ru: {
- active: 'Активен',
- frozen: 'Заморожен',
- deleted: 'Удалён',
- pending: 'Ожидает',
- reviewed: 'Рассмотрено',
- dismissed: 'Отклонено',
- open: 'Открыт',
- in_progress: 'В работе',
- resolved: 'Решён',
- closed: 'Закрыт',
- visible: 'Видимый',
- hidden: 'Скрыт',
- cancelled: 'Отменён',
- completed: 'Завершён',
- },
- en: {
- active: 'Active',
- frozen: 'Frozen',
- deleted: 'Deleted',
- pending: 'Pending',
- reviewed: 'Reviewed',
- dismissed: 'Dismissed',
- open: 'Open',
- in_progress: 'In progress',
- resolved: 'Resolved',
- closed: 'Closed',
- visible: 'Visible',
- hidden: 'Hidden',
- cancelled: 'Cancelled',
- completed: 'Completed',
- },
-};
+import i18n from '@/i18n';
-export function formatStatusLabel(status: string, locale: 'ru' | 'en' = 'ru'): string {
- return STATUS_LABELS[locale]?.[status] ?? status;
+/** Localized status label; falls back to raw status code. */
+export function formatStatusLabel(status: string, locale?: string): string {
+ const lng = (locale ?? i18n.language ?? 'ru').startsWith('en') ? 'en' : 'ru';
+ const key = `status.${status}`;
+ const translated = i18n.t(key, { lng });
+ return translated === key ? status : translated;
}
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 5d098a2..03c459f 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -14,6 +14,11 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
+ "ignoreDeprecations": "6.0",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
/* Linting */
"noUnusedLocals": true,
diff --git a/vite.config.ts b/vite.config.ts
index a392136..79d972b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,11 +1,18 @@
+import path from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
const apiBaseUrl = process.env.VITE_API_BASE_URL ?? 'https://admin-api.dev.eventhub.local';
const wsUrl = process.env.VITE_WS_URL ?? 'wss://admin-ws.dev.eventhub.local';
export default defineConfig({
- plugins: [react()],
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
server: {
proxy: {
'/v1': {
@@ -21,4 +28,4 @@ export default defineConfig({
},
},
},
-});
\ No newline at end of file
+});