From 6a8c4a549ac3002b6202bbc4d7cea4a71c262f27 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Wed, 15 Jul 2026 09:34:13 +0400 Subject: [PATCH] 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 --- docs/ADMIN.md | 54 +++++- docs/BACKEND.md | 8 +- docs/SPRINT-PLAN.md | 82 +++++++++ src/app/app.routes.ts | 14 +- .../admin-categories-list.component.html | 56 ++++++ .../admin-categories-list.component.scss | 8 + .../admin-categories-list.component.ts | 55 ++++++ .../admin-category-form.component.html | 67 +++++++ .../admin-category-form.component.scss | 14 ++ .../admin-category-form.component.ts | 53 ++++++ .../facade/admin-categories.facade.ts | 167 ++++++++++++++++++ .../guards/admin-category-dirty.guard.ts | 14 ++ .../categories/models/admin-category.model.ts | 41 +++++ .../admin-categories-list-page.component.ts | 61 +++++++ .../admin-category-editor-page.component.ts | 50 ++++++ .../services/admin-categories-form.factory.ts | 26 +++ .../admin-categories-gateway.interface.ts | 12 ++ .../admin-categories-local.gateway.ts | 115 ++++++++++++ 18 files changed, 890 insertions(+), 7 deletions(-) create mode 100644 docs/SPRINT-PLAN.md create mode 100644 src/app/features/admin/categories/components/admin-categories-list.component.html create mode 100644 src/app/features/admin/categories/components/admin-categories-list.component.scss create mode 100644 src/app/features/admin/categories/components/admin-categories-list.component.ts create mode 100644 src/app/features/admin/categories/components/admin-category-form.component.html create mode 100644 src/app/features/admin/categories/components/admin-category-form.component.scss create mode 100644 src/app/features/admin/categories/components/admin-category-form.component.ts create mode 100644 src/app/features/admin/categories/facade/admin-categories.facade.ts create mode 100644 src/app/features/admin/categories/guards/admin-category-dirty.guard.ts create mode 100644 src/app/features/admin/categories/models/admin-category.model.ts create mode 100644 src/app/features/admin/categories/pages/admin-categories-list-page.component.ts create mode 100644 src/app/features/admin/categories/pages/admin-category-editor-page.component.ts create mode 100644 src/app/features/admin/categories/services/admin-categories-form.factory.ts create mode 100644 src/app/features/admin/categories/services/admin-categories-gateway.interface.ts create mode 100644 src/app/features/admin/categories/services/admin-categories-local.gateway.ts diff --git a/docs/ADMIN.md b/docs/ADMIN.md index a31bad2..a20e13a 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -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 `` 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 diff --git a/docs/BACKEND.md b/docs/BACKEND.md index 9547b6a..1798caf 100644 --- a/docs/BACKEND.md +++ b/docs/BACKEND.md @@ -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 diff --git a/docs/SPRINT-PLAN.md b/docs/SPRINT-PLAN.md new file mode 100644 index 0000000..6f05c4d --- /dev/null +++ b/docs/SPRINT-PLAN.md @@ -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/-gateway.interface.ts` + `-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 diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 2dd2a0c..1d87c39 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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', diff --git a/src/app/features/admin/categories/components/admin-categories-list.component.html b/src/app/features/admin/categories/components/admin-categories-list.component.html new file mode 100644 index 0000000..b94ed96 --- /dev/null +++ b/src/app/features/admin/categories/components/admin-categories-list.component.html @@ -0,0 +1,56 @@ +
+
+
+ + + +
+ {{ 'adminCategories.create' | translate }} +
+ + @if (!loading && rows.length === 0) { + + } @else { + + + + {{ 'adminCategories.title' | translate }} + {{ 'adminCategories.slug' | translate }} + {{ 'adminCategories.items' | translate }} + {{ 'backoffice.status' | translate }} + {{ 'adminProducts.visibility' | translate }} + {{ 'adminProducts.actions' | translate }} + + + + @for (row of rows; track row.category.id) { + + {{ indent(row) }} {{ row.category.icon }} {{ row.category.title }} + {{ row.category.slug }} + {{ row.category.itemsCount }} + + + {{ ('adminCategories.status.' + row.category.status) | translate }} + + + + + + + @if (row.category.deletedAt) { + {{ 'adminCategories.restore' | translate }} + } @else { + {{ 'adminProducts.edit' | translate }} + {{ 'adminProducts.delete' | translate }} + } + + + } + + + } +
diff --git a/src/app/features/admin/categories/components/admin-categories-list.component.scss b/src/app/features/admin/categories/components/admin-categories-list.component.scss new file mode 100644 index 0000000..5d0e16a --- /dev/null +++ b/src/app/features/admin/categories/components/admin-categories-list.component.scss @@ -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; } } diff --git a/src/app/features/admin/categories/components/admin-categories-list.component.ts b/src/app/features/admin/categories/components/admin-categories-list.component.ts new file mode 100644 index 0000000..1edd242 --- /dev/null +++ b/src/app/features/admin/categories/components/admin-categories-list.component.ts @@ -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>(); + @Output() create = new EventEmitter(); + @Output() edit = new EventEmitter(); + @Output() delete = new EventEmitter(); + @Output() restore = new EventEmitter(); + @Output() toggleVisible = new EventEmitter<{ id: string; visible: boolean }>(); + @Output() reorder = new EventEmitter<{ id: string; 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; + } +} diff --git a/src/app/features/admin/categories/components/admin-category-form.component.html b/src/app/features/admin/categories/components/admin-category-form.component.html new file mode 100644 index 0000000..1d5efab --- /dev/null +++ b/src/app/features/admin/categories/components/admin-category-form.component.html @@ -0,0 +1,67 @@ +
+ @if (breadcrumb.length > 0) { + + } + +
+ + + + + + + + + + + +
+ +

