Add loading indicators to product CRUD and dialog components

This commit is contained in:
Vincent Guillet
2025-12-03 21:46:18 +01:00
parent 1a5d3a570a
commit 00f45ae6c7
10 changed files with 323 additions and 179 deletions

View File

@@ -47,6 +47,21 @@ th, td {
flex-shrink: 0; 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 { mat-paginator {
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;

View File

@@ -1,16 +1,27 @@
<section class="crud"> <section class="crud">
<div class="toolbar"> <div class="toolbar">
<button mat-raised-button color="primary" (click)="create()"> <button mat-raised-button
color="primary"
(click)="create()"
[disabled]="isLoading">
<mat-icon>add</mat-icon>&nbsp;Nouveau produit <mat-icon>add</mat-icon>&nbsp;Nouveau produit
</button> </button>
<mat-form-field appearance="outline" class="filter"> <mat-form-field appearance="outline" class="filter">
<mat-label>Filtrer</mat-label> <mat-label>Filtrer</mat-label>
<input matInput [formControl]="filterCtrl" placeholder="Nom, ID, catégorie, marque, fournisseur…"> <input matInput
[formControl]="filterCtrl"
placeholder="Nom, ID, catégorie, marque, fournisseur…"
[disabled]="isLoading">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="mat-elevation-z2"> <div class="mat-elevation-z2 product-list-root">
<!-- Overlay de chargement -->
<div class="product-list-loading-overlay" *ngIf="isLoading">
<mat-spinner diameter="48"></mat-spinner>
</div>
<table mat-table [dataSource]="dataSource" matSort> <table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="id"> <ng-container matColumnDef="id">
@@ -51,8 +62,19 @@
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th> <th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let el"> <td mat-cell *matCellDef="let el">
<button mat-icon-button (click)="edit(el)" aria-label="edit"><mat-icon>edit</mat-icon></button> <button mat-icon-button
<button mat-icon-button color="warn" (click)="remove(el)" aria-label="delete"><mat-icon>delete</mat-icon></button> aria-label="edit"
(click)="edit(el)"
[disabled]="isLoading">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button
color="warn"
aria-label="delete"
(click)="remove(el)"
[disabled]="isLoading">
<mat-icon>delete</mat-icon>
</button>
</td> </td>
</ng-container> </ng-container>
@@ -60,10 +82,17 @@
<tr mat-row *matRowDef="let row; columns: displayed;"></tr> <tr mat-row *matRowDef="let row; columns: displayed;"></tr>
<tr class="mat-row" *matNoDataRow> <tr class="mat-row" *matNoDataRow>
<td class="mat-cell" [attr.colspan]="displayed.length">Aucune donnée.</td> <td class="mat-cell" [attr.colspan]="displayed.length">
Aucune donnée.
</td>
</tr> </tr>
</table> </table>
<mat-paginator [pageSizeOptions]="[5,10,25,100]" [pageSize]="10" aria-label="Pagination"></mat-paginator> <mat-paginator
[pageSizeOptions]="[5,10,25,100]"
[pageSize]="10"
aria-label="Pagination"
[disabled]="isLoading">
</mat-paginator>
</div> </div>
</section> </section>

View File

@@ -13,7 +13,8 @@ import {MatButton, MatIconButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon'; import {MatIcon} from '@angular/material/icon';
import {FormBuilder, ReactiveFormsModule} from '@angular/forms'; import {FormBuilder, ReactiveFormsModule} from '@angular/forms';
import {MatDialog, MatDialogModule} from '@angular/material/dialog'; import {MatDialog, MatDialogModule} from '@angular/material/dialog';
import {forkJoin} from 'rxjs'; import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import {forkJoin, finalize} from 'rxjs';
import {PsItem} from '../../interfaces/ps-item'; import {PsItem} from '../../interfaces/ps-item';
import {ProductListItem} from '../../interfaces/product-list-item'; import {ProductListItem} from '../../interfaces/product-list-item';
@@ -32,7 +33,8 @@ import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/
MatSortModule, MatPaginatorModule, MatSortModule, MatPaginatorModule,
MatFormField, MatLabel, MatInput, MatFormField, MatLabel, MatInput,
MatButton, MatIconButton, MatIcon, MatButton, MatIconButton, MatIcon,
MatDialogModule MatDialogModule,
MatProgressSpinnerModule
] ]
}) })
export class PsProductCrudComponent implements OnInit { export class PsProductCrudComponent implements OnInit {
@@ -40,26 +42,24 @@ export class PsProductCrudComponent implements OnInit {
private readonly ps = inject(PrestashopService); private readonly ps = inject(PrestashopService);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
// référentiels
categories: PsItem[] = []; categories: PsItem[] = [];
manufacturers: PsItem[] = []; manufacturers: PsItem[] = [];
suppliers: PsItem[] = []; suppliers: PsItem[] = [];
// maps daffichage
private catMap = new Map<number, string>(); private catMap = new Map<number, string>();
private manMap = new Map<number, string>(); private manMap = new Map<number, string>();
private supMap = new Map<number, string>(); private supMap = new Map<number, string>();
// table
displayed: string[] = ['id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'quantity', 'actions']; displayed: string[] = ['id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'quantity', 'actions'];
dataSource = new MatTableDataSource<any>([]); dataSource = new MatTableDataSource<any>([]);
@ViewChild(MatPaginator) paginator!: MatPaginator; @ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort; @ViewChild(MatSort) sort!: MatSort;
@ViewChild(MatTable) table!: MatTable<any>; @ViewChild(MatTable) table!: MatTable<any>;
// filtre
filterCtrl = this.fb.control<string>(''); filterCtrl = this.fb.control<string>('');
isLoading = false;
ngOnInit(): void { ngOnInit(): void {
forkJoin({ forkJoin({
cats: this.ps.list('categories'), cats: this.ps.list('categories'),
@@ -73,6 +73,7 @@ export class PsProductCrudComponent implements OnInit {
this.manMap = new Map(this.manufacturers.map(x => [x.id, x.name])); this.manMap = new Map(this.manufacturers.map(x => [x.id, x.name]));
this.suppliers = sups ?? []; this.suppliers = sups ?? [];
this.supMap = new Map(this.suppliers.map(x => [x.id, x.name])); this.supMap = new Map(this.suppliers.map(x => [x.id, x.name]));
this.reload(); this.reload();
}, },
error: err => { error: err => {
@@ -80,7 +81,6 @@ export class PsProductCrudComponent implements OnInit {
} }
}); });
// filtre client
this.filterCtrl.valueChanges.subscribe(v => { this.filterCtrl.valueChanges.subscribe(v => {
this.dataSource.filter = (v ?? '').toString().trim().toLowerCase(); this.dataSource.filter = (v ?? '').toString().trim().toLowerCase();
if (this.paginator) this.paginator.firstPage(); if (this.paginator) this.paginator.firstPage();
@@ -133,10 +133,24 @@ export class PsProductCrudComponent implements OnInit {
} }
reload() { reload() {
this.ps.listProducts().subscribe(p => this.bindProducts(p)); 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() { create() {
if (this.isLoading) return;
const data: ProductDialogData = { const data: ProductDialogData = {
mode: 'create', mode: 'create',
refs: { refs: {
@@ -145,12 +159,16 @@ export class PsProductCrudComponent implements OnInit {
suppliers: this.suppliers suppliers: this.suppliers
} }
}; };
this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => { this.dialog.open(PsProductDialogComponent, {width: '900px', data})
if (ok) this.reload(); .afterClosed()
}); .subscribe(ok => {
if (ok) this.reload();
});
} }
edit(row: ProductListItem & { priceHt?: number }) { edit(row: ProductListItem & { priceHt?: number }) {
if (this.isLoading) return;
const data: ProductDialogData = { const data: ProductDialogData = {
mode: 'edit', mode: 'edit',
productRow: row, productRow: row,
@@ -160,16 +178,29 @@ export class PsProductCrudComponent implements OnInit {
suppliers: this.suppliers suppliers: this.suppliers
} }
}; };
this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => { this.dialog.open(PsProductDialogComponent, {width: '900px', data})
if (ok) this.reload(); .afterClosed()
}); .subscribe(ok => {
if (ok) this.reload();
});
} }
remove(row: ProductListItem) { remove(row: ProductListItem) {
if (this.isLoading) return;
if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return; if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return;
this.ps.deleteProduct(row.id).subscribe({
next: () => this.reload(), this.isLoading = true;
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e))) 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)));
}
});
} }
} }

