Files
gameovergne-app/client/src/app/components/ps-product-dialog/ps-product-dialog.component.ts

397 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-dialog.component.html',
styleUrls: ['./ps-product-dialog.component.css'],
imports: [
CommonModule, ReactiveFormsModule,
MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox,
MatButton, MatDialogActions, MatDialogContent, MatDialogTitle,
MatIcon, MatIconButton, MatProgressSpinner
]
})
export class PsProductDialogComponent 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<PsProductDialogComponent>
) {
}
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, Validators.required],
supplierId: [null as number | null, Validators.required],
complete: [true],
hasManual: [true],
conditionLabel: ['', Validators.required],
priceTtc: [0, [Validators.required, Validators.min(0)]],
quantity: [0, [Validators.required, Validators.min(0)]],
});
// ---------- 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 <![CDATA[ ... ]]> si présent */
private stripCdata(s: string): string {
if (!s) return '';
return s.startsWith('<![CDATA[') && s.endsWith(']]>')
? 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<string[]>([])))
.subscribe((opts: string[]) => this.conditionOptions = opts);
// ---- 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<string[]>([])));
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<unknown>;
if (this.mode === 'create' || !this.productRow) {
op$ = this.ps.createProduct(dto) as Observable<unknown>;
} else {
op$ = this.ps.updateProduct(this.productRow.id, dto) as Observable<unknown>;
}
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/.../<id>.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 lID de limage à 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 lindex 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 limage : ' + (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);
}
}