397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
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 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);
|
||
}
|
||
}
|