improvements are done
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from './api.service';
|
||||
export * from './validation.service';
|
||||
export * from './toast.service';
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
58
src/app/services/toast.service.ts
Normal file
58
src/app/services/toast.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
183
src/app/services/validation.service.ts
Normal file
183
src/app/services/validation.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user