refactor: reorganize component files and update import paths; add PsItem and PsProduct interfaces

This commit is contained in:
Vincent Guillet
2025-11-12 12:34:58 +01:00
parent f063a245b9
commit bcc71b965b
92 changed files with 1694 additions and 2815 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,160 +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">
<!-- Image -->
<div>
<label for="imageUpload">Photo du produit</label>
<input id="imageUpload" type="file" (change)="onFileSelected($event)" accept="image/*">
@if (imagePreview) {
<div style="margin-top:8px;">
<img [src]="imagePreview" alt="Aperçu" style="max-width:100%; max-height:200px; display:block;">
</div>
}
</div>
<!-- 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>
<textarea matInput
id="description"
name="description"
formControlName="description"
rows="4"
cdkTextareaAutosize
cdkAutosizeMinRows="1"
cdkAutosizeMaxRows="5"
required></textarea>
@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="number"
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="number"
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]="'/products'">Voir la liste des produits</a>
</span>
</mat-card-actions>
</mat-card>
</section>

View File

@@ -1,427 +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 {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';
import {ImageService} from '../../services/app/image.service';
import {ProductImageService} from '../../services/app/product_images.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,
CdkTextareaAutosize
],
templateUrl: './add-product.component.html',
styleUrl: './add-product.component.css'
})
export class AddProductComponent implements OnInit, OnDestroy {
addProductForm: FormGroup;
isSubmitted = false;
isLoading = false;
imageFile: File | null = null;
imagePreview: string | null = null;
private imageUploadSubscription: Subscription | null = null;
private imageLinkSubscription: Subscription | null = null;
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 imageService = inject(ImageService)
private readonly productService = inject(ProductService);
private readonly productImageService = inject(ProductImageService);
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<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.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();
this.imageUploadSubscription?.unsubscribe();
this.imageLinkSubscription?.unsubscribe();
}
onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) {
this.imageFile = null;
this.imagePreview = null;
return;
}
const file = input.files[0];
this.imageFile = file;
const reader = new FileReader();
reader.onload = () => {
this.imagePreview = String(reader.result);
};
reader.readAsDataURL(file);
}
onProductAdd() {
this.isSubmitted = true;
if (!this.addProductForm.valid) return;
this.isLoading = true;
const raw = this.addProductForm.value;
// parsing price/quantity etc (même logique que l'original)
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
};
// 1) créer le produit
this.addProductSubscription = this.productService.add(payload).subscribe({
next: (createdProduct: any) => {
const productId = createdProduct?.id;
if (!this.imageFile) {
// pas d'image => fin
this.afterSuccessfulAdd();
return;
}
// 2) upload de l'image
this.imageUploadSubscription = this.imageService.add(this.imageFile).subscribe({
next: (uploadedImage: { id: any; }) => {
const imageId = uploadedImage?.id;
if (!productId || imageId == null) {
console.error('Missing productId or imageId after upload');
this.afterSuccessfulAdd(); // navigation quand même ou gérer l'erreur
return;
}
// 3) lier image <-> product
this.imageLinkSubscription = this.productImageService.link(productId, imageId).subscribe({
next: () => {
this.afterSuccessfulAdd();
},
error: (error: any) => {
console.error('Error linking image to product:', error);
alert('Produit ajouté, mais la liaison de l\'image a échoué.');
this.afterSuccessfulAdd();
}
});
},
error: (error: any) => {
console.error('Error uploading image:', error);
alert('Produit ajouté, mais l\'upload de l\'image a échoué.');
this.afterSuccessfulAdd();
}
});
},
error: (error: any) => {
console.error("Error adding product:", error);
alert("Une erreur est survenue lors de l'ajout du produit.");
this.isLoading = false;
}
});
}
private afterSuccessfulAdd() {
this.addProductForm.reset();
this.imageFile = null;
this.imagePreview = null;
this.isSubmitted = false;
this.isLoading = false;
alert("Produit ajouté avec succès !");
this.router.navigate(['/products']).then();
}
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 +0,0 @@
<app-admin-navbar></app-admin-navbar>

View File

