feat: add quantity field to product CRUD; implement image upload and condition handling in product dialog

This commit is contained in:
Vincent Guillet
2025-11-18 15:38:24 +01:00
parent bcc71b965b
commit d4ffcf0562
5 changed files with 691 additions and 298 deletions

View File

@@ -43,6 +43,11 @@
<td mat-cell *matCellDef="let el">{{ el.priceTtc | number:'1.2-2' }}</td> <td mat-cell *matCellDef="let el">{{ el.priceTtc | number:'1.2-2' }}</td>
</ng-container> </ng-container>
<ng-container matColumnDef="quantity">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Quantité</th>
<td mat-cell *matCellDef="let el">{{ el.quantity }}</td>
</ng-container>
<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">

View File

@@ -50,7 +50,7 @@ export class PsProductCrudComponent implements OnInit {
private supMap = new Map<number, string>(); private supMap = new Map<number, string>();
// table // table
displayed: string[] = ['id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', '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;
@@ -85,7 +85,8 @@ export class PsProductCrudComponent implements OnInit {
String(row.id).includes(f) || String(row.id).includes(f) ||
(row.categoryName?.toLowerCase().includes(f)) || (row.categoryName?.toLowerCase().includes(f)) ||
(row.manufacturerName?.toLowerCase().includes(f)) || (row.manufacturerName?.toLowerCase().includes(f)) ||
(row.supplierName?.toLowerCase().includes(f)); (row.supplierName?.toLowerCase().includes(f)) ||
String(row.quantity ?? '').includes(f);
} }
private toTtc(ht: number, vat: number) { private toTtc(ht: number, vat: number) {

View File

@@ -1,11 +1,37 @@
<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 mat-dialog-content class="grid" [formGroup]="form">
<mat-form-field class="col-6">
<!-- Input pour l'upload des images -->
<div class="col-12">
<label for="fileInput">Images du produit</label>
<input type="file" multiple (change)="onFiles($event)">
</div>
<!-- Affichage des vignettes des images existantes en mode édition -->
@if (mode==='edit' && existingImageUrls.length) {
<div class="col-12">
<div class="thumbs">
@for (url of existingImageUrls; track url) {
<img [src]="url" alt="Produit">
}
</div>
</div>
}
<!-- Input pour le nom du produit -->
<mat-form-field class="col-12">
<mat-label>Nom du produit</mat-label> <mat-label>Nom du produit</mat-label>
<input matInput formControlName="name" autocomplete="off"> <input matInput formControlName="name" autocomplete="off">
</mat-form-field> </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-form-field class="col-6">
<mat-label>Catégorie</mat-label> <mat-label>Catégorie</mat-label>
<mat-select formControlName="categoryId"> <mat-select formControlName="categoryId">
@@ -16,6 +42,17 @@
</mat-select> </mat-select>
</mat-form-field> </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-form-field class="col-6">
<mat-label>Marque</mat-label> <mat-label>Marque</mat-label>
<mat-select formControlName="manufacturerId"> <mat-select formControlName="manufacturerId">
@@ -26,8 +63,9 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<!-- Sélecteur pour la plateforme (Fournisseur) -->
<mat-form-field class="col-6"> <mat-form-field class="col-6">
<mat-label>Fournisseur</mat-label> <mat-label>Plateforme</mat-label>
<mat-select formControlName="supplierId"> <mat-select formControlName="supplierId">
<mat-option [value]="null" disabled>Choisir…</mat-option> <mat-option [value]="null" disabled>Choisir…</mat-option>
@for (s of suppliers; track s.id) { @for (s of suppliers; track s.id) {
@@ -36,34 +74,19 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field class="col-12"> <!-- Checkboxes pour Complet/Notice -->
<mat-label>Description</mat-label>
<textarea matInput rows="4" formControlName="description"></textarea>
</mat-form-field>
<div class="col-12 flags"> <div class="col-12 flags">
<mat-checkbox formControlName="complete">Complet</mat-checkbox> <mat-checkbox formControlName="complete">Complet</mat-checkbox>
<mat-checkbox formControlName="hasManual">Notice</mat-checkbox> <mat-checkbox formControlName="hasManual">Notice</mat-checkbox>
</div> </div>
<div class="col-12"> <!-- Inputs pour le prix -->
<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-form-field class="col-4">
<mat-label>Prix TTC (€)</mat-label> <mat-label>Prix TTC (€)</mat-label>
<input matInput type="number" step="0.01" min="0" formControlName="priceTtc"> <input matInput type="number" step="0.01" min="0" formControlName="priceTtc">
</mat-form-field> </mat-form-field>
<!-- Input pour la quantité -->
<mat-form-field class="col-4"> <mat-form-field class="col-4">
<mat-label>Quantité</mat-label> <mat-label>Quantité</mat-label>
<input matInput type="number" step="1" min="0" formControlName="quantity"> <input matInput type="number" step="1" min="0" formControlName="quantity">

View File

@@ -18,7 +18,7 @@ import { catchError, forkJoin, of, Observable } 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';
export type ProductDialogData = { export type ProductDialogData = {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
@@ -55,6 +55,9 @@ export class PsProductDialogComponent implements OnInit {
images: File[] = []; images: File[] = [];
existingImageUrls: string[] = []; existingImageUrls: string[] = [];
// 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 // on conserve la dernière description chargée pour éviter lécrasement à vide
private lastLoadedDescription = ''; private lastLoadedDescription = '';
@@ -64,19 +67,36 @@ export class PsProductDialogComponent implements OnInit {
categoryId: [null as number | null, Validators.required], categoryId: [null as number | null, Validators.required],
manufacturerId: [null as number | null, Validators.required], manufacturerId: [null as number | null, Validators.required],
supplierId: [null as number | null, Validators.required], supplierId: [null as number | null, Validators.required],
complete: [false], complete: [true],
hasManual: [false], hasManual: [true],
conditionLabel: ['', Validators.required],
priceTtc: [0, [Validators.required, Validators.min(0)]], priceTtc: [0, [Validators.required, Validators.min(0)]],
quantity: [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; } // ---------- 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 */ /** enlève <![CDATA[ ... ]]> si présent */
private stripCdata(s: string): string { private stripCdata(s: string): string {
if (!s) return ''; if (!s) return '';
return s.startsWith('<![CDATA[') && s.endsWith(']]>') ? s.slice(9, -3) : s; return s.startsWith('<![CDATA[') && s.endsWith(']]>')
? s.slice(9, -3)
: s;
} }
/** convertit du HTML en texte (pour le textarea) */ /** convertit du HTML en texte (pour le textarea) */
private htmlToText(html: string): string { private htmlToText(html: string): string {
if (!html) return ''; if (!html) return '';
@@ -84,32 +104,39 @@ export class PsProductDialogComponent implements OnInit {
div.innerHTML = html; div.innerHTML = html;
return (div.textContent || div.innerText || '').trim(); return (div.textContent || div.innerText || '').trim();
} }
/** nettoyage CDATA+HTML -> texte simple */ /** nettoyage CDATA+HTML -> texte simple */
private cleanForTextarea(src: string): string { private cleanForTextarea(src: string): string {
return this.htmlToText(this.stripCdata(src ?? '')); 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 { ngOnInit(): void {
this.mode = this.data.mode; 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; 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) { if (this.mode === 'edit' && this.productRow) {
const r = this.productRow; const r = this.productRow;
// patch immédiat depuis la ligne
const immediateTtc = r.priceHt == null ? 0 : this.toTtc(r.priceHt); const immediateTtc = r.priceHt == null ? 0 : this.toTtc(r.priceHt);
this.form.patchValue({ this.form.patchValue({
name: r.name, name: r.name,
@@ -119,7 +146,6 @@ export class PsProductDialogComponent implements OnInit {
priceTtc: immediateTtc priceTtc: immediateTtc
}); });
// patch final via API (tolérant aux erreurs)
const details$ = this.ps.getProductDetails(r.id).pipe( const details$ = this.ps.getProductDetails(r.id).pipe(
catchError(() => of({ catchError(() => of({
id: r.id, name: r.name, description: '', id: r.id, name: r.name, description: '',
@@ -129,22 +155,28 @@ export class PsProductDialogComponent implements OnInit {
); );
const qty$ = this.ps.getProductQuantity(r.id).pipe(catchError(() => of(0))); const qty$ = this.ps.getProductQuantity(r.id).pipe(catchError(() => of(0)));
const imgs$ = this.ps.getProductImageUrls(r.id).pipe(catchError(() => of<string[]>([]))); 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$ }) forkJoin({ details: details$, qty: qty$, imgs: imgs$, flags: flags$ })
.subscribe(({ details, qty, imgs }) => { .subscribe(({ details, qty, imgs, flags }) => {
const ttc = this.toTtc(details.priceHt ?? 0); const ttc = this.toTtc(details.priceHt ?? 0);
const { base, complete, hasManual } = this.splitDescriptionFlags(details.description ?? ''); const baseDesc = this.cleanForTextarea(details.description ?? '');
this.lastLoadedDescription = base; this.lastLoadedDescription = baseDesc;
this.form.patchValue({ this.form.patchValue({
description: base, description: baseDesc,
complete, hasManual, complete: flags.complete,
hasManual: flags.hasManual,
conditionLabel: flags.conditionLabel || '',
priceTtc: (ttc || this.form.value.priceTtc || 0), priceTtc: (ttc || this.form.value.priceTtc || 0),
quantity: qty, quantity: qty,
categoryId: (details.id_category_default ?? this.form.value.categoryId) ?? null, categoryId: (details.id_category_default ?? this.form.value.categoryId) ?? null,
manufacturerId: (details.id_manufacturer ?? this.form.value.manufacturerId) ?? null, manufacturerId: (details.id_manufacturer ?? this.form.value.manufacturerId) ?? null,
supplierId: (details.id_supplier ?? this.form.value.supplierId) ?? null supplierId: (details.id_supplier ?? this.form.value.supplierId) ?? null
}); });
this.existingImageUrls = imgs; this.existingImageUrls = imgs;
}); });
} }
@@ -170,7 +202,7 @@ export class PsProductDialogComponent implements OnInit {
images: this.images, images: this.images,
complete: !!v.complete, complete: !!v.complete,
hasManual: !!v.hasManual, hasManual: !!v.hasManual,
conditionLabel: undefined, conditionLabel: v.conditionLabel || undefined,
priceTtc: Number(v.priceTtc ?? 0), priceTtc: Number(v.priceTtc ?? 0),
vatRate: 0.2, vatRate: 0.2,
quantity: Math.max(0, Number(v.quantity ?? 0)) quantity: Math.max(0, Number(v.quantity ?? 0))

File diff suppressed because it is too large Load Diff