feat(admin): complete product management

Sprint 21.

- archived (soft archive/restore, distinct from visible) with an
  include-archived list filter
- barcode field alongside sku
- variants: lightweight name|price|quantity list, same textarea-parse
  convention as specifications/attributes
- relatedProductIds: checkbox picker in the editor
- gallery images now added/removed via the shared MediaPickerComponent
  instead of a raw URL textarea
- read-only discounted-price preview in the editor
- infinite-scroll toggle on the list (loadMore() appends a page instead
  of replacing it; pagination UI swaps for a Load more button)
- category dropdown now sourced from AdminCategoriesGateway (Sprint 20)
  instead of AdminProductsLocalGateway's own BackofficeDataService seed

docs/ADMIN.md + docs/BACKEND.md updated with the new field list and the
known trade-off that related-products search is scoped to the currently
loaded page, not the full catalog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-15 10:31:00 +04:00
parent 6a8c4a549a
commit 60c63a0d2a
15 changed files with 207 additions and 17 deletions

View File

@@ -197,6 +197,17 @@ src/app/features/admin/categories/
seed), not `AdminCategoriesGateway` - unifying them is Sprint 21 scope seed), not `AdminCategoriesGateway` - unifying them is Sprint 21 scope
(`docs/SPRINT-PLAN.md`). (`docs/SPRINT-PLAN.md`).
## Sprint 21 - Product Management completion
- **Categories now real**: `AdminProductsLocalGateway` seeds its category dropdown from `AdminCategoriesLocalGateway.loadCategories()` (Sprint 20) instead of raw `BackofficeDataService.loadCategories()` - product `categoryId` now points at real admin-managed categories.
- **Archive/restore**: `AdminProduct.archived` (soft, distinct from `visible`). List has an "include archived" filter + per-row Archive/Restore action; archived products excluded by default (mirrors categories' `deletedAt`/restore pattern).
- **Barcode**: added alongside `sku`.
- **Variants**: lightweight `AdminProductVariant[]` (`name`/`price`/`quantity`), edited as `name|price|quantity` lines (same textarea-parse convention as `specifications`/`attributes`). Not a full options-matrix variant system - scoped to what the model/backend contract actually needs today.
- **Related products**: `relatedProductIds: string[]`, checkbox picker in the editor sourced from `AdminProductFormComponent`'s `allProducts` input - which is `AdminProductsFacade.products()`, i.e. whatever page is currently loaded in the facade (usually primed by navigating from the list). Not a full catalog search; fine for the current mock-data scale, worth revisiting if `AdminProductsLocalGateway` is ever swapped for a real API with more than a page of products.
- **Gallery**: `media.gallery` now built via the shared `MediaPickerComponent` (add/remove thumbnails) instead of a raw URL textarea; `media.images`/`media.videos` unchanged (still textarea, out of this ticket's scope).
- **Preview**: simple read-only line in the editor showing computed discounted price.
- **Infinite scroll**: `AdminProductsFacade.infiniteScroll` toggle - when on, `loadMore()` appends the next page to `products()` instead of replacing it; pagination UI swaps for a "Load more" button. Off by default (existing paginated behavior unchanged).
## Known gaps / backend needs ## Known gaps / backend needs
- **Dashboard metrics endpoint.** Categories/Products counts are computed - **Dashboard metrics endpoint.** Categories/Products counts are computed

View File

@@ -85,9 +85,9 @@ Backend must also support content moderation/validation on publish (disallow dan
## 6. Products ## 6. Products
**Current behavior:** `features/admin/products/` is fully built (list + editor pages) against `AdminProductsLocalGateway` (swappable via an injection token, same pattern as everywhere else) — i.e. it's ready for a real API gateway, but one has never been implemented. **Current behavior (Sprint 21):** `features/admin/products/` is fully built (list + editor pages) against `AdminProductsLocalGateway` (swappable via an injection token, same pattern as everywhere else) — i.e. it's ready for a real API gateway, but one has never been implemented. Sprint 21 added `barcode`, `archived` (soft archive/restore), `variants` (`{name, price, quantity}[]`), `relatedProductIds`, and wired the category dropdown to `AdminCategoriesGateway` (see item 5) instead of its own seed.
**Needed:** product CRUD endpoints matching the existing storefront product contract (`itemID`, `name`, `price`, `currency`, `categoryID`, `visible`, `discount`, `images`, `badges`, `media`, `specificationGroups`, `variantOptions`, `relatedCollections` — see the Product Engagement / Product Experience 2.0 fields folded from prior sprint reports). **Needed:** product CRUD endpoints matching the current `AdminProduct` shape (`src/app/features/admin/products/models/admin-product.model.ts`) — `itemID`, `name`, `price`, `currency`, `categoryID`, `visible`, `archived`, `discount`, `images`, `badges`, `media`, `specifications`/`attributes`, `variants`, `relatedProductIds`, plus a bulk endpoint matching `PATCH /items/bulk`-style semantics for `applyBulkVisibility`/`applyBulkDelete`.
**Frontend files:** implement `AdminProductsApiGateway` alongside the existing `AdminProductsLocalGateway` and rebind the injection token — `features/admin/products/pages/*` and the facade do not change. **Frontend files:** implement `AdminProductsApiGateway` alongside the existing `AdminProductsLocalGateway` and rebind the injection token — `features/admin/products/pages/*` and the facade do not change.

View File

@@ -24,12 +24,13 @@ Notify user: **from Sprint 20 (Categories) once product↔category link + admin
- Commit: `feat(admin): complete category management` - 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). - 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 ## Sprint 21 — Product Management completion ✅ done
- [ ] 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) - [x] Audit + add missing: `archived` (soft, list filter + archive/restore action), `barcode`, `variants` (lightweight name|price|quantity), `relatedProductIds` (checkbox picker)
- [ ] Wire products to real AdminCategoriesGateway (replace ad-hoc category options) - [x] Wired products to real `AdminCategoriesGateway` (`AdminProductsLocalGateway` now seeds from `AdminCategoriesLocalGateway`, replacing its own `BackofficeDataService` category seed)
- [ ] Gallery via MediaPickerComponent, translation editor, preview, infinite-scroll option on list - [x] Gallery via `MediaPickerComponent` (add/remove thumbnails); translation editor already existed (Sprint pre-19); preview (computed discounted price); infinite-scroll toggle on list (`facade.loadMore()` appends vs pagination)
- [ ] Update `docs/ADMIN.md`, `docs/BACKEND.md` - [x] Updated `docs/ADMIN.md` (new Sprint 21 section), `docs/BACKEND.md` (item 6 rewritten with real field list)
- Commit: `feat(admin): complete product management` - Commit: `feat(admin): complete product management`
- Known trade-off: related-products picker sources from whatever page is currently loaded in `AdminProductsFacade.products()`, not a full catalog search — documented in ADMIN.md, fine at current mock scale.
## Sprint 22 — Media System hardening ## Sprint 22 — Media System hardening
- [ ] Folder support, tags, search in Media Manager - [ ] Folder support, tags, search in Media Manager

View File

@@ -9,6 +9,9 @@
<app-form-field [label]="'backoffice.sku' | translate"> <app-form-field [label]="'backoffice.sku' | translate">
<app-input [ngModel]="product.sku" (ngModelChange)="updateField('sku', $event)" /> <app-input [ngModel]="product.sku" (ngModelChange)="updateField('sku', $event)" />
</app-form-field> </app-form-field>
<app-form-field [label]="'adminProducts.barcode' | translate">
<app-input [ngModel]="product.barcode" (ngModelChange)="updateField('barcode', $event)" />
</app-form-field>
<app-form-field [label]="'adminProducts.brand' | translate"> <app-form-field [label]="'adminProducts.brand' | translate">
<app-input [ngModel]="product.brand" (ngModelChange)="updateField('brand', $event)" /> <app-input [ngModel]="product.brand" (ngModelChange)="updateField('brand', $event)" />
</app-form-field> </app-form-field>
@@ -17,12 +20,21 @@
<app-input type="number" [ngModel]="product.priority" (ngModelChange)="updateField('priority', +$event)" /> <app-input type="number" [ngModel]="product.priority" (ngModelChange)="updateField('priority', +$event)" />
</app-form-field> </app-form-field>
<label class="check"><input type="checkbox" [checked]="product.visible" (change)="updateField('visible', $any($event.target).checked)" /><span>{{ 'adminProducts.visible' | translate }}</span></label> <label class="check"><input type="checkbox" [checked]="product.visible" (change)="updateField('visible', $any($event.target).checked)" /><span>{{ 'adminProducts.visible' | translate }}</span></label>
<label class="check"><input type="checkbox" [checked]="product.archived" (change)="updateField('archived', $any($event.target).checked)" /><span>{{ 'adminProducts.archived' | translate }}</span></label>
</div> </div>
<h3>{{ 'adminProducts.media' | translate }}</h3> <h3>{{ 'adminProducts.media' | translate }}</h3>
<div class="grid one"> <div class="grid one">
<label><span>{{ 'adminProducts.images' | translate }}</span><textarea rows="3" [ngModel]="product.media.images.join('\n')" (ngModelChange)="updateList('images', $event)"></textarea></label> <label><span>{{ 'adminProducts.images' | translate }}</span><textarea rows="3" [ngModel]="product.media.images.join('\n')" (ngModelChange)="updateList('images', $event)"></textarea></label>
<label><span>{{ 'adminProducts.gallery' | translate }}</span><textarea rows="3" [ngModel]="product.media.gallery.join('\n')" (ngModelChange)="updateList('gallery', $event)"></textarea></label> <label><span>{{ 'adminProducts.gallery' | translate }}</span>
<div class="gallery-grid">
@for (url of product.media.gallery; track $index) {
<div class="gallery-item"><img [src]="url" alt="" /><button type="button" (click)="removeGalleryImage($index)">×</button></div>
}
<app-button variant="secondary" size="sm" (click)="openMediaPicker()">{{ 'adminCategories.chooseImage' | translate }}</app-button>
</div>
<app-media-picker [open]="mediaPickerOpen" (selected)="addGalleryImage($event)" (closed)="mediaPickerOpen = false" />
</label>
<label><span>{{ 'adminProducts.videos' | translate }}</span><textarea rows="3" [ngModel]="product.media.videos.join('\n')" (ngModelChange)="updateList('videos', $event)"></textarea></label> <label><span>{{ 'adminProducts.videos' | translate }}</span><textarea rows="3" [ngModel]="product.media.videos.join('\n')" (ngModelChange)="updateList('videos', $event)"></textarea></label>
</div> </div>
@@ -93,6 +105,23 @@
</app-form-field> </app-form-field>
</div> </div>
<h3>{{ 'adminProducts.variants' | translate }}</h3>
<div class="grid one">
<label><span>{{ 'adminProducts.variantsHint' | translate }}</span><textarea rows="4" [ngModel]="joinVariants(product.variants)" (ngModelChange)="updateVariants($event)"></textarea></label>
</div>
<h3>{{ 'adminProducts.relatedProducts' | translate }}</h3>
<div class="related-grid">
@for (other of otherProducts(); track other.id) {
<label class="check"><input type="checkbox" [checked]="product.relatedProductIds.includes(other.id)" (change)="toggleRelated(other.id, $any($event.target).checked)" /><span>{{ other.name }}</span></label>
}
</div>
<h3>{{ 'adminProducts.preview' | translate }}</h3>
<p class="price-preview">{{ product.name || ('adminProducts.name' | translate) }} — {{ finalPrice() }} {{ product.currency }}
@if (product.discount > 0) { <s>{{ product.price }} {{ product.currency }}</s> }
</p>
<h3>{{ 'adminProducts.customer' | translate }}</h3> <h3>{{ 'adminProducts.customer' | translate }}</h3>
<div class="readonly-grid"> <div class="readonly-grid">
<article> <article>

View File

@@ -11,5 +11,12 @@ input[type='checkbox'] { width: auto; }
.sub-block { border-top: 1px dashed #d9e2e1; padding-top: 12px; } .sub-block { border-top: 1px dashed #d9e2e1; padding-top: 12px; }
.readonly-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } .readonly-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.readonly-grid article { border: 1px solid #ececec; border-radius: 12px; padding: 12px; } .readonly-grid article { border: 1px solid #ececec; border-radius: 12px; padding: 12px; }
.gallery-grid { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.gallery-item { position: relative; width: 64px; height: 64px; }
.gallery-item img { width: 100%; height: 100%; object-fit: cover; border-radius: 8px; border: 1px solid var(--border-color, #d3dad9); }
.gallery-item button { position: absolute; top: -6px; right: -6px; width: 20px; height: 20px; border-radius: 50%; border: none; background: #d9433c; color: #fff; cursor: pointer; }
.related-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.price-preview { font-weight: 600; }
.actions { display: flex; justify-content: flex-end; } .actions { display: flex; justify-content: flex-end; }
@media (max-width: 700px) { .related-grid { grid-template-columns: 1fr; } }
@media (max-width: 900px) { .grid.two, .grid.three, .readonly-grid { grid-template-columns: 1fr; } } @media (max-width: 900px) { .grid.two, .grid.three, .readonly-grid { grid-template-columns: 1fr; } }

View File

@@ -1,15 +1,17 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AdminProduct, AdminProductCategoryOption } from '../models/admin-product.model'; import { AdminProduct, AdminProductCategoryOption, AdminProductVariant } from '../models/admin-product.model';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { InputComponent } from '../../../../shared/ui/input/input.component'; import { InputComponent } from '../../../../shared/ui/input/input.component';
import { FormFieldComponent } from '../../../../shared/ui/form-field/form-field.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({ @Component({
selector: 'app-admin-product-form', selector: 'app-admin-product-form',
standalone: true, standalone: true,
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, FormFieldComponent], imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, FormFieldComponent, MediaPickerComponent],
templateUrl: './admin-product-form.component.html', templateUrl: './admin-product-form.component.html',
styleUrls: ['./admin-product-form.component.scss'], styleUrls: ['./admin-product-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -17,8 +19,11 @@ import { FormFieldComponent } from '../../../../shared/ui/form-field/form-field.
export class AdminProductFormComponent { export class AdminProductFormComponent {
@Input({ required: true }) product!: AdminProduct; @Input({ required: true }) product!: AdminProduct;
@Input() categories: AdminProductCategoryOption[] = []; @Input() categories: AdminProductCategoryOption[] = [];
@Input() allProducts: AdminProduct[] = [];
@Input() mode: 'create' | 'edit' | 'duplicate' = 'create'; @Input() mode: 'create' | 'edit' | 'duplicate' = 'create';
protected mediaPickerOpen = false;
@Output() productChange = new EventEmitter<Partial<AdminProduct>>(); @Output() productChange = new EventEmitter<Partial<AdminProduct>>();
@Output() save = new EventEmitter<void>(); @Output() save = new EventEmitter<void>();
@@ -59,4 +64,42 @@ export class AdminProductFormComponent {
joinKeyValue(items: Array<{ key: string; value: string }>): string { joinKeyValue(items: Array<{ key: string; value: string }>): string {
return items.map(item => `${item.key}|${item.value}`).join('\n'); return items.map(item => `${item.key}|${item.value}`).join('\n');
} }
updateVariants(value: string): void {
const variants: AdminProductVariant[] = value.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
const [name, price, quantity] = line.split('|');
return { name: name?.trim() ?? '', price: Number(price?.trim() ?? 0) || 0, quantity: Number(quantity?.trim() ?? 0) || 0 };
});
this.productChange.emit({ variants });
}
joinVariants(variants: AdminProductVariant[]): string {
return variants.map(variant => `${variant.name}|${variant.price}|${variant.quantity}`).join('\n');
}
toggleRelated(id: string, checked: boolean): void {
const current = this.product.relatedProductIds;
this.productChange.emit({ relatedProductIds: checked ? [...new Set([...current, id])] : current.filter(item => item !== id) });
}
otherProducts(): AdminProduct[] {
return this.allProducts.filter(item => item.id !== this.product.id);
}
openMediaPicker(): void {
this.mediaPickerOpen = true;
}
addGalleryImage(asset: MediaAsset): void {
this.productChange.emit({ media: { ...this.product.media, gallery: [...this.product.media.gallery, asset.url] } });
this.mediaPickerOpen = false;
}
removeGalleryImage(index: number): void {
this.productChange.emit({ media: { ...this.product.media, gallery: this.product.media.gallery.filter((_, i) => i !== index) } });
}
finalPrice(): number {
return Math.round(this.product.price * (1 - this.product.discount / 100));
}
} }

View File

@@ -26,6 +26,8 @@
<option value="stock">{{ 'adminProducts.sortStock' | translate }}</option> <option value="stock">{{ 'adminProducts.sortStock' | translate }}</option>
<option value="updated">{{ 'adminProducts.sortUpdated' | translate }}</option> <option value="updated">{{ 'adminProducts.sortUpdated' | translate }}</option>
</select> </select>
<label class="check"><input type="checkbox" [checked]="filters.includeArchived" (change)="filtersChange.emit({ includeArchived: $any($event.target).checked })" /><span>{{ 'adminProducts.showArchived' | translate }}</span></label>
<label class="check"><input type="checkbox" [checked]="infiniteScroll" (change)="infiniteScrollToggle.emit($any($event.target).checked)" /><span>{{ 'adminProducts.infiniteScroll' | translate }}</span></label>
</div> </div>
<app-button variant="primary" (click)="create.emit()">{{ 'adminProducts.create' | translate }}</app-button> <app-button variant="primary" (click)="create.emit()">{{ 'adminProducts.create' | translate }}</app-button>
</div> </div>
@@ -68,6 +70,11 @@
<td class="actions"> <td class="actions">
<app-button variant="secondary" size="sm" (click)="edit.emit(product.id)">{{ 'adminProducts.edit' | translate }}</app-button> <app-button variant="secondary" size="sm" (click)="edit.emit(product.id)">{{ 'adminProducts.edit' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="duplicate.emit(product.id)">{{ 'adminProducts.duplicate' | translate }}</app-button> <app-button variant="secondary" size="sm" (click)="duplicate.emit(product.id)">{{ 'adminProducts.duplicate' | translate }}</app-button>
@if (product.archived) {
<app-button variant="secondary" size="sm" (click)="restore.emit(product.id)">{{ 'adminCategories.restore' | translate }}</app-button>
} @else {
<app-button variant="secondary" size="sm" (click)="archive.emit(product.id)">{{ 'adminProducts.archive' | translate }}</app-button>
}
<app-button variant="danger" size="sm" (click)="delete.emit(product.id)">{{ 'adminProducts.delete' | translate }}</app-button> <app-button variant="danger" size="sm" (click)="delete.emit(product.id)">{{ 'adminProducts.delete' | translate }}</app-button>
</td> </td>
</tr> </tr>
@@ -76,7 +83,13 @@
</app-table> </app-table>
<div class="pager"> <div class="pager">
<span>{{ total }} {{ 'adminProducts.items' | translate }}</span> <span>{{ products.length }} / {{ total }} {{ 'adminProducts.items' | translate }}</span>
@if (infiniteScroll) {
@if (products.length < total) {
<app-button variant="secondary" size="sm" (click)="loadMore.emit()">{{ 'adminProducts.loadMore' | translate }}</app-button>
}
} @else {
<app-pagination [currentPage]="filters.page" [totalPages]="totalPages()" (pageChange)="filtersChange.emit({ page: $event })" /> <app-pagination [currentPage]="filters.page" [totalPages]="totalPages()" (pageChange)="filtersChange.emit({ page: $event })" />
}
</div> </div>
</section> </section>

View File

@@ -23,6 +23,7 @@ export class AdminProductsListComponent {
@Input() total = 0; @Input() total = 0;
@Input() selectedIds: string[] = []; @Input() selectedIds: string[] = [];
@Input() loading = false; @Input() loading = false;
@Input() infiniteScroll = false;
@Output() filtersChange = new EventEmitter<Partial<AdminProductListFilters>>(); @Output() filtersChange = new EventEmitter<Partial<AdminProductListFilters>>();
@Output() create = new EventEmitter<void>(); @Output() create = new EventEmitter<void>();
@@ -33,6 +34,10 @@ export class AdminProductsListComponent {
@Output() selectAll = new EventEmitter<boolean>(); @Output() selectAll = new EventEmitter<boolean>();
@Output() bulkVisibility = new EventEmitter<boolean>(); @Output() bulkVisibility = new EventEmitter<boolean>();
@Output() bulkDelete = new EventEmitter<void>(); @Output() bulkDelete = new EventEmitter<void>();
@Output() archive = new EventEmitter<string>();
@Output() restore = new EventEmitter<string>();
@Output() loadMore = new EventEmitter<void>();
@Output() infiniteScrollToggle = new EventEmitter<boolean>();
isSelected(id: string): boolean { isSelected(id: string): boolean {
return this.selectedIds.includes(id); return this.selectedIds.includes(id);

View File

@@ -14,12 +14,14 @@ export class AdminProductsFacade {
categoryId: null, categoryId: null,
visibility: 'all', visibility: 'all',
stock: 'all', stock: 'all',
includeArchived: false,
sort: 'title', sort: 'title',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
}); });
readonly products = signal<AdminProduct[]>([]); readonly products = signal<AdminProduct[]>([]);
readonly total = signal(0); readonly total = signal(0);
readonly infiniteScroll = signal(false);
readonly categories = signal<AdminProductCategoryOption[]>([]); readonly categories = signal<AdminProductCategoryOption[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly selectedIds = signal<string[]>([]); readonly selectedIds = signal<string[]>([]);
@@ -44,6 +46,35 @@ export class AdminProductsFacade {
}); });
} }
setInfiniteScroll(enabled: boolean): void {
this.infiniteScroll.set(enabled);
this.updateFilters({ page: 1 });
}
loadMore(): void {
if (!this.infiniteScroll()) return;
const nextPage = this.filters().page + 1;
this.loading.set(true);
const nextFilters = { ...this.filters(), page: nextPage };
this.gateway.loadProducts(nextFilters).pipe(take(1)).subscribe({
next: result => {
this.filters.set(nextFilters);
this.products.update(current => [...current, ...result.items]);
this.total.set(result.total);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
archiveOne(id: string): void {
this.gateway.archiveProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
}
restoreOne(id: string): void {
this.gateway.restoreProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
}
loadCategories(): void { loadCategories(): void {
this.gateway.loadCategories().pipe(take(1)).subscribe({ next: categories => this.categories.set(categories) }); this.gateway.loadCategories().pipe(take(1)).subscribe({ next: categories => this.categories.set(categories) });
} }

View File

@@ -13,6 +13,12 @@ export interface AdminProductSpecification {
value: string; value: string;
} }
export interface AdminProductVariant {
name: string;
price: number;
quantity: number;
}
export interface AdminProductAttribute { export interface AdminProductAttribute {
key: string; key: string;
value: string; value: string;
@@ -50,9 +56,11 @@ export interface AdminProduct {
name: string; name: string;
slug: string; slug: string;
sku: string; sku: string;
barcode: string;
brand: string; brand: string;
categoryId: string; categoryId: string;
visible: boolean; visible: boolean;
archived: boolean;
priority: number; priority: number;
media: AdminProductMedia; media: AdminProductMedia;
price: number; price: number;
@@ -65,6 +73,8 @@ export interface AdminProduct {
htmlDescription: string; htmlDescription: string;
specifications: AdminProductSpecification[]; specifications: AdminProductSpecification[];
attributes: AdminProductAttribute[]; attributes: AdminProductAttribute[];
variants: AdminProductVariant[];
relatedProductIds: string[];
translations: Record<string, AdminProductTranslation>; translations: Record<string, AdminProductTranslation>;
seo: AdminProductSeo; seo: AdminProductSeo;
featured: boolean; featured: boolean;
@@ -83,6 +93,7 @@ export interface AdminProductListFilters {
categoryId: string | null; categoryId: string | null;
visibility: 'all' | 'visible' | 'hidden'; visibility: 'all' | 'visible' | 'hidden';
stock: 'all' | AdminProductStockStatus; stock: 'all' | AdminProductStockStatus;
includeArchived: boolean;
sort: AdminProductSort; sort: AdminProductSort;
page: number; page: number;
pageSize: number; pageSize: number;

View File

@@ -9,7 +9,7 @@ import { LanguageService } from '../../../../services/language.service';
selector: 'app-admin-product-editor-page', selector: 'app-admin-product-editor-page',
standalone: true, standalone: true,
imports: [AdminProductFormComponent, TranslatePipe], imports: [AdminProductFormComponent, TranslatePipe],
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-product-form [product]="draft" [categories]="facade.categories()" [mode]="facade.editorMode()" (productChange)="facade.updateDraft($event)" (save)="save()" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`, template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-product-form [product]="draft" [categories]="facade.categories()" [allProducts]="facade.products()" [mode]="facade.editorMode()" (productChange)="facade.updateDraft($event)" (save)="save()" /></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; }`], 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 changeDetection: ChangeDetectionStrategy.OnPush
}) })

View File

@@ -15,11 +15,16 @@ import { LanguageService } from '../../../../services/language.service';
[total]="facade.total()" [total]="facade.total()"
[selectedIds]="facade.selectedIds()" [selectedIds]="facade.selectedIds()"
[loading]="facade.loading()" [loading]="facade.loading()"
[infiniteScroll]="facade.infiniteScroll()"
(filtersChange)="facade.updateFilters($event)" (filtersChange)="facade.updateFilters($event)"
(create)="create()" (create)="create()"
(edit)="edit($event)" (edit)="edit($event)"
(duplicate)="duplicate($event)" (duplicate)="duplicate($event)"
(delete)="facade.deleteOne($event)" (delete)="facade.deleteOne($event)"
(archive)="facade.archiveOne($event)"
(restore)="facade.restoreOne($event)"
(loadMore)="facade.loadMore()"
(infiniteScrollToggle)="facade.setInfiniteScroll($event)"
(selectionChange)="facade.toggleSelection($event.id, $event.checked)" (selectionChange)="facade.toggleSelection($event.id, $event.checked)"
(selectAll)="facade.toggleAll($event)" (selectAll)="facade.toggleAll($event)"
(bulkVisibility)="facade.applyBulkVisibility($event)" (bulkVisibility)="facade.applyBulkVisibility($event)"

View File

@@ -9,9 +9,11 @@ export class AdminProductsFormFactory {
name: '', name: '',
slug: '', slug: '',
sku: '', sku: '',
barcode: '',
brand: '', brand: '',
categoryId: '', categoryId: '',
visible: true, visible: true,
archived: false,
priority: 0, priority: 0,
media: { images: [], gallery: [], videos: [] }, media: { images: [], gallery: [], videos: [] },
price: 0, price: 0,
@@ -24,6 +26,8 @@ export class AdminProductsFormFactory {
htmlDescription: '', htmlDescription: '',
specifications: [], specifications: [],
attributes: [], attributes: [],
variants: [],
relatedProductIds: [],
translations: { en: {}, ru: {}, hy: {} }, translations: { en: {}, ru: {}, hy: {} },
seo: { metaTitle: '', metaDescription: '', keywords: '' }, seo: { metaTitle: '', metaDescription: '', keywords: '' },
featured: false, featured: false,

View File

@@ -9,4 +9,6 @@ export interface AdminProductsGateway {
updateProduct(product: AdminProduct): Observable<AdminProduct>; updateProduct(product: AdminProduct): Observable<AdminProduct>;
deleteProduct(id: string): Observable<void>; deleteProduct(id: string): Observable<void>;
duplicateProduct(id: string): Observable<AdminProduct | null>; duplicateProduct(id: string): Observable<AdminProduct | null>;
archiveProduct(id: string): Observable<void>;
restoreProduct(id: string): Observable<AdminProduct | null>;
} }

View File

@@ -2,22 +2,27 @@ import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators'; import { delay } from 'rxjs/operators';
import { BackofficeDataService } from '../../../../core/backoffice/backoffice-data.service'; import { BackofficeDataService } from '../../../../core/backoffice/backoffice-data.service';
import { CategoryCardConfig, ProductCardConfig } from '../../../../shared/models/ui'; import { ProductCardConfig } from '../../../../shared/models/ui';
import { AdminProduct, AdminProductCategoryOption, AdminProductListFilters, AdminProductsListResult } from '../models/admin-product.model'; import { AdminProduct, AdminProductCategoryOption, AdminProductListFilters, AdminProductsListResult } from '../models/admin-product.model';
import { AdminProductsGateway } from './admin-products-gateway.interface'; import { AdminProductsGateway } from './admin-products-gateway.interface';
import { AdminCategoriesLocalGateway } from '../../categories/services/admin-categories-local.gateway';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AdminProductsLocalGateway implements AdminProductsGateway { export class AdminProductsLocalGateway implements AdminProductsGateway {
private productsCache: AdminProduct[] | null = null; private productsCache: AdminProduct[] | null = null;
private categoriesCache: AdminProductCategoryOption[] | null = null; private categoriesCache: AdminProductCategoryOption[] | null = null;
constructor(private readonly backofficeData: BackofficeDataService) {} constructor(
private readonly backofficeData: BackofficeDataService,
private readonly categoriesGateway: AdminCategoriesLocalGateway
) {}
loadProducts(filters: AdminProductListFilters): Observable<AdminProductsListResult> { loadProducts(filters: AdminProductListFilters): Observable<AdminProductsListResult> {
return new Observable<AdminProductsListResult>(subscriber => { return new Observable<AdminProductsListResult>(subscriber => {
this.ensureData().then(() => { this.ensureData().then(() => {
const all = this.productsCache ?? []; const all = this.productsCache ?? [];
const filtered = all const filtered = all
.filter(product => filters.includeArchived || !product.archived)
.filter(product => !filters.search || `${product.name} ${product.sku} ${product.brand}`.toLowerCase().includes(filters.search.toLowerCase())) .filter(product => !filters.search || `${product.name} ${product.sku} ${product.brand}`.toLowerCase().includes(filters.search.toLowerCase()))
.filter(product => !filters.categoryId || product.categoryId === filters.categoryId) .filter(product => !filters.categoryId || product.categoryId === filters.categoryId)
.filter(product => filters.visibility === 'all' || (filters.visibility === 'visible' ? product.visible : !product.visible)) .filter(product => filters.visibility === 'all' || (filters.visibility === 'visible' ? product.visible : !product.visible))
@@ -76,6 +81,7 @@ export class AdminProductsLocalGateway implements AdminProductsGateway {
const duplicated: AdminProduct = { const duplicated: AdminProduct = {
...source, ...source,
archived: false,
id: `${source.id}-copy-${Date.now()}`, id: `${source.id}-copy-${Date.now()}`,
sku: `${source.sku}-COPY`, sku: `${source.sku}-COPY`,
slug: `${source.slug}-copy-${Date.now()}`, slug: `${source.slug}-copy-${Date.now()}`,
@@ -87,15 +93,33 @@ export class AdminProductsLocalGateway implements AdminProductsGateway {
return of(duplicated).pipe(delay(50)); return of(duplicated).pipe(delay(50));
} }
archiveProduct(id: string): Observable<void> {
this.productsCache = (this.productsCache ?? []).map(item => item.id === id ? { ...item, archived: true, updatedAt: new Date().toISOString() } : item);
return of(void 0).pipe(delay(50));
}
restoreProduct(id: string): Observable<AdminProduct | null> {
const restored = (this.productsCache ?? []).find(item => item.id === id);
if (!restored) {
return of(null);
}
const updated = { ...restored, archived: false, updatedAt: new Date().toISOString() };
this.productsCache = (this.productsCache ?? []).map(item => item.id === id ? updated : item);
return of(updated).pipe(delay(50));
}
private async ensureData(): Promise<void> { private async ensureData(): Promise<void> {
if (this.productsCache && this.categoriesCache) { if (this.productsCache && this.categoriesCache) {
return; return;
} }
const products = await new Promise<ProductCardConfig[]>(resolve => this.backofficeData.loadProducts().subscribe(value => resolve(value))); const products = await new Promise<ProductCardConfig[]>(resolve => this.backofficeData.loadProducts().subscribe(value => resolve(value)));
const categories = await new Promise<CategoryCardConfig[]>(resolve => this.backofficeData.loadCategories().subscribe(value => resolve(value))); const categories = await new Promise<AdminProductCategoryOption[]>(resolve =>
this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: false })
.subscribe(value => resolve(value.map(category => ({ id: category.id, title: category.title }))))
);
this.productsCache = products.map((product, index) => this.toAdminProduct(product, categories[index % Math.max(1, categories.length)]?.id ?? 'cat-001')); this.productsCache = products.map((product, index) => this.toAdminProduct(product, categories[index % Math.max(1, categories.length)]?.id ?? 'cat-001'));
this.categoriesCache = categories.map(category => ({ id: category.id, title: category.title })); this.categoriesCache = categories;
} }
private toAdminProduct(product: ProductCardConfig, categoryId: string): AdminProduct { private toAdminProduct(product: ProductCardConfig, categoryId: string): AdminProduct {
@@ -104,9 +128,11 @@ export class AdminProductsLocalGateway implements AdminProductsGateway {
name: product.title, name: product.title,
slug: product.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''), slug: product.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''),
sku: product.sku, sku: product.sku,
barcode: '',
brand: product.subtitle ?? 'Default Brand', brand: product.subtitle ?? 'Default Brand',
categoryId, categoryId,
visible: true, visible: true,
archived: false,
priority: 0, priority: 0,
media: { media: {
images: [product.imageUrl], images: [product.imageUrl],
@@ -125,6 +151,8 @@ export class AdminProductsLocalGateway implements AdminProductsGateway {
htmlDescription: `<p>${product.subtitle ?? product.title}</p>`, htmlDescription: `<p>${product.subtitle ?? product.title}</p>`,
specifications: [], specifications: [],
attributes: [], attributes: [],
variants: [],
relatedProductIds: [],
translations: { translations: {
en: { name: product.title, shortDescription: product.subtitle ?? '', htmlDescription: `<p>${product.subtitle ?? product.title}</p>` }, en: { name: product.title, shortDescription: product.subtitle ?? '', htmlDescription: `<p>${product.subtitle ?? product.title}</p>` },
ru: {}, ru: {},