refactor: rename components and update dialog implementations; add confirm dialog for deletion actions

This commit is contained in:
Vincent Guillet
2025-11-04 18:13:37 +01:00
parent 2fe52830d8
commit 3bc2a27d76
57 changed files with 396 additions and 1109 deletions

View File

@@ -1,32 +0,0 @@
.auth-wrap {
min-height: 100vh;
display: grid;
place-items: center;
padding: 16px;
}
.auth-card {
width: 100%;
max-width: 520px;
}
.form-grid {
display: grid;
gap: 16px;
margin-top: 16px;
}
.actions {
display: flex;
margin: 8px;
button {
display: inline-flex;
align-items: center;
gap: 8px;
}
}
.ml-8 {
margin-left: 8px;
}

View File

@@ -1,146 +0,0 @@
<section class="auth-wrap">
<mat-card class="auth-card">
<mat-card-header>
<mat-card-title>Ajouter un produit</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="addProductForm" (ngSubmit)="onProductAdd()" class="form-grid">
<!-- Title -->
<mat-form-field appearance="outline">
<mat-label>Titre</mat-label>
<input matInput
id="title"
name="title"
formControlName="title"
type="text"
required>
@if (isFieldInvalid('title')) {
<mat-error>{{ getFieldError('title') }}</mat-error>
}
</mat-form-field>
<!-- Description -->
<mat-form-field appearance="outline">
<mat-label>Description</mat-label>
<input matInput
id="description"
name="description"
formControlName="description"
type="text"
required>
@if (isFieldInvalid('description')) {
<mat-error>{{ getFieldError('description') }}</mat-error>
}
</mat-form-field>
<!-- Category -->
<mat-form-field appearance="outline">
<mat-label>Catégorie</mat-label>
<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 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 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>
<!-- Platform -->
<mat-form-field appearance="outline">
<mat-label>Plateforme</mat-label>
<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>
<!-- Complete state -->
<mat-checkbox formControlName="complete" id="complete">
Complet
</mat-checkbox>
@if (isFieldInvalid('complete')) {
<div class="mat-caption mat-error">{{ getFieldError('complete') }}</div>
}
<!-- manual included -->
<mat-checkbox formControlName="manual" id="manual">
Avec notice
</mat-checkbox>
@if (isFieldInvalid('manual')) {
<div class="mat-caption mat-error">{{ getFieldError('manual') }}</div>
}
<!-- Price -->
<mat-form-field appearance="outline">
<mat-label>Prix TTC</mat-label>
<input matInput
id="price"
name="price"
formControlName="price"
type="text"
required>
@if (isFieldInvalid('price')) {
<mat-error>{{ getFieldError('price') }}</mat-error>
}
</mat-form-field>
<!-- Quantity -->
<mat-form-field appearance="outline">
<mat-label>Quantité</mat-label>
<input matInput
id="quantity"
name="quantity"
formControlName="quantity"
type="text"
required>
@if (isFieldInvalid('quantity')) {
<mat-error>{{ getFieldError('quantity') }}</mat-error>
}
</mat-form-field>
<!-- Submit Button -->
<div class="actions">
<button mat-raised-button color="primary"
type="submit"
[disabled]="isLoading || addProductForm.invalid">
@if (isLoading) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
<span class="ml-8">Ajout du produit…</span>
} @else {
Ajouter le produit
}
</button>
</div>
</form>
</mat-card-content>
<mat-divider></mat-divider>
<mat-card-actions align="end">
<span class="mat-body-small">
<a [routerLink]="'/login'">Voir la liste des produits</a>
</span>
</mat-card-actions>
</mat-card>
</section>

View File

