changes are done
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
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';
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,34 @@
|
|||||||
import { Routes } from '@angular/router';
|
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 = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: ProjectsDashboardComponent
|
loadComponent: () => import('./pages/projects-dashboard/projects-dashboard.component').then(m => m.ProjectsDashboardComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'project/:projectId',
|
path: 'project/:projectId',
|
||||||
component: ProjectViewComponent,
|
loadComponent: () => import('./pages/project-view/project-view.component').then(m => m.ProjectViewComponent),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'category/:categoryId',
|
path: 'category/:categoryId',
|
||||||
component: CategoryEditorComponent
|
loadComponent: () => import('./pages/category-editor/category-editor.component').then(m => m.CategoryEditorComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'subcategory/:subcategoryId',
|
path: 'subcategory/:subcategoryId',
|
||||||
component: SubcategoryEditorComponent
|
loadComponent: () => import('./pages/subcategory-editor/subcategory-editor.component').then(m => m.SubcategoryEditorComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'items/:subcategoryId',
|
path: 'items/:subcategoryId',
|
||||||
component: ItemsListComponent
|
loadComponent: () => import('./pages/items-list/items-list.component').then(m => m.ItemsListComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'item/:itemId',
|
path: 'item/:itemId',
|
||||||
component: ItemEditorComponent
|
loadComponent: () => import('./pages/item-editor/item-editor.component').then(m => m.ItemEditorComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'item/:itemId/preview',
|
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
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface ConfirmDialogData {
|
|||||||
message: string;
|
message: string;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
warning?: boolean;
|
dangerous?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -23,7 +23,7 @@ export interface ConfirmDialogData {
|
|||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<h2 mat-dialog-title>
|
<h2 mat-dialog-title>
|
||||||
@if (data.warning) {
|
@if (data.dangerous) {
|
||||||
<mat-icon class="warning-icon">warning</mat-icon>
|
<mat-icon class="warning-icon">warning</mat-icon>
|
||||||
}
|
}
|
||||||
{{ data.title }}
|
{{ data.title }}
|
||||||
@@ -37,7 +37,7 @@ export interface ConfirmDialogData {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-raised-button
|
mat-raised-button
|
||||||
[color]="data.warning ? 'warn' : 'primary'"
|
[color]="data.dangerous ? 'warn' : 'primary'"
|
||||||
(click)="onConfirm()">
|
(click)="onConfirm()">
|
||||||
{{ data.confirmText || 'Confirm' }}
|
{{ data.confirmText || 'Confirm' }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -115,6 +115,34 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
|||||||
ADD_DESC_ROW: 'Add Row',
|
ADD_DESC_ROW: 'Add Row',
|
||||||
NO_TRANSLATIONS: 'No Russian translation yet',
|
NO_TRANSLATIONS: 'No Russian translation yet',
|
||||||
TRANSLATION_SAVED: 'Translation saved',
|
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: {
|
ru: {
|
||||||
@@ -232,5 +260,33 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
|||||||
ADD_DESC_ROW: 'Добавить строку',
|
ADD_DESC_ROW: 'Добавить строку',
|
||||||
NO_TRANSLATIONS: 'Русский перевод не заполнен',
|
NO_TRANSLATIONS: 'Русский перевод не заполнен',
|
||||||
TRANSLATION_SAVED: 'Перевод сохранён',
|
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: 'Не удалось загрузить изображение',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
||||||
import { MatListModule } from '@angular/material/list';
|
import { MatListModule } from '@angular/material/list';
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { ApiService } from '../../services';
|
import { ApiService } from '../../services';
|
||||||
|
import { ToastService } from '../../services/toast.service';
|
||||||
import { Category } from '../../models';
|
import { Category } from '../../models';
|
||||||
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||||
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||||
@@ -32,7 +33,6 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
|||||||
MatSlideToggleModule,
|
MatSlideToggleModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
MatSnackBarModule,
|
|
||||||
MatListModule,
|
MatListModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatTooltipModule,
|
MatTooltipModule,
|
||||||
@@ -45,18 +45,22 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
|||||||
export class CategoryEditorComponent implements OnInit {
|
export class CategoryEditorComponent implements OnInit {
|
||||||
category = signal<Category | null>(null);
|
category = signal<Category | null>(null);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
saving = signal(false);
|
|
||||||
categoryId = signal<string>('');
|
categoryId = signal<string>('');
|
||||||
projectId = 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 */
|
/** Local buffer for the Russian translation of the category name */
|
||||||
ruName = '';
|
ruName = '';
|
||||||
|
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private snackBar: MatSnackBar,
|
private toast: ToastService,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
public lang: LanguageService
|
public lang: LanguageService
|
||||||
) {}
|
) {}
|
||||||
@@ -68,7 +72,7 @@ export class CategoryEditorComponent implements OnInit {
|
|||||||
this.projectId.set(parentParams['projectId']);
|
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.categoryId.set(params['categoryId']);
|
||||||
this.loadCategory();
|
this.loadCategory();
|
||||||
});
|
});
|
||||||
@@ -84,7 +88,7 @@ export class CategoryEditorComponent implements OnInit {
|
|||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Failed to load category', 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);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -99,13 +103,7 @@ export class CategoryEditorComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onFieldChange(field: keyof Category, value: any) {
|
onFieldChange(field: keyof Category, value: any) {
|
||||||
this.saving.set(true);
|
|
||||||
this.apiService.queueSave('category', this.categoryId(), field, value);
|
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') {
|
async onImageSelect(event: Event, type: 'file' | 'url') {
|
||||||
@@ -124,8 +122,7 @@ export class CategoryEditorComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.snackBar.open('Failed to upload image', 'Close', { duration: 3000 });
|
this.toast.error(this.lang.t('FAILED_UPLOAD_IMAGE'));
|
||||||
this.saving.set(false);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (type === 'url') {
|
} else if (type === 'url') {
|
||||||
@@ -151,12 +148,12 @@ export class CategoryEditorComponent implements OnInit {
|
|||||||
addSubcategory() {
|
addSubcategory() {
|
||||||
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||||
data: {
|
data: {
|
||||||
title: 'Create New Subcategory',
|
title: this.lang.t('CREATE_NEW_SUBCATEGORY'),
|
||||||
type: 'subcategory',
|
type: 'subcategory',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
{ name: 'name', label: this.lang.t('NAME'), type: 'text', required: true },
|
||||||
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
|
{ name: 'priority', label: this.lang.t('PRIORITY'), type: 'number', value: 99 },
|
||||||
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
|
{ name: 'visible', label: this.lang.t('VISIBLE'), type: 'toggle', value: true }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -165,11 +162,11 @@ export class CategoryEditorComponent implements OnInit {
|
|||||||
if (result) {
|
if (result) {
|
||||||
this.apiService.createSubcategory(this.categoryId(), 'category', result).subscribe({
|
this.apiService.createSubcategory(this.categoryId(), 'category', result).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 });
|
this.toast.success(this.lang.t('SUBCATEGORY_CREATED'));
|
||||||
this.loadCategory();
|
this.loadCategory();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
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, {
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
data: {
|
data: {
|
||||||
title: 'Delete Category',
|
title: this.lang.t('DELETE_CATEGORY'),
|
||||||
message: `Are you sure you want to delete "${cat.name}"? This will also delete all subcategories and items.`,
|
message: `${this.lang.t('CONFIRM_DELETE')} "${cat.name}"?`,
|
||||||
confirmText: 'Delete',
|
confirmText: this.lang.t('DELETE'),
|
||||||
cancelText: 'Cancel',
|
cancelText: this.lang.t('CANCEL'),
|
||||||
dangerous: true
|
dangerous: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -194,11 +191,11 @@ export class CategoryEditorComponent implements OnInit {
|
|||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.apiService.deleteCategory(this.categoryId()).subscribe({
|
this.apiService.deleteCategory(this.categoryId()).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackBar.open('Category deleted', 'Close', { duration: 2000 });
|
this.toast.success(this.lang.t('CATEGORY_DELETED'));
|
||||||
this.router.navigate(['/project', this.projectId()]);
|
this.router.navigate(['/project', this.projectId()]);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.snackBar.open('Failed to delete category', 'Close', { duration: 3000 });
|
this.toast.error(this.lang.t('FAILED_DELETE_CATEGORY'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
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 { DragDropModule, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||||
import { ApiService } from '../../services';
|
import { ApiService } from '../../services';
|
||||||
import { ValidationService } from '../../services/validation.service';
|
import { ValidationService } from '../../services/validation.service';
|
||||||
|
import { ToastService } from '../../services/toast.service';
|
||||||
import { Item, ItemDescriptionField, Subcategory } from '../../models';
|
import { Item, ItemDescriptionField, Subcategory } from '../../models';
|
||||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||||
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||||
@@ -50,10 +52,12 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
item = signal<Item | null>(null);
|
item = signal<Item | null>(null);
|
||||||
subcategory = signal<Subcategory | null>(null);
|
subcategory = signal<Subcategory | null>(null);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
saving = signal(false);
|
|
||||||
itemId = signal<string>('');
|
itemId = signal<string>('');
|
||||||
projectId = signal<string>('');
|
projectId = signal<string>('');
|
||||||
validationErrors = signal<Record<string, string>>({});
|
validationErrors = signal<Record<string, string>>({});
|
||||||
|
|
||||||
|
/** Whether the debounced save queue is in-flight */
|
||||||
|
get saving() { return this.apiService.saving; }
|
||||||
|
|
||||||
newTag = '';
|
newTag = '';
|
||||||
newDescKey = '';
|
newDescKey = '';
|
||||||
@@ -79,10 +83,13 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
|
|
||||||
newBadge = '';
|
newBadge = '';
|
||||||
|
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
|
private toast: ToastService,
|
||||||
private snackBar: MatSnackBar,
|
private snackBar: MatSnackBar,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
private validationService: ValidationService,
|
private validationService: ValidationService,
|
||||||
@@ -96,7 +103,7 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
this.projectId.set(parentParams['projectId']);
|
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.itemId.set(params['itemId']);
|
||||||
this.loadItem();
|
this.loadItem();
|
||||||
});
|
});
|
||||||
@@ -117,7 +124,7 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Failed to load item', 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);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -147,20 +154,14 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
if (errors[field]) {
|
if (errors[field]) {
|
||||||
currentErrors[field] = errors[field];
|
currentErrors[field] = errors[field];
|
||||||
this.validationErrors.set(currentErrors);
|
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;
|
return;
|
||||||
} else {
|
} else {
|
||||||
delete currentErrors[field];
|
delete currentErrors[field];
|
||||||
this.validationErrors.set(currentErrors);
|
this.validationErrors.set(currentErrors);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.saving.set(true);
|
|
||||||
this.apiService.queueSave('item', this.itemId(), field, value);
|
this.apiService.queueSave('item', this.itemId(), field, value);
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.saving.set(false);
|
|
||||||
this.snackBar.open('Saved', '', { duration: 1000 });
|
|
||||||
}, 600);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image handling
|
// Image handling
|
||||||
@@ -185,7 +186,7 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
this.onFieldChange('imgs', updatedImgs);
|
this.onFieldChange('imgs', updatedImgs);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.snackBar.open('Failed to upload images', 'Close', { duration: 3000 });
|
this.toast.error(this.lang.t('FAILED_UPLOAD_IMAGE'));
|
||||||
} finally {
|
} finally {
|
||||||
this.uploadingImages.set(false);
|
this.uploadingImages.set(false);
|
||||||
}
|
}
|
||||||
@@ -277,7 +278,7 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
description: this.ruDescFields.filter(f => f.key.trim() || f.value.trim()),
|
description: this.ruDescFields.filter(f => f.key.trim() || f.value.trim()),
|
||||||
};
|
};
|
||||||
this.onFieldChange('translations' as any, currentItem.translations);
|
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() {
|
addRuDescRow() {
|
||||||
@@ -361,10 +362,10 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
|
|
||||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
data: {
|
data: {
|
||||||
title: 'Delete Item',
|
title: this.lang.t('DELETE_ITEM'),
|
||||||
message: `Are you sure you want to delete "${item.name}"? This action cannot be undone.`,
|
message: `${this.lang.t('CONFIRM_DELETE')} "${item.name}"?`,
|
||||||
confirmText: 'Delete',
|
confirmText: this.lang.t('DELETE'),
|
||||||
cancelText: 'Cancel',
|
cancelText: this.lang.t('CANCEL'),
|
||||||
dangerous: true
|
dangerous: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -373,12 +374,12 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
if (result) {
|
if (result) {
|
||||||
this.apiService.deleteItem(item.id).subscribe({
|
this.apiService.deleteItem(item.id).subscribe({
|
||||||
next: () => {
|
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]);
|
this.router.navigate(['/project', this.projectId(), 'items', item.subcategoryId]);
|
||||||
},
|
},
|
||||||
error: (err: any) => {
|
error: (err: any) => {
|
||||||
console.error('Error deleting item:', 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'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
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 { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { ApiService } from '../../services';
|
import { ApiService } from '../../services';
|
||||||
|
import { ToastService } from '../../services/toast.service';
|
||||||
import { Item } from '../../models';
|
import { Item } from '../../models';
|
||||||
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-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>('');
|
subcategoryId = signal<string>('');
|
||||||
projectId = signal<string>('');
|
projectId = signal<string>('');
|
||||||
|
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
|
private toast: ToastService,
|
||||||
private snackBar: MatSnackBar,
|
private snackBar: MatSnackBar,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
public lang: LanguageService
|
public lang: LanguageService
|
||||||
@@ -74,7 +79,7 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.projectId.set(parentParams['projectId']);
|
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.subcategoryId.set(params['subcategoryId']);
|
||||||
this.page.set(1);
|
this.page.set(1);
|
||||||
this.items.set([]);
|
this.items.set([]);
|
||||||
@@ -113,7 +118,7 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Failed to load items', 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);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -177,7 +182,7 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
bulkToggleVisibility(visible: boolean) {
|
bulkToggleVisibility(visible: boolean) {
|
||||||
const itemIds = Array.from(this.selectedItems());
|
const itemIds = Array.from(this.selectedItems());
|
||||||
if (!itemIds.length) {
|
if (!itemIds.length) {
|
||||||
this.snackBar.open('No items selected', 'Close', { duration: 2000 });
|
this.toast.warning(this.lang.t('NO_ITEMS_SELECTED'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,11 +193,11 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
itemIds.includes(item.id) ? { ...item, visible } : item
|
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());
|
this.selectedItems.set(new Set());
|
||||||
},
|
},
|
||||||
error: (err) => {
|
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, {
|
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||||
width: '500px',
|
width: '500px',
|
||||||
data: {
|
data: {
|
||||||
title: 'Create New Item',
|
title: this.lang.t('CREATE_NEW_ITEM'),
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'name', label: 'Item Name', type: 'text', required: true },
|
{ name: 'name', label: this.lang.t('ITEM_NAME'), type: 'text', required: true },
|
||||||
{ name: 'simpleDescription', label: 'Simple Description', type: 'text', required: false },
|
{ name: 'simpleDescription', label: this.lang.t('SIMPLE_DESCRIPTION'), type: 'text', required: false },
|
||||||
{ name: 'price', label: 'Price', type: 'number', required: true },
|
{ name: 'price', label: this.lang.t('PRICE'), type: 'number', required: true },
|
||||||
{ name: 'currency', label: 'Currency', type: 'select', required: true, value: 'USD',
|
{ name: 'currency', label: this.lang.t('CURRENCY'), type: 'select', required: true, value: 'USD',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'USD', label: '🇺🇸 USD' },
|
{ value: 'USD', label: '🇺🇸 USD' },
|
||||||
{ value: 'EUR', label: '🇪🇺 EUR' },
|
{ value: 'EUR', label: '🇪🇺 EUR' },
|
||||||
@@ -234,8 +239,8 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
{ value: 'UAH', label: '🇺🇦 UAH' }
|
{ value: 'UAH', label: '🇺🇦 UAH' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ name: 'quantity', label: 'Quantity', type: 'number', required: true, value: 0 },
|
{ name: 'quantity', label: this.lang.t('QUANTITY'), type: 'number', required: true, value: 0 },
|
||||||
{ name: 'visible', label: 'Visible', type: 'toggle', required: false, value: true }
|
{ 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({
|
this.apiService.createItem(subcategoryId, result).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackBar.open('Item created successfully', 'Close', { duration: 3000 });
|
this.toast.success(this.lang.t('ITEM_CREATED'));
|
||||||
this.page.set(1);
|
this.page.set(1);
|
||||||
this.items.set([]);
|
this.items.set([]);
|
||||||
this.loadItems();
|
this.loadItems();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Error creating item:', 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, {
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
data: {
|
data: {
|
||||||
title: 'Delete Item',
|
title: this.lang.t('DELETE_ITEM'),
|
||||||
message: `Are you sure you want to delete "${item.name}"? This action cannot be undone.`,
|
message: `${this.lang.t('CONFIRM_DELETE')} "${item.name}"?`,
|
||||||
confirmText: 'Delete',
|
confirmText: this.lang.t('DELETE'),
|
||||||
cancelText: 'Cancel',
|
cancelText: this.lang.t('CANCEL'),
|
||||||
dangerous: true
|
dangerous: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -278,14 +283,14 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (result) {
|
if (result) {
|
||||||
this.apiService.deleteItem(item.id).subscribe({
|
this.apiService.deleteItem(item.id).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackBar.open('Item deleted successfully', 'Close', { duration: 3000 });
|
this.toast.success(this.lang.t('ITEM_DELETED'));
|
||||||
this.page.set(1);
|
this.page.set(1);
|
||||||
this.items.set([]);
|
this.items.set([]);
|
||||||
this.loadItems();
|
this.loadItems();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Error deleting item:', 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'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ActivatedRoute, Router, RouterOutlet, NavigationEnd } from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { filter } from 'rxjs/operators';
|
import { filter } from 'rxjs/operators';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||||
import { MatTreeModule } from '@angular/material/tree';
|
import { MatTreeModule } from '@angular/material/tree';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
@@ -10,10 +11,10 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
|||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
||||||
import { ApiService } from '../../services';
|
import { ApiService } from '../../services';
|
||||||
import { ValidationService } from '../../services/validation.service';
|
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 { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||||
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||||
@@ -47,7 +48,6 @@ interface CategoryNode {
|
|||||||
MatToolbarModule,
|
MatToolbarModule,
|
||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatSnackBarModule,
|
|
||||||
MatTooltipModule,
|
MatTooltipModule,
|
||||||
LoadingSkeletonComponent,
|
LoadingSkeletonComponent,
|
||||||
TranslatePipe
|
TranslatePipe
|
||||||
@@ -57,31 +57,36 @@ interface CategoryNode {
|
|||||||
})
|
})
|
||||||
export class ProjectViewComponent implements OnInit {
|
export class ProjectViewComponent implements OnInit {
|
||||||
projectId = signal<string>('');
|
projectId = signal<string>('');
|
||||||
project = signal<any>(null);
|
project = signal<Project | null>(null);
|
||||||
categories = signal<Category[]>([]);
|
categories = signal<Category[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
treeData = signal<CategoryNode[]>([]);
|
treeData = signal<CategoryNode[]>([]);
|
||||||
selectedNodeId = signal<string | null>(null);
|
selectedNodeId = signal<string | null>(null);
|
||||||
|
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
private snackBar: MatSnackBar,
|
private toast: ToastService,
|
||||||
private validationService: ValidationService,
|
private validationService: ValidationService,
|
||||||
public lang: LanguageService
|
public lang: LanguageService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.route.params.subscribe(params => {
|
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => {
|
||||||
this.projectId.set(params['projectId']);
|
this.projectId.set(params['projectId']);
|
||||||
this.loadProject();
|
this.loadProject();
|
||||||
this.loadCategories();
|
this.loadCategories();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track selected route — filter to NavigationEnd so snapshot is fully resolved
|
// 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 child = this.route.children[0]?.snapshot;
|
||||||
const subcategoryId = child?.params['subcategoryId'];
|
const subcategoryId = child?.params['subcategoryId'];
|
||||||
const categoryId = child?.params['categoryId'];
|
const categoryId = child?.params['categoryId'];
|
||||||
@@ -98,7 +103,7 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
this.apiService.getProjects().subscribe({
|
this.apiService.getProjects().subscribe({
|
||||||
next: (projects) => {
|
next: (projects) => {
|
||||||
const project = projects.find(p => p.id === this.projectId());
|
const project = projects.find(p => p.id === this.projectId());
|
||||||
this.project.set(project);
|
this.project.set(project ?? null);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Failed to load project', err);
|
console.error('Failed to load project', err);
|
||||||
@@ -199,12 +204,12 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
addCategory() {
|
addCategory() {
|
||||||
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||||
data: {
|
data: {
|
||||||
title: 'Create New Category',
|
title: this.lang.t('CREATE_NEW_CATEGORY'),
|
||||||
type: 'category',
|
type: 'category',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
{ name: 'name', label: this.lang.t('NAME'), type: 'text', required: true },
|
||||||
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
|
{ name: 'priority', label: this.lang.t('PRIORITY'), type: 'number', value: 99 },
|
||||||
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
|
{ 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);
|
const errors = this.validationService.validateCategoryOrSubcategory(result);
|
||||||
if (Object.keys(errors).length > 0) {
|
if (Object.keys(errors).length > 0) {
|
||||||
const errorMsg = Object.values(errors).join(', ');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.apiService.createCategory(this.projectId(), result).subscribe({
|
this.apiService.createCategory(this.projectId(), result).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackBar.open('Category created!', 'Close', { duration: 2000 });
|
this.toast.success(this.lang.t('CATEGORY_CREATED'));
|
||||||
this.loadCategories();
|
this.loadCategories();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
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, {
|
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||||
data: {
|
data: {
|
||||||
title: 'Create New Subcategory',
|
title: this.lang.t('CREATE_NEW_SUBCATEGORY'),
|
||||||
type: 'subcategory',
|
type: 'subcategory',
|
||||||
fields: [
|
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: 'id', label: 'ID', type: 'text', required: true, hint: 'Used for routing' },
|
||||||
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
|
{ name: 'priority', label: this.lang.t('PRIORITY'), type: 'number', value: 99 },
|
||||||
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
|
{ 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);
|
const errors = this.validationService.validateCategoryOrSubcategory(result);
|
||||||
if (Object.keys(errors).length > 0) {
|
if (Object.keys(errors).length > 0) {
|
||||||
const errorMsg = Object.values(errors).join(', ');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentType = parentNode.type === 'category' ? 'category' : 'subcategory';
|
const parentType = parentNode.type === 'category' ? 'category' : 'subcategory';
|
||||||
this.apiService.createSubcategory(parentNode.id, parentType, result).subscribe({
|
this.apiService.createSubcategory(parentNode.id, parentType, result).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 });
|
this.toast.success(this.lang.t('SUBCATEGORY_CREATED'));
|
||||||
this.loadCategories();
|
this.loadCategories();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
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, {
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
data: {
|
data: {
|
||||||
title: 'Delete Category',
|
title: this.lang.t('DELETE_CATEGORY'),
|
||||||
message: message,
|
message: message,
|
||||||
confirmText: 'Delete',
|
confirmText: this.lang.t('DELETE'),
|
||||||
cancelText: 'Cancel',
|
cancelText: this.lang.t('CANCEL'),
|
||||||
dangerous: true
|
dangerous: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -294,11 +299,11 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.apiService.deleteCategory(node.id).subscribe({
|
this.apiService.deleteCategory(node.id).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackBar.open('Category deleted', 'Close', { duration: 2000 });
|
this.toast.success(this.lang.t('CATEGORY_DELETED'));
|
||||||
this.loadCategories();
|
this.loadCategories();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
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, {
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
data: {
|
data: {
|
||||||
title: 'Delete Subcategory',
|
title: this.lang.t('DELETE_SUBCATEGORY'),
|
||||||
message: message,
|
message: message,
|
||||||
confirmText: 'Delete',
|
confirmText: this.lang.t('DELETE'),
|
||||||
cancelText: 'Cancel',
|
cancelText: this.lang.t('CANCEL'),
|
||||||
dangerous: true
|
dangerous: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -335,11 +340,11 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.apiService.deleteSubcategory(node.id).subscribe({
|
this.apiService.deleteSubcategory(node.id).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackBar.open('Subcategory deleted', 'Close', { duration: 2000 });
|
this.toast.success(this.lang.t('SUBCATEGORY_DELETED'));
|
||||||
this.loadCategories();
|
this.loadCategories();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.snackBar.open('Failed to delete subcategory', 'Close', { duration: 3000 });
|
this.toast.error(this.lang.t('FAILED_DELETE_SUBCATEGORY'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Router } from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { ApiService } from '../../services';
|
import { ApiService } from '../../services';
|
||||||
import { Project } from '../../models';
|
import { Project } from '../../models';
|
||||||
import { LanguageService } from '../../services/language.service';
|
import { LanguageService } from '../../services/language.service';
|
||||||
@@ -21,6 +22,8 @@ export class ProjectsDashboardComponent implements OnInit {
|
|||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
currentProjectId = signal<string | null>(null);
|
currentProjectId = signal<string | null>(null);
|
||||||
|
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
@@ -37,7 +40,7 @@ export class ProjectsDashboardComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Listen to route changes
|
// Listen to route changes
|
||||||
this.router.events.subscribe(() => {
|
this.router.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
const segments = this.router.url.split('/');
|
const segments = this.router.url.split('/');
|
||||||
if (segments[1] === 'project' && segments[2]) {
|
if (segments[1] === 'project' && segments[2]) {
|
||||||
this.currentProjectId.set(segments[2]);
|
this.currentProjectId.set(segments[2]);
|
||||||
|
|||||||
@@ -36,13 +36,9 @@
|
|||||||
<mat-label>ID</mat-label>
|
<mat-label>ID</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
[(ngModel)]="subcategory()!.id"
|
[value]="subcategory()!.id"
|
||||||
(blur)="onFieldChange('id', subcategory()!.id)"
|
disabled>
|
||||||
required>
|
|
||||||
<mat-hint>{{ 'ID' | translate }}</mat-hint>
|
<mat-hint>{{ 'ID' | translate }}</mat-hint>
|
||||||
@if (!subcategory()!.id || subcategory()!.id.trim().length === 0) {
|
|
||||||
<mat-error>ID is required</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -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 { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { ApiService } from '../../services';
|
import { ApiService } from '../../services';
|
||||||
|
import { ToastService } from '../../services/toast.service';
|
||||||
import { Subcategory } from '../../models';
|
import { Subcategory } from '../../models';
|
||||||
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||||
@@ -30,7 +31,6 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
|||||||
MatSlideToggleModule,
|
MatSlideToggleModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
MatSnackBarModule,
|
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatTooltipModule,
|
MatTooltipModule,
|
||||||
LoadingSkeletonComponent,
|
LoadingSkeletonComponent,
|
||||||
@@ -42,18 +42,22 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
|||||||
export class SubcategoryEditorComponent implements OnInit {
|
export class SubcategoryEditorComponent implements OnInit {
|
||||||
subcategory = signal<Subcategory | null>(null);
|
subcategory = signal<Subcategory | null>(null);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
saving = signal(false);
|
|
||||||
subcategoryId = signal<string>('');
|
subcategoryId = signal<string>('');
|
||||||
projectId = 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 */
|
/** Local buffer for the Russian translation of the subcategory name */
|
||||||
ruName = '';
|
ruName = '';
|
||||||
|
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private snackBar: MatSnackBar,
|
private toast: ToastService,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
public lang: LanguageService
|
public lang: LanguageService
|
||||||
) {}
|
) {}
|
||||||
@@ -65,7 +69,7 @@ export class SubcategoryEditorComponent implements OnInit {
|
|||||||
this.projectId.set(parentParams['projectId']);
|
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.subcategoryId.set(params['subcategoryId']);
|
||||||
this.loadSubcategory();
|
this.loadSubcategory();
|
||||||
});
|
});
|
||||||
@@ -81,7 +85,7 @@ export class SubcategoryEditorComponent implements OnInit {
|
|||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Failed to load subcategory', 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);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -96,13 +100,7 @@ export class SubcategoryEditorComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onFieldChange(field: keyof Subcategory, value: any) {
|
onFieldChange(field: keyof Subcategory, value: any) {
|
||||||
this.saving.set(true);
|
|
||||||
this.apiService.queueSave('subcategory', this.subcategoryId(), field, value);
|
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') {
|
async onImageSelect(event: Event, type: 'file' | 'url') {
|
||||||
@@ -121,8 +119,7 @@ export class SubcategoryEditorComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.snackBar.open('Failed to upload image', 'Close', { duration: 3000 });
|
this.toast.error(this.lang.t('FAILED_UPLOAD_IMAGE'));
|
||||||
this.saving.set(false);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (type === 'url') {
|
} else if (type === 'url') {
|
||||||
@@ -149,10 +146,10 @@ export class SubcategoryEditorComponent implements OnInit {
|
|||||||
|
|
||||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
data: {
|
data: {
|
||||||
title: 'Delete Subcategory',
|
title: this.lang.t('DELETE_SUBCATEGORY'),
|
||||||
message: `Are you sure you want to delete "${sub.name}"? This will also delete all items in this subcategory.`,
|
message: `${this.lang.t('CONFIRM_DELETE')} "${sub.name}"?`,
|
||||||
confirmText: 'Delete',
|
confirmText: this.lang.t('DELETE'),
|
||||||
cancelText: 'Cancel',
|
cancelText: this.lang.t('CANCEL'),
|
||||||
dangerous: true
|
dangerous: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -161,7 +158,7 @@ export class SubcategoryEditorComponent implements OnInit {
|
|||||||
if (result) {
|
if (result) {
|
||||||
this.apiService.deleteSubcategory(sub.id).subscribe({
|
this.apiService.deleteSubcategory(sub.id).subscribe({
|
||||||
next: () => {
|
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
|
// Navigate to the direct parent (subcategory) if parentId exists, otherwise the root category
|
||||||
if (sub.parentId && sub.parentId !== sub.categoryId) {
|
if (sub.parentId && sub.parentId !== sub.categoryId) {
|
||||||
this.router.navigate(['/project', this.projectId(), 'subcategory', sub.parentId]);
|
this.router.navigate(['/project', this.projectId(), 'subcategory', sub.parentId]);
|
||||||
@@ -171,7 +168,7 @@ export class SubcategoryEditorComponent implements OnInit {
|
|||||||
},
|
},
|
||||||
error: (err: any) => {
|
error: (err: any) => {
|
||||||
console.error('Error deleting subcategory:', err);
|
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'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable, Subject, timer } from 'rxjs';
|
import { Observable, Subject, throwError } from 'rxjs';
|
||||||
import { debounce, retry, catchError, tap, map } from 'rxjs/operators';
|
import { debounceTime, retry, catchError, map, groupBy, mergeMap } from 'rxjs/operators';
|
||||||
import { Project, Category, Subcategory, Item, ItemsListResponse } from '../models';
|
import { Project, Category, Subcategory, Item, ItemsListResponse } from '../models';
|
||||||
import { MockDataService } from './mock-data.service';
|
import { MockDataService } from './mock-data.service';
|
||||||
|
import { ToastService } from './toast.service';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -12,15 +13,22 @@ import { environment } from '../../environments/environment';
|
|||||||
export class ApiService {
|
export class ApiService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private mockService = inject(MockDataService);
|
private mockService = inject(MockDataService);
|
||||||
|
private toast = inject(ToastService);
|
||||||
private readonly API_BASE = environment.apiUrl;
|
private readonly API_BASE = environment.apiUrl;
|
||||||
|
|
||||||
|
/** Whether a debounced save is in-flight */
|
||||||
|
saving = signal(false);
|
||||||
|
|
||||||
// Debounced save queue
|
// Debounced save queue
|
||||||
private saveQueue$ = new Subject<SaveOperation>();
|
private saveQueue$ = new Subject<SaveOperation>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Set up auto-save with 500ms debounce
|
// Debounce per unique type+id+field so independent fields don't clobber each other
|
||||||
this.saveQueue$
|
this.saveQueue$
|
||||||
.pipe(debounce(() => timer(500)))
|
.pipe(
|
||||||
|
groupBy(op => `${op.type}:${op.id}:${op.field}`),
|
||||||
|
mergeMap(group$ => group$.pipe(debounceTime(500)))
|
||||||
|
)
|
||||||
.subscribe(operation => {
|
.subscribe(operation => {
|
||||||
this.executeSave(operation);
|
this.executeSave(operation);
|
||||||
});
|
});
|
||||||
@@ -198,7 +206,8 @@ export class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Debounced auto-save
|
// 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 });
|
this.saveQueue$.next({ type, id, field, value });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,12 +228,18 @@ export class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
request.subscribe({
|
request.subscribe({
|
||||||
next: () => console.log(`Saved ${operation.type} ${operation.id} - ${operation.field}`),
|
next: () => {
|
||||||
error: (err) => console.error(`Failed to save ${operation.type}`, err)
|
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';
|
let errorMessage = 'An unexpected error occurred';
|
||||||
|
|
||||||
if (error.error instanceof ErrorEvent) {
|
if (error.error instanceof ErrorEvent) {
|
||||||
@@ -269,15 +284,15 @@ export class ApiService {
|
|||||||
url: error.url
|
url: error.url
|
||||||
});
|
});
|
||||||
|
|
||||||
throw { message: errorMessage, status: error.status, originalError: error };
|
return throwError(() => ({ message: errorMessage, status: error.status, originalError: error }));
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SaveOperation {
|
interface SaveOperation {
|
||||||
type: 'category' | 'subcategory' | 'item';
|
type: 'category' | 'subcategory' | 'item';
|
||||||
id: string;
|
id: string;
|
||||||
field: string;
|
field: string;
|
||||||
value: any;
|
value: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ItemFilters {
|
interface ItemFilters {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './api.service';
|
export * from './api.service';
|
||||||
export * from './validation.service';
|
export * from './validation.service';
|
||||||
export * from './toast.service';
|
export * from './toast.service';
|
||||||
|
export * from './language.service';
|
||||||
|
|||||||
@@ -194,18 +194,24 @@ export class MockDataService {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Generate more items for testing infinite scroll
|
// Cache for generated test items so pagination is stable
|
||||||
private generateMoreItems(subcategoryId: string, count: number): Item[] {
|
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[] = [];
|
const items: Item[] = [];
|
||||||
for (let i = 6; i <= count + 5; i++) {
|
for (let i = 6; i <= count + 5; i++) {
|
||||||
items.push({
|
items.push({
|
||||||
id: `item${i}`,
|
id: `${subcategoryId}-item${i}`,
|
||||||
name: `Test Product ${i}`,
|
name: `Test Product ${i}`,
|
||||||
visible: Math.random() > 0.3,
|
visible: i % 4 !== 0,
|
||||||
priority: i,
|
priority: i,
|
||||||
quantity: Math.floor(Math.random() * 100),
|
quantity: (i * 7) % 100,
|
||||||
price: Math.floor(Math.random() * 1000) + 100,
|
price: ((i * 13) % 1000) + 100,
|
||||||
discount: Math.random() > 0.7 ? Math.floor(Math.random() * 30) + 5 : 0,
|
discount: i % 3 === 0 ? (i * 5) % 30 + 5 : 0,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
imgs: [`https://via.placeholder.com/600x400?text=Product+${i}`],
|
imgs: [`https://via.placeholder.com/600x400?text=Product+${i}`],
|
||||||
tags: ['test'],
|
tags: ['test'],
|
||||||
@@ -214,6 +220,7 @@ export class MockDataService {
|
|||||||
subcategoryId
|
subcategoryId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
this.generatedItems.set(subcategoryId, items);
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +367,7 @@ export class MockDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getItems(subcategoryId: string, page = 1, limit = 20, search?: string, filters?: any): Observable<ItemsListResponse> {
|
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
|
// Filter by subcategory
|
||||||
allItems = allItems.filter(item => item.subcategoryId === subcategoryId);
|
allItems = allItems.filter(item => item.subcategoryId === subcategoryId);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/app",
|
"outDir": "./out-tsc/app",
|
||||||
|
"rootDir": "./src",
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
|
"rootDir": "./",
|
||||||
"types": [
|
"types": [
|
||||||
"vitest/globals"
|
"vitest/globals"
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user