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) =>
|
||||
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;
|
||||
|
||||
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