581 lines
24 KiB
TypeScript
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;
|