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:
sdarbinyan
2026-07-18 22:01:16 +04:00
parent 097927f124
commit 3c9b425203
26 changed files with 1369 additions and 1 deletions

View File

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