feat(web): admin flagged-listings moderation dashboard (GOO-160)
Add admin dashboard pages for reviewing and acting on user-flagged listings, backed by the GOO-159 admin-moderation API surface (list/detail/moderate). Pages: - /admin/moderation/flagged — paginated, URL-synced filterable table with sortable columns (flag count, latest flag, created at), reason/status/date filters, bulk select + sticky action bar (dismiss / suspend / warn). - /admin/moderation/flagged/[id] — listing summary, photo grid, seller card, full reporter list, per-listing action buttons mirroring bulk actions. All actions go through a confirmation Dialog with optional moderator note. Vietnamese UI throughout. Loading / empty / error / optimistic states covered. Imports the existing admin layout via the (admin) route group. Adds typed API surface in lib/admin-api.ts: getFlaggedListings, getFlaggedListingDetail, moderateFlaggedListings plus FlagReason / FlagStatus / FlaggedAction / FlaggedSortBy and request/response interfaces. E2E (Playwright) at e2e/web/admin-moderation-flagged.spec.ts mocks the three endpoints and covers: list filter + bulk dismiss happy path, detail view with reporters + suspend action, and the empty state. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,269 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AlertTriangle, ArrowLeft, Flag, RefreshCw, ShieldAlert, UserX } from 'lucide-react';
|
||||||
|
import { use, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { StatusChip } from '@/components/design-system/status-chip';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
|
import {
|
||||||
|
adminApi,
|
||||||
|
type FlaggedAction,
|
||||||
|
type FlaggedListingDetail,
|
||||||
|
type FlagReason,
|
||||||
|
} from '@/lib/admin-api';
|
||||||
|
|
||||||
|
const REASON_LABELS: Record<FlagReason, string> = {
|
||||||
|
SCAM: 'Lừa đảo',
|
||||||
|
DUPLICATE: 'Trùng lặp',
|
||||||
|
WRONG_INFO: 'Sai thông tin',
|
||||||
|
ALREADY_SOLD: 'Đã bán',
|
||||||
|
INAPPROPRIATE: 'Không phù hợp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<FlaggedAction, { title: string; verb: string; danger: boolean }> = {
|
||||||
|
dismiss_flags: { title: 'Bỏ qua báo cáo', verb: 'Bỏ qua', danger: false },
|
||||||
|
suspend_listing: { title: 'Tạm ngưng tin đăng', verb: 'Tạm ngưng', danger: true },
|
||||||
|
warn_seller: { title: 'Cảnh báo người bán', verb: 'Cảnh báo', danger: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FlaggedListingDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = use(params);
|
||||||
|
const [data, setData] = useState<FlaggedListingDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dialogAction, setDialogAction] = useState<FlaggedAction | null>(null);
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchDetail = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await adminApi.getFlaggedListingDetail(id);
|
||||||
|
setData(res);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Không thể tải chi tiết tin đăng');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchDetail();
|
||||||
|
}, [fetchDetail]);
|
||||||
|
|
||||||
|
const runAction = async () => {
|
||||||
|
if (!dialogAction) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await adminApi.moderateFlaggedListings({
|
||||||
|
listingIds: [id],
|
||||||
|
action: dialogAction,
|
||||||
|
note: note || undefined,
|
||||||
|
});
|
||||||
|
setDialogAction(null);
|
||||||
|
setNote('');
|
||||||
|
await fetchDetail();
|
||||||
|
} catch (e) {
|
||||||
|
setActionError(e instanceof Error ? e.message : 'Thao tác thất bại');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<RefreshCw className="h-5 w-5 animate-spin text-foreground-muted" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<Link href={'/admin/moderation/flagged' as never} className="text-sm text-primary hover:underline">
|
||||||
|
<ArrowLeft className="mr-1 inline h-4 w-4" />
|
||||||
|
Quay lại danh sách
|
||||||
|
</Link>
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||||
|
{error ?? 'Không tìm thấy tin đăng'}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void fetchDetail()}>
|
||||||
|
Thử lại
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { listing, flags, totalReports, distinctReasons } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Link href={'/admin/moderation/flagged' as never} className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Quay lại danh sách
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{actionError && (
|
||||||
|
<div role="alert" className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||||
|
{actionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-heading-md font-semibold">{listing.title}</h1>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm text-foreground-muted">
|
||||||
|
<StatusChip status={listing.status.toLowerCase() as 'pending'} hideDot />
|
||||||
|
<span>·</span>
|
||||||
|
<span>{listing.propertyType}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{listing.transactionType === 'SALE' ? 'Bán' : 'Cho thuê'}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="font-mono tabular-nums">
|
||||||
|
{new Intl.NumberFormat('vi-VN').format(listing.priceVND)} ₫
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" onClick={() => { setDialogAction('dismiss_flags'); setNote(''); setActionError(null); }}>
|
||||||
|
<Flag className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Bỏ qua báo cáo
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" onClick={() => { setDialogAction('suspend_listing'); setNote(''); setActionError(null); }}>
|
||||||
|
<ShieldAlert className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Tạm ngưng
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setDialogAction('warn_seller'); setNote(''); setActionError(null); }}>
|
||||||
|
<UserX className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Cảnh báo người bán
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardContent className="flex flex-col gap-3 p-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase text-foreground-muted">Ảnh tin đăng</h2>
|
||||||
|
{listing.photos.length === 0 ? (
|
||||||
|
<p className="text-sm text-foreground-dim">Không có ảnh.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{listing.photos.map((src, i) => (
|
||||||
|
<img
|
||||||
|
key={`${src}-${i}`}
|
||||||
|
src={src}
|
||||||
|
alt={`Ảnh ${i + 1} của ${listing.title}`}
|
||||||
|
className="aspect-video w-full rounded-md object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col gap-3 p-4 text-sm">
|
||||||
|
<h2 className="text-sm font-semibold uppercase text-foreground-muted">Người bán</h2>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{listing.seller.fullName}</div>
|
||||||
|
<div className="text-foreground-muted">{listing.seller.email ?? '—'}</div>
|
||||||
|
<div className="font-mono text-foreground-muted">{listing.seller.phone}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between border-t border-border pt-2 text-xs">
|
||||||
|
<span className="text-foreground-muted">Số báo cáo</span>
|
||||||
|
<span className="font-mono tabular-nums">{totalReports}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{distinctReasons.map((r) => (
|
||||||
|
<span key={r} className="rounded-pill bg-background-surface px-2 py-0.5 text-xs text-foreground-muted ring-1 ring-inset ring-border">
|
||||||
|
{REASON_LABELS[r] ?? r}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col gap-3 p-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase text-foreground-muted">
|
||||||
|
Danh sách báo cáo ({flags.length})
|
||||||
|
</h2>
|
||||||
|
{flags.length === 0 ? (
|
||||||
|
<p className="text-sm text-foreground-dim">Chưa có báo cáo nào.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{flags.map((f) => (
|
||||||
|
<li key={f.id} className="flex flex-col gap-1 py-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="font-medium">{f.reporter.fullName}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-pill bg-background-surface px-2 py-0.5 text-xs text-foreground-muted ring-1 ring-inset ring-border">
|
||||||
|
{REASON_LABELS[f.reason] ?? f.reason}
|
||||||
|
</span>
|
||||||
|
<StatusChip status={f.status.toLowerCase() as 'pending'} hideDot />
|
||||||
|
<span className="font-mono text-xs text-foreground-dim">
|
||||||
|
{new Date(f.createdAt).toLocaleString('vi-VN')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{f.description && (
|
||||||
|
<p className="text-sm text-foreground-muted">{f.description}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={!!dialogAction} onOpenChange={(o) => !o && setDialogAction(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{dialogAction ? ACTION_LABELS[dialogAction].title : ''}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
Thao tác sẽ áp dụng cho tin đăng này.
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Ghi chú (không bắt buộc)"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
aria-label="Ghi chú kiểm duyệt"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogAction(null)} disabled={actionLoading}>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={dialogAction && ACTION_LABELS[dialogAction].danger ? 'destructive' : 'default'}
|
||||||
|
onClick={runAction}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
{actionLoading ? 'Đang xử lý...' : dialogAction ? ACTION_LABELS[dialogAction].verb : ''}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
416
apps/web/app/[locale]/(admin)/admin/moderation/flagged/page.tsx
Normal file
416
apps/web/app/[locale]/(admin)/admin/moderation/flagged/page.tsx
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AlertTriangle, ChevronLeft, ChevronRight, Flag, RefreshCw, ShieldAlert, UserX, X } from 'lucide-react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { StatusChip } from '@/components/design-system/status-chip';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
|
import {
|
||||||
|
adminApi,
|
||||||
|
type FlaggedAction,
|
||||||
|
type FlaggedListingItem,
|
||||||
|
type FlaggedSortBy,
|
||||||
|
type FlagReason,
|
||||||
|
type PaginatedFlaggedListingsResult,
|
||||||
|
} from '@/lib/admin-api';
|
||||||
|
|
||||||
|
const REASON_LABELS: Record<FlagReason, string> = {
|
||||||
|
SCAM: 'Lừa đảo',
|
||||||
|
DUPLICATE: 'Trùng lặp',
|
||||||
|
WRONG_INFO: 'Sai thông tin',
|
||||||
|
ALREADY_SOLD: 'Đã bán',
|
||||||
|
INAPPROPRIATE: 'Không phù hợp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<FlaggedAction, { title: string; verb: string; danger: boolean }> = {
|
||||||
|
dismiss_flags: { title: 'Bỏ qua báo cáo', verb: 'Bỏ qua', danger: false },
|
||||||
|
suspend_listing: { title: 'Tạm ngưng tin đăng', verb: 'Tạm ngưng', danger: true },
|
||||||
|
warn_seller: { title: 'Cảnh báo người bán', verb: 'Cảnh báo', danger: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const LISTING_STATUS_OPTIONS = ['ACTIVE', 'PENDING_REVIEW', 'REJECTED', 'EXPIRED', 'SOLD'];
|
||||||
|
|
||||||
|
export default function AdminFlaggedListingsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const page = Math.max(1, Number(searchParams.get('page') ?? '1') || 1);
|
||||||
|
const reason = (searchParams.get('reason') as FlagReason | null) ?? '';
|
||||||
|
const listingStatus = searchParams.get('listingStatus') ?? '';
|
||||||
|
const dateFrom = searchParams.get('dateFrom') ?? '';
|
||||||
|
const dateTo = searchParams.get('dateTo') ?? '';
|
||||||
|
const minFlagCount = searchParams.get('minFlagCount') ?? '';
|
||||||
|
const sortBy = (searchParams.get('sortBy') as FlaggedSortBy | null) ?? 'flagCount';
|
||||||
|
|
||||||
|
const [result, setResult] = useState<PaginatedFlaggedListingsResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [dialogAction, setDialogAction] = useState<FlaggedAction | null>(null);
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const setParam = useCallback(
|
||||||
|
(patch: Record<string, string | number | undefined>) => {
|
||||||
|
const next = new URLSearchParams(searchParams.toString());
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (v === undefined || v === '' || v === null) next.delete(k);
|
||||||
|
else next.set(k, String(v));
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).some((k) => k !== 'page')) next.delete('page');
|
||||||
|
router.replace(`?${next.toString()}`, { scroll: false });
|
||||||
|
},
|
||||||
|
[router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getFlaggedListings({
|
||||||
|
page,
|
||||||
|
limit: 20,
|
||||||
|
reason: reason || undefined,
|
||||||
|
listingStatus: listingStatus || undefined,
|
||||||
|
dateFrom: dateFrom || undefined,
|
||||||
|
dateTo: dateTo || undefined,
|
||||||
|
flagCountMin: minFlagCount ? Number(minFlagCount) : undefined,
|
||||||
|
sortBy,
|
||||||
|
sortOrder: 'desc',
|
||||||
|
});
|
||||||
|
setResult(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Không thể tải danh sách tin bị báo cáo');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, reason, listingStatus, dateFrom, dateTo, minFlagCount, sortBy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchData();
|
||||||
|
setSelected(new Set());
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const totalPages = useMemo(
|
||||||
|
() => (result ? Math.max(1, Math.ceil(result.total / result.limit)) : 1),
|
||||||
|
[result],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleSelect = (id: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (!result) return;
|
||||||
|
if (selected.size === result.items.length) setSelected(new Set());
|
||||||
|
else setSelected(new Set(result.items.map((i) => i.listingId)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openBulk = (action: FlaggedAction) => {
|
||||||
|
setActionError(null);
|
||||||
|
setNote('');
|
||||||
|
setDialogAction(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runBulk = async () => {
|
||||||
|
if (!dialogAction || selected.size === 0) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await adminApi.moderateFlaggedListings({
|
||||||
|
listingIds: Array.from(selected),
|
||||||
|
action: dialogAction,
|
||||||
|
note: note || undefined,
|
||||||
|
});
|
||||||
|
setDialogAction(null);
|
||||||
|
setNote('');
|
||||||
|
setSelected(new Set());
|
||||||
|
await fetchData();
|
||||||
|
} catch (e) {
|
||||||
|
setActionError(e instanceof Error ? e.message : 'Thao tác thất bại');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
router.replace('?', { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: FlaggedListingItem[] = result?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{actionError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex items-center justify-between rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive"
|
||||||
|
>
|
||||||
|
<span>{actionError}</span>
|
||||||
|
<button onClick={() => setActionError(null)} aria-label="Đóng thông báo lỗi">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-heading-md font-semibold tracking-tight">Tin đăng bị báo cáo</h1>
|
||||||
|
<p className="text-sm text-foreground-muted">Xem xét và xử lý các tin đăng bị người dùng báo cáo.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void fetchData()} disabled={loading}>
|
||||||
|
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Làm mới
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-3 p-4 md:grid-cols-6">
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-foreground-muted">
|
||||||
|
<span>Lý do</span>
|
||||||
|
<select
|
||||||
|
aria-label="Lọc theo lý do"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setParam({ reason: e.target.value })}
|
||||||
|
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Tất cả</option>
|
||||||
|
{(Object.keys(REASON_LABELS) as FlagReason[]).map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{REASON_LABELS[r]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-foreground-muted">
|
||||||
|
<span>Trạng thái tin</span>
|
||||||
|
<select
|
||||||
|
aria-label="Lọc theo trạng thái tin"
|
||||||
|
value={listingStatus}
|
||||||
|
onChange={(e) => setParam({ listingStatus: e.target.value })}
|
||||||
|
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Tất cả</option>
|
||||||
|
{LISTING_STATUS_OPTIONS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-foreground-muted">
|
||||||
|
<span>Từ ngày</span>
|
||||||
|
<Input type="date" value={dateFrom} onChange={(e) => setParam({ dateFrom: e.target.value })} aria-label="Lọc từ ngày" />
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-foreground-muted">
|
||||||
|
<span>Đến ngày</span>
|
||||||
|
<Input type="date" value={dateTo} onChange={(e) => setParam({ dateTo: e.target.value })} aria-label="Lọc đến ngày" />
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-foreground-muted">
|
||||||
|
<span>Số báo cáo tối thiểu</span>
|
||||||
|
<Input type="number" min={0} value={minFlagCount} onChange={(e) => setParam({ minFlagCount: e.target.value })} aria-label="Số báo cáo tối thiểu" />
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-foreground-muted">
|
||||||
|
<span>Sắp xếp</span>
|
||||||
|
<select
|
||||||
|
aria-label="Sắp xếp theo"
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setParam({ sortBy: e.target.value })}
|
||||||
|
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="flagCount">Số báo cáo</option>
|
||||||
|
<option value="latestFlagAt">Báo cáo gần nhất</option>
|
||||||
|
<option value="createdAt">Ngày tạo tin</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-end md:col-span-6">
|
||||||
|
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||||
|
Xoá bộ lọc
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="shadow-elevation-1 overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<RefreshCw className="h-5 w-5 animate-spin text-foreground-muted" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-48 flex-col items-center justify-center gap-2">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void fetchData()}>
|
||||||
|
Thử lại
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="flex h-48 flex-col items-center justify-center gap-2">
|
||||||
|
<Flag className="h-8 w-8 text-foreground-dim" />
|
||||||
|
<p className="text-sm text-foreground-muted">Không có tin đăng nào bị báo cáo.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-sticky-header bg-background-elevated">
|
||||||
|
<TableRow className="border-b border-border-strong">
|
||||||
|
<TableHead className="w-9 px-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.size === rows.length && rows.length > 0}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
aria-label="Chọn tất cả tin"
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-heading-xs uppercase text-foreground-muted">Tin đăng</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell text-heading-xs uppercase text-foreground-muted">Người đăng</TableHead>
|
||||||
|
<TableHead className="text-heading-xs uppercase text-foreground-muted text-center">Số báo cáo</TableHead>
|
||||||
|
<TableHead className="hidden lg:table-cell text-heading-xs uppercase text-foreground-muted">Lý do</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell text-heading-xs uppercase text-foreground-muted">Báo cáo gần nhất</TableHead>
|
||||||
|
<TableHead className="text-heading-xs uppercase text-foreground-muted">Trạng thái</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((item) => (
|
||||||
|
<TableRow key={item.listingId} className="h-row-compact border-b border-border hover:bg-background-surface">
|
||||||
|
<TableCell className="px-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(item.listingId)}
|
||||||
|
onChange={() => toggleSelect(item.listingId)}
|
||||||
|
aria-label={`Chọn ${item.propertyTitle}`}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link href={`/admin/moderation/flagged/${item.listingId}` as never} className="font-medium text-primary hover:underline">
|
||||||
|
{item.propertyTitle}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell text-sm text-foreground-muted">{item.sellerName}</TableCell>
|
||||||
|
<TableCell className="text-center font-mono tabular-nums">{item.totalReports}</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.reasons.map((r) => (
|
||||||
|
<span key={r} className="rounded-pill bg-background-surface px-2 py-0.5 text-xs text-foreground-muted ring-1 ring-inset ring-border">
|
||||||
|
{REASON_LABELS[r] ?? r}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell font-mono text-data-sm text-foreground-dim">
|
||||||
|
{item.latestReportAt ? new Date(item.latestReportAt).toLocaleString('vi-VN') : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusChip status={item.status.toLowerCase() as 'pending'} hideDot />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t border-border px-4 py-2.5">
|
||||||
|
<span className="font-mono text-data-sm text-foreground-muted">
|
||||||
|
Trang {page}/{totalPages} · {result?.total ?? 0} tin
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="outline" size="icon" disabled={page <= 1} onClick={() => setParam({ page: page - 1 })} aria-label="Trang trước">
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" disabled={page >= totalPages} onClick={() => setParam({ page: page + 1 })} aria-label="Trang sau">
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Thanh thao tác hàng loạt"
|
||||||
|
className="sticky bottom-4 z-dropdown flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border-strong bg-background-elevated px-4 py-2.5 shadow-elevation-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Đã chọn <span className="font-mono tabular-nums">{selected.size}</span> tin
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" onClick={() => openBulk('dismiss_flags')}>
|
||||||
|
<Flag className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Bỏ qua báo cáo
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" onClick={() => openBulk('suspend_listing')}>
|
||||||
|
<ShieldAlert className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Tạm ngưng tin
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => openBulk('warn_seller')}>
|
||||||
|
<UserX className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Cảnh báo người bán
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
|
||||||
|
Bỏ chọn
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={!!dialogAction} onOpenChange={(o) => !o && setDialogAction(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{dialogAction ? ACTION_LABELS[dialogAction].title : ''}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
Thao tác sẽ áp dụng cho {selected.size} tin đăng đã chọn.
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Ghi chú (không bắt buộc)"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
aria-label="Ghi chú kiểm duyệt"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogAction(null)} disabled={actionLoading}>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={dialogAction && ACTION_LABELS[dialogAction].danger ? 'destructive' : 'default'}
|
||||||
|
onClick={runBulk}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
{actionLoading ? 'Đang xử lý...' : `${dialogAction ? ACTION_LABELS[dialogAction].verb : ''} ${selected.size} tin`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -242,8 +242,107 @@ export const adminApi = {
|
|||||||
|
|
||||||
provisionParkOperator: (body: ProvisionParkOperatorPayload) =>
|
provisionParkOperator: (body: ProvisionParkOperatorPayload) =>
|
||||||
apiClient.post<ProvisionAccountResult>('/admin/accounts/park-operators', body),
|
apiClient.post<ProvisionAccountResult>('/admin/accounts/park-operators', body),
|
||||||
|
|
||||||
|
// ── Flagged listings (user reports) ──
|
||||||
|
getFlaggedListings: (params: FlaggedListingsQueryParams = {}) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params.page) q.set('page', String(params.page));
|
||||||
|
if (params.limit) q.set('limit', String(params.limit));
|
||||||
|
if (params.reason) q.set('reason', params.reason);
|
||||||
|
if (params.dateFrom) q.set('dateFrom', params.dateFrom);
|
||||||
|
if (params.dateTo) q.set('dateTo', params.dateTo);
|
||||||
|
if (params.listingStatus) q.set('listingStatus', params.listingStatus);
|
||||||
|
if (params.flagCountMin !== undefined) q.set('flagCountMin', String(params.flagCountMin));
|
||||||
|
if (params.sortBy) q.set('sortBy', params.sortBy);
|
||||||
|
if (params.sortOrder) q.set('sortOrder', params.sortOrder);
|
||||||
|
const qs = q.toString();
|
||||||
|
return apiClient.get<PaginatedFlaggedListingsResult>(
|
||||||
|
`/admin/listings/flagged${qs ? `?${qs}` : ''}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
getFlaggedListingDetail: (listingId: string) =>
|
||||||
|
apiClient.get<FlaggedListingDetail>(`/admin/listings/${listingId}/flags`),
|
||||||
|
|
||||||
|
moderateFlaggedListings: (body: ModerateFlaggedListingsPayload) =>
|
||||||
|
apiClient.post<ModerateFlaggedListingsResult>('/admin/listings/moderate', body),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Flagged listings types ──
|
||||||
|
|
||||||
|
export type FlagReason = 'SCAM' | 'DUPLICATE' | 'WRONG_INFO' | 'ALREADY_SOLD' | 'INAPPROPRIATE';
|
||||||
|
export type FlagStatus = 'PENDING' | 'REVIEWED' | 'DISMISSED';
|
||||||
|
export type FlaggedAction = 'dismiss_flags' | 'suspend_listing' | 'warn_seller';
|
||||||
|
export type FlaggedSortBy = 'flagCount' | 'latestFlagAt' | 'createdAt';
|
||||||
|
|
||||||
|
export interface FlaggedListingsQueryParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
reason?: FlagReason;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
listingStatus?: string;
|
||||||
|
flagCountMin?: number;
|
||||||
|
sortBy?: FlaggedSortBy;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlaggedListingItem {
|
||||||
|
listingId: string;
|
||||||
|
propertyTitle: string;
|
||||||
|
sellerName: string;
|
||||||
|
status: string;
|
||||||
|
totalReports: number;
|
||||||
|
reasons: FlagReason[];
|
||||||
|
latestReportAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedFlaggedListingsResult {
|
||||||
|
items: FlaggedListingItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlaggedListingDetailFlag {
|
||||||
|
id: string;
|
||||||
|
reason: FlagReason;
|
||||||
|
description: string | null;
|
||||||
|
status: FlagStatus;
|
||||||
|
reporter: { id: string; fullName: string };
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlaggedListingDetail {
|
||||||
|
listing: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
priceVND: number;
|
||||||
|
propertyType: string;
|
||||||
|
transactionType: string;
|
||||||
|
photos: string[];
|
||||||
|
createdAt: string;
|
||||||
|
seller: { id: string; fullName: string; email: string | null; phone: string };
|
||||||
|
};
|
||||||
|
flags: FlaggedListingDetailFlag[];
|
||||||
|
totalReports: number;
|
||||||
|
distinctReasons: FlagReason[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModerateFlaggedListingsPayload {
|
||||||
|
listingIds: string[];
|
||||||
|
action: FlaggedAction;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModerateFlaggedListingsResult {
|
||||||
|
processed: number;
|
||||||
|
skipped: number;
|
||||||
|
succeeded?: string[];
|
||||||
|
failed?: Array<{ listingId: string; reason: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProvisionDeveloperPayload {
|
export interface ProvisionDeveloperPayload {
|
||||||
phone: string;
|
phone: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
126
e2e/web/admin-moderation-flagged.spec.ts
Normal file
126
e2e/web/admin-moderation-flagged.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const mockFlaggedList = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
listingId: 'l-flagged-1',
|
||||||
|
propertyTitle: 'Căn hộ bị báo cáo',
|
||||||
|
sellerName: 'Nguyen Van A',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
totalReports: 5,
|
||||||
|
reasons: ['SCAM', 'DUPLICATE'],
|
||||||
|
latestReportAt: '2026-04-20T03:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
listingId: 'l-flagged-2',
|
||||||
|
propertyTitle: 'Nhà phố đáng ngờ',
|
||||||
|
sellerName: 'Tran Thi B',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
totalReports: 3,
|
||||||
|
reasons: ['WRONG_INFO'],
|
||||||
|
latestReportAt: '2026-04-21T03:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFlaggedDetail = {
|
||||||
|
listing: {
|
||||||
|
id: 'l-flagged-1',
|
||||||
|
title: 'Căn hộ bị báo cáo',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
priceVND: 3500000000,
|
||||||
|
propertyType: 'APARTMENT',
|
||||||
|
transactionType: 'SALE',
|
||||||
|
photos: ['https://example.test/photo-1.jpg'],
|
||||||
|
createdAt: '2026-04-15T03:00:00.000Z',
|
||||||
|
seller: { id: 's1', fullName: 'Nguyen Van A', email: 'a@example.com', phone: '0900000001' },
|
||||||
|
},
|
||||||
|
flags: [
|
||||||
|
{
|
||||||
|
id: 'f1',
|
||||||
|
reason: 'SCAM',
|
||||||
|
description: 'Giá rẻ bất thường',
|
||||||
|
status: 'PENDING',
|
||||||
|
reporter: { id: 'r1', fullName: 'Le Van C' },
|
||||||
|
createdAt: '2026-04-20T03:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalReports: 1,
|
||||||
|
distinctReasons: ['SCAM'],
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('Admin flagged listings dashboard', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route('**/admin/listings/flagged**', (route) => {
|
||||||
|
if (route.request().method() === 'GET') {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(mockFlaggedList),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
await page.route('**/admin/listings/*/flags', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(mockFlaggedDetail),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/admin/listings/moderate', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ processed: 1, skipped: 0 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list: filter + bulk dismiss happy path', async ({ page }) => {
|
||||||
|
await page.goto('/admin/moderation/flagged');
|
||||||
|
|
||||||
|
await expect(page.getByText('Căn hộ bị báo cáo')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText('Nhà phố đáng ngờ')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel('Lọc theo lý do').selectOption('SCAM');
|
||||||
|
await expect(page).toHaveURL(/reason=SCAM/);
|
||||||
|
|
||||||
|
await page.getByLabel('Chọn Căn hộ bị báo cáo').check();
|
||||||
|
await page.getByRole('button', { name: 'Bỏ qua báo cáo' }).first().click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: /^Bỏ qua \d+ tin$/ }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detail: renders reporters and runs suspend action', async ({ page }) => {
|
||||||
|
await page.goto('/admin/moderation/flagged/l-flagged-1');
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Căn hộ bị báo cáo' })).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText('Le Van C')).toBeVisible();
|
||||||
|
await expect(page.getByText('Giá rẻ bất thường')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Tạm ngưng' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await page.getByLabel('Ghi chú kiểm duyệt').fill('Có dấu hiệu lừa đảo');
|
||||||
|
await page.getByRole('button', { name: 'Tạm ngưng', exact: true }).last().click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty state when no flagged listings', async ({ page }) => {
|
||||||
|
await page.route('**/admin/listings/flagged**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [], total: 0, page: 1, limit: 20 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.goto('/admin/moderation/flagged');
|
||||||
|
await expect(page.getByText('Không có tin đăng nào bị báo cáo.')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user