From e153a67ec08c557e89457d38e6f8740a471c3e53 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 25 Jul 2026 21:31:30 +0400 Subject: [PATCH] fix(backoffice): add error+retry states to Users, Monitoring, Analytics, Reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8 (RC-01): these 4 list/dashboard pages had no error-state handling on their primary data-load subscriptions — on a gateway error, `loading` was either never reset (Users, Monitoring, Analytics: genuine infinite- spinner risk, nested subscribe chain in Analytics never resolved on failure) or there was no loading/empty/error handling at all (Reports queue: raw table with zero skeleton or fallback). - admin-users.facade.ts, admin-monitoring.facade.ts: add `error` signal, error callback on the primary load subscribe so `loading` always resolves. - admin-analytics.facade.ts: add `error` signal; every level of the 4-deep nested gateway subscribe chain (orders -> products -> categories -> reviews) now has an error handler that resolves loading instead of leaving it stuck true. - admin-moderation.facade.ts: add `reportsLoading`/`reportsError` signals (reports list had none previously). - Templates: reuse existing `app-skeleton`/`app-empty-state`/`app-button` primitives for the new error branch, `common.retry` label, two new generic `common.errorTitle`/`common.errorDescription` i18n keys added to en/ru/hy (reused across all 4 fixes instead of one-off per-page copy). Verified: tsc --noEmit clean, `npm run build` green (pre-existing bundle- budget warning only, unrelated). Live-checked Home (375px) and Backoffice Products (1024px) — no console errors, tables/cards render without overflow. Co-Authored-By: Claude Sonnet 5 --- .../facade/admin-analytics.facade.ts | 80 +++++++++++-------- .../pages/admin-analytics-page.component.html | 9 +++ .../facade/admin-moderation.facade.ts | 9 ++- .../admin-reports-list-page.component.html | 13 ++- .../admin-reports-list-page.component.ts | 3 +- .../facade/admin-monitoring.facade.ts | 7 +- .../admin-monitoring-page.component.html | 6 ++ .../pages/admin-monitoring-page.component.ts | 3 +- .../admin/users/facade/admin-users.facade.ts | 7 +- .../pages/admin-users-page.component.html | 6 ++ src/app/i18n/en.ts | 2 + src/app/i18n/hy.ts | 2 + src/app/i18n/ru.ts | 2 + src/app/i18n/translations.ts | 2 + 14 files changed, 113 insertions(+), 38 deletions(-) diff --git a/src/app/features/admin/analytics/facade/admin-analytics.facade.ts b/src/app/features/admin/analytics/facade/admin-analytics.facade.ts index 7551dfa..6daae5c 100644 --- a/src/app/features/admin/analytics/facade/admin-analytics.facade.ts +++ b/src/app/features/admin/analytics/facade/admin-analytics.facade.ts @@ -38,6 +38,7 @@ export class AdminAnalyticsFacade { readonly dateRange = signal(30); readonly loading = signal(false); + readonly error = signal(false); readonly summary = signal(null); readonly salesSeries = signal([]); readonly topProducts = signal([]); @@ -63,6 +64,7 @@ export class AdminAnalyticsFacade { load(): void { this.loading.set(true); + this.error.set(false); this.dashboardFacade.ensureLoaded(); this.recentActivity.set( this.dashboardFacade.activityEntries().map(entry => ({ @@ -72,44 +74,58 @@ export class AdminAnalyticsFacade { })), ); - 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); + const fail = (): void => { this.loading.set(false); this.error.set(true); }; - this.salesSeries.set(this.buildSeries(inRange, this.dateRange())); - this.topProducts.set(this.buildTopProducts(inRange)); - this.customerAnalytics.set(this.buildCustomerAnalytics(orderResult.items, inRange, this.dateRange())); + this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ + next: orderResult => { + const cutoff = Date.now() - this.dateRange() * 24 * 60 * 60 * 1000; + const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff); - 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.salesSeries.set(this.buildSeries(inRange, this.dateRange())); + this.topProducts.set(this.buildTopProducts(inRange)); + this.customerAnalytics.set(this.buildCustomerAnalytics(orderResult.items, inRange, this.dateRange())); - 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.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe(reviewResult => { - const products = productResult.items; - const reviews = reviewResult.items; + 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.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.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ + next: productResult => { + this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({ + next: categories => { + this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ + next: 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); + }, + error: fail + }); + }, + error: fail }); - - 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); - }); + }, + error: fail }); - }); + }, + error: fail }); } 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 index 2f7610e..de0d904 100644 --- a/src/app/features/admin/analytics/pages/admin-analytics-page.component.html +++ b/src/app/features/admin/analytics/pages/admin-analytics-page.component.html @@ -11,6 +11,14 @@ + @if (facade.error()) { + + + {{ 'common.retry' | translate }} + + + } @else { +
@for (tab of tabs; track tab) {
} + } diff --git a/src/app/features/admin/moderation/facade/admin-moderation.facade.ts b/src/app/features/admin/moderation/facade/admin-moderation.facade.ts index 5e82698..07d151c 100644 --- a/src/app/features/admin/moderation/facade/admin-moderation.facade.ts +++ b/src/app/features/admin/moderation/facade/admin-moderation.facade.ts @@ -47,6 +47,8 @@ export class AdminModerationFacade { readonly loading = signal(false); readonly selected = signal(null); readonly reports = signal([]); + readonly reportsLoading = signal(false); + readonly reportsError = signal(false); readonly viewMode = signal((this.localStorage.getItem(VIEW_MODE_KEY) as AdminModerationViewMode) || 'table'); readonly density = signal((this.localStorage.getItem(DENSITY_KEY) as AdminModerationDensity) || 'comfortable'); @@ -167,7 +169,12 @@ export class AdminModerationFacade { } loadReports(): void { - this.gateway.loadReports().pipe(take(1)).subscribe({ next: reports => this.reports.set(reports) }); + this.reportsLoading.set(true); + this.reportsError.set(false); + this.gateway.loadReports().pipe(take(1)).subscribe({ + next: reports => { this.reports.set(reports); this.reportsLoading.set(false); }, + error: () => { this.reports.set([]); this.reportsLoading.set(false); this.reportsError.set(true); } + }); } setReportStatus(id: string, status: AdminReportStatus): void { diff --git a/src/app/features/admin/moderation/pages/admin-reports-list-page.component.html b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.html index 8d96d3a..b415436 100644 --- a/src/app/features/admin/moderation/pages/admin-reports-list-page.component.html +++ b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.html @@ -4,7 +4,18 @@

{{ 'adminModeration.reportsQueue' | translate }}

- @if (facade.reports().length === 0) { + @if (facade.reportsLoading()) { +
+ @for (i of [1,2,3,4]; track i) { } + {{ 'common.loading' | translate }} +
+ } @else if (facade.reportsError()) { + + + {{ 'common.retry' | translate }} + + + } @else if (facade.reports().length === 0) { } @else { diff --git a/src/app/features/admin/moderation/pages/admin-reports-list-page.component.ts b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.ts index db11129..d398b0e 100644 --- a/src/app/features/admin/moderation/pages/admin-reports-list-page.component.ts +++ b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.ts @@ -9,11 +9,12 @@ import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; import { TableComponent } from '../../../../shared/ui/table/table.component'; import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component'; +import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component'; @Component({ selector: 'app-admin-reports-list-page', standalone: true, - imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent, EmptyStateComponent], + imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent, EmptyStateComponent, SkeletonComponent], templateUrl: './admin-reports-list-page.component.html', styleUrls: ['./admin-reports-list-page.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/app/features/admin/monitoring/facade/admin-monitoring.facade.ts b/src/app/features/admin/monitoring/facade/admin-monitoring.facade.ts index b7c1acb..a58b2b6 100644 --- a/src/app/features/admin/monitoring/facade/admin-monitoring.facade.ts +++ b/src/app/features/admin/monitoring/facade/admin-monitoring.facade.ts @@ -12,10 +12,15 @@ export class AdminMonitoringFacade { readonly queues = signal([]); readonly webhooks = signal([]); readonly loading = signal(false); + readonly error = signal(false); loadAll(): void { this.loading.set(true); - this.gateway.loadEvents(this.filters()).pipe(take(1)).subscribe(events => { this.events.set(events); this.loading.set(false); }); + this.error.set(false); + this.gateway.loadEvents(this.filters()).pipe(take(1)).subscribe({ + next: events => { this.events.set(events); this.loading.set(false); }, + error: () => { this.events.set([]); this.loading.set(false); this.error.set(true); } + }); this.gateway.loadQueues().pipe(take(1)).subscribe(queues => this.queues.set(queues)); this.gateway.loadWebhooks().pipe(take(1)).subscribe(webhooks => this.webhooks.set(webhooks)); } diff --git a/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.html b/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.html index 570cb56..ff96129 100644 --- a/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.html +++ b/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.html @@ -73,6 +73,12 @@ } {{ 'common.loading' | translate }} + } @else if (facade.error()) { + + + {{ 'common.retry' | translate }} + + } @else if (facade.events().length === 0) { } @else { diff --git a/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.ts b/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.ts index 747980d..72053d0 100644 --- a/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.ts +++ b/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.ts @@ -9,11 +9,12 @@ import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; 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'; +import { ButtonComponent } from '../../../../shared/ui/button/button.component'; @Component({ selector: 'app-admin-monitoring-page', standalone: true, - imports: [CommonModule, FormsModule, TranslatePipe, InputComponent, BadgeComponent, TableComponent, SkeletonComponent, EmptyStateComponent], + imports: [CommonModule, FormsModule, TranslatePipe, InputComponent, BadgeComponent, TableComponent, SkeletonComponent, EmptyStateComponent, ButtonComponent], templateUrl: './admin-monitoring-page.component.html', styleUrls: ['./admin-monitoring-page.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/app/features/admin/users/facade/admin-users.facade.ts b/src/app/features/admin/users/facade/admin-users.facade.ts index 3fc0a67..3b95261 100644 --- a/src/app/features/admin/users/facade/admin-users.facade.ts +++ b/src/app/features/admin/users/facade/admin-users.facade.ts @@ -11,6 +11,7 @@ export class AdminUsersFacade { readonly roles = signal([]); readonly invitations = signal([]); readonly loading = signal(false); + readonly error = signal(false); readonly sessionsTarget = signal(null); readonly sessions = signal([]); readonly auditTarget = signal(null); @@ -18,7 +19,11 @@ export class AdminUsersFacade { loadAll(): void { this.loading.set(true); - this.gateway.loadUsers().pipe(take(1)).subscribe(users => { this.users.set(users); this.loading.set(false); }); + this.error.set(false); + this.gateway.loadUsers().pipe(take(1)).subscribe({ + next: users => { this.users.set(users); this.loading.set(false); }, + error: () => { this.users.set([]); this.loading.set(false); this.error.set(true); } + }); this.gateway.loadRoles().pipe(take(1)).subscribe(roles => this.roles.set(roles)); this.gateway.loadInvitations().pipe(take(1)).subscribe(invitations => this.invitations.set(invitations)); } diff --git a/src/app/features/admin/users/pages/admin-users-page.component.html b/src/app/features/admin/users/pages/admin-users-page.component.html index 1cbe1ca..0840a66 100644 --- a/src/app/features/admin/users/pages/admin-users-page.component.html +++ b/src/app/features/admin/users/pages/admin-users-page.component.html @@ -6,6 +6,12 @@ @for (i of [1,2,3]; track i) { } {{ 'common.loading' | translate }} + } @else if (facade.error()) { + + + {{ 'common.retry' | translate }} + + } @else if (facade.users().length === 0) { } @else { diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index bdd9fc3..97865e6 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -1084,6 +1084,8 @@ export const en: Translations = { retry: 'Try again', loading: 'Loading...', remove: 'Remove', + errorTitle: 'Something went wrong', + errorDescription: 'We could not load this data. Please try again.', }, location: { allRegions: 'All regions', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index aafebbe..f128f1a 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -1084,6 +1084,8 @@ export const hy: Translations = { retry: 'Փորձել կրկին', loading: 'Բեռնում...', remove: 'Հեռացնել', + errorTitle: 'Ինչ-որ բան այն չէ', + errorDescription: 'Չհաջողվեց բեռնել տվյալները։ Փորձեք կրկին։', }, location: { allRegions: 'Բոլոր տարածաշրջանները', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 1cfd6b9..6b16636 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -1084,6 +1084,8 @@ export const ru: Translations = { retry: 'Попробовать снова', loading: 'Загрузка...', remove: 'Удалить', + errorTitle: 'Что-то пошло не так', + errorDescription: 'Не удалось загрузить данные. Попробуйте ещё раз.', }, location: { allRegions: 'Все регионы', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 419f1c6..ceade53 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -1083,6 +1083,8 @@ export interface Translations { retry: string; loading: string; remove: string; + errorTitle: string; + errorDescription: string; }; location: { allRegions: string;