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 { 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 { 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 [editModal, setEditModal] = useState<{ open: boolean; reviewId: string | null }>({ open: false, reviewId: null, }); 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 = useForm(); 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 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) { 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) { notify.info(t('reviews.selectReviews')); return; } setBulkStatusModal({ open: true, status }); bulkStatusForm.reset(); }; 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; } 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([]); }, }); }); 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 && ( ({ 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, })), }, ]} /> )} 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')} !open && setEditModal({ open: false, reviewId: null })} > {t('reviews.editTitle')} {loadingReview ? ( ) : ( {t('reviews.score')} {t('common.comment')} {t('common.status')} ( visible hidden deleted )} /> {t('common.reason')} {editForm.formState.errors.reason && ( {editForm.formState.errors.reason.message} )} )} setEditModal({ open: false, reviewId: null })}> {t('common.cancel')} {t('common.save')} !open && setBulkStatusModal({ open: false, status: 'hidden' })} > {t('reviews.bulkStatusTitle', { status: bulkStatusModal.status, count: selectedRowKeys.length, })} {t('common.reason')} {bulkStatusForm.formState.errors.reason && ( {bulkStatusForm.formState.errors.reason.message} )} setBulkStatusModal({ open: false, status: 'hidden' })} > {t('common.cancel')} {t('common.apply')} > ); }; export default ReviewListPage;
{editForm.formState.errors.reason.message}
{bulkStatusForm.formState.errors.reason.message}