refactor: reorganize component files and update import paths; add PsItem and PsProduct interfaces

This commit is contained in:
Vincent Guillet
2025-11-12 12:34:58 +01:00
parent f063a245b9
commit bcc71b965b
92 changed files with 1694 additions and 2815 deletions

View File

@@ -0,0 +1,36 @@
.grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(12, 1fr);
align-items: start;
}
.col-12 {
grid-column: span 12;
}
.col-6 {
grid-column: span 6;
}
.col-4 {
grid-column: span 4;
}
.flags {
display: flex;
gap: 16px;
align-items: center;
}
.thumbs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.thumbs img {
height: 64px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, .2);
}

View File

@@ -0,0 +1,78 @@
<h2 mat-dialog-title>{{ mode === 'create' ? 'Nouveau produit' : 'Modifier le produit' }}</h2>
<div mat-dialog-content class="grid" [formGroup]="form">
<mat-form-field class="col-6">
<mat-label>Nom du produit</mat-label>
<input matInput formControlName="name" autocomplete="off">
</mat-form-field>
<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>
<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>
<mat-form-field class="col-6">
<mat-label>Fournisseur</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>
<mat-form-field class="col-12">
<mat-label>Description</mat-label>
<textarea matInput rows="4" formControlName="description"></textarea>
</mat-form-field>
<div class="col-12 flags">
<mat-checkbox formControlName="complete">Complet</mat-checkbox>
<mat-checkbox formControlName="hasManual">Notice</mat-checkbox>
</div>
<div class="col-12">
<label for="fileInput">Images du produit</label>
<input type="file" multiple (change)="onFiles($event)">
</div>
<div class="col-12" *ngIf="mode==='edit' && existingImageUrls.length">
<div class="thumbs">
@for (url of existingImageUrls; track url) {
<img [src]="url" alt="Produit">
}
</div>
</div>
<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>
<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 mat-dialog-actions>
<button mat-button (click)="close()">Annuler</button>
<button mat-raised-button color="primary" (click)="save()" [disabled]="form.invalid">
{{ mode === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>

View File

@@ -0,0 +1,195 @@
import { Component, Inject, OnInit, inject } 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 } from '@angular/material/button';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogContent,
MatDialogTitle
} from '@angular/material/dialog';
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 };
};
@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
]
})
export class PsProductDialogComponent implements OnInit {
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[] = [];
// 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: [false],
hasManual: [false],
priceTtc: [0, [Validators.required, Validators.min(0)]],
quantity: [0, [Validators.required, Validators.min(0)]],
});
private toTtc(ht: number) { return Math.round(((ht * 1.2) + Number.EPSILON) * 100) / 100; }
/** 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 ?? ''));
}
/** sépare la description "contenu" des drapeaux + détecte Complet/Notice */
private splitDescriptionFlags(desc: string) {
const cleaned = this.cleanForTextarea(desc);
const complete = /Complet\s*:\s*Oui/i.test(desc);
const hasManual = /Notice\s*:\s*Oui/i.test(desc);
const idx = cleaned.indexOf('Complet:');
const base = (idx >= 0 ? cleaned.slice(0, idx) : cleaned).trim();
return { base, complete, hasManual };
}
ngOnInit(): void {
this.mode = this.data.mode;
this.categories = this.data.refs.categories ?? [];
this.manufacturers = this.data.refs.manufacturers ?? [];
this.suppliers = this.data.refs.suppliers ?? [];
this.productRow = this.data.productRow;
if (this.mode === 'edit' && this.productRow) {
const r = this.productRow;
// patch immédiat depuis la ligne
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
});
// patch final via API (tolérant aux erreurs)
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[]>([])));
forkJoin({ details: details$, qty: qty$, imgs: imgs$ })
.subscribe(({ details, qty, imgs }) => {
const ttc = this.toTtc(details.priceHt ?? 0);
const { base, complete, hasManual } = this.splitDescriptionFlags(details.description ?? '');
this.lastLoadedDescription = base;
this.form.patchValue({
description: base,
complete, hasManual,
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;
});
}
}
onFiles(ev: Event) {
const fl = (ev.target as HTMLInputElement).files;
this.images = fl ? Array.from(fl) : [];
}
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,
complete: !!v.complete,
hasManual: !!v.hasManual,
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);
}
}