optimisation
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
<div class="inline-items">
|
||||
<div class="inline-items-header">
|
||||
<span class="items-count">{{ totalCount() }} {{ 'ITEMS_COUNT' | translate }}</span>
|
||||
<button mat-mini-fab color="accent" (click)="addItem()" [matTooltip]="'CREATE_NEW_ITEM' | translate">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (items().length > 0) {
|
||||
<div class="inline-items-grid">
|
||||
@for (item of items(); track item.id) {
|
||||
<div class="inline-item-card" (click)="openItem(item.id)" [class.hidden-item]="!item.visible">
|
||||
<div class="inline-item-image">
|
||||
@if (item.imgs.length) {
|
||||
<img [src]="item.imgs[0]" [alt]="item.name" (error)="onImageError($event)">
|
||||
}
|
||||
<div class="no-image" [style.display]="item.imgs.length ? 'none' : 'flex'">
|
||||
<mat-icon>image</mat-icon>
|
||||
</div>
|
||||
@if (item.quantity === 0) {
|
||||
<div class="out-of-stock">{{ 'OUT_OF_STOCK' | translate }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="inline-item-info">
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
<div class="item-details">
|
||||
<span class="price">{{ item.price }} {{ item.currency }}</span>
|
||||
@if (item.discount > 0) {
|
||||
<span class="discount">-{{ item.discount }}%</span>
|
||||
}
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span class="qty">{{ 'QTY' | translate }}: {{ item.quantity }}</span>
|
||||
<mat-icon class="visibility-icon" [class.visible]="item.visible" [class.not-visible]="!item.visible">
|
||||
{{ item.visible ? 'visibility' : 'visibility_off' }}
|
||||
</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
class="delete-btn"
|
||||
(click)="deleteItem(item, $event)"
|
||||
[matTooltip]="'DELETE' | translate">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="inline-loading">
|
||||
<mat-spinner diameter="28"></mat-spinner>
|
||||
<span>{{ 'LOADING_MORE' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading() && items().length === 0) {
|
||||
<div class="inline-empty">
|
||||
<mat-icon>inventory_2</mat-icon>
|
||||
<span>{{ 'NO_ITEMS_FOUND' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!hasMore() && items().length > 0 && !loading()) {
|
||||
<div class="inline-end">{{ 'NO_MORE_ITEMS' | translate }}</div>
|
||||
}
|
||||
|
||||
<div #scrollSentinel class="scroll-sentinel"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,207 @@
|
||||
.inline-items {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inline-items-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
.items-count {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-items-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.inline-item-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: #1976d2;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.hidden-item {
|
||||
opacity: 0.6;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-item-image {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.no-image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #ccc;
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.out-of-stock {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
|
||||
.item-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
|
||||
.price {
|
||||
font-weight: 600;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.discount {
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
|
||||
.qty {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.visibility-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
&.visible { color: #4caf50; }
|
||||
&.not-visible { color: #f44336; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inline-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
|
||||
span {
|
||||
color: #666;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-end {
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
color: #bbb;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.scroll-sentinel {
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
Component, Input, OnChanges, SimpleChanges, AfterViewInit, OnDestroy,
|
||||
signal, ViewChild, ElementRef, DestroyRef, inject
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ApiService } from '../../services';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { Item } from '../../models';
|
||||
import { CreateDialogComponent } from '../create-dialog/create-dialog.component';
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-inline-items-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
MatTooltipModule,
|
||||
TranslatePipe
|
||||
],
|
||||
templateUrl: './inline-items-list.component.html',
|
||||
styleUrls: ['./inline-items-list.component.scss']
|
||||
})
|
||||
export class InlineItemsListComponent implements OnChanges, AfterViewInit, OnDestroy {
|
||||
@Input({ required: true }) subcategoryId!: string;
|
||||
@Input({ required: true }) projectId!: string;
|
||||
|
||||
items = signal<Item[]>([]);
|
||||
loading = signal(false);
|
||||
hasMore = signal(false);
|
||||
page = signal(1);
|
||||
totalCount = signal(0);
|
||||
|
||||
@ViewChild('scrollSentinel') scrollSentinel!: ElementRef;
|
||||
private intersectionObserver?: IntersectionObserver;
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private toast: ToastService,
|
||||
private dialog: MatDialog,
|
||||
public lang: LanguageService
|
||||
) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes['subcategoryId'] && this.subcategoryId) {
|
||||
this.page.set(1);
|
||||
this.items.set([]);
|
||||
this.loadItems();
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.setupObserver();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.intersectionObserver?.disconnect();
|
||||
}
|
||||
|
||||
private setupObserver() {
|
||||
this.intersectionObserver?.disconnect();
|
||||
this.intersectionObserver = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && this.hasMore() && !this.loading()) {
|
||||
this.loadItems(true);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '100px', threshold: 0 }
|
||||
);
|
||||
if (this.scrollSentinel?.nativeElement) {
|
||||
this.intersectionObserver.observe(this.scrollSentinel.nativeElement);
|
||||
}
|
||||
}
|
||||
|
||||
loadItems(append = false) {
|
||||
if (this.loading()) return;
|
||||
|
||||
this.loading.set(true);
|
||||
const currentPage = append ? this.page() + 1 : 1;
|
||||
|
||||
this.apiService.getItems(this.subcategoryId, currentPage, 20).subscribe({
|
||||
next: (response) => {
|
||||
if (append) {
|
||||
this.items.set([...this.items(), ...response.items]);
|
||||
} else {
|
||||
this.items.set(response.items);
|
||||
}
|
||||
this.page.set(currentPage);
|
||||
this.hasMore.set(response.hasMore);
|
||||
this.totalCount.set(response.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error(this.lang.t('FAILED_LOAD_ITEMS'));
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openItem(itemId: string) {
|
||||
this.router.navigate(['/project', this.projectId, 'item', itemId]);
|
||||
}
|
||||
|
||||
addItem() {
|
||||
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||
width: '500px',
|
||||
data: {
|
||||
title: this.lang.t('CREATE_NEW_ITEM'),
|
||||
fields: [
|
||||
{ name: 'name', label: this.lang.t('ITEM_NAME'), type: 'text', required: true },
|
||||
{ name: 'simpleDescription', label: this.lang.t('SIMPLE_DESCRIPTION'), type: 'text', required: false },
|
||||
{ name: 'price', label: this.lang.t('PRICE'), type: 'number', required: true },
|
||||
{
|
||||
name: 'currency', label: this.lang.t('CURRENCY'), type: 'select', required: true, value: 'USD',
|
||||
options: [
|
||||
{ value: 'USD', label: '🇺🇸 USD' },
|
||||
{ value: 'EUR', label: '🇪🇺 EUR' },
|
||||
{ value: 'RUB', label: '🇷🇺 RUB' },
|
||||
{ value: 'GBP', label: '🇬🇧 GBP' },
|
||||
{ value: 'UAH', label: '🇺🇦 UAH' }
|
||||
]
|
||||
},
|
||||
{ name: 'quantity', label: this.lang.t('QUANTITY'), type: 'number', required: true, value: 0 },
|
||||
{ name: 'visible', label: this.lang.t('VISIBLE'), type: 'toggle', required: false, value: true }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.apiService.createItem(this.subcategoryId, result).subscribe({
|
||||
next: () => {
|
||||
this.toast.success(this.lang.t('ITEM_CREATED'));
|
||||
this.page.set(1);
|
||||
this.items.set([]);
|
||||
this.loadItems();
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error(this.lang.t('FAILED_CREATE_ITEM'));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteItem(item: Item, event: Event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: this.lang.t('DELETE_ITEM'),
|
||||
message: `${this.lang.t('CONFIRM_DELETE')} "${item.name}"?`,
|
||||
confirmText: this.lang.t('DELETE'),
|
||||
cancelText: this.lang.t('CANCEL'),
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.apiService.deleteItem(item.id).subscribe({
|
||||
next: () => {
|
||||
this.toast.success(this.lang.t('ITEM_DELETED'));
|
||||
this.page.set(1);
|
||||
this.items.set([]);
|
||||
this.loadItems();
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error(this.lang.t('FAILED_DELETE_ITEM'));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.style.display = 'none';
|
||||
const parent = img.parentElement;
|
||||
if (parent) {
|
||||
const placeholder = parent.querySelector('.no-image') as HTMLElement | null;
|
||||
if (placeholder) placeholder.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { CommonModule } from '@angular/common';
|
||||
@for (item of [1,2,3,4,5]; track item) {
|
||||
<div class="skeleton-tree-item">
|
||||
<div class="skeleton-circle"></div>
|
||||
<div class="skeleton-line" [style.width]="getRandomWidth()"></div>
|
||||
<div class="skeleton-line" [style.width]="treeWidths[item - 1]"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -159,8 +159,8 @@ import { CommonModule } from '@angular/common';
|
||||
export class LoadingSkeletonComponent {
|
||||
@Input() type: 'tree' | 'card' | 'list' | 'form' = 'list';
|
||||
|
||||
getRandomWidth(): string {
|
||||
const widths = ['60%', '70%', '80%', '90%'];
|
||||
return widths[Math.floor(Math.random() * widths.length)];
|
||||
}
|
||||
/** Pre-computed widths so they don't change between CD cycles (NG0100). */
|
||||
readonly treeWidths = [1, 2, 3, 4, 5].map(
|
||||
(_, i) => ['60%', '80%', '70%', '90%', '75%'][i]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,22 +87,34 @@
|
||||
</div>
|
||||
|
||||
@if (category()!.subcategories?.length) {
|
||||
<mat-list>
|
||||
<mat-accordion multi>
|
||||
@for (sub of category()!.subcategories; track sub.id) {
|
||||
<mat-list-item (click)="openSubcategory(sub.id)">
|
||||
<span matListItemTitle>{{ sub.name }}</span>
|
||||
<span matListItemLine>{{ 'PRIORITY' | translate }}: {{ sub.priority }}</span>
|
||||
<button mat-icon-button matListItemMeta>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon class="sub-icon">folder</mat-icon>
|
||||
{{ sub.name }}
|
||||
</mat-panel-title>
|
||||
<mat-panel-description>
|
||||
{{ 'PRIORITY' | translate }}: {{ sub.priority }}
|
||||
<button mat-icon-button (click)="openSubcategory(sub.id); $event.stopPropagation()" [matTooltip]="'EDIT' | translate">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
</mat-list-item>
|
||||
</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
<app-inline-items-list
|
||||
[subcategoryId]="sub.id"
|
||||
[projectId]="projectId()">
|
||||
</app-inline-items-list>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-list>
|
||||
</mat-accordion>
|
||||
} @else {
|
||||
<p class="empty-state">{{ 'NO_SUBCATEGORIES' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Translations section hidden until client provides requirements
|
||||
<div class="translations-section">
|
||||
<h3>{{ 'TRANSLATIONS' | translate }}</h3>
|
||||
<p class="hint">{{ 'TRANSLATIONS_HINT' | translate }}</p>
|
||||
@@ -111,6 +123,7 @@
|
||||
<input matInput [(ngModel)]="ruName" (blur)="saveRuName(ruName)" [placeholder]="'NAME_TRANSLATED' | translate">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -124,12 +124,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
mat-list-item {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
mat-accordion {
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
mat-expansion-panel {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
.sub-icon {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
mat-panel-description {
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
|
||||
button {
|
||||
margin-right: -8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { ApiService } from '../../services';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
@@ -18,6 +19,7 @@ import { Category } from '../../models';
|
||||
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
import { InlineItemsListComponent } from '../../components/inline-items-list/inline-items-list.component';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
|
||||
@@ -36,7 +38,9 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
MatListModule,
|
||||
MatDialogModule,
|
||||
MatTooltipModule,
|
||||
MatExpansionModule,
|
||||
LoadingSkeletonComponent,
|
||||
InlineItemsListComponent,
|
||||
TranslatePipe
|
||||
],
|
||||
templateUrl: './category-editor.component.html',
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Translations Tab -->
|
||||
<!-- Translations Tab - hidden until client provides requirements
|
||||
<mat-tab [label]="'TRANSLATIONS' | translate">
|
||||
<div class="tab-content">
|
||||
<div class="translations-section">
|
||||
@@ -444,6 +444,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
-->
|
||||
</mat-tab-group>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -113,6 +113,11 @@ export class ItemEditorComponent implements OnInit {
|
||||
this.loading.set(true);
|
||||
this.apiService.getItem(this.itemId()).subscribe({
|
||||
next: (item) => {
|
||||
if (!item) {
|
||||
this.toast.error(this.lang.t('ITEM_NOT_FOUND'));
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
this.item.set(item);
|
||||
// Initialise Russian translation buffers
|
||||
const ru = item.translations?.['ru'];
|
||||
|
||||
@@ -99,19 +99,21 @@
|
||||
</div>
|
||||
|
||||
<div class="items-section">
|
||||
<h3>{{ 'VIEW_ITEMS' | translate }}</h3>
|
||||
@if (subcategory()!.subcategories?.length) {
|
||||
<p class="no-items-note">
|
||||
<mat-icon>account_tree</mat-icon>
|
||||
{{ 'SUBCATEGORIES' | translate }}
|
||||
</p>
|
||||
} @else {
|
||||
<button mat-raised-button color="primary" (click)="viewItems()">
|
||||
<mat-icon>{{ subcategory()!.hasItems ? 'list' : 'add' }}</mat-icon>
|
||||
{{ subcategory()!.hasItems ? (('VIEW_ITEMS' | translate) + ' (' + (subcategory()!.itemCount || 0) + ')') : ('ADD_SUBCATEGORY' | translate) }}
|
||||
</button>
|
||||
<app-inline-items-list
|
||||
[subcategoryId]="subcategoryId()"
|
||||
[projectId]="projectId()">
|
||||
</app-inline-items-list>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Translations section hidden until client provides requirements
|
||||
<div class="translations-section">
|
||||
<h3>{{ 'TRANSLATIONS' | translate }}</h3>
|
||||
<p class="hint">{{ 'TRANSLATIONS_HINT' | translate }}</p>
|
||||
@@ -120,6 +122,7 @@
|
||||
<input matInput [(ngModel)]="ruName" (blur)="saveRuName(ruName)" [placeholder]="'NAME_TRANSLATED' | translate">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -110,10 +110,10 @@
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-items-note {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ToastService } from '../../services/toast.service';
|
||||
import { Subcategory } from '../../models';
|
||||
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
import { InlineItemsListComponent } from '../../components/inline-items-list/inline-items-list.component';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
|
||||
@@ -34,6 +35,7 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
MatDialogModule,
|
||||
MatTooltipModule,
|
||||
LoadingSkeletonComponent,
|
||||
InlineItemsListComponent,
|
||||
TranslatePipe
|
||||
],
|
||||
templateUrl: './subcategory-editor.component.html',
|
||||
|
||||
@@ -12,14 +12,14 @@ export class MockDataService {
|
||||
name: 'dexar',
|
||||
displayName: 'Dexar Marketplace',
|
||||
active: true,
|
||||
logoUrl: 'https://via.placeholder.com/150?text=Dexar'
|
||||
logoUrl: 'https://placehold.co/150?text=Dexar'
|
||||
},
|
||||
{
|
||||
id: 'novo',
|
||||
name: 'novo',
|
||||
displayName: 'Novo Shop',
|
||||
active: true,
|
||||
logoUrl: 'https://via.placeholder.com/150?text=Novo'
|
||||
logoUrl: 'https://placehold.co/150?text=Novo'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -29,7 +29,7 @@ export class MockDataService {
|
||||
name: 'Electronics',
|
||||
visible: true,
|
||||
priority: 1,
|
||||
img: 'https://via.placeholder.com/400x300?text=Electronics',
|
||||
img: 'https://placehold.co/400x300?text=Electronics',
|
||||
projectId: 'dexar',
|
||||
subcategories: [
|
||||
{
|
||||
@@ -37,7 +37,7 @@ export class MockDataService {
|
||||
name: 'Smartphones',
|
||||
visible: true,
|
||||
priority: 1,
|
||||
img: 'https://via.placeholder.com/400x300?text=Smartphones',
|
||||
img: 'https://placehold.co/400x300?text=Smartphones',
|
||||
categoryId: 'cat1',
|
||||
itemCount: 15
|
||||
},
|
||||
@@ -46,7 +46,7 @@ export class MockDataService {
|
||||
name: 'Laptops',
|
||||
visible: true,
|
||||
priority: 2,
|
||||
img: 'https://via.placeholder.com/400x300?text=Laptops',
|
||||
img: 'https://placehold.co/400x300?text=Laptops',
|
||||
categoryId: 'cat1',
|
||||
itemCount: 12
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export class MockDataService {
|
||||
name: 'Clothing',
|
||||
visible: true,
|
||||
priority: 2,
|
||||
img: 'https://via.placeholder.com/400x300?text=Clothing',
|
||||
img: 'https://placehold.co/400x300?text=Clothing',
|
||||
projectId: 'dexar',
|
||||
subcategories: [
|
||||
{
|
||||
@@ -65,7 +65,7 @@ export class MockDataService {
|
||||
name: 'Men',
|
||||
visible: true,
|
||||
priority: 1,
|
||||
img: 'https://via.placeholder.com/400x300?text=Men',
|
||||
img: 'https://placehold.co/400x300?text=Men',
|
||||
categoryId: 'cat2',
|
||||
itemCount: 25
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export class MockDataService {
|
||||
name: 'Home & Garden',
|
||||
visible: false,
|
||||
priority: 3,
|
||||
img: 'https://via.placeholder.com/400x300?text=Home',
|
||||
img: 'https://placehold.co/400x300?text=Home',
|
||||
projectId: 'novo',
|
||||
subcategories: []
|
||||
}
|
||||
@@ -93,8 +93,8 @@ export class MockDataService {
|
||||
discount: 0,
|
||||
currency: 'USD',
|
||||
imgs: [
|
||||
'https://via.placeholder.com/600x400?text=iPhone+Front',
|
||||
'https://via.placeholder.com/600x400?text=iPhone+Back'
|
||||
'https://placehold.co/600x400?text=iPhone+Front',
|
||||
'https://placehold.co/600x400?text=iPhone+Back'
|
||||
],
|
||||
tags: ['new', 'featured', 'bestseller'],
|
||||
badges: ['new', 'featured'],
|
||||
@@ -124,7 +124,7 @@ export class MockDataService {
|
||||
price: 1199,
|
||||
discount: 10,
|
||||
currency: 'USD',
|
||||
imgs: ['https://via.placeholder.com/600x400?text=Samsung+S24'],
|
||||
imgs: ['https://placehold.co/600x400?text=Samsung+S24'],
|
||||
tags: ['new', 'android'],
|
||||
badges: ['new'],
|
||||
simpleDescription: 'Premium Samsung flagship with S Pen',
|
||||
@@ -144,7 +144,7 @@ export class MockDataService {
|
||||
price: 999,
|
||||
discount: 15,
|
||||
currency: 'USD',
|
||||
imgs: ['https://via.placeholder.com/600x400?text=Pixel+8'],
|
||||
imgs: ['https://placehold.co/600x400?text=Pixel+8'],
|
||||
tags: ['sale', 'android', 'ai'],
|
||||
badges: ['sale', 'hot'],
|
||||
simpleDescription: 'Best AI photography phone',
|
||||
@@ -163,7 +163,7 @@ export class MockDataService {
|
||||
price: 2499,
|
||||
discount: 0,
|
||||
currency: 'USD',
|
||||
imgs: ['https://via.placeholder.com/600x400?text=MacBook'],
|
||||
imgs: ['https://placehold.co/600x400?text=MacBook'],
|
||||
tags: ['featured', 'professional'],
|
||||
badges: ['exclusive'],
|
||||
simpleDescription: 'Powerful laptop for professionals',
|
||||
@@ -183,7 +183,7 @@ export class MockDataService {
|
||||
price: 1799,
|
||||
discount: 5,
|
||||
currency: 'USD',
|
||||
imgs: ['https://via.placeholder.com/600x400?text=Dell+XPS'],
|
||||
imgs: ['https://placehold.co/600x400?text=Dell+XPS'],
|
||||
tags: ['out-of-stock'],
|
||||
simpleDescription: 'Premium Windows laptop',
|
||||
description: [
|
||||
@@ -213,7 +213,7 @@ export class MockDataService {
|
||||
price: ((i * 13) % 1000) + 100,
|
||||
discount: i % 3 === 0 ? (i * 5) % 30 + 5 : 0,
|
||||
currency: 'USD',
|
||||
imgs: [`https://via.placeholder.com/600x400?text=Product+${i}`],
|
||||
imgs: [`https://placehold.co/600x400?text=Product+${i}`],
|
||||
tags: ['test'],
|
||||
simpleDescription: `This is test product number ${i}`,
|
||||
description: [{ key: 'Size', value: 'Medium' }],
|
||||
@@ -399,14 +399,26 @@ export class MockDataService {
|
||||
}
|
||||
|
||||
getItem(itemId: string): Observable<Item> {
|
||||
const item = this.items.find(i => i.id === itemId)!;
|
||||
return of(item).pipe(delay(200));
|
||||
let item = this.items.find(i => i.id === itemId);
|
||||
if (!item) {
|
||||
for (const generated of this.generatedItems.values()) {
|
||||
item = generated.find(i => i.id === itemId);
|
||||
if (item) break;
|
||||
}
|
||||
}
|
||||
return of(item!).pipe(delay(200));
|
||||
}
|
||||
|
||||
updateItem(itemId: string, data: Partial<Item>): Observable<Item> {
|
||||
const item = this.items.find(i => i.id === itemId)!;
|
||||
Object.assign(item, data);
|
||||
return of(item).pipe(delay(300));
|
||||
let item = this.items.find(i => i.id === itemId);
|
||||
if (!item) {
|
||||
for (const generated of this.generatedItems.values()) {
|
||||
item = generated.find(i => i.id === itemId);
|
||||
if (item) break;
|
||||
}
|
||||
}
|
||||
if (item) Object.assign(item, data);
|
||||
return of(item!).pipe(delay(300));
|
||||
}
|
||||
|
||||
createItem(subcategoryId: string, data: Partial<Item>): Observable<Item> {
|
||||
@@ -438,7 +450,6 @@ export class MockDataService {
|
||||
}
|
||||
|
||||
deleteItem(itemId: string): Observable<void> {
|
||||
const item = this.items.find(i => i.id === itemId);
|
||||
const index = this.items.findIndex(i => i.id === itemId);
|
||||
if (index > -1) {
|
||||
const subcategoryId = this.items[index].subcategoryId;
|
||||
@@ -452,13 +463,28 @@ export class MockDataService {
|
||||
subcategory.hasItems = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Also remove from generated items cache
|
||||
for (const [key, generated] of this.generatedItems.entries()) {
|
||||
const gi = generated.findIndex(i => i.id === itemId);
|
||||
if (gi > -1) {
|
||||
generated.splice(gi, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return of(void 0).pipe(delay(300));
|
||||
}
|
||||
|
||||
bulkUpdateItems(itemIds: string[], data: Partial<Item>): Observable<void> {
|
||||
itemIds.forEach(id => {
|
||||
const item = this.items.find(i => i.id === id);
|
||||
let item = this.items.find(i => i.id === id);
|
||||
if (!item) {
|
||||
for (const generated of this.generatedItems.values()) {
|
||||
item = generated.find(i => i.id === id);
|
||||
if (item) break;
|
||||
}
|
||||
}
|
||||
if (item) Object.assign(item, data);
|
||||
});
|
||||
return of(void 0).pipe(delay(400));
|
||||
@@ -466,7 +492,7 @@ export class MockDataService {
|
||||
|
||||
uploadImage(file: File): Observable<{ url: string }> {
|
||||
// Simulate upload
|
||||
const url = `https://via.placeholder.com/600x400?text=${encodeURIComponent(file.name)}`;
|
||||
const url = `https://placehold.co/600x400?text=${encodeURIComponent(file.name)}`;
|
||||
return of({ url }).pipe(delay(1000));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user