{{ 'adminCategories.image' | translate }}

+
+ @if (category.imageUrl) { + + } + {{ 'adminCategories.chooseImage' | translate }} + +
+ + + + + +

{{ 'adminProducts.translations' | translate }}

+ @for (locale of ['en','ru','hy']; track locale) { +
+ + + + + + +
+ } + +

{{ 'adminProducts.seo' | translate }}

+
+ + + + + + + +
+ +
+ {{ 'adminCategories.saveDraft' | translate }} + {{ 'adminCategories.publish' | translate }} +
+
diff --git a/src/app/features/admin/categories/components/admin-category-form.component.scss b/src/app/features/admin/categories/components/admin-category-form.component.scss new file mode 100644 index 0000000..e37a2a0 --- /dev/null +++ b/src/app/features/admin/categories/components/admin-category-form.component.scss @@ -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; } } diff --git a/src/app/features/admin/categories/components/admin-category-form.component.ts b/src/app/features/admin/categories/components/admin-category-form.component.ts new file mode 100644 index 0000000..254969d --- /dev/null +++ b/src/app/features/admin/categories/components/admin-category-form.component.ts @@ -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>(); + @Output() saveDraft = new EventEmitter(); + @Output() publish = new EventEmitter(); + + protected mediaPickerOpen = false; + + updateField(key: K, value: AdminCategory[K]): void { + this.categoryChange.emit({ [key]: value } as Partial); + } + + 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; + } +} diff --git a/src/app/features/admin/categories/facade/admin-categories.facade.ts b/src/app/features/admin/categories/facade/admin-categories.facade.ts new file mode 100644 index 0000000..3fa222c --- /dev/null +++ b/src/app/features/admin/categories/facade/admin-categories.facade.ts @@ -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({ search: '', visibility: 'all', includeDeleted: false }); + readonly categories = signal([]); + readonly loading = signal(false); + readonly draft = signal(null); + readonly editorMode = signal('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(); + 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): 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(`${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(`${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): 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() }); + } +} diff --git a/src/app/features/admin/categories/guards/admin-category-dirty.guard.ts b/src/app/features/admin/categories/guards/admin-category-dirty.guard.ts new file mode 100644 index 0000000..4a41d06 --- /dev/null +++ b/src/app/features/admin/categories/guards/admin-category-dirty.guard.ts @@ -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 = () => { + const facade = inject(AdminCategoriesFacade); + if (!facade.dirty()) { + return true; + } + const translate = inject(TranslateService); + return window.confirm(translate.t('adminCategories.confirmLeaveUnsaved')); +}; diff --git a/src/app/features/admin/categories/models/admin-category.model.ts b/src/app/features/admin/categories/models/admin-category.model.ts new file mode 100644 index 0000000..b88e65c --- /dev/null +++ b/src/app/features/admin/categories/models/admin-category.model.ts @@ -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; + 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'; diff --git a/src/app/features/admin/categories/pages/admin-categories-list-page.component.ts b/src/app/features/admin/categories/pages/admin-categories-list-page.component.ts new file mode 100644 index 0000000..69e1884 --- /dev/null +++ b/src/app/features/admin/categories/pages/admin-categories-list-page.component.ts @@ -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: ``, + 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(() => 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(); + } +} diff --git a/src/app/features/admin/categories/pages/admin-category-editor-page.component.ts b/src/app/features/admin/categories/pages/admin-category-editor-page.component.ts new file mode 100644 index 0000000..118efa0 --- /dev/null +++ b/src/app/features/admin/categories/pages/admin-category-editor-page.component.ts @@ -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) {

{{ title() | translate }}

} @else {

{{ 'common.loading' | translate }}

}`, + styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`], + changeDetection: ChangeDetectionStrategy.OnPush +}) +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([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)]); + } +} diff --git a/src/app/features/admin/categories/services/admin-categories-form.factory.ts b/src/app/features/admin/categories/services/admin-categories-form.factory.ts new file mode 100644 index 0000000..f72a110 --- /dev/null +++ b/src/app/features/admin/categories/services/admin-categories-form.factory.ts @@ -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(), + }; + } +} diff --git a/src/app/features/admin/categories/services/admin-categories-gateway.interface.ts b/src/app/features/admin/categories/services/admin-categories-gateway.interface.ts new file mode 100644 index 0000000..87bb4e8 --- /dev/null +++ b/src/app/features/admin/categories/services/admin-categories-gateway.interface.ts @@ -0,0 +1,12 @@ +import { Observable } from 'rxjs'; +import { AdminCategory, AdminCategoryListFilters } from '../models/admin-category.model'; + +export interface AdminCategoriesGateway { + loadCategories(filters: AdminCategoryListFilters): Observable; + loadCategory(id: string): Observable; + createCategory(category: AdminCategory): Observable; + updateCategory(category: AdminCategory): Observable; + deleteCategory(id: string): Observable; + restoreCategory(id: string): Observable; + isSlugTaken(slug: string, excludingId: string | null): Observable; +} diff --git a/src/app/features/admin/categories/services/admin-categories-local.gateway.ts b/src/app/features/admin/categories/services/admin-categories-local.gateway.ts new file mode 100644 index 0000000..f5661f0 --- /dev/null +++ b/src/app/features/admin/categories/services/admin-categories-local.gateway.ts @@ -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 { + return new Observable(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 { + return new Observable(subscriber => { + this.ensureData().then(() => { + subscriber.next(this.cache?.find(category => category.id === id) ?? null); + subscriber.complete(); + }); + }); + } + + createCategory(category: AdminCategory): Observable { + this.cache = [category, ...(this.cache ?? [])]; + return of(category).pipe(delay(50)); + } + + updateCategory(category: AdminCategory): Observable { + this.cache = (this.cache ?? []).map(item => item.id === category.id ? category : item); + return of(category).pipe(delay(50)); + } + + deleteCategory(id: string): Observable { + 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 { + 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 { + return new Observable(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 { + if (this.cache) { + return; + } + + const categories = await new Promise(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(), + }; + } +}