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