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:
sdarbinyan
2026-07-15 09:34:13 +04:00
parent dbd905b02f
commit 6a8c4a549a
18 changed files with 890 additions and 7 deletions

View File

@@ -19,7 +19,9 @@ by the existing `adminAuthGuard` (`core/admin-auth/admin-auth.guard.ts`):
/:lang/backoffice/products/create -> AdminProductEditorPageComponent
/:lang/backoffice/products/:id/edit -> AdminProductEditorPageComponent
/:lang/backoffice/products/:id/duplicate -> AdminProductEditorPageComponent
/:lang/backoffice/categories -> BackofficeComingSoonPageComponent
/:lang/backoffice/categories -> AdminCategoriesListPageComponent
/:lang/backoffice/categories/create -> AdminCategoryEditorPageComponent
/:lang/backoffice/categories/:id/edit -> AdminCategoryEditorPageComponent
/:lang/backoffice/static-pages -> BackofficeComingSoonPageComponent
/:lang/backoffice/transactions -> BackofficeComingSoonPageComponent
/:lang/backoffice/orders -> BackofficeComingSoonPageComponent
@@ -145,6 +147,56 @@ save" and "last publish" were indistinguishable after a publish. Added
`ProjectEditorFacade`, set only inside `publish()`. `lastSavedAt` behavior is
unchanged (still updated by both `save()` and `publish()`).
## Sprint 20 - Category Management
`features/admin/categories/` (model/gateway/facade/pages/components), same
container/facade/service split as `admin/products` and `admin/dashboard`:
```text
src/app/features/admin/categories/
models/ admin-category.model.ts
services/ admin-categories-gateway.interface.ts
admin-categories-local.gateway.ts
admin-categories-form.factory.ts
facade/ admin-categories.facade.ts
guards/ admin-category-dirty.guard.ts
components/ admin-categories-list.component.*
admin-category-form.component.*
pages/ admin-categories-list-page.component.ts
admin-category-editor-page.component.ts
```
- **Hierarchy**: `AdminCategory.parentId` (nullable). List page renders a
flattened, indented tree (`AdminCategoriesFacade.rootCategories()` /
`childrenOf(id)`); the editor's parent `<select>` excludes the category
itself and its descendants to prevent cycles.
- **Reordering**: native HTML5 drag-and-drop in
`admin-categories-list.component.ts` (`draggable`, `dragstart`/`drop`),
persists via `AdminCategoriesFacade.reorder()` which just rewrites `order`.
- **Delete/restore**: soft delete (`deletedAt` timestamp). Blocked
client-side (`facade.canDelete()`) if the category has children or
`itemsCount > 0`; list has an "include deleted" filter with a Restore
action for soft-deleted rows.
- **Draft/publish**: `status: 'draft' | 'published'`, set by the editor's
"Save Draft" vs "Publish" buttons (`AdminCategoriesFacade.saveDraft(publish)`).
- **Local draft recovery + unsaved-changes guard**: every `updateDraft()`
call persists the in-progress category to `localStorage` under
`admin-category-draft:<id>` (via the existing `LocalStorageService`,
same pattern as Project Editor autosave); the editor reloads that draft
ahead of the saved value if present, and is cleared on save.
`adminCategoryDirtyGuard` (mirrors `projectEditorDirtyGuard`) blocks
navigation away from an unsaved edit with `window.confirm`.
- **Image**: reuses the existing `MediaPickerComponent` (same one used by
Media Manager) rather than a free-text URL field.
- **Seed data**: `AdminCategoriesLocalGateway` seeds its in-memory cache from
`BackofficeDataService.loadCategories()` (`CategoryCardConfig`, currently
flat/no hierarchy) - same swappable-provider pattern as
`AdminProductsLocalGateway`.
- **Not yet wired**: `admin/products`' category `<select>` still uses
`AdminProductsGateway.loadCategories()` (its own `AdminProductCategoryOption`
seed), not `AdminCategoriesGateway` - unifying them is Sprint 21 scope
(`docs/SPRINT-PLAN.md`).
## Known gaps / backend needs
- **Dashboard metrics endpoint.** Categories/Products counts are computed

View File

