Compare commits

...

2 Commits

Author SHA1 Message Date
sdarbinyan
b71e806bca optimisation 2026-03-01 03:01:31 +04:00
sdarbinyan
e32ee998c1 changes are done 2026-03-01 02:40:42 +04:00
24 changed files with 849 additions and 235 deletions

View File

@@ -1,7 +1,7 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';

View File

@@ -1,57 +1,34 @@
import { Routes } from '@angular/router';
import { ProjectsDashboardComponent } from './pages/projects-dashboard/projects-dashboard.component';
import { ProjectViewComponent } from './pages/project-view/project-view.component';
import { CategoryEditorComponent } from './pages/category-editor/category-editor.component';
import { SubcategoryEditorComponent } from './pages/subcategory-editor/subcategory-editor.component';
import { ItemsListComponent } from './pages/items-list/items-list.component';
import { ItemEditorComponent } from './pages/item-editor/item-editor.component';
import { ItemPreviewComponent } from './pages/item-preview/item-preview.component';
export const routes: Routes = [
{
path: '',
component: ProjectsDashboardComponent
loadComponent: () => import('./pages/projects-dashboard/projects-dashboard.component').then(m => m.ProjectsDashboardComponent)
},
{
path: 'project/:projectId',
component: ProjectViewComponent,
loadComponent: () => import('./pages/project-view/project-view.component').then(m => m.ProjectViewComponent),
children: [
{
path: 'category/:categoryId',
component: CategoryEditorComponent
loadComponent: () => import('./pages/category-editor/category-editor.component').then(m => m.CategoryEditorComponent)
},
{
path: 'subcategory/:subcategoryId',
component: SubcategoryEditorComponent
loadComponent: () => import('./pages/subcategory-editor/subcategory-editor.component').then(m => m.SubcategoryEditorComponent)
},
{
path: 'items/:subcategoryId',
component: ItemsListComponent
loadComponent: () => import('./pages/items-list/items-list.component').then(m => m.ItemsListComponent)
},
{
path: 'item/:itemId',
component: ItemEditorComponent
loadComponent: () => import('./pages/item-editor/item-editor.component').then(m => m.ItemEditorComponent)
},
{
path: 'item/:itemId/preview',
component: ItemPreviewComponent
loadComponent: () => import('./pages/item-preview/item-preview.component').then(m => m.ItemPreviewComponent)
}
]
},
{
path: 'category/:categoryId',
component: CategoryEditorComponent
},
{
path: 'subcategory/:subcategoryId',
component: SubcategoryEditorComponent
},
{
path: 'items/:subcategoryId',
component: ItemsListComponent
},
{
path: 'item/:itemId',
component: ItemEditorComponent
}
];

View File