View File

@@ -163,3 +163,19 @@
.thumb-placeholder mat-icon { .thumb-placeholder mat-icon {
font-size: 28px; 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;
}

View File

@@ -1,162 +1,183 @@
<h2 mat-dialog-title>{{ mode === 'create' ? 'Nouveau produit' : 'Modifier le produit' }}</h2> <h2 mat-dialog-title>{{ mode === 'create' ? 'Nouveau produit' : 'Modifier le produit' }}</h2>
<div mat-dialog-content class="grid" [formGroup]="form"> <div class="dialog-root">
<!-- Overlay de chargement -->
@if (isSaving) {
<div class="dialog-loading-overlay">
<mat-spinner diameter="48"></mat-spinner>
</div>
}
<!-- CARROUSEL IMAGES --> <div mat-dialog-content class="grid" [formGroup]="form">
<div class="col-12 carousel">
<div class="carousel-main">
<!-- Bouton précédent --> <!-- CARROUSEL IMAGES -->
<button mat-icon-button <div class="col-12 carousel">
class="carousel-nav-btn left" <div class="carousel-main">
(click)="prev()"
[disabled]="carouselItems.length <= 1">
<mat-icon>chevron_left</mat-icon>
</button>
<!-- Image principale ou placeholder --> <!-- Bouton précédent -->
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
<img [src]="carouselItems[currentIndex].src" alt="Produit">
} @else {
<div class="carousel-placeholder" (click)="fileInput.click()">
<mat-icon>add_photo_alternate</mat-icon>
<span>Ajouter des images</span>
</div>
}
<!-- Bouton suivant -->
<button mat-icon-button
class="carousel-nav-btn right"
(click)="next()"
[disabled]="carouselItems.length <= 1">
<mat-icon>chevron_right</mat-icon>
</button>
<!-- Bouton de suppression (croix rouge) -->
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
<button mat-icon-button <button mat-icon-button
class="carousel-delete-btn" class="carousel-nav-btn left"
(click)="onDeleteCurrentImage()"> (click)="prev()"
<mat-icon>delete</mat-icon> [disabled]="carouselItems.length <= 1">
<mat-icon>chevron_left</mat-icon>
</button> </button>
}
<!-- Bouton suivant --> <!-- Image principale ou placeholder -->
<button mat-icon-button @if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
class="carousel-nav-btn right" <img [src]="carouselItems[currentIndex].src" alt="Produit">
(click)="next()" } @else {
[disabled]="carouselItems.length <= 1"> <div class="carousel-placeholder" (click)="fileInput.click()">
<mat-icon>chevron_right</mat-icon> <mat-icon>add_photo_alternate</mat-icon>
</button> <span>Ajouter des images</span>
</div>
}
<!-- Bouton suivant -->
<button mat-icon-button
class="carousel-nav-btn right"
(click)="next()"
[disabled]="carouselItems.length <= 1">
<mat-icon>chevron_right</mat-icon>
</button>
<!-- Bouton de suppression (croix rouge) -->
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
<button mat-icon-button
class="carousel-delete-btn"
(click)="onDeleteCurrentImage()">
<mat-icon>delete</mat-icon>
</button>
}
<!-- Bouton suivant -->
<button mat-icon-button
class="carousel-nav-btn right"
(click)="next()"
[disabled]="carouselItems.length <= 1">
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<!-- Bandeau de vignettes -->
<div class="carousel-thumbs">
@for (item of carouselItems; let i = $index; track item) {
<div class="thumb-item"
[class.active]="i === currentIndex"
(click)="onThumbClick(i)">
@if (!item.isPlaceholder) {
<!-- Bouton suppression vignette -->
<button mat-icon-button
class="thumb-delete-btn"
(click)="onDeleteThumb(i, $event)">
<mat-icon>close</mat-icon>
</button>
<img class="thumb-img" [src]="item.src" alt="Vignette produit">
} @else {
<div class="thumb-placeholder" (click)="fileInput.click()">
<mat-icon>add</mat-icon>
</div>
}
</div>
}
</div>
<!-- Input réel, caché -->
<input #fileInput type="file" multiple hidden (change)="onFiles($event)">
</div> </div>
<!-- Bandeau de vignettes --> <!-- Input pour le nom du produit -->
<div class="carousel-thumbs"> <mat-form-field class="col-12">
@for (item of carouselItems; let i = $index; track item) { <mat-label>Nom du produit</mat-label>
<div class="thumb-item" <input matInput formControlName="name" autocomplete="off">
[class.active]="i === currentIndex" </mat-form-field>
(click)="onThumbClick(i)">
@if (!item.isPlaceholder) {
<!-- Bouton suppression vignette --> <!-- Textarea pour la description -->
<button mat-icon-button <mat-form-field class="col-12">
class="thumb-delete-btn" <mat-label>Description</mat-label>
(click)="onDeleteThumb(i, $event)"> <textarea matInput rows="4" formControlName="description"></textarea>
<mat-icon>close</mat-icon> </mat-form-field>
</button>
<img class="thumb-img" [src]="item.src" alt="Vignette produit"> <!-- Sélecteur pour la catégorie -->
} @else { <mat-form-field class="col-6">
<div class="thumb-placeholder" (click)="fileInput.click()"> <mat-label>Catégorie</mat-label>
<mat-icon>add</mat-icon> <mat-select formControlName="categoryId">
</div> <mat-option [value]="null" disabled>Choisir…</mat-option>
} @for (c of categories; track c.id) {
</div> <mat-option [value]="c.id">{{ c.name }}</mat-option>
} }
</mat-select>
</mat-form-field>
<!-- Sélecteur pour l'état du produit -->
<mat-form-field class="col-6">
<mat-label>État</mat-label>
<mat-select formControlName="conditionLabel">
@for (opt of conditionOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour la marque -->
<mat-form-field class="col-6">
<mat-label>Marque</mat-label>
<mat-select formControlName="manufacturerId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (m of manufacturers; track m.id) {
<mat-option [value]="m.id">{{ m.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour la plateforme (Fournisseur) -->
<mat-form-field class="col-6">
<mat-label>Plateforme</mat-label>
<mat-select formControlName="supplierId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (s of suppliers; track s.id) {
<mat-option [value]="s.id">{{ s.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Checkboxes pour Complet/Notice -->
<div class="col-12 flags">
<mat-checkbox formControlName="complete">Complet</mat-checkbox>
<mat-checkbox formControlName="hasManual">Notice</mat-checkbox>
</div> </div>
<!-- Input réel, caché --> <!-- Inputs pour le prix -->
<input #fileInput type="file" multiple hidden (change)="onFiles($event)"> <mat-form-field class="col-4">
<mat-label>Prix TTC (€)</mat-label>
<input matInput type="number" step="0.01" min="0" formControlName="priceTtc">
</mat-form-field>
<!-- Input pour la quantité -->
<mat-form-field class="col-4">
<mat-label>Quantité</mat-label>
<input matInput type="number" step="1" min="0" formControlName="quantity">
</mat-form-field>
</div> </div>
<!-- Input pour le nom du produit -->
<mat-form-field class="col-12">
<mat-label>Nom du produit</mat-label>
<input matInput formControlName="name" autocomplete="off">
</mat-form-field>
<!-- Textarea pour la description -->
<mat-form-field class="col-12">
<mat-label>Description</mat-label>
<textarea matInput rows="4" formControlName="description"></textarea>
</mat-form-field>
<!-- Sélecteur pour la catégorie -->
<mat-form-field class="col-6">
<mat-label>Catégorie</mat-label>
<mat-select formControlName="categoryId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (c of categories; track c.id) {
<mat-option [value]="c.id">{{ c.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour l'état du produit -->
<mat-form-field class="col-6">
<mat-label>État</mat-label>
<mat-select formControlName="conditionLabel">
@for (opt of conditionOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour la marque -->
<mat-form-field class="col-6">
<mat-label>Marque</mat-label>
<mat-select formControlName="manufacturerId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (m of manufacturers; track m.id) {
<mat-option [value]="m.id">{{ m.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour la plateforme (Fournisseur) -->
<mat-form-field class="col-6">
<mat-label>Plateforme</mat-label>
<mat-select formControlName="supplierId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (s of suppliers; track s.id) {
<mat-option [value]="s.id">{{ s.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Checkboxes pour Complet/Notice -->
<div class="col-12 flags">
<mat-checkbox formControlName="complete">Complet</mat-checkbox>
<mat-checkbox formControlName="hasManual">Notice</mat-checkbox>
</div>
<!-- Inputs pour le prix -->
<mat-form-field class="col-4">
<mat-label>Prix TTC (€)</mat-label>
<input matInput type="number" step="0.01" min="0" formControlName="priceTtc">
</mat-form-field>
<!-- Input pour la quantité -->
<mat-form-field class="col-4">
<mat-label>Quantité</mat-label>
<input matInput type="number" step="1" min="0" formControlName="quantity">
</mat-form-field>
</div> </div>
<!-- Actions --> <!-- Actions -->
<div mat-dialog-actions> <mat-dialog-actions align="end">
<button mat-button (click)="close()">Annuler</button> <button mat-button
<button mat-raised-button color="primary" (click)="save()" [disabled]="form.invalid"> (click)="close()"
{{ mode === 'create' ? 'Créer' : 'Enregistrer' }} [disabled]="isSaving">
Annuler
</button> </button>
</div>
<button mat-raised-button
color="primary"
(click)="save()"
[disabled]="form.invalid || isSaving">
@if (!isSaving) {
Enregistrer
} @else {
Enregistrement...
}
</button>
</mat-dialog-actions>

View File

@@ -15,11 +15,12 @@ import {
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import {MatIcon} from '@angular/material/icon'; import {MatIcon} from '@angular/material/icon';
import {catchError, forkJoin, of, Observable} from 'rxjs'; import {catchError, forkJoin, of, Observable, finalize} from 'rxjs';
import {PsItem} from '../../interfaces/ps-item'; import {PsItem} from '../../interfaces/ps-item';
import {ProductListItem} from '../../interfaces/product-list-item'; import {ProductListItem} from '../../interfaces/product-list-item';
import {PrestashopService} from '../../services/prestashop.serivce'; import {PrestashopService} from '../../services/prestashop.serivce';
import {MatProgressSpinner} from '@angular/material/progress-spinner';
export type ProductDialogData = { export type ProductDialogData = {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
@@ -38,7 +39,7 @@ type CarouselItem = { src: string; isPlaceholder: boolean };
CommonModule, ReactiveFormsModule, CommonModule, ReactiveFormsModule,
MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox, MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox,
MatButton, MatDialogActions, MatDialogContent, MatDialogTitle, MatButton, MatDialogActions, MatDialogContent, MatDialogTitle,
MatIcon, MatIconButton MatIcon, MatIconButton, MatProgressSpinner
] ]
}) })
export class PsProductDialogComponent implements OnInit, OnDestroy { export class PsProductDialogComponent implements OnInit, OnDestroy {
@@ -51,6 +52,8 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
) { ) {
} }
isSaving = false;
mode!: 'create' | 'edit'; mode!: 'create' | 'edit';
categories: PsItem[] = []; categories: PsItem[] = [];
manufacturers: PsItem[] = []; manufacturers: PsItem[] = [];
@@ -265,7 +268,10 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
// -------- Save / close -------- // -------- Save / close --------
save() { save() {
if (this.form.invalid) return; if (this.form.invalid || this.isSaving) return;
this.isSaving = true;
this.dialogRef.disableClose = true;
const v = this.form.getRawValue(); const v = this.form.getRawValue();
const effectiveDescription = (v.description ?? '').trim() || this.lastLoadedDescription; const effectiveDescription = (v.description ?? '').trim() || this.lastLoadedDescription;
@@ -276,7 +282,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
categoryId: +v.categoryId!, categoryId: +v.categoryId!,
manufacturerId: +v.manufacturerId!, manufacturerId: +v.manufacturerId!,
supplierId: +v.supplierId!, supplierId: +v.supplierId!,
images: this.images, // toujours les fichiers sélectionnés images: this.images,
complete: !!v.complete, complete: !!v.complete,
hasManual: !!v.hasManual, hasManual: !!v.hasManual,
conditionLabel: v.conditionLabel || undefined, conditionLabel: v.conditionLabel || undefined,
@@ -292,10 +298,19 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
op$ = this.ps.updateProduct(this.productRow.id, dto) as Observable<unknown>; op$ = this.ps.updateProduct(this.productRow.id, dto) as Observable<unknown>;
} }
op$.subscribe({ op$
next: () => this.dialogRef.close(true), .pipe(
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e))) 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/.../<id>.jpg) */ /** Extrait l'id_image depuis une URL FO Presta (.../img/p/.../<id>.jpg) */
@@ -375,6 +390,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
} }
close() { close() {
if (this.isSaving) return;
this.dialogRef.close(false); this.dialogRef.close(false);
} }
} }

View File

@@ -516,6 +516,16 @@ export class PrestashopService {
); );
} }
deleteProductImage(productId: number, imageId: number) {
// Presta : DELETE /images/products/{id_product}/{id_image}
return this.http.delete(
`${this.base}/images/products/${productId}/${imageId}`,
{ responseType: 'text' }
).pipe(
map(() => true)
);
}
// -------- Stock (quantité) — gestion fine via stock_availables // -------- Stock (quantité) — gestion fine via stock_availables
getProductQuantity(productId: number) { getProductQuantity(productId: number) {

View File

@@ -2,4 +2,5 @@ export const environment = {
production: true, production: true,
apiUrl: '/gameovergne-api/api', apiUrl: '/gameovergne-api/api',
psUrl: '/gameovergne-api/api/ps', psUrl: '/gameovergne-api/api/ps',
indexBase: '/gameovergne/',
}; };

View File

@@ -2,4 +2,5 @@ export const environment = {
production: false, production: false,
apiUrl: 'http://localhost:3000/api', apiUrl: 'http://localhost:3000/api',
psUrl: '/ps', psUrl: '/ps',
}; indexBase: '/',
};

View File

@@ -3,13 +3,17 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Game Over'gne App</title> <title>Game Over'gne App</title>
<base href="/gameovergne/"> <script>
import {environment} from "./environments/environment.prod";
document.write('<base href="' + environment.indexBase + '">');
</script>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head> </head>
<body class="mat-typography"> <body class="mat-typography">
<app-root></app-root> <app-root></app-root>
</body> </body>
</html> </html>