feat: add categories and manufacturers CRUD components; implement form handling and image upload functionality

This commit is contained in:
Vincent Guillet
2025-11-10 16:29:42 +01:00
parent b84a829a82
commit a849a4dd15
22 changed files with 968 additions and 47 deletions

12
client/proxy.conf.json Normal file
View File

@@ -0,0 +1,12 @@
{
"/ps": {
"target": "https://shop.gameovergne.fr",
"secure": true,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": { "^/ps": "/api" },
"headers": {
"Authorization": "Basic MkFRUEcxM01KOFgxMTdVNkZKNU5HSFBTOTNIRTM0QUI="
}
}
}

View 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 lutiliser ailleurs
})
export class AdminPrestaModule {
}

View File

@@ -0,0 +1,18 @@
.crud {
display: grid;
gap: 16px
}
.row {
display: flex;
gap: 12px;
align-items: flex-end
}
.grow {
flex: 1
}
table {
width: 100%
}

View File

@@ -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>

View File

@@ -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))
});
}
}

View File

@@ -0,0 +1,18 @@
.crud {
display: grid;
gap: 16px
}
.row {
display: flex;
gap: 12px;
align-items: flex-end
}
.grow {
flex: 1
}
table {
width: 100%
}

View File

@@ -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>

View File

@@ -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))
});
}
}

View File

@@ -0,0 +1,5 @@
.wrap {
padding: 16px;
max-width: 900px;
margin: auto
}

View File

@@ -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>

View File

@@ -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 {}

View File

@@ -0,0 +1,18 @@
.crud {
display: grid;
gap: 16px
}
.row {
display: flex;
gap: 12px;
align-items: flex-end
}
.grow {
flex: 1
}
table {
width: 100%
}

View File

@@ -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>

View File

@@ -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))
});
}
}

View File

@@ -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: ''

View File

@@ -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

View File

@@ -0,0 +1,5 @@
export interface Image {
id: string | number;
name : string;
url: string;
}

View File

@@ -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

View File

@@ -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 {

View 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});
}
}

View 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});
}
}

View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
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'});
}
}