@@ -9,7 +9,7 @@ export interface ConfirmDialogData {
message: string;
confirmText?: string;
cancelText?: string;
warning?: boolean;
dangerous?: boolean;
}
@Component({
@@ -23,7 +23,7 @@ export interface ConfirmDialogData {
],
template: `
<h2 mat-dialog-title>
@if (data.warning) {
@if (data.dangerous) {
<mat-icon class="warning-icon">warning</mat-icon>
}
{{ data.title }}
@@ -37,7 +37,7 @@ export interface ConfirmDialogData {
</button>
<button
mat-raised-button
[color]="data.warning ? 'warn' : 'primary'"
[color]="data.dangerous ? 'warn' : 'primary'"
(click)="onConfirm()">
{{ data.confirmText || 'Confirm' }}
</button>

View File

@@ -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>

View File

@@ -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%;
}

View File

@@ -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';
}
}
}

View File

@@ -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]
);
}

View File

@@ -115,6 +115,34 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
ADD_DESC_ROW: 'Add Row',
NO_TRANSLATIONS: 'No Russian translation yet',
TRANSLATION_SAVED: 'Translation saved',
// --- CRUD / Toast messages ---
CONFIRM_DELETE: 'Are you sure you want to delete',
VALIDATION_ERROR: 'Validation error',
CREATE_NEW_CATEGORY: 'Create New Category',
CREATE_NEW_SUBCATEGORY: 'Create New Subcategory',
CREATE_NEW_ITEM: 'Create New Item',
DELETE_ITEM: 'Delete Item',
CATEGORY_CREATED: 'Category created!',
SUBCATEGORY_CREATED: 'Subcategory created!',
ITEM_CREATED: 'Item created!',
CATEGORY_DELETED: 'Category deleted',
SUBCATEGORY_DELETED: 'Subcategory deleted',
ITEM_DELETED: 'Item deleted',
UPDATED: 'Updated',
NO_ITEMS_SELECTED: 'No items selected',
FAILED_LOAD_CATEGORY: 'Failed to load category',
FAILED_LOAD_SUBCATEGORY: 'Failed to load subcategory',
FAILED_LOAD_ITEM: 'Failed to load item',
FAILED_LOAD_ITEMS: 'Failed to load items',
FAILED_CREATE_CATEGORY: 'Failed to create category',
FAILED_CREATE_SUBCATEGORY: 'Failed to create subcategory',
FAILED_CREATE_ITEM: 'Failed to create item',
FAILED_DELETE_CATEGORY: 'Failed to delete category',
FAILED_DELETE_SUBCATEGORY: 'Failed to delete subcategory',
FAILED_DELETE_ITEM: 'Failed to delete item',
FAILED_UPDATE_ITEMS: 'Failed to update items',
FAILED_UPLOAD_IMAGE: 'Failed to upload image',
},
ru: {
@@ -232,5 +260,33 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
ADD_DESC_ROW: 'Добавить строку',
NO_TRANSLATIONS: 'Русский перевод не заполнен',
TRANSLATION_SAVED: 'Перевод сохранён',
// --- CRUD / Toast messages ---
CONFIRM_DELETE: 'Вы уверены, что хотите удалить',
VALIDATION_ERROR: 'Ошибка валидации',
CREATE_NEW_CATEGORY: 'Создать категорию',
CREATE_NEW_SUBCATEGORY: 'Создать подкатегорию',
CREATE_NEW_ITEM: 'Создать товар',
DELETE_ITEM: 'Удалить товар',
CATEGORY_CREATED: 'Категория создана!',
SUBCATEGORY_CREATED: 'Подкатегория создана!',
ITEM_CREATED: 'Товар создан!',
CATEGORY_DELETED: 'Категория удалена',
SUBCATEGORY_DELETED: 'Подкатегория удалена',
ITEM_DELETED: 'Товар удалён',
UPDATED: 'Обновлено',
NO_ITEMS_SELECTED: 'Ничего не выбрано',
FAILED_LOAD_CATEGORY: 'Не удалось загрузить категорию',
FAILED_LOAD_SUBCATEGORY: 'Не удалось загрузить подкатегорию',
FAILED_LOAD_ITEM: 'Не удалось загрузить товар',
FAILED_LOAD_ITEMS: 'Не удалось загрузить товары',
FAILED_CREATE_CATEGORY: 'Не удалось создать категорию',
FAILED_CREATE_SUBCATEGORY: 'Не удалось создать подкатегорию',
FAILED_CREATE_ITEM: 'Не удалось создать товар',
FAILED_DELETE_CATEGORY: 'Не удалось удалить категорию',
FAILED_DELETE_SUBCATEGORY: 'Не удалось удалить подкатегорию',
FAILED_DELETE_ITEM: 'Не удалось удалить товар',
FAILED_UPDATE_ITEMS: 'Не удалось обновить товары',
FAILED_UPLOAD_IMAGE: 'Не удалось загрузить изображение',
},
};

View File

@@ -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>
</button>
</mat-list-item>
<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-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>

View File

@@ -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;
}
}
}

View File

@@ -1,22 +1,25 @@
import { Component, OnInit, signal, effect } from '@angular/core';
import { Component, OnInit, signal, effect, DestroyRef, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
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';
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';
@@ -32,11 +35,12 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
MatSlideToggleModule,
MatIconModule,
MatProgressSpinnerModule,
MatSnackBarModule,
MatListModule,
MatDialogModule,
MatTooltipModule,
MatExpansionModule,
LoadingSkeletonComponent,
InlineItemsListComponent,
TranslatePipe
],
templateUrl: './category-editor.component.html',
@@ -45,18 +49,22 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
export class CategoryEditorComponent implements OnInit {
category = signal<Category | null>(null);
loading = signal(true);
saving = signal(false);
categoryId = signal<string>('');
projectId = signal<string>('');
/** Whether the debounced save queue is in-flight */
get saving() { return this.apiService.saving; }
/** Local buffer for the Russian translation of the category name */
ruName = '';
private destroyRef = inject(DestroyRef);
constructor(
private route: ActivatedRoute,
private router: Router,
private apiService: ApiService,
private snackBar: MatSnackBar,
private toast: ToastService,
private dialog: MatDialog,
public lang: LanguageService
) {}
@@ -68,7 +76,7 @@ export class CategoryEditorComponent implements OnInit {
this.projectId.set(parentParams['projectId']);
}
this.route.params.subscribe(params => {
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => {
this.categoryId.set(params['categoryId']);
this.loadCategory();
});
@@ -84,7 +92,7 @@ export class CategoryEditorComponent implements OnInit {
},
error: (err) => {
console.error('Failed to load category', err);
this.snackBar.open('Failed to load category', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_LOAD_CATEGORY'));
this.loading.set(false);
}
});
@@ -99,13 +107,7 @@ export class CategoryEditorComponent implements OnInit {
}
onFieldChange(field: keyof Category, value: any) {
this.saving.set(true);
this.apiService.queueSave('category', this.categoryId(), field, value);
setTimeout(() => {
this.saving.set(false);
this.snackBar.open('Saved', '', { duration: 1000 });
}, 600);
}
async onImageSelect(event: Event, type: 'file' | 'url') {
@@ -124,8 +126,7 @@ export class CategoryEditorComponent implements OnInit {
}
},
error: (err) => {
this.snackBar.open('Failed to upload image', 'Close', { duration: 3000 });
this.saving.set(false);
this.toast.error(this.lang.t('FAILED_UPLOAD_IMAGE'));
}
});
} else if (type === 'url') {
@@ -151,12 +152,12 @@ export class CategoryEditorComponent implements OnInit {
addSubcategory() {
const dialogRef = this.dialog.open(CreateDialogComponent, {
data: {
title: 'Create New Subcategory',
title: this.lang.t('CREATE_NEW_SUBCATEGORY'),
type: 'subcategory',
fields: [
{ name: 'name', label: 'Name', type: 'text', required: true },
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
{ name: 'name', label: this.lang.t('NAME'), type: 'text', required: true },
{ name: 'priority', label: this.lang.t('PRIORITY'), type: 'number', value: 99 },
{ name: 'visible', label: this.lang.t('VISIBLE'), type: 'toggle', value: true }
]
}
});
@@ -165,11 +166,11 @@ export class CategoryEditorComponent implements OnInit {
if (result) {
this.apiService.createSubcategory(this.categoryId(), 'category', result).subscribe({
next: () => {
this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 });
this.toast.success(this.lang.t('SUBCATEGORY_CREATED'));
this.loadCategory();
},
error: (err) => {
this.snackBar.open('Failed to create subcategory', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_CREATE_SUBCATEGORY'));
}
});
}
@@ -182,10 +183,10 @@ export class CategoryEditorComponent implements OnInit {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Delete Category',
message: `Are you sure you want to delete "${cat.name}"? This will also delete all subcategories and items.`,
confirmText: 'Delete',
cancelText: 'Cancel',
title: this.lang.t('DELETE_CATEGORY'),
message: `${this.lang.t('CONFIRM_DELETE')} "${cat.name}"?`,
confirmText: this.lang.t('DELETE'),
cancelText: this.lang.t('CANCEL'),
dangerous: true
}
});
@@ -194,11 +195,11 @@ export class CategoryEditorComponent implements OnInit {
if (confirmed) {
this.apiService.deleteCategory(this.categoryId()).subscribe({
next: () => {
this.snackBar.open('Category deleted', 'Close', { duration: 2000 });
this.toast.success(this.lang.t('CATEGORY_DELETED'));
this.router.navigate(['/project', this.projectId()]);
},
error: (err) => {
this.snackBar.open('Failed to delete category', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_DELETE_CATEGORY'));
}
});
}

View File

@@ -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>

View File

@@ -1,7 +1,8 @@
import { Component, OnInit, signal } from '@angular/core';
import { Component, OnInit, signal, DestroyRef, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
@@ -16,6 +17,7 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { DragDropModule, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { ApiService } from '../../services';
import { ValidationService } from '../../services/validation.service';
import { ToastService } from '../../services/toast.service';
import { Item, ItemDescriptionField, Subcategory } from '../../models';
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
@@ -50,11 +52,13 @@ export class ItemEditorComponent implements OnInit {
item = signal<Item | null>(null);
subcategory = signal<Subcategory | null>(null);
loading = signal(true);
saving = signal(false);
itemId = signal<string>('');
projectId = signal<string>('');
validationErrors = signal<Record<string, string>>({});
/** Whether the debounced save queue is in-flight */
get saving() { return this.apiService.saving; }
newTag = '';
newDescKey = '';
newDescValue = '';
@@ -79,10 +83,13 @@ export class ItemEditorComponent implements OnInit {
newBadge = '';
private destroyRef = inject(DestroyRef);
constructor(
private route: ActivatedRoute,
private router: Router,
private apiService: ApiService,
private toast: ToastService,
private snackBar: MatSnackBar,
private dialog: MatDialog,
private validationService: ValidationService,
@@ -96,7 +103,7 @@ export class ItemEditorComponent implements OnInit {
this.projectId.set(parentParams['projectId']);
}
this.route.params.subscribe(params => {
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => {
this.itemId.set(params['itemId']);
this.loadItem();
});
@@ -106,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'];
@@ -117,7 +129,7 @@ export class ItemEditorComponent implements OnInit {
},
error: (err) => {
console.error('Failed to load item', err);
this.snackBar.open('Failed to load item', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_LOAD_ITEM'));
this.loading.set(false);
}
});
@@ -147,20 +159,14 @@ export class ItemEditorComponent implements OnInit {
if (errors[field]) {
currentErrors[field] = errors[field];
this.validationErrors.set(currentErrors);
this.snackBar.open(`Validation error: ${errors[field]}`, 'Close', { duration: 3000 });
this.toast.error(`${this.lang.t('VALIDATION_ERROR')}: ${errors[field]}`);
return;
} else {
delete currentErrors[field];
this.validationErrors.set(currentErrors);
}
this.saving.set(true);
this.apiService.queueSave('item', this.itemId(), field, value);
setTimeout(() => {
this.saving.set(false);
this.snackBar.open('Saved', '', { duration: 1000 });
}, 600);
}
// Image handling
@@ -185,7 +191,7 @@ export class ItemEditorComponent implements OnInit {
this.onFieldChange('imgs', updatedImgs);
}
} catch (err) {
this.snackBar.open('Failed to upload images', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_UPLOAD_IMAGE'));
} finally {
this.uploadingImages.set(false);
}
@@ -277,7 +283,7 @@ export class ItemEditorComponent implements OnInit {
description: this.ruDescFields.filter(f => f.key.trim() || f.value.trim()),
};
this.onFieldChange('translations' as any, currentItem.translations);
this.snackBar.open(this.lang.t('TRANSLATION_SAVED'), '', { duration: 2000 });
this.toast.success(this.lang.t('TRANSLATION_SAVED'));
}
addRuDescRow() {
@@ -361,10 +367,10 @@ export class ItemEditorComponent implements OnInit {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Delete Item',
message: `Are you sure you want to delete "${item.name}"? This action cannot be undone.`,
confirmText: 'Delete',
cancelText: 'Cancel',
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
}
});
@@ -373,12 +379,12 @@ export class ItemEditorComponent implements OnInit {
if (result) {
this.apiService.deleteItem(item.id).subscribe({
next: () => {
this.snackBar.open('Item deleted successfully', 'Close', { duration: 3000 });
this.toast.success(this.lang.t('ITEM_DELETED'));
this.router.navigate(['/project', this.projectId(), 'items', item.subcategoryId]);
},
error: (err: any) => {
console.error('Error deleting item:', err);
this.snackBar.open('Failed to delete item', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_DELETE_ITEM'));
}
});
}

View File

@@ -1,7 +1,8 @@
import { Component, OnInit, AfterViewInit, OnDestroy, signal, ViewChild, ElementRef } from '@angular/core';
import { Component, OnInit, AfterViewInit, OnDestroy, signal, ViewChild, ElementRef, DestroyRef, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
@@ -14,6 +15,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { ApiService } from '../../services';
import { ToastService } from '../../services/toast.service';
import { Item } from '../../models';
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
@@ -58,10 +60,13 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
subcategoryId = signal<string>('');
projectId = signal<string>('');
private destroyRef = inject(DestroyRef);
constructor(
private route: ActivatedRoute,
private router: Router,
private apiService: ApiService,
private toast: ToastService,
private snackBar: MatSnackBar,
private dialog: MatDialog,
public lang: LanguageService
@@ -74,7 +79,7 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
this.projectId.set(parentParams['projectId']);
}
this.route.params.subscribe(params => {
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => {
this.subcategoryId.set(params['subcategoryId']);
this.page.set(1);
this.items.set([]);
@@ -113,7 +118,7 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
},
error: (err) => {
console.error('Failed to load items', err);
this.snackBar.open('Failed to load items', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_LOAD_ITEMS'));
this.loading.set(false);
}
});
@@ -177,7 +182,7 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
bulkToggleVisibility(visible: boolean) {
const itemIds = Array.from(this.selectedItems());
if (!itemIds.length) {
this.snackBar.open('No items selected', 'Close', { duration: 2000 });
this.toast.warning(this.lang.t('NO_ITEMS_SELECTED'));
return;
}
@@ -188,11 +193,11 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
itemIds.includes(item.id) ? { ...item, visible } : item
)
);
this.snackBar.open(`Updated ${itemIds.length} items`, 'Close', { duration: 2000 });
this.toast.success(`${this.lang.t('UPDATED')} ${itemIds.length} ${this.lang.t('ITEMS_COUNT')}`);
this.selectedItems.set(new Set());
},
error: (err) => {
this.snackBar.open('Failed to update items', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_UPDATE_ITEMS'));
}
});
}
@@ -220,12 +225,12 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
const dialogRef = this.dialog.open(CreateDialogComponent, {
width: '500px',
data: {
title: 'Create New Item',
title: this.lang.t('CREATE_NEW_ITEM'),
fields: [
{ name: 'name', label: 'Item Name', type: 'text', required: true },
{ name: 'simpleDescription', label: 'Simple Description', type: 'text', required: false },
{ name: 'price', label: 'Price', type: 'number', required: true },
{ name: 'currency', label: 'Currency', type: 'select', required: true, value: 'USD',
{ 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' },
@@ -234,8 +239,8 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
{ value: 'UAH', label: '🇺🇦 UAH' }
]
},
{ name: 'quantity', label: 'Quantity', type: 'number', required: true, value: 0 },
{ name: 'visible', label: 'Visible', type: 'toggle', required: false, value: true }
{ 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 }
]
}
});
@@ -247,14 +252,14 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
this.apiService.createItem(subcategoryId, result).subscribe({
next: () => {
this.snackBar.open('Item created successfully', 'Close', { duration: 3000 });
this.toast.success(this.lang.t('ITEM_CREATED'));
this.page.set(1);
this.items.set([]);
this.loadItems();
},
error: (err) => {
console.error('Error creating item:', err);
this.snackBar.open('Failed to create item', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_CREATE_ITEM'));
}
});
}
@@ -266,10 +271,10 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Delete Item',
message: `Are you sure you want to delete "${item.name}"? This action cannot be undone.`,
confirmText: 'Delete',
cancelText: 'Cancel',
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
}
});
@@ -278,14 +283,14 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
if (result) {
this.apiService.deleteItem(item.id).subscribe({
next: () => {
this.snackBar.open('Item deleted successfully', 'Close', { duration: 3000 });
this.toast.success(this.lang.t('ITEM_DELETED'));
this.page.set(1);
this.items.set([]);
this.loadItems();
},
error: (err) => {
console.error('Error deleting item:', err);
this.snackBar.open('Failed to delete item', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_DELETE_ITEM'));
}
});
}

View File

@@ -1,7 +1,8 @@
import { Component, OnInit, signal, computed } from '@angular/core';
import { Component, OnInit, signal, computed, DestroyRef, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { CommonModule } from '@angular/common';
import { filter } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatTreeModule } from '@angular/material/tree';
import { MatIconModule } from '@angular/material/icon';
@@ -10,10 +11,10 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { ApiService } from '../../services';
import { ValidationService } from '../../services/validation.service';
import { Category, Subcategory } from '../../models';
import { ToastService } from '../../services/toast.service';
import { Category, Subcategory, Project } from '../../models';
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
@@ -47,7 +48,6 @@ interface CategoryNode {
MatToolbarModule,
MatProgressSpinnerModule,
MatDialogModule,
MatSnackBarModule,
MatTooltipModule,
LoadingSkeletonComponent,
TranslatePipe
@@ -57,31 +57,36 @@ interface CategoryNode {
})
export class ProjectViewComponent implements OnInit {
projectId = signal<string>('');
project = signal<any>(null);
project = signal<Project | null>(null);
categories = signal<Category[]>([]);
loading = signal(true);
treeData = signal<CategoryNode[]>([]);
selectedNodeId = signal<string | null>(null);
private destroyRef = inject(DestroyRef);
constructor(
private route: ActivatedRoute,
private router: Router,
private apiService: ApiService,
private dialog: MatDialog,
private snackBar: MatSnackBar,
private toast: ToastService,
private validationService: ValidationService,
public lang: LanguageService
) {}
ngOnInit() {
this.route.params.subscribe(params => {
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => {
this.projectId.set(params['projectId']);
this.loadProject();
this.loadCategories();
});
// Track selected route — filter to NavigationEnd so snapshot is fully resolved
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
this.router.events.pipe(
filter(e => e instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
const child = this.route.children[0]?.snapshot;
const subcategoryId = child?.params['subcategoryId'];
const categoryId = child?.params['categoryId'];
@@ -98,7 +103,7 @@ export class ProjectViewComponent implements OnInit {
this.apiService.getProjects().subscribe({
next: (projects) => {
const project = projects.find(p => p.id === this.projectId());
this.project.set(project);
this.project.set(project ?? null);
},
error: (err) => {
console.error('Failed to load project', err);
@@ -199,12 +204,12 @@ export class ProjectViewComponent implements OnInit {
addCategory() {
const dialogRef = this.dialog.open(CreateDialogComponent, {
data: {
title: 'Create New Category',
title: this.lang.t('CREATE_NEW_CATEGORY'),
type: 'category',
fields: [
{ name: 'name', label: 'Name', type: 'text', required: true },
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
{ name: 'name', label: this.lang.t('NAME'), type: 'text', required: true },
{ name: 'priority', label: this.lang.t('PRIORITY'), type: 'number', value: 99 },
{ name: 'visible', label: this.lang.t('VISIBLE'), type: 'toggle', value: true }
]
}
});
@@ -215,17 +220,17 @@ export class ProjectViewComponent implements OnInit {
const errors = this.validationService.validateCategoryOrSubcategory(result);
if (Object.keys(errors).length > 0) {
const errorMsg = Object.values(errors).join(', ');
this.snackBar.open(`Validation error: ${errorMsg}`, 'Close', { duration: 4000 });
this.toast.error(`${this.lang.t('VALIDATION_ERROR')}: ${errorMsg}`);
return;
}
this.apiService.createCategory(this.projectId(), result).subscribe({
next: () => {
this.snackBar.open('Category created!', 'Close', { duration: 2000 });
this.toast.success(this.lang.t('CATEGORY_CREATED'));
this.loadCategories();
},
error: (err) => {
this.snackBar.open(err.message || 'Failed to create category', 'Close', { duration: 3000 });
this.toast.error(err.message || this.lang.t('FAILED_CREATE_CATEGORY'));
}
});
}
@@ -237,13 +242,13 @@ export class ProjectViewComponent implements OnInit {
const dialogRef = this.dialog.open(CreateDialogComponent, {
data: {
title: 'Create New Subcategory',
title: this.lang.t('CREATE_NEW_SUBCATEGORY'),
type: 'subcategory',
fields: [
{ name: 'name', label: 'Name', type: 'text', required: true },
{ name: 'name', label: this.lang.t('NAME'), type: 'text', required: true },
{ name: 'id', label: 'ID', type: 'text', required: true, hint: 'Used for routing' },
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
{ name: 'priority', label: this.lang.t('PRIORITY'), type: 'number', value: 99 },
{ name: 'visible', label: this.lang.t('VISIBLE'), type: 'toggle', value: true }
]
}
});
@@ -254,18 +259,18 @@ export class ProjectViewComponent implements OnInit {
const errors = this.validationService.validateCategoryOrSubcategory(result);
if (Object.keys(errors).length > 0) {
const errorMsg = Object.values(errors).join(', ');
this.snackBar.open(`Validation error: ${errorMsg}`, 'Close', { duration: 4000 });
this.toast.error(`${this.lang.t('VALIDATION_ERROR')}: ${errorMsg}`);
return;
}
const parentType = parentNode.type === 'category' ? 'category' : 'subcategory';
this.apiService.createSubcategory(parentNode.id, parentType, result).subscribe({
next: () => {
this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 });
this.toast.success(this.lang.t('SUBCATEGORY_CREATED'));
this.loadCategories();
},
error: (err) => {
this.snackBar.open(err.message || 'Failed to create subcategory', 'Close', { duration: 3000 });
this.toast.error(err.message || this.lang.t('FAILED_CREATE_SUBCATEGORY'));
}
});
}
@@ -282,10 +287,10 @@ export class ProjectViewComponent implements OnInit {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Delete Category',
title: this.lang.t('DELETE_CATEGORY'),
message: message,
confirmText: 'Delete',
cancelText: 'Cancel',
confirmText: this.lang.t('DELETE'),
cancelText: this.lang.t('CANCEL'),
dangerous: true
}
});
@@ -294,11 +299,11 @@ export class ProjectViewComponent implements OnInit {
if (confirmed) {
this.apiService.deleteCategory(node.id).subscribe({
next: () => {
this.snackBar.open('Category deleted', 'Close', { duration: 2000 });
this.toast.success(this.lang.t('CATEGORY_DELETED'));
this.loadCategories();
},
error: (err) => {
this.snackBar.open('Failed to delete category', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_DELETE_CATEGORY'));
}
});
}
@@ -323,10 +328,10 @@ export class ProjectViewComponent implements OnInit {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Delete Subcategory',
title: this.lang.t('DELETE_SUBCATEGORY'),
message: message,
confirmText: 'Delete',
cancelText: 'Cancel',
confirmText: this.lang.t('DELETE'),
cancelText: this.lang.t('CANCEL'),
dangerous: true
}
});
@@ -335,11 +340,11 @@ export class ProjectViewComponent implements OnInit {
if (confirmed) {
this.apiService.deleteSubcategory(node.id).subscribe({
next: () => {
this.snackBar.open('Subcategory deleted', 'Close', { duration: 2000 });
this.toast.success(this.lang.t('SUBCATEGORY_DELETED'));
this.loadCategories();
},
error: (err) => {
this.snackBar.open('Failed to delete subcategory', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_DELETE_SUBCATEGORY'));
}
});
}

View File

@@ -1,8 +1,9 @@
import { Component, OnInit, signal } from '@angular/core';
import { Component, OnInit, signal, DestroyRef, inject } from '@angular/core';
import { Router } from '@angular/router';
import { CommonModule } from '@angular/common';
import { MatCardModule } from '@angular/material/card';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ApiService } from '../../services';
import { Project } from '../../models';
import { LanguageService } from '../../services/language.service';
@@ -21,6 +22,8 @@ export class ProjectsDashboardComponent implements OnInit {
error = signal<string | null>(null);
currentProjectId = signal<string | null>(null);
private destroyRef = inject(DestroyRef);
constructor(
private apiService: ApiService,
private router: Router,
@@ -37,7 +40,7 @@ export class ProjectsDashboardComponent implements OnInit {
}
// Listen to route changes
this.router.events.subscribe(() => {
this.router.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
const segments = this.router.url.split('/');
if (segments[1] === 'project' && segments[2]) {
this.currentProjectId.set(segments[2]);

View File

@@ -36,13 +36,9 @@
<mat-label>ID</mat-label>
<input
matInput
[(ngModel)]="subcategory()!.id"
(blur)="onFieldChange('id', subcategory()!.id)"
required>
[value]="subcategory()!.id"
disabled>
<mat-hint>{{ 'ID' | translate }}</mat-hint>
@if (!subcategory()!.id || subcategory()!.id.trim().length === 0) {
<mat-error>ID is required</mat-error>
}
</mat-form-field>
<div class="form-row">
@@ -103,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>
@@ -124,6 +122,7 @@
<input matInput [(ngModel)]="ruName" (blur)="saveRuName(ruName)" [placeholder]="'NAME_TRANSLATED' | translate">
</mat-form-field>
</div>
-->
</div>
}
</div>

View File

@@ -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 {

View File

@@ -1,20 +1,22 @@
import { Component, OnInit, signal } from '@angular/core';
import { Component, OnInit, signal, DestroyRef, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { ApiService } from '../../services';
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';
@@ -30,10 +32,10 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
MatSlideToggleModule,
MatIconModule,
MatProgressSpinnerModule,
MatSnackBarModule,
MatDialogModule,
MatTooltipModule,
LoadingSkeletonComponent,
InlineItemsListComponent,
TranslatePipe
],
templateUrl: './subcategory-editor.component.html',
@@ -42,18 +44,22 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
export class SubcategoryEditorComponent implements OnInit {
subcategory = signal<Subcategory | null>(null);
loading = signal(true);
saving = signal(false);
subcategoryId = signal<string>('');
projectId = signal<string>('');
/** Whether the debounced save queue is in-flight */
get saving() { return this.apiService.saving; }
/** Local buffer for the Russian translation of the subcategory name */
ruName = '';
private destroyRef = inject(DestroyRef);
constructor(
private route: ActivatedRoute,
private router: Router,
private apiService: ApiService,
private snackBar: MatSnackBar,
private toast: ToastService,
private dialog: MatDialog,
public lang: LanguageService
) {}
@@ -65,7 +71,7 @@ export class SubcategoryEditorComponent implements OnInit {
this.projectId.set(parentParams['projectId']);
}
this.route.params.subscribe(params => {
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => {
this.subcategoryId.set(params['subcategoryId']);
this.loadSubcategory();
});
@@ -81,7 +87,7 @@ export class SubcategoryEditorComponent implements OnInit {
},
error: (err) => {
console.error('Failed to load subcategory', err);
this.snackBar.open('Failed to load subcategory', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_LOAD_SUBCATEGORY'));
this.loading.set(false);
}
});
@@ -96,13 +102,7 @@ export class SubcategoryEditorComponent implements OnInit {
}
onFieldChange(field: keyof Subcategory, value: any) {
this.saving.set(true);
this.apiService.queueSave('subcategory', this.subcategoryId(), field, value);
setTimeout(() => {
this.saving.set(false);
this.snackBar.open('Saved', '', { duration: 1000 });
}, 600);
}
async onImageSelect(event: Event, type: 'file' | 'url') {
@@ -121,8 +121,7 @@ export class SubcategoryEditorComponent implements OnInit {
}
},
error: (err) => {
this.snackBar.open('Failed to upload image', 'Close', { duration: 3000 });
this.saving.set(false);
this.toast.error(this.lang.t('FAILED_UPLOAD_IMAGE'));
}
});
} else if (type === 'url') {
@@ -149,10 +148,10 @@ export class SubcategoryEditorComponent implements OnInit {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Delete Subcategory',
message: `Are you sure you want to delete "${sub.name}"? This will also delete all items in this subcategory.`,
confirmText: 'Delete',
cancelText: 'Cancel',
title: this.lang.t('DELETE_SUBCATEGORY'),
message: `${this.lang.t('CONFIRM_DELETE')} "${sub.name}"?`,
confirmText: this.lang.t('DELETE'),
cancelText: this.lang.t('CANCEL'),
dangerous: true
}
});
@@ -161,7 +160,7 @@ export class SubcategoryEditorComponent implements OnInit {
if (result) {
this.apiService.deleteSubcategory(sub.id).subscribe({
next: () => {
this.snackBar.open('Subcategory deleted successfully', 'Close', { duration: 3000 });
this.toast.success(this.lang.t('SUBCATEGORY_DELETED'));
// Navigate to the direct parent (subcategory) if parentId exists, otherwise the root category
if (sub.parentId && sub.parentId !== sub.categoryId) {
this.router.navigate(['/project', this.projectId(), 'subcategory', sub.parentId]);
@@ -171,7 +170,7 @@ export class SubcategoryEditorComponent implements OnInit {
},
error: (err: any) => {
console.error('Error deleting subcategory:', err);
this.snackBar.open('Failed to delete subcategory', 'Close', { duration: 3000 });
this.toast.error(this.lang.t('FAILED_DELETE_SUBCATEGORY'));
}
});
}

View File

@@ -1,9 +1,10 @@
import { Injectable, inject } from '@angular/core';
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, Subject, timer } from 'rxjs';
import { debounce, retry, catchError, tap, map } from 'rxjs/operators';
import { Observable, Subject, throwError } from 'rxjs';
import { debounceTime, retry, catchError, map, groupBy, mergeMap } from 'rxjs/operators';
import { Project, Category, Subcategory, Item, ItemsListResponse } from '../models';
import { MockDataService } from './mock-data.service';
import { ToastService } from './toast.service';
import { environment } from '../../environments/environment';
@Injectable({
@@ -12,15 +13,22 @@ import { environment } from '../../environments/environment';
export class ApiService {
private http = inject(HttpClient);
private mockService = inject(MockDataService);
private toast = inject(ToastService);
private readonly API_BASE = environment.apiUrl;
/** Whether a debounced save is in-flight */
saving = signal(false);
// Debounced save queue
private saveQueue$ = new Subject<SaveOperation>();
constructor() {
// Set up auto-save with 500ms debounce
// Debounce per unique type+id+field so independent fields don't clobber each other
this.saveQueue$
.pipe(debounce(() => timer(500)))
.pipe(
groupBy(op => `${op.type}:${op.id}:${op.field}`),
mergeMap(group$ => group$.pipe(debounceTime(500)))
)
.subscribe(operation => {
this.executeSave(operation);
});
@@ -198,7 +206,8 @@ export class ApiService {
}
// Debounced auto-save
queueSave(type: 'category' | 'subcategory' | 'item', id: string, field: string, value: any) {
queueSave(type: 'category' | 'subcategory' | 'item', id: string, field: string, value: unknown) {
this.saving.set(true);
this.saveQueue$.next({ type, id, field, value });
}
@@ -219,12 +228,18 @@ export class ApiService {
}
request.subscribe({
next: () => console.log(`Saved ${operation.type} ${operation.id} - ${operation.field}`),
error: (err) => console.error(`Failed to save ${operation.type}`, err)
next: () => {
this.saving.set(false);
this.toast.success('Saved');
},
error: (err) => {
this.saving.set(false);
this.toast.error(err.message || 'Failed to save');
}
});
}
private handleError(error: any): Observable<never> {
private handleError = (error: any): Observable<never> => {
let errorMessage = 'An unexpected error occurred';
if (error.error instanceof ErrorEvent) {
@@ -269,15 +284,15 @@ export class ApiService {
url: error.url
});
throw { message: errorMessage, status: error.status, originalError: error };
}
return throwError(() => ({ message: errorMessage, status: error.status, originalError: error }));
};
}
interface SaveOperation {
type: 'category' | 'subcategory' | 'item';
id: string;
field: string;
value: any;
value: unknown;
}
interface ItemFilters {

View File

@@ -1,3 +1,4 @@
export * from './api.service';
export * from './validation.service';
export * from './toast.service';
export * from './language.service';

View File

@@ -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: [
@@ -194,26 +194,33 @@ export class MockDataService {
}
];
// Generate more items for testing infinite scroll
private generateMoreItems(subcategoryId: string, count: number): Item[] {
// Cache for generated test items so pagination is stable
private generatedItems = new Map<string, Item[]>();
// Generate more items for testing infinite scroll (cached per subcategory)
private getGeneratedItems(subcategoryId: string, count: number): Item[] {
if (this.generatedItems.has(subcategoryId)) {
return this.generatedItems.get(subcategoryId)!;
}
const items: Item[] = [];
for (let i = 6; i <= count + 5; i++) {
items.push({
id: `item${i}`,
id: `${subcategoryId}-item${i}`,
name: `Test Product ${i}`,
visible: Math.random() > 0.3,
visible: i % 4 !== 0,
priority: i,
quantity: Math.floor(Math.random() * 100),
price: Math.floor(Math.random() * 1000) + 100,
discount: Math.random() > 0.7 ? Math.floor(Math.random() * 30) + 5 : 0,
quantity: (i * 7) % 100,
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' }],
subcategoryId
});
}
this.generatedItems.set(subcategoryId, items);
return items;
}
@@ -360,7 +367,7 @@ export class MockDataService {
}
getItems(subcategoryId: string, page = 1, limit = 20, search?: string, filters?: any): Observable<ItemsListResponse> {
let allItems = [...this.items, ...this.generateMoreItems(subcategoryId, 50)];
let allItems = [...this.items, ...this.getGeneratedItems(subcategoryId, 50)];
// Filter by subcategory
allItems = allItems.filter(item => item.subcategoryId === subcategoryId);
@@ -392,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> {
@@ -431,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;
@@ -445,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));
@@ -459,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));
}
}

View File

@@ -4,6 +4,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"rootDir": "./src",
"types": []
},
"include": [

View File

@@ -4,6 +4,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"rootDir": "./",
"types": [
"vitest/globals"
]