feat: implement add product functionality; create add-product component with form validation and styling
This commit is contained in:
@@ -8,6 +8,7 @@ import {authOnlyCanActivate, authOnlyCanMatch} from './guards/auth-only.guard';
|
|||||||
import {AdminComponent} from './pages/admin/admin.component';
|
import {AdminComponent} from './pages/admin/admin.component';
|
||||||
import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard';
|
import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard';
|
||||||
import {ProductsComponent} from './pages/products/products.component';
|
import {ProductsComponent} from './pages/products/products.component';
|
||||||
|
import {AddProductComponent} from './pages/add-product/add-product.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
@@ -54,6 +55,12 @@ export const routes: Routes = [
|
|||||||
canMatch: [authOnlyCanMatch],
|
canMatch: [authOnlyCanMatch],
|
||||||
canActivate: [authOnlyCanActivate],
|
canActivate: [authOnlyCanActivate],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'products/add',
|
||||||
|
component: AddProductComponent,
|
||||||
|
canMatch: [authOnlyCanMatch],
|
||||||
|
canActivate: [authOnlyCanActivate],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
redirectTo: ''
|
redirectTo: ''
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<form [formGroup]="form" (ngSubmit)="save()">
|
<form [formGroup]="form" (ngSubmit)="save()">
|
||||||
<h2 mat-dialog-title>{{ data?.title ?? 'Edit' }}</h2>
|
<h2 mat-dialog-title>{{ data.title ?? 'Edit' }}</h2>
|
||||||
|
|
||||||
<mat-dialog-content>
|
<mat-dialog-content>
|
||||||
@for (f of (fields ?? []); track $index) {
|
@for (f of fields; track $index) {
|
||||||
|
|
||||||
@if (f.type === 'checkbox') {
|
@if (f.type === 'checkbox') {
|
||||||
<mat-checkbox [formControlName]="f.key">
|
<mat-checkbox [formControlName]="f.key">
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<mat-form-field style="width:100%; margin-top:8px;">
|
<mat-form-field style="width:100%; margin-top:8px;">
|
||||||
<mat-label>{{ f.label }}</mat-label>
|
<mat-label>{{ f.label }}</mat-label>
|
||||||
|
|
||||||
<mat-select [formControlName]="f.key">
|
<mat-select [formControlName]="f.key" [compareWith]="getCompareFn(f)">
|
||||||
@let opts = (f.options ?? (f.options$ | async) ?? []);
|
@let opts = (f.options ?? (f.options$ | async) ?? []);
|
||||||
|
|
||||||
@for (opt of opts; track $index) {
|
@for (opt of opts; track $index) {
|
||||||
|
|||||||
@@ -1,23 +1,13 @@
|
|||||||
import {Component, Inject, OnInit} from '@angular/core';
|
import {Component, Inject, OnInit, OnDestroy} from '@angular/core';
|
||||||
import {FormBuilder, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
|
||||||
import {MatDialogRef, MAT_DIALOG_DATA, MatDialogModule} from '@angular/material/dialog';
|
|
||||||
import {CommonModule} from '@angular/common';
|
import {CommonModule} from '@angular/common';
|
||||||
|
import {FormBuilder, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
||||||
|
import {MatDialogModule, MatDialogRef, MAT_DIALOG_DATA} from '@angular/material/dialog';
|
||||||
import {MatFormFieldModule} from '@angular/material/form-field';
|
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||||
import {MatInputModule} from '@angular/material/input';
|
import {MatInputModule} from '@angular/material/input';
|
||||||
import {MatButtonModule} from '@angular/material/button';
|
|
||||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
|
||||||
import {MatSelectModule} from '@angular/material/select';
|
import {MatSelectModule} from '@angular/material/select';
|
||||||
import {Observable} from 'rxjs';
|
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||||
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
type Field = {
|
import {Subscription} from 'rxjs';
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
type?: 'text' | 'number' | 'textarea' | 'checkbox' | 'select';
|
|
||||||
options?: any[]; // options statiques
|
|
||||||
options$?: Observable<any[]>; // options dynamiques
|
|
||||||
displayKey?: string; // clé à afficher (ex: 'name')
|
|
||||||
valueKey?: string; // clé valeur (ex: 'id'), si absente la valeur entière de l'objet est utilisée
|
|
||||||
};
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-generic-dialog',
|
selector: 'app-generic-dialog',
|
||||||
@@ -28,41 +18,161 @@ type Field = {
|
|||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatButtonModule,
|
MatSelectModule,
|
||||||
MatCheckboxModule,
|
MatCheckboxModule,
|
||||||
MatSelectModule
|
MatButtonModule
|
||||||
],
|
],
|
||||||
templateUrl: './generic-dialog.component.html'
|
templateUrl: './generic-dialog.component.html',
|
||||||
})
|
})
|
||||||
export class GenericDialogComponent implements OnInit {
|
export class GenericDialogComponent implements OnInit, OnDestroy {
|
||||||
form!: FormGroup;
|
form!: FormGroup;
|
||||||
fields?: Field[];
|
fields: any[] = [];
|
||||||
|
compareFns = new Map<string, (a: any, b: any) => boolean>();
|
||||||
|
optionsCache = new Map<string, any[]>();
|
||||||
|
private readonly subscriptions: Subscription[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly fb: FormBuilder,
|
private readonly fb: FormBuilder,
|
||||||
private readonly dialogRef: MatDialogRef<GenericDialogComponent>,
|
private readonly dialogRef: MatDialogRef<GenericDialogComponent>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data?: { item?: any; fields?: Field[]; title?: string }
|
@Inject(MAT_DIALOG_DATA) public data: { title?: string; fields?: any[]; model?: any }
|
||||||
) {
|
) {
|
||||||
|
this.fields = data?.fields ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.fields = this.data?.fields ?? [];
|
const model = this.data?.model ?? {};
|
||||||
this.form = this.fb.group({});
|
const controls: { [key: string]: any[] } = {};
|
||||||
|
|
||||||
for (const f of this.fields) {
|
for (const f of this.fields) {
|
||||||
const initial =
|
if (f.options && Array.isArray(f.options)) {
|
||||||
this.data?.item?.[f.key] ??
|
this.optionsCache.set(f.key, f.options);
|
||||||
(f.type === 'checkbox' ? false : (f.type === 'select' ? null : ''));
|
}
|
||||||
this.form.addControl(f.key, this.fb.control(initial));
|
|
||||||
|
if (f.options$ && typeof f.options$.subscribe === 'function') {
|
||||||
|
try {
|
||||||
|
const sub = (f.options$ as any).subscribe((opts: any[]) => {
|
||||||
|
this.optionsCache.set(f.key, opts || []);
|
||||||
|
console.log(`[GenericDialog] options for "${f.key}":`, opts);
|
||||||
|
}, (err: any) => {
|
||||||
|
console.warn(`[GenericDialog] error loading options for "${f.key}":`, err);
|
||||||
|
});
|
||||||
|
this.subscriptions.push(sub);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[GenericDialog] cannot subscribe to options$ for "${f.key}":`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = model?.[f.key];
|
||||||
|
|
||||||
|
if (f.type === 'checkbox') {
|
||||||
|
value = !!value;
|
||||||
|
} else if (f.type === 'select') {
|
||||||
|
if (value && typeof value === 'object' && f.valueKey) {
|
||||||
|
value = value[f.valueKey] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
const idKey = `${f.key}Id`;
|
||||||
|
if (model[idKey] !== undefined) {
|
||||||
|
value = model[idKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((value === null || value === undefined) && f.key === 'brand') {
|
||||||
|
const platBrand = model?.platform?.brand;
|
||||||
|
if (platBrand) {
|
||||||
|
value = f.valueKey ? platBrand[f.valueKey] ?? platBrand : platBrand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((value === null || value === undefined) && f.key === 'condition') {
|
||||||
|
const cond = model?.condition;
|
||||||
|
if (cond) {
|
||||||
|
value = f.valueKey ? cond[f.valueKey] ?? cond : cond;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value = value ?? f.default ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[GenericDialog] field "${f.key}" computed initial value:`, value, 'valueKey:', f.valueKey);
|
||||||
|
|
||||||
|
controls[f.key] = [value ?? (f.default ?? null)];
|
||||||
|
|
||||||
|
const valueKey = f.valueKey;
|
||||||
|
this.compareFns.set(f.key, (a: any, b: any) => {
|
||||||
|
if (a === null || a === undefined || b === null || b === undefined) {
|
||||||
|
return a === b;
|
||||||
|
}
|
||||||
|
if (valueKey) {
|
||||||
|
const aval = (typeof a === 'object') ? (a[valueKey] ?? a) : a;
|
||||||
|
const bval = (typeof b === 'object') ? (b[valueKey] ?? b) : b;
|
||||||
|
return String(aval) === String(bval);
|
||||||
|
}
|
||||||
|
return a === b;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.form = this.fb.group(controls);
|
||||||
|
console.log('[GenericDialog] form initial value:', this.form.value);
|
||||||
|
|
||||||
|
for (const f of this.fields) {
|
||||||
|
if (f.type === 'select') {
|
||||||
|
const ctrl = this.form.get(f.key);
|
||||||
|
if (ctrl) {
|
||||||
|
const sub = ctrl.valueChanges.subscribe((v) => {
|
||||||
|
console.log(`[GenericDialog] form control "${f.key}" valueChanges ->`, v);
|
||||||
|
});
|
||||||
|
this.subscriptions.push(sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
save(): void {
|
save(): void {
|
||||||
if (this.form.valid) {
|
if (this.form.invalid) {
|
||||||
this.dialogRef.close({...this.data?.item, ...this.form.value});
|
this.form.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = this.form.value;
|
||||||
|
const payload: any = {...raw};
|
||||||
|
|
||||||
|
for (const f of this.fields) {
|
||||||
|
if (f.type === 'select') {
|
||||||
|
const val = raw[f.key];
|
||||||
|
if (f.valueKey) {
|
||||||
|
const opts = this.optionsCache.get(f.key) ?? [];
|
||||||
|
const found = opts.find((o: any) => String(o[f.valueKey]) === String(val));
|
||||||
|
if (found) {
|
||||||
|
payload[f.key] = found;
|
||||||
|
} else if (val === null || val === undefined) {
|
||||||
|
payload[f.key] = null;
|
||||||
|
} else {
|
||||||
|
payload[f.key] = {[f.valueKey]: val};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const opts = this.optionsCache.get(f.key) ?? [];
|
||||||
|
const found = opts.find((o: any) => o === val || JSON.stringify(o) === JSON.stringify(val));
|
||||||
|
payload[f.key] = found ?? val;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.dialogRef.close(payload);
|
||||||
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
for (let subscription of this.subscriptions) {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCompareFn(field: any) {
|
||||||
|
return this.compareFns.get(field?.key) ?? ((a: any, b: any) => a === b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
/* ===== Container centré ===== */
|
|
||||||
.generic-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem clamp(1rem, 3vw, 3rem);
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Header ===== */
|
|
||||||
.gl-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: flex-end; /* bouton à droite */
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
border-bottom: 1px solid rgba(0,0,0,.08);
|
|
||||||
padding-bottom: .75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Cartes (filtre, table, pagination) ===== */
|
|
||||||
.gl-block {
|
|
||||||
border: 1px solid rgba(0,0,0,.08);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--gl-surface, #fff);
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(0,0,0,.04),
|
|
||||||
0 2px 8px rgba(0,0,0,.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Barre de filtre ===== */
|
|
||||||
.gl-filter-bar { padding: .75rem; }
|
|
||||||
.gl-filter { display: block; width: 100%; max-width: none; }
|
|
||||||
|
|
||||||
/* ===== Tableau ===== */
|
|
||||||
.gl-table-wrapper {
|
|
||||||
overflow: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
padding: 0.25rem 0.5rem; /* espace interne pour éviter l'effet "collé" */
|
|
||||||
}
|
|
||||||
|
|
||||||
.gl-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: separate;
|
|
||||||
border-spacing: 0;
|
|
||||||
min-width: 720px; /* permet le scroll horizontal si trop de colonnes */
|
|
||||||
}
|
|
||||||
|
|
||||||
.gl-table th[mat-header-cell] {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 2;
|
|
||||||
background: inherit;
|
|
||||||
box-shadow: inset 0 -1px 0 rgba(0,0,0,.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gl-table th[mat-header-cell],
|
|
||||||
.gl-table td[mat-cell] {
|
|
||||||
padding: 14px 18px;
|
|
||||||
vertical-align: middle;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Zebra + hover */
|
|
||||||
.gl-table tr.mat-mdc-row:nth-child(odd) td[mat-cell] { background: rgba(0,0,0,.015); }
|
|
||||||
.gl-table tr.mat-mdc-row:hover td[mat-cell] { background: rgba(0,0,0,.035); }
|
|
||||||
|
|
||||||
/* Actions */
|
|
||||||
.actions-head { width: 1%; white-space: nowrap; }
|
|
||||||
.actions-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: .4rem;
|
|
||||||
}
|
|
||||||
.actions-cell .mat-mdc-icon-button { width: 40px; height: 40px; }
|
|
||||||
|
|
||||||
/* ===== Pagination ===== */
|
|
||||||
.gl-paginator-wrap { padding: .25rem .5rem; }
|
|
||||||
.gl-paginator {
|
|
||||||
margin-top: .25rem;
|
|
||||||
padding-top: .5rem;
|
|
||||||
border-top: 1px solid rgba(0,0,0,.08);
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Empty state ===== */
|
|
||||||
.no-products {
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(0,0,0,.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Responsive ===== */
|
|
||||||
@media (max-width: 799px) {
|
|
||||||
.generic-list { padding: 0.75rem 1rem; }
|
|
||||||
.gl-table { min-width: 0; }
|
|
||||||
.gl-table th[mat-header-cell],
|
|
||||||
.gl-table td[mat-cell] { white-space: normal; padding: 10px 12px; }
|
|
||||||
.actions-cell { justify-content: flex-start; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Dark mode ===== */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.gl-block {
|
|
||||||
background: #1b1b1b;
|
|
||||||
border-color: rgba(255,255,255,.08);
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(0,0,0,.6),
|
|
||||||
0 2px 8px rgba(0,0,0,.45);
|
|
||||||
}
|
|
||||||
.gl-header { border-bottom-color: rgba(255,255,255,.08); }
|
|
||||||
.gl-table th[mat-header-cell] { box-shadow: inset 0 -1px 0 rgba(255,255,255,.08); }
|
|
||||||
.gl-table tr.mat-mdc-row:nth-child(odd) td[mat-cell] { background: rgba(255,255,255,.025); }
|
|
||||||
.gl-table tr.mat-mdc-row:hover td[mat-cell] { background: rgba(255,255,255,.06); }
|
|
||||||
.gl-paginator { border-top-color: rgba(255,255,255,.08); }
|
|
||||||
.no-products { color: rgba(255,255,255,.7); }
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<app-generic-list
|
|
||||||
[service]="productService"
|
|
||||||
[fields]="fields"
|
|
||||||
title="Liste des produits"
|
|
||||||
addTitle="Ajouter un produit"
|
|
||||||
editTitle="Modifier le produit"
|
|
||||||
deleteTitle="Supprimer le produit"
|
|
||||||
idKey="id">
|
|
||||||
</app-generic-list>
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
inject
|
|
||||||
} from '@angular/core';
|
|
||||||
import {GenericListComponent} from '../generic-list/generic-list.component';
|
|
||||||
import {ProductService} from '../../../services/app/product.service';
|
|
||||||
import {BrandService} from '../../../services/app/brand.service';
|
|
||||||
import {PlatformService} from '../../../services/app/platform.service';
|
|
||||||
import {CategoryService} from '../../../services/app/category.service';
|
|
||||||
import {ConditionService} from '../../../services/app/condition.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-product-list',
|
|
||||||
templateUrl: './product-list.component.html',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
GenericListComponent
|
|
||||||
],
|
|
||||||
styleUrls: ['./product-list.component.css']
|
|
||||||
})
|
|
||||||
export class ProductListComponent {
|
|
||||||
|
|
||||||
productService: ProductService = inject(ProductService);
|
|
||||||
categoryService: CategoryService = inject(CategoryService);
|
|
||||||
brandService: BrandService = inject(BrandService);
|
|
||||||
platformService: PlatformService = inject(PlatformService);
|
|
||||||
conditionService: ConditionService = inject(ConditionService);
|
|
||||||
|
|
||||||
fields = [
|
|
||||||
{key: 'title', label: 'Titre', sortable: true},
|
|
||||||
{
|
|
||||||
key: 'description',
|
|
||||||
label: 'Description',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'category',
|
|
||||||
label: 'Catégorie',
|
|
||||||
type: 'select',
|
|
||||||
options$: this.categoryService.getAll(),
|
|
||||||
displayKey: 'name',
|
|
||||||
sortable: true,
|
|
||||||
sortKey: 'category.name'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'platform.brand',
|
|
||||||
label: 'Marque',
|
|
||||||
type: 'select',
|
|
||||||
options$: this.brandService.getAll(),
|
|
||||||
displayKey: 'name',
|
|
||||||
sortable: true,
|
|
||||||
sortKey: 'platform.brand.name'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'platform',
|
|
||||||
label: 'Plateforme',
|
|
||||||
type: 'select',
|
|
||||||
options$: this.platformService.getAll(),
|
|
||||||
displayKey: 'name',
|
|
||||||
sortable: true,
|
|
||||||
sortKey: 'platform.name'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'condition.displayName',
|
|
||||||
label: 'État',
|
|
||||||
type: 'select',
|
|
||||||
options$: this.conditionService.getAll(),
|
|
||||||
displayKey: 'displayName',
|
|
||||||
sortable: true,
|
|
||||||
sortKey: 'condition.displayName'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'complete',
|
|
||||||
label: 'Complet',
|
|
||||||
type: 'checkbox',
|
|
||||||
sortable: true,
|
|
||||||
sortKey: 'complete',
|
|
||||||
displayFn: (value: boolean): string => value ? '✔ Oui' : '✗ Non'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'manualIncluded',
|
|
||||||
label: 'Notice',
|
|
||||||
type: 'checkbox',
|
|
||||||
sortable: true,
|
|
||||||
sortKey: 'manualIncluded',
|
|
||||||
displayFn: (value: boolean): string => value ? '✔ Oui' : '✗ Non'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'price',
|
|
||||||
label: 'Prix',
|
|
||||||
sortable: true,
|
|
||||||
sortKey: 'price',
|
|
||||||
displayFn: (value: string | number): string => {
|
|
||||||
if (value == null || value === '') return '';
|
|
||||||
try {
|
|
||||||
return new Intl
|
|
||||||
.NumberFormat('fr-FR', {style: 'currency', currency: 'EUR', maximumFractionDigits: 2})
|
|
||||||
.format(Number(value));
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'quantity',
|
|
||||||
label: 'Quantité',
|
|
||||||
sortable: true,
|
|
||||||
sortKey: 'quantity'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
32
client/src/app/pages/add-product/add-product.component.css
Normal file
32
client/src/app/pages/add-product/add-product.component.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
.auth-wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
margin: 8px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-8 {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
149
client/src/app/pages/add-product/add-product.component.html
Normal file
149
client/src/app/pages/add-product/add-product.component.html
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<section class="auth-wrap">
|
||||||
|
<mat-card class="auth-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Ajouter un produit</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="addProductForm" (ngSubmit)="onProductAdd()" class="form-grid">
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Titre</mat-label>
|
||||||
|
<input matInput
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
formControlName="title"
|
||||||
|
type="text"
|
||||||
|
required>
|
||||||
|
@if (isFieldInvalid('title')) {
|
||||||
|
<mat-error>{{ getFieldError('title') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Description textarea -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Description</mat-label>
|
||||||
|
<textarea matInput
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
formControlName="description"
|
||||||
|
rows="4"
|
||||||
|
cdkTextareaAutosize
|
||||||
|
cdkAutosizeMinRows="1"
|
||||||
|
cdkAutosizeMaxRows="5"
|
||||||
|
required></textarea>
|
||||||
|
@if (isFieldInvalid('description')) {
|
||||||
|
<mat-error>{{ getFieldError('description') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Category -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Catégorie</mat-label>
|
||||||
|
<mat-select formControlName="category" disableRipple>
|
||||||
|
@for (category of categories; track category.id) {
|
||||||
|
<mat-option [value]="category">{{ category.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Condition -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>État</mat-label>
|
||||||
|
<mat-select formControlName="condition" disableRipple>
|
||||||
|
@for (condition of conditions; track condition.id) {
|
||||||
|
<mat-option [value]="condition">{{ condition.displayName }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Brand -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Marque</mat-label>
|
||||||
|
<mat-select formControlName="brand" [compareWith]="compareById" disableRipple>
|
||||||
|
@for (brand of filteredBrands; track brand.id) {
|
||||||
|
<mat-option [value]="brand.id">{{ brand.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Platform -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Plateforme</mat-label>
|
||||||
|
<mat-select formControlName="platform" [compareWith]="compareById" disableRipple>
|
||||||
|
@for (platform of filteredPlatforms; track platform.id) {
|
||||||
|
<mat-option [value]="platform.id">{{ platform.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Complete state -->
|
||||||
|
<mat-checkbox formControlName="complete" id="complete">
|
||||||
|
Complet
|
||||||
|
</mat-checkbox>
|
||||||
|
@if (isFieldInvalid('complete')) {
|
||||||
|
<div class="mat-caption mat-error">{{ getFieldError('complete') }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- manual included -->
|
||||||
|
<mat-checkbox formControlName="manual" id="manual">
|
||||||
|
Avec notice
|
||||||
|
</mat-checkbox>
|
||||||
|
@if (isFieldInvalid('manual')) {
|
||||||
|
<div class="mat-caption mat-error">{{ getFieldError('manual') }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Price -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Prix TTC</mat-label>
|
||||||
|
<input matInput
|
||||||
|
id="price"
|
||||||
|
name="price"
|
||||||
|
formControlName="price"
|
||||||
|
type="number"
|
||||||
|
required>
|
||||||
|
@if (isFieldInvalid('price')) {
|
||||||
|
<mat-error>{{ getFieldError('price') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Quantity -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Quantité</mat-label>
|
||||||
|
<input matInput
|
||||||
|
id="quantity"
|
||||||
|
name="quantity"
|
||||||
|
formControlName="quantity"
|
||||||
|
type="number"
|
||||||
|
required>
|
||||||
|
@if (isFieldInvalid('quantity')) {
|
||||||
|
<mat-error>{{ getFieldError('quantity') }}</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="actions">
|
||||||
|
<button mat-raised-button color="primary"
|
||||||
|
type="submit"
|
||||||
|
[disabled]="isLoading || addProductForm.invalid">
|
||||||
|
@if (isLoading) {
|
||||||
|
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||||
|
<span class="ml-8">Ajout du produit…</span>
|
||||||
|
} @else {
|
||||||
|
Ajouter le produit
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
|
||||||
|
<mat-card-actions align="end">
|
||||||
|
<span class="mat-body-small">
|
||||||
|
<a [routerLink]="'/products'">Voir la liste des produits</a>
|
||||||
|
</span>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
</section>
|
||||||
361
client/src/app/pages/add-product/add-product.component.ts
Normal file
361
client/src/app/pages/add-product/add-product.component.ts
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
||||||
|
import {
|
||||||
|
AbstractControl,
|
||||||
|
FormBuilder,
|
||||||
|
FormGroup,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule, ValidatorFn,
|
||||||
|
Validators
|
||||||
|
} from "@angular/forms";
|
||||||
|
import {MatButton} from "@angular/material/button";
|
||||||
|
import {
|
||||||
|
MatCard,
|
||||||
|
MatCardActions,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle
|
||||||
|
} from "@angular/material/card";
|
||||||
|
import {MatCheckbox} from "@angular/material/checkbox";
|
||||||
|
import {MatDivider} from "@angular/material/divider";
|
||||||
|
import {MatError, MatFormField, MatLabel} from "@angular/material/form-field";
|
||||||
|
import {MatInput} from "@angular/material/input";
|
||||||
|
import {MatProgressSpinner} from "@angular/material/progress-spinner";
|
||||||
|
import {MatOption, MatSelect} from '@angular/material/select';
|
||||||
|
import {Router, RouterLink} from '@angular/router';
|
||||||
|
import {Subscription} from 'rxjs';
|
||||||
|
import {BrandService} from '../../services/app/brand.service';
|
||||||
|
import {Brand} from '../../interfaces/brand';
|
||||||
|
import {PlatformService} from '../../services/app/platform.service';
|
||||||
|
import {Platform} from '../../interfaces/platform';
|
||||||
|
import {Category} from '../../interfaces/category';
|
||||||
|
import {CategoryService} from '../../services/app/category.service';
|
||||||
|
import {ConditionService} from '../../services/app/condition.service';
|
||||||
|
import {Condition} from '../../interfaces/condition';
|
||||||
|
import {ProductService} from '../../services/app/product.service';
|
||||||
|
import {CdkTextareaAutosize} from '@angular/cdk/text-field';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-add-product',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
MatButton,
|
||||||
|
MatCard,
|
||||||
|
MatCardActions,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
MatCheckbox,
|
||||||
|
MatDivider,
|
||||||
|
MatError,
|
||||||
|
MatFormField,
|
||||||
|
MatInput,
|
||||||
|
MatLabel,
|
||||||
|
MatProgressSpinner,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatSelect,
|
||||||
|
MatOption,
|
||||||
|
RouterLink,
|
||||||
|
CdkTextareaAutosize
|
||||||
|
],
|
||||||
|
templateUrl: './add-product.component.html',
|
||||||
|
styleUrl: './add-product.component.css'
|
||||||
|
})
|
||||||
|
export class AddProductComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
addProductForm: FormGroup;
|
||||||
|
isSubmitted = false;
|
||||||
|
isLoading = false;
|
||||||
|
|
||||||
|
brands: Brand[] = [];
|
||||||
|
platforms: Platform[] = [];
|
||||||
|
categories: Category[] = [];
|
||||||
|
conditions: Condition[] = [];
|
||||||
|
|
||||||
|
filteredBrands: Brand[] = [];
|
||||||
|
filteredPlatforms: Platform[] = [];
|
||||||
|
|
||||||
|
private addProductSubscription: Subscription | null = null;
|
||||||
|
|
||||||
|
private brandControlSubscription: Subscription | null = null;
|
||||||
|
private platformControlSubscription: Subscription | null = null;
|
||||||
|
|
||||||
|
private brandSubscription: Subscription | null = null;
|
||||||
|
private platformSubscription: Subscription | null = null;
|
||||||
|
private categorySubscription: Subscription | null = null;
|
||||||
|
private conditionSubscription: Subscription | null = null;
|
||||||
|
|
||||||
|
private readonly brandService: BrandService = inject(BrandService);
|
||||||
|
private readonly platformService = inject(PlatformService);
|
||||||
|
private readonly categoryService = inject(CategoryService);
|
||||||
|
private readonly conditionService = inject(ConditionService);
|
||||||
|
private readonly productService = inject(ProductService);
|
||||||
|
|
||||||
|
private readonly router: Router = inject(Router);
|
||||||
|
|
||||||
|
constructor(private readonly formBuilder: FormBuilder) {
|
||||||
|
this.addProductForm = this.formBuilder.group({
|
||||||
|
title: ['', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(3),
|
||||||
|
Validators.maxLength(50),
|
||||||
|
Validators.pattern(/^[\p{L}\p{N}\s]+$/u)
|
||||||
|
]],
|
||||||
|
description: ['', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(10),
|
||||||
|
Validators.maxLength(255),
|
||||||
|
Validators.pattern(/^[\p{L}\p{N}\s]+$/u)
|
||||||
|
]],
|
||||||
|
category: ['', [
|
||||||
|
Validators.required
|
||||||
|
]],
|
||||||
|
condition: ['', [
|
||||||
|
Validators.required
|
||||||
|
]],
|
||||||
|
// stocker des ids (string|number) dans les controls
|
||||||
|
brand: ['', [
|
||||||
|
Validators.required
|
||||||
|
]],
|
||||||
|
platform: ['', [
|
||||||
|
Validators.required
|
||||||
|
]],
|
||||||
|
complete: [true],
|
||||||
|
manual: [true],
|
||||||
|
price: ['', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.pattern(/^\d+([.,]\d{1,2})?$/),
|
||||||
|
this.priceRangeValidator(0, 9999)
|
||||||
|
]],
|
||||||
|
quantity: ['', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.min(1),
|
||||||
|
Validators.max(999),
|
||||||
|
Validators.pattern(/^\d+$/)
|
||||||
|
]]
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeIds<T extends Record<string, any>>(items: T[] | undefined, idKey = 'id'): T[] {
|
||||||
|
return (items || []).map((it, i) => ({
|
||||||
|
...it,
|
||||||
|
[idKey]: (it[idKey] ?? i)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPlatformBrandId(platform: any): string | number | undefined {
|
||||||
|
if (!platform) return undefined;
|
||||||
|
const maybe = platform.brand ?? platform['brand_id'] ?? platform['brandId'];
|
||||||
|
if (maybe == null) return undefined;
|
||||||
|
|
||||||
|
if (typeof maybe === 'object') {
|
||||||
|
if (maybe.id != null) return maybe.id;
|
||||||
|
if (maybe.name != null) {
|
||||||
|
const found = this.brands.find(b =>
|
||||||
|
String(b.name).toLowerCase() === String(maybe.name).toLowerCase()
|
||||||
|
|| String(b.id) === String(maybe.name)
|
||||||
|
);
|
||||||
|
return found?.id;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asStr = String(maybe);
|
||||||
|
const match = this.brands.find(b =>
|
||||||
|
String(b.id) === asStr || String(b.name).toLowerCase() === asStr.toLowerCase()
|
||||||
|
);
|
||||||
|
return match?.id ?? maybe;
|
||||||
|
}
|
||||||
|
|
||||||
|
private priceRangeValidator(min: number, max: number): ValidatorFn {
|
||||||
|
return (control: AbstractControl) => {
|
||||||
|
const val = control.value;
|
||||||
|
if (val === null || val === undefined || val === '') return null;
|
||||||
|
const normalized = String(val).replace(',', '.').trim();
|
||||||
|
const num = Number.parseFloat(normalized);
|
||||||
|
if (Number.isNaN(num)) return {pattern: true};
|
||||||
|
return (num < min || num > max) ? {range: {min, max, actual: num}} : null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
|
||||||
|
this.brandSubscription = this.brandService.getAll().subscribe({
|
||||||
|
next: (brands: Brand[]) => {
|
||||||
|
this.brands = this.normalizeIds(brands, 'id');
|
||||||
|
this.filteredBrands = [...this.brands];
|
||||||
|
},
|
||||||
|
error: (error: any) => {
|
||||||
|
console.error('Error fetching brands:', error);
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
console.log('Finished fetching brands:', this.brands);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.platformSubscription = this.platformService.getAll().subscribe({
|
||||||
|
next: (platforms: Platform[]) => {
|
||||||
|
this.platforms = this.normalizeIds(platforms, 'id');
|
||||||
|
this.filteredPlatforms = [...this.platforms];
|
||||||
|
},
|
||||||
|
error: (error: any) => {
|
||||||
|
console.error('Error fetching platforms:', error);
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
console.log('Finished fetching platforms:', this.platforms);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.categorySubscription = this.categoryService.getAll().subscribe({
|
||||||
|
next: (categories: Category[]) => {
|
||||||
|
this.categories = this.normalizeIds(categories, 'id');
|
||||||
|
},
|
||||||
|
error: (error: any) => {
|
||||||
|
console.error('Error fetching categories:', error);
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
console.log('Finished fetching categories:', this.categories);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.conditionSubscription = this.conditionService.getAll().subscribe({
|
||||||
|
next: (conditions: Condition[]) => {
|
||||||
|
this.conditions = this.normalizeIds(conditions, 'id');
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Error fetching conditions:', error);
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
console.log('Finished fetching conditions:', this.conditions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const brandControl = this.addProductForm.get('brand');
|
||||||
|
const platformControl = this.addProductForm.get('platform');
|
||||||
|
|
||||||
|
this.brandControlSubscription = brandControl?.valueChanges.subscribe((brandId) => {
|
||||||
|
if (brandId != null && brandId !== '') {
|
||||||
|
const brandIdStr = String(brandId);
|
||||||
|
this.filteredPlatforms = this.platforms.filter(p => {
|
||||||
|
const pBid = this.getPlatformBrandId(p);
|
||||||
|
return pBid != null && String(pBid) === brandIdStr;
|
||||||
|
});
|
||||||
|
const curPlatformId = platformControl?.value;
|
||||||
|
if (curPlatformId != null && !this.filteredPlatforms.some(p => String(p.id) === String(curPlatformId))) {
|
||||||
|
platformControl?.setValue(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.filteredPlatforms = [...this.platforms];
|
||||||
|
}
|
||||||
|
}) ?? null;
|
||||||
|
|
||||||
|
this.platformControlSubscription = platformControl?.valueChanges.subscribe((platformId) => {
|
||||||
|
if (platformId != null && platformId !== '') {
|
||||||
|
const platformObj = this.platforms.find(p => String(p.id) === String(platformId));
|
||||||
|
const pBrandId = this.getPlatformBrandId(platformObj);
|
||||||
|
if (pBrandId != null) {
|
||||||
|
const pBrandIdStr = String(pBrandId);
|
||||||
|
this.filteredBrands = this.brands.filter(b => String(b.id) === pBrandIdStr);
|
||||||
|
const curBrandId = brandControl?.value;
|
||||||
|
if (curBrandId != null && String(curBrandId) !== pBrandIdStr) {
|
||||||
|
brandControl?.setValue(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.filteredBrands = [...this.brands];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.filteredBrands = [...this.brands];
|
||||||
|
}
|
||||||
|
}) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.addProductSubscription?.unsubscribe();
|
||||||
|
this.brandControlSubscription?.unsubscribe();
|
||||||
|
this.platformControlSubscription?.unsubscribe();
|
||||||
|
this.brandSubscription?.unsubscribe();
|
||||||
|
this.platformSubscription?.unsubscribe();
|
||||||
|
this.categorySubscription?.unsubscribe();
|
||||||
|
this.conditionSubscription?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
onProductAdd() {
|
||||||
|
this.isSubmitted = true;
|
||||||
|
|
||||||
|
if (this.addProductForm.valid) {
|
||||||
|
this.isLoading = true;
|
||||||
|
const raw = this.addProductForm.value;
|
||||||
|
|
||||||
|
const priceStr = raw.price ?? '';
|
||||||
|
const priceNum = Number(String(priceStr).replace(',', '.').trim());
|
||||||
|
if (Number.isNaN(priceNum)) {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.addProductForm.get('price')?.setErrors({pattern: true});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantityNum = Number(raw.quantity);
|
||||||
|
|
||||||
|
const brandId = raw.brand;
|
||||||
|
const brandObj = this.brands.find(b => String(b.id) === String(brandId)) ?? {id: brandId, name: undefined};
|
||||||
|
|
||||||
|
const platformId = raw.platform;
|
||||||
|
const foundPlatform = this.platforms.find(p => String(p.id) === String(platformId));
|
||||||
|
const platformObj = {
|
||||||
|
...(foundPlatform ?? {id: platformId, name: undefined}),
|
||||||
|
brand: foundPlatform?.brand ? (typeof foundPlatform.brand === 'object' ? foundPlatform.brand : (this.brands.find(b => String(b.id) === String(foundPlatform.brand)) ?? brandObj)) : brandObj
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...raw,
|
||||||
|
price: priceNum,
|
||||||
|
quantity: quantityNum,
|
||||||
|
brand: brandObj,
|
||||||
|
platform: platformObj
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addProductSubscription = this.productService.add(payload).subscribe({
|
||||||
|
next: (response: any) => {
|
||||||
|
console.log("Product added successfully:", response);
|
||||||
|
this.addProductForm.reset();
|
||||||
|
this.isSubmitted = false;
|
||||||
|
alert("Produit ajouté avec succès !");
|
||||||
|
this.router.navigate(['/products']).then();
|
||||||
|
},
|
||||||
|
error: (error: any) => {
|
||||||
|
console.error("Error adding product:", error);
|
||||||
|
alert("Une erreur est survenue lors de l'ajout du produit.");
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isFieldInvalid(fieldName: string): boolean {
|
||||||
|
const field = this.addProductForm.get(fieldName);
|
||||||
|
return Boolean(field && field.invalid && (field.dirty || field.touched || this.isSubmitted));
|
||||||
|
}
|
||||||
|
|
||||||
|
getFieldError(fieldName: string): string {
|
||||||
|
const field = this.addProductForm.get(fieldName);
|
||||||
|
|
||||||
|
if (field && field.errors) {
|
||||||
|
if (field.errors['required']) return `Ce champ est obligatoire`;
|
||||||
|
if (field.errors['email']) return `Format d'email invalide`;
|
||||||
|
if (field.errors['minlength']) return `Minimum ${field.errors['minlength'].requiredLength} caractères`;
|
||||||
|
if (field.errors['maxlength']) return `Maximum ${field.errors['maxlength'].requiredLength} caractères`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
compareById = (a: any, b: any) => {
|
||||||
|
if (a == null || b == null) return a === b;
|
||||||
|
if (typeof a !== 'object' || typeof b !== 'object') {
|
||||||
|
return String(a) === String(b);
|
||||||
|
}
|
||||||
|
return String(a.id ?? a) === String(b.id ?? b);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* ===== Container centré ===== */
|
||||||
|
.generic-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem clamp(1rem, 3vw, 3rem);
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Header ===== */
|
||||||
|
.gl-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, .08);
|
||||||
|
padding-bottom: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.1rem, 1.3rem + 0.3vw, 1.6rem);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Cartes (filtre, table, pagination) ===== */
|
||||||
|
.gl-block {
|
||||||
|
border: 1px solid rgba(0,0,0,.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--gl-surface, #fff);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgba(0,0,0,.04),
|
||||||
|
0 2px 8px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Barre de filtre ===== */
|
||||||
|
.gl-filter-bar { padding: .75rem; }
|
||||||
|
.gl-filter { display: block; width: 100%; max-width: none; }
|
||||||
|
|
||||||
|
/* ===== Tableau ===== */
|
||||||
|
.gl-table-wrapper {
|
||||||
|
overflow: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: 0.25rem 0.5rem; /* espace interne pour éviter l'effet "collé" */
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
min-width: 720px; /* permet le scroll horizontal si trop de colonnes */
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-table th[mat-header-cell] {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: inherit;
|
||||||
|
box-shadow: inset 0 -1px 0 rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-table th[mat-header-cell],
|
||||||
|
.gl-table td[mat-cell] {
|
||||||
|
padding: 14px 18px;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zebra + hover */
|
||||||
|
.gl-table tr.mat-mdc-row:nth-child(odd) td[mat-cell] { background: rgba(0,0,0,.015); }
|
||||||
|
.gl-table tr.mat-mdc-row:hover td[mat-cell] { background: rgba(0,0,0,.035); }
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.actions-head { width: 1%; white-space: nowrap; }
|
||||||
|
.actions-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: .4rem;
|
||||||
|
}
|
||||||
|
.actions-cell .mat-mdc-icon-button { width: 40px; height: 40px; }
|
||||||
|
|
||||||
|
/* ===== Pagination ===== */
|
||||||
|
.gl-paginator-wrap { padding: .25rem .5rem; }
|
||||||
|
.gl-paginator {
|
||||||
|
margin-top: .25rem;
|
||||||
|
padding-top: .5rem;
|
||||||
|
border-top: 1px solid rgba(0,0,0,.08);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Empty state ===== */
|
||||||
|
.no-products {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(0,0,0,.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Responsive ===== */
|
||||||
|
@media (max-width: 799px) {
|
||||||
|
.generic-list { padding: 0.75rem 1rem; }
|
||||||
|
.gl-table { min-width: 0; }
|
||||||
|
.gl-table th[mat-header-cell],
|
||||||
|
.gl-table td[mat-cell] { white-space: normal; padding: 10px 12px; }
|
||||||
|
.actions-cell { justify-content: flex-start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Dark mode ===== */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.gl-block {
|
||||||
|
background: #1b1b1b;
|
||||||
|
border-color: rgba(255,255,255,.08);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgba(0,0,0,.6),
|
||||||
|
0 2px 8px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
.gl-header { border-bottom-color: rgba(255,255,255,.08); }
|
||||||
|
.gl-table th[mat-header-cell] { box-shadow: inset 0 -1px 0 rgba(255,255,255,.08); }
|
||||||
|
.gl-table tr.mat-mdc-row:nth-child(odd) td[mat-cell] { background: rgba(255,255,255,.025); }
|
||||||
|
.gl-table tr.mat-mdc-row:hover td[mat-cell] { background: rgba(255,255,255,.06); }
|
||||||
|
.gl-paginator { border-top-color: rgba(255,255,255,.08); }
|
||||||
|
.no-products { color: rgba(255,255,255,.7); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,126 @@
|
|||||||
<ng-container>
|
<div class="generic-list">
|
||||||
@if (showList) {
|
<!-- Header (bouton à droite) -->
|
||||||
<app-product-list></app-product-list>
|
<div class="gl-header">
|
||||||
}
|
<h3 class="gl-title">Gestion des produits</h3>
|
||||||
</ng-container>
|
<div class="gl-controls">
|
||||||
|
<button mat-flat-button color="accent" (click)="onAdd()">
|
||||||
|
<mat-icon>add</mat-icon> Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<router-outlet></router-outlet>
|
<!-- Barre de recherche sous le header -->
|
||||||
|
<div class="gl-filter-bar gl-block">
|
||||||
|
<mat-form-field class="gl-filter" appearance="outline">
|
||||||
|
<mat-label>Rechercher</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
placeholder="Tapez pour filtrer…"
|
||||||
|
(input)="applyFilter($any($event.target).value)"
|
||||||
|
aria-label="Filtrer le tableau"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tableau -->
|
||||||
|
<div class="gl-table-wrapper gl-block">
|
||||||
|
<table mat-table [dataSource]="dataSource" class="gl-table" matSort>
|
||||||
|
|
||||||
|
<!-- Title Column -->
|
||||||
|
<ng-container matColumnDef="title">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Nom</th>
|
||||||
|
<td mat-cell *matCellDef="let product">{{ product.title }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Description Column -->
|
||||||
|
<ng-container matColumnDef="description">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Description</th>
|
||||||
|
<td mat-cell *matCellDef="let product">{{ product.description }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Category Column -->
|
||||||
|
<ng-container matColumnDef="category">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Catégorie</th>
|
||||||
|
<td mat-cell *matCellDef="let product">{{ product.category.name }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Platform Column -->
|
||||||
|
<ng-container matColumnDef="platform">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Plateforme</th>
|
||||||
|
<td mat-cell *matCellDef="let product">{{ product.platform.name }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Condition Column -->
|
||||||
|
<ng-container matColumnDef="condition">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>État</th>
|
||||||
|
<td mat-cell *matCellDef="let product">{{ product.condition.displayName }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Complete Column -->
|
||||||
|
<ng-container matColumnDef="complete">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Complet</th>
|
||||||
|
<td mat-cell *matCellDef="let product">
|
||||||
|
@if (product.complete) {
|
||||||
|
<mat-icon color="primary">check_circle</mat-icon>
|
||||||
|
} @else {
|
||||||
|
<mat-icon color="warn">cancel</mat-icon>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Manual Column -->
|
||||||
|
<ng-container matColumnDef="manual">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Notice</th>
|
||||||
|
<td mat-cell *matCellDef="let product">
|
||||||
|
@if (product.manual) {
|
||||||
|
<mat-icon color="primary">check_circle</mat-icon>
|
||||||
|
} @else {
|
||||||
|
<mat-icon color="warn">cancel</mat-icon>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Price Column -->
|
||||||
|
<ng-container matColumnDef="price">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Prix</th>
|
||||||
|
<td mat-cell *matCellDef="let product">{{ product.price | currency:'EUR' }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Quantity Column -->
|
||||||
|
<ng-container matColumnDef="quantity">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Quantité</th>
|
||||||
|
<td mat-cell *matCellDef="let product">{{ product.quantity }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Actions Column -->
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="actions-head">Actions</th>
|
||||||
|
<td mat-cell *matCellDef="let product" class="actions-cell">
|
||||||
|
<button mat-icon-button (click)="onEdit(product)" aria-label="Modifier">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button color="warn" (click)="onDelete(product)" aria-label="Supprimer">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination avec même carte -->
|
||||||
|
<div class="gl-paginator-wrap gl-block">
|
||||||
|
<mat-paginator
|
||||||
|
class="gl-paginator"
|
||||||
|
[pageSize]="10"
|
||||||
|
[pageSizeOptions]="[5,10,25]"
|
||||||
|
showFirstLastButtons>
|
||||||
|
</mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!products || products.length === 0) {
|
||||||
|
<div class="no-products gl-block">Aucun produit trouvé.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,43 +1,182 @@
|
|||||||
import {
|
import {
|
||||||
Component, inject,
|
Component,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
EventEmitter,
|
||||||
|
ViewChild,
|
||||||
|
AfterViewInit,
|
||||||
|
OnChanges,
|
||||||
|
SimpleChanges,
|
||||||
|
OnInit,
|
||||||
|
inject
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {ProductListComponent} from '../../components/list/product-list/product-list.component';
|
import { Product } from '../../interfaces/product';
|
||||||
import {ActivatedRoute, NavigationEnd, Router, RouterOutlet} from '@angular/router';
|
import { ProductService } from '../../services/app/product.service';
|
||||||
import {filter, Subscription} from 'rxjs';
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { CurrencyPipe } from '@angular/common';
|
||||||
|
import { ConfirmDialogComponent } from '../../components/dialog/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
|
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||||
|
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||||
|
import { MatSortModule, MatSort } from '@angular/material/sort';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import {GenericDialogComponent} from '../../components/dialog/generic-dialog/generic-dialog.component';
|
||||||
|
import {CategoryService} from '../../services/app/category.service';
|
||||||
|
import {PlatformService} from '../../services/app/platform.service';
|
||||||
|
import {ConditionService} from '../../services/app/condition.service';
|
||||||
|
import {BrandService} from '../../services/app/brand.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dialog',
|
selector: 'app-products',
|
||||||
templateUrl: './products.component.html',
|
templateUrl: './products.component.html',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
ProductListComponent,
|
MatTableModule,
|
||||||
RouterOutlet
|
MatPaginatorModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatDialogModule,
|
||||||
|
CurrencyPipe
|
||||||
],
|
],
|
||||||
styleUrls: ['./products.component.css']
|
styleUrls: ['./products.component.css']
|
||||||
})
|
})
|
||||||
export class ProductsComponent {
|
export class ProductsComponent implements OnInit, AfterViewInit, OnChanges {
|
||||||
|
|
||||||
showList = true;
|
@Input() products: Product[] = [];
|
||||||
private sub?: Subscription;
|
@Output() add = new EventEmitter<Product>();
|
||||||
|
@Output() edit = new EventEmitter<Product>();
|
||||||
|
@Output() delete = new EventEmitter<Product>();
|
||||||
|
|
||||||
|
displayedColumns: string[] = ['title', 'description', 'category', 'platform', 'condition', 'complete', 'manual', 'price', 'quantity', 'actions'];
|
||||||
|
dataSource = new MatTableDataSource<Product>([]);
|
||||||
|
|
||||||
|
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||||
|
@ViewChild(MatSort) sort!: MatSort;
|
||||||
|
|
||||||
|
private readonly productService: ProductService = inject(ProductService);
|
||||||
|
private readonly categoryService: CategoryService = inject(CategoryService);
|
||||||
|
private readonly brandService: BrandService = inject(BrandService);
|
||||||
|
private readonly platformService: PlatformService = inject(PlatformService);
|
||||||
|
private readonly conditionService: ConditionService = inject(ConditionService);
|
||||||
|
|
||||||
|
private readonly dialog: MatDialog = inject(MatDialog);
|
||||||
private readonly router: Router = inject(Router);
|
private readonly router: Router = inject(Router);
|
||||||
private readonly route: ActivatedRoute = inject(ActivatedRoute);
|
|
||||||
|
private readonly productFields = [
|
||||||
|
{ key: 'title', label: 'Nom', type: 'text', sortable: true },
|
||||||
|
{ key: 'description', label: 'Description', type: 'textarea' },
|
||||||
|
{ key: 'category', label: 'Catégorie', type: 'select', options$: this.categoryService.getAll(), valueKey: 'id', displayKey: 'name', sortable: true },
|
||||||
|
{ key: 'brand', label: 'Marque', type: 'select', options$: this.brandService.getAll(), valueKey: 'id', displayKey: 'name', sortable: true },
|
||||||
|
{ key: 'platform', label: 'Plateforme', type: 'select', options$: this.platformService.getAll(), valueKey: 'id', displayKey: 'name', sortable: true },
|
||||||
|
{ key: 'condition', label: 'État', type: 'select', options$: this.conditionService.getAll(), valueKey: 'name', displayKey: 'displayName', sortable: true },
|
||||||
|
{ key: 'complete', label: 'Complet', type: 'checkbox' },
|
||||||
|
{ key: 'manual', label: 'Notice', type: 'checkbox' },
|
||||||
|
{ key: 'price', label: 'Prix', type: 'number', sortable: true },
|
||||||
|
{ key: 'quantity', label: 'Quantité', type: 'number', sortable: true }
|
||||||
|
];
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.updateShowList(this.route);
|
if (!this.products || this.products.length === 0) {
|
||||||
this.sub = this.router.events.pipe(
|
this.loadProducts();
|
||||||
filter(evt => evt instanceof NavigationEnd)
|
} else {
|
||||||
).subscribe(() => this.updateShowList(this.route));
|
this.dataSource.data = this.products;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateShowList(route: ActivatedRoute): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
let current = route;
|
if (changes['products']) {
|
||||||
while (current.firstChild) {
|
this.dataSource.data = this.products || [];
|
||||||
current = current.firstChild;
|
|
||||||
}
|
}
|
||||||
this.showList = current === route;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngAfterViewInit(): void {
|
||||||
this.sub?.unsubscribe();
|
this.dataSource.paginator = this.paginator;
|
||||||
|
this.dataSource.sort = this.sort;
|
||||||
|
this.dataSource.sortingDataAccessor = (item: Product, property: string) => {
|
||||||
|
switch (property) {
|
||||||
|
case 'category':
|
||||||
|
return item.category?.name ?? '';
|
||||||
|
case 'platform':
|
||||||
|
return item.platform?.name ?? '';
|
||||||
|
case 'condition':
|
||||||
|
return item.condition?.displayName ?? '';
|
||||||
|
case 'complete':
|
||||||
|
return item.complete ? 1 : 0;
|
||||||
|
case 'manualIncluded':
|
||||||
|
return item.manualIncluded ? 1 : 0;
|
||||||
|
case 'price':
|
||||||
|
return item.price ?? 0;
|
||||||
|
case 'quantity':
|
||||||
|
return item.quantity ?? 0;
|
||||||
|
case 'title':
|
||||||
|
return item.title ?? '';
|
||||||
|
case 'description':
|
||||||
|
return item.description ?? '';
|
||||||
|
default:
|
||||||
|
return (item as any)[property];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProducts() {
|
||||||
|
this.productService.getAll().subscribe({
|
||||||
|
next: (products: Product[]) => {
|
||||||
|
this.products = products || []
|
||||||
|
this.dataSource.data = this.products;
|
||||||
|
},
|
||||||
|
error: () => this.products = []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onAdd(): void {
|
||||||
|
this.router.navigate(['/products/add']).then();
|
||||||
|
}
|
||||||
|
|
||||||
|
onEdit(product: Product): void {
|
||||||
|
console.log('[Products] open edit dialog for product:', product);
|
||||||
|
const ref = this.dialog.open(GenericDialogComponent, {
|
||||||
|
width: '600px',
|
||||||
|
data: {
|
||||||
|
title: `Modifier : ${product.title}`,
|
||||||
|
fields: this.productFields,
|
||||||
|
model: { ...product }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ref.afterClosed().subscribe((result: any) => {
|
||||||
|
if (!result) return;
|
||||||
|
this.productService.update(product.id, result).subscribe({
|
||||||
|
next: () => this.loadProducts(),
|
||||||
|
error: (err) => console.error('Erreur update product:', err)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDelete(product: Product): void {
|
||||||
|
const ref = this.dialog.open(ConfirmDialogComponent, {
|
||||||
|
width: '420px',
|
||||||
|
data: {
|
||||||
|
title: 'Supprimer le produit',
|
||||||
|
message: `Voulez-vous vraiment supprimer « ${product.title} » ?`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ref.afterClosed().subscribe((confirmed: boolean) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.delete.emit(product);
|
||||||
|
this.productService.delete(product.id).subscribe(() => this.loadProducts());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilter(value: string): void {
|
||||||
|
this.dataSource.filter = (value || '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user