diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 9e94f7b..70bbb04 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -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'; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 31397b8..75042f8 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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 } ]; diff --git a/src/app/components/confirm-dialog/confirm-dialog.component.ts b/src/app/components/confirm-dialog/confirm-dialog.component.ts index fc89bb6..13877b7 100644 --- a/src/app/components/confirm-dialog/confirm-dialog.component.ts +++ b/src/app/components/confirm-dialog/confirm-dialog.component.ts @@ -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: `

- @if (data.warning) { + @if (data.dangerous) { warning } {{ data.title }} @@ -37,7 +37,7 @@ export interface ConfirmDialogData { diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 93b8db6..4a404ef 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -115,6 +115,34 @@ export const TRANSLATIONS: Record> = { 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> = { 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: 'Не удалось загрузить изображение', }, }; diff --git a/src/app/pages/category-editor/category-editor.component.ts b/src/app/pages/category-editor/category-editor.component.ts index e49b2b6..0f45bc2 100644 --- a/src/app/pages/category-editor/category-editor.component.ts +++ b/src/app/pages/category-editor/category-editor.component.ts @@ -1,18 +1,19 @@ -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 { 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'; @@ -32,7 +33,6 @@ import { TranslatePipe } from '../../pipes/translate.pipe'; MatSlideToggleModule, MatIconModule, MatProgressSpinnerModule, - MatSnackBarModule, MatListModule, MatDialogModule, MatTooltipModule, @@ -45,18 +45,22 @@ import { TranslatePipe } from '../../pipes/translate.pipe'; export class CategoryEditorComponent implements OnInit { category = signal(null); loading = signal(true); - saving = signal(false); categoryId = 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 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 +72,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 +88,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 +103,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 +122,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 +148,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 +162,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 +179,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 +191,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')); } }); } diff --git a/src/app/pages/item-editor/item-editor.component.ts b/src/app/pages/item-editor/item-editor.component.ts index 0feb541..3c4f34e 100644 --- a/src/app/pages/item-editor/item-editor.component.ts +++ b/src/app/pages/item-editor/item-editor.component.ts @@ -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,10 +52,12 @@ export class ItemEditorComponent implements OnInit { item = signal(null); subcategory = signal(null); loading = signal(true); - saving = signal(false); itemId = signal(''); projectId = signal(''); validationErrors = signal>({}); + + /** Whether the debounced save queue is in-flight */ + get saving() { return this.apiService.saving; } newTag = ''; newDescKey = ''; @@ -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(); }); @@ -117,7 +124,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 +154,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 +186,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 +278,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 +362,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 +374,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')); } }); } diff --git a/src/app/pages/items-list/items-list.component.ts b/src/app/pages/items-list/items-list.component.ts index 9ff9602..10f6e92 100644 --- a/src/app/pages/items-list/items-list.component.ts +++ b/src/app/pages/items-list/items-list.component.ts @@ -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(''); projectId = signal(''); + 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')); } }); } diff --git a/src/app/pages/project-view/project-view.component.ts b/src/app/pages/project-view/project-view.component.ts index d878c49..62bff64 100644 --- a/src/app/pages/project-view/project-view.component.ts +++ b/src/app/pages/project-view/project-view.component.ts @@ -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(''); - project = signal(null); + project = signal(null); categories = signal([]); loading = signal(true); treeData = signal([]); selectedNodeId = signal(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')); } }); } diff --git a/src/app/pages/projects-dashboard/projects-dashboard.component.ts b/src/app/pages/projects-dashboard/projects-dashboard.component.ts index 18d3908..196411c 100644 --- a/src/app/pages/projects-dashboard/projects-dashboard.component.ts +++ b/src/app/pages/projects-dashboard/projects-dashboard.component.ts @@ -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(null); currentProjectId = signal(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" ]