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

304 lines
10 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 } from 'rxjs';
import { PsItem } from '../../interfaces/ps-item';
import { ProductListItem } from '../../interfaces/product-list-item';
import { PrestashopService } from '../../services/prestashop.serivce';
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
]
})
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>
) {}
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 inchangés (à part dto.images) --------
save() {
if (this.form.invalid) return;
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, // toujours les fichiers sélectionnés
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$.subscribe({
next: () => this.dialogRef.close(true),
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
});
}
close() {
this.dialogRef.close(false);
}
}