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:
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user