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.
This commit is contained in:
sdarbinyan
2026-07-18 17:04:11 +04:00
parent aeb48504c5
commit ac615837ab
22 changed files with 1433 additions and 117 deletions

View File

@@ -1,4 +1,8 @@
<section class="admin-categories-card"> <section class="admin-categories-card">
@if (dashboardStats) {
<app-categories-dashboard [stats]="dashboardStats" (openCategory)="edit.emit($event)" />
}
<div class="toolbar"> <div class="toolbar">
<div class="filters"> <div class="filters">
<app-input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminCategories.search' | translate" /> <app-input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminCategories.search' | translate" />
@@ -9,52 +13,196 @@
</select> </select>
<label class="check"><input type="checkbox" [checked]="filters.includeDeleted" (change)="filtersChange.emit({ includeDeleted: $any($event.target).checked })" /><span>{{ 'adminCategories.showDeleted' | translate }}</span></label> <label class="check"><input type="checkbox" [checked]="filters.includeDeleted" (change)="filtersChange.emit({ includeDeleted: $any($event.target).checked })" /><span>{{ 'adminCategories.showDeleted' | translate }}</span></label>
</div> </div>
<app-button variant="primary" (click)="create.emit()">{{ 'adminCategories.create' | translate }}</app-button> <div class="toolbar__view-controls">
<div class="view-toggle" role="group" [attr.aria-label]="'adminProducts.viewMode' | translate">
<app-button variant="ghost" size="sm" [attr.aria-pressed]="viewMode === 'tree'" (click)="viewModeChange.emit('tree')">{{ 'adminCategories.viewTree' | translate }}</app-button>
<app-button variant="ghost" size="sm" [attr.aria-pressed]="viewMode === 'table'" (click)="viewModeChange.emit('table')">{{ 'adminProducts.viewTable' | translate }}</app-button>
<app-button variant="ghost" size="sm" [attr.aria-pressed]="viewMode === 'cards'" (click)="viewModeChange.emit('cards')">{{ 'adminCategories.viewCards' | translate }}</app-button>
</div>
@if (viewMode === 'tree') {
<app-button variant="ghost" size="sm" (click)="expandAll.emit()">{{ 'adminCategories.expandAll' | translate }}</app-button>
<app-button variant="ghost" size="sm" (click)="collapseAll.emit()">{{ 'adminCategories.collapseAll' | translate }}</app-button>
} @else {
<div class="view-toggle" role="group" [attr.aria-label]="'adminProducts.density' | translate">
<app-button variant="ghost" size="sm" [attr.aria-pressed]="density === 'comfortable'" (click)="densityChange.emit('comfortable')">{{ 'adminProducts.densityComfortable' | translate }}</app-button>
<app-button variant="ghost" size="sm" [attr.aria-pressed]="density === 'compact'" (click)="densityChange.emit('compact')">{{ 'adminProducts.densityCompact' | translate }}</app-button>
</div>
@if (viewMode === 'table') {
<app-button variant="ghost" size="sm" (click)="columnsPanelOpen.set(!columnsPanelOpen())">{{ 'adminProducts.columns' | translate }}</app-button>
}
}
<app-button variant="primary" (click)="create.emit()">{{ 'adminCategories.create' | translate }}</app-button>
</div>
</div> </div>
@if (columnsPanelOpen() && viewMode === 'table') {
<app-card padding="sm" class="columns-panel">
@for (column of allColumns; track column) {
<label class="check">
<input type="checkbox" [checked]="visibleColumns.includes(column)" (change)="columnToggle.emit({ column, visible: $any($event.target).checked })" />
<span>{{ ('adminCategories.column_' + column) | translate }}</span>
</label>
}
</app-card>
}
@if (selectedIds.length > 0) {
<div class="bulk-actions">
<span>{{ selectedIds.length }} {{ 'adminProducts.selectedCount' | translate }}</span>
<app-button variant="secondary" size="sm" (click)="bulkVisibility.emit(true)">{{ 'adminProducts.bulkShow' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="bulkVisibility.emit(false)">{{ 'adminProducts.bulkHide' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="openAssignParent()">{{ 'adminCategories.bulkAssignParent' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="openBulkImagePicker()">{{ 'adminCategories.bulkAssignImage' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="bulkDuplicate.emit()">{{ 'adminProducts.bulkDuplicateAction' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="bulkExport.emit()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button>
<app-button variant="danger" size="sm" (click)="bulkDelete.emit()">{{ 'adminProducts.bulkDelete' | translate }}</app-button>
</div>
}
@if (loading) { @if (loading) {
<div class="skeleton-rows"> <div class="skeleton-rows">
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> } @for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
</div> </div>
} @else if (rows.length === 0) { } @else if (treeRows.length === 0) {
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate" /> <app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate">
} @else { <span slot="actions">
<app-table> <app-button variant="primary" (click)="create.emit()">{{ 'adminCategories.create' | translate }}</app-button>
</span>
</app-empty-state>
<p class="admin-categories-card__guide">{{ 'adminCategories.emptyGuide' | translate }}</p>
} @else if (viewMode === 'tree') {
<ul class="category-tree" role="tree" [attr.aria-label]="'adminCategories.title' | translate">
@for (row of treeRows; track row.category.id; let i = $index) {
<li
role="treeitem"
[id]="'category-tree-row-' + row.category.id"
[attr.aria-level]="row.depth + 1"
[attr.aria-expanded]="row.hasChildren ? isExpanded(row.category.id) : null"
[attr.aria-selected]="isSelected(row.category.id)"
tabindex="0"
class="category-tree__row"
[class.category-tree__row--deleted]="row.category.deletedAt"
[style.paddingLeft.px]="row.depth * 20"
draggable="true"
(dragstart)="onDragStart(row.category.id)"
(dragover)="$event.preventDefault()"
(drop)="onDrop(row)"
(keydown)="onTreeKeydown($event, row, i)"
>
<div class="category-tree__row-main">
@if (row.hasChildren) {
<button type="button" class="category-tree__toggle" (click)="toggleExpand.emit(row.category.id)" [attr.aria-label]="(isExpanded(row.category.id) ? 'adminCategories.collapseNode' : 'adminCategories.expandNode') | translate">
{{ isExpanded(row.category.id) ? '▾' : '▸' }}
</button>
} @else {
<span class="category-tree__toggle-spacer"></span>
}
<input type="checkbox" [checked]="isSelected(row.category.id)" (change)="selectionChange.emit({ id: row.category.id, checked: $any($event.target).checked })" [attr.aria-label]="row.category.title" />
<span class="category-tree__icon">{{ row.category.icon || '📁' }}</span>
<span class="category-tree__title">{{ row.category.title }}</span>
@if (row.category.deletedAt) {
<app-badge variant="neutral">{{ 'adminCategories.deletedBadge' | translate }}</app-badge>
} @else {
<app-badge [variant]="row.category.status === 'published' ? 'success' : 'neutral'">{{ ('adminCategories.status.' + row.category.status) | translate }}</app-badge>
@if (!row.category.visible) {
<app-badge variant="neutral">{{ 'adminProducts.hidden' | translate }}</app-badge>
}
}
<span class="category-tree__items">{{ row.category.itemsCount }} {{ 'adminCategories.items' | translate }}</span>
<app-category-health-widget [items]="healthItems(row.category)" [completionPercent]="health(row.category).completionPercent" [compact]="true" />
</div>
<div class="category-tree__actions">
@if (row.category.parentId) {
<app-button variant="ghost" size="sm" (click)="jumpToParent.emit(row.category.id)">{{ 'adminCategories.jumpToParent' | translate }}</app-button>
}
@if (row.category.deletedAt) {
<app-button variant="secondary" size="sm" (click)="restore.emit(row.category.id)">{{ 'adminCategories.restore' | translate }}</app-button>
} @else {
<app-button variant="secondary" size="sm" (click)="edit.emit(row.category.id)">{{ 'adminProducts.edit' | translate }}</app-button>
<app-button variant="danger" size="sm" (click)="delete.emit(row.category.id)">{{ 'adminProducts.delete' | translate }}</app-button>
}
</div>
</li>
}
</ul>
} @else if (viewMode === 'table') {
<app-table [class.density-compact]="density === 'compact'">
<thead> <thead>
<tr> <tr>
<th><input type="checkbox" (change)="selectAll.emit($any($event.target).checked)" /></th>
<th>{{ 'adminCategories.title' | translate }}</th> <th>{{ 'adminCategories.title' | translate }}</th>
<th>{{ 'adminCategories.slug' | translate }}</th> @if (visibleColumns.includes('slug')) { <th>{{ 'adminCategories.slug' | translate }}</th> }
<th>{{ 'adminCategories.items' | translate }}</th> @if (visibleColumns.includes('items')) { <th>{{ 'adminCategories.items' | translate }}</th> }
<th>{{ 'backoffice.status' | translate }}</th> @if (visibleColumns.includes('status')) { <th>{{ 'backoffice.status' | translate }}</th> }
<th>{{ 'adminProducts.visibility' | translate }}</th> @if (visibleColumns.includes('visibility')) { <th>{{ 'adminProducts.visibility' | translate }}</th> }
<th>{{ 'adminProducts.healthColumn' | translate }}</th>
<th>{{ 'adminProducts.actions' | translate }}</th> <th>{{ 'adminProducts.actions' | translate }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (row of rows; track row.category.id) { @for (category of flatCategories; track category.id) {
<tr [class.deleted]="row.category.deletedAt" draggable="true" (dragstart)="onDragStart(row.category.id)" (dragover)="$event.preventDefault()" (drop)="onDrop(row)"> <tr [class.deleted]="category.deletedAt">
<td>{{ indent(row) }} {{ row.category.icon }} {{ row.category.title }}</td> <td><input type="checkbox" [checked]="isSelected(category.id)" (change)="selectionChange.emit({ id: category.id, checked: $any($event.target).checked })" /></td>
<td>{{ row.category.slug }}</td> <td>{{ category.icon }} {{ category.title }}</td>
<td>{{ row.category.itemsCount }}</td> @if (visibleColumns.includes('slug')) { <td>{{ category.slug }}</td> }
<td> @if (visibleColumns.includes('items')) { <td>{{ category.itemsCount }}</td> }
<app-badge [variant]="row.category.status === 'published' ? 'success' : 'neutral'"> @if (visibleColumns.includes('status')) {
{{ ('adminCategories.status.' + row.category.status) | translate }} <td><app-badge [variant]="category.status === 'published' ? 'success' : 'neutral'">{{ ('adminCategories.status.' + category.status) | translate }}</app-badge></td>
</app-badge> }
</td> @if (visibleColumns.includes('visibility')) {
<td> <td><label class="check"><input type="checkbox" [checked]="category.visible" (change)="toggleVisible.emit({ id: category.id, visible: $any($event.target).checked })" /></label></td>
<label class="check"><input type="checkbox" [checked]="row.category.visible" (change)="toggleVisible.emit({ id: row.category.id, visible: $any($event.target).checked })" /></label> }
</td> <td class="health-cell"><app-category-health-widget [items]="healthItems(category)" [completionPercent]="health(category).completionPercent" [compact]="true" /></td>
<td class="actions"> <td class="actions">
@if (row.category.deletedAt) { @if (category.deletedAt) {
<app-button variant="secondary" size="sm" (click)="restore.emit(row.category.id)">{{ 'adminCategories.restore' | translate }}</app-button> <app-button variant="secondary" size="sm" (click)="restore.emit(category.id)">{{ 'adminCategories.restore' | translate }}</app-button>
} @else { } @else {
<app-button variant="secondary" size="sm" (click)="edit.emit(row.category.id)">{{ 'adminProducts.edit' | translate }}</app-button> <app-button variant="secondary" size="sm" (click)="edit.emit(category.id)">{{ 'adminProducts.edit' | translate }}</app-button>
<app-button variant="danger" size="sm" (click)="delete.emit(row.category.id)">{{ 'adminProducts.delete' | translate }}</app-button> <app-button variant="danger" size="sm" (click)="delete.emit(category.id)">{{ 'adminProducts.delete' | translate }}</app-button>
} }
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
</app-table> </app-table>
} @else {
<div class="category-grid" [class.category-grid--compact]="density === 'compact'">
@for (category of flatCategories; track category.id) {
<app-card padding="sm" class="category-grid__item">
<label class="category-grid__select">
<input type="checkbox" [checked]="isSelected(category.id)" (change)="selectionChange.emit({ id: category.id, checked: $any($event.target).checked })" [attr.aria-label]="category.title" />
</label>
@if (category.imageUrl) {
<img class="category-grid__image" [src]="category.imageUrl" [alt]="category.imageAlt || category.title" loading="lazy" />
} @else {
<div class="category-grid__image category-grid__image--placeholder">{{ category.icon || '📁' }}</div>
}
<h4 class="category-grid__name">{{ category.title }}</h4>
<p class="category-grid__items">{{ category.itemsCount }} {{ 'adminCategories.items' | translate }}</p>
<app-category-health-widget [items]="healthItems(category)" [completionPercent]="health(category).completionPercent" [compact]="true" />
<div class="category-grid__actions">
<app-button variant="secondary" size="sm" (click)="edit.emit(category.id)">{{ 'adminProducts.edit' | translate }}</app-button>
<app-button variant="ghost" size="sm" (click)="delete.emit(category.id)">{{ 'adminProducts.delete' | translate }}</app-button>
</div>
</app-card>
}
</div>
} }
<app-dialog [open]="assignParentOpen()" [titleText]="'adminCategories.bulkAssignParent' | translate" size="sm" (closed)="assignParentOpen.set(false)">
<div class="dialog-body">
<select #parentSelect>
<option value="">{{ 'adminCategories.noParent' | translate }}</option>
@for (category of allCategories; track category.id) {
<option [value]="category.id">{{ category.title }}</option>
}
</select>
<div class="dialog-actions">
<app-button variant="secondary" (click)="assignParentOpen.set(false)">{{ 'mediaLibrary.cancel' | translate }}</app-button>
<app-button variant="primary" (click)="confirmAssignParent(parentSelect.value)">{{ 'mediaLibrary.confirm' | translate }}</app-button>
</div>
</div>
</app-dialog>
<app-media-picker [open]="imagePickerOpen()" (selected)="onBulkImagePicked($event)" (closed)="imagePickerOpen.set(false)" />
</section> </section>

View File

@@ -7,3 +7,46 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
tr.deleted { opacity: 0.55; } tr.deleted { opacity: 0.55; }
.skeleton-rows { display: grid; gap: 8px; } .skeleton-rows { display: grid; gap: 8px; }
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } .actions { flex-direction: column; align-items: stretch; } } @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; }

View File

@@ -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 { 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 { TranslatePipe } from '../../../../i18n/translate.pipe';
import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { InputComponent } from '../../../../shared/ui/input/input.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 { TableComponent } from '../../../../shared/ui/table/table.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component'; import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.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; category: AdminCategory;
depth: number; depth: number;
hasChildren: boolean;
} }
@Component({ @Component({
selector: 'app-admin-categories-list', selector: 'app-admin-categories-list',
standalone: true, 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', templateUrl: './admin-categories-list.component.html',
styleUrls: ['./admin-categories-list.component.scss'], styleUrls: ['./admin-categories-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class AdminCategoriesListComponent { export class AdminCategoriesListComponent {
@Input() rows: AdminCategoryRow[] = []; @Input() treeRows: AdminCategoryTreeRow[] = [];
@Input() filters!: AdminCategoryListFilters; @Input() flatCategories: AdminCategory[] = [];
@Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean };
@Input() loading = false; @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<Partial<AdminCategoryListFilters>>(); @Output() filtersChange = new EventEmitter<Partial<{ search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean }>>();
@Output() create = new EventEmitter<void>(); @Output() create = new EventEmitter<void>();
@Output() edit = new EventEmitter<string>(); @Output() edit = new EventEmitter<string>();
@Output() delete = new EventEmitter<string>(); @Output() delete = new EventEmitter<string>();
@Output() restore = new EventEmitter<string>(); @Output() restore = new EventEmitter<string>();
@Output() toggleVisible = new EventEmitter<{ id: string; visible: boolean }>(); @Output() toggleVisible = new EventEmitter<{ id: string; visible: boolean }>();
@Output() reorder = new EventEmitter<{ id: string; targetId: string }>(); @Output() reorder = new EventEmitter<{ id: string; targetId: string }>();
@Output() toggleExpand = new EventEmitter<string>();
@Output() jumpToParent = new EventEmitter<string>();
@Output() expandAll = new EventEmitter<void>();
@Output() collapseAll = new EventEmitter<void>();
@Output() selectionChange = new EventEmitter<{ id: string; checked: boolean }>();
@Output() selectAll = new EventEmitter<boolean>();
@Output() bulkVisibility = new EventEmitter<boolean>();
@Output() bulkDelete = new EventEmitter<void>();
@Output() bulkDuplicate = new EventEmitter<void>();
@Output() bulkAssignParent = new EventEmitter<string | null>();
@Output() bulkAssignImage = new EventEmitter<string>();
@Output() bulkExport = new EventEmitter<void>();
@Output() viewModeChange = new EventEmitter<AdminCategoriesViewMode>();
@Output() densityChange = new EventEmitter<AdminCategoriesDensity>();
@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; private draggedId: string | null = null;
protected focusedRowIndex = signal(0);
indent(row: AdminCategoryRow): string { isSelected(id: string): boolean {
return '—'.repeat(row.depth); 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 { onDragStart(id: string): void {
this.draggedId = id; this.draggedId = id;
} }
onDrop(targetRow: AdminCategoryRow): void { onDrop(targetRow: AdminCategoryTreeRow): void {
if (!this.draggedId || this.draggedId === targetRow.category.id) { if (!this.draggedId || this.draggedId === targetRow.category.id) {
this.draggedId = null; this.draggedId = null;
return; return;
@@ -53,4 +118,57 @@ export class AdminCategoriesListComponent {
this.reorder.emit({ id: this.draggedId, targetId: targetRow.category.id }); this.reorder.emit({ id: this.draggedId, targetId: targetRow.category.id });
this.draggedId = null; 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);
}
} }

View File

@@ -1,63 +1,142 @@
<section class="form-card"> <section class="form-card">
@if (breadcrumb.length > 0) { <div class="form-card__header">
<p class="breadcrumb-preview">{{ breadcrumb.join(' / ') }}</p> <app-category-health-widget [items]="healthItems()" [completionPercent]="health.completionPercent" />
}
<div class="grid two">
<app-form-field [label]="'adminCategories.title' | translate" [required]="true">
<app-input [ngModel]="category.title" (ngModelChange)="updateField('title', $event)" />
</app-form-field>
<app-form-field [label]="'adminCategories.slug' | translate" [required]="true" [error]="slugTaken ? ('adminCategories.slugTaken' | translate) : null">
<app-input [ngModel]="category.slug" (ngModelChange)="updateField('slug', $event)" />
</app-form-field>
<label><span>{{ 'adminCategories.parent' | translate }}</span>
<select [ngModel]="category.parentId" (ngModelChange)="updateField('parentId', $event || null)">
<option [ngValue]="null">{{ 'adminCategories.noParent' | translate }}</option>
@for (option of parentOptions; track option.id) {
<option [ngValue]="option.id">{{ option.title }}</option>
}
</select>
</label>
<app-form-field [label]="'adminCategories.icon' | translate">
<app-input [ngModel]="category.icon" (ngModelChange)="updateField('icon', $event)" />
</app-form-field>
<label class="check"><input type="checkbox" [checked]="category.visible" (change)="updateField('visible', $any($event.target).checked)" /><span>{{ 'adminProducts.visible' | translate }}</span></label>
</div> </div>
<h3>{{ 'adminCategories.image' | translate }}</h3> <div class="form-card__tabs" role="tablist">
<div class="image-field"> @for (group of groups; track group.id) {
@if (category.imageUrl) { <button
<img [src]="category.imageUrl" [alt]="category.title" class="preview" /> type="button"
role="tab"
[attr.aria-selected]="activeGroup() === group.id"
[class.form-card__tab--active]="activeGroup() === group.id"
class="form-card__tab"
(click)="setGroup(group.id)"
>
{{ group.labelKey | translate }}
@if (group.id === 'media' && !health.hasImage) { <app-badge variant="warning">!</app-badge> }
@if (group.id === 'seo' && !health.hasSeo) { <app-badge variant="warning">!</app-badge> }
</button>
} }
<app-button variant="secondary" (click)="openMediaPicker()">{{ 'adminCategories.chooseImage' | translate }}</app-button>
<app-media-picker [open]="mediaPickerOpen" (selected)="onImagePicked($event)" (closed)="mediaPickerOpen = false" />
</div> </div>
<app-form-field [label]="'adminCategories.description' | translate"> <div class="form-card__panel">
<textarea rows="3" [ngModel]="category.description" (ngModelChange)="updateField('description', $event)"></textarea> @if (activeGroup() === 'general') {
</app-form-field> @if (breadcrumb.length > 0) {
<p class="breadcrumb-preview">{{ breadcrumb.join(' / ') }}</p>
}
<div class="grid two">
<app-form-field [label]="'adminCategories.title' | translate" [required]="true">
<app-input [ngModel]="category.title" (ngModelChange)="updateField('title', $event)" />
</app-form-field>
<app-form-field [label]="'adminCategories.slug' | translate" [required]="true" [error]="slugTaken ? ('adminCategories.slugTaken' | translate) : null">
<app-input [ngModel]="category.slug" (ngModelChange)="updateField('slug', $event)" />
</app-form-field>
<label><span>{{ 'adminCategories.parent' | translate }}</span>
<select [ngModel]="category.parentId" (ngModelChange)="updateField('parentId', $event || null)">
<option [ngValue]="null">{{ 'adminCategories.noParent' | translate }}</option>
@for (option of parentOptions; track option.id) {
<option [ngValue]="option.id">{{ option.title }}</option>
}
</select>
</label>
<app-form-field [label]="'adminCategories.icon' | translate">
<app-input [ngModel]="category.icon" (ngModelChange)="updateField('icon', $event)" />
</app-form-field>
</div>
<h3>{{ 'adminProducts.translations' | translate }}</h3> <app-form-field [label]="'adminCategories.description' | translate">
@for (locale of locales; track locale) { <textarea rows="3" [ngModel]="category.description" (ngModelChange)="updateField('description', $event)"></textarea>
<div class="grid two sub-block">
<app-form-field [label]="(('adminCategories.title' | translate) + ' ' + locale)">
<app-input [ngModel]="category.translations[locale]?.title || ''" (ngModelChange)="updateTranslation(locale, 'title', $event)" />
</app-form-field> </app-form-field>
<app-form-field [label]="(('adminCategories.description' | translate) + ' ' + locale)">
<app-input [ngModel]="category.translations[locale]?.description || ''" (ngModelChange)="updateTranslation(locale, 'description', $event)" />
</app-form-field>
</div>
}
<h3>{{ 'adminProducts.seo' | translate }}</h3> <h4 class="form-card__subheading">{{ 'adminProducts.translations' | translate }}</h4>
<div class="grid one"> @for (locale of locales; track locale) {
<app-form-field [label]="'adminProducts.metaTitle' | translate"> <div class="grid two sub-block">
<app-input [ngModel]="category.seo.metaTitle" (ngModelChange)="categoryChange.emit({ seo: { ...category.seo, metaTitle: $event } })" /> <app-form-field [label]="(('adminCategories.title' | translate) + ' ' + locale)">
</app-form-field> <app-input [ngModel]="category.translations[locale]?.title || ''" (ngModelChange)="updateTranslation(locale, 'title', $event)" />
<label><span>{{ 'adminProducts.metaDescription' | translate }}</span><textarea rows="3" [ngModel]="category.seo.metaDescription" (ngModelChange)="categoryChange.emit({ seo: { ...category.seo, metaDescription: $event } })"></textarea></label> </app-form-field>
<app-form-field [label]="'adminProducts.keywords' | translate"> <app-form-field [label]="(('adminCategories.description' | translate) + ' ' + locale)">
<app-input [ngModel]="category.seo.keywords" (ngModelChange)="categoryChange.emit({ seo: { ...category.seo, keywords: $event } })" /> <app-input [ngModel]="category.translations[locale]?.description || ''" (ngModelChange)="updateTranslation(locale, 'description', $event)" />
</app-form-field> </app-form-field>
</div>
}
}
@if (activeGroup() === 'media') {
<app-form-field [label]="'adminCategories.image' | translate" [hint]="'adminCategories.imageHint' | translate">
<app-image-field [value]="category.imageUrl" (valueChange)="setImage($event)" />
</app-form-field>
<app-form-field [label]="'contentManagement.mediaAlt' | translate">
<app-input [ngModel]="category.imageAlt" (ngModelChange)="updateField('imageAlt', $event)" />
</app-form-field>
}
@if (activeGroup() === 'seo') {
<p class="form-card__explain">{{ 'adminProducts.seoExplain' | translate }}</p>
<app-form-field [label]="'adminProducts.searchTitle' | translate" [hint]="'adminProducts.searchTitleHint' | translate" [error]="!category.seo.metaTitle.trim() ? ('adminProducts.seoMissingTitle' | translate) : null">
<app-input [ngModel]="category.seo.metaTitle" (ngModelChange)="categoryChange.emit({ seo: { ...category.seo, metaTitle: $event } })" />
</app-form-field>
<app-form-field [label]="'adminProducts.searchDescription' | translate" [hint]="'adminProducts.searchDescriptionHint' | translate" [error]="!category.seo.metaDescription.trim() ? ('adminProducts.seoMissingDescription' | translate) : null">
<app-input [ngModel]="category.seo.metaDescription" (ngModelChange)="categoryChange.emit({ seo: { ...category.seo, metaDescription: $event } })" />
</app-form-field>
<app-form-field [label]="'adminProducts.keywords' | translate">
<app-input [ngModel]="category.seo.keywords" (ngModelChange)="categoryChange.emit({ seo: { ...category.seo, keywords: $event } })" />
</app-form-field>
<div class="seo-preview">
<p class="seo-preview__title">{{ category.seo.metaTitle || category.title }}</p>
<p class="seo-preview__url">yourstore.com/category/{{ category.slug }}</p>
<p class="seo-preview__description">{{ category.seo.metaDescription || category.description }}</p>
</div>
}
@if (activeGroup() === 'visibility') {
<label class="toggle-row"><app-toggle [ngModel]="category.visible" (ngModelChange)="updateField('visible', $event)" [ariaLabel]="'adminProducts.visible' | translate" /><span>{{ 'adminProducts.visible' | translate }}</span></label>
<p class="form-card__explain">{{ 'adminCategories.visibilityExplain' | translate }}</p>
}
@if (activeGroup() === 'navigation') {
<h4 class="form-card__subheading">{{ 'adminCategories.navBreadcrumb' | translate }}</h4>
<p>{{ breadcrumb.length > 0 ? breadcrumb.join(' / ') : ('adminCategories.navRoot' | translate) }}</p>
<h4 class="form-card__subheading">{{ 'adminCategories.navChildren' | translate }}</h4>
@if (children.length === 0) {
<p class="form-card__explain">{{ 'adminCategories.navNoChildren' | translate }}</p>
} @else {
<ul class="nav-children">
@for (child of children; track child.id) { <li>{{ child.icon }} {{ child.title }}</li> }
</ul>
}
<h4 class="form-card__subheading">{{ 'adminCategories.navAppearsIn' | translate }}</h4>
<p class="form-card__explain">{{ (category.visible ? 'adminCategories.navAppearsVisible' : 'adminCategories.navAppearsHidden') | translate }}</p>
}
@if (activeGroup() === 'attributes') {
<app-key-value-editor
[rows]="category.attributes"
[createRow]="createAttributeRow"
[addLabel]="'adminProducts.addRow' | translate"
[removeLabel]="'adminProducts.removeRow' | translate"
(rowsChange)="updateAttributes($event)"
>
<ng-template let-row let-i="index">
<app-input [ngModel]="row.key" [placeholder]="'adminProducts.rowKey' | translate" (ngModelChange)="updateAttributeField(i, 'key', $event)" />
<app-input [ngModel]="row.value" [placeholder]="'adminProducts.rowValue' | translate" (ngModelChange)="updateAttributeField(i, 'value', $event)" />
</ng-template>
</app-key-value-editor>
}
@if (activeGroup() === 'advanced') {
<div class="grid two">
<app-form-field [label]="'adminCategories.itemsCountLabel' | translate">
<app-input [ngModel]="category.itemsCount" [disabled]="true" />
</app-form-field>
<app-form-field [label]="'adminCategories.createdAtLabel' | translate">
<app-input [ngModel]="category.createdAt | date" [disabled]="true" />
</app-form-field>
</div>
}
</div> </div>
<div class="actions"> <div class="actions">

View File

@@ -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); } .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; } .actions { display: flex; justify-content: flex-end; gap: 10px; }
@media (max-width: 900px) { .grid.two { grid-template-columns: 1fr; } } @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; }

View File

@@ -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 { 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 { TranslatePipe } from '../../../../i18n/translate.pipe';
import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { InputComponent } from '../../../../shared/ui/input/input.component'; import { InputComponent } from '../../../../shared/ui/input/input.component';
import { FormFieldComponent } from '../../../../shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '../../../../shared/ui/form-field/form-field.component';
import { MediaPickerComponent } from '../../../../shared/media/media-picker/media-picker.component'; import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component';
import { MediaAsset } from '../../../../core/media/models/media-asset.model'; 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({ @Component({
selector: 'app-admin-category-form', selector: 'app-admin-category-form',
standalone: true, 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', templateUrl: './admin-category-form.component.html',
styleUrls: ['./admin-category-form.component.scss'], styleUrls: ['./admin-category-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -20,15 +39,40 @@ export class AdminCategoryFormComponent {
@Input({ required: true }) category!: AdminCategory; @Input({ required: true }) category!: AdminCategory;
@Input() parentOptions: AdminCategory[] = []; @Input() parentOptions: AdminCategory[] = [];
@Input() breadcrumb: string[] = []; @Input() breadcrumb: string[] = [];
@Input() children: AdminCategory[] = [];
@Input() slugTaken = false; @Input() slugTaken = false;
@Input() locales: string[] = ['en']; @Input() locales: string[] = ['en'];
@Input() mode: 'create' | 'edit' = 'create'; @Input() mode: 'create' | 'edit' = 'create';
@Input() health!: AdminCategoryHealth;
@Output() categoryChange = new EventEmitter<Partial<AdminCategory>>(); @Output() categoryChange = new EventEmitter<Partial<AdminCategory>>();
@Output() saveDraft = new EventEmitter<void>(); @Output() saveDraft = new EventEmitter<void>();
@Output() publish = new EventEmitter<void>(); @Output() publish = new EventEmitter<void>();
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<AdminCategoryEditorGroup>('general');
setGroup(group: AdminCategoryEditorGroup): void {
this.activeGroup.set(group);
}
readonly healthItems = computed<CategoryHealthItem[]>(() => [
{ 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<K extends keyof AdminCategory>(key: K, value: AdminCategory[K]): void { updateField<K extends keyof AdminCategory>(key: K, value: AdminCategory[K]): void {
this.categoryChange.emit({ [key]: value } as Partial<AdminCategory>); this.categoryChange.emit({ [key]: value } as Partial<AdminCategory>);
@@ -43,12 +87,17 @@ export class AdminCategoryFormComponent {
}); });
} }
openMediaPicker(): void { setImage(url: string): void {
this.mediaPickerOpen = true; this.updateField('imageUrl', url);
} }
onImagePicked(asset: MediaAsset): void { readonly createAttributeRow = (): AdminCategoryAttribute => ({ key: '', value: '' });
this.updateField('imageUrl', asset.url);
this.mediaPickerOpen = false; 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));
} }
} }

View File

@@ -0,0 +1,22 @@
<section class="categories-dashboard">
<div class="categories-dashboard__metrics">
<app-dashboard-metric labelKey="adminCategories.totalCategories" [value]="stats().total.toString()" />
<app-dashboard-metric labelKey="adminCategories.visibleCount" [value]="stats().visible.toString()" />
<app-dashboard-metric labelKey="adminCategories.hiddenCount" [value]="stats().hidden.toString()" />
<app-dashboard-metric labelKey="adminCategories.emptyCount" [value]="stats().empty.toString()" />
<app-dashboard-metric labelKey="adminCategories.rootCount" [value]="stats().rootCount.toString()" />
<app-dashboard-metric labelKey="adminCategories.subCount" [value]="stats().subCount.toString()" />
<app-dashboard-metric labelKey="adminCategories.missingImagesCount" [value]="stats().missingImages.toString()" />
<app-dashboard-metric labelKey="adminCategories.missingSeoCount" [value]="stats().missingSeo.toString()" />
<app-dashboard-metric labelKey="adminCategories.lastModified" [value]="lastModifiedValue()" />
<app-dashboard-metric labelKey="adminCategories.completionLabel" [value]="stats().completionPercent + '%'" />
</div>
<app-card padding="md" class="categories-dashboard__recommend">
<h4>{{ 'adminCategories.recommendedNext' | translate }}</h4>
<p>{{ stats().recommendation.labelKey | translate }}</p>
@if (stats().recommendation.categoryId) {
<app-button variant="primary" size="sm" (click)="goRecommended()">{{ 'adminProducts.openAction' | translate }}</app-button>
}
</app-card>
</section>

View File

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

View File

@@ -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<AdminCategoriesDashboardStats>();
@Output() openCategory = new EventEmitter<string>();
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);
}
}
}

View File

@@ -0,0 +1,16 @@
<div class="category-health" [class.category-health--compact]="compact()">
<div class="category-health__bar" role="progressbar" [attr.aria-valuenow]="completionPercent()" aria-valuemin="0" aria-valuemax="100">
<div class="category-health__bar-fill" [style.width.%]="completionPercent()"></div>
</div>
<span class="category-health__percent">{{ completionPercent() }}%</span>
@if (!compact()) {
<ul class="category-health__list">
@for (item of items(); track item.labelKey) {
<li [class.category-health__list-item--done]="item.done">
<span aria-hidden="true">{{ item.done ? '✓' : '○' }}</span>
<span>{{ item.labelKey | translate }}</span>
</li>
}
</ul>
}
</div>

View File

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

View File

@@ -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<CategoryHealthItem[]>();
readonly completionPercent = input.required<number>();
readonly compact = input(false);
}

View File

@@ -8,6 +8,40 @@ import { ProjectEditorFacade } from '../../../project-editor/facade/project-edit
const DRAFT_KEY_PREFIX = 'admin-category-draft:'; const DRAFT_KEY_PREFIX = 'admin-category-draft:';
const NEW_CATEGORY_DRAFT_KEY = `${DRAFT_KEY_PREFIX}new`; 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' }) @Injectable({ providedIn: 'root' })
export class AdminCategoriesFacade { export class AdminCategoriesFacade {
@@ -18,6 +52,76 @@ export class AdminCategoriesFacade {
readonly supportedLocales = computed(() => this.projectEditor.bootstrap()?.localization.supportedLocales ?? ['en']); readonly supportedLocales = computed(() => this.projectEditor.bootstrap()?.localization.supportedLocales ?? ['en']);
readonly viewMode = signal<AdminCategoriesViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminCategoriesViewMode) || 'tree');
readonly density = signal<AdminCategoriesDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminCategoriesDensity) || 'comfortable');
readonly visibleColumns = signal<AdminCategoryColumn[]>(this.localStorage.getJSON<AdminCategoryColumn[]>(COLUMNS_KEY) ?? [...ALL_CATEGORY_COLUMNS]);
readonly expandedIds = signal<Set<string>>(new Set(this.localStorage.getJSON<string[]>(EXPANDED_KEY) ?? []));
readonly selectedIds = signal<string[]>([]);
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 { ensureLocalesLoaded(): void {
if (!this.projectEditor.bootstrap()) { if (!this.projectEditor.bootstrap()) {
this.projectEditor.loadBootstrap(); this.projectEditor.loadBootstrap();
@@ -60,9 +164,16 @@ export class AdminCategoriesFacade {
return trail; 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 { loadList(): void {
this.loading.set(true); 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 => { next: categories => {
this.categories.set(categories); this.categories.set(categories);
this.loading.set(false); this.loading.set(false);
@@ -76,7 +187,77 @@ export class AdminCategoriesFacade {
updateFilters(patch: Partial<AdminCategoryListFilters>): void { updateFilters(patch: Partial<AdminCategoryListFilters>): void {
this.filters.update(current => ({ ...current, ...patch })); 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<string> {
const all = this.categories().filter(visible);
const matches = all.filter(category => this.matchesSearch(category, query));
const keep = new Set<string>();
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 { startCreate(): void {
@@ -161,18 +342,18 @@ export class AdminCategoriesFacade {
} }
deleteOne(id: string): void { 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 { 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 { setVisible(id: string, visible: boolean): void {
const category = this.categories().find(item => item.id === id); const category = this.categories().find(item => item.id === id);
if (!category) return; if (!category) return;
this.gateway.updateCategory({ ...category, visible, updatedAt: new Date().toISOString() }).pipe(take(1)) 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 { reorder(id: string, targetId: string): void {
@@ -197,4 +378,137 @@ export class AdminCategoriesFacade {
updates.forEach(category => this.gateway.updateCategory(category).pipe(take(1)).subscribe()); updates.forEach(category => this.gateway.updateCategory(category).pipe(take(1)).subscribe());
this.loadList(); 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<AdminCategoriesDashboardStats | null>(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<string | null>((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,
};
}
} }

View File

@@ -13,6 +13,11 @@ export interface AdminCategorySeo {
keywords: string; keywords: string;
} }
export interface AdminCategoryAttribute {
key: string;
value: string;
}
export interface AdminCategory { export interface AdminCategory {
id: string; id: string;
parentId: string | null; parentId: string | null;
@@ -21,12 +26,14 @@ export interface AdminCategory {
description: string; description: string;
icon: string; icon: string;
imageUrl: string; imageUrl: string;
imageAlt: string;
order: number; order: number;
visible: boolean; visible: boolean;
status: AdminCategoryStatus; status: AdminCategoryStatus;
itemsCount: number; itemsCount: number;
translations: Record<string, AdminCategoryTranslation>; translations: Record<string, AdminCategoryTranslation>;
seo: AdminCategorySeo; seo: AdminCategorySeo;
attributes: AdminCategoryAttribute[];
deletedAt: string | null; deletedAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@@ -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 { Router } from '@angular/router';
import { AdminCategoriesFacade } from '../facade/admin-categories.facade'; import { AdminCategoriesFacade } from '../facade/admin-categories.facade';
import { AdminCategoriesListComponent, AdminCategoryRow } from '../components/admin-categories-list.component'; import { AdminCategoriesListComponent } from '../components/admin-categories-list.component';
import { AdminCategory } from '../models/admin-category.model';
import { LanguageService } from '../../../../services/language.service'; import { LanguageService } from '../../../../services/language.service';
import { TranslateService } from '../../../../i18n/translate.service'; import { TranslateService } from '../../../../i18n/translate.service';
@@ -11,16 +10,41 @@ import { TranslateService } from '../../../../i18n/translate.service';
standalone: true, standalone: true,
imports: [AdminCategoriesListComponent], imports: [AdminCategoriesListComponent],
template: `<app-admin-categories-list template: `<app-admin-categories-list
[rows]="rows()" [treeRows]="facade.visibleTreeRows()"
[flatCategories]="facade.filteredCategories()"
[allCategories]="facade.categories()"
[filters]="facade.filters()" [filters]="facade.filters()"
[loading]="facade.loading()" [loading]="facade.loading()"
[viewMode]="facade.viewMode()"
[density]="facade.density()"
[visibleColumns]="facade.visibleColumns()"
[selectedIds]="facade.selectedIds()"
[dashboardStats]="facade.dashboardStats()"
[health]="healthFn"
[isExpanded]="isExpandedFn"
[canDelete]="canDeleteFn"
(filtersChange)="facade.updateFilters($event)" (filtersChange)="facade.updateFilters($event)"
(create)="create()" (create)="create()"
(edit)="edit($event)" (edit)="edit($event)"
(delete)="deleteOne($event)" (delete)="deleteOne($event)"
(restore)="facade.restoreOne($event)" (restore)="facade.restoreOne($event)"
(toggleVisible)="facade.setVisible($event.id, $event.visible)" (toggleVisible)="facade.setVisible($event.id, $event.visible)"
(reorder)="facade.reorder($event.id, $event.targetId)" />`, (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 changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class AdminCategoriesListPageComponent { export class AdminCategoriesListPageComponent {
@@ -29,15 +53,26 @@ export class AdminCategoriesListPageComponent {
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
readonly rows = computed<AdminCategoryRow[]>(() => this.buildRows(this.facade.rootCategories(), 0)); readonly healthFn = (category: Parameters<AdminCategoriesFacade['health']>[0]) => this.facade.health(category);
readonly isExpandedFn = (id: string) => this.facade.isExpanded(id);
readonly canDeleteFn = (id: string) => this.facade.canDelete(id);
constructor() { constructor() {
this.facade.loadList(); this.facade.loadList();
this.facade.loadDashboardStats();
} }
create(): void { this.facade.startCreate(); void this.router.navigate([this.lang(), 'backoffice', 'categories', 'create']); } 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']); } 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 { deleteOne(id: string): void {
if (!this.facade.canDelete(id)) { if (!this.facade.canDelete(id)) {
window.alert(this.translate.t('adminCategories.deleteBlocked')); 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 { private lang(): string {
return this.languageService.currentLanguage(); return this.languageService.currentLanguage();
} }

View File

@@ -9,7 +9,7 @@ import { LanguageService } from '../../../../services/language.service';
selector: 'app-admin-category-editor-page', selector: 'app-admin-category-editor-page',
standalone: true, standalone: true,
imports: [AdminCategoryFormComponent, TranslatePipe], imports: [AdminCategoryFormComponent, TranslatePipe],
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [slugTaken]="facade.slugTaken()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`, template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [children]="facade.childrenOf(draft.id)" [slugTaken]="facade.slugTaken()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`,
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`], 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 changeDetection: ChangeDetectionStrategy.OnPush
}) })

View File

@@ -12,12 +12,14 @@ export class AdminCategoriesFormFactory {
description: '', description: '',
icon: '', icon: '',
imageUrl: '', imageUrl: '',
imageAlt: '',
order: 0, order: 0,
visible: true, visible: true,
status: 'draft', status: 'draft',
itemsCount: 0, itemsCount: 0,
translations: { en: {}, ru: {}, hy: {} }, translations: { en: {}, ru: {}, hy: {} },
seo: { metaTitle: '', metaDescription: '', keywords: '' }, seo: { metaTitle: '', metaDescription: '', keywords: '' },
attributes: [],
deletedAt: null, deletedAt: null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),

View File

@@ -93,6 +93,7 @@ export class AdminCategoriesLocalGateway implements AdminCategoriesGateway {
description: category.description ?? '', description: category.description ?? '',
icon: category.icon ?? '', icon: category.icon ?? '',
imageUrl: category.imageUrl ?? '', imageUrl: category.imageUrl ?? '',
imageAlt: '',
order: 0, order: 0,
visible: true, visible: true,
status: 'published', status: 'published',
@@ -107,6 +108,7 @@ export class AdminCategoriesLocalGateway implements AdminCategoriesGateway {
metaDescription: category.description ?? '', metaDescription: category.description ?? '',
keywords: '', keywords: '',
}, },
attributes: [],
deletedAt: null, deletedAt: null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),

View File

@@ -1221,6 +1221,84 @@ export const en: Translations = {
adminCategories: { adminCategories: {
chooseImage: 'Choose image', chooseImage: 'Choose image',
replaceImage: 'Replace 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: { adminProducts: {
emptyTitle: 'No products found', emptyTitle: 'No products found',

View File

@@ -1216,6 +1216,84 @@ export const hy: Translations = {
adminCategories: { adminCategories: {
chooseImage: 'Ընտրել պատկեր', chooseImage: 'Ընտրել պատկեր',
replaceImage: 'Փոխարինել պատկերը', 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: { adminProducts: {
emptyTitle: 'Ապրանքներ չեն գտնվել', emptyTitle: 'Ապրանքներ չեն գտնվել',

View File

@@ -1216,6 +1216,84 @@ export const ru: Translations = {
adminCategories: { adminCategories: {
chooseImage: 'Выбрать изображение', chooseImage: 'Выбрать изображение',
replaceImage: 'Заменить изображение', 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: { adminProducts: {
emptyTitle: 'Товары не найдены', emptyTitle: 'Товары не найдены',

View File

@@ -1228,6 +1228,84 @@ export interface Translations {
adminCategories: { adminCategories: {
chooseImage: string; chooseImage: string;
replaceImage: 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: { adminProducts: {
emptyTitle: string; emptyTitle: string;