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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user