From 7c8f85a500b8d0f86c5a7c4607272c7e145290c8 Mon Sep 17 00:00:00 2001 From: Vincent Guillet Date: Sat, 1 Nov 2025 15:43:49 +0100 Subject: [PATCH] add Categories management: create CategoriesList component, update admin navbar, and integrate category handling in product forms --- client/src/app/app.routes.ts | 7 + .../admin-navbar/admin-navbar.component.html | 4 +- .../admin-navbar/admin-navbar.component.ts | 4 +- .../brands-list/brands-list.component.html | 2 +- .../brands-list/brands-list.component.ts | 5 +- .../categories-list.component.css | 65 +++++ .../categories-list.component.html | 44 ++++ .../categories-list.component.ts | 138 +++++++++++ .../category-dialog.component.css | 0 .../category-dialog.component.html | 13 + .../category-dialog.component.ts | 51 ++++ .../platform-dialog.component.ts | 2 +- .../platforms-list.component.css | 2 +- .../platforms-list.component.ts | 2 +- .../product-dialog.component.css | 0 .../product-dialog.component.html | 13 + .../product-dialog.component.ts | 62 +++++ .../product-form/product-form.component.css | 0 .../product-form/product-form.component.html | 1 + .../product-form/product-form.component.ts | 12 + .../products-list/products-list.component.css | 0 .../products-list.component.html | 104 ++++++++ .../products-list/products-list.component.ts | 138 +++++++++++ client/src/app/interfaces/category.ts | 4 + client/src/app/interfaces/condition.ts | 5 + client/src/app/interfaces/product.ts | 16 ++ .../add-product/add-product.component.html | 29 ++- .../add-product/add-product.component.ts | 224 +++++++++++++++--- .../app/pages/products/products.component.css | 0 .../pages/products/products.component.html | 1 + .../app/pages/products/products.component.ts | 17 ++ .../services/{brand => app}/brand.service.ts | 2 +- .../src/app/services/app/category.service.ts | 31 +++ .../src/app/services/app/condition.service.ts | 31 +++ .../{platform => app}/platform.service.ts | 2 +- .../src/app/services/app/product.service.ts | 31 +++ .../app/services/product/product.service.ts | 13 - 37 files changed, 1009 insertions(+), 66 deletions(-) create mode 100644 client/src/app/components/categories-list/categories-list.component.css create mode 100644 client/src/app/components/categories-list/categories-list.component.html create mode 100644 client/src/app/components/categories-list/categories-list.component.ts create mode 100644 client/src/app/components/category-dialog/category-dialog.component.css create mode 100644 client/src/app/components/category-dialog/category-dialog.component.html create mode 100644 client/src/app/components/category-dialog/category-dialog.component.ts create mode 100644 client/src/app/components/product-dialog/product-dialog.component.css create mode 100644 client/src/app/components/product-dialog/product-dialog.component.html create mode 100644 client/src/app/components/product-dialog/product-dialog.component.ts create mode 100644 client/src/app/components/product-form/product-form.component.css create mode 100644 client/src/app/components/product-form/product-form.component.html create mode 100644 client/src/app/components/product-form/product-form.component.ts create mode 100644 client/src/app/components/products-list/products-list.component.css create mode 100644 client/src/app/components/products-list/products-list.component.html create mode 100644 client/src/app/components/products-list/products-list.component.ts create mode 100644 client/src/app/interfaces/category.ts create mode 100644 client/src/app/interfaces/condition.ts create mode 100644 client/src/app/interfaces/product.ts create mode 100644 client/src/app/pages/products/products.component.css create mode 100644 client/src/app/pages/products/products.component.html create mode 100644 client/src/app/pages/products/products.component.ts rename client/src/app/services/{brand => app}/brand.service.ts (92%) create mode 100644 client/src/app/services/app/category.service.ts create mode 100644 client/src/app/services/app/condition.service.ts rename client/src/app/services/{platform => app}/platform.service.ts (92%) create mode 100644 client/src/app/services/app/product.service.ts delete mode 100644 client/src/app/services/product/product.service.ts diff --git a/client/src/app/app.routes.ts b/client/src/app/app.routes.ts index 61efc96..c40560e 100644 --- a/client/src/app/app.routes.ts +++ b/client/src/app/app.routes.ts @@ -8,6 +8,7 @@ import {authOnlyCanActivate, authOnlyCanMatch} from './guards/auth-only.guard'; import {AdminComponent} from './pages/admin/admin.component'; import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard'; import {AddProductComponent} from './pages/add-product/add-product.component'; +import {ProductsComponent} from './pages/products/products.component'; export const routes: Routes = [ { @@ -48,6 +49,12 @@ export const routes: Routes = [ canMatch: [adminOnlyCanMatch], canActivate: [adminOnlyCanActivate] }, + { + path : 'products', + component: ProductsComponent, + canMatch: [authOnlyCanMatch], + canActivate: [authOnlyCanActivate] + }, { path : 'add-product', component: AddProductComponent, diff --git a/client/src/app/components/admin-navbar/admin-navbar.component.html b/client/src/app/components/admin-navbar/admin-navbar.component.html index ec470ab..c9dba51 100644 --- a/client/src/app/components/admin-navbar/admin-navbar.component.html +++ b/client/src/app/components/admin-navbar/admin-navbar.component.html @@ -5,5 +5,7 @@ - Catégories + + + diff --git a/client/src/app/components/admin-navbar/admin-navbar.component.ts b/client/src/app/components/admin-navbar/admin-navbar.component.ts index 818dea2..2fcac2c 100644 --- a/client/src/app/components/admin-navbar/admin-navbar.component.ts +++ b/client/src/app/components/admin-navbar/admin-navbar.component.ts @@ -5,6 +5,7 @@ import {RouterLink, RouterLinkActive} from '@angular/router'; import {MatTab, MatTabGroup} from '@angular/material/tabs'; import {BrandsListComponent} from '../brands-list/brands-list.component'; import {PlatformsListComponent} from '../platforms-list/platforms-list.component'; +import {CategoriesListComponent} from '../categories-list/categories-list.component'; @Component({ selector: 'app-admin-navbar', @@ -18,7 +19,8 @@ import {PlatformsListComponent} from '../platforms-list/platforms-list.component MatTabGroup, MatTab, BrandsListComponent, - PlatformsListComponent + PlatformsListComponent, + CategoriesListComponent ], templateUrl: './admin-navbar.component.html', styleUrl: './admin-navbar.component.css' diff --git a/client/src/app/components/brands-list/brands-list.component.html b/client/src/app/components/brands-list/brands-list.component.html index 1ed48ec..c2a37e6 100644 --- a/client/src/app/components/brands-list/brands-list.component.html +++ b/client/src/app/components/brands-list/brands-list.component.html @@ -38,7 +38,7 @@ @if (!brands || brands.length === 0) {
- Aucune marque trouvée. + Aucune plateforme trouvée.
} diff --git a/client/src/app/components/brands-list/brands-list.component.ts b/client/src/app/components/brands-list/brands-list.component.ts index ee01363..b7e426b 100644 --- a/client/src/app/components/brands-list/brands-list.component.ts +++ b/client/src/app/components/brands-list/brands-list.component.ts @@ -25,7 +25,7 @@ import {MatButton, MatIconButton} from '@angular/material/button'; import {MatIcon} from '@angular/material/icon'; import {MatFormField} from '@angular/material/form-field'; import {MatInput} from '@angular/material/input'; -import {BrandService} from '../../services/brand/brand.service'; +import {BrandService} from '../../services/app/brand.service'; import {MatDialog} from '@angular/material/dialog'; import { BrandDialogComponent } from '../brand-dialog/brand-dialog.component'; @@ -94,6 +94,7 @@ export class BrandsListComponent implements OnInit, AfterViewInit, OnChanges { next: (brands:Brand[]) => { this.brands = brands || [] this.dataSource.data = this.brands; + console.log("Fetched brands:", this.brands); }, error: () => this.brands = [] }); @@ -101,7 +102,7 @@ export class BrandsListComponent implements OnInit, AfterViewInit, OnChanges { onAdd(): void { const ref = this.dialog.open(BrandDialogComponent, { - data: { brand: { id: '', name: '' } }, + data: { brand: { id: '', name: '', brand: undefined } }, width: '420px' }); diff --git a/client/src/app/components/categories-list/categories-list.component.css b/client/src/app/components/categories-list/categories-list.component.css new file mode 100644 index 0000000..87a4b85 --- /dev/null +++ b/client/src/app/components/categories-list/categories-list.component.css @@ -0,0 +1,65 @@ +:host { + display: block; + box-sizing: border-box; + width: 100%; +} + +.container { + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +.toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.filter { + max-width: 240px; + width: 100%; +} + +table { + width: 100%; + overflow: auto; +} + +td, th { + word-break: break-word; + white-space: normal; +} + +.actions-cell { + display: flex; + gap: 8px; + justify-content: flex-end; + min-width: 120px; +} + +button.mat-icon-button { + width: 40px; + height: 40px; +} + +.no-brands { + text-align: center; + margin-top: 16px; + color: rgba(0,0,0,0.6); + padding: 8px 12px; +} + +@media (max-width: 600px) { + .toolbar { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .actions-cell { + min-width: 0; + } +} diff --git a/client/src/app/components/categories-list/categories-list.component.html b/client/src/app/components/categories-list/categories-list.component.html new file mode 100644 index 0000000..3c5f4ef --- /dev/null +++ b/client/src/app/components/categories-list/categories-list.component.html @@ -0,0 +1,44 @@ +
+
+ + + + + +
+ + + + + + + + + + + + + + + + + +
Nom{{ category.name }} + + +
+ + + + @if (!categories || categories.length === 0) { +
+ Aucune catégorie trouvée. +
+ } +
diff --git a/client/src/app/components/categories-list/categories-list.component.ts b/client/src/app/components/categories-list/categories-list.component.ts new file mode 100644 index 0000000..5d9e0d4 --- /dev/null +++ b/client/src/app/components/categories-list/categories-list.component.ts @@ -0,0 +1,138 @@ +import { + Component, + Input, + Output, + EventEmitter, + ViewChild, + AfterViewInit, + OnChanges, + SimpleChanges, + OnInit, + inject +} from '@angular/core'; +import { + MatCell, MatCellDef, + MatColumnDef, + MatHeaderCell, + MatHeaderCellDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef, + MatTable, + MatTableDataSource +} from '@angular/material/table'; +import {MatPaginator} from '@angular/material/paginator'; +import {MatSort} from '@angular/material/sort'; +import {Category} from '../../interfaces/category'; +import {MatButton, MatIconButton} from '@angular/material/button'; +import {MatIcon} from '@angular/material/icon'; +import {MatFormField} from '@angular/material/form-field'; +import {MatInput} from '@angular/material/input'; +import {CategoryService} from '../../services/app/category.service'; +import {MatDialog} from '@angular/material/dialog'; +import { CategoryDialogComponent } from '../category-dialog/category-dialog.component'; + +@Component({ + selector: 'app-categories-list', + templateUrl: './categories-list.component.html', + standalone: true, + imports: [ + MatButton, + MatIcon, + MatFormField, + MatInput, + MatTable, + MatColumnDef, + MatHeaderCell, + MatCell, + MatHeaderCellDef, + MatCellDef, + MatSort, + MatIconButton, + MatHeaderRow, + MatRow, + MatHeaderRowDef, + MatRowDef, + MatPaginator + ], + styleUrls: ['./categories-list.component.css'] +}) +export class CategoriesListComponent implements OnInit, AfterViewInit, OnChanges { + + @Input() categories: Category[] = []; + @Output() add = new EventEmitter(); + @Output() edit = new EventEmitter(); + @Output() delete = new EventEmitter(); + + displayedColumns: string[] = ['name', 'actions']; + dataSource = new MatTableDataSource([]); + + @ViewChild(MatPaginator) paginator!: MatPaginator; + @ViewChild(MatSort) sort!: MatSort; + + private readonly categoryService: CategoryService = inject(CategoryService); + private readonly dialog = inject(MatDialog); + + ngOnInit(): void { + if (!this.categories || this.categories.length === 0) { + this.loadCategories(); + } else { + this.dataSource.data = this.categories; + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['categories']) { + this.dataSource.data = this.categories || []; + } + } + + ngAfterViewInit(): void { + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + + loadCategories() { + this.categoryService.getCategories().subscribe({ + next: (categories:Category[]) => { + this.categories = categories || [] + this.dataSource.data = this.categories; + }, + error: () => this.categories = [] + }); + } + + onAdd(): void { + const ref = this.dialog.open(CategoryDialogComponent, { + data: { category: { id: '', name: '' } }, + width: '420px' + }); + + ref.afterClosed().subscribe((result?: Category) => { + if (result) { + this.add.emit(result); + this.categoryService.addCategory(result).subscribe(() => this.loadCategories()); + } + }); + } + + onEdit(category: Category): void { + const ref = this.dialog.open(CategoryDialogComponent, { + data: { category: { ...category } }, + width: '420px' + }); + + ref.afterClosed().subscribe((result?: Category) => { + if (result) { + this.edit.emit(result); + this.categoryService.updateCategory((category as any).id, result).subscribe(() => this.loadCategories()); + } + }); + } + + onDelete(category: Category): void { + this.delete.emit(category); + this.categoryService.deleteCategory((category as any).id).subscribe(() => this.loadCategories()); + } + + applyFilter(value: string): void { + this.dataSource.filter = (value || '').trim().toLowerCase(); + } +} diff --git a/client/src/app/components/category-dialog/category-dialog.component.css b/client/src/app/components/category-dialog/category-dialog.component.css new file mode 100644 index 0000000..e69de29 diff --git a/client/src/app/components/category-dialog/category-dialog.component.html b/client/src/app/components/category-dialog/category-dialog.component.html new file mode 100644 index 0000000..2a05c27 --- /dev/null +++ b/client/src/app/components/category-dialog/category-dialog.component.html @@ -0,0 +1,13 @@ +

{{ categoryExists ? 'Modifier la catégorie' : 'Nouvelle catégorie' }}

+ + + + Nom + + + + + + + + diff --git a/client/src/app/components/category-dialog/category-dialog.component.ts b/client/src/app/components/category-dialog/category-dialog.component.ts new file mode 100644 index 0000000..9d864f2 --- /dev/null +++ b/client/src/app/components/category-dialog/category-dialog.component.ts @@ -0,0 +1,51 @@ +import { Component, Inject } from '@angular/core'; +import { + MatDialogRef, + MAT_DIALOG_DATA, + MatDialogTitle, + MatDialogContent, + MatDialogActions +} from '@angular/material/dialog'; +import { Category } from '../../interfaces/category'; +import {MatFormField, MatLabel} from '@angular/material/form-field'; +import {MatInput} from '@angular/material/input'; +import {FormsModule} from '@angular/forms'; +import {MatButton} from '@angular/material/button'; + +@Component({ + selector: 'app-category-dialog', + standalone: true, + imports: [ + MatDialogTitle, + MatDialogContent, + MatFormField, + MatLabel, + MatInput, + FormsModule, + MatDialogActions, + MatButton + ], + templateUrl: './category-dialog.component.html' +}) +export class CategoryDialogComponent { + category: Category = { id: '', name: '' } + + constructor( + private readonly dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { category: Category } + ) { + this.category = { ...(data?.category || { id: '', name: '' }) }; + } + + get categoryExists(): boolean { + return !!this.data?.category?.id; + } + + save() { + this.dialogRef.close(this.category); + } + + cancel() { + this.dialogRef.close(); + } +} diff --git a/client/src/app/components/platform-dialog/platform-dialog.component.ts b/client/src/app/components/platform-dialog/platform-dialog.component.ts index 2d93833..cc3883a 100644 --- a/client/src/app/components/platform-dialog/platform-dialog.component.ts +++ b/client/src/app/components/platform-dialog/platform-dialog.component.ts @@ -14,7 +14,7 @@ import {Brand} from '../../interfaces/brand'; import {Platform} from '../../interfaces/platform'; import {MatOption} from '@angular/material/core'; import {MatSelect} from '@angular/material/select'; -import {BrandService} from '../../services/brand/brand.service'; +import {BrandService} from '../../services/app/brand.service'; @Component({ selector: 'app-platform-dialog', diff --git a/client/src/app/components/platforms-list/platforms-list.component.css b/client/src/app/components/platforms-list/platforms-list.component.css index 87a4b85..aeccac5 100644 --- a/client/src/app/components/platforms-list/platforms-list.component.css +++ b/client/src/app/components/platforms-list/platforms-list.component.css @@ -45,7 +45,7 @@ button.mat-icon-button { height: 40px; } -.no-brands { +.no-platforms { text-align: center; margin-top: 16px; color: rgba(0,0,0,0.6); diff --git a/client/src/app/components/platforms-list/platforms-list.component.ts b/client/src/app/components/platforms-list/platforms-list.component.ts index ea689fe..309315f 100644 --- a/client/src/app/components/platforms-list/platforms-list.component.ts +++ b/client/src/app/components/platforms-list/platforms-list.component.ts @@ -25,7 +25,7 @@ import {MatButton, MatIconButton} from '@angular/material/button'; import {MatIcon} from '@angular/material/icon'; import {MatFormField} from '@angular/material/form-field'; import {MatInput} from '@angular/material/input'; -import {PlatformService} from '../../services/platform/platform.service'; +import {PlatformService} from '../../services/app/platform.service'; import {MatDialog} from '@angular/material/dialog'; import { PlatformDialogComponent } from '../platform-dialog/platform-dialog.component'; diff --git a/client/src/app/components/product-dialog/product-dialog.component.css b/client/src/app/components/product-dialog/product-dialog.component.css new file mode 100644 index 0000000..e69de29 diff --git a/client/src/app/components/product-dialog/product-dialog.component.html b/client/src/app/components/product-dialog/product-dialog.component.html new file mode 100644 index 0000000..0958e21 --- /dev/null +++ b/client/src/app/components/product-dialog/product-dialog.component.html @@ -0,0 +1,13 @@ +

{{ productExists ? 'Modifier la plateforme' : 'Nouvelle plateforme' }}

+ + + + Nom + + + + + + + + diff --git a/client/src/app/components/product-dialog/product-dialog.component.ts b/client/src/app/components/product-dialog/product-dialog.component.ts new file mode 100644 index 0000000..1fad355 --- /dev/null +++ b/client/src/app/components/product-dialog/product-dialog.component.ts @@ -0,0 +1,62 @@ +import {Component, Inject} from '@angular/core'; +import { + MatDialogRef, + MAT_DIALOG_DATA, + MatDialogTitle, + MatDialogContent, + MatDialogActions +} from '@angular/material/dialog'; +import {Product} from '../../interfaces/product'; +import {MatFormField, MatLabel} from '@angular/material/form-field'; +import {MatInput} from '@angular/material/input'; +import {FormsModule} from '@angular/forms'; +import {MatButton} from '@angular/material/button'; + +@Component({ + selector: 'app-product-dialog', + standalone: true, + imports: [ + MatDialogTitle, + MatDialogContent, + MatFormField, + MatLabel, + MatInput, + FormsModule, + MatDialogActions, + MatButton + ], + templateUrl: './product-dialog.component.html' +}) +export class ProductDialogComponent { + product: Product = { + id: '', + title: '', + description: '', + price: 0, + quantity: 0, + complete: false, + manualIncluded: false, + category: undefined, + platform: undefined, + condition: undefined + }; + + constructor( + private readonly dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { product: Product } + ) { + this.product = {...(data?.product || {id: '', name: ''})}; + } + + get productExists(): boolean { + return !!this.data?.product?.id; + } + + save() { + this.dialogRef.close(this.product); + } + + cancel() { + this.dialogRef.close(); + } +} diff --git a/client/src/app/components/product-form/product-form.component.css b/client/src/app/components/product-form/product-form.component.css new file mode 100644 index 0000000..e69de29 diff --git a/client/src/app/components/product-form/product-form.component.html b/client/src/app/components/product-form/product-form.component.html new file mode 100644 index 0000000..99b2c89 --- /dev/null +++ b/client/src/app/components/product-form/product-form.component.html @@ -0,0 +1 @@ +

product-form works!

diff --git a/client/src/app/components/product-form/product-form.component.ts b/client/src/app/components/product-form/product-form.component.ts new file mode 100644 index 0000000..8d9fa7a --- /dev/null +++ b/client/src/app/components/product-form/product-form.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-product-form', + standalone: true, + imports: [], + templateUrl: './product-form.component.html', + styleUrl: './product-form.component.css' +}) +export class ProductFormComponent { + +} diff --git a/client/src/app/components/products-list/products-list.component.css b/client/src/app/components/products-list/products-list.component.css new file mode 100644 index 0000000..e69de29 diff --git a/client/src/app/components/products-list/products-list.component.html b/client/src/app/components/products-list/products-list.component.html new file mode 100644 index 0000000..83556fd --- /dev/null +++ b/client/src/app/components/products-list/products-list.component.html @@ -0,0 +1,104 @@ +
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Nom{{ product.title }}Description{{ product.description }}Catégorie{{ product.category.name }}Plateforme{{ product.platform.name }}État{{ product.condition.displayName }}Complet + @if (product.complete) { + check_circle + } @else { + cancel + } + Notice + @if (product.manual) { + check_circle + } @else { + cancel + } + Prix{{ product.price | currency:'EUR' }}Quantité{{ product.quantity }} + + +
+ + + + @if (!products || products.length === 0) { +
+ Aucun produit trouvé. +
+ } +
diff --git a/client/src/app/components/products-list/products-list.component.ts b/client/src/app/components/products-list/products-list.component.ts new file mode 100644 index 0000000..48a03e9 --- /dev/null +++ b/client/src/app/components/products-list/products-list.component.ts @@ -0,0 +1,138 @@ +import { + Component, + Input, + Output, + EventEmitter, + ViewChild, + AfterViewInit, + OnChanges, + SimpleChanges, + OnInit, + inject +} from '@angular/core'; +import { + MatCell, MatCellDef, MatColumnDef, MatHeaderCell, + MatHeaderCellDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef, MatTable, + MatTableDataSource +} from '@angular/material/table'; +import {MatPaginator} from '@angular/material/paginator'; +import {MatSort} from '@angular/material/sort'; +import {Product} from '../../interfaces/product'; +import {ProductService} from '../../services/app/product.service'; +import {MatDialog} from '@angular/material/dialog'; +import {ProductDialogComponent} from '../product-dialog/product-dialog.component'; +import {MatButton, MatIconButton} from '@angular/material/button'; +import {MatFormField} from '@angular/material/form-field'; +import {MatIcon} from '@angular/material/icon'; +import {MatInput} from '@angular/material/input'; +import {CurrencyPipe} from '@angular/common'; + +@Component({ + selector: 'app-products-list', + templateUrl: './products-list.component.html', + standalone: true, + imports: [ + MatButton, + MatCell, + MatCellDef, + MatColumnDef, + MatFormField, + MatHeaderCell, + MatHeaderRow, + MatHeaderRowDef, + MatIcon, + MatIconButton, + MatInput, + MatPaginator, + MatRow, + MatRowDef, + MatSort, + MatTable, + MatHeaderCellDef, + CurrencyPipe + ], + styleUrls: ['./products-list.component.css'] +}) +export class ProductsListComponent implements OnInit, AfterViewInit, OnChanges { + + @Input() products: Product[] = []; + @Output() add = new EventEmitter(); + @Output() edit = new EventEmitter(); + @Output() delete = new EventEmitter(); + + displayedColumns: string[] = ['title', 'description', 'category', 'platform', 'condition', 'complete', 'manual', 'price', 'quantity', 'actions']; + dataSource = new MatTableDataSource([]); + + @ViewChild(MatPaginator) paginator!: MatPaginator; + @ViewChild(MatSort) sort!: MatSort; + + private readonly productService: ProductService = inject(ProductService); + private readonly dialog = inject(MatDialog); + + ngOnInit(): void { + if (!this.products || this.products.length === 0) { + this.loadProducts(); + } else { + this.dataSource.data = this.products; + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['products']) { + this.dataSource.data = this.products || []; + } + } + + ngAfterViewInit(): void { + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + + loadProducts() { + this.productService.getProducts().subscribe({ + next: (products: Product[]) => { + this.products = products || [] + this.dataSource.data = this.products; + console.log("Fetched products:", this.products); + }, + error: () => this.products = [] + }); + } + + onAdd(): void { + const ref = this.dialog.open(ProductDialogComponent, { + data: {product: {id: '', name: '', brand: undefined}}, + width: '420px' + }); + + ref.afterClosed().subscribe((result?: Product) => { + if (result) { + this.add.emit(result); + this.productService.addProduct(result).subscribe(() => this.loadProducts()); + } + }); + } + + onEdit(product: Product): void { + const ref = this.dialog.open(ProductDialogComponent, { + data: {product: {...product}}, + width: '420px' + }); + + ref.afterClosed().subscribe((result?: Product) => { + if (result) { + this.edit.emit(result); + this.productService.updateProduct((product as any).id, result).subscribe(() => this.loadProducts()); + } + }); + } + + onDelete(product: Product): void { + this.delete.emit(product); + this.productService.deleteProduct((product as any).id).subscribe(() => this.loadProducts()); + } + + applyFilter(value: string): void { + this.dataSource.filter = (value || '').trim().toLowerCase(); + } +} diff --git a/client/src/app/interfaces/category.ts b/client/src/app/interfaces/category.ts new file mode 100644 index 0000000..e761b1f --- /dev/null +++ b/client/src/app/interfaces/category.ts @@ -0,0 +1,4 @@ +export interface Category { + id: string | number; + name: string; +} diff --git a/client/src/app/interfaces/condition.ts b/client/src/app/interfaces/condition.ts new file mode 100644 index 0000000..43687b0 --- /dev/null +++ b/client/src/app/interfaces/condition.ts @@ -0,0 +1,5 @@ +export interface Condition { + id: string | number; + name: string; + displayName: string; +} diff --git a/client/src/app/interfaces/product.ts b/client/src/app/interfaces/product.ts new file mode 100644 index 0000000..7138219 --- /dev/null +++ b/client/src/app/interfaces/product.ts @@ -0,0 +1,16 @@ +import {Category} from './category'; +import {Platform} from './platform'; +import {Condition} from './condition'; + +export interface Product { + id: string | number; + title: string; + description: string; + price: number; + quantity: number; + complete: boolean; + manualIncluded: boolean; + category: Category | undefined; + platform: Platform | undefined; + condition: Condition | undefined; +} diff --git a/client/src/app/pages/add-product/add-product.component.html b/client/src/app/pages/add-product/add-product.component.html index 84487e4..80f7073 100644 --- a/client/src/app/pages/add-product/add-product.component.html +++ b/client/src/app/pages/add-product/add-product.component.html @@ -15,7 +15,6 @@ name="title" formControlName="title" type="text" - placeholder="Ceci est un titre" required> @if (isFieldInvalid('title')) { {{ getFieldError('title') }} @@ -39,29 +38,29 @@ Catégorie - - Option 1 - Option 2 - Option 3 + + @for (category of categories; track category.id) { + {{ category.name }} + } État - - Option 1 - Option 2 - Option 3 + + @for (condition of conditions; track condition.id) { + {{ condition.displayName }} + } Marque - - @for (brand of brands; track brand.id) { - {{ brand.name }} + + @for (brand of filteredBrands; track brand.id) { + {{ brand.name }} } @@ -69,9 +68,9 @@ Plateforme - - @for (platform of platforms; track platform.id) { - {{ platform.name }} + + @for (platform of filteredPlatforms; track platform.id) { + {{ platform.name }} } diff --git a/client/src/app/pages/add-product/add-product.component.ts b/client/src/app/pages/add-product/add-product.component.ts index e9e4f9c..ac6f109 100644 --- a/client/src/app/pages/add-product/add-product.component.ts +++ b/client/src/app/pages/add-product/add-product.component.ts @@ -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>(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); + }; } diff --git a/client/src/app/pages/products/products.component.css b/client/src/app/pages/products/products.component.css new file mode 100644 index 0000000..e69de29 diff --git a/client/src/app/pages/products/products.component.html b/client/src/app/pages/products/products.component.html new file mode 100644 index 0000000..ff56460 --- /dev/null +++ b/client/src/app/pages/products/products.component.html @@ -0,0 +1 @@ + diff --git a/client/src/app/pages/products/products.component.ts b/client/src/app/pages/products/products.component.ts new file mode 100644 index 0000000..d2cf933 --- /dev/null +++ b/client/src/app/pages/products/products.component.ts @@ -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 { + +} diff --git a/client/src/app/services/brand/brand.service.ts b/client/src/app/services/app/brand.service.ts similarity index 92% rename from client/src/app/services/brand/brand.service.ts rename to client/src/app/services/app/brand.service.ts index 2c43e9c..e7801aa 100644 --- a/client/src/app/services/brand/brand.service.ts +++ b/client/src/app/services/app/brand.service.ts @@ -8,7 +8,7 @@ import {Brand} from '../../interfaces/brand'; export class BrandService { private readonly http = inject(HttpClient); - private readonly BASE_URL = 'http://localhost:3000/api/brands'; + private readonly BASE_URL = 'http://localhost:3000/api/app/brands'; getBrands() { return this.http.get(this.BASE_URL, {withCredentials: true}); diff --git a/client/src/app/services/app/category.service.ts b/client/src/app/services/app/category.service.ts new file mode 100644 index 0000000..0c47cb3 --- /dev/null +++ b/client/src/app/services/app/category.service.ts @@ -0,0 +1,31 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Category} from '../../interfaces/category'; + +@Injectable({ + providedIn: 'root' +}) +export class CategoryService { + + private readonly http = inject(HttpClient); + private readonly BASE_URL = 'http://localhost:3000/api/app/categories'; + + getCategories() { + return this.http.get(this.BASE_URL, {withCredentials: true}); + } + + addCategory(category: Category) { + console.log("Adding category:", category); + return this.http.post(this.BASE_URL, category, {withCredentials: true}); + } + + updateCategory(id: string, category: Category) { + console.log("Updating category:", id, category); + return this.http.put(`${this.BASE_URL}/${id}`, category, {withCredentials: true}); + } + + deleteCategory(id: string) { + console.log("Deleting category:", id); + return this.http.delete(`${this.BASE_URL}/${id}`, {withCredentials: true}); + } +} diff --git a/client/src/app/services/app/condition.service.ts b/client/src/app/services/app/condition.service.ts new file mode 100644 index 0000000..f4eaa3b --- /dev/null +++ b/client/src/app/services/app/condition.service.ts @@ -0,0 +1,31 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Condition} from '../../interfaces/condition'; + +@Injectable({ + providedIn: 'root' +}) +export class ConditionService { + + private readonly http = inject(HttpClient); + private readonly BASE_URL = 'http://localhost:3000/api/app/conditions'; + + getConditions() { + return this.http.get(this.BASE_URL, {withCredentials: true}); + } + + addCondition(condition: Condition) { + console.log("Adding condition:", condition); + return this.http.post(this.BASE_URL, condition, {withCredentials: true}); + } + + updateCondition(id: string, condition: Condition) { + console.log("Updating condition:", id, condition); + return this.http.put(`${this.BASE_URL}/${id}`, condition, {withCredentials: true}); + } + + deleteCondition(id: string) { + console.log("Deleting condition:", id); + return this.http.delete(`${this.BASE_URL}/${id}`, {withCredentials: true}); + } +} diff --git a/client/src/app/services/platform/platform.service.ts b/client/src/app/services/app/platform.service.ts similarity index 92% rename from client/src/app/services/platform/platform.service.ts rename to client/src/app/services/app/platform.service.ts index b717eac..8147153 100644 --- a/client/src/app/services/platform/platform.service.ts +++ b/client/src/app/services/app/platform.service.ts @@ -8,7 +8,7 @@ import {Platform} from '../../interfaces/platform'; export class PlatformService { private readonly http = inject(HttpClient); - private readonly BASE_URL = 'http://localhost:3000/api/platforms'; + private readonly BASE_URL = 'http://localhost:3000/api/app/platforms'; getPlatforms() { return this.http.get(this.BASE_URL, {withCredentials: true}); diff --git a/client/src/app/services/app/product.service.ts b/client/src/app/services/app/product.service.ts new file mode 100644 index 0000000..56f64ea --- /dev/null +++ b/client/src/app/services/app/product.service.ts @@ -0,0 +1,31 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Product} from '../../interfaces/product'; + +@Injectable({ + providedIn: 'root' +}) +export class ProductService { + + private readonly http = inject(HttpClient); + private readonly BASE_URL = 'http://localhost:3000/api/app/products'; + + getProducts() { + return this.http.get(this.BASE_URL, {withCredentials: true}); + } + + addProduct(product: Product) { + console.log("Adding product:", product); + return this.http.post(this.BASE_URL, product, {withCredentials: true}); + } + + updateProduct(id: string, product: Product) { + console.log("Updating product:", id, product); + return this.http.put(`${this.BASE_URL}/${id}`, product, {withCredentials: true}); + } + + deleteProduct(id: string) { + console.log("Deleting product:", id); + return this.http.delete(`${this.BASE_URL}/${id}`, {withCredentials: true}); + } +} diff --git a/client/src/app/services/product/product.service.ts b/client/src/app/services/product/product.service.ts deleted file mode 100644 index af21b65..0000000 --- a/client/src/app/services/product/product.service.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {inject, Injectable} from '@angular/core'; -import {HttpClient} from '@angular/common/http'; - -@Injectable({ - providedIn: 'root' -}) -export class ProductService { - - private readonly http = inject(HttpClient); - private readonly BASE_URL = 'http://localhost:3000/api/products'; - - constructor() { } -}