@@ -75,13 +75,13 @@ Backend must also support content moderation/validation on publish (disallow dan
## 5. Categories
**Current behavior:** `GET /category` (existing) mapped through `CategoryDto -> CategoryMapper -> Category` domain model, exposed via `CategoryFacade`. No editor/CRUD surface — `admin/categories` is a routing placeholder (`BackofficeComingSoonPageComponent`).
**Current behavior (Sprint 20):** `GET /category` (existing) still backs the public storefront via `CategoryDto -> CategoryMapper -> Category -> CategoryFacade`, unchanged. A full admin editor now exists at `features/admin/categories/` (list + create/edit, hierarchy, drag-and-drop reorder, soft delete/restore, draft/publish, SEO/translations — see `docs/ADMIN.md` "Sprint 20") but it runs entirely against `AdminCategoriesLocalGateway`, an in-memory cache seeded once from `BackofficeDataService.loadCategories()` (`CategoryCardConfig`, no hierarchy) — nothing persists across a page reload.
**Gap:** no admin write path (create/update/delete/reorder categories) exists anywhere in this codebase.
**Gap:** no admin write path (create/update/delete/reorder categories) exists on the backend. `AdminCategory` also carries fields the current `CategoryDto`/`CategoryCardConfig` don't have yet: `parentId` (hierarchy), `slug`, `icon`, `imageUrl`, `status` (draft/published), `deletedAt` (soft delete), `seo`, per-locale `translations`.
**Needed:** category CRUD endpoints and, ideally, a bulk reorder/visibility endpoint (category `priority`/`visible` are already read fields — see `docs/BOOTSTRAP.md`/domain report history).
**Needed:** category CRUD endpoints matching the `AdminCategory` shape (`src/app/features/admin/categories/models/admin-category.model.ts`) plus a bulk reorder endpoint (`order` field) and a slug-uniqueness check (`GET /admin/categories/slug-taken?slug=...`, mirrors `AdminCategoriesGateway.isSlugTaken`).
**Frontend files:** would need a new `features/admin/categories/` module mirroring `features/admin/products/` (gateway interface + local/API gateway + facade + pages), plus wiring `/:lang/backoffice/categories` off the current coming-soon placeholder in `app.routes.ts`.
**Frontend files:** implement `AdminCategoriesApiGateway` against `AdminCategoriesGateway` (`services/admin-categories-gateway.interface.ts`) and rebind via an injection token (same swap pattern as `AdminDashboardMetricsGateway`/`BACKOFFICE_DATA_PROVIDER`) — the facade and pages don't change. Also still open: wire `admin/products`' category dropdown to `AdminCategoriesGateway` instead of its own `AdminProductsGateway.loadCategories()` (Sprint 21).
## 6. Products

82
docs/SPRINT-PLAN.md Normal file
View File

@@ -0,0 +1,82 @@
# Sprint Plan (working doc, delete after release)
Repo already past old "Sprint 19" (dashboard). Renumbering user's Sprint7+ roadmap to continue actual sequence: **Sprint 20 = old "Sprint 7" (Categories)** ... **Sprint 30 = old "Sprint 17" (Final Release)**.
Pattern to follow (mirrors `admin/products`): `models/`, `services/<x>-gateway.interface.ts` + `<x>-local.gateway.ts` (IndexedDB via existing local-storage service), `facade/`, `pages/`, `components/`. Local-only until backend endpoint exists — log gap in `docs/BACKEND.md`.
Autonomy: no stop-and-ask except missing API contract / business decision / secrets / external config / legal / multi-valid-design-fork. Commit after each sprint, conventional commits, no push. Quality gate before marking sprint done: build, typecheck, lint (if configured), responsive check, a11y pass, docs updated, no dead code, arch:check green.
Notify user: **from Sprint 20 (Categories) once product↔category link + admin categories CRUD exist, catalog can start receiving real items** (currently admin-products already has categoryId field but no category source of truth — that's the point where "adding items to catalog" becomes real, not mocked dropdown).
---
## Sprint 20 — Category Management ✅ done
- [x] AdminCategory model (id, parentId, title, translations, slug, icon, image, seo, visible, order, draft/published status, soft-delete)
- [x] AdminCategoriesGateway interface + local (in-memory, seeded from BackofficeDataService) gateway, mirrors products gateway
- [x] AdminCategoriesFacade (signals, CRUD, tree ops, slug validation, draft recovery)
- [x] Categories list page: indented tree, native HTML5 DnD reorder, visibility toggle, item counter, empty state, include-deleted filter + restore
- [x] Category editor: name/slug (+ uniqueness validation), translations editor, icon field, image via MediaPickerComponent, SEO fields, breadcrumb preview
- [x] Delete validation (blocked if has children or itemsCount>0) + soft-delete/restore
- [x] Draft/publish workflow + localStorage draft recovery (mirrors Project Editor autosave) + unsaved-changes CanDeactivate guard (mirrors projectEditorDirtyGuard)
- [x] Wired into admin routing (`app.routes.ts`, replaced coming-soon placeholder)
- [x] Updated `docs/ADMIN.md` (new Sprint 20 section), `docs/BACKEND.md` (categories gap rewritten with real field list)
- [~] Responsive/a11y: reuses existing FormField/Input/Table/Button/EmptyState a11y wiring; live-browser click-through blocked by safety classifier on the guarded admin route (see below) — verified via tsc/build/arch:check only
- Commit: `feat(admin): complete category management`
- Note: `admin/products`' category dropdown still uses its own `AdminProductsGateway.loadCategories()`, not this new gateway — unification deferred to Sprint 21 (documented in BACKEND.md).
## Sprint 21 — Product Management completion
- [ ] Audit gaps vs list: duplicate/archive already exist? verify; add missing (archive state, restore draft, related products, variant editor, price/currency/discount editor, inventory/SKU/barcode fields already present—confirm)
- [ ] Wire products to real AdminCategoriesGateway (replace ad-hoc category options)
- [ ] Gallery via MediaPickerComponent, translation editor, preview, infinite-scroll option on list
- [ ] Update `docs/ADMIN.md`, `docs/BACKEND.md`
- Commit: `feat(admin): complete product management`
## Sprint 22 — Media System hardening
- [ ] Folder support, tags, search in Media Manager
- [ ] Crop/resize/compression on upload, SVG + file-type validation
- [ ] Confirm reuse across category/product/logo/hero/static-page selectors
- [ ] Storage abstraction doc note (swap IndexedDB mock -> CDN later)
- Commit: `feat(media): reusable media management`
## Sprint 23 — Orders (mock/local, flag backend gap)
- [ ] Orders model + local gateway (seed mock data)
- [ ] List: filters, search, statuses, timeline, export
- [ ] Detail: customer/payment/shipping info, notes/internal notes, refund request, cancel, print invoice
- Commit: `feat(admin): order management`
## Sprint 24 — Transactions (mock/local)
- [ ] Payments/refunds/QR list, status, history, export, filters, search, retry, fraud flags, audit log view
- Commit: `feat(admin): transaction management`
## Sprint 25 — Users & Roles
- [ ] Users/roles/permissions models (local), marketplace vs office admin distinction
- [ ] Invitations, passwordless login (reuse existing Telegram QR pattern), session/device manager, audit trail
- Commit: `feat(admin): users and permissions`
## Sprint 26 — Monitoring
- [ ] Audit/security/login logs views (local), API/error/warning feed, queue/webhook placeholders, health page
- Commit: `feat(admin): monitoring center`
## Sprint 27 — Analytics
- [ ] Dashboard extensions: sales/revenue/orders/visitors/products/categories charts, date ranges, export
- Commit: `feat(admin): analytics dashboard`
## Sprint 28 — Marketplace Polish
- [ ] Lighthouse + a11y sweep, animations, skeletons/empty/error states, responsive fixes, SEO/meta/social preview/robots/sitemap
- Commit: `refactor: marketplace release polish`
## Sprint 29 — Release Candidate
- [ ] Dead code cleanup, remove TODOs/console logs/debug code, optimize imports/bundle/images/CSS, translation validation, build/type/lint validation, doc validation, arch review, CHANGELOG + release notes
- Commit: `chore: release candidate`
## Sprint 30 — Final Release
- [ ] Full verify pass (build, routing, responsive, translations, admin, marketplace, editor, media, login, bootstrap, APIs, security assumptions), final report
- Then (only after explicit go-ahead, push is a confirm-required action): `git push`
---
### Stop conditions (ask user)
- Real backend API contract needed (orders/transactions/users are local-mock by design per above — flagged, not blocking)
- Ambiguous business rule (e.g. category deletion policy specifics) not inferable from existing product-deletion pattern
- Credentials/external service config
- Legal/product-only decision
- Two equally valid architectures for same feature

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),
};
}
}

View File

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

View File

@@ -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(),
};
}
}