add Categories management: create CategoriesList component, update admin navbar, and integrate category handling in product forms

This commit is contained in:
Vincent Guillet
2025-11-01 15:43:49 +01:00
parent 4b692806c4
commit 7c8f85a500
37 changed files with 1009 additions and 66 deletions

View File

@@ -15,7 +15,6 @@
name="title"
formControlName="title"
type="text"
placeholder="Ceci est un titre"
required>
@if (isFieldInvalid('title')) {
<mat-error>{{ getFieldError('title') }}</mat-error>
@@ -39,29 +38,29 @@
<!-- Category -->
<mat-form-field appearance="outline">
<mat-label>Catégorie</mat-label>
<mat-select disableRipple>
<mat-option value="1">Option 1</mat-option>
<mat-option value="2">Option 2</mat-option>
<mat-option value="3">Option 3</mat-option>
<mat-select formControlName="category" disableRipple>
@for (category of categories; track category.id) {
<mat-option [value]="category">{{ category.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Condition -->
<mat-form-field appearance="outline">
<mat-label>État</mat-label>
<mat-select disableRipple>
<mat-option value="1">Option 1</mat-option>
<mat-option value="2">Option 2</mat-option>
<mat-option value="3">Option 3</mat-option>
<mat-select formControlName="condition" disableRipple>
@for (condition of conditions; track condition.id) {
<mat-option [value]="condition">{{ condition.displayName }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Brand -->
<mat-form-field appearance="outline">
<mat-label>Marque</mat-label>
<mat-select disableRipple>
@for (brand of brands; track brand.id) {
<mat-option [value]="brand">{{ brand.name }}</mat-option>
<mat-select formControlName="brand" [compareWith]="compareById" disableRipple>
@for (brand of filteredBrands; track brand.id) {
<mat-option [value]="brand.id">{{ brand.name }}</mat-option>
}
</mat-select>
</mat-form-field>
@@ -69,9 +68,9 @@
<!-- Platform -->
<mat-form-field appearance="outline">
<mat-label>Plateforme</mat-label>
<mat-select disableRipple>
@for (platform of platforms; track platform.id) {
<mat-option [value]="platform">{{ platform.name }}</mat-option>
<mat-select formControlName="platform" [compareWith]="compareById" disableRipple>
@for (platform of filteredPlatforms; track platform.id) {
<mat-option [value]="platform.id">{{ platform.name }}</mat-option>
}
</mat-select>
</mat-form-field>

View File

@@ -1,9 +1,10 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {
AbstractControl,
FormBuilder,
FormGroup,
FormsModule,
ReactiveFormsModule,
ReactiveFormsModule, ValidatorFn,
Validators
} from "@angular/forms";
import {MatButton} from "@angular/material/button";
@@ -20,12 +21,17 @@ import {MatError, MatFormField, MatLabel} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input";
import {MatProgressSpinner} from "@angular/material/progress-spinner";
import {MatOption, MatSelect} from '@angular/material/select';
import {Router, RouterLink} from '@angular/router';
import {RouterLink} from '@angular/router';
import {Subscription} from 'rxjs';
import {BrandService} from '../../services/brand/brand.service';
import {BrandService} from '../../services/app/brand.service';
import {Brand} from '../../interfaces/brand';
import {PlatformService} from '../../services/platform/platform.service';
import {PlatformService} from '../../services/app/platform.service';
import {Platform} from '../../interfaces/platform';
import {Category} from '../../interfaces/category';
import {CategoryService} from '../../services/app/category.service';
import {ConditionService} from '../../services/app/condition.service';
import {Condition} from '../../interfaces/condition';
import {ProductService} from '../../services/app/product.service';
@Component({
selector: 'app-add-product',
@@ -61,12 +67,28 @@ export class AddProductComponent implements OnInit, OnDestroy {
brands: Brand[] = [];
platforms: Platform[] = [];
categories: Category[] = [];
conditions: Condition[] = [];
private readonly router: Router = inject(Router);
filteredBrands: Brand[] = [];
filteredPlatforms: Platform[] = [];
private addProductSubscription: Subscription | null = null;
private brandControlSubscription: Subscription | null = null;
private platformControlSubscription: Subscription | null = null;
private brandSubscription: Subscription | null = null;
private platformSubscription: Subscription | null = null;
private categorySubscription: Subscription | null = null;
private conditionSubscription: Subscription | null = null;
private readonly addProductSubscription: Subscription | null = null;
private readonly brandService: BrandService = inject(BrandService);
private readonly platformService = inject(PlatformService);
private readonly categoryService = inject(CategoryService);
private readonly conditionService = inject(ConditionService);
private readonly productService = inject(ProductService);
constructor(private readonly formBuilder: FormBuilder) {
this.addProductForm = this.formBuilder.group({
@@ -74,13 +96,13 @@ export class AddProductComponent implements OnInit, OnDestroy {
Validators.required,
Validators.minLength(3),
Validators.maxLength(50),
Validators.pattern('^[a-zA-Z]+$')
Validators.pattern(/^[\p{L}\p{N}\s]+$/u)
]],
description: ['', [
Validators.required,
Validators.minLength(10),
Validators.maxLength(255),
Validators.pattern('^[a-zA-Z]+$')
Validators.pattern(/^[\p{L}\p{N}\s]+$/u)
]],
category: ['', [
Validators.required
@@ -88,38 +110,78 @@ export class AddProductComponent implements OnInit, OnDestroy {
condition: ['', [
Validators.required
]],
// stocker des ids (string|number) dans les controls
brand: ['', [
Validators.required
]],
platform: ['', [
Validators.required
]],
complete: [true,
Validators.requiredTrue
],
manual: [true,
Validators.requiredTrue
],
complete: [true],
manual: [true],
price: ['', [
Validators.required,
Validators.min(0),
Validators.max(9999),
Validators.pattern('^[0-9]+$')
Validators.pattern(/^\d+([.,]\d{1,2})?$/),
this.priceRangeValidator(0, 9999)
]],
quantity: ['', [
Validators.required,
Validators.min(1),
Validators.max(999),
Validators.pattern('^[0-9]+$')
Validators.pattern(/^\d+$/)
]]
},
);
}
private normalizeIds<T extends Record<string, any>>(items: T[] | undefined, idKey = 'id'): T[] {
return (items || []).map((it, i) => ({
...it,
[idKey]: (it[idKey] ?? i)
}));
}
private getPlatformBrandId(platform: any): string | number | undefined {
if (!platform) return undefined;
const maybe = platform.brand ?? platform['brand_id'] ?? platform['brandId'];
if (maybe == null) return undefined;
if (typeof maybe === 'object') {
if (maybe.id != null) return maybe.id;
if (maybe.name != null) {
const found = this.brands.find(b =>
String(b.name).toLowerCase() === String(maybe.name).toLowerCase()
|| String(b.id) === String(maybe.name)
);
return found?.id;
}
return undefined;
}
const asStr = String(maybe);
const match = this.brands.find(b =>
String(b.id) === asStr || String(b.name).toLowerCase() === asStr.toLowerCase()
);
return match?.id ?? maybe;
}
private priceRangeValidator(min: number, max: number): ValidatorFn {
return (control: AbstractControl) => {
const val = control.value;
if (val === null || val === undefined || val === '') return null;
const normalized = String(val).replace(',', '.').trim();
const num = Number.parseFloat(normalized);
if (Number.isNaN(num)) return {pattern: true};
return (num < min || num > max) ? {range: {min, max, actual: num}} : null;
};
}
ngOnInit(): void {
this.brandService.getBrands().subscribe({
next: (brands) => {
this.brands = brands;
this.brandSubscription = this.brandService.getBrands().subscribe({
next: (brands: Brand[]) => {
this.brands = this.normalizeIds(brands, 'id');
this.filteredBrands = [...this.brands];
},
error: (error) => {
console.error('Error fetching brands:', error);
@@ -129,9 +191,10 @@ export class AddProductComponent implements OnInit, OnDestroy {
}
});
this.platformService.getPlatforms().subscribe({
next: (platforms) => {
this.platforms = platforms;
this.platformSubscription = this.platformService.getPlatforms().subscribe({
next: (platforms: Platform[]) => {
this.platforms = this.normalizeIds(platforms, 'id');
this.filteredPlatforms = [...this.platforms];
},
error: (error) => {
console.error('Error fetching platforms:', error);
@@ -140,21 +203,118 @@ export class AddProductComponent implements OnInit, OnDestroy {
console.log('Finished fetching platforms:', this.platforms);
}
});
this.categorySubscription = this.categoryService.getCategories().subscribe({
next: (categories: Category[]) => {
this.categories = this.normalizeIds(categories, 'id');
},
error: (error) => {
console.error('Error fetching categories:', error);
},
complete: () => {
console.log('Finished fetching categories:', this.categories);
}
});
this.conditionSubscription = this.conditionService.getConditions().subscribe({
next: (conditions: Condition[]) => {
this.conditions = this.normalizeIds(conditions, 'id');
},
error: (error) => {
console.error('Error fetching conditions:', error);
},
complete: () => {
console.log('Finished fetching conditions:', this.conditions);
}
});
const brandControl = this.addProductForm.get('brand');
const platformControl = this.addProductForm.get('platform');
this.brandControlSubscription = brandControl?.valueChanges.subscribe((brandId) => {
if (brandId != null && brandId !== '') {
const brandIdStr = String(brandId);
this.filteredPlatforms = this.platforms.filter(p => {
const pBid = this.getPlatformBrandId(p);
return pBid != null && String(pBid) === brandIdStr;
});
const curPlatformId = platformControl?.value;
if (curPlatformId != null && !this.filteredPlatforms.some(p => String(p.id) === String(curPlatformId))) {
platformControl?.setValue(null);
}
} else {
this.filteredPlatforms = [...this.platforms];
}
}) ?? null;
this.platformControlSubscription = platformControl?.valueChanges.subscribe((platformId) => {
if (platformId != null && platformId !== '') {
const platformObj = this.platforms.find(p => String(p.id) === String(platformId));
const pBrandId = this.getPlatformBrandId(platformObj);
if (pBrandId != null) {
const pBrandIdStr = String(pBrandId);
this.filteredBrands = this.brands.filter(b => String(b.id) === pBrandIdStr);
const curBrandId = brandControl?.value;
if (curBrandId != null && String(curBrandId) !== pBrandIdStr) {
brandControl?.setValue(null);
}
} else {
this.filteredBrands = [...this.brands];
}
} else {
this.filteredBrands = [...this.brands];
}
}) ?? null;
}
ngOnDestroy(): void {
this.addProductSubscription?.unsubscribe();
this.brandControlSubscription?.unsubscribe();
this.platformControlSubscription?.unsubscribe();
this.brandSubscription?.unsubscribe();
this.platformSubscription?.unsubscribe();
this.categorySubscription?.unsubscribe();
this.conditionSubscription?.unsubscribe();
}
onProductAdd() {
this.isSubmitted = true;
if (this.addProductForm.valid) {
this.isLoading = true;
const productData = this.addProductForm.value;
alert("Produit ajouté avec succès !");
console.log(productData);
const raw = this.addProductForm.value;
const priceStr = raw.price ?? '';
const priceNum = Number(String(priceStr).replace(',', '.').trim());
if (Number.isNaN(priceNum)) {
this.isLoading = false;
this.addProductForm.get('price')?.setErrors({pattern: true});
return;
}
const quantityNum = Number(raw.quantity);
const payload = {
...raw,
price: priceNum,
quantity: quantityNum
};
this.addProductSubscription = this.productService.addProduct(payload).subscribe({
next: (response) => {
console.log("Product added successfully:", response);
this.addProductForm.reset();
this.isSubmitted = false;
alert("Produit ajouté avec succès !");
},
error: (error) => {
console.error("Error adding product:", error);
alert("Une erreur est survenue lors de l'ajout du produit.");
},
complete: () => {
this.isLoading = false;
}
});
}
}
@@ -174,4 +334,12 @@ export class AddProductComponent implements OnInit, OnDestroy {
}
return '';
}
compareById = (a: any, b: any) => {
if (a == null || b == null) return a === b;
if (typeof a !== 'object' || typeof b !== 'object') {
return String(a) === String(b);
}
return String(a.id ?? a) === String(b.id ?? b);
};
}

View File

@@ -0,0 +1 @@
<app-products-list></app-products-list>

View File

@@ -0,0 +1,17 @@
import {
Component,
} from '@angular/core';
import {ProductsListComponent} from '../../components/products-list/products-list.component';
@Component({
selector: 'app-products',
templateUrl: './products.component.html',
standalone: true,
imports: [
ProductsListComponent
],
styleUrls: ['./products.component.css']
})
export class ProductsComponent {
}