diff --git a/docs/ADMIN.md b/docs/ADMIN.md index 749ca7c..2d7ac87 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -391,6 +391,31 @@ routes at all, this is a net-new admin section. audit and Sprint 25's per-user audit. No consolidation attempted - they track different things. +## Sprint 27 - Analytics + +`features/admin/analytics/`, net-new `/​:lang/backoffice/analytics` route ++ Dashboard Quick Action. + +- **Real, derived data**: revenue/orders/avg-order-value/sales-over-time + chart/top-products are computed by composing the existing + `AdminOrdersLocalGateway` (Sprint 23's seeded mock orders) - not a + separate fabricated dataset. Products/Categories counts come from + `AdminProductsLocalGateway`/`AdminCategoriesLocalGateway`. All of this is + still ultimately backed by mock order/product/category data (per those + sprints), but the *aggregation* is real arithmetic over that data, not + invented numbers. +- **Visitors, funnels, heatmaps**: no analytics/tracking pipeline exists + anywhere in this codebase, so these render an explicit + "Awaiting backend integration" (`pending-backend`) badge, same convention + as the Sprint 19 dashboard's Orders/Revenue cards before Sprint 23 - + not fabricated numbers, not a generic empty state. +- **Chart**: a plain inline `
`-bar chart driven by `[style.height.%]`, + no charting library pulled in - reasonable for one sales-over-time series + at this scale; revisit if more chart types are actually needed. +- **Date ranges**: 7/30/90-day toggle filters orders by `createdAt`. +- **Export**: CSV of the sales series (client-side `Blob` download, same + pattern as Orders/Transactions). + ## Known gaps / backend needs - **Dashboard metrics endpoint.** Categories/Products counts are computed diff --git a/docs/BACKEND.md b/docs/BACKEND.md index 6189976..1a16b9c 100644 --- a/docs/BACKEND.md +++ b/docs/BACKEND.md @@ -168,6 +168,14 @@ Plus, if authenticated history/wishlist/compare/saved-searches sync is wanted: ` **Frontend files:** implement `AdminMonitoringApiGateway`-equivalent methods against a to-be-defined `AdminMonitoringGateway` interface (`AdminMonitoringLocalGateway` currently has no interface extracted — add one when a real implementation is built, mirroring the pattern used everywhere else in `admin/*`). +## 16. Analytics - visitors/funnels/heatmaps (Sprint 27, no backend at all) + +**Current behavior:** `features/admin/analytics/` computes real revenue/orders/top-products aggregations from the existing mock order data (see item 7), but visitor traffic, conversion funnels, and heatmaps have zero data source anywhere in this system (no analytics/tracking pipeline, no event collection) — these render `pending-backend` badges rather than fabricated numbers. + +**Needed:** a traffic/event tracking pipeline (page views, sessions, conversion events) and a funnel/heatmap aggregation service, before this section of the Analytics page can show anything real. + +**Frontend files:** `features/admin/analytics/pages/admin-analytics-page.component.html` currently renders the pending-backend badge inline (no gateway method exists for this yet, unlike every other mocked domain in this doc). + ## Known reliability issues ### Production 502/504 Bad Gateway on refresh / back-navigation diff --git a/docs/SPRINT-PLAN.md b/docs/SPRINT-PLAN.md index 421a1cd..5ed9fee 100644 --- a/docs/SPRINT-PLAN.md +++ b/docs/SPRINT-PLAN.md @@ -64,8 +64,9 @@ Notify user: **from Sprint 20 (Categories) once product↔category link + admin - [x] `docs/ADMIN.md` (new Sprint 26 section), `docs/BACKEND.md` item 15 added - Commit: `feat(admin): monitoring center` -## Sprint 27 — Analytics -- [ ] Dashboard extensions: sales/revenue/orders/visitors/products/categories charts, date ranges, export +## Sprint 27 — Analytics ✅ done +- [x] Sales/revenue/orders/products/categories (real aggregation over mock order data), visitors/funnels/heatmaps (pending-backend badges, no fabricated data), sales-over-time bar chart, 7/30/90-day ranges, CSV export +- [x] `docs/ADMIN.md` (new Sprint 27 section), `docs/BACKEND.md` item 16 added - Commit: `feat(admin): analytics dashboard` ## Sprint 28 — Marketplace Polish diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 14113ca..0c32b87 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -116,6 +116,10 @@ const coreRoutes: Routes = [ path: 'monitoring', loadComponent: () => import('./features/admin/monitoring/pages/admin-monitoring-page.component').then(m => m.AdminMonitoringPageComponent) }, + { + path: 'analytics', + loadComponent: () => import('./features/admin/analytics/pages/admin-analytics-page.component').then(m => m.AdminAnalyticsPageComponent) + }, { path: '**', redirectTo: 'dashboard' } ] }, diff --git a/src/app/features/admin/analytics/facade/admin-analytics.facade.ts b/src/app/features/admin/analytics/facade/admin-analytics.facade.ts new file mode 100644 index 0000000..659b303 --- /dev/null +++ b/src/app/features/admin/analytics/facade/admin-analytics.facade.ts @@ -0,0 +1,89 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { take } from 'rxjs/operators'; +import { AdminAnalyticsDateRange, AdminAnalyticsSeriesPoint, AdminAnalyticsSummary, AdminAnalyticsTopProduct } 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 { AdminOrder } from '../../orders/models/admin-order.model'; + +@Injectable({ providedIn: 'root' }) +export class AdminAnalyticsFacade { + private readonly ordersGateway = inject(AdminOrdersLocalGateway); + private readonly productsGateway = inject(AdminProductsLocalGateway); + private readonly categoriesGateway = inject(AdminCategoriesLocalGateway); + + readonly dateRange = signal(30); + readonly loading = signal(false); + readonly summary = signal(null); + readonly salesSeries = signal([]); + readonly topProducts = signal([]); + + readonly maxSeriesValue = computed(() => Math.max(1, ...this.salesSeries().map(point => point.value))); + + load(): void { + this.loading.set(true); + this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 1000 }).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)); + + const revenueTotal = inRange.reduce((sum, order) => sum + order.total, 0); + const ordersCount = inRange.length; + + this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 1 }).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.loading.set(false); + }); + }); + }); + } + + setDateRange(range: AdminAnalyticsDateRange): void { + this.dateRange.set(range); + this.load(); + } + + exportCsv(): string { + const header = 'Date,Revenue'; + const rows = this.salesSeries().map(point => `${point.date},${point.value}`); + return [header, ...rows].join('\n'); + } + + private buildSeries(orders: AdminOrder[], days: number): AdminAnalyticsSeriesPoint[] { + const buckets = new Map(); + for (let i = days - 1; i >= 0; i--) { + const date = new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + buckets.set(date, 0); + } + for (const order of orders) { + const date = order.createdAt.slice(0, 10); + if (buckets.has(date)) { + buckets.set(date, (buckets.get(date) ?? 0) + order.total); + } + } + return [...buckets.entries()].map(([date, value]) => ({ date, value })); + } + + private buildTopProducts(orders: AdminOrder[]): AdminAnalyticsTopProduct[] { + const map = new Map(); + for (const order of orders) { + for (const item of order.items) { + const existing = map.get(item.productId) ?? { productId: item.productId, name: item.name, quantity: 0, revenue: 0 }; + existing.quantity += item.quantity; + existing.revenue += item.price * item.quantity; + map.set(item.productId, existing); + } + } + return [...map.values()].sort((left, right) => right.revenue - left.revenue).slice(0, 5); + } +} diff --git a/src/app/features/admin/analytics/models/admin-analytics.model.ts b/src/app/features/admin/analytics/models/admin-analytics.model.ts new file mode 100644 index 0000000..34ec245 --- /dev/null +++ b/src/app/features/admin/analytics/models/admin-analytics.model.ts @@ -0,0 +1,22 @@ +export type AdminAnalyticsDateRange = 7 | 30 | 90; + +export interface AdminAnalyticsSummary { + revenueTotal: number; + currency: string; + ordersCount: number; + avgOrderValue: number; + productsCount: number; + categoriesCount: number; +} + +export interface AdminAnalyticsSeriesPoint { + date: string; + value: number; +} + +export interface AdminAnalyticsTopProduct { + productId: string; + name: string; + quantity: number; + revenue: number; +} diff --git a/src/app/features/admin/analytics/pages/admin-analytics-page.component.html b/src/app/features/admin/analytics/pages/admin-analytics-page.component.html new file mode 100644 index 0000000..eec43fb --- /dev/null +++ b/src/app/features/admin/analytics/pages/admin-analytics-page.component.html @@ -0,0 +1,57 @@ +
+
+
+ @for (range of ranges; track range) { + {{ range }}d + } +
+ {{ 'adminOrders.export' | translate }} +
+ + @if (facade.summary(); as summary) { +
+
{{ 'adminAnalytics.revenue' | translate }}{{ summary.revenueTotal }} {{ summary.currency }}
+
{{ 'adminAnalytics.orders' | translate }}{{ summary.ordersCount }}
+
{{ 'adminAnalytics.avgOrderValue' | translate }}{{ summary.avgOrderValue }} {{ summary.currency }}
+
{{ 'adminAnalytics.products' | translate }}{{ summary.productsCount }}
+
{{ 'adminAnalytics.categories' | translate }}{{ summary.categoriesCount }}
+
{{ 'adminAnalytics.visitors' | translate }}{{ 'adminAnalytics.pendingBackend' | translate }}
+
+ } + +
+

