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

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