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 dateRange = signal<AdminAnalyticsDateRange>(30);
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
|
readonly error = signal(false);
|
||||||
readonly summary = signal<AdminAnalyticsSummary | null>(null);
|
readonly summary = signal<AdminAnalyticsSummary | null>(null);
|
||||||
readonly salesSeries = signal<AdminAnalyticsSeriesPoint[]>([]);
|
readonly salesSeries = signal<AdminAnalyticsSeriesPoint[]>([]);
|
||||||
readonly topProducts = signal<AdminAnalyticsTopProduct[]>([]);
|
readonly topProducts = signal<AdminAnalyticsTopProduct[]>([]);
|
||||||
@@ -63,6 +64,7 @@ export class AdminAnalyticsFacade {
|
|||||||
|
|
||||||
load(): void {
|
load(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
this.error.set(false);
|
||||||
this.dashboardFacade.ensureLoaded();
|
this.dashboardFacade.ensureLoaded();
|
||||||
this.recentActivity.set(
|
this.recentActivity.set(
|
||||||
this.dashboardFacade.activityEntries().map(entry => ({
|
this.dashboardFacade.activityEntries().map(entry => ({
|
||||||
@@ -72,7 +74,10 @@ export class AdminAnalyticsFacade {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe(orderResult => {
|
const fail = (): void => { this.loading.set(false); this.error.set(true); };
|
||||||
|
|
||||||
|
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 cutoff = Date.now() - this.dateRange() * 24 * 60 * 60 * 1000;
|
||||||
const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff);
|
const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff);
|
||||||
|
|
||||||
@@ -84,9 +89,12 @@ export class AdminAnalyticsFacade {
|
|||||||
const ordersCount = inRange.length;
|
const ordersCount = inRange.length;
|
||||||
const uniqueCustomers = new Set(inRange.map(order => order.customer.email)).size;
|
const uniqueCustomers = new Set(inRange.map(order => order.customer.email)).size;
|
||||||
|
|
||||||
this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe(productResult => {
|
this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
||||||
this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe(categories => {
|
next: productResult => {
|
||||||
this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe(reviewResult => {
|
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 products = productResult.items;
|
||||||
const reviews = reviewResult.items;
|
const reviews = reviewResult.items;
|
||||||
|
|
||||||
@@ -107,9 +115,17 @@ export class AdminAnalyticsFacade {
|
|||||||
this.recommendations.set(this.buildRecommendations(products, categories));
|
this.recommendations.set(this.buildRecommendations(products, categories));
|
||||||
|
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: fail
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
error: fail
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
error: fail
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
error: fail
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="tabs" role="tablist" [attr.aria-label]="'adminAnalytics.tabsLabel' | translate">
|
||||||
@for (tab of tabs; track tab) {
|
@for (tab of tabs; track tab) {
|
||||||
<button
|
<button
|
||||||
@@ -245,4 +253,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export class AdminModerationFacade {
|
|||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
readonly selected = signal<AdminReview | null>(null);
|
readonly selected = signal<AdminReview | null>(null);
|
||||||
readonly reports = signal<AdminReport[]>([]);
|
readonly reports = signal<AdminReport[]>([]);
|
||||||
|
readonly reportsLoading = signal(false);
|
||||||
|
readonly reportsError = signal(false);
|
||||||
|
|
||||||
readonly viewMode = signal<AdminModerationViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminModerationViewMode) || 'table');
|
readonly viewMode = signal<AdminModerationViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminModerationViewMode) || 'table');
|
||||||
readonly density = signal<AdminModerationDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminModerationDensity) || 'comfortable');
|
readonly density = signal<AdminModerationDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminModerationDensity) || 'comfortable');
|
||||||
@@ -167,7 +169,12 @@ export class AdminModerationFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadReports(): void {
|
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 {
|
setReportStatus(id: string, status: AdminReportStatus): void {
|
||||||
|
|||||||
@@ -4,7 +4,18 @@
|
|||||||
<h1>{{ 'adminModeration.reportsQueue' | translate }}</h1>
|
<h1>{{ 'adminModeration.reportsQueue' | translate }}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (facade.reports().length === 0) {
|
@if (facade.reportsLoading()) {
|
||||||
|
<div class="skeleton-rows" role="status" aria-live="polite" aria-busy="true">
|
||||||
|
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
||||||
|
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
} @else if (facade.reportsError()) {
|
||||||
|
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate">
|
||||||
|
<span slot="actions">
|
||||||
|
<app-button variant="primary" (click)="facade.loadReports()">{{ 'common.retry' | translate }}</app-button>
|
||||||
|
</span>
|
||||||
|
</app-empty-state>
|
||||||
|
} @else if (facade.reports().length === 0) {
|
||||||
<app-empty-state [title]="'adminModeration.reportsEmptyTitle' | translate" [description]="'adminModeration.reportsEmptyDescription' | translate" />
|
<app-empty-state [title]="'adminModeration.reportsEmptyTitle' | translate" [description]="'adminModeration.reportsEmptyDescription' | translate" />
|
||||||
} @else {
|
} @else {
|
||||||
<app-table>
|
<app-table>
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
|||||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||||
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||||
|
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-reports-list-page',
|
selector: 'app-admin-reports-list-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent, EmptyStateComponent],
|
imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent, EmptyStateComponent, SkeletonComponent],
|
||||||
templateUrl: './admin-reports-list-page.component.html',
|
templateUrl: './admin-reports-list-page.component.html',
|
||||||
styleUrls: ['./admin-reports-list-page.component.scss'],
|
styleUrls: ['./admin-reports-list-page.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
|||||||
@@ -12,10 +12,15 @@ export class AdminMonitoringFacade {
|
|||||||
readonly queues = signal<AdminQueue[]>([]);
|
readonly queues = signal<AdminQueue[]>([]);
|
||||||
readonly webhooks = signal<AdminWebhookDelivery[]>([]);
|
readonly webhooks = signal<AdminWebhookDelivery[]>([]);
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
|
readonly error = signal(false);
|
||||||
|
|
||||||
loadAll(): void {
|
loadAll(): void {
|
||||||
this.loading.set(true);
|
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.loadQueues().pipe(take(1)).subscribe(queues => this.queues.set(queues));
|
||||||
this.gateway.loadWebhooks().pipe(take(1)).subscribe(webhooks => this.webhooks.set(webhooks));
|
this.gateway.loadWebhooks().pipe(take(1)).subscribe(webhooks => this.webhooks.set(webhooks));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,12 @@
|
|||||||
}
|
}
|
||||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (facade.error()) {
|
||||||
|
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate">
|
||||||
|
<span slot="actions">
|
||||||
|
<app-button variant="primary" (click)="facade.loadAll()">{{ 'common.retry' | translate }}</app-button>
|
||||||
|
</span>
|
||||||
|
</app-empty-state>
|
||||||
} @else if (facade.events().length === 0) {
|
} @else if (facade.events().length === 0) {
|
||||||
<app-empty-state [title]="'adminMonitoring.eventsEmptyTitle' | translate" [description]="'adminMonitoring.eventsEmptyDescription' | translate" />
|
<app-empty-state [title]="'adminMonitoring.eventsEmptyTitle' | translate" [description]="'adminMonitoring.eventsEmptyDescription' | translate" />
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
|||||||
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||||
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-monitoring-page',
|
selector: 'app-admin-monitoring-page',
|
||||||
standalone: true,
|
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',
|
templateUrl: './admin-monitoring-page.component.html',
|
||||||
styleUrls: ['./admin-monitoring-page.component.scss'],
|
styleUrls: ['./admin-monitoring-page.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export class AdminUsersFacade {
|
|||||||
readonly roles = signal<AdminRole[]>([]);
|
readonly roles = signal<AdminRole[]>([]);
|
||||||
readonly invitations = signal<AdminInvitation[]>([]);
|
readonly invitations = signal<AdminInvitation[]>([]);
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
|
readonly error = signal(false);
|
||||||
readonly sessionsTarget = signal<AdminUser | null>(null);
|
readonly sessionsTarget = signal<AdminUser | null>(null);
|
||||||
readonly sessions = signal<AdminSession[]>([]);
|
readonly sessions = signal<AdminSession[]>([]);
|
||||||
readonly auditTarget = signal<AdminUser | null>(null);
|
readonly auditTarget = signal<AdminUser | null>(null);
|
||||||
@@ -18,7 +19,11 @@ export class AdminUsersFacade {
|
|||||||
|
|
||||||
loadAll(): void {
|
loadAll(): void {
|
||||||
this.loading.set(true);
|
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.loadRoles().pipe(take(1)).subscribe(roles => this.roles.set(roles));
|
||||||
this.gateway.loadInvitations().pipe(take(1)).subscribe(invitations => this.invitations.set(invitations));
|
this.gateway.loadInvitations().pipe(take(1)).subscribe(invitations => this.invitations.set(invitations));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
@for (i of [1,2,3]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
@for (i of [1,2,3]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
||||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (facade.error()) {
|
||||||
|
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate">
|
||||||
|
<span slot="actions">
|
||||||
|
<app-button variant="primary" (click)="facade.loadAll()">{{ 'common.retry' | translate }}</app-button>
|
||||||
|
</span>
|
||||||
|
</app-empty-state>
|
||||||
} @else if (facade.users().length === 0) {
|
} @else if (facade.users().length === 0) {
|
||||||
<app-empty-state [title]="'adminUsers.emptyTitle' | translate" [description]="'adminUsers.emptyDescription' | translate" />
|
<app-empty-state [title]="'adminUsers.emptyTitle' | translate" [description]="'adminUsers.emptyDescription' | translate" />
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -1084,6 +1084,8 @@ export const en: Translations = {
|
|||||||
retry: 'Try again',
|
retry: 'Try again',
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
remove: 'Remove',
|
remove: 'Remove',
|
||||||
|
errorTitle: 'Something went wrong',
|
||||||
|
errorDescription: 'We could not load this data. Please try again.',
|
||||||
},
|
},
|
||||||
location: {
|
location: {
|
||||||
allRegions: 'All regions',
|
allRegions: 'All regions',
|
||||||
|
|||||||
@@ -1084,6 +1084,8 @@ export const hy: Translations = {
|
|||||||
retry: 'Փորձել կրկին',
|
retry: 'Փորձել կրկին',
|
||||||
loading: 'Բեռնում...',
|
loading: 'Բեռնում...',
|
||||||
remove: 'Հեռացնել',
|
remove: 'Հեռացնել',
|
||||||
|
errorTitle: 'Ինչ-որ բան այն չէ',
|
||||||
|
errorDescription: 'Չհաջողվեց բեռնել տվյալները։ Փորձեք կրկին։',
|
||||||
},
|
},
|
||||||
location: {
|
location: {
|
||||||
allRegions: 'Բոլոր տարածաշրջանները',
|
allRegions: 'Բոլոր տարածաշրջանները',
|
||||||
|
|||||||
@@ -1084,6 +1084,8 @@ export const ru: Translations = {
|
|||||||
retry: 'Попробовать снова',
|
retry: 'Попробовать снова',
|
||||||
loading: 'Загрузка...',
|
loading: 'Загрузка...',
|
||||||
remove: 'Удалить',
|
remove: 'Удалить',
|
||||||
|
errorTitle: 'Что-то пошло не так',
|
||||||
|
errorDescription: 'Не удалось загрузить данные. Попробуйте ещё раз.',
|
||||||
},
|
},
|
||||||
location: {
|
location: {
|
||||||
allRegions: 'Все регионы',
|
allRegions: 'Все регионы',
|
||||||
|
|||||||
@@ -1083,6 +1083,8 @@ export interface Translations {
|
|||||||
retry: string;
|
retry: string;
|
||||||
loading: string;
|
loading: string;
|
||||||
remove: string;
|
remove: string;
|
||||||
|
errorTitle: string;
|
||||||
|
errorDescription: string;
|
||||||
};
|
};
|
||||||
location: {
|
location: {
|
||||||
allRegions: string;
|
allRegions: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user