From 4f19c97fd0750d42c4460e31007db39981faaef2 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 24 Apr 2026 12:46:54 +0700 Subject: [PATCH] feat(web): admin flagged-listings moderation dashboard (GOO-160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../admin/moderation/flagged/[id]/page.tsx | 269 +++++++++++ .../(admin)/admin/moderation/flagged/page.tsx | 416 ++++++++++++++++++ apps/web/lib/admin-api.ts | 99 +++++ e2e/web/admin-moderation-flagged.spec.ts | 126 ++++++ 4 files changed, 910 insertions(+) create mode 100644 apps/web/app/[locale]/(admin)/admin/moderation/flagged/[id]/page.tsx create mode 100644 apps/web/app/[locale]/(admin)/admin/moderation/flagged/page.tsx create mode 100644 e2e/web/admin-moderation-flagged.spec.ts diff --git a/apps/web/app/[locale]/(admin)/admin/moderation/flagged/[id]/page.tsx b/apps/web/app/[locale]/(admin)/admin/moderation/flagged/[id]/page.tsx new file mode 100644 index 0000000..4e26b0e --- /dev/null +++ b/apps/web/app/[locale]/(admin)/admin/moderation/flagged/[id]/page.tsx @@ -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 = { + 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 = { + 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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [dialogAction, setDialogAction] = useState(null); + const [note, setNote] = useState(''); + const [actionLoading, setActionLoading] = useState(false); + const [actionError, setActionError] = useState(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 ( +
+ +
+ ); + } + + if (error || !data) { + return ( +
+ + + Quay lại danh sách + +
+ {error ?? 'Không tìm thấy tin đăng'} +
+ +
+ ); + } + + const { listing, flags, totalReports, distinctReasons } = data; + + return ( +
+ + + Quay lại danh sách + + + {actionError && ( +
+ {actionError} +
+ )} + +
+
+

{listing.title}

+
+ + · + {listing.propertyType} + · + {listing.transactionType === 'SALE' ? 'Bán' : 'Cho thuê'} + · + + {new Intl.NumberFormat('vi-VN').format(listing.priceVND)} ₫ + +
+
+
+ + + +
+
+ +
+ + +

Ảnh tin đăng

+ {listing.photos.length === 0 ? ( +

Không có ảnh.

+ ) : ( +
+ {listing.photos.map((src, i) => ( + {`Ảnh + ))} +
+ )} +
+
+ + + +

Người bán

+
+
{listing.seller.fullName}
+
{listing.seller.email ?? '—'}
+
{listing.seller.phone}
+
+
+ Số báo cáo + {totalReports} +
+
+ {distinctReasons.map((r) => ( + + {REASON_LABELS[r] ?? r} + + ))} +
+
+
+
+ + + +

+ Danh sách báo cáo ({flags.length}) +

+ {flags.length === 0 ? ( +

Chưa có báo cáo nào.

+ ) : ( +
    + {flags.map((f) => ( +
  • +
    + {f.reporter.fullName} +
    + + {REASON_LABELS[f.reason] ?? f.reason} + + + + {new Date(f.createdAt).toLocaleString('vi-VN')} + +
    +
    + {f.description && ( +

    {f.description}

    + )} +
  • + ))} +
+ )} +
+
+ + !o && setDialogAction(null)}> + + + {dialogAction ? ACTION_LABELS[dialogAction].title : ''} + + + + Thao tác sẽ áp dụng cho tin đăng này. + + + +