@@ -1,345 +0,0 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {
AbstractControl,
FormBuilder,
FormGroup,
FormsModule,
ReactiveFormsModule, ValidatorFn,
Validators
} from "@angular/forms";
import {MatButton} from "@angular/material/button";
import {
MatCard,
MatCardActions,
MatCardContent,
MatCardHeader,
MatCardTitle
} from "@angular/material/card";
import {MatCheckbox} from "@angular/material/checkbox";
import {MatDivider} from "@angular/material/divider";
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 {RouterLink} from '@angular/router';
import {Subscription} from 'rxjs';
import {BrandService} from '../../services/app/brand.service';
import {Brand} from '../../interfaces/brand';
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',
standalone: true,
imports: [
FormsModule,
MatButton,
MatCard,
MatCardActions,
MatCardContent,
MatCardHeader,
MatCardTitle,
MatCheckbox,
MatDivider,
MatError,
MatFormField,
MatInput,
MatLabel,
MatProgressSpinner,
ReactiveFormsModule,
MatSelect,
MatOption,
RouterLink
],
templateUrl: './add-product.component.html',
styleUrl: './add-product.component.css'
})
export class AddProductComponent implements OnInit, OnDestroy {
addProductForm: FormGroup;
isSubmitted = false;
isLoading = false;
brands: Brand[] = [];
platforms: Platform[] = [];
categories: Category[] = [];
conditions: Condition[] = [];
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 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({
title: ['', [
Validators.required,
Validators.minLength(3),
Validators.maxLength(50),
Validators.pattern(/^[\p{L}\p{N}\s]+$/u)
]],
description: ['', [
Validators.required,
Validators.minLength(10),
Validators.maxLength(255),
Validators.pattern(/^[\p{L}\p{N}\s]+$/u)
]],
category: ['', [
Validators.required
]],
condition: ['', [
Validators.required
]],
// stocker des ids (string|number) dans les controls
brand: ['', [
Validators.required
]],
platform: ['', [
Validators.required
]],
complete: [true],
manual: [true],
price: ['', [
Validators.required,
Validators.pattern(/^\d+([.,]\d{1,2})?$/),
this.priceRangeValidator(0, 9999)
]],
quantity: ['', [
Validators.required,
Validators.min(1),
Validators.max(999),
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.brandSubscription = this.brandService.getAll().subscribe({
next: (brands: Brand[]) => {
this.brands = this.normalizeIds(brands, 'id');
this.filteredBrands = [...this.brands];
},
error: (error: any) => {
console.error('Error fetching brands:', error);
},
complete: () => {
console.log('Finished fetching brands:', this.brands);
}
});
this.platformSubscription = this.platformService.getAll().subscribe({
next: (platforms: Platform[]) => {
this.platforms = this.normalizeIds(platforms, 'id');
this.filteredPlatforms = [...this.platforms];
},
error: (error: any) => {
console.error('Error fetching platforms:', error);
},
complete: () => {
console.log('Finished fetching platforms:', this.platforms);
}
});
this.categorySubscription = this.categoryService.getAll().subscribe({
next: (categories: Category[]) => {
this.categories = this.normalizeIds(categories, 'id');
},
error: (error: any) => {
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 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;
}
});
}
}
isFieldInvalid(fieldName: string): boolean {
const field = this.addProductForm.get(fieldName);
return Boolean(field && field.invalid && (field.dirty || field.touched || this.isSubmitted));
}
getFieldError(fieldName: string): string {
const field = this.addProductForm.get(fieldName);
if (field && field.errors) {
if (field.errors['required']) return `Ce champ est obligatoire`;
if (field.errors['email']) return `Format d'email invalide`;
if (field.errors['minlength']) return `Minimum ${field.errors['minlength'].requiredLength} caractères`;
if (field.errors['maxlength']) return `Maximum ${field.errors['maxlength'].requiredLength} caractères`;
}
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

@@ -1,5 +1,5 @@
import { Component } from '@angular/core';
import {AdminNavbarComponent} from '../../components/admin-navbar/admin-navbar.component';
import {AdminNavbarComponent} from '../../components/navbar/admin-navbar/admin-navbar.component';
@Component({
selector: 'app-admin',

View File

@@ -3,8 +3,10 @@
<h1>Bonjour, {{ user.firstName }}!</h1>
<p>Que souhaitez-vous faire ?</p>
<br>
<button mat-flat-button [routerLink]="'/add-product'">Ajouter un nouveau produit</button>
<button mat-raised-button [routerLink]="'/products'">Voir la liste des produits</button>
<div class="home-actions">
<button mat-flat-button [routerLink]="'/products'">Voir la liste des produits</button>
<button mat-raised-button [routerLink]="'/admin'">Gérer la base de données</button>
</div>
} @else {
<h2>Gestion des produits</h2>
<div class="home-actions">

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HomeComponent } from './home.component';
describe('HomeComponent', () => {
let component: HomeComponent;
let fixture: ComponentFixture<HomeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HomeComponent]
})
.compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,16 +1,14 @@
import {Component, inject} from '@angular/core';
import {MatButton} from '@angular/material/button';
import {AuthService} from '../../services/auth/auth.service';
import {RouterLink} from '@angular/router';
import {AddProductComponent} from '../add-product/add-product.component';
import {Router, RouterLink} from '@angular/router';
@Component({
selector: 'app-home',
standalone: true,
imports: [
MatButton,
RouterLink,
AddProductComponent
RouterLink
],
templateUrl: './home.component.html',
styleUrl: './home.component.css'
@@ -18,6 +16,7 @@ import {AddProductComponent} from '../add-product/add-product.component';
export class HomeComponent {
protected readonly authService: AuthService = inject(AuthService);
protected readonly router: Router = inject(Router);
getUser() {
return this.authService.user();

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LoginComponent } from './login.component';
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LoginComponent]
})
.compileComponents();
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -50,12 +50,11 @@ export class LoginComponent implements OnDestroy {
this.loginSubscription = this.authService.login(
this.loginFormGroup.value as Credentials).subscribe({
next: (result: User | null | undefined) => {
console.log(result);
this.navigateHome();
alert('Login successful!');
},
error: (error) => {
console.log(error);
alert(error.message);
this.invalidCredentials = true;
}
});

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NotFoundComponent } from './not-found.component';
describe('NotFoundComponent', () => {
let component: NotFoundComponent;
let fixture: ComponentFixture<NotFoundComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NotFoundComponent]
})
.compileComponents();
fixture = TestBed.createComponent(NotFoundComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1 +1,7 @@
<app-products-list></app-products-list>
<ng-container>
@if (showList) {
<app-product-list></app-product-list>
}
</ng-container>
<router-outlet></router-outlet>

View File

@@ -1,17 +1,43 @@
import {
Component,
Component, inject,
} from '@angular/core';
import {ProductsListComponent} from '../../components/products-list/products-list.component';
import {ProductListComponent} from '../../components/list/product-list/product-list.component';
import {ActivatedRoute, NavigationEnd, Router, RouterOutlet} from '@angular/router';
import {filter, Subscription} from 'rxjs';
@Component({
selector: 'app-products',
selector: 'app-dialog',
templateUrl: './products.component.html',
standalone: true,
imports: [
ProductsListComponent
ProductListComponent,
RouterOutlet
],
styleUrls: ['./products.component.css']
})
export class ProductsComponent {
showList = true;
private sub?: Subscription;
private readonly router: Router = inject(Router);
private readonly route: ActivatedRoute = inject(ActivatedRoute);
ngOnInit(): void {
this.updateShowList(this.route);
this.sub = this.router.events.pipe(
filter(evt => evt instanceof NavigationEnd)
).subscribe(() => this.updateShowList(this.route));
}
private updateShowList(route: ActivatedRoute): void {
let current = route;
while (current.firstChild) {
current = current.firstChild;
}
this.showList = current === route;
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
}

View File

@@ -5,7 +5,11 @@
<mat-icon>account_circle</mat-icon>
</div>
<mat-card-title>{{ user.firstName }} {{ user.lastName }}</mat-card-title>
<mat-card-subtitle>{{ user.username }} ({{ user.role }})</mat-card-subtitle>
@if (user.role == "Administrator") {
<mat-card-subtitle>{{ user.username }} ({{ user.role }})</mat-card-subtitle>
} @else {
<mat-card-subtitle>{{ user.username }}</mat-card-subtitle>
}
</mat-card-header>
<mat-card-content>
<p>

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProfileComponent } from './profile.component';
describe('ProfileComponent', () => {
let component: ProfileComponent;
let fixture: ComponentFixture<ProfileComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProfileComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ProfileComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});