From ac615837abffde6cbe5fce2c19f47c61f288d26d Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 18 Jul 2026 17:04:11 +0400 Subject: [PATCH] feat(categories): implement professional category management experience Categories dashboard (real total/visible/hidden/empty/root/subcategory/missing-image/missing-SEO/last-modified/completion% stats, recommended next action); tree view is now a real expand/collapse hierarchy (state persisted via LocalStorageService) with keyboard navigation (arrow keys, jump-to-parent), search that force-expands and keeps only matching branches + their ancestors/descendants instead of silently dropping deep matches; added Table/Cards views alongside the tree with density and saved column visibility; real bulk actions (show/hide/delete/assign parent/assign image via the Media Library picker/duplicate via the existing createCategory call/CSV export) plus the pre-existing single-item actions; reusable CategoryHealthWidget (image/SEO/description/valid-parent/visibility/products-assigned + completion%); editor reorganized into General/Media/SEO/Visibility/Navigation/Attributes/Advanced tabs, media now uses the shared ImageFieldComponent, SEO explains fields in plain language with a live preview, Navigation tab shows real breadcrumb/children/visibility-based nav status, Attributes uses the shared KeyValueEditorComponent. Filled in the adminCategories i18n namespace (previously only 2 of ~90 referenced keys existed) across en/ru/hy. Also fixed the pre-existing bug where a filtered/searched category list could silently drop a matched descendant whose ancestor's title didn't match, by loading the full catalog once and filtering client-side. --- .../admin-categories-list.component.html | 200 +++++++++-- .../admin-categories-list.component.scss | 43 +++ .../admin-categories-list.component.ts | 138 +++++++- .../admin-category-form.component.html | 181 +++++++--- .../admin-category-form.component.scss | 30 ++ .../admin-category-form.component.ts | 71 +++- .../categories-dashboard.component.html | 22 ++ .../categories-dashboard.component.scss | 41 +++ .../categories-dashboard.component.ts | 36 ++ .../category-health-widget.component.html | 16 + .../category-health-widget.component.scss | 47 +++ .../category-health-widget.component.ts | 22 ++ .../facade/admin-categories.facade.ts | 324 +++++++++++++++++- .../categories/models/admin-category.model.ts | 7 + .../admin-categories-list-page.component.ts | 54 ++- .../admin-category-editor-page.component.ts | 2 +- .../services/admin-categories-form.factory.ts | 2 + .../admin-categories-local.gateway.ts | 2 + src/app/i18n/en.ts | 78 +++++ src/app/i18n/hy.ts | 78 +++++ src/app/i18n/ru.ts | 78 +++++ src/app/i18n/translations.ts | 78 +++++ 22 files changed, 1433 insertions(+), 117 deletions(-) create mode 100644 src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.html create mode 100644 src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.scss create mode 100644 src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.ts create mode 100644 src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.html create mode 100644 src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.scss create mode 100644 src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.ts 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 34b35b3..b7f8e89 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 @@ -1,4 +1,8 @@
+ @if (dashboardStats) { + + } +
@@ -9,52 +13,196 @@
- {{ 'adminCategories.create' | translate }} +
+
+ {{ 'adminCategories.viewTree' | translate }} + {{ 'adminProducts.viewTable' | translate }} + {{ 'adminCategories.viewCards' | translate }} +
+ @if (viewMode === 'tree') { + {{ 'adminCategories.expandAll' | translate }} + {{ 'adminCategories.collapseAll' | translate }} + } @else { +
+ {{ 'adminProducts.densityComfortable' | translate }} + {{ 'adminProducts.densityCompact' | translate }} +
+ @if (viewMode === 'table') { + {{ 'adminProducts.columns' | translate }} + } + } + {{ 'adminCategories.create' | translate }} +
+ @if (columnsPanelOpen() && viewMode === 'table') { + + @for (column of allColumns; track column) { + + } + + } + + @if (selectedIds.length > 0) { +
+ {{ selectedIds.length }} {{ 'adminProducts.selectedCount' | translate }} + {{ 'adminProducts.bulkShow' | translate }} + {{ 'adminProducts.bulkHide' | translate }} + {{ 'adminCategories.bulkAssignParent' | translate }} + {{ 'adminCategories.bulkAssignImage' | translate }} + {{ 'adminProducts.bulkDuplicateAction' | translate }} + {{ 'adminProducts.bulkExportAction' | translate }} + {{ 'adminProducts.bulkDelete' | translate }} +
+ } + @if (loading) {
@for (i of [1,2,3,4]; track i) { }
- } @else if (rows.length === 0) { - - } @else { - + } @else if (treeRows.length === 0) { + + + {{ 'adminCategories.create' | translate }} + + +

{{ 'adminCategories.emptyGuide' | translate }}

+ } @else if (viewMode === 'tree') { +
    + @for (row of treeRows; track row.category.id; let i = $index) { +
  • +
    + @if (row.hasChildren) { + + } @else { + + } + + {{ row.category.icon || '📁' }} + {{ row.category.title }} + @if (row.category.deletedAt) { + {{ 'adminCategories.deletedBadge' | translate }} + } @else { + {{ ('adminCategories.status.' + row.category.status) | translate }} + @if (!row.category.visible) { + {{ 'adminProducts.hidden' | translate }} + } + } + {{ row.category.itemsCount }} {{ 'adminCategories.items' | translate }} + +
    +
    + @if (row.category.parentId) { + {{ 'adminCategories.jumpToParent' | translate }} + } + @if (row.category.deletedAt) { + {{ 'adminCategories.restore' | translate }} + } @else { + {{ 'adminProducts.edit' | translate }} + {{ 'adminProducts.delete' | translate }} + } +
    +
  • + } +
+ } @else if (viewMode === 'table') { + + {{ 'adminCategories.title' | translate }} - {{ 'adminCategories.slug' | translate }} - {{ 'adminCategories.items' | translate }} - {{ 'backoffice.status' | translate }} - {{ 'adminProducts.visibility' | translate }} + @if (visibleColumns.includes('slug')) { {{ 'adminCategories.slug' | translate }} } + @if (visibleColumns.includes('items')) { {{ 'adminCategories.items' | translate }} } + @if (visibleColumns.includes('status')) { {{ 'backoffice.status' | translate }} } + @if (visibleColumns.includes('visibility')) { {{ 'adminProducts.visibility' | translate }} } + {{ 'adminProducts.healthColumn' | translate }} {{ 'adminProducts.actions' | translate }} - @for (row of rows; track row.category.id) { - - {{ indent(row) }} {{ row.category.icon }} {{ row.category.title }} - {{ row.category.slug }} - {{ row.category.itemsCount }} - - - {{ ('adminCategories.status.' + row.category.status) | translate }} - - - - - + @for (category of flatCategories; track category.id) { + + + {{ category.icon }} {{ category.title }} + @if (visibleColumns.includes('slug')) { {{ category.slug }} } + @if (visibleColumns.includes('items')) { {{ category.itemsCount }} } + @if (visibleColumns.includes('status')) { + {{ ('adminCategories.status.' + category.status) | translate }} + } + @if (visibleColumns.includes('visibility')) { + + } + - @if (row.category.deletedAt) { - {{ 'adminCategories.restore' | translate }} + @if (category.deletedAt) { + {{ 'adminCategories.restore' | translate }} } @else { - {{ 'adminProducts.edit' | translate }} - {{ 'adminProducts.delete' | translate }} + {{ 'adminProducts.edit' | translate }} + {{ 'adminProducts.delete' | translate }} } } + } @else { +
+ @for (category of flatCategories; track category.id) { + + + @if (category.imageUrl) { + + } @else { +
{{ category.icon || '📁' }}
+ } +

{{ category.title }}

+

{{ category.itemsCount }} {{ 'adminCategories.items' | translate }}

+ +
+ {{ 'adminProducts.edit' | translate }} + {{ 'adminProducts.delete' | translate }} +
+
+ } +
} + + +
+ +
+ {{ 'mediaLibrary.cancel' | translate }} + {{ 'mediaLibrary.confirm' | translate }} +
+
+
+ +
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 f8ebb77..300769f 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 @@ -7,3 +7,46 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color 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; } } + +.bulk-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.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; } +.health-cell { min-width: 140px; } +.admin-categories-card__guide { margin: 0; text-align: center; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); } +.density-compact td, .density-compact th { padding: 4px 8px; } + +.category-tree { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; max-width: 100%; overflow-x: auto; } +.category-tree__row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + + &:hover { background: var(--surface-muted, #eef2f0); } + &:focus-visible { outline: 2px solid var(--brand-primary, #1e8a6e); outline-offset: -2px; } +} +.category-tree__row--deleted { opacity: 0.55; } +.category-tree__row-main { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; } +.category-tree__toggle { all: unset; cursor: pointer; width: 18px; text-align: center; color: var(--text-secondary, #5f6e6a); } +.category-tree__toggle-spacer { width: 18px; display: inline-block; } +.category-tree__icon { font-size: 1rem; } +.category-tree__title { font-weight: 600; color: var(--text-primary, #1e3c38); } +.category-tree__items { font-size: 0.78rem; color: var(--text-tertiary, #9aa6a2); } +.category-tree__actions { display: flex; gap: 6px; flex-wrap: wrap; } + +.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)); } +.category-grid__item { position: relative; display: grid; gap: 6px; } +.category-grid__select { position: absolute; top: 8px; left: 8px; z-index: 1; } +.category-grid__image { width: 100%; height: 120px; object-fit: cover; border-radius: 8px; background: var(--surface-muted, #eef2f0); } +.category-grid__image--placeholder { display: flex; align-items: center; justify-content: center; font-size: 1.5rem; } +.category-grid__name { margin: 0; font-size: 0.85rem; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.category-grid__items { margin: 0; font-size: 0.78rem; color: var(--text-secondary, #5f6e6a); } +.category-grid__actions { display: flex; gap: 6px; flex-wrap: wrap; } + +.dialog-body { display: grid; gap: 12px; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; } 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 c2a27f6..603b833 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 @@ -1,6 +1,7 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { AdminCategory, AdminCategoryListFilters } from '../models/admin-category.model'; +import { AdminCategory } from '../models/admin-category.model'; +import { AdminCategoryColumn, AdminCategoryHealth, AdminCategoriesDashboardStats, AdminCategoriesDensity, AdminCategoriesViewMode, ALL_CATEGORY_COLUMNS } from '../facade/admin-categories.facade'; import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { InputComponent } from '../../../../shared/ui/input/input.component'; @@ -8,44 +9,108 @@ 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'; +import { CardComponent } from '../../../../shared/ui/card/card.component'; +import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component'; +import { MediaPickerComponent } from '../../../../shared/media/media-picker/media-picker.component'; +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'; -export interface AdminCategoryRow { +export interface AdminCategoryTreeRow { category: AdminCategory; depth: number; + hasChildren: boolean; } @Component({ selector: 'app-admin-categories-list', standalone: true, - imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, EmptyStateComponent, SkeletonComponent], + imports: [ + FormsModule, + TranslatePipe, + ButtonComponent, + InputComponent, + BadgeComponent, + TableComponent, + EmptyStateComponent, + SkeletonComponent, + CardComponent, + DialogComponent, + MediaPickerComponent, + CategoryHealthWidgetComponent, + CategoriesDashboardComponent, + ], templateUrl: './admin-categories-list.component.html', styleUrls: ['./admin-categories-list.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class AdminCategoriesListComponent { - @Input() rows: AdminCategoryRow[] = []; - @Input() filters!: AdminCategoryListFilters; + @Input() treeRows: AdminCategoryTreeRow[] = []; + @Input() flatCategories: AdminCategory[] = []; + @Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean }; @Input() loading = false; + @Input() viewMode: AdminCategoriesViewMode = 'tree'; + @Input() density: AdminCategoriesDensity = 'comfortable'; + @Input() visibleColumns: AdminCategoryColumn[] = [...ALL_CATEGORY_COLUMNS]; + @Input() selectedIds: string[] = []; + @Input() dashboardStats: AdminCategoriesDashboardStats | null = null; + @Input() health!: (category: AdminCategory) => AdminCategoryHealth; + @Input() isExpanded!: (id: string) => boolean; + @Input() canDelete!: (id: string) => boolean; + @Input() allCategories: AdminCategory[] = []; - @Output() filtersChange = new EventEmitter>(); + @Output() filtersChange = new EventEmitter>(); @Output() create = new EventEmitter(); @Output() edit = new EventEmitter(); @Output() delete = new EventEmitter(); @Output() restore = new EventEmitter(); @Output() toggleVisible = new EventEmitter<{ id: string; visible: boolean }>(); @Output() reorder = new EventEmitter<{ id: string; targetId: string }>(); + @Output() toggleExpand = new EventEmitter(); + @Output() jumpToParent = new EventEmitter(); + @Output() expandAll = new EventEmitter(); + @Output() collapseAll = new EventEmitter(); + @Output() selectionChange = new EventEmitter<{ id: string; checked: boolean }>(); + @Output() selectAll = new EventEmitter(); + @Output() bulkVisibility = new EventEmitter(); + @Output() bulkDelete = new EventEmitter(); + @Output() bulkDuplicate = new EventEmitter(); + @Output() bulkAssignParent = new EventEmitter(); + @Output() bulkAssignImage = new EventEmitter(); + @Output() bulkExport = new EventEmitter(); + @Output() viewModeChange = new EventEmitter(); + @Output() densityChange = new EventEmitter(); + @Output() columnToggle = new EventEmitter<{ column: AdminCategoryColumn; visible: boolean }>(); + + protected readonly allColumns = ALL_CATEGORY_COLUMNS; + protected readonly columnsPanelOpen = signal(false); + protected readonly assignParentOpen = signal(false); + protected readonly imagePickerOpen = signal(false); private draggedId: string | null = null; + protected focusedRowIndex = signal(0); - indent(row: AdminCategoryRow): string { - return '—'.repeat(row.depth); + isSelected(id: string): boolean { + return this.selectedIds.includes(id); + } + + healthItems(category: AdminCategory): CategoryHealthItem[] { + const health = this.health(category); + return [ + { labelKey: 'adminCategories.healthImage', done: health.hasImage }, + { labelKey: 'adminCategories.healthSeo', done: health.hasSeo }, + { labelKey: 'adminCategories.healthDescription', done: health.hasDescription }, + { labelKey: 'adminCategories.healthParent', done: health.hasValidParent }, + { labelKey: 'adminCategories.healthVisibility', done: health.isVisible }, + { labelKey: 'adminCategories.healthProducts', done: health.hasProducts }, + ]; } onDragStart(id: string): void { this.draggedId = id; } - onDrop(targetRow: AdminCategoryRow): void { + onDrop(targetRow: AdminCategoryTreeRow): void { if (!this.draggedId || this.draggedId === targetRow.category.id) { this.draggedId = null; return; @@ -53,4 +118,57 @@ export class AdminCategoriesListComponent { this.reorder.emit({ id: this.draggedId, targetId: targetRow.category.id }); this.draggedId = null; } + + onTreeKeydown(event: KeyboardEvent, row: AdminCategoryTreeRow, index: number): void { + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.focusRow(index + 1); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.focusRow(index - 1); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + if (row.hasChildren && !this.isExpanded(row.category.id)) { + this.toggleExpand.emit(row.category.id); + } else { + this.focusRow(index + 1); + } + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + if (row.hasChildren && this.isExpanded(row.category.id)) { + this.toggleExpand.emit(row.category.id); + } else if (row.category.parentId) { + this.jumpToParent.emit(row.category.id); + } + } else if (event.key === 'Enter') { + event.preventDefault(); + this.edit.emit(row.category.id); + } + } + + private focusRow(index: number): void { + const clamped = Math.max(0, Math.min(this.treeRows.length - 1, index)); + this.focusedRowIndex.set(clamped); + queueMicrotask(() => { + document.getElementById(`category-tree-row-${this.treeRows[clamped]?.category.id}`)?.focus(); + }); + } + + openAssignParent(): void { + this.assignParentOpen.set(true); + } + + confirmAssignParent(parentId: string): void { + this.bulkAssignParent.emit(parentId || null); + this.assignParentOpen.set(false); + } + + openBulkImagePicker(): void { + this.imagePickerOpen.set(true); + } + + onBulkImagePicked(asset: MediaAsset): void { + this.bulkAssignImage.emit(asset.url); + this.imagePickerOpen.set(false); + } } diff --git a/src/app/features/admin/categories/components/admin-category-form.component.html b/src/app/features/admin/categories/components/admin-category-form.component.html index dd7ef48..28b0760 100644 --- a/src/app/features/admin/categories/components/admin-category-form.component.html +++ b/src/app/features/admin/categories/components/admin-category-form.component.html @@ -1,63 +1,142 @@
- @if (breadcrumb.length > 0) { - - } - -
- - - - - - - - - - - +
+
-

{{ 'adminCategories.image' | translate }}

-
- @if (category.imageUrl) { - +
+ @for (group of groups; track group.id) { + } - {{ 'adminCategories.chooseImage' | translate }} -
- - - +
+ @if (activeGroup() === 'general') { + @if (breadcrumb.length > 0) { + + } +
+ + + + + + + + + + +
-

{{ 'adminProducts.translations' | translate }}

- @for (locale of locales; track locale) { -
- - + + - - - -
- } -

{{ 'adminProducts.seo' | translate }}

-
- - - - - - - +

{{ 'adminProducts.translations' | translate }}

+ @for (locale of locales; track locale) { +
+ + + + + + +
+ } + } + + @if (activeGroup() === 'media') { + + + + + + + } + + @if (activeGroup() === 'seo') { +

{{ 'adminProducts.seoExplain' | translate }}

+ + + + + + + + + + +
+

{{ category.seo.metaTitle || category.title }}

+

yourstore.com/category/{{ category.slug }}

+

{{ category.seo.metaDescription || category.description }}

+
+ } + + @if (activeGroup() === 'visibility') { + +

{{ 'adminCategories.visibilityExplain' | translate }}

+ } + + @if (activeGroup() === 'navigation') { +

{{ 'adminCategories.navBreadcrumb' | translate }}

+

{{ breadcrumb.length > 0 ? breadcrumb.join(' / ') : ('adminCategories.navRoot' | translate) }}

+ +

{{ 'adminCategories.navChildren' | translate }}

+ @if (children.length === 0) { +

{{ 'adminCategories.navNoChildren' | translate }}

+ } @else { + + } + +

{{ 'adminCategories.navAppearsIn' | translate }}

+

{{ (category.visible ? 'adminCategories.navAppearsVisible' : 'adminCategories.navAppearsHidden') | translate }}

+ } + + @if (activeGroup() === 'attributes') { + + + + + + + } + + @if (activeGroup() === 'advanced') { +
+ + + + + + +
+ }
diff --git a/src/app/features/admin/categories/components/admin-category-form.component.scss b/src/app/features/admin/categories/components/admin-category-form.component.scss index e37a2a0..1705828 100644 --- a/src/app/features/admin/categories/components/admin-category-form.component.scss +++ b/src/app/features/admin/categories/components/admin-category-form.component.scss @@ -12,3 +12,33 @@ input[type='checkbox'] { width: auto; } .image-field .preview { width: 64px; height: 64px; object-fit: cover; border-radius: 10px; border: 1px solid var(--border-color, #d3dad9); } .actions { display: flex; justify-content: flex-end; gap: 10px; } @media (max-width: 900px) { .grid.two { grid-template-columns: 1fr; } } + +.form-card__header { display: grid; gap: 8px; } + +.form-card__tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--border-subtle, #e7ece9); } +.form-card__tab { + all: unset; + cursor: pointer; + padding: 10px 14px; + border-radius: 8px 8px 0 0; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-secondary, #5f6e6a); + display: inline-flex; + align-items: center; + gap: 6px; + + &:focus-visible { outline: 2px solid var(--brand-primary, #1e8a6e); outline-offset: 2px; } +} +.form-card__tab--active { color: var(--brand-primary, #1e8a6e); background: var(--surface-muted, #eef2f0); } +.form-card__panel { min-height: 200px; display: grid; gap: 14px; } +.form-card__subheading { margin: 8px 0 0; font-size: 0.85rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-secondary, #5f6e6a); } +.form-card__explain { margin: 0; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); } +.toggle-row { display: flex; align-items: center; gap: 8px; font-weight: 400; } + +.nav-children { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: 0.85rem; } + +.seo-preview { border: 1px solid var(--border-subtle, #e7ece9); border-radius: 10px; padding: 12px 14px; display: grid; gap: 2px; } +.seo-preview__title { margin: 0; color: #1a0dab; font-size: 1rem; } +.seo-preview__url { margin: 0; color: #006621; font-size: 0.8rem; } +.seo-preview__description { margin: 0; color: var(--text-secondary, #5f6e6a); font-size: 0.85rem; } diff --git a/src/app/features/admin/categories/components/admin-category-form.component.ts b/src/app/features/admin/categories/components/admin-category-form.component.ts index 9a33f42..f1ad1fa 100644 --- a/src/app/features/admin/categories/components/admin-category-form.component.ts +++ b/src/app/features/admin/categories/components/admin-category-form.component.ts @@ -1,17 +1,36 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { AdminCategory } from '../models/admin-category.model'; +import { DatePipe } from '@angular/common'; +import { AdminCategory, AdminCategoryAttribute } from '../models/admin-category.model'; import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { InputComponent } from '../../../../shared/ui/input/input.component'; import { FormFieldComponent } from '../../../../shared/ui/form-field/form-field.component'; -import { MediaPickerComponent } from '../../../../shared/media/media-picker/media-picker.component'; -import { MediaAsset } from '../../../../core/media/models/media-asset.model'; +import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component'; +import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; +import { KeyValueEditorComponent } from '../../../../shared/ui/key-value-editor/key-value-editor.component'; +import { ImageFieldComponent } from '../../../../shared/ui/image-field/image-field.component'; +import { AdminCategoryHealth } from '../facade/admin-categories.facade'; +import { CategoryHealthWidgetComponent, CategoryHealthItem } from './category-health-widget/category-health-widget.component'; + +export type AdminCategoryEditorGroup = 'general' | 'media' | 'seo' | 'visibility' | 'navigation' | 'attributes' | 'advanced'; @Component({ selector: 'app-admin-category-form', standalone: true, - imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, FormFieldComponent, MediaPickerComponent], + imports: [ + FormsModule, + DatePipe, + TranslatePipe, + ButtonComponent, + InputComponent, + FormFieldComponent, + ToggleComponent, + BadgeComponent, + KeyValueEditorComponent, + ImageFieldComponent, + CategoryHealthWidgetComponent, + ], templateUrl: './admin-category-form.component.html', styleUrls: ['./admin-category-form.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -20,15 +39,40 @@ export class AdminCategoryFormComponent { @Input({ required: true }) category!: AdminCategory; @Input() parentOptions: AdminCategory[] = []; @Input() breadcrumb: string[] = []; + @Input() children: AdminCategory[] = []; @Input() slugTaken = false; @Input() locales: string[] = ['en']; @Input() mode: 'create' | 'edit' = 'create'; + @Input() health!: AdminCategoryHealth; @Output() categoryChange = new EventEmitter>(); @Output() saveDraft = new EventEmitter(); @Output() publish = new EventEmitter(); - protected mediaPickerOpen = false; + readonly groups: { id: AdminCategoryEditorGroup; labelKey: string }[] = [ + { id: 'general', labelKey: 'adminCategories.groupGeneral' }, + { id: 'media', labelKey: 'adminCategories.groupMedia' }, + { id: 'seo', labelKey: 'adminCategories.groupSeo' }, + { id: 'visibility', labelKey: 'adminCategories.groupVisibility' }, + { id: 'navigation', labelKey: 'adminCategories.groupNavigation' }, + { id: 'attributes', labelKey: 'adminCategories.groupAttributes' }, + { id: 'advanced', labelKey: 'adminCategories.groupAdvanced' }, + ]; + + readonly activeGroup = signal('general'); + + setGroup(group: AdminCategoryEditorGroup): void { + this.activeGroup.set(group); + } + + readonly healthItems = computed(() => [ + { labelKey: 'adminCategories.healthImage', done: this.health.hasImage }, + { labelKey: 'adminCategories.healthSeo', done: this.health.hasSeo }, + { labelKey: 'adminCategories.healthDescription', done: this.health.hasDescription }, + { labelKey: 'adminCategories.healthParent', done: this.health.hasValidParent }, + { labelKey: 'adminCategories.healthVisibility', done: this.health.isVisible }, + { labelKey: 'adminCategories.healthProducts', done: this.health.hasProducts }, + ]); updateField(key: K, value: AdminCategory[K]): void { this.categoryChange.emit({ [key]: value } as Partial); @@ -43,12 +87,17 @@ export class AdminCategoryFormComponent { }); } - openMediaPicker(): void { - this.mediaPickerOpen = true; + setImage(url: string): void { + this.updateField('imageUrl', url); } - onImagePicked(asset: MediaAsset): void { - this.updateField('imageUrl', asset.url); - this.mediaPickerOpen = false; + readonly createAttributeRow = (): AdminCategoryAttribute => ({ key: '', value: '' }); + + updateAttributes(rows: AdminCategoryAttribute[]): void { + this.categoryChange.emit({ attributes: rows }); + } + + updateAttributeField(index: number, field: 'key' | 'value', value: string): void { + this.updateAttributes(this.category.attributes.map((row, i) => i === index ? { ...row, [field]: value } : row)); } } diff --git a/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.html b/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.html new file mode 100644 index 0000000..22e2487 --- /dev/null +++ b/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.html @@ -0,0 +1,22 @@ +
+
+ + + + + + + + + + +
+ + +

{{ 'adminCategories.recommendedNext' | translate }}

+

{{ stats().recommendation.labelKey | translate }}

+ @if (stats().recommendation.categoryId) { + {{ 'adminProducts.openAction' | translate }} + } +
+
diff --git a/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.scss b/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.scss new file mode 100644 index 0000000..f50a1a6 --- /dev/null +++ b/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.scss @@ -0,0 +1,41 @@ +.categories-dashboard { + display: grid; + gap: 16px; + margin-bottom: 8px; +} + +.categories-dashboard__metrics { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +@media (max-width: 1100px) { + .categories-dashboard__metrics { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .categories-dashboard__metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.categories-dashboard__recommend { + display: grid; + gap: 8px; + align-content: start; + + h4 { + margin: 0; + font-size: 0.95rem; + font-weight: 700; + } + + p { + margin: 0; + color: var(--text-secondary, #5f6e6a); + font-size: 0.9rem; + } +} diff --git a/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.ts b/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.ts new file mode 100644 index 0000000..310da34 --- /dev/null +++ b/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.ts @@ -0,0 +1,36 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, input } from '@angular/core'; +import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +import { TranslateService } from '../../../../../i18n/translate.service'; +import { ButtonComponent } from '../../../../../shared/ui/button/button.component'; +import { CardComponent } from '../../../../../shared/ui/card/card.component'; +import { DashboardMetricComponent } from '../../../dashboard/components/dashboard-metric.component'; +import { AdminCategoriesDashboardStats } from '../../facade/admin-categories.facade'; +import { inject } from '@angular/core'; + +@Component({ + selector: 'app-categories-dashboard', + standalone: true, + imports: [TranslatePipe, ButtonComponent, CardComponent, DashboardMetricComponent], + templateUrl: './categories-dashboard.component.html', + styleUrl: './categories-dashboard.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CategoriesDashboardComponent { + private readonly translate = inject(TranslateService); + + readonly stats = input.required(); + + @Output() openCategory = new EventEmitter(); + + readonly lastModifiedValue = computed(() => { + const iso = this.stats().lastModifiedAt; + return iso ? new Date(iso).toLocaleDateString() : this.translate.t('adminCategories.lastModifiedNever'); + }); + + goRecommended(): void { + const id = this.stats().recommendation.categoryId; + if (id) { + this.openCategory.emit(id); + } + } +} diff --git a/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.html b/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.html new file mode 100644 index 0000000..b6a5bdc --- /dev/null +++ b/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.html @@ -0,0 +1,16 @@ +
+
+
+
+ {{ completionPercent() }}% + @if (!compact()) { +
    + @for (item of items(); track item.labelKey) { +
  • + + {{ item.labelKey | translate }} +
  • + } +
+ } +
diff --git a/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.scss b/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.scss new file mode 100644 index 0000000..36ec762 --- /dev/null +++ b/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.scss @@ -0,0 +1,47 @@ +.category-health { + display: grid; + gap: 6px; + align-items: center; +} + +.category-health__bar { + height: 6px; + border-radius: 999px; + background: var(--surface-muted, #eef2f0); + overflow: hidden; +} + +.category-health__bar-fill { + height: 100%; + background: var(--brand-primary, #1e8a6e); +} + +.category-health__percent { + font-size: 0.75rem; + font-weight: 700; + color: var(--text-secondary, #5f6e6a); +} + +.category-health__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 4px; + font-size: 0.78rem; + color: var(--text-secondary, #5f6e6a); + + li { + display: flex; + align-items: center; + gap: 6px; + } +} + +.category-health__list-item--done { + color: var(--text-primary, #1e3c38); +} + +.category-health--compact { + grid-template-columns: 1fr auto; +} diff --git a/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.ts b/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.ts new file mode 100644 index 0000000..090a326 --- /dev/null +++ b/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.ts @@ -0,0 +1,22 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { TranslatePipe } from '../../../../../i18n/translate.pipe'; + +export interface CategoryHealthItem { + labelKey: string; + done: boolean; +} + +/** Reusable checklist + completion meter, fed real per-item booleans from AdminCategoriesFacade.health() - never invents its own data. */ +@Component({ + selector: 'app-category-health-widget', + standalone: true, + imports: [TranslatePipe], + templateUrl: './category-health-widget.component.html', + styleUrl: './category-health-widget.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CategoryHealthWidgetComponent { + readonly items = input.required(); + readonly completionPercent = input.required(); + readonly compact = input(false); +} diff --git a/src/app/features/admin/categories/facade/admin-categories.facade.ts b/src/app/features/admin/categories/facade/admin-categories.facade.ts index b9a96ba..2fd31a0 100644 --- a/src/app/features/admin/categories/facade/admin-categories.facade.ts +++ b/src/app/features/admin/categories/facade/admin-categories.facade.ts @@ -8,6 +8,40 @@ import { ProjectEditorFacade } from '../../../project-editor/facade/project-edit const DRAFT_KEY_PREFIX = 'admin-category-draft:'; const NEW_CATEGORY_DRAFT_KEY = `${DRAFT_KEY_PREFIX}new`; +const VIEW_MODE_KEY = 'admin-categories:view-mode'; +const DENSITY_KEY = 'admin-categories:density'; +const COLUMNS_KEY = 'admin-categories:visible-columns'; +const EXPANDED_KEY = 'admin-categories:expanded'; + +export type AdminCategoriesViewMode = 'tree' | 'table' | 'cards'; +export type AdminCategoriesDensity = 'comfortable' | 'compact'; + +export const ALL_CATEGORY_COLUMNS = ['slug', 'items', 'status', 'visibility', 'updated'] as const; +export type AdminCategoryColumn = typeof ALL_CATEGORY_COLUMNS[number]; + +export interface AdminCategoryHealth { + hasImage: boolean; + hasSeo: boolean; + hasDescription: boolean; + hasValidParent: boolean; + isVisible: boolean; + hasProducts: boolean; + completionPercent: number; +} + +export interface AdminCategoriesDashboardStats { + total: number; + visible: number; + hidden: number; + empty: number; + rootCount: number; + subCount: number; + missingImages: number; + missingSeo: number; + lastModifiedAt: string | null; + completionPercent: number; + recommendation: { labelKey: string; categoryId?: string }; +} @Injectable({ providedIn: 'root' }) export class AdminCategoriesFacade { @@ -18,6 +52,76 @@ export class AdminCategoriesFacade { readonly supportedLocales = computed(() => this.projectEditor.bootstrap()?.localization.supportedLocales ?? ['en']); + readonly viewMode = signal((this.localStorage.getItem(VIEW_MODE_KEY) as AdminCategoriesViewMode) || 'tree'); + readonly density = signal((this.localStorage.getItem(DENSITY_KEY) as AdminCategoriesDensity) || 'comfortable'); + readonly visibleColumns = signal(this.localStorage.getJSON(COLUMNS_KEY) ?? [...ALL_CATEGORY_COLUMNS]); + readonly expandedIds = signal>(new Set(this.localStorage.getJSON(EXPANDED_KEY) ?? [])); + readonly selectedIds = signal([]); + + readonly hasSelection = computed(() => this.selectedIds().length > 0); + + setViewMode(mode: AdminCategoriesViewMode): void { + this.viewMode.set(mode); + this.localStorage.setItem(VIEW_MODE_KEY, mode); + } + + setDensity(density: AdminCategoriesDensity): void { + this.density.set(density); + this.localStorage.setItem(DENSITY_KEY, density); + } + + setColumnVisible(column: AdminCategoryColumn, visible: boolean): void { + const next = visible ? [...new Set([...this.visibleColumns(), column])] : this.visibleColumns().filter(c => c !== column); + this.visibleColumns.set(next); + this.localStorage.setJSON(COLUMNS_KEY, next); + } + + isExpanded(id: string): boolean { + return this.expandedIds().has(id); + } + + toggleExpanded(id: string): void { + const next = new Set(this.expandedIds()); + next.has(id) ? next.delete(id) : next.add(id); + this.expandedIds.set(next); + this.localStorage.setJSON(EXPANDED_KEY, [...next]); + } + + /** Expands every ancestor of `id` (not `id` itself) so a deep node becomes reachable - used by search and "jump to parent". */ + expandAncestorsOf(id: string): void { + const next = new Set(this.expandedIds()); + let current = this.categories().find(category => category.id === id); + while (current?.parentId) { + next.add(current.parentId); + current = this.categories().find(category => category.id === current!.parentId); + } + this.expandedIds.set(next); + this.localStorage.setJSON(EXPANDED_KEY, [...next]); + } + + expandAll(): void { + const next = new Set(this.categories().map(category => category.id)); + this.expandedIds.set(next); + this.localStorage.setJSON(EXPANDED_KEY, [...next]); + } + + collapseAll(): void { + this.expandedIds.set(new Set()); + this.localStorage.setJSON(EXPANDED_KEY, []); + } + + toggleSelection(id: string, checked: boolean): void { + this.selectedIds.update(current => checked ? [...new Set([...current, id])] : current.filter(item => item !== id)); + } + + toggleAll(checked: boolean): void { + this.selectedIds.set(checked ? this.categories().map(category => category.id) : []); + } + + clearSelection(): void { + this.selectedIds.set([]); + } + ensureLocalesLoaded(): void { if (!this.projectEditor.bootstrap()) { this.projectEditor.loadBootstrap(); @@ -60,9 +164,16 @@ export class AdminCategoriesFacade { return trail; } + /** + * Always fetches the full unfiltered catalog (search/visibility/deleted always 'all' + * at the gateway) so parent/child chains never go missing when a filter would have + * excluded an ancestor - the tree previously broke this way. All filtering for both + * the tree and the flat table/cards views happens client-side in filteredCategories() + * and visibleTreeRows() below. + */ loadList(): void { this.loading.set(true); - this.gateway.loadCategories(this.filters()).pipe(take(1)).subscribe({ + this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({ next: categories => { this.categories.set(categories); this.loading.set(false); @@ -76,7 +187,77 @@ export class AdminCategoriesFacade { updateFilters(patch: Partial): void { this.filters.update(current => ({ ...current, ...patch })); - this.loadList(); + } + + private matchesSearch(category: AdminCategory, query: string): boolean { + return !query || category.title.toLowerCase().includes(query.trim().toLowerCase()); + } + + /** Flat view (table/cards) - applies search/visibility/includeDeleted client-side. */ + readonly filteredCategories = computed(() => { + const { search, visibility, includeDeleted } = this.filters(); + return this.categories() + .filter(category => includeDeleted || !category.deletedAt) + .filter(category => visibility === 'all' || (visibility === 'visible' ? category.visible : !category.visible)) + .filter(category => this.matchesSearch(category, search)) + .sort((a, b) => a.order - b.order); + }); + + /** + * Tree rows, respecting expand/collapse state - and while searching, branches with no + * matching descendant are hidden and matching branches are force-expanded (search always + * wins over manually-collapsed state), reverting to the remembered expand state once the + * search is cleared. + */ + readonly visibleTreeRows = computed<{ category: AdminCategory; depth: number; hasChildren: boolean }[]>(() => { + const { search, visibility, includeDeleted } = this.filters(); + const visible = (category: AdminCategory) => (includeDeleted || !category.deletedAt) && (visibility === 'all' || (visibility === 'visible' ? category.visible : !category.visible)); + const query = search.trim(); + + const keepSet = query ? this.computeSearchKeepSet(query, visible) : null; + const expanded = this.expandedIds(); + + const build = (parentId: string | null, depth: number): { category: AdminCategory; depth: number; hasChildren: boolean }[] => { + const siblings = (parentId === null ? this.categories().filter(c => !c.parentId) : this.categories().filter(c => c.parentId === parentId)) + .filter(visible) + .filter(category => !keepSet || keepSet.has(category.id)) + .sort((a, b) => a.order - b.order); + + return siblings.flatMap(category => { + const children = this.categories().filter(c => c.parentId === category.id).filter(visible); + const hasChildren = children.length > 0; + const isOpen = query ? true : expanded.has(category.id); + const row = { category, depth, hasChildren }; + const nested = hasChildren && isOpen ? build(category.id, depth + 1) : []; + return [row, ...nested]; + }); + }; + + return build(null, 0); + }); + + private computeSearchKeepSet(query: string, visible: (category: AdminCategory) => boolean): Set { + const all = this.categories().filter(visible); + const matches = all.filter(category => this.matchesSearch(category, query)); + const keep = new Set(); + + const addAncestors = (id: string) => { + let current = all.find(category => category.id === id); + while (current) { + keep.add(current.id); + current = current.parentId ? all.find(category => category.id === current!.parentId) : undefined; + } + }; + const addDescendants = (id: string) => { + keep.add(id); + all.filter(category => category.parentId === id).forEach(child => addDescendants(child.id)); + }; + + matches.forEach(match => { + addAncestors(match.id); + addDescendants(match.id); + }); + return keep; } startCreate(): void { @@ -161,18 +342,18 @@ export class AdminCategoriesFacade { } deleteOne(id: string): void { - this.gateway.deleteCategory(id).pipe(take(1)).subscribe({ next: () => this.loadList() }); + this.gateway.deleteCategory(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } }); } restoreOne(id: string): void { - this.gateway.restoreCategory(id).pipe(take(1)).subscribe({ next: () => this.loadList() }); + this.gateway.restoreCategory(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } }); } setVisible(id: string, visible: boolean): void { const category = this.categories().find(item => item.id === id); if (!category) return; this.gateway.updateCategory({ ...category, visible, updatedAt: new Date().toISOString() }).pipe(take(1)) - .subscribe({ next: () => this.loadList() }); + .subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } }); } reorder(id: string, targetId: string): void { @@ -197,4 +378,137 @@ export class AdminCategoriesFacade { updates.forEach(category => this.gateway.updateCategory(category).pipe(take(1)).subscribe()); this.loadList(); } + + applyBulkVisibility(visible: boolean): void { + const selected = new Set(this.selectedIds()); + const updates = this.categories().filter(category => selected.has(category.id)).map(category => ({ ...category, visible, updatedAt: new Date().toISOString() })); + updates.forEach(category => this.gateway.updateCategory(category).pipe(take(1)).subscribe()); + this.clearSelection(); + this.loadList(); + this.loadDashboardStats(); + } + + applyBulkDelete(): void { + const ids = [...this.selectedIds()].filter(id => this.canDelete(id)); + ids.forEach(id => this.gateway.deleteCategory(id).pipe(take(1)).subscribe()); + this.clearSelection(); + this.loadList(); + this.loadDashboardStats(); + } + + applyBulkAssignParent(parentId: string | null): void { + const selected = new Set(this.selectedIds()); + // A category can never become its own ancestor - drop any selected id that is the target parent itself. + const updates = this.categories() + .filter(category => selected.has(category.id) && category.id !== parentId) + .map(category => ({ ...category, parentId, updatedAt: new Date().toISOString() })); + updates.forEach(category => this.gateway.updateCategory(category).pipe(take(1)).subscribe()); + this.clearSelection(); + this.loadList(); + this.loadDashboardStats(); + } + + applyBulkAssignImage(imageUrl: string): void { + const selected = new Set(this.selectedIds()); + const updates = this.categories().filter(category => selected.has(category.id)).map(category => ({ ...category, imageUrl, updatedAt: new Date().toISOString() })); + updates.forEach(category => this.gateway.updateCategory(category).pipe(take(1)).subscribe()); + this.clearSelection(); + this.loadList(); + this.loadDashboardStats(); + } + + /** Duplicates via the existing createCategory call (no dedicated duplicate endpoint exists) - a real create, not a fabricated action. */ + applyBulkDuplicate(): void { + const selected = new Set(this.selectedIds()); + const sources = this.categories().filter(category => selected.has(category.id)); + sources.forEach(source => { + const now = new Date().toISOString(); + const duplicated: AdminCategory = { + ...source, + id: `category-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + slug: `${source.slug}-copy-${Date.now()}`, + title: `${source.title} Copy`, + status: 'draft', + createdAt: now, + updatedAt: now, + }; + this.gateway.createCategory(duplicated).pipe(take(1)).subscribe(); + }); + this.clearSelection(); + this.loadList(); + this.loadDashboardStats(); + } + + /** Client-side CSV of the currently selected rows - no backend export endpoint exists. */ + exportSelectedAsCsv(): void { + const selected = new Set(this.selectedIds()); + const rows = this.categories().filter(category => selected.has(category.id)); + const header = ['id', 'title', 'slug', 'parentId', 'visible', 'status', 'itemsCount']; + const lines = rows.map(category => [category.id, category.title, category.slug, category.parentId ?? '', category.visible, category.status, category.itemsCount] + .map(value => `"${String(value).replace(/"/g, '""')}"`).join(',')); + const csv = [header.join(','), ...lines].join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'categories-export.csv'; + link.click(); + URL.revokeObjectURL(url); + } + + health(category: AdminCategory): AdminCategoryHealth { + const hasImage = category.imageUrl.trim().length > 0; + const hasSeo = !!(category.seo.metaTitle.trim() && category.seo.metaDescription.trim()); + const hasDescription = category.description.trim().length > 0; + const hasValidParent = category.parentId === null || this.categories().some(other => other.id === category.parentId); + const isVisible = category.visible; + const hasProducts = category.itemsCount > 0; + const checks = [hasImage, hasSeo, hasDescription, hasValidParent, isVisible, hasProducts]; + const completionPercent = Math.round((checks.filter(Boolean).length / checks.length) * 100); + return { hasImage, hasSeo, hasDescription, hasValidParent, isVisible, hasProducts, completionPercent }; + } + + readonly dashboardStats = signal(null); + + /** Dashboard stats reflect the whole catalog, not the current filtered view. */ + loadDashboardStats(): void { + this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: false }).pipe(take(1)) + .subscribe({ next: categories => this.dashboardStats.set(this.computeDashboardStats(categories)) }); + } + + private computeDashboardStats(categories: AdminCategory[]): AdminCategoriesDashboardStats { + const withHealth = categories.map(category => ({ category, health: this.health(category) })); + const missingImages = withHealth.filter(entry => !entry.health.hasImage); + const missingSeo = withHealth.filter(entry => !entry.health.hasSeo); + const empty = categories.filter(category => category.itemsCount === 0); + const lastModifiedAt = categories.reduce((latest, category) => !latest || category.updatedAt > latest ? category.updatedAt : latest, null); + const avgCompletion = withHealth.length > 0 + ? Math.round(withHealth.reduce((sum, entry) => sum + entry.health.completionPercent, 0) / withHealth.length) + : 0; + + let recommendation: AdminCategoriesDashboardStats['recommendation']; + if (missingImages[0]) { + recommendation = { labelKey: 'adminCategories.recommendAddImage', categoryId: missingImages[0].category.id }; + } else if (missingSeo[0]) { + recommendation = { labelKey: 'adminCategories.recommendAddSeo', categoryId: missingSeo[0].category.id }; + } else if (empty[0]) { + recommendation = { labelKey: 'adminCategories.recommendFillEmpty', categoryId: empty[0].id }; + } else { + recommendation = { labelKey: 'adminCategories.recommendNone' }; + } + + return { + total: categories.length, + visible: categories.filter(category => category.visible).length, + hidden: categories.filter(category => !category.visible).length, + empty: empty.length, + rootCount: categories.filter(category => !category.parentId).length, + subCount: categories.filter(category => !!category.parentId).length, + missingImages: missingImages.length, + missingSeo: missingSeo.length, + lastModifiedAt, + completionPercent: avgCompletion, + recommendation, + }; + } } diff --git a/src/app/features/admin/categories/models/admin-category.model.ts b/src/app/features/admin/categories/models/admin-category.model.ts index b88e65c..c655a4c 100644 --- a/src/app/features/admin/categories/models/admin-category.model.ts +++ b/src/app/features/admin/categories/models/admin-category.model.ts @@ -13,6 +13,11 @@ export interface AdminCategorySeo { keywords: string; } +export interface AdminCategoryAttribute { + key: string; + value: string; +} + export interface AdminCategory { id: string; parentId: string | null; @@ -21,12 +26,14 @@ export interface AdminCategory { description: string; icon: string; imageUrl: string; + imageAlt: string; order: number; visible: boolean; status: AdminCategoryStatus; itemsCount: number; translations: Record; seo: AdminCategorySeo; + attributes: AdminCategoryAttribute[]; deletedAt: string | null; createdAt: string; updatedAt: string; diff --git a/src/app/features/admin/categories/pages/admin-categories-list-page.component.ts b/src/app/features/admin/categories/pages/admin-categories-list-page.component.ts index 22a6abd..e6bf355 100644 --- a/src/app/features/admin/categories/pages/admin-categories-list-page.component.ts +++ b/src/app/features/admin/categories/pages/admin-categories-list-page.component.ts @@ -1,8 +1,7 @@ -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { Router } from '@angular/router'; import { AdminCategoriesFacade } from '../facade/admin-categories.facade'; -import { AdminCategoriesListComponent, AdminCategoryRow } from '../components/admin-categories-list.component'; -import { AdminCategory } from '../models/admin-category.model'; +import { AdminCategoriesListComponent } from '../components/admin-categories-list.component'; import { LanguageService } from '../../../../services/language.service'; import { TranslateService } from '../../../../i18n/translate.service'; @@ -11,16 +10,41 @@ import { TranslateService } from '../../../../i18n/translate.service'; standalone: true, imports: [AdminCategoriesListComponent], template: ``, + (reorder)="facade.reorder($event.id, $event.targetId)" + (toggleExpand)="facade.toggleExpanded($event)" + (jumpToParent)="jumpToParent($event)" + (expandAll)="facade.expandAll()" + (collapseAll)="facade.collapseAll()" + (selectionChange)="facade.toggleSelection($event.id, $event.checked)" + (selectAll)="facade.toggleAll($event)" + (bulkVisibility)="facade.applyBulkVisibility($event)" + (bulkDelete)="facade.applyBulkDelete()" + (bulkDuplicate)="facade.applyBulkDuplicate()" + (bulkAssignParent)="facade.applyBulkAssignParent($event)" + (bulkAssignImage)="facade.applyBulkAssignImage($event)" + (bulkExport)="facade.exportSelectedAsCsv()" + (viewModeChange)="facade.setViewMode($event)" + (densityChange)="facade.setDensity($event)" + (columnToggle)="facade.setColumnVisible($event.column, $event.visible)" />`, changeDetection: ChangeDetectionStrategy.OnPush }) export class AdminCategoriesListPageComponent { @@ -29,15 +53,26 @@ export class AdminCategoriesListPageComponent { private readonly languageService = inject(LanguageService); private readonly translate = inject(TranslateService); - readonly rows = computed(() => this.buildRows(this.facade.rootCategories(), 0)); + readonly healthFn = (category: Parameters[0]) => this.facade.health(category); + readonly isExpandedFn = (id: string) => this.facade.isExpanded(id); + readonly canDeleteFn = (id: string) => this.facade.canDelete(id); constructor() { this.facade.loadList(); + this.facade.loadDashboardStats(); } create(): void { this.facade.startCreate(); void this.router.navigate([this.lang(), 'backoffice', 'categories', 'create']); } edit(id: string): void { this.facade.loadForEdit(id); void this.router.navigate([this.lang(), 'backoffice', 'categories', id, 'edit']); } + jumpToParent(id: string): void { + const category = this.facade.categories().find(c => c.id === id); + if (category?.parentId) { + this.facade.expandAncestorsOf(id); + queueMicrotask(() => document.getElementById(`category-tree-row-${category.parentId}`)?.focus()); + } + } + deleteOne(id: string): void { if (!this.facade.canDelete(id)) { window.alert(this.translate.t('adminCategories.deleteBlocked')); @@ -48,13 +83,6 @@ export class AdminCategoriesListPageComponent { } } - private buildRows(categories: AdminCategory[], depth: number): AdminCategoryRow[] { - return categories.flatMap(category => [ - { category, depth }, - ...this.buildRows(this.facade.childrenOf(category.id), depth + 1) - ]); - } - private lang(): string { return this.languageService.currentLanguage(); } diff --git a/src/app/features/admin/categories/pages/admin-category-editor-page.component.ts b/src/app/features/admin/categories/pages/admin-category-editor-page.component.ts index 778266c..1b6d935 100644 --- a/src/app/features/admin/categories/pages/admin-category-editor-page.component.ts +++ b/src/app/features/admin/categories/pages/admin-category-editor-page.component.ts @@ -9,7 +9,7 @@ import { LanguageService } from '../../../../services/language.service'; selector: 'app-admin-category-editor-page', standalone: true, imports: [AdminCategoryFormComponent, TranslatePipe], - template: `@if (facade.draft(); as draft) {

{{ title() | translate }}

} @else {

{{ 'common.loading' | translate }}

}`, + template: `@if (facade.draft(); as draft) {

{{ title() | translate }}

} @else {

{{ 'common.loading' | translate }}

}`, styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`], changeDetection: ChangeDetectionStrategy.OnPush }) diff --git a/src/app/features/admin/categories/services/admin-categories-form.factory.ts b/src/app/features/admin/categories/services/admin-categories-form.factory.ts index f72a110..0506a02 100644 --- a/src/app/features/admin/categories/services/admin-categories-form.factory.ts +++ b/src/app/features/admin/categories/services/admin-categories-form.factory.ts @@ -12,12 +12,14 @@ export class AdminCategoriesFormFactory { description: '', icon: '', imageUrl: '', + imageAlt: '', order: 0, visible: true, status: 'draft', itemsCount: 0, translations: { en: {}, ru: {}, hy: {} }, seo: { metaTitle: '', metaDescription: '', keywords: '' }, + attributes: [], deletedAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/src/app/features/admin/categories/services/admin-categories-local.gateway.ts b/src/app/features/admin/categories/services/admin-categories-local.gateway.ts index f5661f0..95456f0 100644 --- a/src/app/features/admin/categories/services/admin-categories-local.gateway.ts +++ b/src/app/features/admin/categories/services/admin-categories-local.gateway.ts @@ -93,6 +93,7 @@ export class AdminCategoriesLocalGateway implements AdminCategoriesGateway { description: category.description ?? '', icon: category.icon ?? '', imageUrl: category.imageUrl ?? '', + imageAlt: '', order: 0, visible: true, status: 'published', @@ -107,6 +108,7 @@ export class AdminCategoriesLocalGateway implements AdminCategoriesGateway { metaDescription: category.description ?? '', keywords: '', }, + attributes: [], deletedAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 38feb4e..a7e1bb5 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -1221,6 +1221,84 @@ export const en: Translations = { adminCategories: { chooseImage: 'Choose image', replaceImage: 'Replace image', + search: 'Search categories…', + showDeleted: 'Show deleted', + create: 'Create category', + edit: 'Edit category', + title: 'Title', + slug: 'URL slug', + slugTaken: 'This slug is already used by another category.', + parent: 'Parent category', + noParent: 'No parent (top level)', + icon: 'Icon', + image: 'Image', + imageHint: 'Shown in category listings and navigation.', + description: 'Description', + items: 'products', + status: { draft: 'Draft', published: 'Published' }, + restore: 'Restore', + saveDraft: 'Save draft', + publish: 'Publish', + confirmDelete: 'Delete this category? This cannot be undone.', + confirmLeaveUnsaved: 'You have unsaved changes. Leave without saving?', + deleteBlocked: 'This category can\'t be deleted while it has subcategories or assigned products.', + emptyTitle: 'No categories yet', + emptyDescription: 'Create your first category to start organizing products.', + emptyGuide: 'Categories help customers browse and find products. Start with a few broad top-level categories (e.g. Clothing, Electronics), then add subcategories as your catalog grows — avoid nesting more than 2-3 levels deep.', + viewTree: 'Tree', + viewCards: 'Cards', + expandAll: 'Expand all', + collapseAll: 'Collapse all', + expandNode: 'Expand', + collapseNode: 'Collapse', + jumpToParent: 'Jump to parent', + deletedBadge: 'Deleted', + column_slug: 'Slug', + column_items: 'Products', + column_status: 'Status', + column_visibility: 'Visibility', + column_updated: 'Last updated', + bulkAssignParent: 'Assign parent', + bulkAssignImage: 'Assign image', + groupGeneral: 'General', + groupMedia: 'Media', + groupSeo: 'SEO', + groupVisibility: 'Visibility', + groupNavigation: 'Navigation', + groupAttributes: 'Attributes', + groupAdvanced: 'Advanced', + visibilityExplain: 'A hidden category and its products stay out of the storefront navigation and search until you make it visible again.', + navBreadcrumb: 'Breadcrumb', + navRoot: 'This is a top-level category.', + navChildren: 'Subcategories', + navNoChildren: 'No subcategories yet.', + navAppearsIn: 'Where this appears', + navAppearsVisible: 'Visible in catalog navigation and search.', + navAppearsHidden: 'Hidden — not shown in catalog navigation while hidden.', + itemsCountLabel: 'Assigned products', + createdAtLabel: 'Created', + healthImage: 'Image', + healthSeo: 'SEO', + healthDescription: 'Description', + healthParent: 'Parent', + healthVisibility: 'Visibility', + healthProducts: 'Products assigned', + totalCategories: 'Total categories', + visibleCount: 'Visible', + hiddenCount: 'Hidden', + emptyCount: 'Empty', + rootCount: 'Root categories', + subCount: 'Subcategories', + missingImagesCount: 'Missing images', + missingSeoCount: 'Missing SEO', + lastModified: 'Last modified', + lastModifiedNever: 'Never', + completionLabel: 'Completion', + recommendedNext: 'Recommended next step', + recommendAddImage: 'A category has no image — add one so it looks trustworthy in listings.', + recommendAddSeo: 'A category is missing a search title or description — add one so it can be found.', + recommendFillEmpty: 'A category has no products assigned — add products or consider hiding it.', + recommendNone: 'Nice work — your category structure is in good shape.', }, adminProducts: { emptyTitle: 'No products found', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 8635e6b..314003e 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -1216,6 +1216,84 @@ export const hy: Translations = { adminCategories: { chooseImage: 'Ընտրել պատկեր', replaceImage: 'Փոխարինել պատկերը', + search: 'Փնտրել կատեգորիաներ…', + showDeleted: 'Ցույց տալ ջնջվածները', + create: 'Ստեղծել կատեգորիա', + edit: 'Խմբագրել կատեգորիան', + title: 'Անուն', + slug: 'URL հասցե', + slugTaken: 'Այս հասցեն արդեն օգտագործվում է այլ կատեգորիայի կողմից։', + parent: 'Ծնող կատեգորիա', + noParent: 'Առանց ծնողի (վերին մակարդակ)', + icon: 'Պատկերակ', + image: 'Պատկեր', + imageHint: 'Ցուցադրվում է կատեգորիաների ցանկերում և նավիգացիայում։', + description: 'Նկարագրություն', + items: 'ապրանք', + status: { draft: 'Սևագիր', published: 'Հրապարակված' }, + restore: 'Վերականգնել', + saveDraft: 'Պահպանել սևագիրը', + publish: 'Հրապարակել', + confirmDelete: 'Ջնջե՞լ այս կատեգորիան։ Հնարավոր չէ հետարկել։', + confirmLeaveUnsaved: 'Դուք ունեք չպահպանված փոփոխություններ։ Դո՞ւրս գալ առանց պահպանելու։', + deleteBlocked: 'Այս կատեգորիան հնարավոր չէ ջնջել, քանի դեռ ունի ենթակատեգորիաներ կամ նշանակված ապրանքներ։', + emptyTitle: 'Կատեգորիաներ դեռ չկան', + emptyDescription: 'Ստեղծեք առաջին կատեգորիան՝ ապրանքները կազմակերպելու համար։', + emptyGuide: 'Կատեգորիաները օգնում են հաճախորդներին գտնել ապրանքներ։ Սկսեք մի քանի լայն վերին մակարդակի կատեգորիաներից (օր․՝ Հագուստ, Էլեկտրոնիկա), ապա ավելացրեք ենթակատեգորիաներ՝ կատալոգի աճին զուգահեռ․ խուսափեք 2-3 մակարդակից ավելի խորությունից։', + viewTree: 'Ծառ', + viewCards: 'Քարտեր', + expandAll: 'Ընդարձակել բոլորը', + collapseAll: 'Ծալել բոլորը', + expandNode: 'Ընդարձակել', + collapseNode: 'Ծալել', + jumpToParent: 'Անցնել ծնողին', + deletedBadge: 'Ջնջված', + column_slug: 'Հասցե', + column_items: 'Ապրանքներ', + column_status: 'Կարգավիճակ', + column_visibility: 'Տեսանելիություն', + column_updated: 'Վերջին փոփոխություն', + bulkAssignParent: 'Նշանակել ծնող', + bulkAssignImage: 'Նշանակել պատկեր', + groupGeneral: 'Հիմնական', + groupMedia: 'Մեդիա', + groupSeo: 'SEO', + groupVisibility: 'Տեսանելիություն', + groupNavigation: 'Նավիգացիա', + groupAttributes: 'Հատկանիշներ', + groupAdvanced: 'Լրացուցիչ', + visibilityExplain: 'Թաքցված կատեգորիան և դրա ապրանքները չեն երևում խանութի նավիգացիայում և որոնման մեջ, մինչև այն նորից տեսանելի դարձնեք։', + navBreadcrumb: 'Ուղենիշ', + navRoot: 'Սա վերին մակարդակի կատեգորիա է։', + navChildren: 'Ենթակատեգորիաներ', + navNoChildren: 'Ենթակատեգորիաներ դեռ չկան։', + navAppearsIn: 'Որտեղ է երևում', + navAppearsVisible: 'Տեսանելի է կատալոգի նավիգացիայում և որոնման մեջ։', + navAppearsHidden: 'Թաքցված է․ չի երևում կատալոգի նավիգացիայում, քանի դեռ թաքցված է։', + itemsCountLabel: 'Նշանակված ապրանքներ', + createdAtLabel: 'Ստեղծված է', + healthImage: 'Պատկեր', + healthSeo: 'SEO', + healthDescription: 'Նկարագրություն', + healthParent: 'Ծնող', + healthVisibility: 'Տեսանելիություն', + healthProducts: 'Նշանակված ապրանքներ', + totalCategories: 'Ընդհանուր կատեգորիաներ', + visibleCount: 'Տեսանելի', + hiddenCount: 'Թաքցված', + emptyCount: 'Դատարկ', + rootCount: 'Արմատային կատեգորիաներ', + subCount: 'Ենթակատեգորիաներ', + missingImagesCount: 'Առանց պատկերի', + missingSeoCount: 'Առանց SEO-ի', + lastModified: 'Վերջին փոփոխություն', + lastModifiedNever: 'Երբեք', + completionLabel: 'Ավարտվածություն', + recommendedNext: 'Հաջորդ առաջարկվող քայլը', + recommendAddImage: 'Կատեգորիան պատկեր չունի․ ավելացրեք մեկը՝ վստահելիության համար։', + recommendAddSeo: 'Կատեգորիան չունի որոնման վերնագիր կամ նկարագրություն․ ավելացրեք դրանք։', + recommendFillEmpty: 'Կատեգորիան ապրանքներ չունի․ ավելացրեք ապրանքներ կամ թաքցրեք այն։', + recommendNone: 'Հիանալի է․ կատեգորիաների կառուցվածքը լավ վիճակում է։', }, adminProducts: { emptyTitle: 'Ապրանքներ չեն գտնվել', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 8c1004a..20c150d 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -1216,6 +1216,84 @@ export const ru: Translations = { adminCategories: { chooseImage: 'Выбрать изображение', replaceImage: 'Заменить изображение', + search: 'Поиск категорий…', + showDeleted: 'Показать удалённые', + create: 'Создать категорию', + edit: 'Редактировать категорию', + title: 'Название', + slug: 'URL-адрес', + slugTaken: 'Этот адрес уже используется другой категорией.', + parent: 'Родительская категория', + noParent: 'Без родителя (верхний уровень)', + icon: 'Иконка', + image: 'Изображение', + imageHint: 'Показывается в списках категорий и навигации.', + description: 'Описание', + items: 'товаров', + status: { draft: 'Черновик', published: 'Опубликовано' }, + restore: 'Восстановить', + saveDraft: 'Сохранить черновик', + publish: 'Опубликовать', + confirmDelete: 'Удалить эту категорию? Это действие нельзя отменить.', + confirmLeaveUnsaved: 'У вас есть несохранённые изменения. Выйти без сохранения?', + deleteBlocked: 'Эту категорию нельзя удалить, пока у неё есть подкатегории или назначенные товары.', + emptyTitle: 'Категорий пока нет', + emptyDescription: 'Создайте первую категорию, чтобы начать организовывать товары.', + emptyGuide: 'Категории помогают покупателям находить товары. Начните с нескольких верхнеуровневых категорий (например, Одежда, Электроника), затем добавляйте подкатегории по мере роста каталога — избегайте вложенности глубже 2-3 уровней.', + viewTree: 'Дерево', + viewCards: 'Карточки', + expandAll: 'Развернуть всё', + collapseAll: 'Свернуть всё', + expandNode: 'Развернуть', + collapseNode: 'Свернуть', + jumpToParent: 'К родителю', + deletedBadge: 'Удалена', + column_slug: 'Адрес', + column_items: 'Товары', + column_status: 'Статус', + column_visibility: 'Видимость', + column_updated: 'Последнее изменение', + bulkAssignParent: 'Назначить родителя', + bulkAssignImage: 'Назначить изображение', + groupGeneral: 'Основное', + groupMedia: 'Медиа', + groupSeo: 'SEO', + groupVisibility: 'Видимость', + groupNavigation: 'Навигация', + groupAttributes: 'Атрибуты', + groupAdvanced: 'Дополнительно', + visibilityExplain: 'Скрытая категория и её товары не отображаются в навигации и поиске магазина, пока вы снова не сделаете её видимой.', + navBreadcrumb: 'Хлебные крошки', + navRoot: 'Это категория верхнего уровня.', + navChildren: 'Подкатегории', + navNoChildren: 'Подкатегорий пока нет.', + navAppearsIn: 'Где это отображается', + navAppearsVisible: 'Видна в навигации каталога и поиске.', + navAppearsHidden: 'Скрыта — не отображается в навигации каталога, пока скрыта.', + itemsCountLabel: 'Назначенные товары', + createdAtLabel: 'Создана', + healthImage: 'Изображение', + healthSeo: 'SEO', + healthDescription: 'Описание', + healthParent: 'Родитель', + healthVisibility: 'Видимость', + healthProducts: 'Назначены товары', + totalCategories: 'Всего категорий', + visibleCount: 'Видимые', + hiddenCount: 'Скрытые', + emptyCount: 'Пустые', + rootCount: 'Корневые категории', + subCount: 'Подкатегории', + missingImagesCount: 'Без изображений', + missingSeoCount: 'Без SEO', + lastModified: 'Последнее изменение', + lastModifiedNever: 'Никогда', + completionLabel: 'Готовность', + recommendedNext: 'Рекомендуемый следующий шаг', + recommendAddImage: 'У категории нет изображения — добавьте его для доверия в листингах.', + recommendAddSeo: 'У категории нет заголовка или описания для поиска — добавьте их.', + recommendFillEmpty: 'В категории нет товаров — добавьте товары или скройте категорию.', + recommendNone: 'Отлично — структура категорий в хорошем состоянии.', }, adminProducts: { emptyTitle: 'Товары не найдены', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 554d43b..aeeb62e 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -1228,6 +1228,84 @@ export interface Translations { adminCategories: { chooseImage: string; replaceImage: string; + search: string; + showDeleted: string; + create: string; + edit: string; + title: string; + slug: string; + slugTaken: string; + parent: string; + noParent: string; + icon: string; + image: string; + imageHint: string; + description: string; + items: string; + status: { draft: string; published: string }; + restore: string; + saveDraft: string; + publish: string; + confirmDelete: string; + confirmLeaveUnsaved: string; + deleteBlocked: string; + emptyTitle: string; + emptyDescription: string; + emptyGuide: string; + viewTree: string; + viewCards: string; + expandAll: string; + collapseAll: string; + expandNode: string; + collapseNode: string; + jumpToParent: string; + deletedBadge: string; + column_slug: string; + column_items: string; + column_status: string; + column_visibility: string; + column_updated: string; + bulkAssignParent: string; + bulkAssignImage: string; + groupGeneral: string; + groupMedia: string; + groupSeo: string; + groupVisibility: string; + groupNavigation: string; + groupAttributes: string; + groupAdvanced: string; + visibilityExplain: string; + navBreadcrumb: string; + navRoot: string; + navChildren: string; + navNoChildren: string; + navAppearsIn: string; + navAppearsVisible: string; + navAppearsHidden: string; + itemsCountLabel: string; + createdAtLabel: string; + healthImage: string; + healthSeo: string; + healthDescription: string; + healthParent: string; + healthVisibility: string; + healthProducts: string; + totalCategories: string; + visibleCount: string; + hiddenCount: string; + emptyCount: string; + rootCount: string; + subCount: string; + missingImagesCount: string; + missingSeoCount: string; + lastModified: string; + lastModifiedNever: string; + completionLabel: string; + recommendedNext: string; + recommendAddImage: string; + recommendAddSeo: string; + recommendFillEmpty: string; + recommendNone: string; }; adminProducts: { emptyTitle: string;