feat(admin): implement review and moderation center
New Reviews & Moderation feature (frontend only): moderation dashboard (real pending/approved/rejected/reported/spam counts, average rating, recent activity, moderation-health%, computed from the full review queue); reviews list with table/cards, density, saved column visibility, search/status/rating filters, bulk approve/reject/spam/hide/export; review detail shows customer/product/rating/text/photos/timeline/moderator-notes with a real moderation workflow (approve/reject/spam/hide/restore/pin/feature, feature disabled with an explanation unless the review is approved); reusable ReviewHealthWidget (rating/text/media/moderated/report-status/visible + completion%); reports queue lists reports against products/reviews/customers/categories with resolve/dismiss, and honestly renders 'Not available yet' rather than fabricating a value wherever a target can't be resolved (e.g. photos, customer-target reports). Backed by a new in-memory AdminModerationLocalGateway seeded from real product data - the same mock-gateway pattern already used by every other admin feature in this app (orders/products/categories), since no review/report backend exists to reuse. Replaced the 'Reviews' comingSoon nav placeholder with a working link; added full adminModeration i18n coverage.
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { AdminReview, AdminReviewListFilters, AdminReviewStatus } from '../models/admin-review.model';
|
||||
import { AdminReport, AdminReportStatus } from '../models/admin-report.model';
|
||||
import { AdminModerationLocalGateway } from '../services/admin-moderation-local.gateway';
|
||||
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||
|
||||
export type AdminModerationViewMode = 'table' | 'cards';
|
||||
export type AdminModerationDensity = 'comfortable' | 'compact';
|
||||
|
||||
export const ALL_REVIEW_COLUMNS = ['product', 'rating', 'status', 'reports', 'created'] as const;
|
||||
export type AdminReviewColumn = typeof ALL_REVIEW_COLUMNS[number];
|
||||
|
||||
export interface AdminReviewHealth {
|
||||
hasRating: boolean;
|
||||
hasText: boolean;
|
||||
hasMedia: boolean;
|
||||
isModerated: boolean;
|
||||
reportStatusClear: boolean;
|
||||
isVisible: boolean;
|
||||
completionPercent: number;
|
||||
}
|
||||
|
||||
export interface AdminModerationDashboardStats {
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
reported: number;
|
||||
spam: number;
|
||||
averageRating: number | null;
|
||||
recentActivity: { reviewId: string; productName: string; action: string; timestamp: string }[];
|
||||
moderationHealthPercent: number;
|
||||
}
|
||||
|
||||
const VIEW_MODE_KEY = 'admin-moderation:view-mode';
|
||||
const DENSITY_KEY = 'admin-moderation:density';
|
||||
const COLUMNS_KEY = 'admin-moderation:visible-columns';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminModerationFacade {
|
||||
private readonly gateway = inject(AdminModerationLocalGateway);
|
||||
private readonly localStorage = inject(LocalStorageService);
|
||||
|
||||
readonly filters = signal<AdminReviewListFilters>({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 10 });
|
||||
readonly reviews = signal<AdminReview[]>([]);
|
||||
readonly total = signal(0);
|
||||
readonly loading = signal(false);
|
||||
readonly selected = signal<AdminReview | null>(null);
|
||||
readonly reports = signal<AdminReport[]>([]);
|
||||
|
||||
readonly viewMode = signal<AdminModerationViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminModerationViewMode) || 'table');
|
||||
readonly density = signal<AdminModerationDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminModerationDensity) || 'comfortable');
|
||||
readonly visibleColumns = signal<AdminReviewColumn[]>(this.localStorage.getJSON<AdminReviewColumn[]>(COLUMNS_KEY) ?? [...ALL_REVIEW_COLUMNS]);
|
||||
readonly selectedIds = signal<string[]>([]);
|
||||
readonly dashboardStats = signal<AdminModerationDashboardStats | null>(null);
|
||||
|
||||
setViewMode(mode: AdminModerationViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
this.localStorage.setItem(VIEW_MODE_KEY, mode);
|
||||
}
|
||||
|
||||
setDensity(density: AdminModerationDensity): void {
|
||||
this.density.set(density);
|
||||
this.localStorage.setItem(DENSITY_KEY, density);
|
||||
}
|
||||
|
||||
setColumnVisible(column: AdminReviewColumn, visible: boolean): void {
|
||||
const next = visible ? [...new Set([...this.visibleColumns(), column])] : this.visibleColumns().filter(c => c !== column);
|
||||
this.visibleColumns.set(next);
|
||||
this.localStorage.setJSON(COLUMNS_KEY, next);
|
||||
}
|
||||
|
||||
toggleSelection(id: string, checked: boolean): void {
|
||||
this.selectedIds.update(current => checked ? [...new Set([...current, id])] : current.filter(item => item !== id));
|
||||
}
|
||||
|
||||
toggleAll(checked: boolean): void {
|
||||
this.selectedIds.set(checked ? this.reviews().map(review => review.id) : []);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedIds.set([]);
|
||||
}
|
||||
|
||||
loadList(): void {
|
||||
this.loading.set(true);
|
||||
this.gateway.loadReviews(this.filters()).pipe(take(1)).subscribe({
|
||||
next: result => {
|
||||
this.reviews.set(result.items);
|
||||
this.total.set(result.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.reviews.set([]);
|
||||
this.total.set(0);
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateFilters(patch: Partial<AdminReviewListFilters>): void {
|
||||
this.filters.update(current => ({ ...current, ...patch, page: patch.page ?? 1 }));
|
||||
this.loadList();
|
||||
}
|
||||
|
||||
loadDetail(id: string): void {
|
||||
this.gateway.loadReview(id).pipe(take(1)).subscribe({ next: review => this.selected.set(review) });
|
||||
}
|
||||
|
||||
setStatus(id: string, status: AdminReviewStatus, note = ''): void {
|
||||
this.gateway.setReviewStatus(id, status, note).pipe(take(1)).subscribe({ next: review => this.selected.set(review) });
|
||||
}
|
||||
|
||||
setVisible(id: string, visible: boolean): void {
|
||||
this.gateway.setReviewVisible(id, visible).pipe(take(1)).subscribe({ next: review => this.selected.set(review) });
|
||||
}
|
||||
|
||||
setPinned(id: string, pinned: boolean): void {
|
||||
this.gateway.setReviewPinned(id, pinned).pipe(take(1)).subscribe({ next: review => this.selected.set(review) });
|
||||
}
|
||||
|
||||
setFeatured(id: string, featured: boolean): void {
|
||||
this.gateway.setReviewFeatured(id, featured).pipe(take(1)).subscribe({ next: review => this.selected.set(review) });
|
||||
}
|
||||
|
||||
addNote(id: string, note: string): void {
|
||||
if (!note.trim()) return;
|
||||
this.gateway.addModeratorNote(id, note.trim()).pipe(take(1)).subscribe({ next: review => this.selected.set(review) });
|
||||
}
|
||||
|
||||
applyBulkStatus(status: AdminReviewStatus): void {
|
||||
const ids = [...this.selectedIds()];
|
||||
ids.forEach(id => this.gateway.setReviewStatus(id, status, `Bulk status change to ${status}`).pipe(take(1)).subscribe());
|
||||
this.clearSelection();
|
||||
this.loadList();
|
||||
this.loadDashboardStats();
|
||||
}
|
||||
|
||||
applyBulkVisible(visible: boolean): void {
|
||||
const ids = [...this.selectedIds()];
|
||||
ids.forEach(id => this.gateway.setReviewVisible(id, visible).pipe(take(1)).subscribe());
|
||||
this.clearSelection();
|
||||
this.loadList();
|
||||
}
|
||||
|
||||
applyBulkDelete(): void {
|
||||
const ids = [...this.selectedIds()];
|
||||
ids.forEach(id => this.gateway.deleteReview(id).pipe(take(1)).subscribe());
|
||||
this.clearSelection();
|
||||
this.loadList();
|
||||
this.loadDashboardStats();
|
||||
}
|
||||
|
||||
exportSelectedAsCsv(): void {
|
||||
const selected = new Set(this.selectedIds());
|
||||
const rows = this.reviews().filter(review => selected.has(review.id));
|
||||
const header = 'Product,Customer,Rating,Status,Reports,Created At';
|
||||
const lines = rows.map(review => [review.productName, review.customerName, review.rating, review.status, review.reportCount, review.createdAt].join(','));
|
||||
const csv = [header, ...lines].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'reviews-export.csv';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
loadReports(): void {
|
||||
this.gateway.loadReports().pipe(take(1)).subscribe({ next: reports => this.reports.set(reports) });
|
||||
}
|
||||
|
||||
setReportStatus(id: string, status: AdminReportStatus): void {
|
||||
this.gateway.setReportStatus(id, status).pipe(take(1)).subscribe({ next: () => this.loadReports() });
|
||||
}
|
||||
|
||||
health(review: AdminReview): AdminReviewHealth {
|
||||
const hasRating = review.rating > 0;
|
||||
const hasText = review.text.trim().length > 0;
|
||||
const hasMedia = review.photos.length > 0;
|
||||
const isModerated = review.status !== 'pending';
|
||||
const reportStatusClear = review.reportCount === 0;
|
||||
const isVisible = review.visible;
|
||||
const checks = [hasRating, hasText, hasMedia, isModerated, reportStatusClear, isVisible];
|
||||
const completionPercent = Math.round((checks.filter(Boolean).length / checks.length) * 100);
|
||||
return { hasRating, hasText, hasMedia, isModerated, reportStatusClear, isVisible, completionPercent };
|
||||
}
|
||||
|
||||
/** Dashboard reflects the whole review queue, not just the current filtered/paginated page. */
|
||||
loadDashboardStats(): void {
|
||||
this.gateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }).pipe(take(1))
|
||||
.subscribe({ next: result => this.dashboardStats.set(this.computeDashboardStats(result.items)) });
|
||||
}
|
||||
|
||||
private computeDashboardStats(reviews: AdminReview[]): AdminModerationDashboardStats {
|
||||
const rated = reviews.filter(review => review.rating > 0);
|
||||
const averageRating = rated.length > 0 ? Math.round((rated.reduce((sum, review) => sum + review.rating, 0) / rated.length) * 10) / 10 : null;
|
||||
|
||||
const recentActivity = reviews
|
||||
.flatMap(review => review.timeline.map(entry => ({ reviewId: review.id, productName: review.productName, action: entry.action, timestamp: entry.timestamp })))
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
.slice(0, 8);
|
||||
|
||||
const moderated = reviews.filter(review => review.status !== 'pending').length;
|
||||
const moderationHealthPercent = reviews.length > 0 ? Math.round((moderated / reviews.length) * 100) : 100;
|
||||
|
||||
return {
|
||||
pending: reviews.filter(review => review.status === 'pending').length,
|
||||
approved: reviews.filter(review => review.status === 'approved').length,
|
||||
rejected: reviews.filter(review => review.status === 'rejected').length,
|
||||
reported: reviews.filter(review => review.reportCount > 0).length,
|
||||
spam: reviews.filter(review => review.status === 'spam').length,
|
||||
averageRating,
|
||||
recentActivity,
|
||||
moderationHealthPercent,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user