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:
Ho Ngoc Hai
2026-04-24 12:46:54 +07:00
parent deb99e14fb
commit 4f19c97fd0
4 changed files with 910 additions and 0 deletions

View File

@@ -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 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 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>
);
}

View 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 xử 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> 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 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"> 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>
);
}

View File

@@ -242,8 +242,107 @@ export const adminApi = {
provisionParkOperator: (body: ProvisionParkOperatorPayload) =>
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 {
phone: string;
password: string;

View 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 });
});
});