Files
EventHubFrontAdmin/src/pages/reviews/ReviewListPage.tsx
T

581 lines
24 KiB
TypeScript

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: <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 { 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<string[]>([]);
const [editModal, setEditModal] = useState<{ open: boolean; reviewId: string | null }>({
open: false,
reviewId: null,
});
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 = useForm<BulkStatusFormValues>();
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<ColumnDef<Review>[]>(
() => [
{
id: 'select',
header: () => (
<input
type="checkbox"
className="h-4 w-4 rounded border"
checked={allPageSelected}
onChange={toggleSelectAll}
aria-label={t('reviews.selectAllAria')}
/>
),
size: 40,
enableSorting: false,
cell: ({ row }) => (
<input
type="checkbox"
data-testid={`review-check-${row.original.id}`}
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 (
<>
<ExploreListShell
data-testid="page-reviews"
title={t('reviews.title')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
>
{stats && (
<ExploreInsightsCollapse
title={t('explore.tops')}
tabs={[
{
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,
})),
},
]}
/>
)}
<div className="flex flex-wrap gap-2">
<Button
data-testid="btn-reviews-bulk-hidden"
variant="outline"
onClick={() => openBulkStatusModal('hidden')}
disabled={selectedRowKeys.length === 0}
>
{t('reviews.hideSelected')}
</Button>
<Button
data-testid="btn-reviews-bulk-visible"
variant="outline"
onClick={() => openBulkStatusModal('visible')}
disabled={selectedRowKeys.length === 0}
>
{t('reviews.showSelected')}
</Button>
<Button
data-testid="btn-reviews-bulk-deleted"
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 data-testid="dialog-reviews-bulk-status">
<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
data-testid="input-bulk-reason"
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"
data-testid="btn-save"
disabled={bulkUpdate.isPending}
> {t('common.apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default ReviewListPage;