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:
@@ -19,7 +19,9 @@ by the existing `adminAuthGuard` (`core/admin-auth/admin-auth.guard.ts`):
|
|||||||
/:lang/backoffice/products/create -> AdminProductEditorPageComponent
|
/:lang/backoffice/products/create -> AdminProductEditorPageComponent
|
||||||
/:lang/backoffice/products/:id/edit -> AdminProductEditorPageComponent
|
/:lang/backoffice/products/:id/edit -> AdminProductEditorPageComponent
|
||||||
/:lang/backoffice/products/:id/duplicate -> 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/static-pages -> BackofficeComingSoonPageComponent
|
||||||
/:lang/backoffice/transactions -> BackofficeComingSoonPageComponent
|
/:lang/backoffice/transactions -> BackofficeComingSoonPageComponent
|
||||||
/:lang/backoffice/orders -> 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
|
`ProjectEditorFacade`, set only inside `publish()`. `lastSavedAt` behavior is
|
||||||
unchanged (still updated by both `save()` and `publish()`).
|
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
|
## Known gaps / backend needs
|
||||||
|
|
||||||
- **Dashboard metrics endpoint.** Categories/Products counts are computed
|
- **Dashboard metrics endpoint.** Categories/Products counts are computed
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ Backend must also support content moderation/validation on publish (disallow dan
|
|||||||
|
|
||||||
## 5. Categories
|
## 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
|
## 6. Products
|
||||||
|
|
||||||
|
|||||||
82
docs/SPRINT-PLAN.md
Normal file
82
docs/SPRINT-PLAN.md
Normal 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
|
||||||
@@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
|
|||||||
import { languageGuard } from './guards/language.guard';
|
import { languageGuard } from './guards/language.guard';
|
||||||
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard';
|
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard';
|
||||||
import { adminAuthGuard } from './core/admin-auth/admin-auth.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';
|
import { environment } from '../environments/environment';
|
||||||
|
|
||||||
// Core routes (same across all brands)
|
// Core routes (same across all brands)
|
||||||
@@ -73,8 +74,17 @@ const coreRoutes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'categories',
|
path: 'categories',
|
||||||
loadComponent: () => import('./features/backoffice/shared/backoffice-coming-soon-page.component').then(m => m.BackofficeComingSoonPageComponent),
|
loadComponent: () => import('./features/admin/categories/pages/admin-categories-list-page.component').then(m => m.AdminCategoriesListPageComponent)
|
||||||
data: { titleKey: 'dashboard.actionCategories' }
|
},
|
||||||
|
{
|
||||||
|
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',
|
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