From de0844b41fb3172e14ff6a973a20c17cccdd11ec Mon Sep 17 00:00:00 2001 From: Vincent Guillet Date: Tue, 23 Dec 2025 16:28:54 +0100 Subject: [PATCH] Add product cards management with CRUD functionality and update routing --- client/src/app/app.routes.ts | 7 + .../ps-product-card-crud.component.css | 95 +++++ .../ps-product-card-crud.component.html | 121 ++++++ .../ps-product-card-crud.component.ts | 148 +++++++ .../ps-product-card-dialog.component.css | 181 ++++++++ .../ps-product-card-dialog.component.html | 155 +++++++ .../ps-product-card-dialog.component.ts | 402 ++++++++++++++++++ .../ps-product-crud-base.component.ts | 276 ++++++++++++ .../ps-product-crud.component.html | 131 +++--- .../ps-product-crud.component.ts | 270 +----------- .../ps-product-dialog.component.ts | 11 +- .../src/app/interfaces/product-list-item.ts | 1 + client/src/app/pages/home/home.component.html | 3 +- .../products-cards.component.css | 5 + .../products-cards.component.html | 4 + .../product-cards/products-cards.component.ts | 15 + 16 files changed, 1511 insertions(+), 314 deletions(-) create mode 100644 client/src/app/components/ps-product-card-crud/ps-product-card-crud.component.css create mode 100644 client/src/app/components/ps-product-card-crud/ps-product-card-crud.component.html create mode 100644 client/src/app/components/ps-product-card-crud/ps-product-card-crud.component.ts create mode 100644 client/src/app/components/ps-product-card-dialog/ps-product-card-dialog.component.css create mode 100644 client/src/app/components/ps-product-card-dialog/ps-product-card-dialog.component.html create mode 100644 client/src/app/components/ps-product-card-dialog/ps-product-card-dialog.component.ts create mode 100644 client/src/app/components/ps-product-crud-base/ps-product-crud-base.component.ts create mode 100644 client/src/app/pages/product-cards/products-cards.component.css create mode 100644 client/src/app/pages/product-cards/products-cards.component.html create mode 100644 client/src/app/pages/product-cards/products-cards.component.ts diff --git a/client/src/app/app.routes.ts b/client/src/app/app.routes.ts index 15fa460..9086684 100644 --- a/client/src/app/app.routes.ts +++ b/client/src/app/app.routes.ts @@ -8,6 +8,7 @@ import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard import {authOnlyCanActivate, authOnlyCanMatch} from './guards/auth-only.guard'; import {PsAdminComponent} from './pages/admin/ps-admin/ps-admin.component'; import {ProductsComponent} from './pages/products/products.component'; +import {ProductsCardsComponent} from './pages/product-cards/products-cards.component'; export const routes: Routes = [ { @@ -48,6 +49,12 @@ export const routes: Routes = [ canMatch: [adminOnlyCanMatch], canActivate: [adminOnlyCanActivate] }, + { + path: 'cards', + component: ProductsCardsComponent, + canMatch: [adminOnlyCanMatch], + canActivate: [adminOnlyCanActivate] + }, { path: 'admin', component: PsAdminComponent, diff --git a/client/src/app/components/ps-product-card-crud/ps-product-card-crud.component.css b/client/src/app/components/ps-product-card-crud/ps-product-card-crud.component.css new file mode 100644 index 0000000..b0c5f8e --- /dev/null +++ b/client/src/app/components/ps-product-card-crud/ps-product-card-crud.component.css @@ -0,0 +1,95 @@ +.crud { + display: grid; + gap: 16px; +} + + +.toolbar { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.toolbar .filter { + margin-left: auto; + min-width: 360px; +} + +.mat-elevation-z2 { + width: 100%; + max-width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +table { + width: 100%; + min-width: 800px; + border-collapse: collapse; +} + +th, td { + white-space: nowrap; +} + +.prod-cell { + display: flex; + align-items: center; + gap: 8px; +} + +.prod-thumb { + width: 32px; + height: 32px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} + +.product-list-root { + position: relative; +} + +.product-list-loading-overlay { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + pointer-events: all; +} + +mat-paginator { + width: 100%; + overflow: hidden; +} + +@media (max-width: 720px) { + .toolbar { + gap: 8px; + } + + .toolbar button { + flex: 0 0 auto; + order: 1; + } + + .toolbar .filter { + order: 2; + margin-left: 0; + min-width: 0; + width: 100%; + } + + .prod-thumb { + width: 24px; + height: 24px; + } + + table { + min-width: 720px; + } +} diff --git a/client/src/app/components/ps-product-card-crud/ps-product-card-crud.component.html b/client/src/app/components/ps-product-card-crud/ps-product-card-crud.component.html new file mode 100644 index 0000000..855fb80 --- /dev/null +++ b/client/src/app/components/ps-product-card-crud/ps-product-card-crud.component.html @@ -0,0 +1,121 @@ +
+
+ + + @if (selection.hasValue()) { + + } + + + Filtrer + + +
+
+ @if (isLoading) { +
+ +
+ } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + ID{{ el.id }}Nom{{ el.name }}Catégorie{{ el.categoryName }}État{{ el.conditionLabel }}Prix TTC (€){{ el.priceTtc | number:'1.2-2' }}Quantité{{ el.quantity }}Actions + + +
+ Aucune donnée. +
+ + + +
+
diff --git a/client/src/app/components/ps-product-card-crud/ps-product-card-crud.component.ts b/client/src/app/components/ps-product-card-crud/ps-product-card-crud.component.ts new file mode 100644 index 0000000..b8617f8 --- /dev/null +++ b/client/src/app/components/ps-product-card-crud/ps-product-card-crud.component.ts @@ -0,0 +1,148 @@ +import {Component, ViewChild} from '@angular/core'; +import { + MatCell, + MatCellDef, + MatColumnDef, + MatHeaderCell, MatHeaderCellDef, + MatHeaderRow, + MatHeaderRowDef, MatRow, MatRowDef, + MatTable +} from '@angular/material/table'; +import {MatPaginator} from '@angular/material/paginator'; +import {MatSort} from '@angular/material/sort'; +import {FormBuilder, ReactiveFormsModule} from '@angular/forms'; +import {PrestashopService} from '../../services/prestashop.serivce'; +import {MatDialog} from '@angular/material/dialog'; +import {PsProductCrudBase} from '../ps-product-crud-base/ps-product-crud-base.component'; +import {ProductListItem} from '../../interfaces/product-list-item'; +import {DecimalPipe} from '@angular/common'; +import {MatButton, MatIconButton} from '@angular/material/button'; +import {MatCheckbox} from '@angular/material/checkbox'; +import {MatFormField, MatInput, MatLabel} from '@angular/material/input'; +import {MatIcon} from '@angular/material/icon'; +import {MatProgressSpinner} from '@angular/material/progress-spinner'; +import {PsProductCardDialogComponent} from '../ps-product-card-dialog/ps-product-card-dialog.component'; +import {ProductDialogData} from '../ps-product-dialog/ps-product-dialog.component'; +import {catchError, finalize, forkJoin, of} from 'rxjs'; + +@Component({ + selector: 'app-ps-product-crud-cards', + standalone: true, + templateUrl: './ps-product-card-crud.component.html', + imports: [ + DecimalPipe, + MatButton, + MatCell, + MatCellDef, + MatCheckbox, + MatColumnDef, + MatFormField, + MatHeaderCell, + MatHeaderRow, + MatHeaderRowDef, + MatIcon, + MatIconButton, + MatInput, + MatLabel, + MatPaginator, + MatProgressSpinner, + MatRow, + MatRowDef, + MatSort, + MatTable, + ReactiveFormsModule, + MatHeaderCellDef + ], + styleUrls: ['./ps-product-card-crud.component.css'] +}) +export class PsProductCrudCardsComponent extends PsProductCrudBase { + override displayed = ['select', 'id', 'name', 'category', 'condition', 'priceTtc', 'quantity', 'actions']; + + @ViewChild(MatPaginator) declare paginator?: MatPaginator; + @ViewChild(MatSort) declare sort?: MatSort; + @ViewChild(MatTable) declare table?: MatTable; + + private readonly targetCategory = 'Cartes Pokémon'; + + constructor( + fb: FormBuilder, + ps: PrestashopService, + dialog: MatDialog + ) { + super(fb, ps, dialog); + } + + protected override getProductFilter(): ((p: ProductListItem) => boolean) | undefined { + return (item: ProductListItem) => { + const catId = item.id_category_default; + if (!catId) return false; + const cname = this.catMap.get(catId); + return cname === this.targetCategory; + }; + } + + override reload() { + this.isLoading = true; + this.ps.listProducts() + .pipe(finalize(() => { + this.isLoading = false; + })) + .subscribe({ + next: p => { + const arr = p ?? []; + const filterFn = this.getProductFilter(); + const filtered = filterFn ? arr.filter(filterFn) : arr; + + if (!filtered.length) { + this.bindProducts([]); + return; + } + + const flagsCalls = filtered.map(prod => + this.ps.getProductFlags(prod.id).pipe(catchError(() => of(null))) + ); + + forkJoin(flagsCalls).subscribe({ + next: flagsArr => { + const merged = filtered.map((prod, i) => ({...prod, flags: flagsArr[i]})); + this.bindProducts(merged as (ProductListItem & { priceHt?: number })[]); + }, + error: err => { + console.error('Erreur lors du chargement des flags', err); + this.bindProducts(filtered as (ProductListItem & { priceHt?: number })[]); + } + }); + }, + error: err => { + console.error('Erreur lors du chargement des produits', err); + } + }); + } + + override create() { + if (this.isLoading) return; + const data: ProductDialogData = { + mode: 'create', + refs: {categories: this.categories, manufacturers: this.manufacturers, suppliers: this.suppliers} + }; + this.dialog.open(PsProductCardDialogComponent, {width: '900px', data}) + .afterClosed() + .subscribe(ok => { + if (ok) this.reload(); + }); + } + + override edit(row: ProductListItem & { priceHt?: number }) { + if (this.isLoading) return; + const data: ProductDialogData = { + mode: 'edit', + productRow: row, + refs: {categories: this.categories, manufacturers: this.manufacturers, suppliers: this.suppliers} + }; + this.dialog.open(PsProductCardDialogComponent, {width: '900px', data}) + .afterClosed() + .subscribe(ok => { + if (ok) this.reload(); + }); + } +} diff --git a/client/src/app/components/ps-product-card-dialog/ps-product-card-dialog.component.css b/client/src/app/components/ps-product-card-dialog/ps-product-card-dialog.component.css new file mode 100644 index 0000000..9cfdf40 --- /dev/null +++ b/client/src/app/components/ps-product-card-dialog/ps-product-card-dialog.component.css @@ -0,0 +1,181 @@ +.grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(12, 1fr); + align-items: start; +} + +.col-12 { + grid-column: span 12; +} + +.col-6 { + grid-column: span 6; +} + +.col-4 { + grid-column: span 4; +} + +.flags { + display: flex; + gap: 16px; + align-items: center; +} + +/* ===== Nouveau : carrousel ===== */ + +.carousel { + grid-column: span 12; + display: grid; + gap: 8px; +} + +.carousel-main { + position: relative; + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + background: #f5f5f5; + border-radius: 8px; + overflow: hidden; +} + +.carousel-main img { + max-width: 100%; + max-height: 280px; + object-fit: contain; +} + +.carousel-nav-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); +} + +.carousel-nav-btn.left { + left: 4px; +} + +.carousel-nav-btn.right { + right: 4px; +} + +.carousel-placeholder { + text-align: center; + color: #757575; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.carousel-placeholder mat-icon { + font-size: 40px; +} + +/* Bouton de suppression (croix rouge) */ +.carousel-delete-btn { + position: absolute; + top: 6px; + right: 6px; + background: rgba(255, 255, 255, 0.9); + border-radius: 4px; + padding: 2px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); +} + +.carousel-delete-btn mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #e53935; +} + +/* Bandeau de vignettes */ + +.carousel-thumbs { + display: flex; + gap: 8px; + overflow-x: auto; +} + +.thumb-item { + position: relative; + width: 64px; + height: 64px; + border-radius: 4px; + overflow: hidden; /* tu peux laisser comme ça */ + border: 2px solid transparent; + flex: 0 0 auto; + cursor: pointer; +} + +/* Bouton de suppression sur les vignettes */ +.thumb-delete-btn { + position: absolute; + top: 2px; + right: 2px; + + width: 18px; + height: 18px; + min-width: 18px; + padding: 0; + + line-height: 18px; + + background: transparent; + border-radius: 0; + box-shadow: none; +} + +.thumb-delete-btn mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + + color: #e53935; /* rouge discret mais lisible */ +} + +.thumb-item.active { + border-color: #1976d2; +} + +.thumb-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.thumb-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: #eeeeee; + color: #575656; + border: 1px dashed #bdbdbd; +} + +.thumb-placeholder mat-icon { + font-size: 28px; +} + +.dialog-root { + position: relative; +} + +/* Overlay plein écran dans le dialog pendant la sauvegarde */ +.dialog-loading-overlay { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + pointer-events: all; +} diff --git a/client/src/app/components/ps-product-card-dialog/ps-product-card-dialog.component.html b/client/src/app/components/ps-product-card-dialog/ps-product-card-dialog.component.html new file mode 100644 index 0000000..e808ef5 --- /dev/null +++ b/client/src/app/components/ps-product-card-dialog/ps-product-card-dialog.component.html @@ -0,0 +1,155 @@ +

{{ mode === 'create' ? 'Nouvelle carte' : 'Modifier la carte' }}

+ +
+ + @if (isSaving) { +
+ +
+ } + +
+ + + + + + + Nom de la carte + + + + + + Description + + + + + + Catégorie + + Choisir… + @for (c of categories; track c.id) { + {{ c.name }} + } + + + + + + État + + @for (opt of conditionOptions; track opt) { + {{ opt }} + } + + + + + + Prix TTC (€) + + + + + + Quantité + + +
+
+ + + + + + + diff --git a/client/src/app/components/ps-product-card-dialog/ps-product-card-dialog.component.ts b/client/src/app/components/ps-product-card-dialog/ps-product-card-dialog.component.ts new file mode 100644 index 0000000..8a31372 --- /dev/null +++ b/client/src/app/components/ps-product-card-dialog/ps-product-card-dialog.component.ts @@ -0,0 +1,402 @@ +import {Component, Inject, OnInit, inject, OnDestroy} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; +import {MatFormField, MatLabel} from '@angular/material/form-field'; +import {MatInput} from '@angular/material/input'; +import {MatSelectModule} from '@angular/material/select'; +import {MatCheckbox} from '@angular/material/checkbox'; +import {MatButton, MatIconButton} from '@angular/material/button'; +import { + MatDialogRef, + MAT_DIALOG_DATA, + MatDialogActions, + MatDialogContent, + MatDialogTitle +} from '@angular/material/dialog'; +import {MatIcon} from '@angular/material/icon'; + +import {catchError, forkJoin, of, Observable, finalize} from 'rxjs'; + +import {PsItem} from '../../interfaces/ps-item'; +import {ProductListItem} from '../../interfaces/product-list-item'; +import {PrestashopService} from '../../services/prestashop.serivce'; +import {MatProgressSpinner} from '@angular/material/progress-spinner'; + +export type ProductDialogData = { + mode: 'create' | 'edit'; + refs: { categories: PsItem[]; manufacturers: PsItem[]; suppliers: PsItem[]; }; + productRow?: ProductListItem & { priceHt?: number }; +}; + +type CarouselItem = { src: string; isPlaceholder: boolean }; + +@Component({ + selector: 'app-ps-product-dialog', + standalone: true, + templateUrl: './ps-product-card-dialog.component.html', + styleUrls: ['./ps-product-card-dialog.component.css'], + imports: [ + CommonModule, ReactiveFormsModule, + MatFormField, MatLabel, MatInput, MatSelectModule, + MatButton, MatDialogActions, MatDialogContent, MatDialogTitle, + MatIcon, MatIconButton, MatProgressSpinner + ] +}) +export class PsProductCardDialogComponent implements OnInit, OnDestroy { + private readonly fb = inject(FormBuilder); + private readonly ps = inject(PrestashopService); + + constructor( + @Inject(MAT_DIALOG_DATA) public data: ProductDialogData, + private readonly dialogRef: MatDialogRef + ) { + } + + isSaving = false; + + mode!: 'create' | 'edit'; + categories: PsItem[] = []; + manufacturers: PsItem[] = []; + suppliers: PsItem[] = []; + productRow?: ProductListItem & { priceHt?: number }; + + images: File[] = []; + existingImageUrls: string[] = []; + + // Previews des fichiers nouvellement sélectionnés + previewUrls: string[] = []; + + // items du carrousel (existants + nouveaux + placeholder) + carouselItems: CarouselItem[] = []; + currentIndex = 0; + + // options possibles pour l'état (Neuf, Très bon état, etc.) + conditionOptions: string[] = []; + + // on conserve la dernière description chargée pour éviter l’écrasement à vide + private lastLoadedDescription = ''; + + form = this.fb.group({ + name: ['', Validators.required], + description: [''], + categoryId: [null as number | null, Validators.required], + manufacturerId: [null as number | null], + supplierId: [null as number | null], + complete: [true], + hasManual: [false], + conditionLabel: ['', Validators.required], + priceTtc: [0, [Validators.required, Validators.min(0.1)]], + quantity: [0, [Validators.required, Validators.min(1)]], + }); + + // ---------- Helpers locaux ---------- + + private toTtc(ht: number) { + return Math.round(((ht * 1.2) + Number.EPSILON) * 100) / 100; + } + + /** normalisation simple pour comparaison de labels (insensible à casse/accents) */ + private normalizeLabel(s: string): string { + return String(s ?? '') + .normalize('NFD') + .replaceAll(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim(); + } + + /** enlève si présent */ + private stripCdata(s: string): string { + if (!s) return ''; + return s.startsWith('') + ? s.slice(9, -3) + : s; + } + + /** convertit du HTML en texte (pour le textarea) */ + private htmlToText(html: string): string { + if (!html) return ''; + const div = document.createElement('div'); + div.innerHTML = html; + return (div.textContent || div.innerText || '').trim(); + } + + /** nettoyage CDATA+HTML -> texte simple */ + private cleanForTextarea(src: string): string { + return this.htmlToText(this.stripCdata(src ?? '')); + } + + ngOnInit(): void { + this.mode = this.data.mode; + this.productRow = this.data.productRow; + + // Les refs viennent déjà triées du service + const rawCategories = this.data.refs.categories ?? []; + const rawManufacturers = this.data.refs.manufacturers ?? []; + const rawSuppliers = this.data.refs.suppliers ?? []; + + const forbiddenCats = new Set(['racine', 'root', 'accueil', 'home']); + + // on filtre seulement ici, tri déjà fait côté service + this.categories = rawCategories.filter( + c => !forbiddenCats.has(this.normalizeLabel(c.name)) + ); + this.manufacturers = rawManufacturers; + this.suppliers = rawSuppliers; + + // charger les valeurs possibles pour l’état (Neuf, Très bon état, etc.) + this.ps.getConditionValues() + .pipe(catchError(() => of([]))) + .subscribe((opts: string[]) => this.conditionOptions = opts.filter(o => + this.normalizeLabel(o) !== this.normalizeLabel('Neuf') && + this.normalizeLabel(o) !== this.normalizeLabel('Très bon état') && + this.normalizeLabel(o) !== this.normalizeLabel('Bon état') && + this.normalizeLabel(o) !== this.normalizeLabel('Mauvais état') && + this.normalizeLabel(o) !== this.normalizeLabel('Très mauvais état') + )); + + // ---- Mode édition : pré-remplissage ---- + if (this.mode === 'edit' && this.productRow) { + const r = this.productRow; + + const immediateTtc = r.priceHt == null ? 0 : this.toTtc(r.priceHt); + this.form.patchValue({ + name: r.name, + categoryId: r.id_category_default ?? null, + manufacturerId: r.id_manufacturer ?? null, + supplierId: r.id_supplier ?? null, + priceTtc: immediateTtc + }); + + const details$ = this.ps.getProductDetails(r.id).pipe( + catchError(() => of({ + id: r.id, name: r.name, description: '', + id_manufacturer: r.id_manufacturer, id_supplier: r.id_supplier, + id_category_default: r.id_category_default, priceHt: r.priceHt ?? 0 + })) + ); + const qty$ = this.ps.getProductQuantity(r.id).pipe(catchError(() => of(0))); + const imgs$ = this.ps.getProductImageUrls(r.id).pipe(catchError(() => of([]))); + const flags$ = this.ps.getProductFlags(r.id).pipe( + catchError(() => of({complete: false, hasManual: false, conditionLabel: undefined})) + ); + + forkJoin({details: details$, qty: qty$, imgs: imgs$, flags: flags$}) + .subscribe(({details, qty, imgs, flags}) => { + const ttc = this.toTtc(details.priceHt ?? 0); + const baseDesc = this.cleanForTextarea(details.description ?? ''); + this.lastLoadedDescription = baseDesc; + + this.form.patchValue({ + description: baseDesc, + complete: flags.complete, + hasManual: flags.hasManual, + conditionLabel: flags.conditionLabel || '', + priceTtc: (ttc || this.form.value.priceTtc || 0), + quantity: qty, + categoryId: (details.id_category_default ?? this.form.value.categoryId) ?? null, + manufacturerId: (details.id_manufacturer ?? this.form.value.manufacturerId) ?? null, + supplierId: (details.id_supplier ?? this.form.value.supplierId) ?? null + }); + + this.existingImageUrls = imgs; + this.buildCarousel(); + }); + } else { + // mode création : uniquement placeholder au début + this.buildCarousel(); + } + } + + // -------- Carrousel / gestion des fichiers -------- + + onFiles(ev: Event) { + const fl = (ev.target as HTMLInputElement).files; + + // Nettoyage des anciens objectURL + for (let url of this.previewUrls) { + URL.revokeObjectURL(url); + } + this.previewUrls = []; + this.images = []; + + if (fl) { + this.images = Array.from(fl); + this.previewUrls = this.images.map(f => URL.createObjectURL(f)); + } + + this.buildCarousel(); + + // si on a ajouté des images, se placer sur la première nouvelle + if (this.images.length && this.existingImageUrls.length + this.previewUrls.length > 0) { + this.currentIndex = this.existingImageUrls.length; // première nouvelle + } + } + + private buildCarousel() { + const items: CarouselItem[] = [ + ...this.existingImageUrls.map(u => ({src: u, isPlaceholder: false})), + ...this.previewUrls.map(u => ({src: u, isPlaceholder: false})) + ]; + + // placeholder en dernier + items.push({src: '', isPlaceholder: true}); + + this.carouselItems = items; + if (!this.carouselItems.length) { + this.currentIndex = 0; + } else if (this.currentIndex >= this.carouselItems.length) { + this.currentIndex = 0; + } + } + + prev() { + if (!this.carouselItems.length) return; + this.currentIndex = (this.currentIndex - 1 + this.carouselItems.length) % this.carouselItems.length; + } + + next() { + if (!this.carouselItems.length) return; + this.currentIndex = (this.currentIndex + 1) % this.carouselItems.length; + } + + onThumbClick(index: number) { + if (index < 0 || index >= this.carouselItems.length) return; + this.currentIndex = index; + } + + ngOnDestroy() { + // Nettoyage des objectURL + for (let url of this.previewUrls) { + URL.revokeObjectURL(url); + } + } + + // -------- Save / close -------- + + save() { + if (this.form.invalid || this.isSaving) return; + + this.isSaving = true; + this.dialogRef.disableClose = true; + + const v = this.form.getRawValue(); + const effectiveDescription = (v.description ?? '').trim() || this.lastLoadedDescription; + + const dto = { + name: v.name!, + description: effectiveDescription, + categoryId: +v.categoryId!, + manufacturerId: +v.manufacturerId!, + supplierId: +v.supplierId!, + images: this.images, + complete: !!v.complete, + hasManual: !!v.hasManual, + conditionLabel: v.conditionLabel || undefined, + priceTtc: Number(v.priceTtc ?? 0), + vatRate: 0.2, + quantity: Math.max(0, Number(v.quantity ?? 0)) + }; + + let op$: Observable; + if (this.mode === 'create' || !this.productRow) { + op$ = this.ps.createProduct(dto) as Observable; + } else { + op$ = this.ps.updateProduct(this.productRow.id, dto) as Observable; + } + + op$ + .pipe( + finalize(() => { + // si la boîte de dialogue est encore ouverte, on réactive tout + this.isSaving = false; + this.dialogRef.disableClose = false; + }) + ) + .subscribe({ + next: () => this.dialogRef.close(true), + error: (e: unknown) => + alert('Erreur: ' + (e instanceof Error ? e.message : String(e))) + }); + } + + /** Extrait l'id_image depuis une URL FO Presta (.../img/p/.../.jpg) */ + private extractImageIdFromUrl(url: string): number | null { + const m = /\/(\d+)\.(?:jpg|jpeg|png|gif)$/i.exec(url); + if (!m) return null; + const id = Number(m[1]); + return Number.isFinite(id) ? id : null; + } + + /** Suppression générique d'une image à l'index donné (carrousel + vignettes) */ + private deleteImageAtIndex(idx: number) { + if (!this.carouselItems.length) return; + + const item = this.carouselItems[idx]; + if (!item || item.isPlaceholder) return; + + const existingCount = this.existingImageUrls.length; + + // --- Cas 1 : image existante (déjà chez Presta) --- + if (idx < existingCount) { + if (!this.productRow) return; // sécurité + + const url = this.existingImageUrls[idx]; + const imageId = this.extractImageIdFromUrl(url); + if (!imageId) { + alert('Impossible de déterminer l’ID de l’image à supprimer.'); + return; + } + + if (!confirm('Supprimer cette image du produit ?')) return; + + this.ps.deleteProductImage(this.productRow.id, imageId).subscribe({ + next: () => { + // On la retire du tableau local et on reconstruit le carrousel + this.existingImageUrls.splice(idx, 1); + this.buildCarousel(); + + // Repositionnement de l’index si nécessaire + if (this.currentIndex >= this.carouselItems.length - 1) { + this.currentIndex = Math.max(0, this.carouselItems.length - 2); + } + }, + error: (e: unknown) => { + alert('Erreur lors de la suppression de l’image : ' + (e instanceof Error ? e.message : String(e))); + } + }); + + return; + } + + // --- Cas 2 : image locale (nouvelle) --- + const localIdx = idx - existingCount; + if (localIdx >= 0 && localIdx < this.previewUrls.length) { + if (!confirm('Retirer cette image de la sélection ?')) return; + + this.previewUrls.splice(localIdx, 1); + this.images.splice(localIdx, 1); + this.buildCarousel(); + + if (this.currentIndex >= this.carouselItems.length - 1) { + this.currentIndex = Math.max(0, this.carouselItems.length - 2); + } + } + } + + // utilisée par la grande image + onDeleteCurrentImage() { + if (!this.carouselItems.length) return; + this.deleteImageAtIndex(this.currentIndex); + } + + // utilisée par la croix sur une vignette + onDeleteThumb(index: number, event: MouseEvent) { + event.stopPropagation(); + this.deleteImageAtIndex(index); + } + + close() { + if (this.isSaving) return; + this.dialogRef.close(false); + } +} diff --git a/client/src/app/components/ps-product-crud-base/ps-product-crud-base.component.ts b/client/src/app/components/ps-product-crud-base/ps-product-crud-base.component.ts new file mode 100644 index 0000000..fd65898 --- /dev/null +++ b/client/src/app/components/ps-product-crud-base/ps-product-crud-base.component.ts @@ -0,0 +1,276 @@ +import {Directive, OnInit} from '@angular/core'; +import {MatTable, MatTableDataSource} from '@angular/material/table'; +import {MatPaginator} from '@angular/material/paginator'; +import {MatSort} from '@angular/material/sort'; +import {FormBuilder, FormControl} from '@angular/forms'; +import {finalize, forkJoin} from 'rxjs'; +import {SelectionModel} from '@angular/cdk/collections'; +import {PsItem} from '../../interfaces/ps-item'; +import {ProductListItem} from '../../interfaces/product-list-item'; +import {PrestashopService} from '../../services/prestashop.serivce'; +import {MatDialog} from '@angular/material/dialog'; +import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/ps-product-dialog.component'; + +@Directive() +export abstract class PsProductCrudBase implements OnInit { + protected readonly fb: FormBuilder; + protected readonly ps: PrestashopService; + protected readonly dialog: MatDialog; + + categories: PsItem[] = []; + manufacturers: PsItem[] = []; + suppliers: PsItem[] = []; + conditions: string[] = []; + + protected catMap = new Map(); + protected manMap = new Map(); + protected supMap = new Map(); + protected conditionMap = new Map(); + + displayed: string[] = ['select', 'id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'quantity', 'actions']; + dataSource = new MatTableDataSource([]); + paginator?: MatPaginator; + sort?: MatSort; + table?: MatTable; + + selection = new SelectionModel(true, []); + filterCtrl!: FormControl; + private _isLoading = false; + + get isLoading(): boolean { + return this._isLoading; + } + + set isLoading(v: boolean) { + this._isLoading = v; + + if (this.filterCtrl) { + if (this._isLoading) this.filterCtrl.disable({emitEvent: false}); + else this.filterCtrl.enable({emitEvent: false}); + } + } + + protected constructor(fb: FormBuilder, ps: PrestashopService, dialog: MatDialog) { + this.fb = fb; + this.ps = ps; + this.dialog = dialog; + this.filterCtrl = this.fb.control(''); + } + + ngOnInit(): void { + forkJoin({ + categories: this.ps.list('categories'), + manufacturers: this.ps.list('manufacturers'), + suppliers: this.ps.list('suppliers'), + conditions: this.ps.getConditionValues() + }).subscribe({ + next: ({categories, manufacturers, suppliers, conditions}) => { + this.categories = categories ?? []; + this.catMap = new Map(this.categories.map(x => [x.id, x.name])); + this.manufacturers = manufacturers ?? []; + this.manMap = new Map(this.manufacturers.map(x => [x.id, x.name])); + this.suppliers = suppliers ?? []; + this.supMap = new Map(this.suppliers.map(x => [x.id, x.name])); + this.conditions = conditions ?? []; + this.conditionMap = new Map((conditions ?? []).map((c, i) => [i, c])); + this.reload(); + }, + error: err => { + console.error('Erreur lors du chargement des référentiels', err); + } + }); + + this.filterCtrl.valueChanges.subscribe((v: any) => { + this.dataSource.filter = (v ?? '').toString().trim().toLowerCase(); + if (this.paginator) this.paginator.firstPage(); + }); + this.dataSource.filterPredicate = (row: any, f: string) => + row.name?.toLowerCase().includes(f) || + String(row.id).includes(f) || + (row.categoryName?.toLowerCase().includes(f)) || + (row.manufacturerName?.toLowerCase().includes(f)) || + (row.supplierName?.toLowerCase().includes(f)) || + String(row.quantity ?? '').includes(f); + } + + protected toTtc(ht: number, vat: number) { + return Math.round(((ht * (1 + vat)) + Number.EPSILON) * 100) / 100; + } + + protected attachSortingAccessors() { + this.dataSource.sortingDataAccessor = (item: any, property: string) => { + switch (property) { + case 'category': + return (item.categoryName ?? '').toLowerCase(); + case 'manufacturer': + return (item.manufacturerName ?? '').toLowerCase(); + case 'supplier': + return (item.supplierName ?? '').toLowerCase(); + case 'priceTtc': + return Number(item.priceTtc ?? 0); + case 'name': + return (item.name ?? '').toLowerCase(); + case 'condition': + return (item.conditionLabel ?? '').toLowerCase(); + default: + return item[property]; + } + }; + if (this.paginator) this.dataSource.paginator = this.paginator; + if (this.sort) this.dataSource.sort = this.sort; + } + + protected bindProducts(p: (ProductListItem & { priceHt?: number })[]) { + const vat = 0.2; + this.selection.clear(); + + // map des données + const mapped = p.map(x => { + const anyX = x as any; + let conditionLabel = ''; + if (typeof anyX.conditionLabel === 'string' && anyX.conditionLabel) { + conditionLabel = anyX.conditionLabel; + } else if (typeof anyX.condition === 'number') { + conditionLabel = this.conditionMap.get(anyX.condition) ?? ''; + } else if (typeof anyX.conditionValue === 'number') { + conditionLabel = this.conditionMap.get(anyX.conditionValue) ?? ''; + } else if (anyX.flags && typeof anyX.flags.conditionLabel === 'string') { + conditionLabel = anyX.flags.conditionLabel; + } + return { + ...x, + categoryName: x.id_category_default ? (this.catMap.get(x.id_category_default) ?? '') : '', + manufacturerName: x.id_manufacturer ? (this.manMap.get(x.id_manufacturer) ?? '') : '', + supplierName: x.id_supplier ? (this.supMap.get(x.id_supplier) ?? '') : '', + priceTtc: this.toTtc(x.priceHt ?? 0, vat), + conditionLabel + }; + }); + + // appliquer les données + this.dataSource.data = mapped; + + // réaffecter paginator/sort (surtout utile si viewChild n'était pas encore attaché) + if (this.paginator) this.dataSource.paginator = this.paginator; + if (this.sort) this.dataSource.sort = this.sort; + + // (re)appliquer le filtre courant pour éviter qu'un filtre persistant masque les lignes + this.dataSource.filter = (this.filterCtrl?.value ?? '').toString().trim().toLowerCase(); + + // remettre les accessors/liaisons et forcer le rendu de la table après le cycle de détection + this.attachSortingAccessors(); + // petit délai pour laisser Angular attacher les ViewChild avant renderRows + setTimeout(() => { + this.table?.renderRows?.(); + }); + } + + protected getProductFilter(): ((p: ProductListItem) => boolean) | undefined { + return undefined; + } + + reload() { + this.isLoading = true; + this.ps.listProducts() + .pipe(finalize(() => { + this.isLoading = false; + })) + .subscribe({ + next: p => { + const arr = p ?? []; + const filterFn = this.getProductFilter(); + const filtered = filterFn ? arr.filter(filterFn) : arr; + this.bindProducts(filtered as (ProductListItem & { priceHt?: number })[]); + }, + error: err => { + console.error('Erreur lors du chargement des produits', err); + } + }); + } + + create() { + if (this.isLoading) return; + const data: ProductDialogData = { + mode: 'create', + refs: {categories: this.categories, manufacturers: this.manufacturers, suppliers: this.suppliers} + }; + this.dialog.open(PsProductDialogComponent, {width: '900px', data}) + .afterClosed() + .subscribe(ok => { + if (ok) this.reload(); + }); + } + + edit(row: ProductListItem & { priceHt?: number }) { + if (this.isLoading) return; + const data: ProductDialogData = { + mode: 'edit', + productRow: row, + refs: {categories: this.categories, manufacturers: this.manufacturers, suppliers: this.suppliers} + }; + this.dialog.open(PsProductDialogComponent, {width: '900px', data}) + .afterClosed() + .subscribe(ok => { + if (ok) this.reload(); + }); + } + + remove(row: ProductListItem) { + if (this.isLoading) return; + if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return; + this.isLoading = true; + this.ps.deleteProduct(row.id).pipe(finalize(() => { + })).subscribe({ + next: () => this.reload(), + error: (e: unknown) => { + this.isLoading = false; + alert('Erreur: ' + (e instanceof Error ? e.message : String(e))); + } + }); + } + + // selection helpers (identiques) + protected getVisibleRows(): any[] { + const data = this.dataSource.filteredData || []; + if (!this.paginator) return data; + const start = this.paginator.pageIndex * this.paginator.pageSize; + return data.slice(start, start + this.paginator.pageSize); + } + + isAllSelected(): boolean { + const visible = this.getVisibleRows(); + return visible.length > 0 && visible.every(r => this.selection.isSelected(r)); + } + + isAnySelected(): boolean { + return this.selection.hasValue(); + } + + masterToggle(checked: boolean) { + const visible = this.getVisibleRows(); + if (checked) visible.forEach(r => this.selection.select(r)); + else visible.forEach(r => this.selection.deselect(r)); + } + + toggleSelection(row: any, checked: boolean) { + if (checked) this.selection.select(row); + else this.selection.deselect(row); + } + + deleteSelected() { + if (this.isLoading) return; + const ids = this.selection.selected.map(s => s.id); + if (!ids.length) return; + if (!confirm(`Supprimer ${ids.length} produit(s) sélectionné(s) ?`)) return; + this.isLoading = true; + const calls = ids.map((id: number) => this.ps.deleteProduct(id)); + forkJoin(calls).pipe(finalize(() => { + })).subscribe({ + next: () => this.reload(), + error: (e: unknown) => { + this.isLoading = false; + alert('Erreur: ' + (e instanceof Error ? e.message : String(e))); + } + }); + } +} diff --git a/client/src/app/components/ps-product-crud/ps-product-crud.component.html b/client/src/app/components/ps-product-crud/ps-product-crud.component.html index d1606f6..be6c621 100644 --- a/client/src/app/components/ps-product-crud/ps-product-crud.component.html +++ b/client/src/app/components/ps-product-crud/ps-product-crud.component.html @@ -1,37 +1,27 @@
- @if (selection.hasValue()) { - } Filtrer - +
+
@if (isLoading) {
} - @@ -53,61 +43,76 @@ - + + @if (displayed.includes('id')) { + + + + + } - - - - + + @if (displayed.includes('name')) { + + + + + } - - - - + + @if (displayed.includes('category')) { + + + + + } - - - - + + @if (displayed.includes('manufacturer')) { + + + + + } - - - - + + @if (displayed.includes('supplier')) { + + + + + } - - - - + + @if (displayed.includes('priceTtc')) { + + + + + } - - - - + + @if (displayed.includes('quantity')) { + + + + + } - - - - - - - - - + + @if (displayed.includes('actions')) { + + + + + } diff --git a/client/src/app/components/ps-product-crud/ps-product-crud.component.ts b/client/src/app/components/ps-product-crud/ps-product-crud.component.ts index 9e16e99..39af409 100644 --- a/client/src/app/components/ps-product-crud/ps-product-crud.component.ts +++ b/client/src/app/components/ps-product-crud/ps-product-crud.component.ts @@ -1,27 +1,18 @@ -import {Component, inject, OnInit, ViewChild} from '@angular/core'; +import {Component, ViewChild} from '@angular/core'; import {CommonModule} from '@angular/common'; -import { - MatCell, MatCellDef, MatColumnDef, MatHeaderCell, MatHeaderCellDef, - MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef, - MatNoDataRow, MatTable, MatTableDataSource -} from '@angular/material/table'; -import {MatPaginator, MatPaginatorModule} from '@angular/material/paginator'; -import {MatSort, MatSortModule} from '@angular/material/sort'; -import {MatFormField, MatLabel} from '@angular/material/form-field'; -import {MatInput} from '@angular/material/input'; -import {MatButton, MatIconButton} from '@angular/material/button'; -import {MatIcon} from '@angular/material/icon'; -import {FormBuilder, ReactiveFormsModule, FormsModule} from '@angular/forms'; +import {MatTable, MatTableModule} from '@angular/material/table'; +import {MatPaginator} from '@angular/material/paginator'; +import {MatSort} from '@angular/material/sort'; +import {ReactiveFormsModule, FormsModule, FormBuilder} from '@angular/forms'; import {MatDialog, MatDialogModule} from '@angular/material/dialog'; import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; -import {forkJoin, finalize} from 'rxjs'; -import {SelectionModel} from '@angular/cdk/collections'; import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatButton, MatIconButton} from '@angular/material/button'; +import {MatIcon} from '@angular/material/icon'; -import {PsItem} from '../../interfaces/ps-item'; -import {ProductListItem} from '../../interfaces/product-list-item'; import {PrestashopService} from '../../services/prestashop.serivce'; -import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/ps-product-dialog.component'; +import {PsProductCrudBase} from '../ps-product-crud-base/ps-product-crud-base.component'; +import {MatFormField, MatInput, MatLabel} from '@angular/material/input'; @Component({ selector: 'app-ps-product-crud', @@ -29,238 +20,21 @@ import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/ templateUrl: './ps-product-crud.component.html', styleUrls: ['./ps-product-crud.component.css'], imports: [ - CommonModule, ReactiveFormsModule, FormsModule, - MatTable, MatColumnDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef, - MatHeaderCell, MatHeaderCellDef, MatCell, MatCellDef, MatNoDataRow, - MatSortModule, MatPaginatorModule, - MatFormField, MatLabel, MatInput, - MatButton, MatIconButton, MatIcon, - MatDialogModule, - MatProgressSpinnerModule, - MatCheckboxModule + CommonModule, ReactiveFormsModule, FormsModule, MatTableModule, + MatIconButton, MatIcon, + MatDialogModule, MatProgressSpinnerModule, MatCheckboxModule, MatFormField, MatButton, MatLabel, MatInput, MatSort, MatPaginator ] }) -export class PsProductCrudComponent implements OnInit { - private readonly fb = inject(FormBuilder); - private readonly ps = inject(PrestashopService); - private readonly dialog = inject(MatDialog); +export class PsProductCrudComponent extends PsProductCrudBase { + @ViewChild(MatPaginator) declare paginator?: MatPaginator; + @ViewChild(MatSort) declare sort?: MatSort; + @ViewChild(MatTable) declare table?: MatTable; - categories: PsItem[] = []; - manufacturers: PsItem[] = []; - suppliers: PsItem[] = []; - - private catMap = new Map(); - private manMap = new Map(); - private supMap = new Map(); - - // added 'select' column first - displayed: string[] = ['select', 'id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'quantity', 'actions']; - dataSource = new MatTableDataSource([]); - @ViewChild(MatPaginator) paginator!: MatPaginator; - @ViewChild(MatSort) sort!: MatSort; - @ViewChild(MatTable) table!: MatTable; - - // selection model (multiple) - selection = new SelectionModel(true, []); - - filterCtrl = this.fb.control(''); - - isLoading = false; - - ngOnInit(): void { - forkJoin({ - cats: this.ps.list('categories'), - mans: this.ps.list('manufacturers'), - sups: this.ps.list('suppliers') - }).subscribe({ - next: ({cats, mans, sups}) => { - this.categories = cats ?? []; - this.catMap = new Map(this.categories.map(x => [x.id, x.name])); - this.manufacturers = mans ?? []; - this.manMap = new Map(this.manufacturers.map(x => [x.id, x.name])); - this.suppliers = sups ?? []; - this.supMap = new Map(this.suppliers.map(x => [x.id, x.name])); - - this.reload(); - }, - error: err => { - console.error('Erreur lors du chargement des référentiels', err); - } - }); - - this.filterCtrl.valueChanges.subscribe(v => { - this.dataSource.filter = (v ?? '').toString().trim().toLowerCase(); - if (this.paginator) this.paginator.firstPage(); - }); - this.dataSource.filterPredicate = (row: any, f: string) => - row.name?.toLowerCase().includes(f) || - String(row.id).includes(f) || - (row.categoryName?.toLowerCase().includes(f)) || - (row.manufacturerName?.toLowerCase().includes(f)) || - (row.supplierName?.toLowerCase().includes(f)) || - String(row.quantity ?? '').includes(f); - } - - private toTtc(ht: number, vat: number) { - return Math.round(((ht * (1 + vat)) + Number.EPSILON) * 100) / 100; - } - - private attachSortingAccessors() { - this.dataSource.sortingDataAccessor = (item: any, property: string) => { - switch (property) { - case 'category': - return (item.categoryName ?? '').toLowerCase(); - case 'manufacturer': - return (item.manufacturerName ?? '').toLowerCase(); - case 'supplier': - return (item.supplierName ?? '').toLowerCase(); - case 'priceTtc': - return Number(item.priceTtc ?? 0); - case 'name': - return (item.name ?? '').toLowerCase(); - default: - return item[property]; - } - }; - this.dataSource.paginator = this.paginator; - this.dataSource.sort = this.sort; - } - - private bindProducts(p: (ProductListItem & { priceHt?: number })[]) { - const vat = 0.2; - // clear selection because objects will be new after reload - this.selection.clear(); - this.dataSource.data = p.map(x => ({ - ...x, - categoryName: x.id_category_default ? (this.catMap.get(x.id_category_default) ?? '') : '', - manufacturerName: x.id_manufacturer ? (this.manMap.get(x.id_manufacturer) ?? '') : '', - supplierName: x.id_supplier ? (this.supMap.get(x.id_supplier) ?? '') : '', - priceTtc: this.toTtc(x.priceHt ?? 0, vat) - })); - this.attachSortingAccessors(); - this.table?.renderRows?.(); - } - - reload() { - this.isLoading = true; - this.ps.listProducts() - .pipe( - finalize(() => { - this.isLoading = false; - }) - ) - .subscribe({ - next: p => this.bindProducts(p), - error: err => { - console.error('Erreur lors du chargement des produits', err); - } - }); - } - - create() { - if (this.isLoading) return; - - const data: ProductDialogData = { - mode: 'create', - refs: { - categories: this.categories, - manufacturers: this.manufacturers, - suppliers: this.suppliers - } - }; - this.dialog.open(PsProductDialogComponent, {width: '900px', data}) - .afterClosed() - .subscribe(ok => { - if (ok) this.reload(); - }); - } - - edit(row: ProductListItem & { priceHt?: number }) { - if (this.isLoading) return; - - const data: ProductDialogData = { - mode: 'edit', - productRow: row, - refs: { - categories: this.categories, - manufacturers: this.manufacturers, - suppliers: this.suppliers - } - }; - this.dialog.open(PsProductDialogComponent, {width: '900px', data}) - .afterClosed() - .subscribe(ok => { - if (ok) this.reload(); - }); - } - - remove(row: ProductListItem) { - if (this.isLoading) return; - if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return; - - this.isLoading = true; - this.ps.deleteProduct(row.id) - .pipe( - finalize(() => { - }) - ) - .subscribe({ - next: () => this.reload(), - error: (e: unknown) => { - this.isLoading = false; - alert('Erreur: ' + (e instanceof Error ? e.message : String(e))); - } - }); - } - - // --- Selection helpers --- - - private getVisibleRows(): any[] { - const data = this.dataSource.filteredData || []; - if (!this.paginator) return data; - const start = this.paginator.pageIndex * this.paginator.pageSize; - return data.slice(start, start + this.paginator.pageSize); - } - - isAllSelected(): boolean { - const visible = this.getVisibleRows(); - return visible.length > 0 && visible.every(r => this.selection.isSelected(r)); - } - - isAnySelected(): boolean { - return this.selection.hasValue(); - } - - masterToggle(checked: boolean) { - const visible = this.getVisibleRows(); - if (checked) { - visible.forEach(r => this.selection.select(r)); - } else { - visible.forEach(r => this.selection.deselect(r)); - } - } - - toggleSelection(row: any, checked: boolean) { - if (checked) this.selection.select(row); - else this.selection.deselect(row); - } - - deleteSelected() { - if (this.isLoading) return; - const ids = this.selection.selected.map(s => s.id); - if (!ids.length) return; - if (!confirm(`Supprimer ${ids.length} produit(s) sélectionné(s) ?`)) return; - - this.isLoading = true; - const calls = ids.map((id: number) => this.ps.deleteProduct(id)); - forkJoin(calls).pipe(finalize(() => { - // nothing extra, reload will clear selection - })).subscribe({ - next: () => this.reload(), - error: (e: unknown) => { - this.isLoading = false; - alert('Erreur: ' + (e instanceof Error ? e.message : String(e))); - } - }); + constructor( + fb: FormBuilder, + ps: PrestashopService, + dialog: MatDialog + ) { + super(fb, ps, dialog); } } diff --git a/client/src/app/components/ps-product-dialog/ps-product-dialog.component.ts b/client/src/app/components/ps-product-dialog/ps-product-dialog.component.ts index caef1dd..88fbdba 100644 --- a/client/src/app/components/ps-product-dialog/ps-product-dialog.component.ts +++ b/client/src/app/components/ps-product-dialog/ps-product-dialog.component.ts @@ -143,10 +143,17 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { this.manufacturers = rawManufacturers; this.suppliers = rawSuppliers; - // charger les valeurs possibles pour l’état (Neuf, Très bon état, etc.) this.ps.getConditionValues() .pipe(catchError(() => of([]))) - .subscribe((opts: string[]) => this.conditionOptions = opts); + .subscribe((opts: string[]) => this.conditionOptions = opts.filter(o => + this.normalizeLabel(o) !== this.normalizeLabel('Mint') && + this.normalizeLabel(o) !== this.normalizeLabel('Near Mint') && + this.normalizeLabel(o) !== this.normalizeLabel('Excellent') && + this.normalizeLabel(o) !== this.normalizeLabel('Good') && + this.normalizeLabel(o) !== this.normalizeLabel('Light Played') && + this.normalizeLabel(o) !== this.normalizeLabel('Played') && + this.normalizeLabel(o) !== this.normalizeLabel('Poor') + )); // ---- Mode édition : pré-remplissage ---- if (this.mode === 'edit' && this.productRow) { diff --git a/client/src/app/interfaces/product-list-item.ts b/client/src/app/interfaces/product-list-item.ts index c2e1beb..add4107 100644 --- a/client/src/app/interfaces/product-list-item.ts +++ b/client/src/app/interfaces/product-list-item.ts @@ -4,4 +4,5 @@ export interface ProductListItem { id_manufacturer?: number; id_supplier?: number; id_category_default?: number; + condition?: string; } diff --git a/client/src/app/pages/home/home.component.html b/client/src/app/pages/home/home.component.html index 05fb07c..07883a3 100644 --- a/client/src/app/pages/home/home.component.html +++ b/client/src/app/pages/home/home.component.html @@ -5,7 +5,8 @@
- + +
} @else {

Gestion des produits

diff --git a/client/src/app/pages/product-cards/products-cards.component.css b/client/src/app/pages/product-cards/products-cards.component.css new file mode 100644 index 0000000..625265e --- /dev/null +++ b/client/src/app/pages/product-cards/products-cards.component.css @@ -0,0 +1,5 @@ +.wrap { + padding: 16px; + max-width: 1100px; + margin: auto +} diff --git a/client/src/app/pages/product-cards/products-cards.component.html b/client/src/app/pages/product-cards/products-cards.component.html new file mode 100644 index 0000000..bda89c0 --- /dev/null +++ b/client/src/app/pages/product-cards/products-cards.component.html @@ -0,0 +1,4 @@ +
+

Gestion des cartes Pokémon

+ +
diff --git a/client/src/app/pages/product-cards/products-cards.component.ts b/client/src/app/pages/product-cards/products-cards.component.ts new file mode 100644 index 0000000..d52cebf --- /dev/null +++ b/client/src/app/pages/product-cards/products-cards.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import {PsProductCrudCardsComponent} from '../../components/ps-product-card-crud/ps-product-card-crud.component'; + +@Component({ + selector: 'app-products-cards', + standalone: true, + imports: [ + PsProductCrudCardsComponent + ], + templateUrl: './products-cards.component.html', + styleUrl: './products-cards.component.css' +}) +export class ProductsCardsComponent { + +}
ID{{ el.id }}ID{{ el.id }}Nom{{ el.name }}Nom{{ el.name }}Catégorie{{ el.categoryName }}Catégorie{{ el.categoryName }}Marque{{ el.manufacturerName }}Marque{{ el.manufacturerName }}Fournisseur{{ el.supplierName }}Fournisseur{{ el.supplierName }}Prix TTC (€){{ el.priceTtc | number:'1.2-2' }}Prix TTC (€){{ el.priceTtc | number:'1.2-2' }}Quantité{{ el.quantity }}Quantité{{ el.quantity }}Actions - - - Actions + + +