(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]);
diff --git a/src/app/pages/subcategory-editor/subcategory-editor.component.html b/src/app/pages/subcategory-editor/subcategory-editor.component.html
index 1f7e543..4b194e9 100644
--- a/src/app/pages/subcategory-editor/subcategory-editor.component.html
+++ b/src/app/pages/subcategory-editor/subcategory-editor.component.html
@@ -36,13 +36,9 @@
ID
+ [value]="subcategory()!.id"
+ disabled>
{{ 'ID' | translate }}
- @if (!subcategory()!.id || subcategory()!.id.trim().length === 0) {
- ID is required
- }
diff --git a/src/app/pages/subcategory-editor/subcategory-editor.component.ts b/src/app/pages/subcategory-editor/subcategory-editor.component.ts
index 315f40f..4dfa203 100644
--- a/src/app/pages/subcategory-editor/subcategory-editor.component.ts
+++ b/src/app/pages/subcategory-editor/subcategory-editor.component.ts
@@ -1,17 +1,18 @@
-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';
@@ -30,7 +31,6 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
MatSlideToggleModule,
MatIconModule,
MatProgressSpinnerModule,
- MatSnackBarModule,
MatDialogModule,
MatTooltipModule,
LoadingSkeletonComponent,
@@ -42,18 +42,22 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
export class SubcategoryEditorComponent implements OnInit {
subcategory = signal(null);
loading = signal(true);
- saving = signal(false);
subcategoryId = signal('');
projectId = signal('');
+ /** 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 +69,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 +85,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 +100,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 +119,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 +146,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 +158,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 +168,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'));
}
});
}
diff --git a/src/app/services/api.service.ts b/src/app/services/api.service.ts
index 1ccbf96..c976dc4 100644
--- a/src/app/services/api.service.ts
+++ b/src/app/services/api.service.ts
@@ -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();
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 {
+ private handleError = (error: any): Observable => {
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 {
diff --git a/src/app/services/index.ts b/src/app/services/index.ts
index ffc1381..cb50045 100644
--- a/src/app/services/index.ts
+++ b/src/app/services/index.ts
@@ -1,3 +1,4 @@
export * from './api.service';
export * from './validation.service';
export * from './toast.service';
+export * from './language.service';
diff --git a/src/app/services/mock-data.service.ts b/src/app/services/mock-data.service.ts
index f1deb10..3211f6c 100644
--- a/src/app/services/mock-data.service.ts
+++ b/src/app/services/mock-data.service.ts
@@ -194,18 +194,24 @@ 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();
+
+ // 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}`],
tags: ['test'],
@@ -214,6 +220,7 @@ export class MockDataService {
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 {
- 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);
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 264f459..e4dd97c 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -4,6 +4,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
+ "rootDir": "./src",
"types": []
},
"include": [
diff --git a/tsconfig.spec.json b/tsconfig.spec.json
index d383706..1fad8dd 100644
--- a/tsconfig.spec.json
+++ b/tsconfig.spec.json
@@ -4,6 +4,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
+ "rootDir": "./",
"types": [
"vitest/globals"
]