feat(admin): complete category management
Sprint 20. Adds features/admin/categories/ (model, gateway interface + local gateway, facade, list/editor pages), mirroring the admin/products container/facade/service split. - indented hierarchy view + native HTML5 drag-and-drop reorder - visibility toggle, item counter, empty state, include-deleted filter - editor: slug uniqueness validation, translations, SEO fields, breadcrumb preview, image via existing MediaPickerComponent - soft delete/restore, blocked when a category has children or items - draft/publish status + localStorage draft recovery (mirrors Project Editor autosave) + CanDeactivate unsaved-changes guard - wired into app.routes.ts (replaces the categories coming-soon placeholder) - docs/ADMIN.md + docs/BACKEND.md updated with the new gap detail Not yet done: admin/products' category dropdown still reads from its own AdminProductsGateway.loadCategories() rather than this gateway (Sprint 21). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
|
||||
import { languageGuard } from './guards/language.guard';
|
||||
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard';
|
||||
import { adminAuthGuard } from './core/admin-auth/admin-auth.guard';
|
||||
import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
// Core routes (same across all brands)
|
||||
@@ -73,8 +74,17 @@ const coreRoutes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
loadComponent: () => import('./features/backoffice/shared/backoffice-coming-soon-page.component').then(m => m.BackofficeComingSoonPageComponent),
|
||||
data: { titleKey: 'dashboard.actionCategories' }
|
||||
loadComponent: () => import('./features/admin/categories/pages/admin-categories-list-page.component').then(m => m.AdminCategoriesListPageComponent)
|
||||
},
|
||||
{
|
||||
path: 'categories/create',
|
||||
loadComponent: () => import('./features/admin/categories/pages/admin-category-editor-page.component').then(m => m.AdminCategoryEditorPageComponent),
|
||||
canDeactivate: [adminCategoryDirtyGuard]
|
||||
},
|
||||
{
|
||||
path: 'categories/:id/edit',
|
||||
loadComponent: () => import('./features/admin/categories/pages/admin-category-editor-page.component').then(m => m.AdminCategoryEditorPageComponent),
|
||||
canDeactivate: [adminCategoryDirtyGuard]
|
||||
},
|
||||
{
|
||||
path: 'static-pages',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<section class="admin-categories-card">
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<app-input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminCategories.search' | translate" />
|
||||
<select [ngModel]="filters.visibility" (ngModelChange)="filtersChange.emit({ visibility: $event })">
|
||||
<option value="all">{{ 'adminProducts.allVisibility' | translate }}</option>
|
||||
<option value="visible">{{ 'adminProducts.visible' | translate }}</option>
|
||||
<option value="hidden">{{ 'adminProducts.hidden' | translate }}</option>
|
||||
</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>
|
||||
|
||||
@if (!loading && rows.length === 0) {
|
||||
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate" />
|
||||
} @else {
|
||||
<app-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
<td class="actions">
|
||||
@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>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,8 @@
|
||||
.admin-categories-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; }
|
||||
.toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; align-items: flex-start; }
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; flex: 1; }
|
||||
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
|
||||
.check { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.actions { display: flex; gap: 10px; align-items: center; }
|
||||
tr.deleted { opacity: 0.55; }
|
||||
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } .actions { flex-direction: column; align-items: stretch; } }
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AdminCategory, AdminCategoryListFilters } 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 { 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';
|
||||
|
||||
export interface AdminCategoryRow {
|
||||
category: AdminCategory;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-categories-list',
|
||||
standalone: true,
|
||||
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, EmptyStateComponent],
|
||||
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() loading = false;
|
||||
|
||||
@Output() filtersChange = new EventEmitter<Partial<AdminCategoryListFilters>>();
|
||||
@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; targetOrder: number }>();
|
||||
|
||||
private draggedId: string | null = null;
|
||||
|
||||
indent(row: AdminCategoryRow): string {
|
||||
return '—'.repeat(row.depth);
|
||||
}
|
||||
|
||||
onDragStart(id: string): void {
|
||||
this.draggedId = id;
|
||||
}
|
||||
|
||||
onDrop(targetRow: AdminCategoryRow): void {
|
||||
if (!this.draggedId || this.draggedId === targetRow.category.id) {
|
||||
this.draggedId = null;
|
||||
return;
|
||||
}
|
||||
this.reorder.emit({ id: this.draggedId, targetOrder: targetRow.category.order });
|
||||
this.draggedId = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<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>
|
||||
|
||||
<h3>{{ 'adminCategories.image' | translate }}</h3>
|
||||
<div class="image-field">
|
||||
@if (category.imageUrl) {
|
||||
<img [src]="category.imageUrl" [alt]="category.title" class="preview" />
|
||||
}
|
||||
<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>
|
||||
|
||||
<h3>{{ 'adminProducts.translations' | translate }}</h3>
|
||||
@for (locale of ['en','ru','hy']; 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>
|
||||
}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<app-button variant="secondary" (click)="saveDraft.emit()">{{ 'adminCategories.saveDraft' | translate }}</app-button>
|
||||
<app-button variant="primary" (click)="publish.emit()">{{ 'adminCategories.publish' | translate }}</app-button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,14 @@
|
||||
.form-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; }
|
||||
.grid { display: grid; gap: 12px; }
|
||||
.grid.one { grid-template-columns: 1fr; }
|
||||
.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
label { display: grid; gap: 6px; font-weight: 600; }
|
||||
label.check { display: flex; align-items: center; gap: 8px; }
|
||||
textarea, select { width: 100%; padding: 10px 12px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; font: inherit; }
|
||||
input[type='checkbox'] { width: auto; }
|
||||
.sub-block { border-top: 1px dashed #d9e2e1; padding-top: 12px; }
|
||||
.breadcrumb-preview { margin: 0; color: var(--text-muted, #667); font-size: 0.9em; }
|
||||
.image-field { display: flex; align-items: center; gap: 12px; }
|
||||
.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; } }
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AdminCategory } 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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-category-form',
|
||||
standalone: true,
|
||||
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, FormFieldComponent, MediaPickerComponent],
|
||||
templateUrl: './admin-category-form.component.html',
|
||||
styleUrls: ['./admin-category-form.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AdminCategoryFormComponent {
|
||||
@Input({ required: true }) category!: AdminCategory;
|
||||
@Input() parentOptions: AdminCategory[] = [];
|
||||
@Input() breadcrumb: string[] = [];
|
||||
@Input() slugTaken = false;
|
||||
@Input() mode: 'create' | 'edit' = 'create';
|
||||
|
||||
@Output() categoryChange = new EventEmitter<Partial<AdminCategory>>();
|
||||
@Output() saveDraft = new EventEmitter<void>();
|
||||
@Output() publish = new EventEmitter<void>();
|
||||
|
||||
protected mediaPickerOpen = false;
|
||||
|
||||
updateField<K extends keyof AdminCategory>(key: K, value: AdminCategory[K]): void {
|
||||
this.categoryChange.emit({ [key]: value } as Partial<AdminCategory>);
|
||||
}
|
||||
|
||||
updateTranslation(locale: string, field: 'title' | 'description', value: string): void {
|
||||
this.categoryChange.emit({
|
||||
translations: {
|
||||
...this.category.translations,
|
||||
[locale]: { ...(this.category.translations[locale] ?? {}), [field]: value }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openMediaPicker(): void {
|
||||
this.mediaPickerOpen = true;
|
||||
}
|
||||
|
||||
onImagePicked(asset: MediaAsset): void {
|
||||
this.updateField('imageUrl', asset.url);
|
||||
this.mediaPickerOpen = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { AdminCategory, AdminCategoryEditorMode, AdminCategoryListFilters } from '../models/admin-category.model';
|
||||
import { AdminCategoriesFormFactory } from '../services/admin-categories-form.factory';
|
||||
import { AdminCategoriesLocalGateway } from '../services/admin-categories-local.gateway';
|
||||
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||
|
||||
const DRAFT_KEY_PREFIX = 'admin-category-draft:';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminCategoriesFacade {
|
||||
private readonly gateway = inject(AdminCategoriesLocalGateway);
|
||||
private readonly formFactory = inject(AdminCategoriesFormFactory);
|
||||
private readonly localStorage = inject(LocalStorageService);
|
||||
|
||||
readonly filters = signal<AdminCategoryListFilters>({ search: '', visibility: 'all', includeDeleted: false });
|
||||
readonly categories = signal<AdminCategory[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly draft = signal<AdminCategory | null>(null);
|
||||
readonly editorMode = signal<AdminCategoryEditorMode>('create');
|
||||
readonly dirty = signal(false);
|
||||
readonly slugTaken = signal(false);
|
||||
private savedSnapshot: string | null = null;
|
||||
|
||||
readonly rootCategories = computed(() => this.categories().filter(category => !category.parentId));
|
||||
readonly childrenByParent = computed(() => {
|
||||
const map = new Map<string, AdminCategory[]>();
|
||||
for (const category of this.categories()) {
|
||||
if (!category.parentId) continue;
|
||||
const list = map.get(category.parentId) ?? [];
|
||||
list.push(category);
|
||||
map.set(category.parentId, list);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
childrenOf(id: string): AdminCategory[] {
|
||||
return this.childrenByParent().get(id) ?? [];
|
||||
}
|
||||
|
||||
breadcrumbFor(id: string | null): string[] {
|
||||
const trail: string[] = [];
|
||||
let current = this.categories().find(category => category.id === id);
|
||||
while (current) {
|
||||
trail.unshift(current.title);
|
||||
current = current.parentId ? this.categories().find(category => category.id === current!.parentId) : undefined;
|
||||
}
|
||||
return trail;
|
||||
}
|
||||
|
||||
loadList(): void {
|
||||
this.loading.set(true);
|
||||
this.gateway.loadCategories(this.filters()).pipe(take(1)).subscribe({
|
||||
next: categories => {
|
||||
this.categories.set(categories);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.categories.set([]);
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateFilters(patch: Partial<AdminCategoryListFilters>): void {
|
||||
this.filters.update(current => ({ ...current, ...patch }));
|
||||
this.loadList();
|
||||
}
|
||||
|
||||
startCreate(): void {
|
||||
this.editorMode.set('create');
|
||||
const empty = this.formFactory.createEmpty();
|
||||
const recovered = this.localStorage.getJSON<AdminCategory>(`${DRAFT_KEY_PREFIX}${empty.id}`);
|
||||
this.draft.set(recovered ?? empty);
|
||||
this.savedSnapshot = null;
|
||||
this.dirty.set(!!recovered);
|
||||
}
|
||||
|
||||
loadForEdit(id: string): void {
|
||||
this.editorMode.set('edit');
|
||||
this.gateway.loadCategory(id).pipe(take(1)).subscribe({
|
||||
next: category => {
|
||||
if (!category) {
|
||||
this.draft.set(null);
|
||||
return;
|
||||
}
|
||||
const recovered = this.localStorage.getJSON<AdminCategory>(`${DRAFT_KEY_PREFIX}${id}`);
|
||||
this.draft.set(recovered ?? { ...category });
|
||||
this.savedSnapshot = JSON.stringify(category);
|
||||
this.dirty.set(!!recovered && JSON.stringify(recovered) !== this.savedSnapshot);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateDraft(patch: Partial<AdminCategory>): void {
|
||||
this.draft.update(current => {
|
||||
if (!current) return current;
|
||||
const updated = { ...current, ...patch, updatedAt: new Date().toISOString() };
|
||||
this.localStorage.setJSON(`${DRAFT_KEY_PREFIX}${updated.id}`, updated);
|
||||
this.dirty.set(this.savedSnapshot !== JSON.stringify(updated));
|
||||
return updated;
|
||||
});
|
||||
if ('slug' in patch) {
|
||||
this.validateSlug();
|
||||
}
|
||||
}
|
||||
|
||||
validateSlug(): void {
|
||||
const draft = this.draft();
|
||||
if (!draft || !draft.slug) {
|
||||
this.slugTaken.set(false);
|
||||
return;
|
||||
}
|
||||
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(take(1))
|
||||
.subscribe(taken => this.slugTaken.set(taken));
|
||||
}
|
||||
|
||||
saveDraft(publish: boolean): void {
|
||||
const draft = this.draft();
|
||||
if (!draft || this.slugTaken()) return;
|
||||
|
||||
const toSave: AdminCategory = { ...draft, status: publish ? 'published' : 'draft', updatedAt: new Date().toISOString() };
|
||||
const request = this.editorMode() === 'create' ? this.gateway.createCategory(toSave) : this.gateway.updateCategory(toSave);
|
||||
|
||||
request.pipe(take(1)).subscribe({
|
||||
next: saved => {
|
||||
this.localStorage.removeItem(`${DRAFT_KEY_PREFIX}${saved.id}`);
|
||||
this.savedSnapshot = JSON.stringify(saved);
|
||||
this.dirty.set(false);
|
||||
this.loadList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
discardDraftRecovery(): void {
|
||||
const draft = this.draft();
|
||||
if (!draft) return;
|
||||
this.localStorage.removeItem(`${DRAFT_KEY_PREFIX}${draft.id}`);
|
||||
}
|
||||
|
||||
canDelete(id: string): boolean {
|
||||
const category = this.categories().find(item => item.id === id);
|
||||
return !!category && this.childrenOf(id).length === 0 && category.itemsCount === 0;
|
||||
}
|
||||
|
||||
deleteOne(id: string): void {
|
||||
this.gateway.deleteCategory(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
|
||||
}
|
||||
|
||||
restoreOne(id: string): void {
|
||||
this.gateway.restoreCategory(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
|
||||
}
|
||||
|
||||
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() });
|
||||
}
|
||||
|
||||
reorder(id: string, targetOrder: number): void {
|
||||
const category = this.categories().find(item => item.id === id);
|
||||
if (!category) return;
|
||||
this.gateway.updateCategory({ ...category, order: targetOrder, updatedAt: new Date().toISOString() }).pipe(take(1))
|
||||
.subscribe({ next: () => this.loadList() });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanDeactivateFn } from '@angular/router';
|
||||
import { AdminCategoriesFacade } from '../facade/admin-categories.facade';
|
||||
import { AdminCategoryEditorPageComponent } from '../pages/admin-category-editor-page.component';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
|
||||
export const adminCategoryDirtyGuard: CanDeactivateFn<AdminCategoryEditorPageComponent> = () => {
|
||||
const facade = inject(AdminCategoriesFacade);
|
||||
if (!facade.dirty()) {
|
||||
return true;
|
||||
}
|
||||
const translate = inject(TranslateService);
|
||||
return window.confirm(translate.t('adminCategories.confirmLeaveUnsaved'));
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
export type AdminCategoryStatus = 'draft' | 'published';
|
||||
|
||||
export interface AdminCategoryTranslation {
|
||||
title?: string;
|
||||
description?: string;
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
}
|
||||
|
||||
export interface AdminCategorySeo {
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
export interface AdminCategory {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
imageUrl: string;
|
||||
order: number;
|
||||
visible: boolean;
|
||||
status: AdminCategoryStatus;
|
||||
itemsCount: number;
|
||||
translations: Record<string, AdminCategoryTranslation>;
|
||||
seo: AdminCategorySeo;
|
||||
deletedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminCategoryListFilters {
|
||||
search: string;
|
||||
visibility: 'all' | 'visible' | 'hidden';
|
||||
includeDeleted: boolean;
|
||||
}
|
||||
|
||||
export type AdminCategoryEditorMode = 'create' | 'edit';
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, 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 { LanguageService } from '../../../../services/language.service';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-categories-list-page',
|
||||
standalone: true,
|
||||
imports: [AdminCategoriesListComponent],
|
||||
template: `<app-admin-categories-list
|
||||
[rows]="rows()"
|
||||
[filters]="facade.filters()"
|
||||
[loading]="facade.loading()"
|
||||
(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.targetOrder)" />`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AdminCategoriesListPageComponent {
|
||||
readonly facade = inject(AdminCategoriesFacade);
|
||||
private readonly router = inject(Router);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly rows = computed<AdminCategoryRow[]>(() => this.buildRows(this.facade.rootCategories(), 0));
|
||||
|
||||
constructor() {
|
||||
this.facade.loadList();
|
||||
}
|
||||
|
||||
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']); }
|
||||
|
||||
deleteOne(id: string): void {
|
||||
if (!this.facade.canDelete(id)) {
|
||||
window.alert(this.translate.t('adminCategories.deleteBlocked'));
|
||||
return;
|
||||
}
|
||||
if (window.confirm(this.translate.t('adminCategories.confirmDelete'))) {
|
||||
this.facade.deleteOne(id);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AdminCategoriesFacade } from '../facade/admin-categories.facade';
|
||||
import { AdminCategoryFormComponent } from '../components/admin-category-form.component';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
|
||||
@Component({
|
||||
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()" [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>}`,
|
||||
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
|
||||
})
|
||||
export class AdminCategoryEditorPageComponent {
|
||||
readonly facade = inject(AdminCategoriesFacade);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
readonly title = computed(() => this.facade.editorMode() === 'create' ? 'adminCategories.create' : 'adminCategories.edit');
|
||||
|
||||
readonly parentOptions = computed(() => {
|
||||
const draft = this.facade.draft();
|
||||
if (!draft) return [];
|
||||
const excluded = new Set<string>([draft.id, ...this.descendantIds(draft.id)]);
|
||||
return this.facade.categories().filter(category => !excluded.has(category.id) && !category.deletedAt);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (this.facade.categories().length === 0) {
|
||||
this.facade.loadList();
|
||||
}
|
||||
if (!id) {
|
||||
this.facade.startCreate();
|
||||
} else {
|
||||
this.facade.loadForEdit(id);
|
||||
}
|
||||
}
|
||||
|
||||
save(publish: boolean): void {
|
||||
this.facade.saveDraft(publish);
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
|
||||
}
|
||||
|
||||
private descendantIds(id: string): string[] {
|
||||
return this.facade.childrenOf(id).flatMap(child => [child.id, ...this.descendantIds(child.id)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { AdminCategory } from '../models/admin-category.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminCategoriesFormFactory {
|
||||
createEmpty(): AdminCategory {
|
||||
return {
|
||||
id: `category-${Date.now()}`,
|
||||
parentId: null,
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
imageUrl: '',
|
||||
order: 0,
|
||||
visible: true,
|
||||
status: 'draft',
|
||||
itemsCount: 0,
|
||||
translations: { en: {}, ru: {}, hy: {} },
|
||||
seo: { metaTitle: '', metaDescription: '', keywords: '' },
|
||||
deletedAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { AdminCategory, AdminCategoryListFilters } from '../models/admin-category.model';
|
||||
|
||||
export interface AdminCategoriesGateway {
|
||||
loadCategories(filters: AdminCategoryListFilters): Observable<AdminCategory[]>;
|
||||
loadCategory(id: string): Observable<AdminCategory | null>;
|
||||
createCategory(category: AdminCategory): Observable<AdminCategory>;
|
||||
updateCategory(category: AdminCategory): Observable<AdminCategory>;
|
||||
deleteCategory(id: string): Observable<void>;
|
||||
restoreCategory(id: string): Observable<AdminCategory | null>;
|
||||
isSlugTaken(slug: string, excludingId: string | null): Observable<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { BackofficeDataService } from '../../../../core/backoffice/backoffice-data.service';
|
||||
import { CategoryCardConfig } from '../../../../shared/models/ui';
|
||||
import { AdminCategory, AdminCategoryListFilters } from '../models/admin-category.model';
|
||||
import { AdminCategoriesGateway } from './admin-categories-gateway.interface';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminCategoriesLocalGateway implements AdminCategoriesGateway {
|
||||
private cache: AdminCategory[] | null = null;
|
||||
|
||||
constructor(private readonly backofficeData: BackofficeDataService) {}
|
||||
|
||||
loadCategories(filters: AdminCategoryListFilters): Observable<AdminCategory[]> {
|
||||
return new Observable<AdminCategory[]>(subscriber => {
|
||||
this.ensureData().then(() => {
|
||||
const filtered = (this.cache ?? [])
|
||||
.filter(category => filters.includeDeleted || !category.deletedAt)
|
||||
.filter(category => !filters.search || category.title.toLowerCase().includes(filters.search.toLowerCase()))
|
||||
.filter(category => filters.visibility === 'all' || (filters.visibility === 'visible' ? category.visible : !category.visible))
|
||||
.sort((left, right) => left.order - right.order);
|
||||
subscriber.next(filtered);
|
||||
subscriber.complete();
|
||||
});
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
loadCategory(id: string): Observable<AdminCategory | null> {
|
||||
return new Observable<AdminCategory | null>(subscriber => {
|
||||
this.ensureData().then(() => {
|
||||
subscriber.next(this.cache?.find(category => category.id === id) ?? null);
|
||||
subscriber.complete();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createCategory(category: AdminCategory): Observable<AdminCategory> {
|
||||
this.cache = [category, ...(this.cache ?? [])];
|
||||
return of(category).pipe(delay(50));
|
||||
}
|
||||
|
||||
updateCategory(category: AdminCategory): Observable<AdminCategory> {
|
||||
this.cache = (this.cache ?? []).map(item => item.id === category.id ? category : item);
|
||||
return of(category).pipe(delay(50));
|
||||
}
|
||||
|
||||
deleteCategory(id: string): Observable<void> {
|
||||
this.cache = (this.cache ?? []).map(item => item.id === id ? { ...item, deletedAt: new Date().toISOString() } : item);
|
||||
return of(void 0).pipe(delay(50));
|
||||
}
|
||||
|
||||
restoreCategory(id: string): Observable<AdminCategory | null> {
|
||||
const restored = (this.cache ?? []).find(item => item.id === id);
|
||||
if (!restored) {
|
||||
return of(null);
|
||||
}
|
||||
const updated = { ...restored, deletedAt: null, updatedAt: new Date().toISOString() };
|
||||
this.cache = (this.cache ?? []).map(item => item.id === id ? updated : item);
|
||||
return of(updated).pipe(delay(50));
|
||||
}
|
||||
|
||||
isSlugTaken(slug: string, excludingId: string | null): Observable<boolean> {
|
||||
return new Observable<boolean>(subscriber => {
|
||||
this.ensureData().then(() => {
|
||||
const taken = (this.cache ?? []).some(category => category.slug === slug && category.id !== excludingId && !category.deletedAt);
|
||||
subscriber.next(taken);
|
||||
subscriber.complete();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
hasChildren(id: string): boolean {
|
||||
return (this.cache ?? []).some(category => category.parentId === id && !category.deletedAt);
|
||||
}
|
||||
|
||||
private async ensureData(): Promise<void> {
|
||||
if (this.cache) {
|
||||
return;
|
||||
}
|
||||
|
||||
const categories = await new Promise<CategoryCardConfig[]>(resolve => this.backofficeData.loadCategories().subscribe(value => resolve(value)));
|
||||
this.cache = categories.map(category => this.toAdminCategory(category));
|
||||
}
|
||||
|
||||
private toAdminCategory(category: CategoryCardConfig): AdminCategory {
|
||||
const slug = category.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
return {
|
||||
id: category.id,
|
||||
parentId: null,
|
||||
title: category.title,
|
||||
slug,
|
||||
description: category.description ?? '',
|
||||
icon: category.icon ?? '',
|
||||
imageUrl: category.imageUrl ?? '',
|
||||
order: 0,
|
||||
visible: true,
|
||||
status: 'published',
|
||||
itemsCount: category.itemsCount ?? 0,
|
||||
translations: {
|
||||
en: { title: category.title, description: category.description ?? '' },
|
||||
ru: {},
|
||||
hy: {},
|
||||
},
|
||||
seo: {
|
||||
metaTitle: category.title,
|
||||
metaDescription: category.description ?? '',
|
||||
keywords: '',
|
||||
},
|
||||
deletedAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user