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,24 @@
|
||||
<div class="moderation-dashboard">
|
||||
<div class="moderation-dashboard__metrics">
|
||||
<app-dashboard-metric labelKey="adminModeration.status.pending" [value]="stats().pending.toString()" />
|
||||
<app-dashboard-metric labelKey="adminModeration.status.approved" [value]="stats().approved.toString()" />
|
||||
<app-dashboard-metric labelKey="adminModeration.status.rejected" [value]="stats().rejected.toString()" />
|
||||
<app-dashboard-metric labelKey="adminModeration.reportedCount" [value]="stats().reported.toString()" />
|
||||
<app-dashboard-metric labelKey="adminModeration.status.spam" [value]="stats().spam.toString()" />
|
||||
<app-dashboard-metric labelKey="adminModeration.averageRating" [value]="stats().averageRating !== null ? stats().averageRating!.toString() : ('adminModeration.notAvailable' | translate)" />
|
||||
<app-dashboard-metric labelKey="adminModeration.moderationHealth" [value]="stats().moderationHealthPercent + '%'" />
|
||||
</div>
|
||||
|
||||
<app-card padding="sm" class="moderation-dashboard__activity">
|
||||
<h4>{{ 'adminOrders.recentActivity' | translate }}</h4>
|
||||
@if (stats().recentActivity.length === 0) {
|
||||
<p>{{ 'adminOrders.noRecentActivity' | translate }}</p>
|
||||
} @else {
|
||||
<ul>
|
||||
@for (entry of stats().recentActivity; track entry.timestamp + entry.reviewId) {
|
||||
<li>{{ entry.timestamp | date:'short' }} — {{ entry.productName }} — {{ entry.action }}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</app-card>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
.moderation-dashboard { display: grid; gap: 16px; margin-bottom: 8px; }
|
||||
.moderation-dashboard__metrics { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 12px; }
|
||||
@media (max-width: 1100px) { .moderation-dashboard__metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width: 640px) { .moderation-dashboard__metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.moderation-dashboard__activity h4 { margin: 0 0 8px; font-size: 0.95rem; font-weight: 700; }
|
||||
.moderation-dashboard__activity ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); }
|
||||
.moderation-dashboard__activity p { margin: 0; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); }
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { CardComponent } from '../../../../../shared/ui/card/card.component';
|
||||
import { DashboardMetricComponent } from '../../../dashboard/components/dashboard-metric.component';
|
||||
import { AdminModerationDashboardStats } from '../../facade/admin-moderation.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-moderation-dashboard',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe, DatePipe, CardComponent, DashboardMetricComponent],
|
||||
templateUrl: './moderation-dashboard.component.html',
|
||||
styleUrl: './moderation-dashboard.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ModerationDashboardComponent {
|
||||
readonly stats = input.required<AdminModerationDashboardStats>();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="review-health" [class.review-health--compact]="compact()">
|
||||
<div class="review-health__bar"><div class="review-health__bar-fill" [style.width.%]="completionPercent()"></div></div>
|
||||
<span class="review-health__percent">{{ completionPercent() }}%</span>
|
||||
@if (!compact()) {
|
||||
<ul class="review-health__list">
|
||||
@for (item of items(); track item.labelKey) {
|
||||
<li [class.review-health__list-item--done]="item.done">
|
||||
<span aria-hidden="true">{{ item.done ? '✓' : '○' }}</span>
|
||||
<span>{{ item.labelKey | translate }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
.review-health { display: grid; gap: 6px; align-items: center; }
|
||||
.review-health__bar { height: 6px; border-radius: 999px; background: var(--surface-muted, #eef2f0); overflow: hidden; }
|
||||
.review-health__bar-fill { height: 100%; background: var(--brand-primary, #1e8a6e); }
|
||||
.review-health__percent { font-size: 0.75rem; font-weight: 700; color: var(--text-secondary, #5f6e6a); }
|
||||
.review-health__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: 0.78rem; color: var(--text-secondary, #5f6e6a); }
|
||||
.review-health__list li { display: flex; align-items: center; gap: 6px; }
|
||||
.review-health__list-item--done { color: var(--text-primary, #1e3c38); }
|
||||
.review-health--compact { grid-template-columns: 1fr auto; }
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
export interface ReviewHealthItem {
|
||||
labelKey: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/** Reusable checklist + completion meter, fed real per-item booleans from AdminModerationFacade.health() - never invents its own data. */
|
||||
@Component({
|
||||
selector: 'app-review-health-widget',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './review-health-widget.component.html',
|
||||
styleUrl: './review-health-widget.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ReviewHealthWidgetComponent {
|
||||
readonly items = input.required<ReviewHealthItem[]>();
|
||||
readonly completionPercent = input.required<number>();
|
||||
readonly compact = input(false);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type AdminReportTargetType = 'product' | 'review' | 'customer' | 'category' | 'unknown';
|
||||
export type AdminReportStatus = 'open' | 'resolved' | 'dismissed';
|
||||
|
||||
export interface AdminReport {
|
||||
id: string;
|
||||
targetType: AdminReportTargetType;
|
||||
targetId: string;
|
||||
targetLabel: string;
|
||||
reason: string;
|
||||
reporterEmail: string;
|
||||
status: AdminReportStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type AdminReviewStatus = 'pending' | 'approved' | 'rejected' | 'spam';
|
||||
|
||||
export interface AdminReviewTimelineEntry {
|
||||
action: string;
|
||||
actor: string;
|
||||
note: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AdminReview {
|
||||
id: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
rating: number;
|
||||
text: string;
|
||||
photos: string[];
|
||||
status: AdminReviewStatus;
|
||||
visible: boolean;
|
||||
pinned: boolean;
|
||||
featured: boolean;
|
||||
reportCount: number;
|
||||
moderatorNotes: string;
|
||||
timeline: AdminReviewTimelineEntry[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminReviewListFilters {
|
||||
search: string;
|
||||
status: 'all' | AdminReviewStatus;
|
||||
rating: 'all' | number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface AdminReviewsListResult {
|
||||
items: AdminReview[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<section class="admin-reports-card">
|
||||
<header class="toolbar">
|
||||
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminOrders.back' | translate }}</app-button>
|
||||
<h1>{{ 'adminModeration.reportsQueue' | translate }}</h1>
|
||||
</header>
|
||||
|
||||
@if (facade.reports().length === 0) {
|
||||
<app-empty-state [title]="'adminModeration.reportsEmptyTitle' | translate" [description]="'adminModeration.reportsEmptyDescription' | translate" />
|
||||
} @else {
|
||||
<app-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'adminModeration.reportTarget' | translate }}</th>
|
||||
<th>{{ 'adminModeration.reportType' | translate }}</th>
|
||||
<th>{{ 'adminModeration.reportReason' | translate }}</th>
|
||||
<th>{{ 'backoffice.status' | translate }}</th>
|
||||
<th>{{ 'adminOrders.createdAt' | translate }}</th>
|
||||
<th>{{ 'adminProducts.actions' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (report of facade.reports(); track report.id) {
|
||||
<tr>
|
||||
<td>
|
||||
@if (canOpen(report.targetType, report.targetId)) {
|
||||
<button type="button" class="link-button" (click)="openTarget(report.targetType, report.targetId)">{{ report.targetLabel }}</button>
|
||||
} @else {
|
||||
{{ report.targetLabel || ('adminModeration.notAvailable' | translate) }}
|
||||
}
|
||||
</td>
|
||||
<td>{{ ('adminModeration.targetType.' + report.targetType) | translate }}</td>
|
||||
<td>{{ report.reason }}</td>
|
||||
<td><app-badge [variant]="report.status === 'open' ? 'warning' : report.status === 'resolved' ? 'success' : 'neutral'">{{ ('adminModeration.reportStatus.' + report.status) | translate }}</app-badge></td>
|
||||
<td>{{ report.createdAt | date:'short' }}</td>
|
||||
<td class="actions">
|
||||
@if (report.status === 'open') {
|
||||
<app-button variant="secondary" size="sm" (click)="facade.setReportStatus(report.id, 'resolved')">{{ 'adminModeration.resolve' | translate }}</app-button>
|
||||
<app-button variant="ghost" size="sm" (click)="facade.setReportStatus(report.id, 'dismissed')">{{ 'adminModeration.dismiss' | translate }}</app-button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,6 @@
|
||||
.admin-reports-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; }
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; }
|
||||
.toolbar h1 { margin: 0; font-size: 1.25rem; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.link-button { all: unset; cursor: pointer; color: var(--brand-primary, #1e8a6e); font-weight: 600; }
|
||||
.link-button:hover, .link-button:focus-visible { text-decoration: underline; }
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { AdminModerationFacade } from '../facade/admin-moderation.facade';
|
||||
import { AdminReportTargetType } from '../models/admin-report.model';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-reports-list-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent, EmptyStateComponent],
|
||||
templateUrl: './admin-reports-list-page.component.html',
|
||||
styleUrls: ['./admin-reports-list-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AdminReportsListPageComponent {
|
||||
readonly facade = inject(AdminModerationFacade);
|
||||
private readonly router = inject(Router);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
constructor() {
|
||||
this.facade.loadReports();
|
||||
}
|
||||
|
||||
back(): void {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation']);
|
||||
}
|
||||
|
||||
openTarget(targetType: AdminReportTargetType, targetId: string): void {
|
||||
if (!targetId) return;
|
||||
const lang = this.languageService.currentLanguage();
|
||||
if (targetType === 'review') {
|
||||
void this.router.navigate([lang, 'backoffice', 'moderation', targetId]);
|
||||
} else if (targetType === 'product') {
|
||||
void this.router.navigate([lang, 'backoffice', 'products', targetId, 'edit']);
|
||||
}
|
||||
}
|
||||
|
||||
canOpen(targetType: AdminReportTargetType, targetId: string): boolean {
|
||||
return !!targetId && (targetType === 'review' || targetType === 'product');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
@if (facade.selected(); as review) {
|
||||
<main class="review-detail">
|
||||
<header class="toolbar">
|
||||
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminOrders.back' | translate }}</app-button>
|
||||
<h1>{{ review.customerName }} — {{ review.rating }}★</h1>
|
||||
<app-badge [variant]="review.status === 'approved' ? 'success' : review.status === 'pending' ? 'neutral' : review.status === 'spam' ? 'danger' : 'warning'">{{ ('adminModeration.status.' + review.status) | translate }}</app-badge>
|
||||
@if (review.reportCount > 0) {
|
||||
<app-badge variant="danger">{{ review.reportCount }} {{ 'adminModeration.reportsColumn' | translate }}</app-badge>
|
||||
}
|
||||
</header>
|
||||
|
||||
<section class="grid two">
|
||||
<div class="card">
|
||||
<h3>{{ 'adminModeration.customer' | translate }}</h3>
|
||||
<p>{{ review.customerName }}</p>
|
||||
<p>{{ review.customerEmail }}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>{{ 'adminModeration.product' | translate }}</h3>
|
||||
<p>{{ review.productName }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>{{ 'adminModeration.reviewText' | translate }}</h3>
|
||||
<p>{{ review.text }}</p>
|
||||
<h4>{{ 'adminModeration.photos' | translate }}</h4>
|
||||
@if (review.photos.length === 0) {
|
||||
<p class="muted">{{ 'adminModeration.notAvailable' | translate }}</p>
|
||||
} @else {
|
||||
<div class="photo-row">
|
||||
@for (photo of review.photos; track photo) { <img [src]="photo" alt="" loading="lazy" /> }
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>{{ 'adminModeration.workflow' | translate }}</h3>
|
||||
<div class="workflow-actions">
|
||||
<app-button variant="secondary" size="sm" [disabled]="review.status === 'approved'" (click)="facade.setStatus(review.id, 'approved')">{{ 'adminModeration.approve' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" [disabled]="review.status === 'rejected'" (click)="facade.setStatus(review.id, 'rejected')">{{ 'adminModeration.reject' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" [disabled]="review.status === 'spam'" (click)="facade.setStatus(review.id, 'spam')">{{ 'adminModeration.markSpam' | translate }}</app-button>
|
||||
@if (review.visible) {
|
||||
<app-button variant="secondary" size="sm" (click)="facade.setVisible(review.id, false)">{{ 'adminModeration.hide' | translate }}</app-button>
|
||||
} @else {
|
||||
<app-button variant="secondary" size="sm" (click)="facade.setVisible(review.id, true)">{{ 'adminModeration.restore' | translate }}</app-button>
|
||||
}
|
||||
<app-button variant="secondary" size="sm" (click)="facade.setPinned(review.id, !review.pinned)">{{ (review.pinned ? 'adminModeration.unpin' : 'adminModeration.pin') | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" [disabled]="review.status !== 'approved'" [attr.title]="review.status !== 'approved' ? ('adminModeration.featureDisabledHint' | translate) : null" (click)="facade.setFeatured(review.id, !review.featured)">{{ (review.featured ? 'adminModeration.unfeature' : 'adminModeration.feature') | translate }}</app-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>{{ 'adminOrders.timeline' | translate }}</h3>
|
||||
<app-order-timeline [entries]="timelineEntries()" />
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>{{ 'adminModeration.moderatorNotes' | translate }}</h3>
|
||||
@for (line of review.moderatorNotes.split('\n'); track $index) { @if (line) { <p>{{ line }}</p> } }
|
||||
<textarea rows="2" [ngModel]="noteDraft()" (ngModelChange)="noteDraft.set($event)"></textarea>
|
||||
<app-button variant="secondary" size="sm" (click)="submitNote(review.id)">{{ 'adminOrders.addNote' | translate }}</app-button>
|
||||
</section>
|
||||
</main>
|
||||
} @else {
|
||||
<p>{{ 'common.loading' | translate }}</p>
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.review-detail { max-width: 1000px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.toolbar h1 { margin: 0; font-size: 1.25rem; }
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.card { border: 1px solid var(--border-color, #d3dad9); border-radius: 12px; padding: 14px; display: grid; gap: 6px; }
|
||||
.card h3 { margin: 0 0 6px; }
|
||||
.card p { margin: 0; }
|
||||
.muted { color: var(--text-secondary, #5f6e6a); font-size: 0.85rem; }
|
||||
.photo-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.photo-row img { width: 72px; height: 72px; object-fit: cover; border-radius: 8px; }
|
||||
.workflow-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 8px; font: inherit; }
|
||||
@media (max-width: 700px) { .grid.two { grid-template-columns: 1fr; } }
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AdminModerationFacade } from '../facade/admin-moderation.facade';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { OrderTimelineComponent, OrderTimelineEntry } from '../../orders/components/order-timeline/order-timeline.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-review-detail-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent],
|
||||
templateUrl: './admin-review-detail-page.component.html',
|
||||
styleUrls: ['./admin-review-detail-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AdminReviewDetailPageComponent {
|
||||
readonly facade = inject(AdminModerationFacade);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
readonly noteDraft = signal('');
|
||||
|
||||
constructor() {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (id) {
|
||||
this.facade.loadDetail(id);
|
||||
}
|
||||
}
|
||||
|
||||
timelineEntries(): OrderTimelineEntry[] {
|
||||
const review = this.facade.selected();
|
||||
return review ? review.timeline.map(entry => ({ timestamp: entry.timestamp, note: `${entry.action}${entry.note ? ' — ' + entry.note : ''} (${entry.actor})` })) : [];
|
||||
}
|
||||
|
||||
back(): void {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation']);
|
||||
}
|
||||
|
||||
submitNote(id: string): void {
|
||||
this.facade.addNote(id, this.noteDraft());
|
||||
this.noteDraft.set('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<section class="admin-reviews-card">
|
||||
@if (facade.dashboardStats(); as stats) {
|
||||
<app-moderation-dashboard [stats]="stats" />
|
||||
}
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminModeration.search' | translate" />
|
||||
<select [attr.aria-label]="'backoffice.status' | translate" [ngModel]="facade.filters().status" (ngModelChange)="facade.updateFilters({ status: $event })">
|
||||
@for (status of statuses; track status) {
|
||||
<option [value]="status">{{ status === 'all' ? ('adminModeration.allStatuses' | translate) : ('adminModeration.status.' + status | translate) }}</option>
|
||||
}
|
||||
</select>
|
||||
<select [attr.aria-label]="'adminModeration.rating' | translate" [ngModel]="facade.filters().rating" (ngModelChange)="facade.updateFilters({ rating: $event })">
|
||||
@for (rating of ratings; track rating) {
|
||||
<option [ngValue]="rating">{{ rating === 'all' ? ('adminModeration.allRatings' | translate) : rating + '★' }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="toolbar__view-controls">
|
||||
<div class="view-toggle" role="group" [attr.aria-label]="'adminProducts.viewMode' | translate">
|
||||
<app-button variant="ghost" size="sm" [attr.aria-pressed]="facade.viewMode() === 'table'" (click)="facade.setViewMode('table')">{{ 'adminProducts.viewTable' | translate }}</app-button>
|
||||
<app-button variant="ghost" size="sm" [attr.aria-pressed]="facade.viewMode() === 'cards'" (click)="facade.setViewMode('cards')">{{ 'adminProducts.viewGrid' | translate }}</app-button>
|
||||
</div>
|
||||
<div class="view-toggle" role="group" [attr.aria-label]="'adminProducts.density' | translate">
|
||||
<app-button variant="ghost" size="sm" [attr.aria-pressed]="facade.density() === 'comfortable'" (click)="facade.setDensity('comfortable')">{{ 'adminProducts.densityComfortable' | translate }}</app-button>
|
||||
<app-button variant="ghost" size="sm" [attr.aria-pressed]="facade.density() === 'compact'" (click)="facade.setDensity('compact')">{{ 'adminProducts.densityCompact' | translate }}</app-button>
|
||||
</div>
|
||||
<app-button variant="ghost" size="sm" (click)="columnsPanelOpen.set(!columnsPanelOpen())">{{ 'adminProducts.columns' | translate }}</app-button>
|
||||
<app-button variant="secondary" (click)="viewReports()">{{ 'adminModeration.reportsQueue' | translate }}</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (columnsPanelOpen()) {
|
||||
<app-card padding="sm" class="columns-panel">
|
||||
@for (column of allColumns; track column) {
|
||||
<label class="check">
|
||||
<input type="checkbox" [checked]="isColumnVisible(column)" (change)="facade.setColumnVisible(column, $any($event.target).checked)" />
|
||||
<span>{{ ('adminModeration.column_' + column) | translate }}</span>
|
||||
</label>
|
||||
}
|
||||
</app-card>
|
||||
}
|
||||
|
||||
@if (facade.selectedIds().length > 0) {
|
||||
<div class="bulk-actions">
|
||||
<span>{{ facade.selectedIds().length }} {{ 'adminProducts.selectedCount' | translate }}</span>
|
||||
<select [attr.aria-label]="'adminModeration.changeStatus' | translate" [ngModel]="bulkStatusValue()" (ngModelChange)="bulkStatusValue.set($event)">
|
||||
<option value="approved">{{ 'adminModeration.status.approved' | translate }}</option>
|
||||
<option value="rejected">{{ 'adminModeration.status.rejected' | translate }}</option>
|
||||
<option value="spam">{{ 'adminModeration.status.spam' | translate }}</option>
|
||||
</select>
|
||||
<app-button variant="secondary" size="sm" (click)="applyBulkStatus()">{{ 'adminOrders.applyStatus' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="facade.applyBulkVisible(false)">{{ 'adminModeration.hideSelected' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="facade.exportSelectedAsCsv()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button>
|
||||
<app-button variant="danger" size="sm" (click)="facade.applyBulkDelete()">{{ 'adminModeration.archiveSelected' | translate }}</app-button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (facade.loading()) {
|
||||
<div class="skeleton-rows">
|
||||
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
||||
</div>
|
||||
} @else if (facade.reviews().length === 0) {
|
||||
<app-empty-state [title]="'adminModeration.emptyTitle' | translate" [description]="'adminModeration.emptyDescription' | translate" />
|
||||
<p class="admin-reviews-card__guide">{{ 'adminModeration.emptyGuide' | translate }}</p>
|
||||
} @else if (facade.viewMode() === 'table') {
|
||||
<div class="table-scroll" [class.density-compact]="facade.density() === 'compact'">
|
||||
<app-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" (change)="facade.toggleAll($any($event.target).checked)" /></th>
|
||||
<th>{{ 'adminModeration.customer' | translate }}</th>
|
||||
@if (isColumnVisible('product')) { <th>{{ 'adminModeration.product' | translate }}</th> }
|
||||
@if (isColumnVisible('rating')) { <th>{{ 'adminModeration.rating' | translate }}</th> }
|
||||
@if (isColumnVisible('status')) { <th>{{ 'backoffice.status' | translate }}</th> }
|
||||
@if (isColumnVisible('reports')) { <th>{{ 'adminModeration.reportsColumn' | translate }}</th> }
|
||||
@if (isColumnVisible('created')) { <th>{{ 'adminOrders.createdAt' | translate }}</th> }
|
||||
<th>{{ 'adminModeration.health' | translate }}</th>
|
||||
<th>{{ 'adminProducts.actions' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (review of facade.reviews(); track review.id) {
|
||||
<tr>
|
||||
<td><input type="checkbox" [checked]="isSelected(review.id)" (change)="facade.toggleSelection(review.id, $any($event.target).checked)" /></td>
|
||||
<td>{{ review.customerName }}</td>
|
||||
@if (isColumnVisible('product')) { <td>{{ review.productName }}</td> }
|
||||
@if (isColumnVisible('rating')) { <td>{{ review.rating }}★</td> }
|
||||
@if (isColumnVisible('status')) {
|
||||
<td><app-badge [variant]="review.status === 'approved' ? 'success' : review.status === 'pending' ? 'neutral' : review.status === 'spam' ? 'danger' : 'warning'">{{ ('adminModeration.status.' + review.status) | translate }}</app-badge></td>
|
||||
}
|
||||
@if (isColumnVisible('reports')) {
|
||||
<td>@if (review.reportCount > 0) { <app-badge variant="danger">{{ review.reportCount }}</app-badge> } @else { — }</td>
|
||||
}
|
||||
@if (isColumnVisible('created')) { <td>{{ review.createdAt | date:'short' }}</td> }
|
||||
<td class="health-cell"><app-review-health-widget [items]="healthItems(review)" [completionPercent]="facade.health(review).completionPercent" [compact]="true" /></td>
|
||||
<td class="actions">
|
||||
<app-button variant="secondary" size="sm" (click)="view(review.id)">{{ 'adminOrders.view' | translate }}</app-button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="review-grid" [class.review-grid--compact]="facade.density() === 'compact'">
|
||||
@for (review of facade.reviews(); track review.id) {
|
||||
<app-card padding="sm" class="review-grid__item">
|
||||
<label class="review-grid__select">
|
||||
<input type="checkbox" [checked]="isSelected(review.id)" (change)="facade.toggleSelection(review.id, $any($event.target).checked)" [attr.aria-label]="review.customerName" />
|
||||
</label>
|
||||
<div class="review-grid__meta">
|
||||
<strong>{{ review.customerName }}</strong>
|
||||
<span>{{ review.rating }}★</span>
|
||||
</div>
|
||||
<p class="review-grid__product">{{ review.productName }}</p>
|
||||
<p class="review-grid__text">{{ review.text }}</p>
|
||||
<app-badge [variant]="review.status === 'approved' ? 'success' : review.status === 'pending' ? 'neutral' : review.status === 'spam' ? 'danger' : 'warning'">{{ ('adminModeration.status.' + review.status) | translate }}</app-badge>
|
||||
<app-review-health-widget [items]="healthItems(review)" [completionPercent]="facade.health(review).completionPercent" [compact]="true" />
|
||||
<app-button variant="secondary" size="sm" (click)="view(review.id)">{{ 'adminOrders.view' | translate }}</app-button>
|
||||
</app-card>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (facade.reviews().length > 0) {
|
||||
<div class="pager">
|
||||
<span>{{ facade.total() }} {{ 'adminProducts.items' | translate }}</span>
|
||||
<app-pagination [currentPage]="facade.filters().page" [totalPages]="totalPages()" (pageChange)="facade.updateFilters({ page: $event })" />
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,32 @@
|
||||
.admin-reviews-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; }
|
||||
.toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; align-items: flex-start; }
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 10px; flex: 1; }
|
||||
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
|
||||
.toolbar__view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.view-toggle { display: flex; gap: 2px; }
|
||||
.columns-panel { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.check { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.bulk-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.skeleton-rows { display: grid; gap: 8px; }
|
||||
.pager { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.admin-reviews-card__guide { margin: 0; text-align: center; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); }
|
||||
.health-cell { min-width: 140px; }
|
||||
|
||||
.table-scroll {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
|
||||
thead th { position: sticky; top: 0; background: #fff; z-index: 1; }
|
||||
}
|
||||
|
||||
.density-compact td, .density-compact th { padding: 4px 8px; }
|
||||
|
||||
.review-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||
.review-grid--compact { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
|
||||
.review-grid__item { position: relative; display: grid; gap: 6px; }
|
||||
.review-grid__select { position: absolute; top: 8px; left: 8px; z-index: 1; }
|
||||
.review-grid__meta { display: flex; justify-content: space-between; font-size: 0.85rem; }
|
||||
.review-grid__product { margin: 0; font-size: 0.78rem; color: var(--text-tertiary, #9aa6a2); }
|
||||
.review-grid__text { margin: 0; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
|
||||
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } }
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { AdminModerationFacade, AdminReviewColumn, ALL_REVIEW_COLUMNS } from '../facade/admin-moderation.facade';
|
||||
import { AdminReviewStatus } from '../models/admin-review.model';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { InputComponent } from '../../../../shared/ui/input/input.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
|
||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
import { CardComponent } from '../../../../shared/ui/card/card.component';
|
||||
import { ModerationDashboardComponent } from '../components/moderation-dashboard/moderation-dashboard.component';
|
||||
import { ReviewHealthWidgetComponent, ReviewHealthItem } from '../components/review-health-widget/review-health-widget.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-reviews-list-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent, CardComponent, ModerationDashboardComponent, ReviewHealthWidgetComponent],
|
||||
templateUrl: './admin-reviews-list-page.component.html',
|
||||
styleUrls: ['./admin-reviews-list-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AdminReviewsListPageComponent {
|
||||
readonly facade = inject(AdminModerationFacade);
|
||||
private readonly router = inject(Router);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
readonly statuses = ['all', 'pending', 'approved', 'rejected', 'spam'] as const;
|
||||
readonly ratings = ['all', 5, 4, 3, 2, 1] as const;
|
||||
readonly allColumns = ALL_REVIEW_COLUMNS;
|
||||
protected readonly columnsPanelOpen = signal(false);
|
||||
protected readonly bulkStatusValue = signal<AdminReviewStatus>('approved');
|
||||
|
||||
constructor() {
|
||||
this.facade.loadList();
|
||||
this.facade.loadDashboardStats();
|
||||
}
|
||||
|
||||
totalPages(): number {
|
||||
return Math.max(1, Math.ceil(this.facade.total() / this.facade.filters().pageSize));
|
||||
}
|
||||
|
||||
view(id: string): void {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation', id]);
|
||||
}
|
||||
|
||||
viewReports(): void {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation', 'reports']);
|
||||
}
|
||||
|
||||
isSelected(id: string): boolean {
|
||||
return this.facade.selectedIds().includes(id);
|
||||
}
|
||||
|
||||
isColumnVisible(column: AdminReviewColumn): boolean {
|
||||
return this.facade.visibleColumns().includes(column);
|
||||
}
|
||||
|
||||
healthItems(review: Parameters<AdminModerationFacade['health']>[0]): ReviewHealthItem[] {
|
||||
const health = this.facade.health(review);
|
||||
return [
|
||||
{ labelKey: 'adminModeration.healthRating', done: health.hasRating },
|
||||
{ labelKey: 'adminModeration.healthText', done: health.hasText },
|
||||
{ labelKey: 'adminModeration.healthMedia', done: health.hasMedia },
|
||||
{ labelKey: 'adminModeration.healthModeration', done: health.isModerated },
|
||||
{ labelKey: 'adminModeration.healthReportStatus', done: health.reportStatusClear },
|
||||
{ labelKey: 'adminModeration.healthVisibility', done: health.isVisible },
|
||||
];
|
||||
}
|
||||
|
||||
applyBulkStatus(): void {
|
||||
this.facade.applyBulkStatus(this.bulkStatusValue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { AdminReview, AdminReviewListFilters, AdminReviewsListResult, AdminReviewStatus } from '../models/admin-review.model';
|
||||
import { AdminReport, AdminReportStatus } from '../models/admin-report.model';
|
||||
|
||||
export interface AdminModerationGateway {
|
||||
loadReviews(filters: AdminReviewListFilters): Observable<AdminReviewsListResult>;
|
||||
loadReview(id: string): Observable<AdminReview | null>;
|
||||
setReviewStatus(id: string, status: AdminReviewStatus, note: string): Observable<AdminReview | null>;
|
||||
setReviewVisible(id: string, visible: boolean): Observable<AdminReview | null>;
|
||||
setReviewPinned(id: string, pinned: boolean): Observable<AdminReview | null>;
|
||||
setReviewFeatured(id: string, featured: boolean): Observable<AdminReview | null>;
|
||||
addModeratorNote(id: string, note: string): Observable<AdminReview | null>;
|
||||
deleteReview(id: string): Observable<void>;
|
||||
loadReports(): Observable<AdminReport[]>;
|
||||
setReportStatus(id: string, status: AdminReportStatus): Observable<AdminReport | null>;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { BackofficeDataService } from '../../../../core/backoffice/backoffice-data.service';
|
||||
import { ProductCardConfig } from '../../../../shared/models/ui';
|
||||
import { AdminReview, AdminReviewListFilters, AdminReviewsListResult, AdminReviewStatus } from '../models/admin-review.model';
|
||||
import { AdminReport, AdminReportStatus } from '../models/admin-report.model';
|
||||
import { AdminModerationGateway } from './admin-moderation-gateway.interface';
|
||||
|
||||
const STATUSES: AdminReviewStatus[] = ['pending', 'approved', 'rejected', 'spam'];
|
||||
const AUTHORS = ['Anna Petrova', 'Karen Sargsyan', 'Ivan Ivanov', 'Mariam Grigoryan', 'Sergey Volkov', 'Lilit Hakobyan'];
|
||||
const SNIPPETS = [
|
||||
'Great quality, exactly as described.',
|
||||
'Arrived late and packaging was damaged.',
|
||||
'Good value for the price, would buy again.',
|
||||
'Not what I expected based on the photos.',
|
||||
'Excellent customer service and fast delivery.',
|
||||
'Average product, nothing special.',
|
||||
];
|
||||
const SEED_COUNT = 32;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminModerationLocalGateway implements AdminModerationGateway {
|
||||
private readonly backofficeData = inject(BackofficeDataService);
|
||||
private cache: AdminReview[] | null = null;
|
||||
private reportsCache: AdminReport[] | null = null;
|
||||
private products: ProductCardConfig[] = [];
|
||||
|
||||
loadReviews(filters: AdminReviewListFilters): Observable<AdminReviewsListResult> {
|
||||
return new Observable<AdminReviewsListResult>(subscriber => {
|
||||
this.ensureData().then(() => {
|
||||
const all = this.cache ?? [];
|
||||
const filtered = all
|
||||
.filter(review => !filters.search || `${review.customerName} ${review.productName} ${review.text}`.toLowerCase().includes(filters.search.toLowerCase()))
|
||||
.filter(review => filters.status === 'all' || review.status === filters.status)
|
||||
.filter(review => filters.rating === 'all' || review.rating === filters.rating)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
const start = (filters.page - 1) * filters.pageSize;
|
||||
subscriber.next({
|
||||
items: filtered.slice(start, start + filters.pageSize),
|
||||
total: filtered.length,
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
});
|
||||
subscriber.complete();
|
||||
});
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
loadReview(id: string): Observable<AdminReview | null> {
|
||||
return new Observable<AdminReview | null>(subscriber => {
|
||||
this.ensureData().then(() => {
|
||||
subscriber.next((this.cache ?? []).find(review => review.id === id) ?? null);
|
||||
subscriber.complete();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setReviewStatus(id: string, status: AdminReviewStatus, note: string): Observable<AdminReview | null> {
|
||||
return this.mutate(id, review => ({
|
||||
...review,
|
||||
status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
timeline: [...review.timeline, { action: `Status set to ${status}`, actor: 'Admin', note, timestamp: new Date().toISOString() }],
|
||||
}));
|
||||
}
|
||||
|
||||
setReviewVisible(id: string, visible: boolean): Observable<AdminReview | null> {
|
||||
return this.mutate(id, review => ({
|
||||
...review,
|
||||
visible,
|
||||
updatedAt: new Date().toISOString(),
|
||||
timeline: [...review.timeline, { action: visible ? 'Restored' : 'Hidden', actor: 'Admin', note: '', timestamp: new Date().toISOString() }],
|
||||
}));
|
||||
}
|
||||
|
||||
setReviewPinned(id: string, pinned: boolean): Observable<AdminReview | null> {
|
||||
return this.mutate(id, review => ({ ...review, pinned, updatedAt: new Date().toISOString() }));
|
||||
}
|
||||
|
||||
setReviewFeatured(id: string, featured: boolean): Observable<AdminReview | null> {
|
||||
return this.mutate(id, review => ({ ...review, featured, updatedAt: new Date().toISOString() }));
|
||||
}
|
||||
|
||||
addModeratorNote(id: string, note: string): Observable<AdminReview | null> {
|
||||
return this.mutate(id, review => ({
|
||||
...review,
|
||||
moderatorNotes: review.moderatorNotes ? `${review.moderatorNotes}\n${note}` : note,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
deleteReview(id: string): Observable<void> {
|
||||
this.cache = (this.cache ?? []).filter(review => review.id !== id);
|
||||
return of(void 0).pipe(delay(50));
|
||||
}
|
||||
|
||||
loadReports(): Observable<AdminReport[]> {
|
||||
return new Observable<AdminReport[]>(subscriber => {
|
||||
this.ensureData().then(() => {
|
||||
subscriber.next(this.reportsCache ?? []);
|
||||
subscriber.complete();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setReportStatus(id: string, status: AdminReportStatus): Observable<AdminReport | null> {
|
||||
const existing = (this.reportsCache ?? []).find(report => report.id === id);
|
||||
if (!existing) {
|
||||
return of(null);
|
||||
}
|
||||
const updated = { ...existing, status };
|
||||
this.reportsCache = (this.reportsCache ?? []).map(report => report.id === id ? updated : report);
|
||||
return of(updated).pipe(delay(50));
|
||||
}
|
||||
|
||||
private mutate(id: string, update: (review: AdminReview) => AdminReview): Observable<AdminReview | null> {
|
||||
const all = this.cache ?? [];
|
||||
const existing = all.find(review => review.id === id);
|
||||
if (!existing) {
|
||||
return of(null);
|
||||
}
|
||||
const updated = update(existing);
|
||||
this.cache = all.map(review => review.id === id ? updated : review);
|
||||
return of(updated).pipe(delay(50));
|
||||
}
|
||||
|
||||
private async ensureData(): Promise<void> {
|
||||
if (this.cache && this.reportsCache) {
|
||||
return;
|
||||
}
|
||||
this.products = await new Promise<ProductCardConfig[]>(resolve => this.backofficeData.loadProducts().subscribe(value => resolve(value)));
|
||||
this.cache = Array.from({ length: SEED_COUNT }, (_, index) => this.seedReview(index));
|
||||
this.reportsCache = this.seedReports();
|
||||
}
|
||||
|
||||
private seedReview(index: number): AdminReview {
|
||||
const product = this.products[index % Math.max(1, this.products.length)];
|
||||
const status = STATUSES[index % STATUSES.length];
|
||||
const rating = 1 + (index % 5);
|
||||
const createdAt = new Date(Date.now() - index * 20 * 60 * 60 * 1000).toISOString();
|
||||
const reportCount = index % 7 === 0 ? 1 + (index % 3) : 0;
|
||||
return {
|
||||
id: `review-${index + 1}`,
|
||||
productId: product?.id ?? '',
|
||||
productName: product?.title ?? 'Unknown product',
|
||||
customerName: AUTHORS[index % AUTHORS.length],
|
||||
customerEmail: `${AUTHORS[index % AUTHORS.length].toLowerCase().replace(/\s+/g, '.')}@example.com`,
|
||||
rating,
|
||||
text: SNIPPETS[index % SNIPPETS.length],
|
||||
photos: index % 5 === 0 ? [product?.imageUrl ?? ''].filter(Boolean) : [],
|
||||
status,
|
||||
visible: status === 'approved',
|
||||
pinned: false,
|
||||
featured: false,
|
||||
reportCount,
|
||||
moderatorNotes: '',
|
||||
timeline: [{ action: 'Submitted', actor: 'Customer', note: '', timestamp: createdAt }],
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
private seedReports(): AdminReport[] {
|
||||
return Array.from({ length: 10 }, (_, index) => {
|
||||
const targetTypes: Array<AdminReport['targetType']> = ['review', 'product', 'customer', 'category'];
|
||||
const targetType = targetTypes[index % targetTypes.length];
|
||||
const product = this.products[index % Math.max(1, this.products.length)];
|
||||
const review = this.cache?.[index % (this.cache?.length ?? 1)];
|
||||
const targetLabel = targetType === 'review' ? (review?.text.slice(0, 40) ?? 'Unknown review')
|
||||
: targetType === 'product' ? (product?.title ?? 'Unknown product')
|
||||
: targetType === 'customer' ? (review?.customerName ?? 'Unknown customer')
|
||||
: 'Unknown category';
|
||||
return {
|
||||
id: `report-${index + 1}`,
|
||||
targetType,
|
||||
targetId: targetType === 'review' ? (review?.id ?? '') : targetType === 'product' ? (product?.id ?? '') : '',
|
||||
targetLabel,
|
||||
reason: index % 2 === 0 ? 'Inappropriate content' : 'Spam or misleading',
|
||||
reporterEmail: `reporter${index + 1}@example.com`,
|
||||
status: index % 3 === 0 ? 'resolved' : index % 3 === 1 ? 'dismissed' : 'open',
|
||||
createdAt: new Date(Date.now() - index * 30 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
|
||||
{ type: 'link', id: 'orders', icon: 'pi-shopping-cart', labelKey: 'adminShell.nav.orders', path: ['orders'] },
|
||||
{ type: 'link', id: 'customers', icon: 'pi-user', labelKey: 'adminShell.nav.customers', path: ['customers'] },
|
||||
{ type: 'link', id: 'transactions', icon: 'pi-credit-card', labelKey: 'adminShell.nav.transactions', path: ['transactions'] },
|
||||
{ type: 'link', id: 'reviews', icon: 'pi-star', labelKey: 'adminShell.nav.reviews', comingSoon: true },
|
||||
{ type: 'link', id: 'moderation', icon: 'pi-star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] },
|
||||
{ type: 'link', id: 'reports', icon: 'pi-chart-bar', labelKey: 'adminShell.nav.reports', comingSoon: true },
|
||||
{ type: 'link', id: 'content', icon: 'pi-file-edit', labelKey: 'adminShell.nav.content', absolutePath: ['edit', 'static-pages'] },
|
||||
{ type: 'link', id: 'media', icon: 'pi-images', labelKey: 'adminShell.nav.mediaLibrary', path: ['media'] },
|
||||
|
||||
Reference in New Issue
Block a user