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:
@@ -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