Files
marketplaces/src/app/features/admin/users/pages/admin-users-page.component.html
sdarbinyan e153a67ec0 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>
2026-07-25 21:31:30 +04:00

124 lines
6.3 KiB
HTML

<section class="admin-users-page">
<div class="card">
<h2>{{ 'adminUsers.title' | translate }}</h2>
@if (facade.loading()) {
<div class="skeleton-rows" role="status" aria-live="polite" aria-busy="true">
@for (i of [1,2,3]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</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) {
<app-empty-state [title]="'adminUsers.emptyTitle' | translate" [description]="'adminUsers.emptyDescription' | translate" />
} @else {
<app-table>
<thead>
<tr>
<th scope="col">{{ 'adminUsers.name' | translate }}</th>
<th scope="col">{{ 'adminUsers.scope' | translate }}</th>
<th scope="col">{{ 'adminUsers.role' | translate }}</th>
<th scope="col">{{ 'backoffice.status' | translate }}</th>
<th scope="col">{{ 'adminUsers.lastLogin' | translate }}</th>
<th scope="col">{{ 'adminProducts.actions' | translate }}</th>
</tr>
</thead>
<tbody>
@for (user of facade.users(); track user.id) {
<tr>
<th scope="row">{{ user.name }}<br /><small>{{ user.telegramUsername }}</small></th>
<td>{{ ('adminUsers.scopeValue.' + user.scope) | translate }}</td>
<td>
<select [attr.aria-label]="'adminUsers.role' | translate" [ngModel]="user.roleId" (ngModelChange)="facade.setRole(user.id, $event)">
@for (role of facade.roles(); track role.id) {
<option [value]="role.id">{{ roleLabel(role.id) }}</option>
}
</select>
</td>
<td><app-badge [variant]="user.status === 'active' ? 'success' : user.status === 'suspended' ? 'danger' : 'neutral'">{{ ('adminUsers.statusValue.' + user.status) | translate }}</app-badge></td>
<td>{{ user.lastLoginAt ? (user.lastLoginAt | date:'short') : '—' }}</td>
<td class="actions">
<app-button variant="secondary" size="sm" (click)="toggleStatus(user.id, user.status)">{{ (user.status === 'suspended' ? 'adminUsers.reactivate' : 'adminUsers.suspend') | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="facade.openSessions(user)">{{ 'adminUsers.sessions' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="facade.openAudit(user)">{{ 'adminTransactions.audit' | translate }}</app-button>
</td>
</tr>
}
</tbody>
</app-table>
}
</div>
<div class="card">
<h2>{{ 'adminUsers.invite' | translate }}</h2>
<p class="hint">{{ 'adminUsers.passwordlessHint' | translate }}</p>
<div class="invite-form">
<app-input type="email" [ngModel]="inviteEmail()" (ngModelChange)="inviteEmail.set($event)" [placeholder]="'adminUsers.email' | translate" />
<select [attr.aria-label]="'adminUsers.role' | translate" [ngModel]="inviteRoleId()" (ngModelChange)="inviteRoleId.set($event)">
@for (role of facade.roles(); track role.id) {
<option [value]="role.id">{{ roleLabel(role.id) }}</option>
}
</select>
<select [attr.aria-label]="'adminUsers.scope' | translate" [ngModel]="inviteScope()" (ngModelChange)="inviteScope.set($event)">
<option value="marketplace">{{ 'adminUsers.scopeValue.marketplace' | translate }}</option>
<option value="office">{{ 'adminUsers.scopeValue.office' | translate }}</option>
</select>
<app-button variant="primary" (click)="sendInvite()">{{ 'adminUsers.sendInvite' | translate }}</app-button>
</div>
@if (facade.invitations().length > 0) {
<app-table>
<thead>
<tr>
<th scope="col">{{ 'adminUsers.email' | translate }}</th>
<th scope="col">{{ 'adminUsers.role' | translate }}</th>
<th scope="col">{{ 'backoffice.status' | translate }}</th>
<th scope="col">{{ 'adminProducts.actions' | translate }}</th>
</tr>
</thead>
<tbody>
@for (invite of facade.invitations(); track invite.id) {
<tr>
<th scope="row">{{ invite.email }}</th>
<td>{{ roleLabel(invite.roleId) }}</td>
<td><app-badge variant="neutral">{{ ('adminUsers.invitationStatus.' + invite.status) | translate }}</app-badge></td>
<td>
@if (invite.status === 'pending') {
<app-button variant="danger" size="sm" (click)="facade.revokeInvitation(invite.id)">{{ 'adminUsers.revoke' | translate }}</app-button>
}
</td>
</tr>
}
</tbody>
</app-table>
} @else {
<app-empty-state [title]="'adminUsers.invitationsEmptyTitle' | translate" />
}
</div>
<div class="card">
<h2>{{ 'adminUsers.roles' | translate }}</h2>
@for (role of facade.roles(); track role.id) {
<p><strong>{{ roleLabel(role.id) }}</strong> — {{ role.permissions.map(permissionLabel).join(', ') }}</p>
}
</div>
<app-dialog [open]="!!facade.sessionsTarget()" [titleText]="'adminUsers.sessions' | translate" size="sm" (closed)="facade.closeSessions()">
@for (session of facade.sessions(); track session.id) {
<p>{{ session.device }} — {{ session.ip }} — {{ session.lastActiveAt | date:'short' }}
@if (session.current) { <app-badge variant="success">{{ 'adminUsers.currentSession' | translate }}</app-badge> }
@else { <app-button variant="danger" size="sm" (click)="facade.revokeSession(session.id)">{{ 'adminUsers.revoke' | translate }}</app-button> }
</p>
}
</app-dialog>
<app-dialog [open]="!!facade.auditTarget()" [titleText]="'adminTransactions.audit' | translate" size="sm" (closed)="facade.closeAudit()">
@for (entry of facade.audit(); track $index) {
<p>{{ entry.timestamp | date:'short' }} — {{ ('adminUsers.actor.' + entry.actor) | translate }} — {{ auditText(entry) }}</p>
}
</app-dialog>
</section>