feat(admin): implement analytics and monitoring center

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

- Business Dashboard: revenue/orders/customers/AOV/top products/low stock/recent activity/warnings/completion% (Overview tab)
- Marketplace Health: real completion checks (images, SEO, categories, reviews, orders, translations, homepage, backend, performance) with clickable recommendations
- Product Analytics: top selling/most reviewed/worst rated/hidden/archived counts; top-viewed honestly marked Unknown (no view tracking exists)
- Customer Analytics: new/returning customers, average spend, retention - derived from real order data
- Search & Traffic tabs: honest 'Unknown - available after backend integration' placeholders, no fabricated numbers
- Recommendations engine: actionable cards (missing images/SEO/category, empty categories, incomplete homepage, unpublished static pages) linking to the relevant admin page
- System Monitoring: added real draft/publish/sync/backend-connectivity state from AdminDashboardFacade
- CSV export extended to top products and health checks; added print action
- New adminAnalytics.* and adminMarketplaceHealth.* i18n namespaces (en/ru/hy), no raw i18n keys
- Reused existing shared UI (app-table/app-badge/app-skeleton/app-empty-state) and content-health-widget completion-meter pattern - no new backend APIs, no storage changes
This commit is contained in:
sdarbinyan
2026-07-18 22:33:36 +04:00
parent 3c9b425203
commit 6af746c561
10 changed files with 1009 additions and 75 deletions

View File

