feat: add categories and manufacturers CRUD components; implement form handling and image upload functionality
This commit is contained in:
32
client/src/app/admin-presta/admin-presta.module.ts
Normal file
32
client/src/app/admin-presta/admin-presta.module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {ReactiveFormsModule, FormsModule} from '@angular/forms';
|
||||
|
||||
// Angular Material (tous utilisés dans ces composants)
|
||||
import {MatTabsModule} from '@angular/material/tabs';
|
||||
import {MatTableModule} from '@angular/material/table';
|
||||
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';
|
||||
|
||||
// Composants de cette feature
|
||||
import {PsCrudTabsComponent} from './ps-crud-tabs/ps-crud-tabs.component';
|
||||
import {CategoriesCrudComponent} from './categories-crud/categories-crud.component';
|
||||
import {ManufacturersCrudComponent} from './manufacturers-crud/manufacturers-crud.component';
|
||||
import {SuppliersCrudComponent} from './suppliers-crud/suppliers-crud.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule, FormsModule,
|
||||
MatTabsModule, MatTableModule, MatFormFieldModule, MatInputModule,
|
||||
MatButtonModule, MatIconModule, PsCrudTabsComponent, CategoriesCrudComponent, ManufacturersCrudComponent, SuppliersCrudComponent
|
||||
],
|
||||
exports: [PsCrudTabsComponent] // pour pouvoir l’utiliser ailleurs
|
||||
})
|
||||
export class AdminPrestaModule {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.crud {
|
||||
display: grid;
|
||||
gap: 16px
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<section class="crud">
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="row">
|
||||
<mat-form-field class="grow">
|
||||
<mat-label>Nom de la catégorie</mat-label>
|
||||
<input matInput formControlName="name" />
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid">
|
||||
{{ editId ? 'Enregistrer' : 'Créer' }}
|
||||
</button>
|
||||
<button *ngIf="editId" mat-button type="button" (click)="cancelEdit()">Annuler</button>
|
||||
</form>
|
||||
|
||||
<table mat-table [dataSource]="items" class="mat-elevation-z2">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>ID</th>
|
||||
<td mat-cell *matCellDef="let el">{{ el.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Nom</th>
|
||||
<td mat-cell *matCellDef="let el">{{ el.name }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let el">
|
||||
<button mat-icon-button (click)="startEdit(el)" aria-label="edit"><mat-icon>edit</mat-icon></button>
|
||||
<button mat-icon-button color="warn" (click)="remove(el)" aria-label="delete"><mat-icon>delete</mat-icon></button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="cols"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: cols;"></tr>
|
||||
</table>
|
||||
</section>
|
||||
@@ -0,0 +1,99 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {PrestaService, PsItem} from '../../services/presta.serivce';
|
||||
import {map} from 'rxjs';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
import {MatButton, MatIconButton} from '@angular/material/button';
|
||||
import {
|
||||
MatCell,
|
||||
MatCellDef,
|
||||
MatColumnDef,
|
||||
MatHeaderCell, MatHeaderCellDef,
|
||||
MatHeaderRow,
|
||||
MatHeaderRowDef,
|
||||
MatRow,
|
||||
MatRowDef,
|
||||
MatTable
|
||||
} from '@angular/material/table';
|
||||
import {MatFormField, MatLabel} from '@angular/material/form-field';
|
||||
import {MatInput} from '@angular/material/input';
|
||||
import {NgIf} from '@angular/common';
|
||||
|
||||
@Component({
|
||||
standalone : true,
|
||||
selector: 'app-categories-crud',
|
||||
templateUrl: './categories-crud.component.html',
|
||||
styleUrls: ['./categories-crud.component.css'],
|
||||
imports: [
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatHeaderRow,
|
||||
MatRow,
|
||||
MatRowDef,
|
||||
MatHeaderRowDef,
|
||||
ReactiveFormsModule,
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatInput,
|
||||
MatButton,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCell,
|
||||
NgIf,
|
||||
MatHeaderCellDef,
|
||||
MatCellDef,
|
||||
MatCell
|
||||
]
|
||||
})
|
||||
export class CategoriesCrudComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly ps = inject(PrestaService);
|
||||
|
||||
items: PsItem[] = [];
|
||||
cols = ['id', 'name', 'actions'];
|
||||
form = this.fb.group({name: ['', Validators.required]});
|
||||
editId: number | null = null;
|
||||
|
||||
ngOnInit() {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.ps.list('categories').subscribe(data => this.items = data);
|
||||
}
|
||||
|
||||
startEdit(el: PsItem) {
|
||||
this.editId = el.id;
|
||||
this.form.patchValue({name: el.name});
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.editId = null;
|
||||
this.form.reset({name: ''});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
const name = this.form.value.name!.trim();
|
||||
if (!name) return;
|
||||
|
||||
const op$ = this.editId
|
||||
? this.ps.update('categories', this.editId, name).pipe(map(() => undefined))
|
||||
: this.ps.create('categories', name).pipe(map(() => undefined));
|
||||
|
||||
op$.subscribe({
|
||||
next: () => {
|
||||
this.cancelEdit();
|
||||
this.reload();
|
||||
},
|
||||
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
|
||||
});
|
||||
}
|
||||
|
||||
remove(el: PsItem) {
|
||||
if (!confirm(`Supprimer la catégorie "${el.name}" (#${el.id}) ?`)) return;
|
||||
this.ps.delete('categories', el.id).subscribe({
|
||||
next: () => this.reload(),
|
||||
error: e => alert('Erreur: ' + (e?.message || e))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.crud {
|
||||
display: grid;
|
||||
gap: 16px
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<section class="crud">
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="row">
|
||||
<mat-form-field class="grow">
|
||||
<mat-label>Nom de la marque</mat-label>
|
||||
<input matInput formControlName="name"/>
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid">
|
||||
{{ editId ? 'Enregistrer' : 'Créer' }}
|
||||
</button>
|
||||
<button *ngIf="editId" mat-button type="button" (click)="cancelEdit()">Annuler</button>
|
||||
</form>
|
||||
|
||||
<table mat-table [dataSource]="items" class="mat-elevation-z2">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>ID</th>
|
||||
<td mat-cell *matCellDef="let el">{{ el.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Nom</th>
|
||||
<td mat-cell *matCellDef="let el">{{ el.name }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let el">
|
||||
<button mat-icon-button (click)="startEdit(el)" aria-label="edit">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="warn" (click)="remove(el)" aria-label="delete">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="cols"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: cols;"></tr>
|
||||
</table>
|
||||
</section>
|
||||
@@ -0,0 +1,99 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {PrestaService, PsItem} from '../../services/presta.serivce';
|
||||
import {map} from 'rxjs';
|
||||
import {MatFormField, MatLabel} from '@angular/material/form-field';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
import {MatButton, MatIconButton} from '@angular/material/button';
|
||||
import {
|
||||
MatCell,
|
||||
MatCellDef,
|
||||
MatColumnDef,
|
||||
MatHeaderCell, MatHeaderCellDef,
|
||||
MatHeaderRow,
|
||||
MatHeaderRowDef,
|
||||
MatRow,
|
||||
MatRowDef,
|
||||
MatTable
|
||||
} from '@angular/material/table';
|
||||
import {MatInput} from '@angular/material/input';
|
||||
import {NgIf} from '@angular/common';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-manufacturers-crud',
|
||||
templateUrl: './manufacturers-crud.component.html',
|
||||
imports: [
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatHeaderRow,
|
||||
MatRow,
|
||||
MatRowDef,
|
||||
MatHeaderRowDef,
|
||||
ReactiveFormsModule,
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatInput,
|
||||
MatButton,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCell,
|
||||
NgIf,
|
||||
MatHeaderCellDef,
|
||||
MatCellDef,
|
||||
MatCell
|
||||
],
|
||||
styleUrls: ['./manufacturers-crud.component.css']
|
||||
})
|
||||
export class ManufacturersCrudComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly ps = inject(PrestaService);
|
||||
|
||||
items: PsItem[] = [];
|
||||
cols = ['id', 'name', 'actions'];
|
||||
form = this.fb.group({name: ['', Validators.required]});
|
||||
editId: number | null = null;
|
||||
|
||||
ngOnInit() {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.ps.list('manufacturers').subscribe(d => this.items = d);
|
||||
}
|
||||
|
||||
startEdit(el: PsItem) {
|
||||
this.editId = el.id;
|
||||
this.form.patchValue({name: el.name});
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.editId = null;
|
||||
this.form.reset({name: ''});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
const name = this.form.value.name!.trim();
|
||||
if (!name) return;
|
||||
|
||||
const op$ = this.editId
|
||||
? this.ps.update('manufacturers', this.editId, name).pipe(map(() => undefined))
|
||||
: this.ps.create('manufacturers', name).pipe(map(() => undefined));
|
||||
|
||||
op$.subscribe({
|
||||
next: () => {
|
||||
this.cancelEdit();
|
||||
this.reload();
|
||||
},
|
||||
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
|
||||
});
|
||||
}
|
||||
|
||||
remove(el: PsItem) {
|
||||
if (!confirm(`Supprimer la marque "${el.name}" (#${el.id}) ?`)) return;
|
||||
this.ps.delete('manufacturers', el.id).subscribe({
|
||||
next: () => this.reload(),
|
||||
error: e => alert('Erreur: ' + (e?.message || e))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.wrap {
|
||||
padding: 16px;
|
||||
max-width: 900px;
|
||||
margin: auto
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="wrap">
|
||||
<h2>PrestaShop — CRUD simplifié</h2>
|
||||
<mat-tab-group>
|
||||
<mat-tab label="Catégories"><app-categories-crud></app-categories-crud></mat-tab>
|
||||
<mat-tab label="Marques"><app-manufacturers-crud></app-manufacturers-crud></mat-tab>
|
||||
<mat-tab label="Fournisseurs"><app-suppliers-crud></app-suppliers-crud></mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {MatTab, MatTabGroup} from '@angular/material/tabs';
|
||||
import {CategoriesCrudComponent} from '../categories-crud/categories-crud.component';
|
||||
import {ManufacturersCrudComponent} from '../manufacturers-crud/manufacturers-crud.component';
|
||||
import {SuppliersCrudComponent} from '../suppliers-crud/suppliers-crud.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-ps-crud-tabs',
|
||||
templateUrl: './ps-crud-tabs.component.html',
|
||||
imports: [
|
||||
MatTabGroup,
|
||||
MatTab,
|
||||
CategoriesCrudComponent,
|
||||
ManufacturersCrudComponent,
|
||||
SuppliersCrudComponent
|
||||
],
|
||||
styleUrls: ['./ps-crud-tabs.component.css']
|
||||
})
|
||||
export class PsCrudTabsComponent {}
|
||||
@@ -0,0 +1,18 @@
|
||||
.crud {
|
||||
display: grid;
|
||||
gap: 16px
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<section class="crud">
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="row">
|
||||
<mat-form-field class="grow">
|
||||
<mat-label>Nom du fournisseur</mat-label>
|
||||
<input matInput formControlName="name"/>
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid">
|
||||
{{ editId ? 'Enregistrer' : 'Créer' }}
|
||||
</button>
|
||||
<button *ngIf="editId" mat-button type="button" (click)="cancelEdit()">Annuler</button>
|
||||
</form>
|
||||
|
||||
<table mat-table [dataSource]="items" class="mat-elevation-z2">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>ID</th>
|
||||
<td mat-cell *matCellDef="let el">{{ el.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Nom</th>
|
||||
<td mat-cell *matCellDef="let el">{{ el.name }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let el">
|
||||
<button mat-icon-button (click)="startEdit(el)" aria-label="edit">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="warn" (click)="remove(el)" aria-label="delete">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="cols"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: cols;"></tr>
|
||||
</table>
|
||||
</section>
|
||||
@@ -0,0 +1,99 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {PrestaService, PsItem} from '../../services/presta.serivce';
|
||||
import {map} from 'rxjs';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
import {MatButton, MatIconButton} from '@angular/material/button';
|
||||
import {
|
||||
MatCell,
|
||||
MatCellDef,
|
||||
MatColumnDef,
|
||||
MatHeaderCell, MatHeaderCellDef,
|
||||
MatHeaderRow,
|
||||
MatHeaderRowDef,
|
||||
MatRow,
|
||||
MatRowDef,
|
||||
MatTable
|
||||
} from '@angular/material/table';
|
||||
import {MatFormField, MatLabel} from '@angular/material/form-field';
|
||||
import {MatInput} from '@angular/material/input';
|
||||
import {NgIf} from '@angular/common';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-suppliers-crud',
|
||||
templateUrl: './suppliers-crud.component.html',
|
||||
imports: [
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatHeaderRow,
|
||||
MatRow,
|
||||
MatRowDef,
|
||||
MatHeaderRowDef,
|
||||
ReactiveFormsModule,
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatInput,
|
||||
MatButton,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCell,
|
||||
NgIf,
|
||||
MatHeaderCellDef,
|
||||
MatCellDef,
|
||||
MatCell
|
||||
],
|
||||
styleUrls: ['./suppliers-crud.component.css']
|
||||
})
|
||||
export class SuppliersCrudComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly ps = inject(PrestaService);
|
||||
|
||||
items: PsItem[] = [];
|
||||
cols = ['id', 'name', 'actions'];
|
||||
form = this.fb.group({name: ['', Validators.required]});
|
||||
editId: number | null = null;
|
||||
|
||||
ngOnInit() {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.ps.list('suppliers').subscribe(d => this.items = d);
|
||||
}
|
||||
|
||||
startEdit(el: PsItem) {
|
||||
this.editId = el.id;
|
||||
this.form.patchValue({name: el.name});
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.editId = null;
|
||||
this.form.reset({name: ''});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
const name = this.form.value.name!.trim();
|
||||
if (!name) return;
|
||||
|
||||
const op$ = this.editId
|
||||
? this.ps.update('suppliers', this.editId, name).pipe(map(() => undefined))
|
||||
: this.ps.create('suppliers', name).pipe(map(() => undefined));
|
||||
|
||||
op$.subscribe({
|
||||
next: () => {
|
||||
this.cancelEdit();
|
||||
this.reload();
|
||||
},
|
||||
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
|
||||
});
|
||||
}
|
||||
|
||||
remove(el: PsItem) {
|
||||
if (!confirm(`Supprimer le fournisseur "${el.name}" (#${el.id}) ?`)) return;
|
||||
this.ps.delete('suppliers', el.id).subscribe({
|
||||
next: () => this.reload(),
|
||||
error: e => alert('Erreur: ' + (e?.message || e))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {AdminComponent} from './pages/admin/admin.component';
|
||||
import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard';
|
||||
import {ProductsComponent} from './pages/products/products.component';
|
||||
import {AddProductComponent} from './pages/add-product/add-product.component';
|
||||
import {PsCrudTabsComponent} from './admin-presta/ps-crud-tabs/ps-crud-tabs.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -61,6 +62,12 @@ export const routes: Routes = [
|
||||
canMatch: [authOnlyCanMatch],
|
||||
canActivate: [authOnlyCanActivate],
|
||||
},
|
||||
{
|
||||
path: 'prestashop',
|
||||
component: PsCrudTabsComponent,
|
||||
canMatch: [authOnlyCanMatch],
|
||||
canActivate: [authOnlyCanActivate],
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
</button>
|
||||
<mat-menu #userMenu="matMenu">
|
||||
@if (authService.hasRole('Administrator')) {
|
||||
<button mat-menu-item [routerLink]="'/prestashop'">
|
||||
<mat-icon>admin_panel_settings</mat-icon>
|
||||
Prestashop
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="'/admin'">
|
||||
<mat-icon>admin_panel_settings</mat-icon>
|
||||
Administration
|
||||
|
||||
5
client/src/app/interfaces/image.ts
Normal file
5
client/src/app/interfaces/image.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface Image {
|
||||
id: string | number;
|
||||
name : string;
|
||||
url: string;
|
||||
}
|
||||
@@ -7,6 +7,17 @@
|
||||
<mat-card-content>
|
||||
<form [formGroup]="addProductForm" (ngSubmit)="onProductAdd()" class="form-grid">
|
||||
|
||||
<!-- Image -->
|
||||
<div>
|
||||
<label for="imageUpload">Photo du produit</label>
|
||||
<input id="imageUpload" type="file" (change)="onFileSelected($event)" accept="image/*">
|
||||
@if (imagePreview) {
|
||||
<div style="margin-top:8px;">
|
||||
<img [src]="imagePreview" alt="Aperçu" style="max-width:100%; max-height:200px; display:block;">
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Titre</mat-label>
|
||||
@@ -21,7 +32,7 @@
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Description textarea -->
|
||||
<!-- Description -->
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Description</mat-label>
|
||||
<textarea matInput
|
||||
|
||||
@@ -33,6 +33,8 @@ 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';
|
||||
import {ImageService} from '../../services/app/image.service';
|
||||
import {ProductImageService} from '../../services/app/product_images.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-add-product',
|
||||
@@ -67,6 +69,11 @@ export class AddProductComponent implements OnInit, OnDestroy {
|
||||
isSubmitted = false;
|
||||
isLoading = false;
|
||||
|
||||
imageFile: File | null = null;
|
||||
imagePreview: string | null = null;
|
||||
private imageUploadSubscription: Subscription | null = null;
|
||||
private imageLinkSubscription: Subscription | null = null;
|
||||
|
||||
brands: Brand[] = [];
|
||||
platforms: Platform[] = [];
|
||||
categories: Category[] = [];
|
||||
@@ -89,7 +96,9 @@ export class AddProductComponent implements OnInit, OnDestroy {
|
||||
private readonly platformService = inject(PlatformService);
|
||||
private readonly categoryService = inject(CategoryService);
|
||||
private readonly conditionService = inject(ConditionService);
|
||||
private readonly imageService = inject(ImageService)
|
||||
private readonly productService = inject(ProductService);
|
||||
private readonly productImageService = inject(ProductImageService);
|
||||
|
||||
private readonly router: Router = inject(Router);
|
||||
|
||||
@@ -278,60 +287,117 @@ export class AddProductComponent implements OnInit, OnDestroy {
|
||||
this.platformSubscription?.unsubscribe();
|
||||
this.categorySubscription?.unsubscribe();
|
||||
this.conditionSubscription?.unsubscribe();
|
||||
this.imageUploadSubscription?.unsubscribe();
|
||||
this.imageLinkSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) {
|
||||
this.imageFile = null;
|
||||
this.imagePreview = null;
|
||||
return;
|
||||
}
|
||||
const file = input.files[0];
|
||||
this.imageFile = file;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
this.imagePreview = String(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
onProductAdd() {
|
||||
this.isSubmitted = true;
|
||||
|
||||
if (this.addProductForm.valid) {
|
||||
this.isLoading = true;
|
||||
const raw = this.addProductForm.value;
|
||||
if (!this.addProductForm.valid) return;
|
||||
|
||||
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;
|
||||
}
|
||||
this.isLoading = true;
|
||||
const raw = this.addProductForm.value;
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
// parsing price/quantity etc (même logique que l'original)
|
||||
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
|
||||
};
|
||||
|
||||
// 1) créer le produit
|
||||
this.addProductSubscription = this.productService.add(payload).subscribe({
|
||||
next: (createdProduct: any) => {
|
||||
const productId = createdProduct?.id;
|
||||
if (!this.imageFile) {
|
||||
// pas d'image => fin
|
||||
this.afterSuccessfulAdd();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) upload de l'image
|
||||
this.imageUploadSubscription = this.imageService.add(this.imageFile).subscribe({
|
||||
next: (uploadedImage: { id: any; }) => {
|
||||
const imageId = uploadedImage?.id;
|
||||
if (!productId || imageId == null) {
|
||||
console.error('Missing productId or imageId after upload');
|
||||
this.afterSuccessfulAdd(); // navigation quand même ou gérer l'erreur
|
||||
return;
|
||||
}
|
||||
// 3) lier image <-> product
|
||||
this.imageLinkSubscription = this.productImageService.link(productId, imageId).subscribe({
|
||||
next: () => {
|
||||
this.afterSuccessfulAdd();
|
||||
},
|
||||
error: (error: any) => {
|
||||
console.error('Error linking image to product:', error);
|
||||
alert('Produit ajouté, mais la liaison de l\'image a échoué.');
|
||||
this.afterSuccessfulAdd();
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (error: any) => {
|
||||
console.error('Error uploading image:', error);
|
||||
alert('Produit ajouté, mais l\'upload de l\'image a échoué.');
|
||||
this.afterSuccessfulAdd();
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (error: any) => {
|
||||
console.error("Error adding product:", error);
|
||||
alert("Une erreur est survenue lors de l'ajout du produit.");
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private afterSuccessfulAdd() {
|
||||
this.addProductForm.reset();
|
||||
this.imageFile = null;
|
||||
this.imagePreview = null;
|
||||
this.isSubmitted = false;
|
||||
this.isLoading = false;
|
||||
alert("Produit ajouté avec succès !");
|
||||
this.router.navigate(['/products']).then();
|
||||
}
|
||||
|
||||
isFieldInvalid(fieldName: string): boolean {
|
||||
|
||||
17
client/src/app/services/app/image.service.ts
Normal file
17
client/src/app/services/app/image.service.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
import {Image} from '../../interfaces/image';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class ImageService {
|
||||
|
||||
private readonly BASE = 'http://localhost:3000/api/app/images';
|
||||
private readonly http: HttpClient = inject(HttpClient);
|
||||
|
||||
add(file: File): Observable<Image> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file, file.name);
|
||||
return this.http.post<Image>(this.BASE, fd, {withCredentials: true});
|
||||
}
|
||||
}
|
||||
33
client/src/app/services/app/product_images.service.ts
Normal file
33
client/src/app/services/app/product_images.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
import {CrudService} from '../crud.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ProductImageService implements CrudService<string | number> {
|
||||
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly BASE_URL = 'http://localhost:3000/api/app/products_images';
|
||||
|
||||
getAll(): Observable<(string | number)[]> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
add(item: string | number): Observable<string | number> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
update(id: string | number, item: string | number): Observable<string | number> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
delete(id: string | number): Observable<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
link(productId: string | number, imageId: string | number): Observable<any> {
|
||||
return this.http.post(`${this.BASE_URL}/link`, {productId, imageId}, {withCredentials: true});
|
||||
}
|
||||
}
|
||||
234
client/src/app/services/presta.serivce.ts
Normal file
234
client/src/app/services/presta.serivce.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http';
|
||||
import {map, switchMap} from 'rxjs';
|
||||
|
||||
export interface PsItem {
|
||||
id: number;
|
||||
name: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
type Resource = 'categories' | 'manufacturers' | 'suppliers';
|
||||
|
||||
const UPDATE_CFG: Record<Resource, {
|
||||
root: 'category' | 'manufacturer' | 'supplier';
|
||||
needsDefaultLang?: boolean; // champs multilingues ?
|
||||
keepFields?: string[]; // champs à renvoyer (si besoin)
|
||||
nameIsMultilang?: boolean; // name multilingue ?
|
||||
}> = {
|
||||
categories: {
|
||||
root: 'category',
|
||||
needsDefaultLang: true,
|
||||
keepFields: ['active', 'id_parent', 'link_rewrite'],
|
||||
nameIsMultilang: true
|
||||
},
|
||||
manufacturers: {root: 'manufacturer', needsDefaultLang: false, keepFields: ['active'], nameIsMultilang: false},
|
||||
suppliers: {root: 'supplier', needsDefaultLang: false, keepFields: ['active'], nameIsMultilang: false},
|
||||
};
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class PrestaService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly base = '/ps'; // proxy Angular -> https://.../api
|
||||
|
||||
// ---------- Utils ----------
|
||||
private readonly headersXml = new HttpHeaders({
|
||||
'Content-Type': 'application/xml',
|
||||
'Accept': 'application/xml'
|
||||
});
|
||||
|
||||
private escapeXml(v: string) {
|
||||
return String(v)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
private extractIdFromXml(xml: string): number | null {
|
||||
const m = /<id>(\d+)<\/id>/.exec(String(xml));
|
||||
return m ? +m[1] : null;
|
||||
}
|
||||
|
||||
private slug(str: string) {
|
||||
return String(str)
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replaceAll(/[\u0300-\u036f]/g, '')
|
||||
.replaceAll(/[^a-z0-9]+/g, '-')
|
||||
.replaceAll(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
private toLangBlock(tag: string, entries: Array<{ id: number; value: string }>) {
|
||||
const inner = entries
|
||||
.filter(e => e.value !== undefined && e.value !== null)
|
||||
.map(e => `<language id="${e.id}">${this.escapeXml(String(e.value))}</language>`)
|
||||
.join('');
|
||||
return `<${tag}>${inner}</${tag}>`;
|
||||
}
|
||||
|
||||
private ensureArrayLang(v: any): Array<{ id: number; value: string }> {
|
||||
if (Array.isArray(v)) return v.map(x => ({id: +x.id, value: String(x.value ?? '')}));
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------- Langues / lecture objets ----------
|
||||
/** ID langue par défaut (PS_LANG_DEFAULT) */
|
||||
getDefaultLangId() {
|
||||
const params = new HttpParams()
|
||||
.set('display', '[value]')
|
||||
.set('filter[name]', 'PS_LANG_DEFAULT')
|
||||
.set('output_format', 'JSON');
|
||||
|
||||
return this.http.get<any>(`${this.base}/configurations`, {params}).pipe(
|
||||
map(r => +r?.configurations?.[0]?.value || 1)
|
||||
);
|
||||
}
|
||||
|
||||
/** IDs des langues actives (utile pour create catégories) */
|
||||
getActiveLangIds() {
|
||||
const params = new HttpParams()
|
||||
.set('display', '[id,active]')
|
||||
.set('filter[active]', '1')
|
||||
.set('output_format', 'JSON');
|
||||
|
||||
return this.http.get<any>(`${this.base}/languages`, {params}).pipe(
|
||||
map(r => (r?.languages ?? []).map((l: any) => +l.id as number))
|
||||
);
|
||||
}
|
||||
|
||||
/** Récupère l'objet complet (JSON) pour un update sûr */
|
||||
private getOne(resource: Resource, id: number) {
|
||||
const params = new HttpParams().set('output_format', 'JSON').set('display', 'full');
|
||||
return this.http.get<any>(`${this.base}/${resource}/${id}`, {params}).pipe(
|
||||
map(r => r?.category ?? r?.manufacturer ?? r?.supplier ?? r)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- LIST ----------
|
||||
list(resource: Resource) {
|
||||
const params = new HttpParams()
|
||||
.set('display', '[id,name,active]')
|
||||
.set('output_format', 'JSON');
|
||||
|
||||
return this.http.get<any>(`${this.base}/${resource}`, {params}).pipe(
|
||||
map(r => {
|
||||
const arr = r?.[resource] ?? [];
|
||||
return arr.map((x: any) => ({
|
||||
id: +x.id,
|
||||
name: Array.isArray(x.name) ? (x.name[0]?.value ?? '') : (x.name ?? ''),
|
||||
active: x.active === undefined ? undefined : !!+x.active
|
||||
}) as PsItem);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- CREATE ----------
|
||||
create(resource: Resource, name: string) {
|
||||
const safeName = this.escapeXml(name);
|
||||
|
||||
if (resource === 'categories') {
|
||||
// Catégories: champs multilingues -> remplir toutes les langues actives
|
||||
return this.getActiveLangIds().pipe(
|
||||
switchMap((langIds) => {
|
||||
const xml =
|
||||
`<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<category>
|
||||
<id_parent>2</id_parent>
|
||||
<active>1</active>
|
||||
${this.toLangBlock('name', langIds.map((id: any) => ({id, value: safeName})))}
|
||||
${this.toLangBlock('link_rewrite', langIds.map((id: any) => ({id, value: this.slug(name)})))}
|
||||
</category>
|
||||
</prestashop>`;
|
||||
return this.http.post(`${this.base}/categories`, xml, {
|
||||
headers: this.headersXml,
|
||||
responseType: 'text'
|
||||
});
|
||||
}),
|
||||
map(res => this.extractIdFromXml(res))
|
||||
);
|
||||
}
|
||||
|
||||
// Marques / Fournisseurs : name simple
|
||||
const xml =
|
||||
resource === 'manufacturers'
|
||||
? `<prestashop xmlns:xlink="http://www.w3.org/1999/xlink"><manufacturer><active>1</active><name>${safeName}</name></manufacturer></prestashop>`
|
||||
: `<prestashop xmlns:xlink="http://www.w3.org/1999/xlink"><supplier><active>1</active><name>${safeName}</name></supplier></prestashop>`;
|
||||
|
||||
return this.http.post(`${this.base}/${resource}`, xml, {
|
||||
headers: this.headersXml,
|
||||
responseType: 'text'
|
||||
}).pipe(
|
||||
map((res: string) => this.extractIdFromXml(res))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- UPDATE (générique) ----------
|
||||
update(resource: Resource, id: number, newName: string) {
|
||||
const cfg = UPDATE_CFG[resource];
|
||||
const safeName = this.escapeXml(newName);
|
||||
|
||||
const defaultLang$ = cfg.needsDefaultLang ? this.getDefaultLangId() : undefined;
|
||||
|
||||
// petit trick pour typer proprement si pas besoin de langue
|
||||
const defaultLangOr1$ = defaultLang$ ?? this.getDefaultLangId().pipe(map(() => 1));
|
||||
|
||||
return defaultLangOr1$.pipe(
|
||||
switchMap((idLang: number) =>
|
||||
this.getOne(resource, id).pipe(
|
||||
switchMap(obj => {
|
||||
const root = cfg.root;
|
||||
const active = obj?.active === undefined ? 1 : +obj.active;
|
||||
|
||||
// Catégories: garder slug existant (obligatoire en PUT) + id_parent
|
||||
let linkRewriteXml = '';
|
||||
let idParentXml = '';
|
||||
if (resource === 'categories') {
|
||||
const lr = this.ensureArrayLang(obj?.link_rewrite);
|
||||
if (lr.length) {
|
||||
linkRewriteXml = this.toLangBlock('link_rewrite', lr);
|
||||
} else {
|
||||
// fallback: si pas d'info multilang, au moins la langue défaut
|
||||
linkRewriteXml = this.toLangBlock('link_rewrite', [{id: idLang, value: this.slug(newName)}]);
|
||||
}
|
||||
if (obj?.id_parent) idParentXml = `<id_parent>${+obj.id_parent}</id_parent>`;
|
||||
}
|
||||
|
||||
const nameXml = cfg.nameIsMultilang
|
||||
? `<name><language id="${idLang}">${safeName}</language></name>`
|
||||
: `<name>${safeName}</name>`;
|
||||
|
||||
const body =
|
||||
`<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<${root}>
|
||||
<id>${id}</id>
|
||||
<active>${active}</active>
|
||||
${idParentXml}
|
||||
${nameXml}
|
||||
${linkRewriteXml}
|
||||
</${root}>
|
||||
</prestashop>`;
|
||||
|
||||
return this.http.put(`${this.base}/${resource}/${id}`, body, {
|
||||
headers: this.headersXml,
|
||||
responseType: 'text'
|
||||
});
|
||||
})
|
||||
)
|
||||
),
|
||||
map(() => true)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- DELETE ----------
|
||||
delete(resource: Resource, id: number) {
|
||||
return this.http.delete(`${this.base}/${resource}/${id}`, {responseType: 'text'})
|
||||
.pipe(map(() => true));
|
||||
}
|
||||
|
||||
// ---------- (optionnel) READ XML brut ----------
|
||||
getXml(resource: Resource, id: number) {
|
||||
return this.http.get(`${this.base}/${resource}/${id}`, {responseType: 'text'});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user