improvements are done

This commit is contained in:
sdarbinyan
2026-01-22 00:41:13 +04:00
parent a1a2a69fd0
commit 0f3d0ae3ef
27 changed files with 2115 additions and 107 deletions

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, Subject, timer } from 'rxjs';
import { debounce, retry, catchError, tap } from 'rxjs/operators';
import { debounce, retry, catchError, tap, map } from 'rxjs/operators';
import { Project, Category, Subcategory, Item, ItemsListResponse } from '../models';
import { MockDataService } from './mock-data.service';
import { environment } from '../../environments/environment';
@@ -76,9 +76,11 @@ export class ApiService {
// Subcategories
getSubcategories(categoryId: string): Observable<Subcategory[]> {
if (environment.useMockData) return this.mockService.getCategory(categoryId).pipe(
tap(cat => cat.subcategories || [])
) as any;
if (environment.useMockData) {
return this.mockService.getCategory(categoryId).pipe(
map(cat => cat.subcategories || [])
);
}
return this.http.get<Subcategory[]>(`${this.API_BASE}/categories/${categoryId}/subcategories`).pipe(
retry(2),
catchError(this.handleError)
@@ -220,8 +222,51 @@ export class ApiService {
}
private handleError(error: any): Observable<never> {
console.error('API Error:', error);
throw error;
let errorMessage = 'An unexpected error occurred';
if (error.error instanceof ErrorEvent) {
// Client-side or network error
errorMessage = `Network error: ${error.error.message}`;
} else if (error.status) {
// Backend returned an unsuccessful response code
switch (error.status) {
case 400:
errorMessage = error.error?.message || 'Invalid request';
break;
case 401:
errorMessage = 'Unauthorized. Please log in again.';
break;
case 403:
errorMessage = 'You do not have permission to perform this action';
break;
case 404:
errorMessage = 'Resource not found';
break;
case 409:
errorMessage = error.error?.message || 'Conflict: Resource already exists or has conflicts';
break;
case 422:
errorMessage = error.error?.message || 'Validation failed';
break;
case 500:
errorMessage = 'Server error. Please try again later.';
break;
case 503:
errorMessage = 'Service unavailable. Please try again later.';
break;
default:
errorMessage = error.error?.message || `Error: ${error.status} - ${error.statusText}`;
}
}
console.error('API Error:', {
message: errorMessage,
status: error.status,
error: error.error,
url: error.url
});
throw { message: errorMessage, status: error.status, originalError: error };
}
}

View File

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

View File

@@ -248,52 +248,105 @@ export class MockDataService {
}
getSubcategory(subcategoryId: string): Observable<Subcategory> {
let sub: Subcategory | undefined;
for (const cat of this.categories) {
sub = cat.subcategories?.find(s => s.id === subcategoryId);
if (sub) break;
}
const sub = this.findSubcategoryById(subcategoryId);
return of(sub!).pipe(delay(200));
}
updateSubcategory(subcategoryId: string, data: Partial<Subcategory>): Observable<Subcategory> {
let sub: Subcategory | undefined;
for (const cat of this.categories) {
sub = cat.subcategories?.find(s => s.id === subcategoryId);
if (sub) {
Object.assign(sub, data);
break;
}
const sub = this.findSubcategoryById(subcategoryId);
if (sub) {
Object.assign(sub, data);
}
return of(sub!).pipe(delay(300));
}
createSubcategory(categoryId: string, data: Partial<Subcategory>): Observable<Subcategory> {
const cat = this.categories.find(c => c.id === categoryId)!;
createSubcategory(parentId: string, data: Partial<Subcategory>): Observable<Subcategory> {
// Check if parent already has items
const parentSubcategory = this.findSubcategoryById(parentId);
if (parentSubcategory?.hasItems) {
throw new Error('Cannot create subcategory: parent already has items');
}
const newSub: Subcategory = {
id: `sub${Date.now()}`,
id: data.id || `sub${Date.now()}`,
name: data.name || 'New Subcategory',
visible: data.visible ?? true,
priority: data.priority || 99,
img: data.img,
categoryId,
categoryId: parentId,
itemCount: 0
};
if (!cat.subcategories) cat.subcategories = [];
cat.subcategories.push(newSub);
return of(newSub).pipe(delay(300));
// Try to find parent category first
const cat = this.categories.find(c => c.id === parentId);
if (cat) {
if (!cat.subcategories) cat.subcategories = [];
cat.subcategories.push(newSub);
return of(newSub).pipe(delay(300));
}
// If not a category, search for parent subcategory recursively
const parent = this.findSubcategoryById(parentId);
if (parent) {
if (!parent.subcategories) parent.subcategories = [];
parent.subcategories.push(newSub);
return of(newSub).pipe(delay(300));
}
// Parent not found
throw new Error(`Parent with id ${parentId} not found`);
}
private findSubcategoryById(id: string): Subcategory | null {
for (const cat of this.categories) {
const result = this.searchSubcategories(cat.subcategories || [], id);
if (result) return result;
}
return null;
}
private searchSubcategories(subcategories: Subcategory[], id: string): Subcategory | null {
for (const sub of subcategories) {
if (sub.id === id) return sub;
if (sub.subcategories) {
const result = this.searchSubcategories(sub.subcategories, id);
if (result) return result;
}
}
return null;
}
deleteSubcategory(subcategoryId: string): Observable<void> {
// Try to delete from category level
for (const cat of this.categories) {
const index = cat.subcategories?.findIndex(s => s.id === subcategoryId) ?? -1;
if (index > -1) {
cat.subcategories?.splice(index, 1);
break;
return of(void 0).pipe(delay(300));
}
// Try to delete from nested subcategories
if (this.deleteFromSubcategories(cat.subcategories || [], subcategoryId)) {
return of(void 0).pipe(delay(300));
}
}
return of(void 0).pipe(delay(300));
}
private deleteFromSubcategories(subcategories: Subcategory[], id: string): boolean {
for (const sub of subcategories) {
if (sub.subcategories) {
const index = sub.subcategories.findIndex(s => s.id === id);
if (index > -1) {
sub.subcategories.splice(index, 1);
return true;
}
if (this.deleteFromSubcategories(sub.subcategories, id)) {
return true;
}
}
}
return false;
}
getItems(subcategoryId: string, page = 1, limit = 20, search?: string, filters?: any): Observable<ItemsListResponse> {
let allItems = [...this.items, ...this.generateMoreItems(subcategoryId, 50)];
@@ -354,12 +407,32 @@ export class MockDataService {
subcategoryId
};
this.items.push(newItem);
// Mark subcategory as having items
const subcategory = this.findSubcategoryById(subcategoryId);
if (subcategory) {
subcategory.hasItems = true;
}
return of(newItem).pipe(delay(300));
}
deleteItem(itemId: string): Observable<void> {
const item = this.items.find(i => i.id === itemId);
const index = this.items.findIndex(i => i.id === itemId);
if (index > -1) this.items.splice(index, 1);
if (index > -1) {
const subcategoryId = this.items[index].subcategoryId;
this.items.splice(index, 1);
// Check if subcategory still has items
const remainingItems = this.items.filter(i => i.subcategoryId === subcategoryId);
if (remainingItems.length === 0) {
const subcategory = this.findSubcategoryById(subcategoryId);
if (subcategory) {
subcategory.hasItems = false;
}
}
}
return of(void 0).pipe(delay(300));
}

View File

@@ -0,0 +1,58 @@
import { Injectable, inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
@Injectable({
providedIn: 'root'
})
export class ToastService {
private snackBar = inject(MatSnackBar);
private readonly durations = {
success: 2000,
error: 4000,
warning: 3000,
info: 2000
};
private readonly classes = {
success: 'toast-success',
error: 'toast-error',
warning: 'toast-warning',
info: 'toast-info'
};
show(message: string, type: ToastType = 'info', duration?: number) {
this.snackBar.open(
message,
'Close',
{
duration: duration || this.durations[type],
horizontalPosition: 'end',
verticalPosition: 'top',
panelClass: [this.classes[type]]
}
);
}
success(message: string, duration?: number) {
this.show(message, 'success', duration);
}
error(message: string, duration?: number) {
this.show(message, 'error', duration);
}
warning(message: string, duration?: number) {
this.show(message, 'warning', duration);
}
info(message: string, duration?: number) {
this.show(message, 'info', duration);
}
dismiss() {
this.snackBar.dismiss();
}
}

View File

@@ -0,0 +1,183 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class ValidationService {
validateRequired(value: any): string | null {
if (value === null || value === undefined || value === '') {
return 'This field is required';
}
if (typeof value === 'string' && value.trim().length === 0) {
return 'This field cannot be empty';
}
return null;
}
validateNumber(value: any, min?: number, max?: number): string | null {
const num = Number(value);
if (isNaN(num)) {
return 'Must be a valid number';
}
if (min !== undefined && num < min) {
return `Must be at least ${min}`;
}
if (max !== undefined && num > max) {
return `Must be at most ${max}`;
}
return null;
}
validatePrice(value: any): string | null {
const numberError = this.validateNumber(value, 0);
if (numberError) return numberError;
const num = Number(value);
if (num < 0) {
return 'Price cannot be negative';
}
return null;
}
validateQuantity(value: any): string | null {
const numberError = this.validateNumber(value, 0);
if (numberError) return numberError;
const num = Number(value);
if (!Number.isInteger(num)) {
return 'Quantity must be a whole number';
}
if (num < 0) {
return 'Quantity cannot be negative';
}
return null;
}
validateUrl(value: string): string | null {
if (!value || value.trim().length === 0) {
return null; // Optional field
}
try {
new URL(value);
return null;
} catch {
return 'Must be a valid URL (e.g., https://example.com)';
}
}
validateImageUrl(value: string): string | null {
const urlError = this.validateUrl(value);
if (urlError) return urlError;
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
const url = value.toLowerCase();
const hasValidExtension = imageExtensions.some(ext => url.includes(ext));
if (!hasValidExtension) {
return 'URL should point to an image file';
}
return null;
}
validateId(value: string): string | null {
if (!value || value.trim().length === 0) {
return 'ID is required';
}
// ID should be URL-safe (no spaces, special chars)
const validIdPattern = /^[a-z0-9_-]+$/i;
if (!validIdPattern.test(value)) {
return 'ID can only contain letters, numbers, hyphens, and underscores';
}
if (value.length < 2) {
return 'ID must be at least 2 characters';
}
if (value.length > 50) {
return 'ID must be less than 50 characters';
}
return null;
}
validatePriority(value: any): string | null {
return this.validateNumber(value, 0, 9999);
}
validateCurrency(value: string): string | null {
const validCurrencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH'];
if (!validCurrencies.includes(value)) {
return `Currency must be one of: ${validCurrencies.join(', ')}`;
}
return null;
}
validateArrayNotEmpty(arr: any[], fieldName: string): string | null {
if (!arr || arr.length === 0) {
return `At least one ${fieldName} is required`;
}
return null;
}
// Composite validation for item
validateItem(item: Partial<any>): Record<string, string> {
const errors: Record<string, string> = {};
if (item['name'] !== undefined) {
const nameError = this.validateRequired(item['name']);
if (nameError) errors['name'] = nameError;
}
if (item['price'] !== undefined) {
const priceError = this.validatePrice(item['price']);
if (priceError) errors['price'] = priceError;
}
if (item['quantity'] !== undefined) {
const quantityError = this.validateQuantity(item['quantity']);
if (quantityError) errors['quantity'] = quantityError;
}
if (item['currency'] !== undefined) {
const currencyError = this.validateCurrency(item['currency']);
if (currencyError) errors['currency'] = currencyError;
}
if (item['priority'] !== undefined) {
const priorityError = this.validatePriority(item['priority']);
if (priorityError) errors['priority'] = priorityError;
}
return errors;
}
// Composite validation for category/subcategory
validateCategoryOrSubcategory(data: Partial<any>): Record<string, string> {
const errors: Record<string, string> = {};
if (data['name'] !== undefined) {
const nameError = this.validateRequired(data['name']);
if (nameError) errors['name'] = nameError;
}
if (data['id'] !== undefined) {
const idError = this.validateId(data['id']);
if (idError) errors['id'] = idError;
}
if (data['priority'] !== undefined) {
const priorityError = this.validatePriority(data['priority']);
if (priorityError) errors['priority'] = priorityError;
}
if (data['img'] !== undefined && data['img']) {
const imgError = this.validateImageUrl(data['img']);
if (imgError) errors['img'] = imgError;
}
return errors;
}
}