From d64fed8157ead8a19bd1f3660cf3e61fdb589d8b Mon Sep 17 00:00:00 2001 From: Vincent Guillet Date: Sat, 29 Nov 2025 11:53:25 +0100 Subject: [PATCH] Refactor PrestashopService and ps-product-dialog component to enhance API integration and improve product management functionality --- .../ps-product-dialog.component.ts | 33 +- client/src/app/services/prestashop.serivce.ts | 364 ++++++++++++------ 2 files changed, 268 insertions(+), 129 deletions(-) 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 15f7610..187c717 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 @@ -1,4 +1,5 @@ -import {Component, Inject, OnInit, inject, OnDestroy} from '@angular/core'; +// File: src/app/components/ps-product-dialog/ps-product-dialog.component.ts +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'; @@ -20,6 +21,7 @@ import { catchError, forkJoin, of, Observable } from 'rxjs'; import { PsItem } from '../../interfaces/ps-item'; import { ProductListItem } from '../../interfaces/product-list-item'; import { PrestashopService } from '../../services/prestashop.serivce'; +import { PsProduct } from '../../interfaces/ps-product'; // si tu as une interface dédiée export type ProductDialogData = { mode: 'create' | 'edit'; @@ -87,6 +89,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { // ---------- Helpers locaux ---------- + // utilisé seulement pour pré-afficher un TTC approximatif à partir du HT de la liste private toTtc(ht: number) { return Math.round(((ht * 1.2) + Number.EPSILON) * 100) / 100; } @@ -148,6 +151,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { if (this.mode === 'edit' && this.productRow) { const r = this.productRow; + // pré-remplissage rapide avec la ligne de liste (HT -> TTC approximatif) const immediateTtc = r.priceHt == null ? 0 : this.toTtc(r.priceHt); this.form.patchValue({ name: r.name, @@ -158,11 +162,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { }); 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 - })) + catchError(() => of(null)) ); const qty$ = this.ps.getProductQuantity(r.id).pipe(catchError(() => of(0))); const imgs$ = this.ps.getProductImageUrls(r.id).pipe(catchError(() => of([]))); @@ -172,20 +172,23 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { 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 ?? ''); + const baseDesc = this.cleanForTextarea(details?.description ?? ''); this.lastLoadedDescription = baseDesc; this.form.patchValue({ + // description depuis le backend (déjà propre / nettoyée normalement) description: baseDesc, + // flags complete: flags.complete, hasManual: flags.hasManual, conditionLabel: flags.conditionLabel || '', - priceTtc: (ttc || this.form.value.priceTtc || 0), + // TTC "réel" calculé par le backend (si dispo) sinon on garde la valeur actuelle + priceTtc: (details?.priceTtc ?? 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 + // ids de ref issus du DTO backend + categoryId: (details?.categoryId ?? this.form.value.categoryId) ?? null, + manufacturerId: (details?.manufacturerId ?? this.form.value.manufacturerId) ?? null, + supplierId: (details?.supplierId ?? this.form.value.supplierId) ?? null }); this.existingImageUrls = imgs; @@ -203,7 +206,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { const fl = (ev.target as HTMLInputElement).files; // Nettoyage des anciens objectURL - for(let url of this.previewUrls) { + for (let url of this.previewUrls) { URL.revokeObjectURL(url); } this.previewUrls = []; @@ -261,7 +264,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { } } - // -------- Save / close inchangés (à part dto.images) -------- + // -------- Save / close -------- save() { if (this.form.invalid) return; @@ -269,7 +272,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { const v = this.form.getRawValue(); const effectiveDescription = (v.description ?? '').trim() || this.lastLoadedDescription; - const dto = { + const dto: PsProduct = { name: v.name!, description: effectiveDescription, categoryId: +v.categoryId!, diff --git a/client/src/app/services/prestashop.serivce.ts b/client/src/app/services/prestashop.serivce.ts index 6d539ca..f2ec62d 100644 --- a/client/src/app/services/prestashop.serivce.ts +++ b/client/src/app/services/prestashop.serivce.ts @@ -1,165 +1,301 @@ +// File: src/app/services/prestashop.service.ts import { inject, Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable, map } from 'rxjs'; +import { Observable, map, of } from 'rxjs'; import { PsItem } from '../interfaces/ps-item'; import { PsProduct } from '../interfaces/ps-product'; import { ProductListItem } from '../interfaces/product-list-item'; import { environment } from '../../environments/environment'; -export type Resource = 'categories' | 'manufacturers' | 'suppliers'; - -export interface ProductFlags { - complete: boolean; - hasManual: boolean; - conditionLabel?: string | null; -} +type Resource = 'categories' | 'manufacturers' | 'suppliers'; @Injectable({ providedIn: 'root' }) export class PrestashopService { private readonly http = inject(HttpClient); /** - * Base de l'API Spring. - * Exemple dans environment: - * apiUrl: '/gameovergne-api/api' + * Base de ton API Spring. + * Exemple : https://dev.vincent-guillet.fr/gameovergne-api + * + * Assure-toi d'avoir dans environment : + * apiUrl: 'https://dev.vincent-guillet.fr/gameovergne-api' */ - private readonly base = `${environment.apiUrl}/ps-admin`; + private readonly apiBase = environment.apiUrl; - // --------------------------------------------------------------------------- - // RÉFÉRENTIELS SIMPLES : categories / manufacturers / suppliers - // --------------------------------------------------------------------------- + /** Endpoints backend "intelligents" (logique métier Presta) */ + private readonly adminBase = `${this.apiBase}/api/ps-admin`; - list(resource: Resource): Observable { - return this.http.get(`${this.base}/${resource}`); - } + /** Endpoints proxy bruts vers Presta (si tu en as encore besoin) */ + private readonly proxyBase = `${this.apiBase}/api/ps`; - create(resource: Resource, name: string): Observable { - const params = new HttpParams().set('name', name); - return this.http.post(`${this.base}/${resource}`, null, { params }); - } - - update(resource: Resource, id: number, newName: string): Observable { - const params = new HttpParams().set('name', newName); - return this.http.put(`${this.base}/${resource}/${id}`, null, { params }); - } - - delete(resource: Resource, id: number): Observable { - return this.http.delete(`${this.base}/${resource}/${id}`); - } - - // --------------------------------------------------------------------------- - // PRODUITS : liste, flags, création / mise à jour / suppression - // --------------------------------------------------------------------------- + // =========================================================================== + // 1) CRUD générique (categories / manufacturers / suppliers) + // =========================================================================== /** - * Liste des produits avec quantité, basée sur l’API Spring. - * - * Le backend renvoie un DTO en camelCase : - * { - * id: number; - * name: string; - * manufacturerId?: number; - * supplierId?: number; - * categoryId?: number; - * priceHt?: number; - * quantity?: number; - * } - * - * On le remappe sur ton interface existante ProductListItem - * (id_manufacturer, id_supplier, id_category_default, etc.). + * Liste les catégories / fabricants / fournisseurs. + * Signature conservée pour les composants existants. */ - listProducts(query?: string): Observable { + list(resource: Resource): Observable { + return this.http + .get(`${this.adminBase}/reference-data/${resource}`) + .pipe( + // Par sécurité, on trie par nom ici (comme avant) + map(items => + [...items].sort((a, b) => + a.name.localeCompare(b.name, 'fr', { sensitivity: 'base' }) + ) + ) + ); + } + + /** + * Crée une catégorie / manufacturer / supplier. + * Retourne l'id créé (ou null si souci). + */ + create(resource: Resource, name: string): Observable { + return this.http + .post<{ id: number }>( + `${this.adminBase}/reference-data/${resource}`, + { name } + ) + .pipe(map(res => (res && typeof res.id === 'number' ? res.id : null))); + } + + /** + * Met à jour le nom d'une catégorie / manufacturer / supplier. + */ + update(resource: Resource, id: number, newName: string): Observable { + return this.http + .put( + `${this.adminBase}/reference-data/${resource}/${id}`, + { name: newName } + ) + .pipe(map(() => true)); + } + + /** + * Supprime une catégorie / manufacturer / supplier. + */ + delete(resource: Resource, id: number): Observable { + return this.http + .delete(`${this.adminBase}/reference-data/${resource}/${id}`) + .pipe(map(() => true)); + } + + /** + * Récupère le XML brut de l'objet Presta (pour debug / vérif). + * Signature conservée, mais on passe désormais par le backend. + */ + getXml(resource: Resource, id: number): Observable { + return this.http.get( + `${this.adminBase}/reference-data/${resource}/${id}/raw-xml`, + { + responseType: 'text', + } + ); + } + + // =========================================================================== + // 2) Produits (liste / détails) + // =========================================================================== + + /** + * Liste les produits pour l'écran de recherche / listing. + * Anciennement : multi-requêtes directes sur Presta. + * Maintenant : le backend te renvoie déjà les infos nécessaires. + */ + listProducts( + query?: string + ): Observable<(ProductListItem & { priceHt?: number; quantity?: number })[]> { let params = new HttpParams(); if (query?.trim()) { params = params.set('q', query.trim()); } - return this.http - .get< - Array<{ - id: number; - name: string; - manufacturerId?: number; - supplierId?: number; - categoryId?: number; - priceHt?: number; - quantity?: number; - }> - >(`${this.base}/products`, { params }) - .pipe( - map((dtoList) => - dtoList.map( - (dto) => - ({ - id: dto.id, - name: dto.name, - id_manufacturer: dto.manufacturerId, - id_supplier: dto.supplierId, - id_category_default: dto.categoryId, - priceHt: dto.priceHt, - quantity: dto.quantity ?? 0, - } as ProductListItem), - ), - ), - ); + return this.http.get< + (ProductListItem & { priceHt?: number; quantity?: number })[] + >(`${this.adminBase}/products`, { params }); } /** - * Flags Complet / Notice / État pour un produit. - * Le backend renvoie déjà ce format : - * { complete: boolean; hasManual: boolean; conditionLabel?: string } + * Détails complets d'un produit. + * Le backend renvoie un PsProduct (ou équivalent). */ - getProductFlags(productId: number): Observable { - return this.http.get(`${this.base}/products/${productId}/flags`); + getProductDetails(id: number): Observable { + return this.http.get(`${this.adminBase}/products/${id}`); + } + + // =========================================================================== + // 3) Images produits + // =========================================================================== + + getProductImageUrls(productId: number) { + return this.http.get( + `${this.adminBase}/products/${productId}/images` + ); } /** - * Valeurs possibles de la caractéristique "État". - * Renvoie une simple liste de libellés. + * URL de la miniature d'un produit (ou null). + * On délègue au backend (qui sait comment construire l'URL FO). + * + * Ancienne signature conservée : Observable. + */ + getProductThumbnailUrl(productId: number): Observable { + return this.http + .get<{ url: string | null }>( + `${this.adminBase}/products/${productId}/thumbnail` + ) + .pipe(map(res => res?.url ?? null)); + } + + /** + * Si tu as encore un usage direct de l'upload dans certains composants, + * tu peux garder cette méthode mais la faire pointer sur ton endpoint Spring + * "upload image only". + * + * Si tu n'en as pas besoin, tu peux la laisser mais non utilisée. + */ + uploadProductImage( + productId: number, + file: File + ): Observable { + const fd = new FormData(); + fd.append('image', file); + + return this.http.post( + `${this.adminBase}/products/${productId}/images`, + fd + ); + } + + // =========================================================================== + // 4) Stock (quantité) + // =========================================================================== + + /** + * Récupère la quantité du produit dans Presta. + */ + getProductQuantity(productId: number): Observable { + return this.http + .get<{ quantity: number }>( + `${this.adminBase}/products/${productId}/quantity` + ) + .pipe(map(res => (res?.quantity ?? 0))); + } + + // =========================================================================== + // 5) Caractéristiques Complet / Notice / Etat + // =========================================================================== + + /** + * Flags Complet / Notice / Etat d'un produit. + * Signature conservée. + */ + getProductFlags( + productId: number + ): Observable<{ complete: boolean; hasManual: boolean; conditionLabel?: string }> { + return this.http.get<{ + complete: boolean; + hasManual: boolean; + conditionLabel?: string; + }>(`${this.adminBase}/products/${productId}/flags`); + } + + /** + * Liste des valeurs possibles pour la caractéristique "État". + * Signature conservée (Observable). */ getConditionValues(): Observable { - return this.http.get(`${this.base}/meta/condition-values`); + return this.http.get( + `${this.adminBase}/products/conditions` + ); } + // =========================================================================== + // 6) Création / mise à jour / suppression d'un produit + // =========================================================================== + /** - * Création produit : on envoie ton PsProduct tel quel. - * Toute la logique XML / TVA / caractéristiques / stock est dans Spring. + * Construit un FormData pour envoyer : + * - le DTO produit sans les images en JSON ("meta"), + * - les fichiers images ("images"). * - * NB: Si ton interface PsProduct contient `images?: File[]`, - * Jackson côté Spring ignorera cette propriété (unknown property), - * et tu pourras gérer l'upload dans un endpoint dédié plus tard. + * C'est aligné avec un endpoint Spring @PostMapping(consumes = MULTIPART_FORM_DATA) + * qui prend : + * @RequestPart("meta") PsProductDto + * @RequestPart(name = "images", required = false) List */ - createProduct(dto: PsProduct): Observable { - return this.http.post(`${this.base}/products`, dto); + private buildProductFormData(dto: PsProduct): FormData { + const fd = new FormData(); + + // On clone le DTO sans les images pour l'envoyer en JSON + const { images, ...meta } = dto as PsProduct & { images?: File[] }; + + fd.append( + 'meta', + new Blob([JSON.stringify(meta)], { type: 'application/json' }) + ); + + if (images && images.length) { + for (const img of images) { + fd.append('images', img); + } + } + + return fd; } /** - * Mise à jour produit. + * Création d'un produit Presta via ton backend. + * Signature conservée : Observable (id du produit Presta). */ - updateProduct(id: number, dto: PsProduct): Observable { - return this.http.put(`${this.base}/products/${id}`, dto); + createProduct(dto: PsProduct): Observable { + const fd = this.buildProductFormData(dto); + + return this.http + .post<{ id: number }>(`${this.adminBase}/products`, fd) + .pipe(map(res => (res && typeof res.id === 'number' ? res.id : null))); } /** - * Suppression produit. + * Mise à jour d'un produit Presta via ton backend. + * Signature conservée : Observable. */ - deleteProduct(id: number): Observable { - return this.http.delete(`${this.base}/products/${id}`); + updateProduct(id: number, dto: PsProduct): Observable { + const fd = this.buildProductFormData(dto); + + return this.http + .put(`${this.adminBase}/products/${id}`, fd) + .pipe(map(() => true)); } - // --------------------------------------------------------------------------- - // TODO ultérieur : gestion des images produits via Spring - // - // Exemple de signature à garder côté front (sans impl pour l’instant) : - // - // uploadProductImage(productId: number, file: File): Observable { - // const formData = new FormData(); - // formData.append('image', file); - // return this.http.post(`${this.base}/products/${productId}/images`, formData); - // } - // - // Il faudra ajouter le contrôleur + service côté Spring pour - // relayer vers /images/products/{id} de Presta. - // --------------------------------------------------------------------------- + /** + * Suppression d'un produit Presta via ton backend. + * Signature conservée : Observable. + */ + deleteProduct(id: number): Observable { + return this.http + .delete(`${this.adminBase}/products/${id}`) + .pipe(map(() => true)); + } + + // =========================================================================== + // 7) (Optionnel) Accès direct proxy /api/ps/** si tu en as encore besoin + // =========================================================================== + + /** + * Si certains composants utilisent encore directement l'ancien endpoint + * "base Presta proxy" (ex: this.base + '/categories?display=...'), + * tu peux garder ce helper pour les migrations progressives. + */ + rawGet(path: string, params?: HttpParams): Observable { + const url = `${this.proxyBase}/${path.replace(/^\/+/, '')}`; + return this.http.get(url, { + params, + responseType: 'text', + }); + } }