feat: add quantity field to product CRUD; implement image upload and condition handling in product dialog
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user