feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
@@ -1,388 +1,571 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Tag, Space, Modal, Form, Select, InputNumber, message, Tooltip, Input, Spin, Card, Col, Row, Statistic } from 'antd';
|
||||
import { InfoCircleOutlined, EditOutlined, StarOutlined } from '@ant-design/icons';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useReviews, useUpdateReview, useBulkUpdateReviews, useReview, useReviewStats } from '../../hooks/useReviews';
|
||||
import { useUser } from '../../hooks/useUsers';
|
||||
import { useEvent } from '../../hooks/useEvents';
|
||||
import { Review, ReviewListParams } from '../../types/api';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
|
||||
import { formatStatusLabel } from '../../utils/statusLabels';
|
||||
import { Info, Pencil } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import {
|
||||
useReviews,
|
||||
useUpdateReview,
|
||||
useBulkUpdateReviews,
|
||||
useReview,
|
||||
useReviewStats,
|
||||
} from '@/hooks/useReviews';
|
||||
import { useExploreView } from '@/hooks/useExploreView';
|
||||
import { useUser } from '@/hooks/useUsers';
|
||||
import { useEvent } from '@/hooks/useEvents';
|
||||
import { Review, ReviewListParams } from '@/types/api';
|
||||
import { getSavedPageSize } from '@/utils/tablePagination';
|
||||
import { formatStatusLabel } from '@/utils/statusLabels';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { ExploreListShell } from '@/components/explore/ExploreListShell';
|
||||
import { ExploreDataView } from '@/components/explore/ExploreDataView';
|
||||
import { ExploreInsightsCollapse } from '@/components/explore/ExploreInsightsCollapse';
|
||||
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
|
||||
import type { DenseRowModel } from '@/components/explore/DenseRowList';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
const TABLE_KEY = 'reviews';
|
||||
|
||||
const typeBadgeVariant = (type: string) => {
|
||||
if (type === 'calendar') return 'success' as const;
|
||||
if (type === 'event') return 'default' as const;
|
||||
if (type === 'review') return 'secondary' as const;
|
||||
return 'outline' as const;
|
||||
};
|
||||
|
||||
const statusBadgeVariant = (status: string) => {
|
||||
if (status === 'visible') return 'success' as const;
|
||||
if (status === 'hidden') return 'warning' as const;
|
||||
return 'destructive' as const;
|
||||
};
|
||||
|
||||
const isBadValue = (val: unknown) =>
|
||||
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||
|
||||
interface EditFormValues {
|
||||
status: string;
|
||||
reason?: string;
|
||||
comment?: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
interface BulkStatusFormValues {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function reviewActions(
|
||||
handlers: { onOpen: () => void; onEdit: () => void },
|
||||
t: TFunction
|
||||
): RowAction[] {
|
||||
return [
|
||||
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
|
||||
{ label: t('common.edit'), icon: <Pencil className="h-4 w-4" />, onClick: handlers.onEdit },
|
||||
];
|
||||
}
|
||||
|
||||
const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
const { data: user, isLoading: loading } = useUser(userId);
|
||||
if (loading) return <Skeleton className="h-4 w-24" />;
|
||||
if (!user) return <span>{userId}</span>;
|
||||
|
||||
const isGoodString = (val: unknown): val is string => !isBadValue(val);
|
||||
let name = '';
|
||||
if (isGoodString(user.nickname)) {
|
||||
name = user.nickname;
|
||||
} else if (isGoodString(user.email)) {
|
||||
name = user.email;
|
||||
} else {
|
||||
name = user.id;
|
||||
}
|
||||
return <Link to={`/users/${user.id}`}>{name}</Link>;
|
||||
};
|
||||
|
||||
const TargetCell: React.FC<{ targetType: string; targetId: string }> = ({ targetType, targetId }) => {
|
||||
const { data: event, isLoading: loading } = useEvent(targetType === 'event' ? targetId : '');
|
||||
if (targetType === 'event') {
|
||||
if (loading) return <Skeleton className="h-4 w-24" />;
|
||||
if (event) {
|
||||
const name = event.title || event.id;
|
||||
return <Link to={`/events/${event.id}`}>{name}</Link>;
|
||||
}
|
||||
return <span>{targetId}</span>;
|
||||
}
|
||||
return <span>{targetId}</span>;
|
||||
};
|
||||
|
||||
const ReviewListPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useState<ReviewListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'created_at', order: 'desc' });
|
||||
const { viewMode, setViewMode } = useExploreView();
|
||||
const [params, setParams] = useState<ReviewListParams>({
|
||||
limit: getSavedPageSize(TABLE_KEY),
|
||||
offset: 0,
|
||||
sort: 'created_at',
|
||||
order: 'desc',
|
||||
});
|
||||
const { data, isLoading } = useReviews(params);
|
||||
const { data: stats } = useReviewStats();
|
||||
const updateReview = useUpdateReview();
|
||||
const bulkUpdate = useBulkUpdateReviews();
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||
|
||||
// Модальное окно для индивидуального редактирования
|
||||
const [editModal, setEditModal] = useState<{ open: boolean; reviewId: string | null }>({
|
||||
open: false,
|
||||
reviewId: null,
|
||||
});
|
||||
const [editForm] = Form.useForm();
|
||||
const { data: editingReview, isLoading: loadingReview } = useReview(editModal.reviewId || '');
|
||||
const editForm = useForm<EditFormValues>();
|
||||
|
||||
// Модальное окно для массового изменения статуса с причиной
|
||||
const [bulkStatusModal, setBulkStatusModal] = useState<{
|
||||
open: boolean;
|
||||
status: string;
|
||||
}>({ open: false, status: 'hidden' });
|
||||
const [bulkStatusForm] = Form.useForm();
|
||||
const [bulkStatusModal, setBulkStatusModal] = useState<{ open: boolean; status: string }>({
|
||||
open: false,
|
||||
status: 'hidden',
|
||||
});
|
||||
const bulkStatusForm = useForm<BulkStatusFormValues>();
|
||||
|
||||
// ---- Индивидуальное редактирование ----
|
||||
const handleEdit = (id: string) => {
|
||||
setEditModal({ open: true, reviewId: id });
|
||||
const pageIds = useMemo(() => (data?.data ?? []).map((r) => r.id), [data?.data]);
|
||||
const allPageSelected = pageIds.length > 0 && pageIds.every((id) => selectedRowKeys.includes(id));
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allPageSelected) {
|
||||
setSelectedRowKeys((prev) => prev.filter((id) => !pageIds.includes(id)));
|
||||
} else {
|
||||
setSelectedRowKeys((prev) => [...new Set([...prev, ...pageIds])]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
editForm.validateFields().then((values) => {
|
||||
if (!editModal.reviewId) return;
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
|
||||
);
|
||||
updateReview.mutate(
|
||||
{ id: editModal.reviewId, data: payload },
|
||||
{ onSuccess: () => setEditModal({ open: false, reviewId: null }) }
|
||||
);
|
||||
});
|
||||
const toggleRow = (id: string) => {
|
||||
setSelectedRowKeys((prev) =>
|
||||
prev.includes(id) ? prev.filter((k) => k !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => setEditModal({ open: true, reviewId: id });
|
||||
|
||||
const handleSaveEdit = editForm.handleSubmit((values) => {
|
||||
if (!editModal.reviewId) return;
|
||||
const status = values.status;
|
||||
if ((status === 'hidden' || status === 'deleted') && !values.reason?.trim()) {
|
||||
editForm.setError('reason', { message: t('reviews.reasonRequired') });
|
||||
return;
|
||||
}
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
|
||||
);
|
||||
updateReview.mutate(
|
||||
{ id: editModal.reviewId, data: payload },
|
||||
{ onSuccess: () => setEditModal({ open: false, reviewId: null }) }
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editingReview && editModal.open) {
|
||||
setTimeout(() => {
|
||||
editForm.setFieldsValue({
|
||||
status: editingReview.status,
|
||||
reason: editingReview.reason,
|
||||
comment: editingReview.comment,
|
||||
rating: editingReview.rating,
|
||||
});
|
||||
}, 0);
|
||||
editForm.reset({
|
||||
status: editingReview.status,
|
||||
reason: editingReview.reason ?? undefined,
|
||||
comment: editingReview.comment ?? undefined,
|
||||
rating: editingReview.rating,
|
||||
});
|
||||
}
|
||||
}, [editingReview, editModal.open, editForm]);
|
||||
|
||||
// ---- Массовое изменение статуса ----
|
||||
const openBulkStatusModal = (status: string) => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('Выберите отзывы');
|
||||
notify.info(t('reviews.selectReviews'));
|
||||
return;
|
||||
}
|
||||
setBulkStatusModal({ open: true, status });
|
||||
bulkStatusForm.resetFields();
|
||||
bulkStatusForm.reset();
|
||||
};
|
||||
|
||||
const handleBulkStatusSubmit = () => {
|
||||
bulkStatusForm.validateFields().then((values) => {
|
||||
const reason = values.reason ? values.reason.trim() : '';
|
||||
const updates = selectedRowKeys.map(id => ({
|
||||
id: id as string,
|
||||
status: bulkStatusModal.status,
|
||||
reason: reason || undefined, // если пусто, не передаем
|
||||
}));
|
||||
bulkUpdate.mutate(updates, {
|
||||
onSuccess: () => {
|
||||
setBulkStatusModal({ open: false, status: 'hidden' });
|
||||
setSelectedRowKeys([]);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ---- Таблица ----
|
||||
const handleTableChange = (
|
||||
pagination: any,
|
||||
_filters: any,
|
||||
sorter: SorterResult<Review> | SorterResult<Review>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
}, pagination, TABLE_KEY));
|
||||
};
|
||||
|
||||
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||
|
||||
const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
const { data: user, isLoading: loading } = useUser(userId);
|
||||
if (loading) return <Spin size="small" />;
|
||||
if (!user) return <span>{userId}</span>;
|
||||
|
||||
const isGoodString = (val: any): val is string => !isBadValue(val);
|
||||
let name = '';
|
||||
if (isGoodString(user.nickname)) {
|
||||
name = user.nickname;
|
||||
} else if (isGoodString(user.email)) {
|
||||
name = user.email;
|
||||
} else {
|
||||
name = user.id;
|
||||
const handleBulkStatusSubmit = bulkStatusForm.handleSubmit((values) => {
|
||||
const reasonRequired =
|
||||
bulkStatusModal.status === 'hidden' || bulkStatusModal.status === 'deleted';
|
||||
if (reasonRequired && !values.reason?.trim()) {
|
||||
bulkStatusForm.setError('reason', { message: t('reviews.reasonRequiredShort') });
|
||||
return;
|
||||
}
|
||||
return <Link to={`/users/${user.id}`}>{name}</Link>;
|
||||
};
|
||||
|
||||
const TargetCell: React.FC<{ targetType: string; targetId: string }> = ({ targetType, targetId }) => {
|
||||
const { data: event, isLoading: loading } = useEvent(targetType === 'event' ? targetId : '');
|
||||
if (targetType === 'event') {
|
||||
if (loading) return <Spin size="small" />;
|
||||
if (event) {
|
||||
const name = event.title || event.id;
|
||||
return <Link to={`/events/${event.id}`}>{name}</Link>;
|
||||
}
|
||||
return <span>{targetId}</span>;
|
||||
}
|
||||
return <span>{targetId}</span>;
|
||||
};
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
calendar: 'green',
|
||||
event: 'blue',
|
||||
review: 'purple',
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Review> = [
|
||||
{
|
||||
title: <InfoCircleOutlined />,
|
||||
key: 'detail',
|
||||
width: 48,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Tooltip title="Открыть страницу отзыва">
|
||||
<Button
|
||||
icon={<InfoCircleOutlined />}
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => navigate(`/reviews/${record.id}`)}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Пользователь',
|
||||
key: 'user',
|
||||
ellipsis: true,
|
||||
render: (_, record) => <UserCell userId={record.user_id} />,
|
||||
},
|
||||
{
|
||||
title: 'Тип',
|
||||
dataIndex: 'target_type',
|
||||
key: 'target_type',
|
||||
width: 100,
|
||||
sorter: true,
|
||||
render: (type: string) => (
|
||||
<Tag color={typeColors[type] || 'default'}>{type}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Цель',
|
||||
key: 'target',
|
||||
ellipsis: true,
|
||||
render: (_, record) => <TargetCell targetType={record.target_type} targetId={record.target_id} />,
|
||||
},
|
||||
{
|
||||
title: 'Оценка',
|
||||
dataIndex: 'rating',
|
||||
key: 'rating',
|
||||
width: 140,
|
||||
sorter: true,
|
||||
render: (rating: number) => '⭐'.repeat(rating),
|
||||
},
|
||||
{
|
||||
title: 'Комментарий',
|
||||
dataIndex: 'comment',
|
||||
key: 'comment',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Статус',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
sorter: true,
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'visible' ? 'green' : status === 'hidden' ? 'orange' : 'red'}>
|
||||
{formatStatusLabel(status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Лайки / Дизлайки',
|
||||
key: 'likes',
|
||||
width: 120,
|
||||
render: (_, record) => {
|
||||
const likes = isBadValue(record.likes) ? 0 : record.likes;
|
||||
const dislikes = isBadValue(record.dislikes) ? 0 : record.dislikes;
|
||||
return `${likes} / ${dislikes}`;
|
||||
const reason = values.reason ? values.reason.trim() : '';
|
||||
const updates = selectedRowKeys.map((id) => ({
|
||||
id,
|
||||
status: bulkStatusModal.status,
|
||||
reason: reason || undefined,
|
||||
}));
|
||||
bulkUpdate.mutate(updates, {
|
||||
onSuccess: () => {
|
||||
setBulkStatusModal({ open: false, status: 'hidden' });
|
||||
setSelectedRowKeys([]);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 48,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Tooltip title="Редактировать">
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleEdit(record.id)}
|
||||
});
|
||||
});
|
||||
|
||||
const exploreStats = useMemo(() => {
|
||||
if (!stats) return [];
|
||||
const items = [{ label: t('common.total'), value: stats.total_reviews }];
|
||||
Object.entries(stats.reviews_by_status || {})
|
||||
.slice(0, 3)
|
||||
.forEach(([status, count]) => {
|
||||
items.push({ label: formatStatusLabel(status), value: count });
|
||||
});
|
||||
return items.slice(0, 4);
|
||||
}, [stats, t]);
|
||||
|
||||
const columns = useMemo<ColumnDef<Review>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'select',
|
||||
header: () => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border"
|
||||
checked={allPageSelected}
|
||||
onChange={toggleSelectAll}
|
||||
aria-label={t('reviews.selectAllAria')}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
),
|
||||
size: 40,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border"
|
||||
checked={selectedRowKeys.includes(row.original.id)}
|
||||
onChange={() => toggleRow(row.original.id)}
|
||||
aria-label={t('reviews.selectReviewAria', { id: row.original.id })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
header: t('common.user'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <UserCell userId={row.original.user_id} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'target_type',
|
||||
header: t('common.type'),
|
||||
enableSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
const type = getValue<string>();
|
||||
return <Badge variant={typeBadgeVariant(type)}>{type}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'target',
|
||||
header: t('common.target'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<TargetCell targetType={row.original.target_type} targetId={row.original.target_id} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'rating',
|
||||
header: t('reviews.score'),
|
||||
enableSorting: true,
|
||||
cell: ({ getValue }) => '⭐'.repeat(getValue<number>()),
|
||||
},
|
||||
{
|
||||
accessorKey: 'comment',
|
||||
header: t('common.comment'),
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => {
|
||||
const comment = getValue<string>();
|
||||
return <span className="line-clamp-2">{comment}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('common.status'),
|
||||
enableSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
const status = getValue<string>();
|
||||
return <Badge variant={statusBadgeVariant(status)}>{formatStatusLabel(status)}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'likes',
|
||||
header: t('reviews.likesDislikes'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const likes = isBadValue(row.original.likes) ? 0 : row.original.likes;
|
||||
const dislikes = isBadValue(row.original.dislikes) ? 0 : row.original.dislikes;
|
||||
return `${likes} / ${dislikes}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
size: 48,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
return (
|
||||
<RowActionsMenu
|
||||
actions={reviewActions({
|
||||
onOpen: () => navigate(`/reviews/${record.id}`),
|
||||
onEdit: () => handleEdit(record.id),
|
||||
}, t)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[allPageSelected, selectedRowKeys, navigate, t]
|
||||
);
|
||||
|
||||
const denseRows = useMemo<DenseRowModel[]>(() => {
|
||||
return (data?.data ?? []).map((record) => {
|
||||
const likes = isBadValue(record.likes) ? 0 : record.likes;
|
||||
const dislikes = isBadValue(record.dislikes) ? 0 : record.dislikes;
|
||||
const title = record.comment
|
||||
? record.comment.length > 60
|
||||
? `${record.comment.slice(0, 60)}…`
|
||||
: record.comment
|
||||
: t('reviews.ratingValue', { rating: record.rating });
|
||||
return {
|
||||
id: record.id,
|
||||
title,
|
||||
subtitle: `${record.target_type} · ${record.target_id}`,
|
||||
badges: (
|
||||
<>
|
||||
<Badge variant={typeBadgeVariant(record.target_type)}>{record.target_type}</Badge>
|
||||
<Badge variant={statusBadgeVariant(record.status)}>
|
||||
{formatStatusLabel(record.status)}
|
||||
</Badge>
|
||||
<span className="text-xs">{'⭐'.repeat(record.rating)}</span>
|
||||
</>
|
||||
),
|
||||
meta: `${likes} / ${dislikes}`,
|
||||
actions: reviewActions({
|
||||
onOpen: () => navigate(`/reviews/${record.id}`),
|
||||
onEdit: () => handleEdit(record.id),
|
||||
}, t),
|
||||
};
|
||||
});
|
||||
}, [data?.data, navigate, t]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Отзывы</h2>
|
||||
{stats && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Всего" value={stats.total_reviews} prefix={<StarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
{Object.entries(stats.reviews_by_status || {}).map(([status, count]) => (
|
||||
<Col xs={12} sm={6} md={4} key={status}>
|
||||
<Card>
|
||||
<Statistic title={status} value={count} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
{Object.entries(stats.reviews_by_target_type || {}).map(([type, count]) => (
|
||||
<Col xs={12} sm={6} md={4} key={`type-${type}`}>
|
||||
<Card>
|
||||
<Statistic title={`Цель: ${type}`} value={count} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
{stats?.top_targets_by_reviews && stats.top_targets_by_reviews.length > 0 && (
|
||||
<Card title="Топ целей по отзывам" style={{ marginBottom: 16 }}>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey={(r) => `${r.target_type}-${r.target_id}`}
|
||||
dataSource={stats.top_targets_by_reviews}
|
||||
columns={[
|
||||
{ title: 'Тип', dataIndex: 'target_type', key: 'target_type' },
|
||||
<>
|
||||
<ExploreListShell
|
||||
title={t('reviews.title')}
|
||||
description={t('explore.descBulk')}
|
||||
stats={exploreStats}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
>
|
||||
{stats && (
|
||||
<ExploreInsightsCollapse
|
||||
title={t('explore.tops')}
|
||||
tabs={[
|
||||
{
|
||||
title: 'ID цели',
|
||||
dataIndex: 'target_id',
|
||||
key: 'target_id',
|
||||
render: (id: string, record) => (
|
||||
<Link to={`/${record.target_type}s/${id}`}>{id}</Link>
|
||||
),
|
||||
id: 'all',
|
||||
label: t('explore.allReviews'),
|
||||
rows: (stats.top_targets_by_reviews ?? []).map((item) => ({
|
||||
id: `${item.target_type}-${item.target_id}`,
|
||||
primary: (
|
||||
<Link to={`/${item.target_type}s/${item.target_id}`}>{item.target_id}</Link>
|
||||
),
|
||||
secondary: item.target_type,
|
||||
value: item.review_count,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: 'positive',
|
||||
label: t('explore.positive'),
|
||||
rows: (stats.top_targets_by_positive_reviews ?? []).map((item) => ({
|
||||
id: `${item.target_type}-${item.target_id}`,
|
||||
primary: (
|
||||
<Link to={`/${item.target_type}s/${item.target_id}`}>{item.target_id}</Link>
|
||||
),
|
||||
secondary: item.target_type,
|
||||
value: item.review_count,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: 'negative',
|
||||
label: t('explore.negative'),
|
||||
rows: (stats.top_targets_by_negative_reviews ?? []).map((item) => ({
|
||||
id: `${item.target_type}-${item.target_id}`,
|
||||
primary: (
|
||||
<Link to={`/${item.target_type}s/${item.target_id}`}>{item.target_id}</Link>
|
||||
),
|
||||
secondary: item.target_type,
|
||||
value: item.review_count,
|
||||
})),
|
||||
},
|
||||
{ title: 'Отзывов', dataIndex: 'review_count', key: 'review_count' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button onClick={() => openBulkStatusModal('hidden')} disabled={selectedRowKeys.length === 0}>
|
||||
Скрыть выбранные
|
||||
</Button>
|
||||
<Button onClick={() => openBulkStatusModal('visible')} disabled={selectedRowKeys.length === 0}>
|
||||
Показать выбранные
|
||||
</Button>
|
||||
<Button danger onClick={() => openBulkStatusModal('deleted')} disabled={selectedRowKeys.length === 0}>
|
||||
Удалить выбранные
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
}}
|
||||
columns={columns}
|
||||
dataSource={data?.data}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={getTablePagination(params, data?.total)}
|
||||
/>
|
||||
|
||||
{/* Модальное окно индивидуального редактирования */}
|
||||
<Modal
|
||||
title="Редактировать отзыв"
|
||||
open={editModal.open}
|
||||
onCancel={() => setEditModal({ open: false, reviewId: null })}
|
||||
onOk={handleSaveEdit}
|
||||
confirmLoading={updateReview.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
{loadingReview ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<Form form={editForm} layout="vertical" preserve={false}>
|
||||
<Form.Item label="Оценка" name="rating">
|
||||
<InputNumber min={1} max={5} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Комментарий" name="comment">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Статус" name="status">
|
||||
<Select>
|
||||
<Select.Option value="visible">visible</Select.Option>
|
||||
<Select.Option value="hidden">hidden</Select.Option>
|
||||
<Select.Option value="deleted">deleted</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Причина"
|
||||
name="reason"
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
const status = getFieldValue('status');
|
||||
if ((status === 'hidden' || status === 'deleted') && (!value || value.trim() === '')) {
|
||||
return Promise.reject(new Error('Укажите причину изменения статуса'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Модальное окно для массового изменения с причиной */}
|
||||
<Modal
|
||||
title={`Изменить статус на «${bulkStatusModal.status}» для ${selectedRowKeys.length} отзывов`}
|
||||
open={bulkStatusModal.open}
|
||||
onCancel={() => setBulkStatusModal({ open: false, status: 'hidden' })}
|
||||
onOk={handleBulkStatusSubmit}
|
||||
confirmLoading={bulkUpdate.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={bulkStatusForm} layout="vertical" preserve={false}>
|
||||
<Form.Item
|
||||
label="Причина"
|
||||
name="reason"
|
||||
rules={[
|
||||
{
|
||||
required: bulkStatusModal.status === 'hidden' || bulkStatusModal.status === 'deleted',
|
||||
message: 'Укажите причину',
|
||||
},
|
||||
]}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openBulkStatusModal('hidden')}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="Общая причина для выбранных отзывов" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
{t('reviews.hideSelected')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openBulkStatusModal('visible')}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
{t('reviews.showSelected')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => openBulkStatusModal('deleted')}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
{t('reviews.deleteSelected')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ExploreDataView
|
||||
viewMode={viewMode}
|
||||
columns={columns}
|
||||
data={data?.data ?? []}
|
||||
denseRows={denseRows}
|
||||
total={data?.total}
|
||||
isLoading={isLoading}
|
||||
tableKey={TABLE_KEY}
|
||||
params={params}
|
||||
onParamsChange={setParams}
|
||||
/>
|
||||
</ExploreListShell>
|
||||
|
||||
<Dialog
|
||||
open={editModal.open}
|
||||
onOpenChange={(open) => !open && setEditModal({ open: false, reviewId: null })}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('reviews.editTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{loadingReview ? (
|
||||
<div className="space-y-3 py-4">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<form id="edit-review-form" className="space-y-4" onSubmit={handleSaveEdit}>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('reviews.score')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
{...editForm.register('rating', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.comment')}</Label>
|
||||
<Textarea rows={3} {...editForm.register('comment')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.status')}</Label>
|
||||
<Controller
|
||||
name="status"
|
||||
control={editForm.control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('common.selectStatus')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="visible">visible</SelectItem>
|
||||
<SelectItem value="hidden">hidden</SelectItem>
|
||||
<SelectItem value="deleted">deleted</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.reason')}</Label>
|
||||
<Textarea rows={2} {...editForm.register('reason')} />
|
||||
{editForm.formState.errors.reason && (
|
||||
<p className="text-sm text-destructive">
|
||||
{editForm.formState.errors.reason.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditModal({ open: false, reviewId: null })}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" form="edit-review-form" disabled={updateReview.isPending}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={bulkStatusModal.open}
|
||||
onOpenChange={(open) => !open && setBulkStatusModal({ open: false, status: 'hidden' })}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('reviews.bulkStatusTitle', {
|
||||
status: bulkStatusModal.status,
|
||||
count: selectedRowKeys.length,
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form id="bulk-status-form" className="space-y-4" onSubmit={handleBulkStatusSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.reason')}</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder={t('reviews.bulkReasonPlaceholder')}
|
||||
{...bulkStatusForm.register('reason')}
|
||||
/>
|
||||
{bulkStatusForm.formState.errors.reason && (
|
||||
<p className="text-sm text-destructive">
|
||||
{bulkStatusForm.formState.errors.reason.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setBulkStatusModal({ open: false, status: 'hidden' })}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" form="bulk-status-form" disabled={bulkUpdate.isPending}>
|
||||
{t('common.apply')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReviewListPage;
|
||||
export default ReviewListPage;
|
||||
|
||||
Reference in New Issue
Block a user