@@ -1,48 +1,113 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { AdminAnalyticsDateRange, AdminAnalyticsSeriesPoint, AdminAnalyticsSummary, AdminAnalyticsTopProduct } from '../models/admin-analytics.model';
import {
AdminAnalyticsDateRange,
AdminAnalyticsSeriesPoint,
AdminAnalyticsSummary,
AdminAnalyticsTopProduct,
AdminCustomerAnalytics,
AdminLowStockProduct,
AdminMarketplaceHealthCheck,
AdminProductAnalytics,
AdminRecentActivityEntry,
AdminRecommendationCard,
} from '../models/admin-analytics.model';
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
import { AdminProductsLocalGateway } from '../../products/services/admin-products-local.gateway';
import { AdminCategoriesLocalGateway } from '../../categories/services/admin-categories-local.gateway';
import { AdminModerationLocalGateway } from '../../moderation/services/admin-moderation-local.gateway';
import { AdminDashboardFacade } from '../../dashboard/facade/admin-dashboard.facade';
import { AdminOrder } from '../../orders/models/admin-order.model';
import { AdminProduct } from '../../products/models/admin-product.model';
import { AdminCategory } from '../../categories/models/admin-category.model';
import { AdminReview } from '../../moderation/models/admin-review.model';
/**
* Aggregates existing gateways (orders/products/categories/reviews) plus the dashboard facade's
* bootstrap/validation state into merchant-facing analytics. Never fabricates a number: anything
* with no real data source (visitor traffic, search queries, product views) stays null/'unknown'
* and is rendered honestly rather than guessed, per Sprint 12 scope.
*/
@Injectable({ providedIn: 'root' })
export class AdminAnalyticsFacade {
private readonly ordersGateway = inject(AdminOrdersLocalGateway);
private readonly productsGateway = inject(AdminProductsLocalGateway);
private readonly categoriesGateway = inject(AdminCategoriesLocalGateway);
private readonly moderationGateway = inject(AdminModerationLocalGateway);
private readonly dashboardFacade = inject(AdminDashboardFacade);
readonly dateRange = signal<AdminAnalyticsDateRange>(30);
readonly loading = signal(false);
readonly summary = signal<AdminAnalyticsSummary | null>(null);
readonly salesSeries = signal<AdminAnalyticsSeriesPoint[]>([]);
readonly topProducts = signal<AdminAnalyticsTopProduct[]>([]);
readonly lowStockProducts = signal<AdminLowStockProduct[]>([]);
readonly recentActivity = signal<AdminRecentActivityEntry[]>([]);
readonly marketplaceHealth = signal<AdminMarketplaceHealthCheck[]>([]);
readonly productAnalytics = signal<AdminProductAnalytics | null>(null);
readonly customerAnalytics = signal<AdminCustomerAnalytics | null>(null);
readonly recommendations = signal<AdminRecommendationCard[]>([]);
readonly maxSeriesValue = computed(() => Math.max(1, ...this.salesSeries().map(point => point.value)));
readonly healthCompletionPercent = computed(() => {
const known = this.marketplaceHealth().filter(check => check.status !== 'unknown');
if (known.length === 0) {
return 0;
}
const healthy = known.filter(check => check.status === 'healthy').length;
return Math.round((healthy / known.length) * 100);
});
readonly warnings = computed(() => this.recommendations().filter(card => card.severity !== 'info'));
load(): void {
this.loading.set(true);
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 1000 }).pipe(take(1)).subscribe(orderResult => {
this.dashboardFacade.ensureLoaded();
this.recentActivity.set(
this.dashboardFacade.activityEntries().map(entry => ({
id: entry.id,
labelKey: entry.type === 'published' ? 'adminAnalytics.activityPublished' : 'adminAnalytics.activityDraftSaved',
timestamp: entry.timestamp,
})),
);
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe(orderResult => {
const cutoff = Date.now() - this.dateRange() * 24 * 60 * 60 * 1000;
const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff);
this.salesSeries.set(this.buildSeries(inRange, this.dateRange()));
this.topProducts.set(this.buildTopProducts(inRange));
this.customerAnalytics.set(this.buildCustomerAnalytics(orderResult.items, inRange, this.dateRange()));
const revenueTotal = inRange.reduce((sum, order) => sum + order.total, 0);
const ordersCount = inRange.length;
const uniqueCustomers = new Set(inRange.map(order => order.customer.email)).size;
this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 1 }).pipe(take(1)).subscribe(productResult => {
this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe(productResult => {
this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe(categories => {
this.summary.set({
revenueTotal,
currency: inRange[0]?.currency ?? 'RUB',
ordersCount,
avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0,
productsCount: productResult.total,
categoriesCount: categories.length,
this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe(reviewResult => {
const products = productResult.items;
const reviews = reviewResult.items;
this.summary.set({
revenueTotal,
currency: inRange[0]?.currency ?? 'RUB',
ordersCount,
avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0,
productsCount: products.length,
categoriesCount: categories.length,
customersCount: uniqueCustomers,
conversionRate: null,
});
this.lowStockProducts.set(this.buildLowStock(products));
this.productAnalytics.set(this.buildProductAnalytics(products));
this.marketplaceHealth.set(this.buildMarketplaceHealth(products, categories, reviews, orderResult.items));
this.recommendations.set(this.buildRecommendations(products, categories));
this.loading.set(false);
});
this.loading.set(false);
});
});
});
@@ -59,6 +124,18 @@ export class AdminAnalyticsFacade {
return [header, ...rows].join('\n');
}
exportTopProductsCsv(): string {
const header = 'Product,Quantity Sold,Revenue';
const rows = this.topProducts().map(product => `${product.name},${product.quantity},${product.revenue}`);
return [header, ...rows].join('\n');
}
exportHealthCsv(): string {
const header = 'Check,Status,Value';
const rows = this.marketplaceHealth().map(check => `${check.labelKey},${check.status},${check.displayValue ?? ''}`);
return [header, ...rows].join('\n');
}
private buildSeries(orders: AdminOrder[], days: number): AdminAnalyticsSeriesPoint[] {
const buckets = new Map<string, number>();
for (let i = days - 1; i >= 0; i--) {
@@ -86,4 +163,176 @@ export class AdminAnalyticsFacade {
}
return [...map.values()].sort((left, right) => right.revenue - left.revenue).slice(0, 5);
}
private buildLowStock(products: AdminProduct[]): AdminLowStockProduct[] {
return products
.filter(product => !product.archived && (product.stockStatus === 'low_stock' || product.stockStatus === 'out_of_stock'))
.sort((left, right) => left.quantity - right.quantity)
.slice(0, 8)
.map(product => ({ productId: product.id, name: product.name, quantity: product.quantity, stockStatus: product.stockStatus as 'low_stock' | 'out_of_stock' }));
}
private buildCustomerAnalytics(allOrders: AdminOrder[], inRangeOrders: AdminOrder[], days: number): AdminCustomerAnalytics {
const firstOrderByEmail = new Map<string, number>();
for (const order of allOrders) {
const email = order.customer.email;
const time = new Date(order.createdAt).getTime();
const existing = firstOrderByEmail.get(email);
if (existing === undefined || time < existing) {
firstOrderByEmail.set(email, time);
}
}
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const emailsInRange = new Set(inRangeOrders.map(order => order.customer.email));
let newCustomers = 0;
let returningCustomers = 0;
for (const email of emailsInRange) {
const firstOrderAt = firstOrderByEmail.get(email) ?? Date.now();
if (firstOrderAt >= cutoff) {
newCustomers += 1;
} else {
returningCustomers += 1;
}
}
const revenueInRange = inRangeOrders.reduce((sum, order) => sum + order.total, 0);
const uniqueCustomersInRange = emailsInRange.size;
return {
newCustomers,
returningCustomers,
averageSpend: uniqueCustomersInRange > 0 ? Math.round(revenueInRange / uniqueCustomersInRange) : 0,
currency: inRangeOrders[0]?.currency ?? 'RUB',
retentionPercent: uniqueCustomersInRange > 0 ? Math.round((returningCustomers / uniqueCustomersInRange) * 100) : null,
};
}
private buildProductAnalytics(products: AdminProduct[]): AdminProductAnalytics {
const mostReviewed = [...products]
.filter(product => product.reviews.length > 0)
.sort((left, right) => right.reviews.length - left.reviews.length)
.slice(0, 5)
.map(product => ({ productId: product.id, name: product.name, value: product.reviews.length }));
const worstRated = [...products]
.filter(product => product.reviews.length > 0)
.map(product => ({
productId: product.id,
name: product.name,
value: Math.round((product.reviews.reduce((sum, review) => sum + review.rating, 0) / product.reviews.length) * 10) / 10,
}))
.sort((left, right) => left.value - right.value)
.slice(0, 5);
return {
topSelling: this.topProducts(),
mostReviewed,
worstRated,
hiddenCount: products.filter(product => !product.visible && !product.archived).length,
archivedCount: products.filter(product => product.archived).length,
};
}
private buildMarketplaceHealth(products: AdminProduct[], categories: AdminCategory[], reviews: AdminReview[], allOrders: AdminOrder[]): AdminMarketplaceHealthCheck[] {
const activeProducts = products.filter(product => !product.archived);
const withImages = activeProducts.filter(product => product.media.images.length > 0).length;
const withSeo = activeProducts.filter(product => product.seo.metaTitle && product.seo.metaDescription).length;
const categoriesWithProducts = categories.filter(category => category.itemsCount > 0).length;
const moderatedReviews = reviews.filter(review => review.status !== 'pending').length;
const processedOrders = allOrders.filter(order => order.status !== 'pending').length;
const bootstrap = this.dashboardFacade.bootstrap();
const issues = new Set(this.dashboardFacade.validationIssues().map(issue => issue.code));
const percentStatus = (done: number, total: number) => {
if (total === 0) return 'unknown' as const;
const ratio = done / total;
if (ratio === 1) return 'healthy' as const;
if (ratio >= 0.5) return 'attention' as const;
return 'unhealthy' as const;
};
return [
{
code: 'products-with-images', labelKey: 'adminMarketplaceHealth.checkImages',
status: percentStatus(withImages, activeProducts.length),
displayValue: activeProducts.length ? `${withImages}/${activeProducts.length}` : null,
actionRoute: ['backoffice', 'products'],
},
{
code: 'products-with-seo', labelKey: 'adminMarketplaceHealth.checkSeo',
status: percentStatus(withSeo, activeProducts.length),
displayValue: activeProducts.length ? `${withSeo}/${activeProducts.length}` : null,
actionRoute: ['backoffice', 'products'],
},
{
code: 'categories-with-products', labelKey: 'adminMarketplaceHealth.checkCategories',
status: percentStatus(categoriesWithProducts, categories.length),
displayValue: categories.length ? `${categoriesWithProducts}/${categories.length}` : null,
actionRoute: ['backoffice', 'categories'],
},
{
code: 'reviews-moderated', labelKey: 'adminMarketplaceHealth.checkReviews',
status: percentStatus(moderatedReviews, reviews.length),
displayValue: reviews.length ? `${moderatedReviews}/${reviews.length}` : null,
actionRoute: ['backoffice', 'moderation'],
},
{
code: 'orders-processed', labelKey: 'adminMarketplaceHealth.checkOrders',
status: percentStatus(processedOrders, allOrders.length),
displayValue: allOrders.length ? `${processedOrders}/${allOrders.length}` : null,
actionRoute: ['backoffice', 'orders'],
},
{
code: 'translations-complete', labelKey: 'adminMarketplaceHealth.checkTranslations',
status: !bootstrap ? 'unknown' : issues.has('missing-translations') ? 'unhealthy' : 'healthy',
actionRoute: ['edit'],
},
{
code: 'homepage-configured', labelKey: 'adminMarketplaceHealth.checkHomepage',
status: !bootstrap ? 'unknown' : this.dashboardFacade.enabledWidgetsCount() > 0 ? 'healthy' : 'attention',
actionRoute: ['edit', 'homepage'],
},
{
code: 'backend-connectivity', labelKey: 'adminMarketplaceHealth.checkBackend',
status: bootstrap ? 'healthy' : 'unknown',
actionRoute: ['backoffice', 'monitoring'],
},
{
code: 'performance', labelKey: 'adminMarketplaceHealth.checkPerformance',
status: 'unknown',
actionRoute: ['backoffice', 'monitoring'],
},
];
}
private buildRecommendations(products: AdminProduct[], categories: AdminCategory[]): AdminRecommendationCard[] {
const cards: AdminRecommendationCard[] = [];
const activeProducts = products.filter(product => !product.archived);
const missingImages = activeProducts.filter(product => product.media.images.length === 0).length;
const missingSeo = activeProducts.filter(product => !product.seo.metaTitle || !product.seo.metaDescription).length;
const missingCategory = activeProducts.filter(product => !product.categoryId).length;
const emptyCategories = categories.filter(category => category.itemsCount === 0).length;
if (missingImages > 0) {
cards.push({ id: 'missing-images', labelKey: 'adminMarketplaceHealth.recommendImages', descriptionKey: 'adminMarketplaceHealth.recommendImagesCount', severity: 'warning', route: ['backoffice', 'products'] });
}
if (missingSeo > 0) {
cards.push({ id: 'missing-seo', labelKey: 'adminMarketplaceHealth.recommendSeo', descriptionKey: 'adminMarketplaceHealth.recommendSeoCount', severity: 'warning', route: ['backoffice', 'products'] });
}
if (missingCategory > 0) {
cards.push({ id: 'missing-category', labelKey: 'adminMarketplaceHealth.recommendCategory', descriptionKey: 'adminMarketplaceHealth.recommendCategoryCount', severity: 'warning', route: ['backoffice', 'products'] });
}
if (emptyCategories > 0) {
cards.push({ id: 'empty-categories', labelKey: 'adminMarketplaceHealth.recommendEmptyCategories', descriptionKey: 'adminMarketplaceHealth.recommendEmptyCategoriesCount', severity: 'info', route: ['backoffice', 'categories'] });
}
if (this.dashboardFacade.bootstrap() && this.dashboardFacade.enabledWidgetsCount() === 0) {
cards.push({ id: 'homepage-incomplete', labelKey: 'adminMarketplaceHealth.recommendHomepage', severity: 'critical', route: ['edit', 'homepage'] });
}
if (this.dashboardFacade.bootstrap() && this.dashboardFacade.staticPagesUnpublishedCount() > 0) {
cards.push({ id: 'static-pages-unpublished', labelKey: 'adminMarketplaceHealth.recommendStaticPages', severity: 'info', route: ['backoffice', 'static-pages'] });
}
return cards;
}
}

View File

@@ -7,6 +7,9 @@ export interface AdminAnalyticsSummary {
avgOrderValue: number;
productsCount: number;
categoriesCount: number;
customersCount: number;
/** null = unknown - no visitor/traffic tracking exists yet. Never fabricated. */
conversionRate: number | null;
}
export interface AdminAnalyticsSeriesPoint {
@@ -20,3 +23,59 @@ export interface AdminAnalyticsTopProduct {
quantity: number;
revenue: number;
}
export interface AdminLowStockProduct {
productId: string;
name: string;
quantity: number;
stockStatus: 'low_stock' | 'out_of_stock';
}
export interface AdminRecentActivityEntry {
id: string;
labelKey: string;
timestamp: number;
}
export type AdminMarketplaceHealthStatus = 'healthy' | 'attention' | 'unhealthy' | 'unknown';
export interface AdminMarketplaceHealthCheck {
code: string;
labelKey: string;
status: AdminMarketplaceHealthStatus;
displayValue?: string | null;
actionRoute?: string[];
}
export interface AdminProductAnalyticsRow {
productId: string;
name: string;
value: number;
}
export interface AdminProductAnalytics {
topSelling: AdminAnalyticsTopProduct[];
mostReviewed: AdminProductAnalyticsRow[];
worstRated: AdminProductAnalyticsRow[];
hiddenCount: number;
archivedCount: number;
}
export interface AdminCustomerAnalytics {
newCustomers: number;
returningCustomers: number;
averageSpend: number;
currency: string;
/** null = unknown when there are no customers in range to compute a rate from. */
retentionPercent: number | null;
}
export type AdminRecommendationSeverity = 'info' | 'warning' | 'critical';
export interface AdminRecommendationCard {
id: string;
labelKey: string;
descriptionKey?: string;
severity: AdminRecommendationSeverity;
route: string[];
}

View File

@@ -5,69 +5,242 @@
<app-button [variant]="facade.dateRange() === range ? 'primary' : 'secondary'" size="sm" (click)="facade.setDateRange(range)">{{ range }}d</app-button>
}
</div>
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
</div>
@if (facade.loading()) {
<div class="summary-grid">
@for (i of [1,2,3,4,5,6]; track i) {
<app-skeleton shape="rect" height="64px" />
}
</div>
} @else if (facade.summary(); as summary) {
<div class="summary-grid">
<div class="summary-card"><span>{{ 'adminAnalytics.revenue' | translate }}</span><strong>{{ summary.revenueTotal }} {{ summary.currency }}</strong></div>
<div class="summary-card"><span>{{ 'adminAnalytics.orders' | translate }}</span><strong>{{ summary.ordersCount }}</strong></div>
<div class="summary-card"><span>{{ 'adminAnalytics.avgOrderValue' | translate }}</span><strong>{{ summary.avgOrderValue }} {{ summary.currency }}</strong></div>
<div class="summary-card"><span>{{ 'adminAnalytics.products' | translate }}</span><strong>{{ summary.productsCount }}</strong></div>
<div class="summary-card"><span>{{ 'adminAnalytics.categories' | translate }}</span><strong>{{ summary.categoriesCount }}</strong></div>
<div class="summary-card pending"><span>{{ 'adminAnalytics.visitors' | translate }}</span><app-badge variant="neutral">{{ 'adminAnalytics.pendingBackend' | translate }}</app-badge></div>
</div>
}
<div class="card">
<h2>{{ 'adminAnalytics.salesChart' | translate }}</h2>
<div class="chart">
@for (point of facade.salesSeries(); track point.date) {
<div class="bar" [style.height.%]="barHeight(point.value)" [title]="point.date + ': ' + point.value"></div>
}
<div class="toolbar-actions">
<app-button variant="secondary" size="sm" (click)="print()">{{ 'adminAnalytics.print' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
</div>
</div>
<div class="card">
<h2>{{ 'adminAnalytics.topProducts' | translate }}</h2>
@if (facade.loading()) {
<div class="skeleton-rows">
@for (i of [1,2,3]; track i) {
<app-skeleton shape="rect" height="36px" />
}
</div>
} @else if (facade.topProducts().length === 0) {
<app-empty-state [title]="'adminAnalytics.topProductsEmptyTitle' | translate" [description]="'adminAnalytics.topProductsEmptyDescription' | translate" />
} @else {
<app-table>
<thead>
<tr>
<th>{{ 'adminProducts.name' | translate }}</th>
<th>{{ 'adminAnalytics.quantitySold' | translate }}</th>
<th>{{ 'adminAnalytics.revenue' | translate }}</th>
</tr>
</thead>
<tbody>
@for (product of facade.topProducts(); track product.productId) {
<tr>
<td>{{ product.name }}</td>
<td>{{ product.quantity }}</td>
<td>{{ product.revenue }}</td>
</tr>
}
</tbody>
</app-table>
<div class="tabs" role="tablist" [attr.aria-label]="'adminAnalytics.tabsLabel' | translate">
@for (tab of tabs; track tab) {
<button
type="button"
role="tab"
class="tab"
[class.tab--active]="activeTab() === tab"
[attr.aria-selected]="activeTab() === tab"
[attr.id]="'tab-' + tab"
[attr.aria-controls]="'panel-' + tab"
(click)="selectTab(tab)"
>{{ 'adminAnalytics.tab.' + tab | translate }}</button>
}
</div>
<div class="card pending-section">
<h2>{{ 'adminAnalytics.funnelsHeatmaps' | translate }}</h2>
<p><app-badge variant="neutral">{{ 'adminAnalytics.pendingBackend' | translate }}</app-badge> {{ 'adminAnalytics.pendingBackendHint' | translate }}</p>
</div>
@if (activeTab() === 'overview') {
<div class="panel" role="tabpanel" id="panel-overview" aria-labelledby="tab-overview">
@if (facade.loading()) {
<div class="summary-grid">
@for (i of [1,2,3,4,5,6]; track i) {
<app-skeleton shape="rect" height="64px" />
}
</div>
} @else if (facade.summary(); as summary) {
<div class="summary-grid">
<div class="summary-card"><span>{{ 'adminAnalytics.revenue' | translate }}</span><strong>{{ summary.revenueTotal }} {{ summary.currency }}</strong></div>
<div class="summary-card"><span>{{ 'adminAnalytics.orders' | translate }}</span><strong>{{ summary.ordersCount }}</strong></div>
<div class="summary-card"><span>{{ 'adminAnalytics.customers' | translate }}</span><strong>{{ summary.customersCount }}</strong></div>
<div class="summary-card"><span>{{ 'adminAnalytics.avgOrderValue' | translate }}</span><strong>{{ summary.avgOrderValue }} {{ summary.currency }}</strong></div>
<div class="summary-card pending"><span>{{ 'adminAnalytics.conversion' | translate }}</span><app-badge variant="neutral">{{ 'adminAnalytics.unknown' | translate }}</app-badge></div>
<div class="summary-card"><span>{{ 'adminAnalytics.completionPercent' | translate }}</span><strong>{{ facade.healthCompletionPercent() }}%</strong></div>
</div>
}
<div class="card">
<h2>{{ 'adminAnalytics.salesChart' | translate }}</h2>
<div class="chart">
@for (point of facade.salesSeries(); track point.date) {
<div class="bar" [style.height.%]="barHeight(point.value)" [title]="point.date + ': ' + point.value"></div>
}
</div>
</div>
<div class="grid-2">
<div class="card">
<h2>{{ 'adminAnalytics.topProducts' | translate }}</h2>
@if (facade.loading()) {
<div class="skeleton-rows">
@for (i of [1,2,3]; track i) { <app-skeleton shape="rect" height="36px" /> }
</div>
} @else if (facade.topProducts().length === 0) {
<app-empty-state [title]="'adminAnalytics.topProductsEmptyTitle' | translate" [description]="'adminAnalytics.topProductsEmptyDescription' | translate" />
} @else {
<app-table>
<thead><tr><th>{{ 'adminProducts.name' | translate }}</th><th>{{ 'adminAnalytics.quantitySold' | translate }}</th><th>{{ 'adminAnalytics.revenue' | translate }}</th></tr></thead>
<tbody>
@for (product of facade.topProducts(); track product.productId) {
<tr><td>{{ product.name }}</td><td>{{ product.quantity }}</td><td>{{ product.revenue }}</td></tr>
}
</tbody>
</app-table>
}
</div>
<div class="card">
<h2>{{ 'adminAnalytics.lowStock' | translate }}</h2>
@if (facade.lowStockProducts().length === 0) {
<app-empty-state [title]="'adminAnalytics.lowStockEmptyTitle' | translate" />
} @else {
<app-table>
<thead><tr><th>{{ 'adminProducts.name' | translate }}</th><th>{{ 'adminAnalytics.quantity' | translate }}</th><th>{{ 'adminAnalytics.status' | translate }}</th></tr></thead>
<tbody>
@for (product of facade.lowStockProducts(); track product.productId) {
<tr>
<td>{{ product.name }}</td>
<td>{{ product.quantity }}</td>
<td><app-badge [variant]="product.stockStatus === 'out_of_stock' ? 'danger' : 'warning'">{{ ('adminProducts.' + (product.stockStatus === 'out_of_stock' ? 'outOfStock' : 'lowStock')) | translate }}</app-badge></td>
</tr>
}
</tbody>
</app-table>
}
</div>
</div>
<div class="grid-2">
<div class="card">
<h2>{{ 'adminAnalytics.recentActivity' | translate }}</h2>
@if (facade.recentActivity().length === 0) {
<app-empty-state [title]="'adminAnalytics.recentActivityEmptyTitle' | translate" />
} @else {
<ul class="activity-list">
@for (entry of facade.recentActivity(); track entry.id) {
<li>{{ entry.labelKey | translate }} <span class="muted">{{ entry.timestamp | date:'short' }}</span></li>
}
</ul>
}
</div>
<div class="card">
<h2>{{ 'adminAnalytics.warnings' | translate }}</h2>
@if (facade.warnings().length === 0) {
<app-empty-state [title]="'adminMarketplaceHealth.recommendNone' | translate" />
} @else {
<ul class="warning-list">
@for (card of facade.warnings(); track card.id) {
<li><app-badge [variant]="severityVariant(card.severity)">{{ card.labelKey | translate }}</app-badge></li>
}
</ul>
}
</div>
</div>
</div>
}
@if (activeTab() === 'health') {
<div class="panel" role="tabpanel" id="panel-health" aria-labelledby="tab-health">
<div class="card">
<div class="card-head">
<h2>{{ 'adminMarketplaceHealth.title' | translate }}</h2>
<app-button variant="secondary" size="sm" (click)="exportHealthCsv()">{{ 'adminOrders.export' | translate }}</app-button>
</div>
<div class="health-widget__bar" role="progressbar" [attr.aria-valuenow]="facade.healthCompletionPercent()" aria-valuemin="0" aria-valuemax="100">
<div class="health-widget__bar-fill" [style.width.%]="facade.healthCompletionPercent()"></div>
</div>
<p class="health-percent">{{ facade.healthCompletionPercent() }}% {{ 'adminMarketplaceHealth.complete' | translate }}</p>
<ul class="health-checks">
@for (check of facade.marketplaceHealth(); track check.code) {
<li>
<app-badge [variant]="healthStatusVariant(check.status)">{{ check.status === 'unknown' ? ('adminAnalytics.unknown' | translate) : (check.displayValue ?? check.status) }}</app-badge>
<a [routerLink]="check.actionRoute">{{ check.labelKey | translate }}</a>
</li>
}
</ul>
</div>
</div>
}
@if (activeTab() === 'products') {
<div class="panel" role="tabpanel" id="panel-products" aria-labelledby="tab-products">
@if (facade.productAnalytics(); as pa) {
<div class="summary-grid">
<div class="summary-card pending"><span>{{ 'adminAnalytics.topViewed' | translate }}</span><app-badge variant="neutral">{{ 'adminAnalytics.unknown' | translate }}</app-badge></div>
<div class="summary-card"><span>{{ 'adminAnalytics.hiddenProducts' | translate }}</span><strong>{{ pa.hiddenCount }}</strong></div>
<div class="summary-card"><span>{{ 'adminAnalytics.archivedProducts' | translate }}</span><strong>{{ pa.archivedCount }}</strong></div>
</div>
<div class="grid-2">
<div class="card">
<h2>{{ 'adminAnalytics.mostReviewed' | translate }}</h2>
@if (pa.mostReviewed.length === 0) {
<app-empty-state [title]="'adminAnalytics.noReviewedProducts' | translate" />
} @else {
<app-table>
<thead><tr><th>{{ 'adminProducts.name' | translate }}</th><th>{{ 'adminAnalytics.reviewCount' | translate }}</th></tr></thead>
<tbody>@for (row of pa.mostReviewed; track row.productId) { <tr><td>{{ row.name }}</td><td>{{ row.value }}</td></tr> }</tbody>
</app-table>
}
</div>
<div class="card">
<h2>{{ 'adminAnalytics.worstRated' | translate }}</h2>
@if (pa.worstRated.length === 0) {
<app-empty-state [title]="'adminAnalytics.noReviewedProducts' | translate" />
} @else {
<app-table>
<thead><tr><th>{{ 'adminProducts.name' | translate }}</th><th>{{ 'adminAnalytics.avgRating' | translate }}</th></tr></thead>
<tbody>@for (row of pa.worstRated; track row.productId) { <tr><td>{{ row.name }}</td><td>{{ row.value }}</td></tr> }</tbody>
</app-table>
}
</div>
</div>
}
</div>
}
@if (activeTab() === 'customers') {
<div class="panel" role="tabpanel" id="panel-customers" aria-labelledby="tab-customers">
@if (facade.customerAnalytics(); as ca) {
<div class="summary-grid">
<div class="summary-card"><span>{{ 'adminAnalytics.newCustomers' | translate }}</span><strong>{{ ca.newCustomers }}</strong></div>
<div class="summary-card"><span>{{ 'adminAnalytics.returningCustomers' | translate }}</span><strong>{{ ca.returningCustomers }}</strong></div>
<div class="summary-card"><span>{{ 'adminAnalytics.averageSpend' | translate }}</span><strong>{{ ca.averageSpend }} {{ ca.currency }}</strong></div>
<div class="summary-card">
<span>{{ 'adminAnalytics.retention' | translate }}</span>
@if (ca.retentionPercent === null) {
<app-badge variant="neutral">{{ 'adminAnalytics.unknown' | translate }}</app-badge>
} @else {
<strong>{{ ca.retentionPercent }}%</strong>
}
</div>
</div>
}
</div>
}
@if (activeTab() === 'search') {
<div class="panel" role="tabpanel" id="panel-search" aria-labelledby="tab-search">
<div class="card pending-section">
<h2>{{ 'adminAnalytics.searchAnalytics' | translate }}</h2>
<p><app-badge variant="neutral">{{ 'adminAnalytics.unknown' | translate }}</app-badge> {{ 'adminAnalytics.pendingBackendHint' | translate }}</p>
</div>
</div>
}
@if (activeTab() === 'traffic') {
<div class="panel" role="tabpanel" id="panel-traffic" aria-labelledby="tab-traffic">
<div class="card pending-section">
<h2>{{ 'adminAnalytics.trafficAnalytics' | translate }}</h2>
<p><app-badge variant="neutral">{{ 'adminAnalytics.unknown' | translate }}</app-badge> {{ 'adminAnalytics.availableAfterBackend' | translate }}</p>
</div>
</div>
}
@if (activeTab() === 'recommendations') {
<div class="panel" role="tabpanel" id="panel-recommendations" aria-labelledby="tab-recommendations">
<div class="card">
<h2>{{ 'adminMarketplaceHealth.recommendationsTitle' | translate }}</h2>
@if (facade.recommendations().length === 0) {
<app-empty-state [title]="'adminMarketplaceHealth.recommendNone' | translate" />
} @else {
<ul class="recommendation-list">
@for (card of facade.recommendations(); track card.id) {
<li>
<app-badge [variant]="severityVariant(card.severity)">{{ 'adminMarketplaceHealth.severity.' + card.severity | translate }}</app-badge>
<a [routerLink]="card.route">{{ card.labelKey | translate }}</a>
@if (card.descriptionKey) { <span class="muted">{{ card.descriptionKey | translate }}</span> }
</li>
}
</ul>
}
</div>
</div>
}
</section>

View File

@@ -1,14 +1,57 @@
.admin-analytics-page { display: grid; gap: 16px; padding: 16px; max-width: 1100px; margin: 0 auto; }
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.toolbar-actions { display: flex; gap: 8px; }
.ranges { display: flex; gap: 6px; }
.tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--border-color, #d3dad9); }
.tab {
border: none;
background: transparent;
padding: 10px 14px;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary, #6b7280);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tab:hover { color: var(--text-primary, #1e3c38); }
.tab:focus-visible { outline: 2px solid var(--color-primary, #2f8f5b); outline-offset: 2px; }
.tab--active { color: var(--color-primary, #2f8f5b); border-bottom-color: var(--color-primary, #2f8f5b); }
.panel { display: grid; gap: 16px; }
.summary-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; }
.summary-card { border: 1px solid var(--border-color, #d3dad9); border-radius: 12px; padding: 12px; display: grid; gap: 4px; background: #fff; }
.summary-card span { font-size: 0.75rem; color: var(--text-secondary, #6b7280); }
.summary-card strong { font-size: 1.1rem; }
.card { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; }
.card-head { display: flex; justify-content: space-between; align-items: center; }
.card h2 { margin: 0; font-size: 1.1rem; }
.chart { display: flex; align-items: flex-end; gap: 3px; height: 140px; }
.chart .bar { flex: 1; background: var(--color-primary, #2f8f5b); border-radius: 3px 3px 0 0; min-height: 2px; }
.pending-section p { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--text-secondary, #6b7280); }
@media (max-width: 900px) { .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.skeleton-rows { display: grid; gap: 8px; }
.grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.activity-list, .warning-list, .recommendation-list, .health-checks { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; font-size: 0.85rem; }
.activity-list li { display: flex; justify-content: space-between; gap: 8px; }
.warning-list li, .recommendation-list li, .health-checks li { display: flex; align-items: center; gap: 10px; }
.recommendation-list a, .health-checks a { color: var(--text-primary, #1e3c38); text-decoration: underline; }
.recommendation-list a:focus-visible, .health-checks a:focus-visible { outline: 2px solid var(--color-primary, #2f8f5b); outline-offset: 2px; }
.muted { color: var(--text-secondary, #6b7280); font-size: 0.8rem; }
.health-widget__bar { height: 8px; border-radius: 999px; background: var(--surface-muted, #eef2f0); overflow: hidden; }
.health-widget__bar-fill { height: 100%; background: var(--brand-primary, #1e8a6e); transition: width 0.3s ease; }
.health-percent { margin: 0; font-size: 0.85rem; color: var(--text-secondary, #6b7280); }
@media (max-width: 900px) {
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-2 { grid-template-columns: 1fr; }
}
@media (max-width: 560px) {
.summary-grid { grid-template-columns: 1fr; }
.tabs { overflow-x: auto; flex-wrap: nowrap; }
}

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { AdminAnalyticsFacade } from '../facade/admin-analytics.facade';
import { AdminAnalyticsDateRange } from '../models/admin-analytics.model';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
@@ -9,10 +10,14 @@ import { TableComponent } from '../../../../shared/ui/table/table.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
export type AdminAnalyticsTab = 'overview' | 'health' | 'products' | 'customers' | 'search' | 'traffic' | 'recommendations';
const TABS: AdminAnalyticsTab[] = ['overview', 'health', 'products', 'customers', 'search', 'traffic', 'recommendations'];
@Component({
selector: 'app-admin-analytics-page',
standalone: true,
imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent, SkeletonComponent, EmptyStateComponent],
imports: [CommonModule, RouterLink, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent, SkeletonComponent, EmptyStateComponent],
templateUrl: './admin-analytics-page.component.html',
styleUrls: ['./admin-analytics-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -21,22 +26,56 @@ export class AdminAnalyticsPageComponent {
readonly facade = inject(AdminAnalyticsFacade);
readonly ranges: AdminAnalyticsDateRange[] = [7, 30, 90];
readonly tabs = TABS;
readonly activeTab = signal<AdminAnalyticsTab>('overview');
constructor() {
this.facade.load();
}
selectTab(tab: AdminAnalyticsTab): void {
this.activeTab.set(tab);
}
barHeight(value: number): number {
return Math.max(2, Math.round((value / this.facade.maxSeriesValue()) * 100));
}
healthStatusVariant(status: string): 'success' | 'warning' | 'danger' | 'neutral' {
switch (status) {
case 'healthy': return 'success';
case 'attention': return 'warning';
case 'unhealthy': return 'danger';
default: return 'neutral';
}
}
severityVariant(severity: string): 'warning' | 'danger' | 'info' {
return severity === 'critical' ? 'danger' : severity === 'warning' ? 'warning' : 'info';
}
exportCsv(): void {
const csv = this.facade.exportCsv();
this.download(this.facade.exportCsv(), 'sales.csv');
}
exportTopProductsCsv(): void {
this.download(this.facade.exportTopProductsCsv(), 'top-products.csv');
}
exportHealthCsv(): void {
this.download(this.facade.exportHealthCsv(), 'marketplace-health.csv');
}
print(): void {
window.print();
}
private download(csv: string, filename: string): void {
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'sales.csv';
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}

View File

@@ -1,4 +1,15 @@
<section class="admin-monitoring-page">
<div class="card">
<h2>{{ 'adminMonitoring.systemState' | translate }}</h2>
<div class="health-grid">
<app-badge [variant]="dashboardFacade.bootstrap() ? 'success' : 'neutral'">{{ 'adminMonitoring.backendConnectivity' | translate }}: {{ dashboardFacade.bootstrap() ? ('adminMonitoring.connected' | translate) : ('adminMonitoring.loading' | translate) }}</app-badge>
<app-badge [variant]="dashboardFacade.dirty() ? 'warning' : 'success'">{{ 'adminMonitoring.draftState' | translate }}: {{ dashboardFacade.dirty() ? ('adminMonitoring.unsavedChanges' | translate) : ('adminMonitoring.noDraft' | translate) }}</app-badge>
<app-badge variant="neutral">{{ 'adminMonitoring.lastSaved' | translate }}: {{ dashboardFacade.lastSavedAt() ? (dashboardFacade.lastSavedAt() | date:'short') : ('adminAnalytics.unknown' | translate) }}</app-badge>
<app-badge variant="neutral">{{ 'adminMonitoring.lastPublished' | translate }}: {{ dashboardFacade.lastPublishedAt() ? (dashboardFacade.lastPublishedAt() | date:'short') : ('adminAnalytics.unknown' | translate) }}</app-badge>
<app-badge variant="neutral">{{ 'adminMonitoring.syncStatus' | translate }}: {{ 'adminAnalytics.unknown' | translate }}</app-badge>
</div>
</div>
<div class="card">
<h2>{{ 'adminMonitoring.health' | translate }}</h2>
<div class="health-grid">