feat(products): implement professional product management experience
Products dashboard (real total/published/drafts/out-of-stock/hidden/missing-images/missing-SEO/low-quality counts, recently-edited list, recommended next action); list gains table/grid view toggle, density, saved column visibility and saved sort (persisted via LocalStorageService), plus real bulk assign-category/assign-tags/duplicate/CSV-export alongside existing publish/hide/delete; per-row and per-editor reusable ProductHealthWidget (images/SEO/price/category/description/inventory checklist + completion %); editor reorganized into General/Media/Pricing/Inventory/Categories/Attributes/SEO/Visibility/Advanced tabs, media now uses the shared MediaPickerComponent/ImageFieldComponent (primary image + reorderable gallery) instead of raw URL textareas, specifications/attributes/variants moved off pipe-delimited textareas onto the shared KeyValueEditorComponent, SEO tab explains fields in plain language with a live search-result preview, inventory relabeled in business language, toggles/badges use the shared Toggle/Badge components. Filled in the adminProducts i18n namespace (previously ~98% missing, rendering raw translation keys) across en/ru/hy.
This commit is contained in:
@@ -1,139 +1,254 @@
|
||||
<section class="form-card">
|
||||
<div class="grid two">
|
||||
<app-form-field [label]="'adminProducts.name' | translate">
|
||||
<app-input [ngModel]="product.name" (ngModelChange)="updateField('name', $event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'adminProducts.slug' | translate">
|
||||
<app-input [ngModel]="product.slug" (ngModelChange)="updateField('slug', $event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'backoffice.sku' | translate">
|
||||
<app-input [ngModel]="product.sku" (ngModelChange)="updateField('sku', $event)" />
|
||||
</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-input [ngModel]="product.brand" (ngModelChange)="updateField('brand', $event)" />
|
||||
</app-form-field>
|
||||
<label><span>{{ 'adminProducts.category' | translate }}</span><select [ngModel]="product.categoryId" (ngModelChange)="updateField('categoryId', $event)">@for (category of categories; track category.id) {<option [ngValue]="category.id">{{ category.title }}</option>}</select></label>
|
||||
<app-form-field [label]="'adminProducts.priority' | translate">
|
||||
<app-input type="number" [ngModel]="product.priority" (ngModelChange)="updateField('priority', +$event)" />
|
||||
</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.archived" (change)="updateField('archived', $any($event.target).checked)" /><span>{{ 'adminProducts.archived' | translate }}</span></label>
|
||||
<div class="form-card__header">
|
||||
<app-product-health-widget [items]="healthItems()" [completionPercent]="health.completionPercent" />
|
||||
</div>
|
||||
|
||||
<h3>{{ 'adminProducts.media' | translate }}</h3>
|
||||
<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.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>
|
||||
</div>
|
||||
|
||||
<h3>{{ 'adminProducts.pricing' | translate }}</h3>
|
||||
<div class="grid three">
|
||||
<app-form-field [label]="'backoffice.price' | translate">
|
||||
<app-input type="number" [ngModel]="product.price" (ngModelChange)="updateField('price', +$event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'adminProducts.discount' | translate">
|
||||
<app-input type="number" [ngModel]="product.discount" (ngModelChange)="updateField('discount', +$event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'adminProducts.currency' | translate">
|
||||
<app-input [ngModel]="product.currency" (ngModelChange)="updateField('currency', $event)" />
|
||||
</app-form-field>
|
||||
</div>
|
||||
|
||||
<h3>{{ 'adminProducts.inventory' | translate }}</h3>
|
||||
<div class="grid three">
|
||||
<app-form-field [label]="'adminProducts.quantity' | translate">
|
||||
<app-input type="number" [ngModel]="product.quantity" (ngModelChange)="updateField('quantity', +$event)" />
|
||||
</app-form-field>
|
||||
<label><span>{{ 'adminProducts.stockStatus' | translate }}</span><select [ngModel]="product.stockStatus" (ngModelChange)="updateField('stockStatus', $event)"><option value="in_stock">{{ 'adminProducts.inStock' | translate }}</option><option value="low_stock">{{ 'adminProducts.lowStock' | translate }}</option><option value="out_of_stock">{{ 'adminProducts.outOfStock' | translate }}</option></select></label>
|
||||
<app-form-field [label]="'adminProducts.availability' | translate">
|
||||
<app-input [ngModel]="product.availability" (ngModelChange)="updateField('availability', $event)" />
|
||||
</app-form-field>
|
||||
</div>
|
||||
|
||||
<h3>{{ 'adminProducts.content' | translate }}</h3>
|
||||
<div class="grid one">
|
||||
<label><span>{{ 'adminProducts.shortDescription' | translate }}</span><textarea rows="3" [ngModel]="product.shortDescription" (ngModelChange)="updateField('shortDescription', $event)"></textarea></label>
|
||||
<label><span>{{ 'adminProducts.htmlDescription' | translate }}</span><textarea rows="6" [ngModel]="product.htmlDescription" (ngModelChange)="updateField('htmlDescription', $event)"></textarea></label>
|
||||
<label><span>{{ 'adminProducts.specifications' | translate }}</span><textarea rows="5" [ngModel]="joinKeyValue(product.specifications)" (ngModelChange)="updateList('specifications', $event)"></textarea></label>
|
||||
<label><span>{{ 'adminProducts.attributes' | translate }}</span><textarea rows="5" [ngModel]="joinKeyValue(product.attributes)" (ngModelChange)="updateList('attributes', $event)"></textarea></label>
|
||||
</div>
|
||||
|
||||
<h3>{{ 'adminProducts.translations' | translate }}</h3>
|
||||
@for (locale of locales; track locale) {
|
||||
<div class="grid two sub-block">
|
||||
<app-form-field [label]="(('adminProducts.name' | translate) + ' ' + locale)">
|
||||
<app-input [ngModel]="product.translations[locale]?.name || ''" (ngModelChange)="updateTranslation(locale, 'name', $event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="(('adminProducts.shortDescription' | translate) + ' ' + locale)">
|
||||
<app-input [ngModel]="product.translations[locale]?.shortDescription || ''" (ngModelChange)="updateTranslation(locale, 'shortDescription', $event)" />
|
||||
</app-form-field>
|
||||
<label class="full"><span>{{ 'adminProducts.htmlDescription' | translate }} {{ locale }}</span><textarea rows="4" [ngModel]="product.translations[locale]?.htmlDescription || ''" (ngModelChange)="updateTranslation(locale, 'htmlDescription', $event)"></textarea></label>
|
||||
</div>
|
||||
}
|
||||
|
||||
<h3>{{ 'adminProducts.seo' | translate }}</h3>
|
||||
<div class="grid one">
|
||||
<app-form-field [label]="'adminProducts.metaTitle' | translate">
|
||||
<app-input [ngModel]="product.seo.metaTitle" (ngModelChange)="productChange.emit({ seo: { ...product.seo, metaTitle: $event } })" />
|
||||
</app-form-field>
|
||||
<label><span>{{ 'adminProducts.metaDescription' | translate }}</span><textarea rows="3" [ngModel]="product.seo.metaDescription" (ngModelChange)="productChange.emit({ seo: { ...product.seo, metaDescription: $event } })"></textarea></label>
|
||||
<app-form-field [label]="'adminProducts.keywords' | translate">
|
||||
<app-input [ngModel]="product.seo.keywords" (ngModelChange)="productChange.emit({ seo: { ...product.seo, keywords: $event } })" />
|
||||
</app-form-field>
|
||||
</div>
|
||||
|
||||
<h3>{{ 'adminProducts.marketplace' | translate }}</h3>
|
||||
<div class="grid three">
|
||||
<label class="check"><input type="checkbox" [checked]="product.featured" (change)="updateField('featured', $any($event.target).checked)" /><span>{{ 'adminProducts.featured' | translate }}</span></label>
|
||||
<label class="check"><input type="checkbox" [checked]="product.recommended" (change)="updateField('recommended', $any($event.target).checked)" /><span>{{ 'adminProducts.recommended' | translate }}</span></label>
|
||||
<label class="check"><input type="checkbox" [checked]="product.isNew" (change)="updateField('isNew', $any($event.target).checked)" /><span>{{ 'adminProducts.new' | translate }}</span></label>
|
||||
<label class="check"><input type="checkbox" [checked]="product.bestseller" (change)="updateField('bestseller', $any($event.target).checked)" /><span>{{ 'adminProducts.bestseller' | translate }}</span></label>
|
||||
<app-form-field class="full" [label]="'adminProducts.badges' | translate">
|
||||
<app-input [ngModel]="product.badges.join(', ')" (ngModelChange)="updateList('badges', $event)" />
|
||||
</app-form-field>
|
||||
</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 class="form-card__tabs" role="tablist">
|
||||
@for (group of groups; track group.id) {
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeGroup() === group.id"
|
||||
[class.form-card__tab--active]="activeGroup() === group.id"
|
||||
class="form-card__tab"
|
||||
(click)="setGroup(group.id)"
|
||||
>
|
||||
{{ group.labelKey | translate }}
|
||||
@if (group.id === 'media' && !health.hasImages) { <app-badge variant="warning">!</app-badge> }
|
||||
@if (group.id === 'seo' && !health.hasSeo) { <app-badge variant="warning">!</app-badge> }
|
||||
</button>
|
||||
}
|
||||
</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>
|
||||
<div class="form-card__panel">
|
||||
@if (activeGroup() === 'general') {
|
||||
<div class="grid two">
|
||||
<app-form-field [label]="'adminProducts.name' | translate">
|
||||
<app-input [ngModel]="product.name" (ngModelChange)="updateField('name', $event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'adminProducts.slug' | translate">
|
||||
<app-input [ngModel]="product.slug" (ngModelChange)="updateField('slug', $event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'backoffice.sku' | translate">
|
||||
<app-input [ngModel]="product.sku" (ngModelChange)="updateField('sku', $event)" />
|
||||
</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-input [ngModel]="product.brand" (ngModelChange)="updateField('brand', $event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'adminProducts.priority' | translate">
|
||||
<app-input type="number" [ngModel]="product.priority" (ngModelChange)="updateField('priority', +$event)" />
|
||||
</app-form-field>
|
||||
</div>
|
||||
<app-form-field [label]="'adminProducts.shortDescription' | translate">
|
||||
<app-input [ngModel]="product.shortDescription" (ngModelChange)="updateField('shortDescription', $event)" />
|
||||
</app-form-field>
|
||||
|
||||
<h3>{{ 'adminProducts.customer' | translate }}</h3>
|
||||
<div class="readonly-grid">
|
||||
<article>
|
||||
<h4>{{ 'adminProducts.reviewsReadonly' | translate }}</h4>
|
||||
@if (product.reviews.length === 0) { <p>{{ 'adminProducts.noReviews' | translate }}</p> }
|
||||
@for (review of product.reviews; track review.id) { <p><strong>{{ review.author }}</strong> · {{ review.rating }}/5<br />{{ review.text }}</p> }
|
||||
</article>
|
||||
<article>
|
||||
<h4>{{ 'adminProducts.questionsReadonly' | translate }}</h4>
|
||||
@if (product.questions.length === 0) { <p>{{ 'adminProducts.noQuestions' | translate }}</p> }
|
||||
@for (question of product.questions; track question.id) { <p><strong>{{ question.question }}</strong><br />{{ question.answer || '-' }}</p> }
|
||||
</article>
|
||||
<h4 class="form-card__subheading">{{ 'adminProducts.translations' | translate }}</h4>
|
||||
@for (locale of locales; track locale) {
|
||||
<div class="grid two sub-block">
|
||||
<app-form-field [label]="(('adminProducts.name' | translate) + ' — ' + locale)">
|
||||
<app-input [ngModel]="product.translations[locale]?.name || ''" (ngModelChange)="updateTranslation(locale, 'name', $event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="(('adminProducts.shortDescription' | translate) + ' — ' + locale)">
|
||||
<app-input [ngModel]="product.translations[locale]?.shortDescription || ''" (ngModelChange)="updateTranslation(locale, 'shortDescription', $event)" />
|
||||
</app-form-field>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (activeGroup() === 'media') {
|
||||
<div class="grid one">
|
||||
<app-form-field [label]="'adminProducts.primaryImage' | translate" [hint]="'adminProducts.primaryImageHint' | translate">
|
||||
<app-image-field [value]="product.media.images[0] || ''" (valueChange)="setPrimaryImage($event)" />
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field [label]="'adminProducts.gallery' | translate" [hint]="'adminProducts.galleryHint' | translate">
|
||||
<div class="gallery-grid">
|
||||
@for (url of product.media.gallery; track $index) {
|
||||
<div class="gallery-item">
|
||||
<img [src]="url" alt="" loading="lazy" />
|
||||
<div class="gallery-item__actions">
|
||||
<button type="button" (click)="moveGalleryImage($index, -1)" [attr.aria-label]="'adminProducts.moveLeft' | translate">←</button>
|
||||
<button type="button" (click)="moveGalleryImage($index, 1)" [attr.aria-label]="'adminProducts.moveRight' | translate">→</button>
|
||||
<button type="button" (click)="removeGalleryImage($index)" [attr.aria-label]="'mediaLibrary.delete' | translate">×</button>
|
||||
</div>
|
||||
</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" />
|
||||
</app-form-field>
|
||||
|
||||
<label class="full"><span>{{ 'adminProducts.videos' | translate }}</span><textarea rows="3" [ngModel]="product.media.videos.join('\n')" (ngModelChange)="updateVideos($event)"></textarea></label>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeGroup() === 'pricing') {
|
||||
<div class="grid three">
|
||||
<app-form-field [label]="'backoffice.price' | translate">
|
||||
<app-input type="number" [ngModel]="product.price" (ngModelChange)="updateField('price', +$event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'adminProducts.discount' | translate">
|
||||
<app-input type="number" [ngModel]="product.discount" (ngModelChange)="updateField('discount', +$event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'adminProducts.currency' | translate">
|
||||
<app-input [ngModel]="product.currency" (ngModelChange)="updateField('currency', $event)" />
|
||||
</app-form-field>
|
||||
</div>
|
||||
<p class="price-preview">{{ product.name || ('adminProducts.name' | translate) }} — {{ finalPrice() }} {{ product.currency }}
|
||||
@if (product.discount > 0) { <s>{{ product.price }} {{ product.currency }}</s> }
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (activeGroup() === 'inventory') {
|
||||
<div class="grid three">
|
||||
<app-form-field [label]="'adminProducts.stockQuantity' | translate" [hint]="'adminProducts.stockQuantityHint' | translate">
|
||||
<app-input type="number" [ngModel]="product.quantity" (ngModelChange)="updateField('quantity', +$event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'adminProducts.availabilityStatus' | translate" [hint]="'adminProducts.availabilityStatusHint' | translate">
|
||||
<select [ngModel]="product.stockStatus" (ngModelChange)="updateField('stockStatus', $event)">
|
||||
<option value="in_stock">{{ 'adminProducts.inStock' | translate }}</option>
|
||||
<option value="low_stock">{{ 'adminProducts.lowStock' | translate }}</option>
|
||||
<option value="out_of_stock">{{ 'adminProducts.outOfStock' | translate }}</option>
|
||||
</select>
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'adminProducts.availabilityNote' | translate" [hint]="'adminProducts.availabilityNoteHint' | translate">
|
||||
<app-input [ngModel]="product.availability" (ngModelChange)="updateField('availability', $event)" />
|
||||
</app-form-field>
|
||||
</div>
|
||||
<p class="form-card__explain">{{ 'adminProducts.reservedNote' | translate }}</p>
|
||||
}
|
||||
|
||||
@if (activeGroup() === 'categories') {
|
||||
<app-form-field [label]="'adminProducts.category' | translate">
|
||||
<select [ngModel]="product.categoryId" (ngModelChange)="updateField('categoryId', $event)">
|
||||
@for (category of categories; track category.id) { <option [ngValue]="category.id">{{ category.title }}</option> }
|
||||
</select>
|
||||
</app-form-field>
|
||||
|
||||
<h4 class="form-card__subheading">{{ 'adminProducts.relatedProducts' | translate }}</h4>
|
||||
<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>
|
||||
}
|
||||
|
||||
@if (activeGroup() === 'attributes') {
|
||||
<details class="form-card__group" open>
|
||||
<summary>{{ 'adminProducts.specifications' | translate }}</summary>
|
||||
<app-key-value-editor
|
||||
[rows]="product.specifications"
|
||||
[createRow]="createSpecRow"
|
||||
[addLabel]="'adminProducts.addRow' | translate"
|
||||
[removeLabel]="'adminProducts.removeRow' | translate"
|
||||
(rowsChange)="updateSpecifications($event)"
|
||||
>
|
||||
<ng-template let-row let-i="index">
|
||||
<app-input [ngModel]="row.key" [placeholder]="'adminProducts.rowKey' | translate" (ngModelChange)="updateSpecificationField(i, 'key', $event)" />
|
||||
<app-input [ngModel]="row.value" [placeholder]="'adminProducts.rowValue' | translate" (ngModelChange)="updateSpecificationField(i, 'value', $event)" />
|
||||
</ng-template>
|
||||
</app-key-value-editor>
|
||||
</details>
|
||||
|
||||
<details class="form-card__group">
|
||||
<summary>{{ 'adminProducts.attributes' | translate }}</summary>
|
||||
<app-key-value-editor
|
||||
[rows]="product.attributes"
|
||||
[createRow]="createAttributeRow"
|
||||
[addLabel]="'adminProducts.addRow' | translate"
|
||||
[removeLabel]="'adminProducts.removeRow' | translate"
|
||||
(rowsChange)="updateAttributes($event)"
|
||||
>
|
||||
<ng-template let-row let-i="index">
|
||||
<app-input [ngModel]="row.key" [placeholder]="'adminProducts.rowKey' | translate" (ngModelChange)="updateAttributeField(i, 'key', $event)" />
|
||||
<app-input [ngModel]="row.value" [placeholder]="'adminProducts.rowValue' | translate" (ngModelChange)="updateAttributeField(i, 'value', $event)" />
|
||||
</ng-template>
|
||||
</app-key-value-editor>
|
||||
</details>
|
||||
|
||||
<details class="form-card__group">
|
||||
<summary>{{ 'adminProducts.variants' | translate }}</summary>
|
||||
<p class="form-card__explain">{{ 'adminProducts.variantsHint' | translate }}</p>
|
||||
<app-key-value-editor
|
||||
[rows]="product.variants"
|
||||
[createRow]="createVariantRow"
|
||||
[addLabel]="'adminProducts.addVariant' | translate"
|
||||
[removeLabel]="'adminProducts.removeRow' | translate"
|
||||
(rowsChange)="updateVariants($event)"
|
||||
>
|
||||
<ng-template let-row let-i="index">
|
||||
<app-input [ngModel]="row.name" [placeholder]="'adminProducts.variantName' | translate" (ngModelChange)="updateVariantField(i, { name: $event })" />
|
||||
<app-input type="number" [ngModel]="row.price" [placeholder]="'backoffice.price' | translate" (ngModelChange)="updateVariantField(i, { price: +$event })" />
|
||||
<app-input type="number" [ngModel]="row.quantity" [placeholder]="'adminProducts.stockQuantity' | translate" (ngModelChange)="updateVariantField(i, { quantity: +$event })" />
|
||||
</ng-template>
|
||||
</app-key-value-editor>
|
||||
</details>
|
||||
}
|
||||
|
||||
@if (activeGroup() === 'seo') {
|
||||
<p class="form-card__explain">{{ 'adminProducts.seoExplain' | translate }}</p>
|
||||
<app-form-field [label]="'adminProducts.searchTitle' | translate" [hint]="'adminProducts.searchTitleHint' | translate" [error]="!product.seo.metaTitle.trim() ? ('adminProducts.seoMissingTitle' | translate) : null">
|
||||
<app-input [ngModel]="product.seo.metaTitle" (ngModelChange)="productChange.emit({ seo: { ...product.seo, metaTitle: $event } })" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'adminProducts.searchDescription' | translate" [hint]="'adminProducts.searchDescriptionHint' | translate" [error]="!product.seo.metaDescription.trim() ? ('adminProducts.seoMissingDescription' | translate) : null">
|
||||
<app-input [ngModel]="product.seo.metaDescription" (ngModelChange)="productChange.emit({ seo: { ...product.seo, metaDescription: $event } })" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'adminProducts.keywords' | translate">
|
||||
<app-input [ngModel]="product.seo.keywords" (ngModelChange)="productChange.emit({ seo: { ...product.seo, keywords: $event } })" />
|
||||
</app-form-field>
|
||||
|
||||
<div class="seo-preview">
|
||||
<p class="seo-preview__title">{{ product.seo.metaTitle || product.name }}</p>
|
||||
<p class="seo-preview__url">yourstore.com/{{ product.slug }}</p>
|
||||
<p class="seo-preview__description">{{ product.seo.metaDescription || product.shortDescription }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeGroup() === 'visibility') {
|
||||
<div class="grid two">
|
||||
<label class="toggle-row"><app-toggle [ngModel]="product.visible" (ngModelChange)="updateField('visible', $event)" [ariaLabel]="'adminProducts.visible' | translate" /><span>{{ 'adminProducts.visible' | translate }}</span></label>
|
||||
<label class="toggle-row"><app-toggle [ngModel]="product.archived" (ngModelChange)="updateField('archived', $event)" [ariaLabel]="'adminProducts.archived' | translate" /><span>{{ 'adminProducts.archived' | translate }}</span></label>
|
||||
<label class="toggle-row"><app-toggle [ngModel]="product.featured" (ngModelChange)="updateField('featured', $event)" [ariaLabel]="'adminProducts.featured' | translate" /><span>{{ 'adminProducts.featured' | translate }}</span></label>
|
||||
<label class="toggle-row"><app-toggle [ngModel]="product.recommended" (ngModelChange)="updateField('recommended', $event)" [ariaLabel]="'adminProducts.recommended' | translate" /><span>{{ 'adminProducts.recommended' | translate }}</span></label>
|
||||
<label class="toggle-row"><app-toggle [ngModel]="product.isNew" (ngModelChange)="updateField('isNew', $event)" [ariaLabel]="'adminProducts.new' | translate" /><span>{{ 'adminProducts.new' | translate }}</span></label>
|
||||
<label class="toggle-row"><app-toggle [ngModel]="product.bestseller" (ngModelChange)="updateField('bestseller', $event)" [ariaLabel]="'adminProducts.bestseller' | translate" /><span>{{ 'adminProducts.bestseller' | translate }}</span></label>
|
||||
</div>
|
||||
<app-form-field [label]="'adminProducts.badges' | translate">
|
||||
<app-input [ngModel]="product.badges.join(', ')" (ngModelChange)="updateBadges($event)" />
|
||||
</app-form-field>
|
||||
}
|
||||
|
||||
@if (activeGroup() === 'advanced') {
|
||||
<h4 class="form-card__subheading">{{ 'adminProducts.htmlDescription' | translate }}</h4>
|
||||
<app-marketplace-html-editor [html]="product.htmlDescription" (htmlChange)="updateField('htmlDescription', $event)" />
|
||||
|
||||
@for (locale of locales; track locale) {
|
||||
<label class="full sub-block"><span>{{ 'adminProducts.htmlDescription' | translate }} {{ locale }}</span>
|
||||
<app-marketplace-html-editor
|
||||
[html]="product.translations[locale]?.htmlDescription || ''"
|
||||
(htmlChange)="updateTranslation(locale, 'htmlDescription', $event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
|
||||
<h4 class="form-card__subheading">{{ 'adminProducts.customer' | translate }}</h4>
|
||||
<div class="readonly-grid">
|
||||
<article>
|
||||
<h5>{{ 'adminProducts.reviewsReadonly' | translate }}</h5>
|
||||
@if (product.reviews.length === 0) { <p>{{ 'adminProducts.noReviews' | translate }}</p> }
|
||||
@for (review of product.reviews; track review.id) { <p><strong>{{ review.author }}</strong> · {{ review.rating }}/5<br />{{ review.text }}</p> }
|
||||
</article>
|
||||
<article>
|
||||
<h5>{{ 'adminProducts.questionsReadonly' | translate }}</h5>
|
||||
@if (product.questions.length === 0) { <p>{{ 'adminProducts.noQuestions' | translate }}</p> }
|
||||
@for (question of product.questions; track question.id) { <p><strong>{{ question.question }}</strong><br />{{ question.answer || '-' }}</p> }
|
||||
</article>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions"><app-button variant="primary" (click)="save.emit()">{{ 'adminProducts.save' | translate }}</app-button></div>
|
||||
|
||||
@@ -11,36 +11,24 @@ input[type='checkbox'] { width: auto; }
|
||||
.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 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%;
|
||||
.gallery-grid { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-start; }
|
||||
.gallery-item { display: grid; gap: 4px; width: 72px; }
|
||||
.gallery-item img { width: 72px; height: 72px; object-fit: cover; border-radius: 8px; border: 1px solid var(--border-color, #d3dad9); }
|
||||
.gallery-item__actions { display: flex; justify-content: space-between; gap: 2px; }
|
||||
.gallery-item__actions button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: #d9433c;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-muted, #eef2f0);
|
||||
color: var(--text-secondary, #5f6e6a);
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast, 120ms ease), background-color var(--transition-fast, 120ms ease);
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 4px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #b8352f;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #d9433c;
|
||||
outline-offset: 2px;
|
||||
&:hover, &:focus-visible {
|
||||
background: var(--brand-primary, #1e8a6e);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
.related-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
||||
@@ -48,3 +36,39 @@ input[type='checkbox'] { width: auto; }
|
||||
.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; } }
|
||||
|
||||
.form-card__header { display: grid; gap: 8px; }
|
||||
|
||||
.form-card__tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--border-subtle, #e7ece9); }
|
||||
.form-card__tab {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #5f6e6a);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
&:focus-visible { outline: 2px solid var(--brand-primary, #1e8a6e); outline-offset: 2px; }
|
||||
}
|
||||
.form-card__tab--active { color: var(--brand-primary, #1e8a6e); background: var(--surface-muted, #eef2f0); }
|
||||
.form-card__panel { min-height: 200px; display: grid; gap: 14px; }
|
||||
.form-card__subheading { margin: 8px 0 0; font-size: 0.85rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-secondary, #5f6e6a); }
|
||||
.form-card__explain { margin: 0; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); }
|
||||
.form-card__group { border: 1px solid var(--border-subtle, #e7ece9); border-radius: 10px; padding: 10px 14px; }
|
||||
.form-card__group summary { cursor: pointer; font-weight: 700; color: var(--text-primary, #1e3c38); }
|
||||
.toggle-row { display: flex; align-items: center; gap: 8px; font-weight: 400; }
|
||||
|
||||
.seo-preview {
|
||||
border: 1px solid var(--border-subtle, #e7ece9);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.seo-preview__title { margin: 0; color: #1a0dab; font-size: 1rem; }
|
||||
.seo-preview__url { margin: 0; color: #006621; font-size: 0.8rem; }
|
||||
.seo-preview__description { margin: 0; color: var(--text-secondary, #5f6e6a); font-size: 0.85rem; }
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AdminProduct, AdminProductCategoryOption, AdminProductVariant } from '../models/admin-product.model';
|
||||
import { AdminProduct, AdminProductAttribute, AdminProductCategoryOption, AdminProductSpecification, AdminProductVariant } from '../models/admin-product.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 { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { KeyValueEditorComponent } from '../../../../shared/ui/key-value-editor/key-value-editor.component';
|
||||
import { ImageFieldComponent } from '../../../../shared/ui/image-field/image-field.component';
|
||||
import { MediaPickerComponent } from '../../../../shared/media/media-picker/media-picker.component';
|
||||
import { MediaAsset } from '../../../../core/media/models/media-asset.model';
|
||||
import { MarketplaceHtmlEditorComponent } from '../../../project-editor/components/html-editor/marketplace-html-editor.component';
|
||||
import { AdminProductHealth } from '../facade/admin-products.facade';
|
||||
import { ProductHealthWidgetComponent, ProductHealthItem } from './product-health-widget/product-health-widget.component';
|
||||
|
||||
export type AdminProductEditorGroup = 'general' | 'media' | 'pricing' | 'inventory' | 'categories' | 'attributes' | 'seo' | 'visibility' | 'advanced';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-product-form',
|
||||
standalone: true,
|
||||
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, FormFieldComponent, MediaPickerComponent],
|
||||
imports: [
|
||||
FormsModule,
|
||||
TranslatePipe,
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
FormFieldComponent,
|
||||
ToggleComponent,
|
||||
BadgeComponent,
|
||||
KeyValueEditorComponent,
|
||||
ImageFieldComponent,
|
||||
MediaPickerComponent,
|
||||
MarketplaceHtmlEditorComponent,
|
||||
ProductHealthWidgetComponent,
|
||||
],
|
||||
templateUrl: './admin-product-form.component.html',
|
||||
styleUrls: ['./admin-product-form.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -22,12 +44,40 @@ export class AdminProductFormComponent {
|
||||
@Input() allProducts: AdminProduct[] = [];
|
||||
@Input() locales: string[] = ['en'];
|
||||
@Input() mode: 'create' | 'edit' | 'duplicate' = 'create';
|
||||
|
||||
protected mediaPickerOpen = false;
|
||||
@Input() health!: AdminProductHealth;
|
||||
|
||||
@Output() productChange = new EventEmitter<Partial<AdminProduct>>();
|
||||
@Output() save = new EventEmitter<void>();
|
||||
|
||||
protected mediaPickerOpen = false;
|
||||
|
||||
readonly groups: { id: AdminProductEditorGroup; labelKey: string }[] = [
|
||||
{ id: 'general', labelKey: 'adminProducts.groupGeneral' },
|
||||
{ id: 'media', labelKey: 'adminProducts.groupMedia' },
|
||||
{ id: 'pricing', labelKey: 'adminProducts.groupPricing' },
|
||||
{ id: 'inventory', labelKey: 'adminProducts.groupInventory' },
|
||||
{ id: 'categories', labelKey: 'adminProducts.groupCategories' },
|
||||
{ id: 'attributes', labelKey: 'adminProducts.groupAttributes' },
|
||||
{ id: 'seo', labelKey: 'adminProducts.groupSeo' },
|
||||
{ id: 'visibility', labelKey: 'adminProducts.groupVisibility' },
|
||||
{ id: 'advanced', labelKey: 'adminProducts.groupAdvanced' },
|
||||
];
|
||||
|
||||
readonly activeGroup = signal<AdminProductEditorGroup>('general');
|
||||
|
||||
setGroup(group: AdminProductEditorGroup): void {
|
||||
this.activeGroup.set(group);
|
||||
}
|
||||
|
||||
readonly healthItems = computed<ProductHealthItem[]>(() => [
|
||||
{ labelKey: 'adminProducts.healthImages', done: this.health.hasImages },
|
||||
{ labelKey: 'adminProducts.healthSeo', done: this.health.hasSeo },
|
||||
{ labelKey: 'adminProducts.healthPrice', done: this.health.hasPrice },
|
||||
{ labelKey: 'adminProducts.healthCategory', done: this.health.hasCategory },
|
||||
{ labelKey: 'adminProducts.healthDescription', done: this.health.hasDescription },
|
||||
{ labelKey: 'adminProducts.healthInventory', done: this.health.hasInventory },
|
||||
]);
|
||||
|
||||
updateField<K extends keyof AdminProduct>(key: K, value: AdminProduct[K]): void {
|
||||
this.productChange.emit({ [key]: value } as Partial<AdminProduct>);
|
||||
}
|
||||
@@ -44,38 +94,36 @@ export class AdminProductFormComponent {
|
||||
});
|
||||
}
|
||||
|
||||
updateList(type: 'specifications' | 'attributes' | 'badges' | 'images' | 'gallery' | 'videos', value: string): void {
|
||||
if (type === 'badges') {
|
||||
this.productChange.emit({ badges: value.split(',').map(item => item.trim()).filter(Boolean) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'images' || type === 'gallery' || type === 'videos') {
|
||||
this.productChange.emit({ media: { ...this.product.media, [type]: value.split('\n').map(item => item.trim()).filter(Boolean) } });
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = value.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
|
||||
const [key, raw] = line.split('|');
|
||||
return { key: key?.trim() ?? '', value: raw?.trim() ?? '' };
|
||||
});
|
||||
this.productChange.emit({ [type]: entries } as Partial<AdminProduct>);
|
||||
updateBadges(value: string): void {
|
||||
this.productChange.emit({ badges: value.split(',').map(item => item.trim()).filter(Boolean) });
|
||||
}
|
||||
|
||||
joinKeyValue(items: Array<{ key: string; value: string }>): string {
|
||||
return items.map(item => `${item.key}|${item.value}`).join('\n');
|
||||
readonly createSpecRow = () => ({ key: '', value: '' });
|
||||
readonly createAttributeRow = () => ({ key: '', value: '' });
|
||||
readonly createVariantRow = (): AdminProductVariant => ({ name: '', price: 0, quantity: 0 });
|
||||
|
||||
updateSpecifications(rows: AdminProductSpecification[]): void {
|
||||
this.productChange.emit({ specifications: rows });
|
||||
}
|
||||
|
||||
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 });
|
||||
updateSpecificationField(index: number, field: 'key' | 'value', value: string): void {
|
||||
this.updateSpecifications(this.product.specifications.map((row, i) => i === index ? { ...row, [field]: value } : row));
|
||||
}
|
||||
|
||||
joinVariants(variants: AdminProductVariant[]): string {
|
||||
return variants.map(variant => `${variant.name}|${variant.price}|${variant.quantity}`).join('\n');
|
||||
updateAttributes(rows: AdminProductAttribute[]): void {
|
||||
this.productChange.emit({ attributes: rows });
|
||||
}
|
||||
|
||||
updateAttributeField(index: number, field: 'key' | 'value', value: string): void {
|
||||
this.updateAttributes(this.product.attributes.map((row, i) => i === index ? { ...row, [field]: value } : row));
|
||||
}
|
||||
|
||||
updateVariants(rows: AdminProductVariant[]): void {
|
||||
this.productChange.emit({ variants: rows });
|
||||
}
|
||||
|
||||
updateVariantField(index: number, patch: Partial<AdminProductVariant>): void {
|
||||
this.updateVariants(this.product.variants.map((row, i) => i === index ? { ...row, ...patch } : row));
|
||||
}
|
||||
|
||||
toggleRelated(id: string, checked: boolean): void {
|
||||
@@ -87,6 +135,14 @@ export class AdminProductFormComponent {
|
||||
return this.allProducts.filter(item => item.id !== this.product.id);
|
||||
}
|
||||
|
||||
setPrimaryImage(url: string): void {
|
||||
this.productChange.emit({ media: { ...this.product.media, images: url ? [url] : [] } });
|
||||
}
|
||||
|
||||
updateVideos(value: string): void {
|
||||
this.productChange.emit({ media: { ...this.product.media, videos: value.split('\n').map(item => item.trim()).filter(Boolean) } });
|
||||
}
|
||||
|
||||
openMediaPicker(): void {
|
||||
this.mediaPickerOpen = true;
|
||||
}
|
||||
@@ -100,7 +156,23 @@ export class AdminProductFormComponent {
|
||||
this.productChange.emit({ media: { ...this.product.media, gallery: this.product.media.gallery.filter((_, i) => i !== index) } });
|
||||
}
|
||||
|
||||
moveGalleryImage(index: number, direction: -1 | 1): void {
|
||||
const gallery = [...this.product.media.gallery];
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= gallery.length) return;
|
||||
[gallery[index], gallery[target]] = [gallery[target], gallery[index]];
|
||||
this.productChange.emit({ media: { ...this.product.media, gallery } });
|
||||
}
|
||||
|
||||
finalPrice(): number {
|
||||
return Math.round(this.product.price * (1 - this.product.discount / 100));
|
||||
}
|
||||
|
||||
stockStatusLabelKey(): string {
|
||||
switch (this.product.stockStatus) {
|
||||
case 'out_of_stock': return 'adminProducts.outOfStock';
|
||||
case 'low_stock': return 'adminProducts.lowStock';
|
||||
default: return 'adminProducts.inStock';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<section class="admin-products-card">
|
||||
@if (dashboardStats) {
|
||||
<app-products-dashboard [stats]="dashboardStats" (openProduct)="edit.emit($event)" />
|
||||
}
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<app-input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminProducts.search' | translate" />
|
||||
@@ -29,13 +33,40 @@
|
||||
<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>
|
||||
<app-button variant="primary" (click)="create.emit()">{{ 'adminProducts.create' | translate }}</app-button>
|
||||
<div class="toolbar__view-controls">
|
||||
<div class="view-toggle" role="group" [attr.aria-label]="'adminProducts.viewMode' | translate">
|
||||
<app-button variant="ghost" size="sm" [attr.aria-pressed]="viewMode === 'table'" (click)="viewModeChange.emit('table')">{{ 'adminProducts.viewTable' | translate }}</app-button>
|
||||
<app-button variant="ghost" size="sm" [attr.aria-pressed]="viewMode === 'grid'" (click)="viewModeChange.emit('grid')">{{ 'adminProducts.viewGrid' | translate }}</app-button>
|
||||
</div>
|
||||
<div class="view-toggle" role="group" [attr.aria-label]="'adminProducts.density' | translate">
|
||||
<app-button variant="ghost" size="sm" [attr.aria-pressed]="density === 'comfortable'" (click)="densityChange.emit('comfortable')">{{ 'adminProducts.densityComfortable' | translate }}</app-button>
|
||||
<app-button variant="ghost" size="sm" [attr.aria-pressed]="density === 'compact'" (click)="densityChange.emit('compact')">{{ 'adminProducts.densityCompact' | translate }}</app-button>
|
||||
</div>
|
||||
<app-button variant="ghost" size="sm" (click)="columnsPanelOpen.set(!columnsPanelOpen())">{{ 'adminProducts.columns' | translate }}</app-button>
|
||||
<app-button variant="primary" (click)="create.emit()">{{ 'adminProducts.create' | translate }}</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (columnsPanelOpen()) {
|
||||
<app-card padding="sm" class="columns-panel">
|
||||
@for (column of allColumns; track column) {
|
||||
<label class="check">
|
||||
<input type="checkbox" [checked]="isColumnVisible(column)" (change)="columnToggle.emit({ column, visible: $any($event.target).checked })" />
|
||||
<span>{{ ('adminProducts.column_' + column) | translate }}</span>
|
||||
</label>
|
||||
}
|
||||
</app-card>
|
||||
}
|
||||
|
||||
@if (selectedIds.length > 0) {
|
||||
<div class="bulk-actions">
|
||||
<span>{{ selectedIds.length }} {{ 'adminProducts.selectedCount' | translate }}</span>
|
||||
<app-button variant="secondary" size="sm" (click)="bulkVisibility.emit(true)">{{ 'adminProducts.bulkShow' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="bulkVisibility.emit(false)">{{ 'adminProducts.bulkHide' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="openAssignCategory()">{{ 'adminProducts.bulkAssignCategory' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="openAssignTags()">{{ 'adminProducts.bulkAssignTags' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="bulkDuplicate.emit()">{{ 'adminProducts.bulkDuplicateAction' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="bulkExport.emit()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button>
|
||||
<app-button variant="danger" size="sm" (click)="bulkDelete.emit()">{{ 'adminProducts.bulkDelete' | translate }}</app-button>
|
||||
</div>
|
||||
}
|
||||
@@ -47,18 +78,24 @@
|
||||
}
|
||||
</div>
|
||||
} @else if (products.length === 0) {
|
||||
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate" />
|
||||
} @else {
|
||||
<app-table>
|
||||
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate">
|
||||
<span slot="actions">
|
||||
<app-button variant="primary" (click)="create.emit()">{{ 'adminProducts.create' | translate }}</app-button>
|
||||
</span>
|
||||
</app-empty-state>
|
||||
<p class="admin-products-card__guide">{{ 'adminProducts.emptyGuide' | translate }}</p>
|
||||
} @else if (viewMode === 'table') {
|
||||
<app-table [class.density-compact]="density === 'compact'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" (change)="selectAll.emit($any($event.target).checked)" /></th>
|
||||
<th>{{ 'adminProducts.name' | translate }}</th>
|
||||
<th>{{ 'backoffice.sku' | translate }}</th>
|
||||
<th>{{ 'adminProducts.brand' | translate }}</th>
|
||||
<th>{{ 'backoffice.price' | translate }}</th>
|
||||
<th>{{ 'backoffice.status' | translate }}</th>
|
||||
<th>{{ 'adminProducts.visibility' | translate }}</th>
|
||||
@if (isColumnVisible('sku')) { <th>{{ 'backoffice.sku' | translate }}</th> }
|
||||
@if (isColumnVisible('brand')) { <th>{{ 'adminProducts.brand' | translate }}</th> }
|
||||
@if (isColumnVisible('price')) { <th>{{ 'backoffice.price' | translate }}</th> }
|
||||
@if (isColumnVisible('stock')) { <th>{{ 'adminProducts.stockStatus' | translate }}</th> }
|
||||
@if (isColumnVisible('visibility')) { <th>{{ 'adminProducts.visibility' | translate }}</th> }
|
||||
<th>{{ 'adminProducts.healthColumn' | translate }}</th>
|
||||
<th>{{ 'adminProducts.actions' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -67,15 +104,24 @@
|
||||
<tr>
|
||||
<td><input type="checkbox" [checked]="isSelected(product.id)" (change)="selectionChange.emit({ id: product.id, checked: $any($event.target).checked })" /></td>
|
||||
<td>{{ product.name }}</td>
|
||||
<td>{{ product.sku }}</td>
|
||||
<td>{{ product.brand }}</td>
|
||||
<td>{{ product.price }} {{ product.currency }}</td>
|
||||
<td>{{ product.stockStatus }}</td>
|
||||
<td>
|
||||
<app-badge [variant]="product.visible ? 'success' : 'neutral'">
|
||||
{{ product.visible ? ('adminProducts.visible' | translate) : ('adminProducts.hidden' | translate) }}
|
||||
</app-badge>
|
||||
</td>
|
||||
@if (isColumnVisible('sku')) { <td>{{ product.sku }}</td> }
|
||||
@if (isColumnVisible('brand')) { <td>{{ product.brand }}</td> }
|
||||
@if (isColumnVisible('price')) { <td>{{ product.price }} {{ product.currency }}</td> }
|
||||
@if (isColumnVisible('stock')) {
|
||||
<td>
|
||||
<app-badge [variant]="product.stockStatus === 'out_of_stock' ? 'danger' : product.stockStatus === 'low_stock' ? 'warning' : 'success'">
|
||||
{{ ('adminProducts.' + (product.stockStatus === 'out_of_stock' ? 'outOfStock' : product.stockStatus === 'low_stock' ? 'lowStock' : 'inStock')) | translate }}
|
||||
</app-badge>
|
||||
</td>
|
||||
}
|
||||
@if (isColumnVisible('visibility')) {
|
||||
<td>
|
||||
<app-badge [variant]="product.visible ? 'success' : 'neutral'">
|
||||
{{ product.visible ? ('adminProducts.visible' | translate) : ('adminProducts.hidden' | translate) }}
|
||||
</app-badge>
|
||||
</td>
|
||||
}
|
||||
<td class="health-cell"><app-product-health-widget [items]="healthItems(product)" [completionPercent]="health(product).completionPercent" [compact]="true" /></td>
|
||||
<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)="duplicate.emit(product.id)">{{ 'adminProducts.duplicate' | translate }}</app-button>
|
||||
@@ -90,7 +136,35 @@
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
} @else {
|
||||
<div class="product-grid" [class.product-grid--compact]="density === 'compact'">
|
||||
@for (product of products; track product.id) {
|
||||
<app-card padding="sm" class="product-grid__item">
|
||||
<label class="product-grid__select">
|
||||
<input type="checkbox" [checked]="isSelected(product.id)" (change)="selectionChange.emit({ id: product.id, checked: $any($event.target).checked })" [attr.aria-label]="product.name" />
|
||||
</label>
|
||||
@if (product.media.images[0] || product.media.gallery[0]) {
|
||||
<img class="product-grid__image" [src]="product.media.images[0] || product.media.gallery[0]" [alt]="product.name" loading="lazy" />
|
||||
} @else {
|
||||
<div class="product-grid__image product-grid__image--placeholder">{{ 'adminProducts.noImage' | translate }}</div>
|
||||
}
|
||||
<h4 class="product-grid__name">{{ product.name }}</h4>
|
||||
<p class="product-grid__price">{{ product.price }} {{ product.currency }}</p>
|
||||
<app-badge [variant]="product.visible ? 'success' : 'neutral'">
|
||||
{{ product.visible ? ('adminProducts.visible' | translate) : ('adminProducts.hidden' | translate) }}
|
||||
</app-badge>
|
||||
<app-product-health-widget [items]="healthItems(product)" [completionPercent]="health(product).completionPercent" [compact]="true" />
|
||||
<div class="product-grid__actions">
|
||||
<app-button variant="secondary" size="sm" (click)="edit.emit(product.id)">{{ 'adminProducts.edit' | translate }}</app-button>
|
||||
<app-button variant="ghost" size="sm" (click)="duplicate.emit(product.id)">{{ 'adminProducts.duplicate' | translate }}</app-button>
|
||||
<app-button variant="ghost" size="sm" (click)="delete.emit(product.id)">{{ 'adminProducts.delete' | translate }}</app-button>
|
||||
</div>
|
||||
</app-card>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (products.length > 0) {
|
||||
<div class="pager">
|
||||
<span>{{ products.length }} / {{ total }} {{ 'adminProducts.items' | translate }}</span>
|
||||
@if (infiniteScroll) {
|
||||
@@ -102,4 +176,28 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-dialog [open]="assignCategoryOpen()" [titleText]="'adminProducts.bulkAssignCategory' | translate" size="sm" (closed)="assignCategoryOpen.set(false)">
|
||||
<div class="dialog-body">
|
||||
<select #categorySelect>
|
||||
@for (category of categories; track category.id) {
|
||||
<option [value]="category.id">{{ category.title }}</option>
|
||||
}
|
||||
</select>
|
||||
<div class="dialog-actions">
|
||||
<app-button variant="secondary" (click)="assignCategoryOpen.set(false)">{{ 'mediaLibrary.cancel' | translate }}</app-button>
|
||||
<app-button variant="primary" (click)="confirmAssignCategory(categorySelect.value)">{{ 'mediaLibrary.confirm' | translate }}</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</app-dialog>
|
||||
|
||||
<app-dialog [open]="assignTagsOpen()" [titleText]="'adminProducts.bulkAssignTags' | translate" size="sm" (closed)="assignTagsOpen.set(false)">
|
||||
<div class="dialog-body">
|
||||
<app-input [ngModel]="tagsInput()" (ngModelChange)="tagsInput.set($event)" [placeholder]="'adminProducts.tagsPlaceholder' | translate" />
|
||||
<div class="dialog-actions">
|
||||
<app-button variant="secondary" (click)="assignTagsOpen.set(false)">{{ 'mediaLibrary.cancel' | translate }}</app-button>
|
||||
<app-button variant="primary" (click)="confirmAssignTags()">{{ 'mediaLibrary.confirm' | translate }}</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</app-dialog>
|
||||
</section>
|
||||
|
||||
@@ -7,3 +7,24 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
|
||||
@media (max-width: 960px) { .filters { grid-template-columns: repeat(2, minmax(140px, 1fr)); } }
|
||||
@media (max-width: 640px) { .filters { grid-template-columns: 1fr; } .actions { flex-direction: column; align-items: stretch; } }
|
||||
.skeleton-rows { display: grid; gap: 8px; }
|
||||
|
||||
.toolbar__view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.view-toggle { display: flex; gap: 2px; }
|
||||
.columns-panel { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.health-cell { min-width: 140px; }
|
||||
.admin-products-card__guide { margin: 0; text-align: center; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); }
|
||||
|
||||
.density-compact td, .density-compact th { padding: 4px 8px; }
|
||||
|
||||
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||||
.product-grid--compact { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
|
||||
.product-grid__item { position: relative; display: grid; gap: 6px; }
|
||||
.product-grid__select { position: absolute; top: 8px; left: 8px; z-index: 1; }
|
||||
.product-grid__image { width: 100%; height: 120px; object-fit: cover; border-radius: 8px; background: var(--surface-muted, #eef2f0); }
|
||||
.product-grid__image--placeholder { display: flex; align-items: center; justify-content: center; font-size: 0.75rem; color: var(--text-tertiary, #9aa6a2); }
|
||||
.product-grid__name { margin: 0; font-size: 0.85rem; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.product-grid__price { margin: 0; font-size: 0.8rem; color: var(--text-secondary, #5f6e6a); }
|
||||
.product-grid__actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.dialog-body { display: grid; gap: 12px; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AdminProduct, AdminProductCategoryOption, AdminProductListFilters } from '../models/admin-product.model';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
@@ -9,11 +9,30 @@ import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
import { CardComponent } from '../../../../shared/ui/card/card.component';
|
||||
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||
import { ProductHealthWidgetComponent, ProductHealthItem } from './product-health-widget/product-health-widget.component';
|
||||
import { ProductsDashboardComponent } from './products-dashboard/products-dashboard.component';
|
||||
import { AdminProductColumn, AdminProductHealth, AdminProductsDashboardStats, AdminProductsDensity, AdminProductsViewMode, ALL_PRODUCT_COLUMNS } from '../facade/admin-products.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-products-list',
|
||||
standalone: true,
|
||||
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, SkeletonComponent, EmptyStateComponent],
|
||||
imports: [
|
||||
FormsModule,
|
||||
TranslatePipe,
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
BadgeComponent,
|
||||
TableComponent,
|
||||
PaginationComponent,
|
||||
SkeletonComponent,
|
||||
EmptyStateComponent,
|
||||
CardComponent,
|
||||
DialogComponent,
|
||||
ProductHealthWidgetComponent,
|
||||
ProductsDashboardComponent,
|
||||
],
|
||||
templateUrl: './admin-products-list.component.html',
|
||||
styleUrls: ['./admin-products-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -26,6 +45,11 @@ export class AdminProductsListComponent {
|
||||
@Input() selectedIds: string[] = [];
|
||||
@Input() loading = false;
|
||||
@Input() infiniteScroll = false;
|
||||
@Input() viewMode: AdminProductsViewMode = 'table';
|
||||
@Input() density: AdminProductsDensity = 'comfortable';
|
||||
@Input() visibleColumns: AdminProductColumn[] = [...ALL_PRODUCT_COLUMNS];
|
||||
@Input() dashboardStats: AdminProductsDashboardStats | null = null;
|
||||
@Input() health!: (product: AdminProduct) => AdminProductHealth;
|
||||
|
||||
@Output() filtersChange = new EventEmitter<Partial<AdminProductListFilters>>();
|
||||
@Output() create = new EventEmitter<void>();
|
||||
@@ -36,10 +60,23 @@ export class AdminProductsListComponent {
|
||||
@Output() selectAll = new EventEmitter<boolean>();
|
||||
@Output() bulkVisibility = new EventEmitter<boolean>();
|
||||
@Output() bulkDelete = new EventEmitter<void>();
|
||||
@Output() bulkDuplicate = new EventEmitter<void>();
|
||||
@Output() bulkAssignCategory = new EventEmitter<string>();
|
||||
@Output() bulkAssignTags = new EventEmitter<string[]>();
|
||||
@Output() bulkExport = new EventEmitter<void>();
|
||||
@Output() archive = new EventEmitter<string>();
|
||||
@Output() restore = new EventEmitter<string>();
|
||||
@Output() loadMore = new EventEmitter<void>();
|
||||
@Output() infiniteScrollToggle = new EventEmitter<boolean>();
|
||||
@Output() viewModeChange = new EventEmitter<AdminProductsViewMode>();
|
||||
@Output() densityChange = new EventEmitter<AdminProductsDensity>();
|
||||
@Output() columnToggle = new EventEmitter<{ column: AdminProductColumn; visible: boolean }>();
|
||||
|
||||
protected readonly allColumns = ALL_PRODUCT_COLUMNS;
|
||||
protected readonly columnsPanelOpen = signal(false);
|
||||
protected readonly assignCategoryOpen = signal(false);
|
||||
protected readonly assignTagsOpen = signal(false);
|
||||
protected readonly tagsInput = signal('');
|
||||
|
||||
isSelected(id: string): boolean {
|
||||
return this.selectedIds.includes(id);
|
||||
@@ -48,4 +85,42 @@ export class AdminProductsListComponent {
|
||||
totalPages(): number {
|
||||
return Math.max(1, Math.ceil(this.total / this.filters.pageSize));
|
||||
}
|
||||
|
||||
isColumnVisible(column: AdminProductColumn): boolean {
|
||||
return this.visibleColumns.includes(column);
|
||||
}
|
||||
|
||||
healthItems(product: AdminProduct): ProductHealthItem[] {
|
||||
const health = this.health(product);
|
||||
return [
|
||||
{ labelKey: 'adminProducts.healthImages', done: health.hasImages },
|
||||
{ labelKey: 'adminProducts.healthSeo', done: health.hasSeo },
|
||||
{ labelKey: 'adminProducts.healthPrice', done: health.hasPrice },
|
||||
{ labelKey: 'adminProducts.healthCategory', done: health.hasCategory },
|
||||
{ labelKey: 'adminProducts.healthDescription', done: health.hasDescription },
|
||||
{ labelKey: 'adminProducts.healthInventory', done: health.hasInventory },
|
||||
];
|
||||
}
|
||||
|
||||
openAssignCategory(): void {
|
||||
this.assignCategoryOpen.set(true);
|
||||
}
|
||||
|
||||
confirmAssignCategory(categoryId: string): void {
|
||||
this.bulkAssignCategory.emit(categoryId);
|
||||
this.assignCategoryOpen.set(false);
|
||||
}
|
||||
|
||||
openAssignTags(): void {
|
||||
this.tagsInput.set('');
|
||||
this.assignTagsOpen.set(true);
|
||||
}
|
||||
|
||||
confirmAssignTags(): void {
|
||||
const tags = this.tagsInput().split(',').map(t => t.trim()).filter(Boolean);
|
||||
if (tags.length > 0) {
|
||||
this.bulkAssignTags.emit(tags);
|
||||
}
|
||||
this.assignTagsOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="product-health" [class.product-health--compact]="compact()">
|
||||
<div class="product-health__bar" role="progressbar" [attr.aria-valuenow]="completionPercent()" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="product-health__bar-fill" [style.width.%]="completionPercent()"></div>
|
||||
</div>
|
||||
<span class="product-health__percent">{{ completionPercent() }}%</span>
|
||||
@if (!compact()) {
|
||||
<ul class="product-health__list">
|
||||
@for (item of items(); track item.labelKey) {
|
||||
<li [class.product-health__list-item--done]="item.done">
|
||||
<span aria-hidden="true">{{ item.done ? '✓' : '○' }}</span>
|
||||
<span>{{ item.labelKey | translate }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
.product-health {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.product-health__bar {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-muted, #eef2f0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-health__bar-fill {
|
||||
height: 100%;
|
||||
background: var(--brand-primary, #1e8a6e);
|
||||
}
|
||||
|
||||
.product-health__percent {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary, #5f6e6a);
|
||||
}
|
||||
|
||||
.product-health__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary, #5f6e6a);
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.product-health__list-item--done {
|
||||
color: var(--text-primary, #1e3c38);
|
||||
}
|
||||
|
||||
.product-health--compact {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
export interface ProductHealthItem {
|
||||
labelKey: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/** Reusable checklist + completion meter, fed real per-item booleans from AdminProductsFacade.health() - never invents its own data. */
|
||||
@Component({
|
||||
selector: 'app-product-health-widget',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './product-health-widget.component.html',
|
||||
styleUrl: './product-health-widget.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProductHealthWidgetComponent {
|
||||
readonly items = input.required<ProductHealthItem[]>();
|
||||
readonly completionPercent = input.required<number>();
|
||||
readonly compact = input(false);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<section class="products-dashboard">
|
||||
<div class="products-dashboard__metrics">
|
||||
<app-dashboard-metric labelKey="adminProducts.totalProducts" [value]="stats().total.toString()" />
|
||||
<app-dashboard-metric labelKey="adminProducts.publishedCount" [value]="stats().published.toString()" />
|
||||
<app-dashboard-metric labelKey="adminProducts.draftsCount" [value]="stats().drafts.toString()" />
|
||||
<app-dashboard-metric labelKey="adminProducts.outOfStockCount" [value]="stats().outOfStock.toString()" />
|
||||
<app-dashboard-metric labelKey="adminProducts.hiddenCount" [value]="stats().hidden.toString()" />
|
||||
<app-dashboard-metric labelKey="adminProducts.missingImagesCount" [value]="stats().missingImages.toString()" />
|
||||
<app-dashboard-metric labelKey="adminProducts.missingSeoCount" [value]="stats().missingSeo.toString()" />
|
||||
<app-dashboard-metric labelKey="adminProducts.lowQualityCount" [value]="stats().lowQuality.toString()" />
|
||||
</div>
|
||||
|
||||
<div class="products-dashboard__row">
|
||||
<app-card padding="md" class="products-dashboard__recent">
|
||||
<h4>{{ 'adminProducts.recentlyEdited' | translate }}</h4>
|
||||
@if (stats().recentlyEdited.length === 0) {
|
||||
<p class="products-dashboard__hint">{{ 'adminProducts.emptyDescription' | translate }}</p>
|
||||
} @else {
|
||||
<ul>
|
||||
@for (product of stats().recentlyEdited; track product.id) {
|
||||
<li>
|
||||
<button type="button" (click)="openProduct.emit(product.id)">{{ product.name || product.sku }}</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</app-card>
|
||||
|
||||
<app-card padding="md" class="products-dashboard__recommend">
|
||||
<h4>{{ 'adminProducts.recommendedNext' | translate }}</h4>
|
||||
<p>{{ stats().recommendation.labelKey | translate }}</p>
|
||||
@if (stats().recommendation.productId) {
|
||||
<app-button variant="primary" size="sm" (click)="goRecommended()">{{ 'adminProducts.openAction' | translate }}</app-button>
|
||||
}
|
||||
</app-card>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,77 @@
|
||||
.products-dashboard {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.products-dashboard__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.products-dashboard__metrics {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.products-dashboard__metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.products-dashboard__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.products-dashboard__row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.products-dashboard__recent,
|
||||
.products-dashboard__recommend {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary, #5f6e6a);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.products-dashboard__recent ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.products-dashboard__recent button {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: var(--brand-primary, #1e8a6e);
|
||||
font-size: 0.85rem;
|
||||
|
||||
&:hover, &:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.products-dashboard__hint {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Output, input } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { ButtonComponent } from '../../../../../shared/ui/button/button.component';
|
||||
import { CardComponent } from '../../../../../shared/ui/card/card.component';
|
||||
import { DashboardMetricComponent } from '../../../dashboard/components/dashboard-metric.component';
|
||||
import { AdminProductsDashboardStats } from '../../facade/admin-products.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-products-dashboard',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe, ButtonComponent, CardComponent, DashboardMetricComponent],
|
||||
templateUrl: './products-dashboard.component.html',
|
||||
styleUrl: './products-dashboard.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProductsDashboardComponent {
|
||||
readonly stats = input.required<AdminProductsDashboardStats>();
|
||||
|
||||
@Output() openProduct = new EventEmitter<string>();
|
||||
|
||||
goRecommended(): void {
|
||||
const id = this.stats().recommendation.productId;
|
||||
if (id) {
|
||||
this.openProduct.emit(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,69 @@ import { AdminProduct, AdminProductCategoryOption, AdminProductEditorMode, Admin
|
||||
import { AdminProductsFormFactory } from '../services/admin-products-form.factory';
|
||||
import { AdminProductsLocalGateway } from '../services/admin-products-local.gateway';
|
||||
import { ProjectEditorFacade } from '../../../project-editor/facade/project-editor.facade';
|
||||
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||
|
||||
export type AdminProductsViewMode = 'table' | 'grid';
|
||||
export type AdminProductsDensity = 'comfortable' | 'compact';
|
||||
|
||||
export interface AdminProductHealth {
|
||||
hasImages: boolean;
|
||||
hasSeo: boolean;
|
||||
hasPrice: boolean;
|
||||
hasCategory: boolean;
|
||||
hasDescription: boolean;
|
||||
hasInventory: boolean;
|
||||
completionPercent: number;
|
||||
}
|
||||
|
||||
export interface AdminProductsDashboardStats {
|
||||
total: number;
|
||||
published: number;
|
||||
drafts: number;
|
||||
outOfStock: number;
|
||||
hidden: number;
|
||||
missingImages: number;
|
||||
missingSeo: number;
|
||||
lowQuality: number;
|
||||
recentlyEdited: AdminProduct[];
|
||||
recommendation: { labelKey: string; productId?: string };
|
||||
}
|
||||
|
||||
const VIEW_MODE_KEY = 'admin-products:view-mode';
|
||||
const DENSITY_KEY = 'admin-products:density';
|
||||
const COLUMNS_KEY = 'admin-products:visible-columns';
|
||||
|
||||
export const ALL_PRODUCT_COLUMNS = ['sku', 'brand', 'price', 'stock', 'visibility', 'updated'] as const;
|
||||
export type AdminProductColumn = typeof ALL_PRODUCT_COLUMNS[number];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminProductsFacade {
|
||||
private readonly gateway = inject(AdminProductsLocalGateway);
|
||||
private readonly formFactory = inject(AdminProductsFormFactory);
|
||||
private readonly projectEditor = inject(ProjectEditorFacade);
|
||||
private readonly localStorage = inject(LocalStorageService);
|
||||
|
||||
readonly viewMode = signal<AdminProductsViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminProductsViewMode) || 'table');
|
||||
readonly density = signal<AdminProductsDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminProductsDensity) || 'comfortable');
|
||||
readonly visibleColumns = signal<AdminProductColumn[]>(this.localStorage.getJSON<AdminProductColumn[]>(COLUMNS_KEY) ?? [...ALL_PRODUCT_COLUMNS]);
|
||||
|
||||
setViewMode(mode: AdminProductsViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
this.localStorage.setItem(VIEW_MODE_KEY, mode);
|
||||
}
|
||||
|
||||
setDensity(density: AdminProductsDensity): void {
|
||||
this.density.set(density);
|
||||
this.localStorage.setItem(DENSITY_KEY, density);
|
||||
}
|
||||
|
||||
setColumnVisible(column: AdminProductColumn, visible: boolean): void {
|
||||
const next = visible
|
||||
? [...new Set([...this.visibleColumns(), column])]
|
||||
: this.visibleColumns().filter(c => c !== column);
|
||||
this.visibleColumns.set(next);
|
||||
this.localStorage.setJSON(COLUMNS_KEY, next);
|
||||
}
|
||||
|
||||
readonly supportedLocales = computed(() => this.projectEditor.bootstrap()?.localization.supportedLocales ?? ['en']);
|
||||
|
||||
@@ -25,7 +82,7 @@ export class AdminProductsFacade {
|
||||
visibility: 'all',
|
||||
stock: 'all',
|
||||
includeArchived: false,
|
||||
sort: 'title',
|
||||
sort: (this.localStorage.getItem('admin-products:sort') as AdminProductListFilters['sort']) || 'title',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
@@ -78,11 +135,11 @@ export class AdminProductsFacade {
|
||||
}
|
||||
|
||||
archiveOne(id: string): void {
|
||||
this.gateway.archiveProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
|
||||
this.gateway.archiveProduct(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } });
|
||||
}
|
||||
|
||||
restoreOne(id: string): void {
|
||||
this.gateway.restoreProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
|
||||
this.gateway.restoreProduct(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } });
|
||||
}
|
||||
|
||||
loadCategories(): void {
|
||||
@@ -90,6 +147,9 @@ export class AdminProductsFacade {
|
||||
}
|
||||
|
||||
updateFilters(patch: Partial<AdminProductListFilters>): void {
|
||||
if (patch.sort) {
|
||||
this.localStorage.setItem('admin-products:sort', patch.sort);
|
||||
}
|
||||
this.filters.update(current => ({ ...current, ...patch, page: patch.page ?? 1 }));
|
||||
this.loadList();
|
||||
}
|
||||
@@ -108,6 +168,7 @@ export class AdminProductsFacade {
|
||||
updates.forEach(product => this.gateway.updateProduct(product).pipe(take(1)).subscribe());
|
||||
this.selectedIds.set([]);
|
||||
this.loadList();
|
||||
this.loadDashboardStats();
|
||||
}
|
||||
|
||||
applyBulkDelete(): void {
|
||||
@@ -115,6 +176,108 @@ export class AdminProductsFacade {
|
||||
ids.forEach(id => this.gateway.deleteProduct(id).pipe(take(1)).subscribe());
|
||||
this.selectedIds.set([]);
|
||||
this.loadList();
|
||||
this.loadDashboardStats();
|
||||
}
|
||||
|
||||
applyBulkAssignCategory(categoryId: string): void {
|
||||
const selected = new Set(this.selectedIds());
|
||||
const updates = this.products().filter(product => selected.has(product.id)).map(product => ({ ...product, categoryId, updatedAt: new Date().toISOString() }));
|
||||
updates.forEach(product => this.gateway.updateProduct(product).pipe(take(1)).subscribe());
|
||||
this.selectedIds.set([]);
|
||||
this.loadList();
|
||||
this.loadDashboardStats();
|
||||
}
|
||||
|
||||
applyBulkAssignTags(tags: string[]): void {
|
||||
const selected = new Set(this.selectedIds());
|
||||
const updates = this.products().filter(product => selected.has(product.id)).map(product => ({
|
||||
...product,
|
||||
badges: [...new Set([...product.badges, ...tags])],
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
updates.forEach(product => this.gateway.updateProduct(product).pipe(take(1)).subscribe());
|
||||
this.selectedIds.set([]);
|
||||
this.loadList();
|
||||
this.loadDashboardStats();
|
||||
}
|
||||
|
||||
applyBulkDuplicate(): void {
|
||||
const ids = [...this.selectedIds()];
|
||||
ids.forEach(id => this.gateway.duplicateProduct(id).pipe(take(1)).subscribe());
|
||||
this.selectedIds.set([]);
|
||||
this.loadList();
|
||||
this.loadDashboardStats();
|
||||
}
|
||||
|
||||
/** Client-side CSV of the currently selected rows - no backend export endpoint exists, so this never claims a server-generated file. */
|
||||
exportSelectedAsCsv(): void {
|
||||
const selected = new Set(this.selectedIds());
|
||||
const rows = this.products().filter(product => selected.has(product.id));
|
||||
const header = ['id', 'name', 'sku', 'brand', 'price', 'currency', 'quantity', 'stockStatus', 'visible', 'categoryId'];
|
||||
const lines = rows.map(product => [product.id, product.name, product.sku, product.brand, product.price, product.currency, product.quantity, product.stockStatus, product.visible, product.categoryId]
|
||||
.map(value => `"${String(value).replace(/"/g, '""')}"`).join(','));
|
||||
const csv = [header.join(','), ...lines].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'products-export.csv';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
health(product: AdminProduct): AdminProductHealth {
|
||||
const hasImages = product.media.images.length > 0 || product.media.gallery.length > 0;
|
||||
const hasSeo = !!(product.seo.metaTitle.trim() && product.seo.metaDescription.trim());
|
||||
const hasPrice = product.price > 0;
|
||||
const hasCategory = !!product.categoryId;
|
||||
const hasDescription = product.shortDescription.trim().length > 0;
|
||||
const hasInventory = product.quantity > 0 || product.availability.trim().length > 0;
|
||||
const checks = [hasImages, hasSeo, hasPrice, hasCategory, hasDescription, hasInventory];
|
||||
const completionPercent = Math.round((checks.filter(Boolean).length / checks.length) * 100);
|
||||
return { hasImages, hasSeo, hasPrice, hasCategory, hasDescription, hasInventory, completionPercent };
|
||||
}
|
||||
|
||||
readonly dashboardStats = signal<AdminProductsDashboardStats | null>(null);
|
||||
|
||||
/** Dashboard stats must reflect the whole catalog, not just the current page - loads with a page size large enough to cover the mock catalog in one call. */
|
||||
loadDashboardStats(): void {
|
||||
this.gateway.loadProducts({ ...this.filters(), page: 1, pageSize: 100000, includeArchived: true, search: '', categoryId: null, visibility: 'all', stock: 'all' })
|
||||
.pipe(take(1))
|
||||
.subscribe({ next: result => this.dashboardStats.set(this.computeDashboardStats(result.items)) });
|
||||
}
|
||||
|
||||
private computeDashboardStats(products: AdminProduct[]): AdminProductsDashboardStats {
|
||||
const withHealth = products.map(product => ({ product, health: this.health(product) }));
|
||||
|
||||
const missingImages = withHealth.filter(entry => !entry.health.hasImages);
|
||||
const missingSeo = withHealth.filter(entry => !entry.health.hasSeo);
|
||||
const lowQuality = withHealth.filter(entry => entry.health.completionPercent < 50);
|
||||
const recentlyEdited = [...products].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 5);
|
||||
|
||||
let recommendation: AdminProductsDashboardStats['recommendation'];
|
||||
if (missingImages[0]) {
|
||||
recommendation = { labelKey: 'adminProducts.recommendAddImages', productId: missingImages[0].product.id };
|
||||
} else if (missingSeo[0]) {
|
||||
recommendation = { labelKey: 'adminProducts.recommendAddSeo', productId: missingSeo[0].product.id };
|
||||
} else if (products.some(p => p.stockStatus === 'out_of_stock')) {
|
||||
recommendation = { labelKey: 'adminProducts.recommendRestock', productId: products.find(p => p.stockStatus === 'out_of_stock')?.id };
|
||||
} else {
|
||||
recommendation = { labelKey: 'adminProducts.recommendNone' };
|
||||
}
|
||||
|
||||
return {
|
||||
total: products.length,
|
||||
published: products.filter(p => p.visible && !p.archived).length,
|
||||
drafts: products.filter(p => !p.visible && !p.archived).length,
|
||||
outOfStock: products.filter(p => p.stockStatus === 'out_of_stock').length,
|
||||
hidden: products.filter(p => !p.visible).length,
|
||||
missingImages: missingImages.length,
|
||||
missingSeo: missingSeo.length,
|
||||
lowQuality: lowQuality.length,
|
||||
recentlyEdited,
|
||||
recommendation,
|
||||
};
|
||||
}
|
||||
|
||||
startCreate(): void {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { LanguageService } from '../../../../services/language.service';
|
||||
selector: 'app-admin-product-editor-page',
|
||||
standalone: true,
|
||||
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()" [allProducts]="facade.products()" [locales]="facade.supportedLocales()" [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()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (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; }`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
|
||||
@@ -16,6 +16,11 @@ import { LanguageService } from '../../../../services/language.service';
|
||||
[selectedIds]="facade.selectedIds()"
|
||||
[loading]="facade.loading()"
|
||||
[infiniteScroll]="facade.infiniteScroll()"
|
||||
[viewMode]="facade.viewMode()"
|
||||
[density]="facade.density()"
|
||||
[visibleColumns]="facade.visibleColumns()"
|
||||
[dashboardStats]="facade.dashboardStats()"
|
||||
[health]="healthFn"
|
||||
(filtersChange)="facade.updateFilters($event)"
|
||||
(create)="create()"
|
||||
(edit)="edit($event)"
|
||||
@@ -28,7 +33,14 @@ import { LanguageService } from '../../../../services/language.service';
|
||||
(selectionChange)="facade.toggleSelection($event.id, $event.checked)"
|
||||
(selectAll)="facade.toggleAll($event)"
|
||||
(bulkVisibility)="facade.applyBulkVisibility($event)"
|
||||
(bulkDelete)="facade.applyBulkDelete()" />`,
|
||||
(bulkDelete)="facade.applyBulkDelete()"
|
||||
(bulkDuplicate)="facade.applyBulkDuplicate()"
|
||||
(bulkAssignCategory)="facade.applyBulkAssignCategory($event)"
|
||||
(bulkAssignTags)="facade.applyBulkAssignTags($event)"
|
||||
(bulkExport)="facade.exportSelectedAsCsv()"
|
||||
(viewModeChange)="facade.setViewMode($event)"
|
||||
(densityChange)="facade.setDensity($event)"
|
||||
(columnToggle)="facade.setColumnVisible($event.column, $event.visible)" />`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AdminProductsListPageComponent {
|
||||
@@ -36,9 +48,12 @@ export class AdminProductsListPageComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
readonly healthFn = (product: Parameters<AdminProductsFacade['health']>[0]) => this.facade.health(product);
|
||||
|
||||
constructor() {
|
||||
this.facade.loadCategories();
|
||||
this.facade.loadList();
|
||||
this.facade.loadDashboardStats();
|
||||
}
|
||||
|
||||
create(): void { this.facade.startCreate(); void this.router.navigate([this.lang(), 'backoffice', 'products', 'create']); }
|
||||
|
||||
Reference in New Issue
Block a user