@@ -1,15 +0,0 @@
import { Component } from '@angular/core';
import {AdminNavbarComponent} from '../../components/navbar/admin-navbar/admin-navbar.component';
@Component({
selector: 'app-admin',
templateUrl: './admin.component.html',
standalone: true,
imports: [
AdminNavbarComponent
],
styleUrls: ['./admin.component.scss']
})
export class AdminComponent{
}

View File

@@ -0,0 +1,5 @@
.wrap {
padding: 16px;
max-width: 900px;
margin: auto
}

View File

@@ -0,0 +1,14 @@
<div class="wrap">
<h2>Administration Prestashop</h2>
<mat-tab-group>
<mat-tab label="Catégories">
<app-ps-admin-crud [resource]="'categories'" [label]="'Catégorie'"></app-ps-admin-crud>
</mat-tab>
<mat-tab label="Marques">
<app-ps-admin-crud [resource]="'manufacturers'" [label]="'Marque'"></app-ps-admin-crud>
</mat-tab>
<mat-tab label="Fournisseurs">
<app-ps-admin-crud [resource]="'suppliers'" [label]="'Fournisseur'"></app-ps-admin-crud>
</mat-tab>
</mat-tab-group>
</div>

View File

@@ -0,0 +1,16 @@
import { Component } from '@angular/core';
import {MatTab, MatTabGroup} from '@angular/material/tabs';
import {PsAdminCrudComponent} from '../../../components/ps-generic-crud/ps-admin-crud.component';
@Component({
standalone: true,
selector: 'app-ps-admin',
templateUrl: './ps-admin.component.html',
imports: [
MatTabGroup,
MatTab,
PsAdminCrudComponent
],
styleUrls: ['./ps-admin.component.css']
})
export class PsAdminComponent {}

View File

@@ -1,11 +1,11 @@
import {Component, inject, OnDestroy} from '@angular/core';
import {MatError, MatFormField, MatLabel} from '@angular/material/form-field';
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
import {AuthService} from '../../services/auth/auth.service';
import {AuthService} from '../../../services/auth.service';
import {Router} from '@angular/router';
import {Subscription} from 'rxjs';
import {Credentials} from '../../interfaces/credentials';
import {User} from '../../interfaces/user';
import {Credentials} from '../../../interfaces/credentials';
import {User} from '../../../interfaces/user';
import {MatInput} from '@angular/material/input';
import {MatButton} from '@angular/material/button';

View File

@@ -21,7 +21,7 @@ import {
} from '@angular/material/card';
import {MatProgressSpinner} from '@angular/material/progress-spinner';
import {MatDivider} from '@angular/material/divider';
import {AuthService} from '../../services/auth/auth.service';
import {AuthService} from '../../../services/auth.service';
import {MatCheckbox} from '@angular/material/checkbox';
import {MatButton} from '@angular/material/button';
import {Subscription} from 'rxjs';

View File

