changes are done

This commit is contained in:
sdarbinyan
2026-03-01 02:40:42 +04:00
parent ffde301181
commit e32ee998c1
16 changed files with 246 additions and 184 deletions

View File

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

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,10 +52,12 @@ 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 = '';
@@ -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'));
}
});
}

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

View File

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