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:
@@ -1,4 +1,8 @@
|
||||
<section class="admin-categories-card">
|
||||
@if (dashboardStats) {
|
||||
<app-categories-dashboard [stats]="dashboardStats" (openCategory)="edit.emit($event)" />
|
||||
}
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<app-input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminCategories.search' | translate" />
|
||||
@@ -9,52 +13,196 @@
|
||||
</select>
|
||||
<label class="check"><input type="checkbox" [checked]="filters.includeDeleted" (change)="filtersChange.emit({ includeDeleted: $any($event.target).checked })" /><span>{{ 'adminCategories.showDeleted' | translate }}</span></label>
|
||||
</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>
|
||||
|
||||
@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) {
|
||||
<div class="skeleton-rows">
|
||||
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
||||
</div>
|
||||
} @else if (rows.length === 0) {
|
||||
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate" />
|
||||
} @else {
|
||||
<app-table>
|
||||
} @else if (treeRows.length === 0) {
|
||||
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate">
|
||||
<span slot="actions">
|
||||
<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>
|
||||
<tr>
|
||||
<th><input type="checkbox" (change)="selectAll.emit($any($event.target).checked)" /></th>
|
||||
<th>{{ 'adminCategories.title' | translate }}</th>
|
||||
<th>{{ 'adminCategories.slug' | translate }}</th>
|
||||
<th>{{ 'adminCategories.items' | translate }}</th>
|
||||
<th>{{ 'backoffice.status' | translate }}</th>
|
||||
<th>{{ 'adminProducts.visibility' | translate }}</th>
|
||||
@if (visibleColumns.includes('slug')) { <th>{{ 'adminCategories.slug' | translate }}</th> }
|
||||
@if (visibleColumns.includes('items')) { <th>{{ 'adminCategories.items' | translate }}</th> }
|
||||
@if (visibleColumns.includes('status')) { <th>{{ 'backoffice.status' | translate }}</th> }
|
||||
@if (visibleColumns.includes('visibility')) { <th>{{ 'adminProducts.visibility' | translate }}</th> }
|
||||
<th>{{ 'adminProducts.healthColumn' | translate }}</th>
|
||||
<th>{{ 'adminProducts.actions' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows; track row.category.id) {
|
||||
<tr [class.deleted]="row.category.deletedAt" draggable="true" (dragstart)="onDragStart(row.category.id)" (dragover)="$event.preventDefault()" (drop)="onDrop(row)">
|
||||
<td>{{ indent(row) }} {{ row.category.icon }} {{ row.category.title }}</td>
|
||||
<td>{{ row.category.slug }}</td>
|
||||
<td>{{ row.category.itemsCount }}</td>
|
||||
<td>
|
||||
<app-badge [variant]="row.category.status === 'published' ? 'success' : 'neutral'">
|
||||
{{ ('adminCategories.status.' + row.category.status) | translate }}
|
||||
</app-badge>
|
||||
</td>
|
||||
<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>
|
||||
@for (category of flatCategories; track category.id) {
|
||||
<tr [class.deleted]="category.deletedAt">
|
||||
<td><input type="checkbox" [checked]="isSelected(category.id)" (change)="selectionChange.emit({ id: category.id, checked: $any($event.target).checked })" /></td>
|
||||
<td>{{ category.icon }} {{ category.title }}</td>
|
||||
@if (visibleColumns.includes('slug')) { <td>{{ category.slug }}</td> }
|
||||
@if (visibleColumns.includes('items')) { <td>{{ category.itemsCount }}</td> }
|
||||
@if (visibleColumns.includes('status')) {
|
||||
<td><app-badge [variant]="category.status === 'published' ? 'success' : 'neutral'">{{ ('adminCategories.status.' + category.status) | translate }}</app-badge></td>
|
||||
}
|
||||
@if (visibleColumns.includes('visibility')) {
|
||||
<td><label class="check"><input type="checkbox" [checked]="category.visible" (change)="toggleVisible.emit({ id: 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">
|
||||
@if (row.category.deletedAt) {
|
||||
<app-button variant="secondary" size="sm" (click)="restore.emit(row.category.id)">{{ 'adminCategories.restore' | translate }}</app-button>
|
||||
@if (category.deletedAt) {
|
||||
<app-button variant="secondary" size="sm" (click)="restore.emit(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>
|
||||
<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(category.id)">{{ 'adminProducts.delete' | translate }}</app-button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<Partial<AdminCategoryListFilters>>();
|
||||
@Output() filtersChange = new EventEmitter<Partial<{ search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean }>>();
|
||||
@Output() create = new EventEmitter<void>();
|
||||
@Output() edit = new EventEmitter<string>();
|
||||
@Output() delete = new EventEmitter<string>();
|
||||
@Output() restore = new EventEmitter<string>();
|
||||
@Output() toggleVisible = new EventEmitter<{ id: string; visible: boolean }>();
|
||||
@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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,142 @@
|
||||
<section class="form-card">
|
||||
@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>
|
||||
<label class="check"><input type="checkbox" [checked]="category.visible" (change)="updateField('visible', $any($event.target).checked)" /><span>{{ 'adminProducts.visible' | translate }}</span></label>
|
||||
<div class="form-card__header">
|
||||
<app-category-health-widget [items]="healthItems()" [completionPercent]="health.completionPercent" />
|
||||
</div>
|
||||
|
||||
<h3>{{ 'adminCategories.image' | translate }}</h3>
|
||||
<div class="image-field">
|
||||
@if (category.imageUrl) {
|
||||
<img [src]="category.imageUrl" [alt]="category.title" class="preview" />
|
||||
<div class="form-card__tabs" role="tablist">
|
||||
@for (group of groups; track group.id) {
|
||||
<button
|
||||
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>
|
||||
|
||||
<app-form-field [label]="'adminCategories.description' | translate">
|
||||
<textarea rows="3" [ngModel]="category.description" (ngModelChange)="updateField('description', $event)"></textarea>
|
||||
</app-form-field>
|
||||
<div class="form-card__panel">
|
||||
@if (activeGroup() === 'general') {
|
||||
@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>
|
||||
@for (locale of locales; track locale) {
|
||||
<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 [label]="'adminCategories.description' | translate">
|
||||
<textarea rows="3" [ngModel]="category.description" (ngModelChange)="updateField('description', $event)"></textarea>
|
||||
</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>
|
||||
<div class="grid one">
|
||||
<app-form-field [label]="'adminProducts.metaTitle' | translate">
|
||||
<app-input [ngModel]="category.seo.metaTitle" (ngModelChange)="categoryChange.emit({ seo: { ...category.seo, metaTitle: $event } })" />
|
||||
</app-form-field>
|
||||
<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 [label]="'adminProducts.keywords' | translate">
|
||||
<app-input [ngModel]="category.seo.keywords" (ngModelChange)="categoryChange.emit({ seo: { ...category.seo, keywords: $event } })" />
|
||||
</app-form-field>
|
||||
<h4 class="form-card__subheading">{{ 'adminProducts.translations' | translate }}</h4>
|
||||
@for (locale of locales; track locale) {
|
||||
<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 [label]="(('adminCategories.description' | translate) + ' — ' + locale)">
|
||||
<app-input [ngModel]="category.translations[locale]?.description || ''" (ngModelChange)="updateTranslation(locale, 'description', $event)" />
|
||||
</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 class="actions">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<Partial<AdminCategory>>();
|
||||
@Output() saveDraft = 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 {
|
||||
this.categoryChange.emit({ [key]: value } as Partial<AdminCategory>);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<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 {
|
||||
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<AdminCategoryListFilters>): 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<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 {
|
||||
@@ -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<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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, AdminCategoryTranslation>;
|
||||
seo: AdminCategorySeo;
|
||||
attributes: AdminCategoryAttribute[];
|
||||
deletedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -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: `<app-admin-categories-list
|
||||
[rows]="rows()"
|
||||
[treeRows]="facade.visibleTreeRows()"
|
||||
[flatCategories]="facade.filteredCategories()"
|
||||
[allCategories]="facade.categories()"
|
||||
[filters]="facade.filters()"
|
||||
[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)"
|
||||
(create)="create()"
|
||||
(edit)="edit($event)"
|
||||
(delete)="deleteOne($event)"
|
||||
(restore)="facade.restoreOne($event)"
|
||||
(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
|
||||
})
|
||||
export class AdminCategoriesListPageComponent {
|
||||
@@ -29,15 +53,26 @@ export class AdminCategoriesListPageComponent {
|
||||
private readonly languageService = inject(LanguageService);
|
||||
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() {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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) {<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; }`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user