@@ -1,6 +1,6 @@
import {Component, inject} from '@angular/core';
import {MatButton} from '@angular/material/button';
import {AuthService} from '../../services/auth/auth.service';
import {AuthService} from '../../services/auth.service';
import {Router, RouterLink} from '@angular/router';
@Component({

View File

@@ -1,128 +1,5 @@
/* ===== 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); }
.wrap {
padding: 16px;
max-width: 1100px;
margin: auto
}

View File

@@ -1,126 +1,4 @@
<div class="generic-list">
<!-- Header (bouton à droite) -->
<div class="gl-header">
<h3 class="gl-title">Gestion des produits</h3>
<div class="gl-controls">
<button mat-flat-button color="accent" (click)="onAdd()">
<mat-icon>add</mat-icon>&nbsp;Ajouter
</button>
</div>
</div>
<!-- Barre de recherche sous le header -->
<div class="gl-filter-bar gl-block">
<mat-form-field class="gl-filter" appearance="outline">
<mat-label>Rechercher</mat-label>
<input
matInput
placeholder="Tapez pour filtrer…"
(input)="applyFilter($any($event.target).value)"
aria-label="Filtrer le tableau"
/>
</mat-form-field>
</div>
<!-- Tableau -->
<div class="gl-table-wrapper gl-block">
<table mat-table [dataSource]="dataSource" class="gl-table" matSort>
<!-- Title Column -->
<ng-container matColumnDef="title">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Nom</th>
<td mat-cell *matCellDef="let product">{{ product.title }}</td>
</ng-container>
<!-- Description Column -->
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Description</th>
<td mat-cell *matCellDef="let product">{{ product.description }}</td>
</ng-container>
<!-- Category Column -->
<ng-container matColumnDef="category">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Catégorie</th>
<td mat-cell *matCellDef="let product">{{ product.category.name }}</td>
</ng-container>
<!-- Platform Column -->
<ng-container matColumnDef="platform">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Plateforme</th>
<td mat-cell *matCellDef="let product">{{ product.platform.name }}</td>
</ng-container>
<!-- Condition Column -->
<ng-container matColumnDef="condition">
<th mat-header-cell *matHeaderCellDef mat-sort-header>État</th>
<td mat-cell *matCellDef="let product">{{ product.condition.displayName }}</td>
</ng-container>
<!-- Complete Column -->
<ng-container matColumnDef="complete">
<th mat-header-cell *matHeaderCellDef>Complet</th>
<td mat-cell *matCellDef="let product">
@if (product.complete) {
<mat-icon color="primary">check_circle</mat-icon>
} @else {
<mat-icon color="warn">cancel</mat-icon>
}
</td>
</ng-container>
<!-- Manual Column -->
<ng-container matColumnDef="manual">
<th mat-header-cell *matHeaderCellDef>Notice</th>
<td mat-cell *matCellDef="let product">
@if (product.manual) {
<mat-icon color="primary">check_circle</mat-icon>
} @else {
<mat-icon color="warn">cancel</mat-icon>
}
</td>
</ng-container>
<!-- Price Column -->
<ng-container matColumnDef="price">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Prix</th>
<td mat-cell *matCellDef="let product">{{ product.price | currency:'EUR' }}</td>
</ng-container>
<!-- Quantity Column -->
<ng-container matColumnDef="quantity">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Quantité</th>
<td mat-cell *matCellDef="let product">{{ product.quantity }}</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="actions-head">Actions</th>
<td mat-cell *matCellDef="let product" class="actions-cell">
<button mat-icon-button (click)="onEdit(product)" aria-label="Modifier">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="onDelete(product)" aria-label="Supprimer">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
<!-- Pagination avec même carte -->
<div class="gl-paginator-wrap gl-block">
<mat-paginator
class="gl-paginator"
[pageSize]="10"
[pageSizeOptions]="[5,10,25]"
showFirstLastButtons>
</mat-paginator>
</div>
@if (!products || products.length === 0) {
<div class="no-products gl-block">Aucun produit trouvé.</div>
}
</div>
<section class="wrap">
<h2>Gestion des produits</h2>
<app-ps-product-crud></app-ps-product-crud>
</section>

View File

@@ -1,182 +1,15 @@
import {
Component,
Input,
Output,
EventEmitter,
ViewChild,
AfterViewInit,
OnChanges,
SimpleChanges,
OnInit,
inject
} from '@angular/core';
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';
import { Component } from '@angular/core';
import {PsProductCrudComponent} from '../../components/ps-product-crud/ps-product-crud.component';
@Component({
selector: 'app-products',
templateUrl: './products.component.html',
standalone: true,
imports: [
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatIconModule,
MatDialogModule,
CurrencyPipe
PsProductCrudComponent
],
styleUrls: ['./products.component.css']
templateUrl: './products.component.html',
styleUrl: './products.component.css'
})
export class ProductsComponent implements OnInit, AfterViewInit, OnChanges {
export class ProductsComponent {
@Input() products: Product[] = [];
@Output() add = new EventEmitter<Product>();
@Output() edit = new EventEmitter<Product>();
@Output() delete = new EventEmitter<Product>();
displayedColumns: string[] = ['title', 'description', 'category', 'platform', 'condition', 'complete', 'manual', 'price', 'quantity', 'actions'];
dataSource = new MatTableDataSource<Product>([]);
@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 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 {
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;
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();
}
}

View File

@@ -9,7 +9,7 @@ import {
} from '@angular/material/card';
import {MatIcon} from '@angular/material/icon';
import {MatButton} from '@angular/material/button';
import {AuthService} from '../../services/auth/auth.service';
import {AuthService} from '../../services/auth.service';
import {User} from '../../interfaces/user';
import {Router} from '@angular/router';