refactor: marketplace release polish

Sprint 28, scoped to admin/* (user decision — full marketplace audit
declined in favor of a bounded pass over the 8 admin features from
Sprints 20-27).

- a11y: aria-label added to every bare <select> not already inside a
  <label> across categories/products/orders/transactions/users/monitoring
- loading states: app-skeleton rows/cards added to list pages that
  previously rendered blank during the initial fetch (categories, orders,
  transactions, users, monitoring's event feed, analytics summary cards)
- admin-dashboard-card's custom shimmer CSS replaced with the shared
  SkeletonComponent (same visual result, one less duplicated animation)
- bundle-size budget warning (~198kB over) confirmed pre-existing —
  present at Sprint 20's first build before any admin/* code existed,
  and new admin pages are all lazy-loaded — documented as out of scope
  for this pass rather than chased

docs/ADMIN.md + docs/SPRINT-PLAN.md updated with the scope decision and
what was explicitly not done (Lighthouse, animations, SEO/sitemap,
storefront/editor a11y).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-16 00:14:46 +04:00
parent 1db63ac99d
commit 576f2600a5
27 changed files with 223 additions and 127 deletions

View File

@@ -8,7 +8,13 @@
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
</div>
@if (facade.summary(); as summary) {
@if (facade.loading()) {
<div class="summary-grid">
@for (i of [1,2,3,4,5,6]; track i) {
<app-skeleton shape="rect" height="64px" />
}
</div>
} @else if (facade.summary(); as summary) {
<div class="summary-grid">
<div class="summary-card"><span>{{ 'adminAnalytics.revenue' | translate }}</span><strong>{{ summary.revenueTotal }} {{ summary.currency }}</strong></div>
<div class="summary-card"><span>{{ 'adminAnalytics.orders' | translate }}</span><strong>{{ summary.ordersCount }}</strong></div>
@@ -30,24 +36,34 @@
<div class="card">
<h2>{{ 'adminAnalytics.topProducts' | translate }}</h2>
<app-table>
<thead>
<tr>
<th>{{ 'adminProducts.name' | translate }}</th>
<th>{{ 'adminAnalytics.quantitySold' | translate }}</th>
<th>{{ 'adminAnalytics.revenue' | translate }}</th>
</tr>
</thead>
<tbody>
@for (product of facade.topProducts(); track product.productId) {
<tr>
<td>{{ product.name }}</td>
<td>{{ product.quantity }}</td>
<td>{{ product.revenue }}</td>
</tr>
@if (facade.loading()) {
<div class="skeleton-rows">
@for (i of [1,2,3]; track i) {
<app-skeleton shape="rect" height="36px" />
}
</tbody>
</app-table>
</div>
} @else if (facade.topProducts().length === 0) {
<app-empty-state [title]="'adminAnalytics.topProductsEmptyTitle' | translate" [description]="'adminAnalytics.topProductsEmptyDescription' | translate" />
} @else {
<app-table>
<thead>
<tr>
<th>{{ 'adminProducts.name' | translate }}</th>
<th>{{ 'adminAnalytics.quantitySold' | translate }}</th>
<th>{{ 'adminAnalytics.revenue' | translate }}</th>
</tr>
</thead>
<tbody>
@for (product of facade.topProducts(); track product.productId) {
<tr>
<td>{{ product.name }}</td>
<td>{{ product.quantity }}</td>
<td>{{ product.revenue }}</td>
</tr>
}
</tbody>
</app-table>
}
</div>
<div class="card pending-section">

View File

@@ -11,3 +11,4 @@
.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)); } }
.skeleton-rows { display: grid; gap: 8px; }

View File

@@ -6,11 +6,13 @@ 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';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({
selector: 'app-admin-analytics-page',
standalone: true,
imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent],
imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent, SkeletonComponent, EmptyStateComponent],
templateUrl: './admin-analytics-page.component.html',
styleUrls: ['./admin-analytics-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -2,7 +2,7 @@
<div class="toolbar">
<div class="filters">
<app-input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminCategories.search' | translate" />
<select [ngModel]="filters.visibility" (ngModelChange)="filtersChange.emit({ visibility: $event })">
<select [attr.aria-label]="'adminProducts.visibility' | translate" [ngModel]="filters.visibility" (ngModelChange)="filtersChange.emit({ visibility: $event })">
<option value="all">{{ 'adminProducts.allVisibility' | translate }}</option>
<option value="visible">{{ 'adminProducts.visible' | translate }}</option>
<option value="hidden">{{ 'adminProducts.hidden' | translate }}</option>
@@ -12,7 +12,11 @@
<app-button variant="primary" (click)="create.emit()">{{ 'adminCategories.create' | translate }}</app-button>
</div>
@if (!loading && rows.length === 0) {
@if (loading) {
<div class="skeleton-rows">
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
</div>
} @else if (rows.length === 0) {
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate" />
} @else {
<app-table>

View File

@@ -5,4 +5,5 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
.check { display: inline-flex; align-items: center; gap: 6px; }
.actions { display: flex; gap: 10px; align-items: center; }
tr.deleted { opacity: 0.55; }
.skeleton-rows { display: grid; gap: 8px; }
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } .actions { flex-direction: column; align-items: stretch; } }

View File

@@ -7,6 +7,7 @@ import { InputComponent } from '../../../../shared/ui/input/input.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';
export interface AdminCategoryRow {
category: AdminCategory;
@@ -16,7 +17,7 @@ export interface AdminCategoryRow {
@Component({
selector: 'app-admin-categories-list',
standalone: true,
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, EmptyStateComponent],
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, EmptyStateComponent, SkeletonComponent],
templateUrl: './admin-categories-list.component.html',
styleUrls: ['./admin-categories-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -6,7 +6,7 @@
<div class="dashboard-card__body">
<ng-container [ngSwitch]="status">
<div class="dashboard-card__skeleton" *ngSwitchCase="'loading'"></div>
<app-skeleton *ngSwitchCase="'loading'" shape="rect" height="24px" width="60%" />
<p class="dashboard-card__muted" *ngSwitchCase="'empty'">{{ 'dashboard.stateEmpty' | translate }}</p>

View File

@@ -68,16 +68,3 @@
color: var(--error-color, #ef4444);
}
.dashboard-card__skeleton {
height: 24px;
width: 60%;
border-radius: var(--radius-sm, 8px);
background: linear-gradient(90deg, var(--bg-secondary, #f5f5f5) 25%, var(--bg-tertiary, #f0f0f0) 37%, var(--bg-secondary, #f5f5f5) 63%);
background-size: 400% 100%;
animation: dashboard-card-shimmer 1.4s ease infinite;
}
@keyframes dashboard-card-shimmer {
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}

View File

@@ -2,11 +2,12 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { AdminDashboardCardStatus } from '../models/admin-dashboard.model';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
@Component({
selector: 'app-admin-dashboard-card',
standalone: true,
imports: [CommonModule, TranslatePipe],
imports: [CommonModule, TranslatePipe, SkeletonComponent],
templateUrl: './admin-dashboard-card.component.html',
styleUrls: ['./admin-dashboard-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -49,33 +49,43 @@
<h2>{{ 'adminMonitoring.events' | translate }}</h2>
<div class="filters">
<app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminOrders.search' | translate" />
<select [ngModel]="facade.filters().category" (ngModelChange)="facade.updateFilters({ category: $event })">
<select [attr.aria-label]="'adminMonitoring.category' | translate" [ngModel]="facade.filters().category" (ngModelChange)="facade.updateFilters({ category: $event })">
@for (category of categories; track category) {
<option [value]="category">{{ ('adminMonitoring.category.' + category) | translate }}</option>
}
</select>
</div>
<app-table>
<thead>
<tr>
<th>{{ 'adminMonitoring.category' | translate }}</th>
<th>{{ 'adminMonitoring.level' | translate }}</th>
<th>{{ 'adminMonitoring.message' | translate }}</th>
<th>{{ 'adminUsers.name' | translate }}</th>
<th>{{ 'adminOrders.createdAt' | translate }}</th>
</tr>
</thead>
<tbody>
@for (event of facade.events(); track event.id) {
<tr>
<td>{{ ('adminMonitoring.category.' + event.category) | translate }}</td>
<td><app-badge [variant]="event.level === 'error' ? 'danger' : event.level === 'warning' ? 'warning' : 'neutral'">{{ event.level }}</app-badge></td>
<td>{{ event.message }}</td>
<td>{{ event.actor }}</td>
<td>{{ event.timestamp | date:'short' }}</td>
</tr>
@if (facade.loading()) {
<div class="skeleton-rows">
@for (i of [1,2,3]; track i) {
<app-skeleton shape="rect" height="36px" />
}
</tbody>
</app-table>
</div>
} @else if (facade.events().length === 0) {
<app-empty-state [title]="'adminMonitoring.eventsEmptyTitle' | translate" [description]="'adminMonitoring.eventsEmptyDescription' | translate" />
} @else {
<app-table>
<thead>
<tr>
<th>{{ 'adminMonitoring.category' | translate }}</th>
<th>{{ 'adminMonitoring.level' | translate }}</th>
<th>{{ 'adminMonitoring.message' | translate }}</th>
<th>{{ 'adminUsers.name' | translate }}</th>
<th>{{ 'adminOrders.createdAt' | translate }}</th>
</tr>
</thead>
<tbody>
@for (event of facade.events(); track event.id) {
<tr>
<td>{{ ('adminMonitoring.category.' + event.category) | translate }}</td>
<td><app-badge [variant]="event.level === 'error' ? 'danger' : event.level === 'warning' ? 'warning' : 'neutral'">{{ event.level }}</app-badge></td>
<td>{{ event.message }}</td>
<td>{{ event.actor }}</td>
<td>{{ event.timestamp | date:'short' }}</td>
</tr>
}
</tbody>
</app-table>
}
</div>
</section>

View File

@@ -7,3 +7,4 @@
.filters { display: flex; flex-wrap: wrap; gap: 10px; }
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
@media (max-width: 700px) { .queue-grid { grid-template-columns: 1fr; } .filters { flex-direction: column; align-items: stretch; } }
.skeleton-rows { display: grid; gap: 8px; }

View File

@@ -7,11 +7,13 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { InputComponent } from '../../../../shared/ui/input/input.component';
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';
@Component({
selector: 'app-admin-monitoring-page',
standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe, InputComponent, BadgeComponent, TableComponent],
imports: [CommonModule, FormsModule, TranslatePipe, InputComponent, BadgeComponent, TableComponent, SkeletonComponent, EmptyStateComponent],
templateUrl: './admin-monitoring-page.component.html',
styleUrls: ['./admin-monitoring-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -28,7 +28,7 @@
</div>
<div class="card no-print">
<h3>{{ 'adminOrders.changeStatus' | translate }}</h3>
<select [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)">
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)">
@for (status of statuses; track status) {
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
}

View File

@@ -2,7 +2,7 @@
<div class="toolbar">
<div class="filters">
<app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminOrders.search' | translate" />
<select [ngModel]="facade.filters().status" (ngModelChange)="facade.updateFilters({ status: $event })">
<select [attr.aria-label]="'backoffice.status' | translate" [ngModel]="facade.filters().status" (ngModelChange)="facade.updateFilters({ status: $event })">
@for (status of statuses; track status) {
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
}
@@ -11,7 +11,11 @@
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
</div>
@if (!facade.loading() && facade.orders().length === 0) {
@if (facade.loading()) {
<div class="skeleton-rows">
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
</div>
} @else if (facade.orders().length === 0) {
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
} @else {
<app-table>

View File

@@ -3,4 +3,5 @@
.filters { display: flex; flex-wrap: wrap; gap: 10px; flex: 1; }
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
.pager { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.skeleton-rows { display: grid; gap: 8px; }
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } }

View File

@@ -11,11 +11,12 @@ import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
import { TableComponent } from '../../../../shared/ui/table/table.component';
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
@Component({
selector: 'app-admin-orders-list-page',
standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent],
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent],
templateUrl: './admin-orders-list-page.component.html',
styleUrls: ['./admin-orders-list-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -2,24 +2,24 @@
<div class="toolbar">
<div class="filters">
<app-input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminProducts.search' | translate" />
<select [ngModel]="filters.categoryId" (ngModelChange)="filtersChange.emit({ categoryId: $event || null })">
<select [attr.aria-label]="'adminProducts.category' | translate" [ngModel]="filters.categoryId" (ngModelChange)="filtersChange.emit({ categoryId: $event || null })">
<option [ngValue]="null">{{ 'adminProducts.allCategories' | translate }}</option>
@for (category of categories; track category.id) {
<option [ngValue]="category.id">{{ category.title }}</option>
}
</select>
<select [ngModel]="filters.visibility" (ngModelChange)="filtersChange.emit({ visibility: $event })">
<select [attr.aria-label]="'adminProducts.visibility' | translate" [ngModel]="filters.visibility" (ngModelChange)="filtersChange.emit({ visibility: $event })">
<option value="all">{{ 'adminProducts.allVisibility' | translate }}</option>
<option value="visible">{{ 'adminProducts.visible' | translate }}</option>
<option value="hidden">{{ 'adminProducts.hidden' | translate }}</option>
</select>
<select [ngModel]="filters.stock" (ngModelChange)="filtersChange.emit({ stock: $event })">
<select [attr.aria-label]="'adminProducts.stockStatus' | translate" [ngModel]="filters.stock" (ngModelChange)="filtersChange.emit({ stock: $event })">
<option value="all">{{ 'adminProducts.allStock' | translate }}</option>
<option value="in_stock">{{ 'adminProducts.inStock' | translate }}</option>
<option value="low_stock">{{ 'adminProducts.lowStock' | translate }}</option>
<option value="out_of_stock">{{ 'adminProducts.outOfStock' | translate }}</option>
</select>
<select [ngModel]="filters.sort" (ngModelChange)="filtersChange.emit({ sort: $event })">
<select [attr.aria-label]="'adminProducts.sortTitle' | translate" [ngModel]="filters.sort" (ngModelChange)="filtersChange.emit({ sort: $event })">
<option value="title">{{ 'adminProducts.sortTitle' | translate }}</option>
<option value="price">{{ 'adminProducts.sortPrice' | translate }}</option>
<option value="priority">{{ 'adminProducts.sortPriority' | translate }}</option>
@@ -40,56 +40,66 @@
</div>
}
<app-table>
<thead>
<tr>
<th><input type="checkbox" (change)="selectAll.emit($any($event.target).checked)" /></th>
<th>{{ 'adminProducts.name' | translate }}</th>
<th>{{ 'backoffice.sku' | translate }}</th>
<th>{{ 'adminProducts.brand' | translate }}</th>
<th>{{ 'backoffice.price' | translate }}</th>
<th>{{ 'backoffice.status' | translate }}</th>
<th>{{ 'adminProducts.visibility' | translate }}</th>
<th>{{ 'adminProducts.actions' | translate }}</th>
</tr>
</thead>
<tbody>
@for (product of products; track product.id) {
@if (loading) {
<div class="skeleton-rows">
@for (i of [1,2,3,4,5]; track i) {
<app-skeleton shape="rect" height="40px" />
}
</div>
} @else if (products.length === 0) {
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate" />
} @else {
<app-table>
<thead>
<tr>
<td><input type="checkbox" [checked]="isSelected(product.id)" (change)="selectionChange.emit({ id: product.id, checked: $any($event.target).checked })" /></td>
<td>{{ product.name }}</td>
<td>{{ product.sku }}</td>
<td>{{ product.brand }}</td>
<td>{{ product.price }} {{ product.currency }}</td>
<td>{{ product.stockStatus }}</td>
<td>
<app-badge [variant]="product.visible ? 'success' : 'neutral'">
{{ product.visible ? ('adminProducts.visible' | translate) : ('adminProducts.hidden' | translate) }}
</app-badge>
</td>
<td class="actions">
<app-button variant="secondary" size="sm" (click)="edit.emit(product.id)">{{ 'adminProducts.edit' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="duplicate.emit(product.id)">{{ 'adminProducts.duplicate' | translate }}</app-button>
@if (product.archived) {
<app-button variant="secondary" size="sm" (click)="restore.emit(product.id)">{{ 'adminCategories.restore' | translate }}</app-button>
} @else {
<app-button variant="secondary" size="sm" (click)="archive.emit(product.id)">{{ 'adminProducts.archive' | translate }}</app-button>
}
<app-button variant="danger" size="sm" (click)="delete.emit(product.id)">{{ 'adminProducts.delete' | translate }}</app-button>
</td>
<th><input type="checkbox" (change)="selectAll.emit($any($event.target).checked)" /></th>
<th>{{ 'adminProducts.name' | translate }}</th>
<th>{{ 'backoffice.sku' | translate }}</th>
<th>{{ 'adminProducts.brand' | translate }}</th>
<th>{{ 'backoffice.price' | translate }}</th>
<th>{{ 'backoffice.status' | translate }}</th>
<th>{{ 'adminProducts.visibility' | translate }}</th>
<th>{{ 'adminProducts.actions' | translate }}</th>
</tr>
}
</tbody>
</app-table>
</thead>
<tbody>
@for (product of products; track product.id) {
<tr>
<td><input type="checkbox" [checked]="isSelected(product.id)" (change)="selectionChange.emit({ id: product.id, checked: $any($event.target).checked })" /></td>
<td>{{ product.name }}</td>
<td>{{ product.sku }}</td>
<td>{{ product.brand }}</td>
<td>{{ product.price }} {{ product.currency }}</td>
<td>{{ product.stockStatus }}</td>
<td>
<app-badge [variant]="product.visible ? 'success' : 'neutral'">
{{ product.visible ? ('adminProducts.visible' | translate) : ('adminProducts.hidden' | translate) }}
</app-badge>
</td>
<td class="actions">
<app-button variant="secondary" size="sm" (click)="edit.emit(product.id)">{{ 'adminProducts.edit' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="duplicate.emit(product.id)">{{ 'adminProducts.duplicate' | translate }}</app-button>
@if (product.archived) {
<app-button variant="secondary" size="sm" (click)="restore.emit(product.id)">{{ 'adminCategories.restore' | translate }}</app-button>
} @else {
<app-button variant="secondary" size="sm" (click)="archive.emit(product.id)">{{ 'adminProducts.archive' | translate }}</app-button>
}
<app-button variant="danger" size="sm" (click)="delete.emit(product.id)">{{ 'adminProducts.delete' | translate }}</app-button>
</td>
</tr>
}
</tbody>
</app-table>
<div class="pager">
<span>{{ products.length }} / {{ total }} {{ 'adminProducts.items' | translate }}</span>
@if (infiniteScroll) {
@if (products.length < total) {
<app-button variant="secondary" size="sm" (click)="loadMore.emit()">{{ 'adminProducts.loadMore' | translate }}</app-button>
<div class="pager">
<span>{{ products.length }} / {{ total }} {{ 'adminProducts.items' | translate }}</span>
@if (infiniteScroll) {
@if (products.length < total) {
<app-button variant="secondary" size="sm" (click)="loadMore.emit()">{{ 'adminProducts.loadMore' | translate }}</app-button>
}
} @else {
<app-pagination [currentPage]="filters.page" [totalPages]="totalPages()" (pageChange)="filtersChange.emit({ page: $event })" />
}
} @else {
<app-pagination [currentPage]="filters.page" [totalPages]="totalPages()" (pageChange)="filtersChange.emit({ page: $event })" />
}
</div>
</div>
}
</section>

View File

@@ -6,3 +6,4 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
.pager { justify-content: space-between; flex-wrap: wrap; }
@media (max-width: 960px) { .filters { grid-template-columns: repeat(2, minmax(140px, 1fr)); } }
@media (max-width: 640px) { .filters { grid-template-columns: 1fr; } .actions { flex-direction: column; align-items: stretch; } }
.skeleton-rows { display: grid; gap: 8px; }

View File

@@ -7,11 +7,13 @@ import { InputComponent } from '../../../../shared/ui/input/input.component';
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
import { TableComponent } from '../../../../shared/ui/table/table.component';
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({
selector: 'app-admin-products-list',
standalone: true,
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent],
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, SkeletonComponent, EmptyStateComponent],
templateUrl: './admin-products-list.component.html',
styleUrls: ['./admin-products-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -2,12 +2,12 @@
<div class="toolbar">
<div class="filters">
<app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminTransactions.search' | translate" />
<select [ngModel]="facade.filters().status" (ngModelChange)="facade.updateFilters({ status: $event })">
<select [attr.aria-label]="'backoffice.status' | translate" [ngModel]="facade.filters().status" (ngModelChange)="facade.updateFilters({ status: $event })">
@for (status of statuses; track status) {
<option [value]="status">{{ ('adminTransactions.status.' + status) | translate }}</option>
}
</select>
<select [ngModel]="facade.filters().type" (ngModelChange)="facade.updateFilters({ type: $event })">
<select [attr.aria-label]="'adminTransactions.type' | translate" [ngModel]="facade.filters().type" (ngModelChange)="facade.updateFilters({ type: $event })">
@for (type of types; track type) {
<option [value]="type">{{ ('adminTransactions.type.' + type) | translate }}</option>
}
@@ -16,7 +16,11 @@
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
</div>
@if (!facade.loading() && facade.transactions().length === 0) {
@if (facade.loading()) {
<div class="skeleton-rows">
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
</div>
} @else if (facade.transactions().length === 0) {
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
} @else {
<app-table>

View File

@@ -4,4 +4,5 @@
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
.pager { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.skeleton-rows { display: grid; gap: 8px; }
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } }

View File

@@ -11,11 +11,12 @@ import { TableComponent } from '../../../../shared/ui/table/table.component';
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
@Component({
selector: 'app-admin-transactions-list-page',
standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, DialogComponent],
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, DialogComponent, SkeletonComponent],
templateUrl: './admin-transactions-list-page.component.html',
styleUrls: ['./admin-transactions-list-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,6 +1,13 @@
<section class="admin-users-page">
<div class="card">
<h2>{{ 'adminUsers.title' | translate }}</h2>
@if (facade.loading()) {
<div class="skeleton-rows">
@for (i of [1,2,3]; track i) { <app-skeleton shape="rect" height="40px" /> }
</div>
} @else if (facade.users().length === 0) {
<app-empty-state [title]="'adminUsers.emptyTitle' | translate" [description]="'adminUsers.emptyDescription' | translate" />
} @else {
<app-table>
<thead>
<tr>
@@ -18,7 +25,7 @@
<td>{{ user.name }}<br /><small>{{ user.telegramUsername }}</small></td>
<td>{{ ('adminUsers.scopeValue.' + user.scope) | translate }}</td>
<td>
<select [ngModel]="user.roleId" (ngModelChange)="facade.setRole(user.id, $event)">
<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">{{ role.name }}</option>
}
@@ -35,6 +42,7 @@
}
</tbody>
</app-table>
}
</div>
<div class="card">
@@ -42,12 +50,12 @@
<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 [ngModel]="inviteRoleId()" (ngModelChange)="inviteRoleId.set($event)">
<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">{{ role.name }}</option>
}
</select>
<select [ngModel]="inviteScope()" (ngModelChange)="inviteScope.set($event)">
<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>
@@ -79,6 +87,8 @@
}
</tbody>
</app-table>
} @else {
<app-empty-state [title]="'adminUsers.invitationsEmptyTitle' | translate" />
}
</div>

View File

@@ -5,4 +5,5 @@
.invite-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.skeleton-rows { display: grid; gap: 8px; }
@media (max-width: 640px) { .invite-form { flex-direction: column; align-items: stretch; } }

View File

@@ -10,11 +10,13 @@ import { InputComponent } from '../../../../shared/ui/input/input.component';
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
import { TableComponent } from '../../../../shared/ui/table/table.component';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({
selector: 'app-admin-users-page',
standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, DialogComponent],
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, DialogComponent, SkeletonComponent, EmptyStateComponent],
templateUrl: './admin-users-page.component.html',
styleUrls: ['./admin-users-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush