diff --git a/src/app/features/admin/analytics/pages/admin-analytics-page.component.html b/src/app/features/admin/analytics/pages/admin-analytics-page.component.html index 5906071..2f7610e 100644 --- a/src/app/features/admin/analytics/pages/admin-analytics-page.component.html +++ b/src/app/features/admin/analytics/pages/admin-analytics-page.component.html @@ -29,10 +29,11 @@ @if (activeTab() === 'overview') {
@if (facade.loading()) { -
+
@for (i of [1,2,3,4,5,6]; track i) { } + {{ 'common.loading' | translate }}
} @else if (facade.summary(); as summary) {
@@ -58,8 +59,9 @@

{{ 'adminAnalytics.topProducts' | translate }}

@if (facade.loading()) { -
+
@for (i of [1,2,3]; track i) { } + {{ 'common.loading' | translate }}
} @else if (facade.topProducts().length === 0) { @@ -68,7 +70,7 @@ {{ 'adminProducts.name' | translate }}{{ 'adminAnalytics.quantitySold' | translate }}{{ 'adminAnalytics.revenue' | translate }} @for (product of facade.topProducts(); track product.productId) { - {{ product.name }}{{ product.quantity }}{{ product.revenue }} + {{ product.name }}{{ product.quantity }}{{ product.revenue }} } @@ -85,7 +87,7 @@ @for (product of facade.lowStockProducts(); track product.productId) { - {{ product.name }} + {{ product.name }} {{ product.quantity }} {{ ('adminProducts.' + (product.stockStatus === 'out_of_stock' ? 'outOfStock' : 'lowStock')) | translate }} diff --git a/src/app/features/admin/analytics/pages/admin-analytics-page.component.scss b/src/app/features/admin/analytics/pages/admin-analytics-page.component.scss index a152a52..b173997 100644 --- a/src/app/features/admin/analytics/pages/admin-analytics-page.component.scss +++ b/src/app/features/admin/analytics/pages/admin-analytics-page.component.scss @@ -55,3 +55,8 @@ .summary-grid { grid-template-columns: 1fr; } .tabs { overflow-x: auto; flex-wrap: nowrap; } } + +// scope="row" on the product-name cell (a11y row header) must render +// identically to a plain - only the semantics change, not the visuals. +tbody th[scope='row'] { font-weight: inherit; color: inherit; text-align: left; vertical-align: middle; } +tbody tr:last-child th[scope='row'] { border-bottom: none; } diff --git a/src/app/features/admin/categories/components/admin-categories-list.component.html b/src/app/features/admin/categories/components/admin-categories-list.component.html index aae5a22..d30783d 100644 --- a/src/app/features/admin/categories/components/admin-categories-list.component.html +++ b/src/app/features/admin/categories/components/admin-categories-list.component.html @@ -60,8 +60,9 @@ } @if (loading) { -
+
@for (i of [1,2,3,4]; track i) { } + {{ 'common.loading' | translate }}
} @else if (treeRows.length === 0) { @@ -112,6 +113,12 @@
+ + @if (row.category.parentId) { {{ 'adminCategories.jumpToParent' | translate }} } @@ -143,7 +150,7 @@ @for (category of flatCategories; track category.id) { - {{ category.icon }} {{ category.title }} + {{ category.icon }} {{ category.title }} @if (visibleColumns.includes('slug')) { {{ category.slug }} } @if (visibleColumns.includes('items')) { {{ category.itemsCount }} } @if (visibleColumns.includes('status')) { diff --git a/src/app/features/admin/categories/components/admin-categories-list.component.scss b/src/app/features/admin/categories/components/admin-categories-list.component.scss index 97abd82..74c2796 100644 --- a/src/app/features/admin/categories/components/admin-categories-list.component.scss +++ b/src/app/features/admin/categories/components/admin-categories-list.component.scss @@ -6,6 +6,11 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color .actions { display: flex; gap: 10px; align-items: center; } tr.deleted { opacity: 0.55; } .skeleton-rows { display: grid; gap: 8px; } + +// scope="row" on the title cell (a11y row header) must render identically to a +// plain - only the semantics change, not the visuals. +tbody th[scope='row'] { font-weight: inherit; color: inherit; text-align: left; vertical-align: middle; } +tbody tr:last-child th[scope='row'] { border-bottom: none; } @media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } .actions { flex-direction: column; align-items: stretch; } } .bulk-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } @@ -36,7 +41,21 @@ tr.deleted { opacity: 0.55; } .category-tree__icon { font-size: var(--font-size-lg, 1rem); } .category-tree__title { font-weight: var(--font-weight-semibold, 600); color: var(--text-primary); } .category-tree__items { font-size: var(--font-size-xs, 0.75rem); color: var(--text-light); } -.category-tree__actions { display: flex; gap: 6px; flex-wrap: wrap; } +.category-tree__actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } +.category-tree__reorder-btn { + all: unset; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-sm); + color: var(--text-secondary); +} +.category-tree__reorder-btn:hover:not(:disabled) { background: var(--bg-tertiary); } +.category-tree__reorder-btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 1px; } +.category-tree__reorder-btn:disabled { color: var(--text-light); cursor: not-allowed; opacity: 0.5; } .category-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } .category-grid--compact { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } diff --git a/src/app/features/admin/categories/components/admin-categories-list.component.ts b/src/app/features/admin/categories/components/admin-categories-list.component.ts index 603b833..5398b98 100644 --- a/src/app/features/admin/categories/components/admin-categories-list.component.ts +++ b/src/app/features/admin/categories/components/admin-categories-list.component.ts @@ -15,6 +15,7 @@ import { MediaPickerComponent } from '../../../../shared/media/media-picker/medi import { MediaAsset } from '../../../../core/media/models/media-asset.model'; import { CategoryHealthWidgetComponent, CategoryHealthItem } from './category-health-widget/category-health-widget.component'; import { CategoriesDashboardComponent } from './categories-dashboard/categories-dashboard.component'; +import { IconComponent } from '../../../../shared/ui/icon/icon.component'; export interface AdminCategoryTreeRow { category: AdminCategory; @@ -39,6 +40,7 @@ export interface AdminCategoryTreeRow { MediaPickerComponent, CategoryHealthWidgetComponent, CategoriesDashboardComponent, + IconComponent, ], templateUrl: './admin-categories-list.component.html', styleUrls: ['./admin-categories-list.component.scss'], @@ -94,6 +96,44 @@ export class AdminCategoriesListComponent { return this.selectedIds.includes(id); } + // Keyboard-accessible equivalent of the native HTML5 drag-and-drop reorder + // above (WCAG 2.1.1) - swaps a row with its adjacent sibling using the same + // `reorder` output the mouse path already emits, so no facade/business + // logic changes. + private siblingRows(row: AdminCategoryTreeRow): AdminCategoryTreeRow[] { + return this.treeRows.filter(candidate => candidate.category.parentId === row.category.parentId); + } + + canMoveUp(row: AdminCategoryTreeRow): boolean { + const siblings = this.siblingRows(row); + return siblings.findIndex(candidate => candidate.category.id === row.category.id) > 0; + } + + canMoveDown(row: AdminCategoryTreeRow): boolean { + const siblings = this.siblingRows(row); + const index = siblings.findIndex(candidate => candidate.category.id === row.category.id); + return index >= 0 && index < siblings.length - 1; + } + + moveRowUp(row: AdminCategoryTreeRow): void { + const siblings = this.siblingRows(row); + const index = siblings.findIndex(candidate => candidate.category.id === row.category.id); + if (index > 0) { + this.reorder.emit({ id: row.category.id, targetId: siblings[index - 1].category.id }); + } + } + + moveRowDown(row: AdminCategoryTreeRow): void { + const siblings = this.siblingRows(row); + const index = siblings.findIndex(candidate => candidate.category.id === row.category.id); + if (index >= 0 && index < siblings.length - 1) { + // Emitted with roles reversed: reorder() always inserts `id` before + // `targetId`, so moving the *next* sibling in front of this row + // produces the same adjacent swap as "move this row down". + this.reorder.emit({ id: siblings[index + 1].category.id, targetId: row.category.id }); + } + } + healthItems(category: AdminCategory): CategoryHealthItem[] { const health = this.health(category); return [ diff --git a/src/app/features/admin/customers/pages/admin-customers-list-page.component.html b/src/app/features/admin/customers/pages/admin-customers-list-page.component.html index 4c65011..33f4237 100644 --- a/src/app/features/admin/customers/pages/admin-customers-list-page.component.html +++ b/src/app/features/admin/customers/pages/admin-customers-list-page.component.html @@ -4,8 +4,9 @@
@if (facade.loading()) { -
+
@for (i of [1,2,3,4]; track i) { } + {{ 'common.loading' | translate }}
} @else if (facade.filteredCustomers().length === 0) { @@ -25,7 +26,7 @@ @for (customer of facade.filteredCustomers(); track customer.email) { - {{ customer.name }} + {{ customer.name }} {{ customer.email }} {{ customer.phone }} diff --git a/src/app/features/admin/customers/pages/admin-customers-list-page.component.scss b/src/app/features/admin/customers/pages/admin-customers-list-page.component.scss index b1dc9a0..528cfde 100644 --- a/src/app/features/admin/customers/pages/admin-customers-list-page.component.scss +++ b/src/app/features/admin/customers/pages/admin-customers-list-page.component.scss @@ -1,3 +1,8 @@ .admin-customers-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: var(--radius-md); background: var(--bg-primary, #fff); } .toolbar { display: flex; } .skeleton-rows { display: grid; gap: 8px; } + +// scope="row" on the name cell (a11y row header) must render identically to a +// plain - only the semantics change, not the visuals. +tbody th[scope='row'] { font-weight: inherit; color: inherit; text-align: left; vertical-align: middle; } +tbody tr:last-child th[scope='row'] { border-bottom: none; } diff --git a/src/app/features/admin/moderation/pages/admin-reports-list-page.component.html b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.html index 7a97921..8d96d3a 100644 --- a/src/app/features/admin/moderation/pages/admin-reports-list-page.component.html +++ b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.html @@ -21,13 +21,13 @@ @for (report of facade.reports(); track report.id) { - + @if (canOpen(report.targetType, report.targetId)) { } @else { {{ report.targetLabel || ('adminModeration.notAvailable' | translate) }} } - + {{ ('adminModeration.targetType.' + report.targetType) | translate }} {{ report.reason }} {{ ('adminModeration.reportStatus.' + report.status) | translate }} diff --git a/src/app/features/admin/moderation/pages/admin-reports-list-page.component.scss b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.scss index 2f3db3a..cc73cee 100644 --- a/src/app/features/admin/moderation/pages/admin-reports-list-page.component.scss +++ b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.scss @@ -4,3 +4,8 @@ .actions { display: flex; gap: 8px; } .link-button { all: unset; cursor: pointer; color: var(--primary-color); font-weight: var(--font-weight-semibold, 600); } .link-button:hover, .link-button:focus-visible { text-decoration: underline; } + +// scope="row" on the report-target cell (a11y row header) must render +// identically to a plain - only the semantics change, not the visuals. +tbody th[scope='row'] { font-weight: inherit; color: inherit; text-align: left; vertical-align: middle; } +tbody tr:last-child th[scope='row'] { border-bottom: none; } diff --git a/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.html b/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.html index 4a89e92..42438cc 100644 --- a/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.html +++ b/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.html @@ -58,8 +58,9 @@ } @if (facade.loading()) { -
+
@for (i of [1,2,3,4]; track i) { } + {{ 'common.loading' | translate }}
} @else if (facade.reviews().length === 0) { @@ -84,7 +85,7 @@ @for (review of facade.reviews(); track review.id) { - {{ review.customerName }} + {{ review.customerName }} @if (isColumnVisible('product')) { {{ review.productName }} } @if (isColumnVisible('rating')) { {{ review.rating }}★ } @if (isColumnVisible('status')) { diff --git a/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.scss b/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.scss index 21dcce3..9805727 100644 --- a/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.scss +++ b/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.scss @@ -29,4 +29,9 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color .review-grid__product { margin: 0; font-size: var(--font-size-xs, 0.75rem); color: var(--text-tertiary, #9aa6a2); } .review-grid__text { margin: 0; font-size: var(--font-size-sm, 0.8125rem); color: var(--text-secondary, #5f6e6a); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +// scope="row" on the customer-name cell (a11y row header) must render +// identically to a plain - only the semantics change, not the visuals. +tbody th[scope='row'] { font-weight: inherit; color: inherit; text-align: left; vertical-align: middle; } +tbody tr:last-child th[scope='row'] { border-bottom: none; } + @media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } } diff --git a/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.html b/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.html index ca7c5ba..570cb56 100644 --- a/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.html +++ b/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.html @@ -46,7 +46,7 @@ @for (delivery of facade.webhooks(); track delivery.id) { - {{ delivery.endpoint }} + {{ delivery.endpoint }} {{ delivery.event }} {{ ('adminMonitoring.webhookStatus.' + delivery.status) | translate }} {{ delivery.timestamp | date:'short' }} @@ -67,10 +67,11 @@
@if (facade.loading()) { -
+
@for (i of [1,2,3]; track i) { } + {{ 'common.loading' | translate }}
} @else if (facade.events().length === 0) { diff --git a/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.scss b/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.scss index 16fe091..7d731af 100644 --- a/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.scss +++ b/src/app/features/admin/monitoring/pages/admin-monitoring-page.component.scss @@ -8,3 +8,8 @@ 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; } + +// scope="row" on the endpoint cell (a11y row header) must render identically +// to a plain - only the semantics change, not the visuals. +tbody th[scope='row'] { font-weight: inherit; color: inherit; text-align: left; vertical-align: middle; } +tbody tr:last-child th[scope='row'] { border-bottom: none; } diff --git a/src/app/features/admin/orders/pages/admin-orders-list-page.component.html b/src/app/features/admin/orders/pages/admin-orders-list-page.component.html index 63ec84e..760f605 100644 --- a/src/app/features/admin/orders/pages/admin-orders-list-page.component.html +++ b/src/app/features/admin/orders/pages/admin-orders-list-page.component.html @@ -50,8 +50,9 @@ } @if (facade.loading()) { -
+
@for (i of [1,2,3,4]; track i) { } + {{ 'common.loading' | translate }}
} @else if (facade.orders().length === 0) { @@ -74,7 +75,7 @@ @for (order of facade.orders(); track order.id) { - {{ order.orderNumber }} + {{ order.orderNumber }} @if (isColumnVisible('customer')) { {{ order.customer.name }} } @if (isColumnVisible('status')) { {{ ('adminOrders.status.' + order.status) | translate }} } @if (isColumnVisible('payment')) { {{ ('adminOrders.paymentStatus.' + order.payment.status) | translate }} } diff --git a/src/app/features/admin/orders/pages/admin-orders-list-page.component.scss b/src/app/features/admin/orders/pages/admin-orders-list-page.component.scss index 87944b5..75a3cf0 100644 --- a/src/app/features/admin/orders/pages/admin-orders-list-page.component.scss +++ b/src/app/features/admin/orders/pages/admin-orders-list-page.component.scss @@ -25,3 +25,8 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color } .density-compact td, .density-compact th { padding: 4px 8px; } + +// scope="row" on the order-number cell (a11y row header) must render +// identically to a plain - only the semantics change, not the visuals. +tbody th[scope='row'] { font-weight: inherit; color: inherit; text-align: left; vertical-align: middle; } +tbody tr:last-child th[scope='row'] { border-bottom: none; } diff --git a/src/app/features/admin/products/components/admin-products-list.component.html b/src/app/features/admin/products/components/admin-products-list.component.html index 7f7a2ab..b426390 100644 --- a/src/app/features/admin/products/components/admin-products-list.component.html +++ b/src/app/features/admin/products/components/admin-products-list.component.html @@ -72,10 +72,11 @@ } @if (loading) { -
+
@for (i of [1,2,3,4,5]; track i) { } + {{ 'common.loading' | translate }}
} @else if (products.length === 0) { @@ -103,7 +104,7 @@ @for (product of products; track product.id) { - {{ product.name }} + {{ product.name }} @if (isColumnVisible('sku')) { {{ product.sku }} } @if (isColumnVisible('brand')) { {{ product.brand }} } @if (isColumnVisible('price')) { {{ product.price }} {{ product.currency }} } diff --git a/src/app/features/admin/products/components/admin-products-list.component.scss b/src/app/features/admin/products/components/admin-products-list.component.scss index 997883e..fdd68aa 100644 --- a/src/app/features/admin/products/components/admin-products-list.component.scss +++ b/src/app/features/admin/products/components/admin-products-list.component.scss @@ -8,6 +8,11 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color @media (max-width: 640px) { .filters { grid-template-columns: 1fr; } .actions { flex-direction: column; align-items: stretch; } } .skeleton-rows { display: grid; gap: 8px; } +// scope="row" on the name cell (a11y row header) must render identically to a +// plain - only the semantics change, not the visuals. +tbody th[scope='row'] { font-weight: inherit; color: inherit; text-align: left; vertical-align: middle; } +tbody tr:last-child th[scope='row'] { border-bottom: none; } + .toolbar__view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .view-toggle { display: flex; gap: 2px; } .columns-panel { display: flex; flex-wrap: wrap; gap: 12px; } diff --git a/src/app/features/admin/shell/admin-layout.component.html b/src/app/features/admin/shell/admin-layout.component.html index 8e0b704..f94a950 100644 --- a/src/app/features/admin/shell/admin-layout.component.html +++ b/src/app/features/admin/shell/admin-layout.component.html @@ -8,7 +8,7 @@
@if (facade.loading()) { -
+
@for (i of [1,2,3,4]; track i) { } + {{ 'common.loading' | translate }}
} @else if (facade.transactions().length === 0) { @@ -39,7 +40,7 @@ @for (tx of facade.transactions(); track tx.id) { - {{ tx.orderNumber }} + {{ tx.orderNumber }} {{ ('adminTransactions.typeValue.' + tx.type) | translate }} {{ tx.method }} {{ ('adminTransactions.status.' + tx.status) | translate }} diff --git a/src/app/features/admin/transactions/pages/admin-transactions-list-page.component.scss b/src/app/features/admin/transactions/pages/admin-transactions-list-page.component.scss index eda87c7..9145d80 100644 --- a/src/app/features/admin/transactions/pages/admin-transactions-list-page.component.scss +++ b/src/app/features/admin/transactions/pages/admin-transactions-list-page.component.scss @@ -6,3 +6,8 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color .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; } } + +// scope="row" on the order-number cell (a11y row header) must render +// identically to a plain - only the semantics change, not the visuals. +tbody th[scope='row'] { font-weight: inherit; color: inherit; text-align: left; vertical-align: middle; } +tbody tr:last-child th[scope='row'] { border-bottom: none; } diff --git a/src/app/features/admin/users/pages/admin-users-page.component.html b/src/app/features/admin/users/pages/admin-users-page.component.html index 3bf9976..df97c14 100644 --- a/src/app/features/admin/users/pages/admin-users-page.component.html +++ b/src/app/features/admin/users/pages/admin-users-page.component.html @@ -2,8 +2,9 @@

{{ 'adminUsers.title' | translate }}

@if (facade.loading()) { -
+
@for (i of [1,2,3]; track i) { } + {{ 'common.loading' | translate }}
} @else if (facade.users().length === 0) { @@ -22,7 +23,7 @@ @for (user of facade.users(); track user.id) { - {{ user.name }}
{{ user.telegramUsername }} + {{ user.name }}
{{ user.telegramUsername }} {{ ('adminUsers.scopeValue.' + user.scope) | translate }}