diff --git a/client/src/app/app.routes.ts b/client/src/app/app.routes.ts index 064ceea..2b0bda3 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 {ProductsComponent} from './pages/products/products.component'; +import {AddProductComponent} from './pages/add-product/add-product.component'; export const routes: Routes = [ { @@ -54,6 +55,12 @@ export const routes: Routes = [ canMatch: [authOnlyCanMatch], canActivate: [authOnlyCanActivate], }, + { + path: 'products/add', + component: AddProductComponent, + canMatch: [authOnlyCanMatch], + canActivate: [authOnlyCanActivate], + }, { path: '**', redirectTo: '' diff --git a/client/src/app/components/dialog/generic-dialog/generic-dialog.component.html b/client/src/app/components/dialog/generic-dialog/generic-dialog.component.html index f13a2e3..7f39b11 100644 --- a/client/src/app/components/dialog/generic-dialog/generic-dialog.component.html +++ b/client/src/app/components/dialog/generic-dialog/generic-dialog.component.html @@ -1,8 +1,8 @@
-

{{ data?.title ?? 'Edit' }}

+

{{ data.title ?? 'Edit' }}

- @for (f of (fields ?? []); track $index) { + @for (f of fields; track $index) { @if (f.type === 'checkbox') { @@ -12,7 +12,7 @@ {{ f.label }} - + @let opts = (f.options ?? (f.options$ | async) ?? []); @for (opt of opts; track $index) { diff --git a/client/src/app/components/dialog/generic-dialog/generic-dialog.component.ts b/client/src/app/components/dialog/generic-dialog/generic-dialog.component.ts index ebea0cd..9f9ccef 100644 --- a/client/src/app/components/dialog/generic-dialog/generic-dialog.component.ts +++ b/client/src/app/components/dialog/generic-dialog/generic-dialog.component.ts @@ -1,23 +1,13 @@ -import {Component, Inject, OnInit} from '@angular/core'; -import {FormBuilder, FormGroup, ReactiveFormsModule} from '@angular/forms'; -import {MatDialogRef, MAT_DIALOG_DATA, MatDialogModule} from '@angular/material/dialog'; +import {Component, Inject, OnInit, OnDestroy} from '@angular/core'; import {CommonModule} from '@angular/common'; +import {FormBuilder, FormGroup, ReactiveFormsModule} from '@angular/forms'; +import {MatDialogModule, MatDialogRef, MAT_DIALOG_DATA} from '@angular/material/dialog'; import {MatFormFieldModule} from '@angular/material/form-field'; import {MatInputModule} from '@angular/material/input'; -import {MatButtonModule} from '@angular/material/button'; -import {MatCheckboxModule} from '@angular/material/checkbox'; import {MatSelectModule} from '@angular/material/select'; -import {Observable} from 'rxjs'; - -type Field = { - key: string; - label: string; - type?: 'text' | 'number' | 'textarea' | 'checkbox' | 'select'; - options?: any[]; // options statiques - options$?: Observable; // options dynamiques - displayKey?: string; // clé à afficher (ex: 'name') - valueKey?: string; // clé valeur (ex: 'id'), si absente la valeur entière de l'objet est utilisée -}; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatButtonModule} from '@angular/material/button'; +import {Subscription} from 'rxjs'; @Component({ selector: 'app-generic-dialog', @@ -28,41 +18,161 @@ type Field = { MatDialogModule, MatFormFieldModule, MatInputModule, - MatButtonModule, + MatSelectModule, MatCheckboxModule, - MatSelectModule + MatButtonModule ], - templateUrl: './generic-dialog.component.html' + templateUrl: './generic-dialog.component.html', }) -export class GenericDialogComponent implements OnInit { +export class GenericDialogComponent implements OnInit, OnDestroy { form!: FormGroup; - fields?: Field[]; + fields: any[] = []; + compareFns = new Map boolean>(); + optionsCache = new Map(); + private readonly subscriptions: Subscription[] = []; constructor( private readonly fb: FormBuilder, private readonly dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data?: { item?: any; fields?: Field[]; title?: string } + @Inject(MAT_DIALOG_DATA) public data: { title?: string; fields?: any[]; model?: any } ) { + this.fields = data?.fields ?? []; } ngOnInit(): void { - this.fields = this.data?.fields ?? []; - this.form = this.fb.group({}); + const model = this.data?.model ?? {}; + const controls: { [key: string]: any[] } = {}; + for (const f of this.fields) { - const initial = - this.data?.item?.[f.key] ?? - (f.type === 'checkbox' ? false : (f.type === 'select' ? null : '')); - this.form.addControl(f.key, this.fb.control(initial)); + if (f.options && Array.isArray(f.options)) { + this.optionsCache.set(f.key, f.options); + } + + if (f.options$ && typeof f.options$.subscribe === 'function') { + try { + const sub = (f.options$ as any).subscribe((opts: any[]) => { + this.optionsCache.set(f.key, opts || []); + console.log(`[GenericDialog] options for "${f.key}":`, opts); + }, (err: any) => { + console.warn(`[GenericDialog] error loading options for "${f.key}":`, err); + }); + this.subscriptions.push(sub); + } catch (err) { + console.warn(`[GenericDialog] cannot subscribe to options$ for "${f.key}":`, err); + } + } + + let value = model?.[f.key]; + + if (f.type === 'checkbox') { + value = !!value; + } else if (f.type === 'select') { + if (value && typeof value === 'object' && f.valueKey) { + value = value[f.valueKey] ?? value; + } + + if (value === null || value === undefined) { + const idKey = `${f.key}Id`; + if (model[idKey] !== undefined) { + value = model[idKey]; + } + } + + if ((value === null || value === undefined) && f.key === 'brand') { + const platBrand = model?.platform?.brand; + if (platBrand) { + value = f.valueKey ? platBrand[f.valueKey] ?? platBrand : platBrand; + } + } + + if ((value === null || value === undefined) && f.key === 'condition') { + const cond = model?.condition; + if (cond) { + value = f.valueKey ? cond[f.valueKey] ?? cond : cond; + } + } + } else { + value = value ?? f.default ?? null; + } + + console.log(`[GenericDialog] field "${f.key}" computed initial value:`, value, 'valueKey:', f.valueKey); + + controls[f.key] = [value ?? (f.default ?? null)]; + + const valueKey = f.valueKey; + this.compareFns.set(f.key, (a: any, b: any) => { + if (a === null || a === undefined || b === null || b === undefined) { + return a === b; + } + if (valueKey) { + const aval = (typeof a === 'object') ? (a[valueKey] ?? a) : a; + const bval = (typeof b === 'object') ? (b[valueKey] ?? b) : b; + return String(aval) === String(bval); + } + return a === b; + }); + } + + this.form = this.fb.group(controls); + console.log('[GenericDialog] form initial value:', this.form.value); + + for (const f of this.fields) { + if (f.type === 'select') { + const ctrl = this.form.get(f.key); + if (ctrl) { + const sub = ctrl.valueChanges.subscribe((v) => { + console.log(`[GenericDialog] form control "${f.key}" valueChanges ->`, v); + }); + this.subscriptions.push(sub); + } + } } } save(): void { - if (this.form.valid) { - this.dialogRef.close({...this.data?.item, ...this.form.value}); + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; } + + const raw = this.form.value; + const payload: any = {...raw}; + + for (const f of this.fields) { + if (f.type === 'select') { + const val = raw[f.key]; + if (f.valueKey) { + const opts = this.optionsCache.get(f.key) ?? []; + const found = opts.find((o: any) => String(o[f.valueKey]) === String(val)); + if (found) { + payload[f.key] = found; + } else if (val === null || val === undefined) { + payload[f.key] = null; + } else { + payload[f.key] = {[f.valueKey]: val}; + } + } else { + const opts = this.optionsCache.get(f.key) ?? []; + const found = opts.find((o: any) => o === val || JSON.stringify(o) === JSON.stringify(val)); + payload[f.key] = found ?? val; + } + } + } + + this.dialogRef.close(payload); } close(): void { - this.dialogRef.close(); + this.dialogRef.close(null); + } + + ngOnDestroy(): void { + for (let subscription of this.subscriptions) { + subscription.unsubscribe(); + } + } + + getCompareFn(field: any) { + return this.compareFns.get(field?.key) ?? ((a: any, b: any) => a === b); } } diff --git a/client/src/app/components/list/product-list/product-list.component.css b/client/src/app/components/list/product-list/product-list.component.css deleted file mode 100644 index 27c854b..0000000 --- a/client/src/app/components/list/product-list/product-list.component.css +++ /dev/null @@ -1,122 +0,0 @@ -/* ===== Container centré ===== */ -.generic-list { - display: flex; - flex-direction: column; - gap: 1rem; - padding: 1rem clamp(1rem, 3vw, 3rem); - max-width: 1200px; - margin: 0 auto; -} - -/* ===== Header ===== */ -.gl-header { - display: flex; - align-items: flex-end; - justify-content: flex-end; /* bouton à droite */ - gap: 1rem; - flex-wrap: wrap; - border-bottom: 1px solid rgba(0,0,0,.08); - padding-bottom: .75rem; -} - -/* ===== Cartes (filtre, table, pagination) ===== */ -.gl-block { - border: 1px solid rgba(0,0,0,.08); - border-radius: 12px; - background: var(--gl-surface, #fff); - box-shadow: - 0 1px 2px rgba(0,0,0,.04), - 0 2px 8px rgba(0,0,0,.06); -} - -/* ===== Barre de filtre ===== */ -.gl-filter-bar { padding: .75rem; } -.gl-filter { display: block; width: 100%; max-width: none; } - -/* ===== Tableau ===== */ -.gl-table-wrapper { - overflow: auto; - -webkit-overflow-scrolling: touch; - padding: 0.25rem 0.5rem; /* espace interne pour éviter l'effet "collé" */ -} - -.gl-table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - min-width: 720px; /* permet le scroll horizontal si trop de colonnes */ -} - -.gl-table th[mat-header-cell] { - position: sticky; - top: 0; - z-index: 2; - background: inherit; - box-shadow: inset 0 -1px 0 rgba(0,0,0,.08); -} - -.gl-table th[mat-header-cell], -.gl-table td[mat-cell] { - padding: 14px 18px; - vertical-align: middle; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Zebra + hover */ -.gl-table tr.mat-mdc-row:nth-child(odd) td[mat-cell] { background: rgba(0,0,0,.015); } -.gl-table tr.mat-mdc-row:hover td[mat-cell] { background: rgba(0,0,0,.035); } - -/* Actions */ -.actions-head { width: 1%; white-space: nowrap; } -.actions-cell { - display: flex; - align-items: center; - justify-content: center; - gap: .4rem; -} -.actions-cell .mat-mdc-icon-button { width: 40px; height: 40px; } - -/* ===== Pagination ===== */ -.gl-paginator-wrap { padding: .25rem .5rem; } -.gl-paginator { - margin-top: .25rem; - padding-top: .5rem; - border-top: 1px solid rgba(0,0,0,.08); - display: flex; - justify-content: flex-end; -} - -/* ===== Empty state ===== */ -.no-products { - padding: 1rem; - text-align: center; - color: rgba(0,0,0,.6); -} - -/* ===== Responsive ===== */ -@media (max-width: 799px) { - .generic-list { padding: 0.75rem 1rem; } - .gl-table { min-width: 0; } - .gl-table th[mat-header-cell], - .gl-table td[mat-cell] { white-space: normal; padding: 10px 12px; } - .actions-cell { justify-content: flex-start; } -} - -/* ===== Dark mode ===== */ -@media (prefers-color-scheme: dark) { - .gl-block { - background: #1b1b1b; - border-color: rgba(255,255,255,.08); - box-shadow: - 0 1px 2px rgba(0,0,0,.6), - 0 2px 8px rgba(0,0,0,.45); - } - .gl-header { border-bottom-color: rgba(255,255,255,.08); } - .gl-table th[mat-header-cell] { box-shadow: inset 0 -1px 0 rgba(255,255,255,.08); } - .gl-table tr.mat-mdc-row:nth-child(odd) td[mat-cell] { background: rgba(255,255,255,.025); } - .gl-table tr.mat-mdc-row:hover td[mat-cell] { background: rgba(255,255,255,.06); } - .gl-paginator { border-top-color: rgba(255,255,255,.08); } - .no-products { color: rgba(255,255,255,.7); } -} diff --git a/client/src/app/components/list/product-list/product-list.component.html b/client/src/app/components/list/product-list/product-list.component.html deleted file mode 100644 index bb90201..0000000 --- a/client/src/app/components/list/product-list/product-list.component.html +++ /dev/null @@ -1,9 +0,0 @@ - - diff --git a/client/src/app/components/list/product-list/product-list.component.ts b/client/src/app/components/list/product-list/product-list.component.ts deleted file mode 100644 index 2afe3e3..0000000 --- a/client/src/app/components/list/product-list/product-list.component.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { - Component, - inject -} from '@angular/core'; -import {GenericListComponent} from '../generic-list/generic-list.component'; -import {ProductService} from '../../../services/app/product.service'; -import {BrandService} from '../../../services/app/brand.service'; -import {PlatformService} from '../../../services/app/platform.service'; -import {CategoryService} from '../../../services/app/category.service'; -import {ConditionService} from '../../../services/app/condition.service'; - -@Component({ - selector: 'app-product-list', - templateUrl: './product-list.component.html', - standalone: true, - imports: [ - GenericListComponent - ], - styleUrls: ['./product-list.component.css'] -}) -export class ProductListComponent { - - productService: ProductService = inject(ProductService); - categoryService: CategoryService = inject(CategoryService); - brandService: BrandService = inject(BrandService); - platformService: PlatformService = inject(PlatformService); - conditionService: ConditionService = inject(ConditionService); - - fields = [ - {key: 'title', label: 'Titre', sortable: true}, - { - key: 'description', - label: 'Description', - }, - { - key: 'category', - label: 'Catégorie', - type: 'select', - options$: this.categoryService.getAll(), - displayKey: 'name', - sortable: true, - sortKey: 'category.name' - }, - { - key: 'platform.brand', - label: 'Marque', - type: 'select', - options$: this.brandService.getAll(), - displayKey: 'name', - sortable: true, - sortKey: 'platform.brand.name' - }, - { - key: 'platform', - label: 'Plateforme', - type: 'select', - options$: this.platformService.getAll(), - displayKey: 'name', - sortable: true, - sortKey: 'platform.name' - }, - { - key: 'condition.displayName', - label: 'État', - type: 'select', - options$: this.conditionService.getAll(), - displayKey: 'displayName', - sortable: true, - sortKey: 'condition.displayName' - }, - { - key: 'complete', - label: 'Complet', - type: 'checkbox', - sortable: true, - sortKey: 'complete', - displayFn: (value: boolean): string => value ? '✔ Oui' : '✗ Non' - }, - { - key: 'manualIncluded', - label: 'Notice', - type: 'checkbox', - sortable: true, - sortKey: 'manualIncluded', - displayFn: (value: boolean): string => value ? '✔ Oui' : '✗ Non' - }, - { - key: 'price', - label: 'Prix', - sortable: true, - sortKey: 'price', - displayFn: (value: string | number): string => { - if (value == null || value === '') return ''; - try { - return new Intl - .NumberFormat('fr-FR', {style: 'currency', currency: 'EUR', maximumFractionDigits: 2}) - .format(Number(value)); - } catch { - return String(value); - } - } - }, - { - key: 'quantity', - label: 'Quantité', - sortable: true, - sortKey: 'quantity' - } - ]; -} diff --git a/client/src/app/pages/add-product/add-product.component.css b/client/src/app/pages/add-product/add-product.component.css new file mode 100644 index 0000000..552673e --- /dev/null +++ b/client/src/app/pages/add-product/add-product.component.css @@ -0,0 +1,32 @@ +.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; +} diff --git a/client/src/app/pages/add-product/add-product.component.html b/client/src/app/pages/add-product/add-product.component.html new file mode 100644 index 0000000..4bb4bc7 --- /dev/null +++ b/client/src/app/pages/add-product/add-product.component.html @@ -0,0 +1,149 @@ +
+ + + Ajouter un produit + + + + + + + + Titre + + @if (isFieldInvalid('title')) { + {{ getFieldError('title') }} + } + + + + + Description + + @if (isFieldInvalid('description')) { + {{ getFieldError('description') }} + } + + + + + Catégorie + + @for (category of categories; track category.id) { + {{ category.name }} + } + + + + + + État + + @for (condition of conditions; track condition.id) { + {{ condition.displayName }} + } + + + + + + Marque + + @for (brand of filteredBrands; track brand.id) { + {{ brand.name }} + } + + + + + + Plateforme + + @for (platform of filteredPlatforms; track platform.id) { + {{ platform.name }} + } + + + + + + Complet + + @if (isFieldInvalid('complete')) { +
{{ getFieldError('complete') }}
+ } + + + + Avec notice + + @if (isFieldInvalid('manual')) { +
{{ getFieldError('manual') }}
+ } + + + + Prix TTC + + @if (isFieldInvalid('price')) { + {{ getFieldError('price') }} + } + + + + + Quantité + + @if (isFieldInvalid('quantity')) { + {{ getFieldError('quantity') }} + } + + + +
+ +
+ +
+ + + + + + Voir la liste des produits + + +
+
diff --git a/client/src/app/pages/add-product/add-product.component.ts b/client/src/app/pages/add-product/add-product.component.ts new file mode 100644 index 0000000..e74601b --- /dev/null +++ b/client/src/app/pages/add-product/add-product.component.ts @@ -0,0 +1,361 @@ +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 {Router, 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'; +import {CdkTextareaAutosize} from '@angular/cdk/text-field'; + +@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, + CdkTextareaAutosize + ], + 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); + + private readonly router: Router = inject(Router); + + 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>(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.getAll().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 brandId = raw.brand; + const brandObj = this.brands.find(b => String(b.id) === String(brandId)) ?? {id: brandId, name: undefined}; + + const platformId = raw.platform; + const foundPlatform = this.platforms.find(p => String(p.id) === String(platformId)); + const platformObj = { + ...(foundPlatform ?? {id: platformId, name: undefined}), + brand: foundPlatform?.brand ? (typeof foundPlatform.brand === 'object' ? foundPlatform.brand : (this.brands.find(b => String(b.id) === String(foundPlatform.brand)) ?? brandObj)) : brandObj + }; + + const payload = { + ...raw, + price: priceNum, + quantity: quantityNum, + brand: brandObj, + platform: platformObj + }; + + this.addProductSubscription = this.productService.add(payload).subscribe({ + next: (response: any) => { + console.log("Product added successfully:", response); + this.addProductForm.reset(); + this.isSubmitted = false; + alert("Produit ajouté avec succès !"); + this.router.navigate(['/products']).then(); + }, + error: (error: any) => { + 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); + }; +} diff --git a/client/src/app/pages/products/products.component.css b/client/src/app/pages/products/products.component.css index e69de29..dd8b416 100644 --- a/client/src/app/pages/products/products.component.css +++ b/client/src/app/pages/products/products.component.css @@ -0,0 +1,128 @@ +/* ===== Container centré ===== */ +.generic-list { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem clamp(1rem, 3vw, 3rem); + max-width: 1200px; + margin: 0 auto; +} + +/* ===== Header ===== */ +.gl-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + border-bottom: 1px solid rgba(0, 0, 0, .08); + padding-bottom: .75rem; +} + +.gl-title { + margin: 0; + font-size: clamp(1.1rem, 1.3rem + 0.3vw, 1.6rem); + font-weight: 600; +} + +/* ===== Cartes (filtre, table, pagination) ===== */ +.gl-block { + border: 1px solid rgba(0,0,0,.08); + border-radius: 12px; + background: var(--gl-surface, #fff); + box-shadow: + 0 1px 2px rgba(0,0,0,.04), + 0 2px 8px rgba(0,0,0,.06); +} + +/* ===== Barre de filtre ===== */ +.gl-filter-bar { padding: .75rem; } +.gl-filter { display: block; width: 100%; max-width: none; } + +/* ===== Tableau ===== */ +.gl-table-wrapper { + overflow: auto; + -webkit-overflow-scrolling: touch; + padding: 0.25rem 0.5rem; /* espace interne pour éviter l'effet "collé" */ +} + +.gl-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + min-width: 720px; /* permet le scroll horizontal si trop de colonnes */ +} + +.gl-table th[mat-header-cell] { + position: sticky; + top: 0; + z-index: 2; + background: inherit; + box-shadow: inset 0 -1px 0 rgba(0,0,0,.08); +} + +.gl-table th[mat-header-cell], +.gl-table td[mat-cell] { + padding: 14px 18px; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Zebra + hover */ +.gl-table tr.mat-mdc-row:nth-child(odd) td[mat-cell] { background: rgba(0,0,0,.015); } +.gl-table tr.mat-mdc-row:hover td[mat-cell] { background: rgba(0,0,0,.035); } + +/* Actions */ +.actions-head { width: 1%; white-space: nowrap; } +.actions-cell { + display: flex; + align-items: center; + justify-content: center; + gap: .4rem; +} +.actions-cell .mat-mdc-icon-button { width: 40px; height: 40px; } + +/* ===== Pagination ===== */ +.gl-paginator-wrap { padding: .25rem .5rem; } +.gl-paginator { + margin-top: .25rem; + padding-top: .5rem; + border-top: 1px solid rgba(0,0,0,.08); + display: flex; + justify-content: flex-end; +} + +/* ===== Empty state ===== */ +.no-products { + padding: 1rem; + text-align: center; + color: rgba(0,0,0,.6); +} + +/* ===== Responsive ===== */ +@media (max-width: 799px) { + .generic-list { padding: 0.75rem 1rem; } + .gl-table { min-width: 0; } + .gl-table th[mat-header-cell], + .gl-table td[mat-cell] { white-space: normal; padding: 10px 12px; } + .actions-cell { justify-content: flex-start; } +} + +/* ===== Dark mode ===== */ +@media (prefers-color-scheme: dark) { + .gl-block { + background: #1b1b1b; + border-color: rgba(255,255,255,.08); + box-shadow: + 0 1px 2px rgba(0,0,0,.6), + 0 2px 8px rgba(0,0,0,.45); + } + .gl-header { border-bottom-color: rgba(255,255,255,.08); } + .gl-table th[mat-header-cell] { box-shadow: inset 0 -1px 0 rgba(255,255,255,.08); } + .gl-table tr.mat-mdc-row:nth-child(odd) td[mat-cell] { background: rgba(255,255,255,.025); } + .gl-table tr.mat-mdc-row:hover td[mat-cell] { background: rgba(255,255,255,.06); } + .gl-paginator { border-top-color: rgba(255,255,255,.08); } + .no-products { color: rgba(255,255,255,.7); } +} diff --git a/client/src/app/pages/products/products.component.html b/client/src/app/pages/products/products.component.html index 9394c6a..930ee47 100644 --- a/client/src/app/pages/products/products.component.html +++ b/client/src/app/pages/products/products.component.html @@ -1,7 +1,126 @@ - - @if (showList) { - - } - +
+ +
+

Gestion des produits

+
+ +
+
- + +
+ + Rechercher + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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 }}Actions + + +
+
+ + +
+ + +
+ + @if (!products || products.length === 0) { +
Aucun produit trouvé.
+ } +
diff --git a/client/src/app/pages/products/products.component.ts b/client/src/app/pages/products/products.component.ts index b7ad3fb..a11bcb6 100644 --- a/client/src/app/pages/products/products.component.ts +++ b/client/src/app/pages/products/products.component.ts @@ -1,43 +1,182 @@ import { - Component, inject, + Component, + Input, + Output, + EventEmitter, + ViewChild, + AfterViewInit, + OnChanges, + SimpleChanges, + OnInit, + inject } from '@angular/core'; -import {ProductListComponent} from '../../components/list/product-list/product-list.component'; -import {ActivatedRoute, NavigationEnd, Router, RouterOutlet} from '@angular/router'; -import {filter, Subscription} from 'rxjs'; +import { Product } from '../../interfaces/product'; +import { ProductService } from '../../services/app/product.service'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { CurrencyPipe } from '@angular/common'; +import { ConfirmDialogComponent } from '../../components/dialog/confirm-dialog/confirm-dialog.component'; + +import { MatTableModule, MatTableDataSource } from '@angular/material/table'; +import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator'; +import { MatSortModule, MatSort } from '@angular/material/sort'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import {GenericDialogComponent} from '../../components/dialog/generic-dialog/generic-dialog.component'; +import {CategoryService} from '../../services/app/category.service'; +import {PlatformService} from '../../services/app/platform.service'; +import {ConditionService} from '../../services/app/condition.service'; +import {BrandService} from '../../services/app/brand.service'; @Component({ - selector: 'app-dialog', + selector: 'app-products', templateUrl: './products.component.html', standalone: true, imports: [ - ProductListComponent, - RouterOutlet + MatTableModule, + MatPaginatorModule, + MatSortModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + MatDialogModule, + CurrencyPipe ], styleUrls: ['./products.component.css'] }) -export class ProductsComponent { +export class ProductsComponent implements OnInit, AfterViewInit, OnChanges { - showList = true; - private sub?: Subscription; + @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 categoryService: CategoryService = inject(CategoryService); + private readonly brandService: BrandService = inject(BrandService); + private readonly platformService: PlatformService = inject(PlatformService); + private readonly conditionService: ConditionService = inject(ConditionService); + + private readonly dialog: MatDialog = inject(MatDialog); private readonly router: Router = inject(Router); - private readonly route: ActivatedRoute = inject(ActivatedRoute); + + private readonly productFields = [ + { key: 'title', label: 'Nom', type: 'text', sortable: true }, + { key: 'description', label: 'Description', type: 'textarea' }, + { key: 'category', label: 'Catégorie', type: 'select', options$: this.categoryService.getAll(), valueKey: 'id', displayKey: 'name', sortable: true }, + { key: 'brand', label: 'Marque', type: 'select', options$: this.brandService.getAll(), valueKey: 'id', displayKey: 'name', sortable: true }, + { key: 'platform', label: 'Plateforme', type: 'select', options$: this.platformService.getAll(), valueKey: 'id', displayKey: 'name', sortable: true }, + { key: 'condition', label: 'État', type: 'select', options$: this.conditionService.getAll(), valueKey: 'name', displayKey: 'displayName', sortable: true }, + { key: 'complete', label: 'Complet', type: 'checkbox' }, + { key: 'manual', label: 'Notice', type: 'checkbox' }, + { key: 'price', label: 'Prix', type: 'number', sortable: true }, + { key: 'quantity', label: 'Quantité', type: 'number', sortable: true } + ]; 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; + if (!this.products || this.products.length === 0) { + this.loadProducts(); + } else { + this.dataSource.data = this.products; } - this.showList = current === route; } - ngOnDestroy(): void { - this.sub?.unsubscribe(); + ngOnChanges(changes: SimpleChanges): void { + if (changes['products']) { + this.dataSource.data = this.products || []; + } + } + + ngAfterViewInit(): void { + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.dataSource.sortingDataAccessor = (item: Product, property: string) => { + switch (property) { + case 'category': + return item.category?.name ?? ''; + case 'platform': + return item.platform?.name ?? ''; + case 'condition': + return item.condition?.displayName ?? ''; + case 'complete': + return item.complete ? 1 : 0; + case 'manualIncluded': + return item.manualIncluded ? 1 : 0; + case 'price': + return item.price ?? 0; + case 'quantity': + return item.quantity ?? 0; + case 'title': + return item.title ?? ''; + case 'description': + return item.description ?? ''; + default: + return (item as any)[property]; + } + }; + } + + loadProducts() { + this.productService.getAll().subscribe({ + next: (products: Product[]) => { + this.products = products || [] + this.dataSource.data = this.products; + }, + error: () => this.products = [] + }); + } + + onAdd(): void { + this.router.navigate(['/products/add']).then(); + } + + onEdit(product: Product): void { + console.log('[Products] open edit dialog for product:', product); + const ref = this.dialog.open(GenericDialogComponent, { + width: '600px', + data: { + title: `Modifier : ${product.title}`, + fields: this.productFields, + model: { ...product } + } + }); + + ref.afterClosed().subscribe((result: any) => { + if (!result) return; + this.productService.update(product.id, result).subscribe({ + next: () => this.loadProducts(), + error: (err) => console.error('Erreur update product:', err) + }); + }); + } + + onDelete(product: Product): void { + const ref = this.dialog.open(ConfirmDialogComponent, { + width: '420px', + data: { + title: 'Supprimer le produit', + message: `Voulez-vous vraiment supprimer « ${product.title} » ?` + } + }); + + ref.afterClosed().subscribe((confirmed: boolean) => { + if (confirmed) { + this.delete.emit(product); + this.productService.delete(product.id).subscribe(() => this.loadProducts()); + } + }); + } + + applyFilter(value: string): void { + this.dataSource.filter = (value || '').trim().toLowerCase(); } }