{{ 'adminAnalytics.salesChart' | translate }}

+
+ @for (point of facade.salesSeries(); track point.date) { +
+ } +
+
+ +
+

{{ 'adminAnalytics.topProducts' | translate }}

+ + + + {{ 'adminProducts.name' | translate }} + {{ 'adminAnalytics.quantitySold' | translate }} + {{ 'adminAnalytics.revenue' | translate }} + + + + @for (product of facade.topProducts(); track product.productId) { + + {{ product.name }} + {{ product.quantity }} + {{ product.revenue }} + + } + + +
+ +
+

{{ 'adminAnalytics.funnelsHeatmaps' | translate }}

+

{{ 'adminAnalytics.pendingBackend' | translate }} {{ 'adminAnalytics.pendingBackendHint' | translate }}

+
+
diff --git a/src/app/features/admin/analytics/pages/admin-analytics-page.component.scss b/src/app/features/admin/analytics/pages/admin-analytics-page.component.scss new file mode 100644 index 0000000..646df8b --- /dev/null +++ b/src/app/features/admin/analytics/pages/admin-analytics-page.component.scss @@ -0,0 +1,13 @@ +.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; } +.ranges { display: flex; gap: 6px; } +.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 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)); } } diff --git a/src/app/features/admin/analytics/pages/admin-analytics-page.component.ts b/src/app/features/admin/analytics/pages/admin-analytics-page.component.ts new file mode 100644 index 0000000..0a3dd45 --- /dev/null +++ b/src/app/features/admin/analytics/pages/admin-analytics-page.component.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AdminAnalyticsFacade } from '../facade/admin-analytics.facade'; +import { AdminAnalyticsDateRange } from '../models/admin-analytics.model'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { ButtonComponent } from '../../../../shared/ui/button/button.component'; +import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; +import { TableComponent } from '../../../../shared/ui/table/table.component'; + +@Component({ + selector: 'app-admin-analytics-page', + standalone: true, + imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent], + templateUrl: './admin-analytics-page.component.html', + styleUrls: ['./admin-analytics-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AdminAnalyticsPageComponent { + readonly facade = inject(AdminAnalyticsFacade); + + readonly ranges: AdminAnalyticsDateRange[] = [7, 30, 90]; + + constructor() { + this.facade.load(); + } + + barHeight(value: number): number { + return Math.max(2, Math.round((value / this.facade.maxSeriesValue()) * 100)); + } + + exportCsv(): void { + const csv = this.facade.exportCsv(); + 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.click(); + URL.revokeObjectURL(url); + } +} diff --git a/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts b/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts index 2acd828..36bb33f 100644 --- a/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts +++ b/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts @@ -20,6 +20,7 @@ const QUICK_ACTIONS: AdminDashboardQuickAction[] = [ { id: 'media-library', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] }, { id: 'users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] }, { id: 'monitoring', labelKey: 'dashboard.actionMonitoring', route: ['backoffice', 'monitoring'] }, + { id: 'analytics', labelKey: 'dashboard.actionAnalytics', route: ['backoffice', 'analytics'] }, { id: 'preview-marketplace', labelKey: 'dashboard.actionPreviewMarketplace', route: [''] }, ]; diff --git a/src/app/features/admin/dashboard/models/admin-dashboard.model.ts b/src/app/features/admin/dashboard/models/admin-dashboard.model.ts index c9f4797..54c29a5 100644 --- a/src/app/features/admin/dashboard/models/admin-dashboard.model.ts +++ b/src/app/features/admin/dashboard/models/admin-dashboard.model.ts @@ -20,6 +20,7 @@ export type AdminDashboardQuickActionId = | 'media-library' | 'users' | 'monitoring' + | 'analytics' | 'preview-marketplace'; export interface AdminDashboardQuickAction {