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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; } }
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
|||||||
Reference in New Issue
Block a user