@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 @@
| | | | | |