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