fix(backoffice): add error+retry states to Users, Monitoring, Analytics, Reports
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 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@ export class AdminAnalyticsFacade {
|
||||
|
||||
readonly dateRange = signal<AdminAnalyticsDateRange>(30);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal(false);
|
||||
readonly summary = signal<AdminAnalyticsSummary | null>(null);
|
||||
readonly salesSeries = signal<AdminAnalyticsSeriesPoint[]>([]);
|
||||
readonly topProducts = signal<AdminAnalyticsTopProduct[]>([]);
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (facade.error()) {
|
||||
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate">
|
||||
<span slot="actions">
|
||||
<app-button variant="primary" (click)="facade.load()">{{ 'common.retry' | translate }}</app-button>
|
||||
</span>
|
||||
</app-empty-state>
|
||||
} @else {
|
||||
|
||||
<div class="tabs" role="tablist" [attr.aria-label]="'adminAnalytics.tabsLabel' | translate">
|
||||
@for (tab of tabs; track tab) {
|
||||
<button
|
||||
@@ -245,4 +253,5 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user