refactor: reorganize component files and update import paths; add PsItem and PsProduct interfaces
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
@@ -37,4 +37,4 @@
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.5.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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 {
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
.crud {
|
||||
display: grid;
|
||||
gap: 16px
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<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>
|
||||
@@ -1,99 +0,0 @@
|
||||
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))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
.crud {
|
||||
display: grid;
|
||||
gap: 16px
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<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>
|
||||
@@ -1,99 +0,0 @@
|
||||
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))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<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>
|
||||
@@ -1,20 +0,0 @@
|
||||
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 {}
|
||||
@@ -1,18 +0,0 @@
|
||||
.crud {
|
||||
display: grid;
|
||||
gap: 16px
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<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>
|
||||
@@ -1,99 +0,0 @@
|
||||
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))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import {MainNavbarComponent} from './components/navbar/main-navbar/main-navbar.component';
|
||||
import {MainNavbarComponent} from './components/main-navbar/main-navbar.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {routes} from './app.routes';
|
||||
import {provideHttpClient, withInterceptors} from '@angular/common/http';
|
||||
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
|
||||
import {authTokenInterceptor} from './interceptors/auth-token.interceptor';
|
||||
import {AuthService} from './services/auth/auth.service';
|
||||
import {AuthService} from './services/auth.service';
|
||||
import {catchError, firstValueFrom, of} from 'rxjs';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import {Routes} from '@angular/router';
|
||||
import {HomeComponent} from './pages/home/home.component';
|
||||
import {RegisterComponent} from './pages/register/register.component';
|
||||
import {LoginComponent} from './pages/login/login.component';
|
||||
import {RegisterComponent} from './pages/auth/register/register.component';
|
||||
import {LoginComponent} from './pages/auth/login/login.component';
|
||||
import {ProfileComponent} from './pages/profile/profile.component';
|
||||
import {guestOnlyCanActivate, guestOnlyCanMatch} from './guards/guest-only.guard';
|
||||
import {authOnlyCanActivate, authOnlyCanMatch} from './guards/auth-only.guard';
|
||||
import {AdminComponent} from './pages/admin/admin.component';
|
||||
import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard';
|
||||
import {authOnlyCanMatch} from './guards/auth-only.guard';
|
||||
import {PsAdminComponent} from './pages/admin/ps-admin/ps-admin.component';
|
||||
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 = [
|
||||
{
|
||||
@@ -44,29 +42,17 @@ export const routes: Routes = [
|
||||
canMatch: [authOnlyCanMatch],
|
||||
canActivate: [authOnlyCanMatch]
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
component: AdminComponent,
|
||||
canMatch: [adminOnlyCanMatch],
|
||||
canActivate: [adminOnlyCanActivate]
|
||||
},
|
||||
{
|
||||
path: 'products',
|
||||
component: ProductsComponent,
|
||||
canMatch: [authOnlyCanMatch],
|
||||
canActivate: [authOnlyCanActivate],
|
||||
canActivate: [authOnlyCanMatch]
|
||||
},
|
||||
{
|
||||
path: 'products/add',
|
||||
component: AddProductComponent,
|
||||
canMatch: [authOnlyCanMatch],
|
||||
canActivate: [authOnlyCanActivate],
|
||||
},
|
||||
{
|
||||
path: 'prestashop',
|
||||
component: PsCrudTabsComponent,
|
||||
canMatch: [authOnlyCanMatch],
|
||||
canActivate: [authOnlyCanActivate],
|
||||
path: 'admin',
|
||||
component: PsAdminComponent,
|
||||
canMatch: [adminOnlyCanMatch],
|
||||
canActivate: [adminOnlyCanActivate]
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<h2 mat-dialog-title>{{ data.title ?? 'Confirmation' }}</h2>
|
||||
<mat-dialog-content>
|
||||
<p>{{ data.message ?? 'Êtes-vous sûr·e ?' }}</p>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="close(false)">Annuler</button>
|
||||
<button mat-flat-button color="warn" type="button" (click)="close(true)">Supprimer</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatDialogModule, MatButtonModule],
|
||||
templateUrl: './confirm-dialog.component.html',
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
constructor(
|
||||
private readonly dialogRef: MatDialogRef<ConfirmDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: { title?: string; message?: string }
|
||||
) {}
|
||||
|
||||
close(result: boolean) {
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<form [formGroup]="form" (ngSubmit)="save()">
|
||||
<h2 mat-dialog-title>{{ data.title ?? 'Edit' }}</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
@for (f of fields; track $index) {
|
||||
|
||||
@if (f.type === 'checkbox') {
|
||||
<mat-checkbox [formControlName]="f.key">
|
||||
{{ f.label }}
|
||||
</mat-checkbox>
|
||||
} @else if (f.type === 'select') {
|
||||
<mat-form-field style="width:100%; margin-top:8px;">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
|
||||
<mat-select [formControlName]="f.key" [compareWith]="getCompareFn(f)">
|
||||
@let opts = (f.options ?? (f.options$ | async) ?? []);
|
||||
|
||||
@for (opt of opts; track $index) {
|
||||
<mat-option [value]="f.valueKey ? opt?.[f.valueKey] : opt">
|
||||
{{ f.displayKey ? opt?.[f.displayKey] : (opt?.name ?? opt?.label ?? opt) }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field style="width:100%; margin-top:8px;">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
|
||||
@if (f.type === 'textarea') {
|
||||
<textarea matInput [formControlName]="f.key"></textarea>
|
||||
} @else {
|
||||
<input
|
||||
matInput
|
||||
[type]="f.type ?? 'text'"
|
||||
[formControlName]="f.key"
|
||||
/>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="close()">Annuler</button>
|
||||
<button mat-flat-button color="primary" type="submit">Enregistrer</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
@@ -1,178 +0,0 @@
|
||||
import {Component, Inject, OnInit, OnDestroy} from '@angular/core';
|
||||
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 {MatInputModule} from '@angular/material/input';
|
||||
import {MatSelectModule} from '@angular/material/select';
|
||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||
import {MatButtonModule} from '@angular/material/button';
|
||||
import {Subscription} from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-generic-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatButtonModule
|
||||
],
|
||||
templateUrl: './generic-dialog.component.html',
|
||||
})
|
||||
export class GenericDialogComponent implements OnInit, OnDestroy {
|
||||
form!: FormGroup;
|
||||
fields: any[] = [];
|
||||
compareFns = new Map<string, (a: any, b: any) => boolean>();
|
||||
optionsCache = new Map<string, any[]>();
|
||||
private readonly subscriptions: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly fb: FormBuilder,
|
||||
private readonly dialogRef: MatDialogRef<GenericDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: { title?: string; fields?: any[]; model?: any }
|
||||
) {
|
||||
this.fields = data?.fields ?? [];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const model = this.data?.model ?? {};
|
||||
const controls: { [key: string]: any[] } = {};
|
||||
|
||||
for (const f of this.fields) {
|
||||
if (f.options && Array.isArray(f.options)) {
|
||||
this.optionsCache.set(f.key, f.options);
|
||||
}
|
||||
|
||||
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 {
|
||||
if (this.form.invalid) {
|
||||
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 {
|
||||
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,9 +0,0 @@
|
||||
<app-generic-list
|
||||
[service]="brandService"
|
||||
[fields]="fields"
|
||||
title="Gestion des marques"
|
||||
addTitle="Ajouter une marque"
|
||||
editTitle="Modifier la marque"
|
||||
deleteTitle="Supprimer la marque"
|
||||
idKey="id">
|
||||
</app-generic-list>
|
||||
@@ -1,23 +0,0 @@
|
||||
import {
|
||||
Component, inject
|
||||
} from '@angular/core';
|
||||
import {BrandService} from '../../../services/app/brand.service';
|
||||
import {GenericListComponent} from '../generic-list/generic-list.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-brand-list',
|
||||
templateUrl: './brand-list.component.html',
|
||||
standalone: true,
|
||||
imports: [
|
||||
GenericListComponent
|
||||
],
|
||||
styleUrls: ['./brand-list.component.css']
|
||||
})
|
||||
export class BrandListComponent {
|
||||
|
||||
brandService: BrandService = inject(BrandService)
|
||||
|
||||
fields = [
|
||||
{key: 'name', label: 'Nom', sortable: true}
|
||||
];
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<app-generic-list
|
||||
[service]="categoryService"
|
||||
[fields]="fields"
|
||||
title="Gestin des catégories"
|
||||
addTitle="Ajouter une catégorie"
|
||||
editTitle="Modifier la catégorie"
|
||||
deleteTitle="Supprimer la catégorie"
|
||||
idKey="id">
|
||||
</app-generic-list>
|
||||
@@ -1,23 +0,0 @@
|
||||
import {
|
||||
Component, inject
|
||||
} from '@angular/core';
|
||||
import {GenericListComponent} from '../generic-list/generic-list.component';
|
||||
import {CategoryService} from '../../../services/app/category.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-list',
|
||||
templateUrl: './category-list.component.html',
|
||||
standalone: true,
|
||||
imports: [
|
||||
GenericListComponent
|
||||
],
|
||||
styleUrls: ['./category.component.css']
|
||||
})
|
||||
export class CategoryListComponent {
|
||||
|
||||
categoryService: CategoryService = inject(CategoryService)
|
||||
|
||||
fields = [
|
||||
{key: 'name', label: 'Nom', sortable: true}
|
||||
];
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter {
|
||||
max-width: 240px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
td, th {
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
button.mat-icon-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.no-brands {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
color: rgba(0,0,0,0.6);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,171 +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: 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, tableau, pagination) partagent le même style ===== */
|
||||
.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;
|
||||
}
|
||||
|
||||
.gl-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
/* Header sticky */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Cellules */
|
||||
.gl-table th[mat-header-cell],
|
||||
.gl-table td[mat-cell] {
|
||||
padding: 14px 18px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 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-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;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 799px) {
|
||||
.generic-list {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.gl-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<div class="generic-list">
|
||||
<div class="gl-header">
|
||||
<h3 class="gl-title">{{ title }}</h3>
|
||||
|
||||
<div class="gl-controls">
|
||||
<button mat-flat-button color="primary" type="button" (click)="openDialog(null)">
|
||||
{{ addTitle ?? 'Ajouter' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gl-filter-bar gl-block">
|
||||
<mat-form-field class="gl-filter" appearance="outline">
|
||||
<mat-label>Rechercher</mat-label>
|
||||
<input
|
||||
matInput
|
||||
(input)="applyFilter($any($event.target).value)"
|
||||
aria-label="Filtrer le tableau"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="gl-table-wrapper gl-block">
|
||||
<table
|
||||
mat-table
|
||||
[dataSource]="dataSource"
|
||||
matSort
|
||||
matSortDisableClear
|
||||
class="gl-table"
|
||||
>
|
||||
@for (col of (fields ?? []); track $index) {
|
||||
<ng-container [matColumnDef]="col.key">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
mat-sort-header
|
||||
[disabled]="!col.sortable"
|
||||
>
|
||||
{{ col.label }}
|
||||
</th>
|
||||
|
||||
<td mat-cell *matCellDef="let element">
|
||||
{{ displayValue(element, col) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let element" class="actions-cell">
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
type="button"
|
||||
(click)="openDialog(element)"
|
||||
aria-label="Modifier"
|
||||
>
|
||||
<mat-icon>edit</mat-icon>
|
||||
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
type="button"
|
||||
(click)="remove(element)"
|
||||
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>
|
||||
|
||||
<div class="gl-paginator-wrap gl-block">
|
||||
<mat-paginator
|
||||
class="gl-paginator"
|
||||
[pageSize]="10"
|
||||
[pageSizeOptions]="[5, 10, 25, 50]"
|
||||
showFirstLastButtons
|
||||
></mat-paginator>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,219 +0,0 @@
|
||||
import {Component, Input, Output, EventEmitter, ViewChild, AfterViewInit, OnInit} from '@angular/core';
|
||||
import {MatTableDataSource, MatTableModule} from '@angular/material/table';
|
||||
import {MatPaginator, MatPaginatorModule} from '@angular/material/paginator';
|
||||
import {MatSort, MatSortModule} from '@angular/material/sort';
|
||||
import {MatDialog, MatDialogModule} from '@angular/material/dialog';
|
||||
import {MatButtonModule} from '@angular/material/button';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {CrudService} from '../../../services/crud.service';
|
||||
import {GenericDialogComponent} from '../../dialog/generic-dialog/generic-dialog.component';
|
||||
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
import {ConfirmDialogComponent} from '../../dialog/confirm-dialog/confirm-dialog.component';
|
||||
import {MatChip} from '@angular/material/chips';
|
||||
|
||||
type Field = {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
displayKey?: string;
|
||||
displayFn?: (value: any, element?: any) => string;
|
||||
sortKey?: string | ((item: any) => any);
|
||||
sortFn?: (a: any, b: any) => number;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-generic-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatTableModule, MatPaginatorModule, MatSortModule, MatDialogModule, MatButtonModule, MatInput, MatLabel, MatFormField, MatIcon, MatChip],
|
||||
templateUrl: './generic-list.component.html',
|
||||
styleUrl: './generic-list.component.css'
|
||||
})
|
||||
export class GenericListComponent<T> implements OnInit, AfterViewInit {
|
||||
@Input() service!: CrudService<T>;
|
||||
@Input() fields?: Field[];
|
||||
@Input() title = '';
|
||||
@Input() addTitle?: string;
|
||||
@Input() editTitle?: string;
|
||||
@Input() deleteTitle?: string;
|
||||
@Input() idKey = 'id';
|
||||
@Input() dialogComponent: any = GenericDialogComponent;
|
||||
@Output() add = new EventEmitter<T>();
|
||||
@Output() edit = new EventEmitter<T>();
|
||||
@Output() delete = new EventEmitter<T>();
|
||||
|
||||
dataSource = new MatTableDataSource<T>([]);
|
||||
displayedColumns: string[] = [];
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
@ViewChild(MatSort) sort!: MatSort;
|
||||
|
||||
constructor(private readonly dialog: MatDialog) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.fields = this.fields ?? [];
|
||||
this.displayedColumns = this.fields.map(f => f.key).concat(['actions']);
|
||||
this.load();
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
|
||||
this.dataSource.sortingDataAccessor = (data: any, sortHeaderId: string) => {
|
||||
const field = this.fields!.find(f => f.key === sortHeaderId);
|
||||
if (!field) {
|
||||
const raw = getByPath(data, sortHeaderId) ?? data?.[sortHeaderId];
|
||||
return raw == null ? '' : String(raw);
|
||||
}
|
||||
|
||||
if (field.sortKey) {
|
||||
if (typeof field.sortKey === 'function') {
|
||||
const v = field.sortKey(data);
|
||||
return v == null ? '' : String(v);
|
||||
}
|
||||
const v = getByPath(data, field.sortKey as string);
|
||||
return v == null ? '' : String(v);
|
||||
}
|
||||
|
||||
const val = getByPath(data, field.key);
|
||||
if (val == null) return '';
|
||||
if (typeof val === 'object') {
|
||||
if (field.displayKey && val[field.displayKey] != null) return String(val[field.displayKey]);
|
||||
const candidates = ['name', 'title', 'label', 'id'];
|
||||
for (const k of candidates) {
|
||||
if (val[k] != null) return String(val[k]);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(val);
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
return String(val);
|
||||
};
|
||||
|
||||
this.dataSource.sort = this.sort;
|
||||
this.dataSource.paginator = this.paginator;
|
||||
|
||||
const originalSortData = this.dataSource.sortData;
|
||||
this.dataSource.sortData = (data: T[], sort: MatSort) => {
|
||||
if (!sort || !sort.active || sort.direction === '') return originalSortData.call(this.dataSource, data, sort);
|
||||
const field = this.fields!.find(f => f.key === sort.active);
|
||||
if (field?.sortFn) {
|
||||
const dir = sort.direction === 'asc' ? 1 : -1;
|
||||
return [...data].sort((a, b) => dir * field.sortFn!(a, b));
|
||||
}
|
||||
return originalSortData.call(this.dataSource, data, sort);
|
||||
};
|
||||
}
|
||||
|
||||
load() {
|
||||
this.service.getAll().subscribe(items => {
|
||||
console.debug('Loaded items from service:', items);
|
||||
this.dataSource.data = (items as any[]).map(item => {
|
||||
const normalizedId = getByPath(item, this.idKey) ?? item?.[this.idKey] ?? item?.id ?? item?._id ?? item?.platformId ?? null;
|
||||
return {...item, [this.idKey]: normalizedId};
|
||||
}) as T[];
|
||||
});
|
||||
}
|
||||
|
||||
applyFilter(value: string) {
|
||||
this.dataSource.filter = (value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
openDialog(item: any | null) {
|
||||
const originalId = item ? (getByPath(item, this.idKey) ?? item?.[this.idKey] ?? item?.id ?? item?._id) : null;
|
||||
|
||||
const dialogTitle = item
|
||||
? (this.editTitle ?? 'Modifier')
|
||||
: (this.addTitle ?? 'Ajouter');
|
||||
|
||||
const dialogRef = this.dialog.open(this.dialogComponent, {
|
||||
width: '420px',
|
||||
data: {
|
||||
item: item ? {...item} : {},
|
||||
fields: this.fields,
|
||||
title: dialogTitle,
|
||||
originalId
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (!result) return;
|
||||
|
||||
if (item) {
|
||||
const idToUpdate = originalId ?? getByPath(result, this.idKey) ?? result?.[this.idKey] ?? result?.id ?? result?._id;
|
||||
if (idToUpdate == null) {
|
||||
console.error('Cannot update: id is null/undefined for item', {item, result});
|
||||
return;
|
||||
}
|
||||
this.service.update(idToUpdate, result).subscribe(() => {
|
||||
this.edit.emit(result);
|
||||
this.load();
|
||||
});
|
||||
} else {
|
||||
this.service.add(result).subscribe(() => {
|
||||
this.add.emit(result);
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
remove(item: any) {
|
||||
const id = getByPath(item, this.idKey) ?? item[this.idKey] ?? item?.id ?? item?._id;
|
||||
if (id == null) {
|
||||
console.error('Cannot delete: id is null/undefined for item', item);
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
width: '380px',
|
||||
data: {
|
||||
title: this.deleteTitle ?? 'Confirmer la suppression',
|
||||
message: 'Voulez-vous vraiment supprimer cet élément ? Cette action est irréversible.'
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((confirmed: boolean) => {
|
||||
if (!confirmed) return;
|
||||
this.service.delete(id).subscribe(() => {
|
||||
this.delete.emit(item);
|
||||
this.load();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
displayValue(element: any, field: Field): string {
|
||||
const val = getByPath(element, field.key);
|
||||
if (field.displayFn) {
|
||||
try {
|
||||
return String(field.displayFn(val, element) ?? '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
if (val == null) return '';
|
||||
if (typeof val === 'object') {
|
||||
if (field.displayKey && val[field.displayKey] != null) return String(val[field.displayKey]);
|
||||
const candidates = ['name', 'title', 'label', 'id'];
|
||||
for (const k of candidates) {
|
||||
if (val[k] != null) return String(val[k]);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(val);
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
protected readonly HTMLInputElement = HTMLInputElement;
|
||||
}
|
||||
|
||||
function getByPath(obj: any, path: string | undefined): any {
|
||||
if (!obj || !path) return undefined;
|
||||
if (typeof path !== 'string') return undefined;
|
||||
return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<app-generic-list
|
||||
[service]="platformService"
|
||||
[fields]="fields"
|
||||
title="Gestion des plateformes"
|
||||
addTitle="Ajouter une plateforme"
|
||||
editTitle="Modifier la plateforme"
|
||||
deleteTitle="Supprimer la plateforme"
|
||||
idKey="id">
|
||||
</app-generic-list>
|
||||
@@ -1,35 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import {PlatformService} from '../../../services/app/platform.service';
|
||||
import {GenericListComponent} from '../generic-list/generic-list.component';
|
||||
import {BrandService} from '../../../services/app/brand.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-list',
|
||||
templateUrl: './platform-list.component.html',
|
||||
standalone: true,
|
||||
imports: [
|
||||
GenericListComponent
|
||||
],
|
||||
styleUrls: ['./platform-list.component.css']
|
||||
})
|
||||
export class PlatformListComponent {
|
||||
|
||||
platformService: PlatformService = inject(PlatformService)
|
||||
brandService: BrandService = inject(BrandService);
|
||||
|
||||
fields = [
|
||||
{key: 'name', label: 'Nom', sortable: true},
|
||||
{
|
||||
key: 'brand',
|
||||
label: 'Marque',
|
||||
type: 'select',
|
||||
options$: this.brandService.getAll(),
|
||||
displayKey: 'name',
|
||||
sortable: true,
|
||||
sortKey: 'brand.name'
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -10,10 +10,6 @@
|
||||
</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
|
||||
@@ -2,7 +2,7 @@ import {Component, inject} from '@angular/core';
|
||||
import {MatToolbar} from '@angular/material/toolbar';
|
||||
import {MatButton} from '@angular/material/button';
|
||||
import {Router, RouterLink} from '@angular/router';
|
||||
import {AuthService} from '../../../services/auth/auth.service';
|
||||
import {AuthService} from '../../services/auth.service';
|
||||
import {MatMenu, MatMenuItem, MatMenuTrigger} from '@angular/material/menu';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<mat-tab-group>
|
||||
<mat-tab label="Marques">
|
||||
<app-brand-list></app-brand-list>
|
||||
</mat-tab>
|
||||
<mat-tab label="Plateformes">
|
||||
<app-platform-list></app-platform-list>
|
||||
</mat-tab>
|
||||
<mat-tab label="Catégories">
|
||||
<app-category-list></app-category-list>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {MatTab, MatTabGroup} from '@angular/material/tabs';
|
||||
import {PlatformListComponent} from '../../list/platform-list/platform-list.component';
|
||||
import {CategoryListComponent} from '../../list/category-list/category-list.component';
|
||||
import {BrandListComponent} from '../../list/brand-list/brand-list.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-navbar',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatTabGroup,
|
||||
MatTab,
|
||||
CategoryListComponent,
|
||||
BrandListComponent,
|
||||
PlatformListComponent
|
||||
],
|
||||
templateUrl: './admin-navbar.component.html',
|
||||
styleUrl: './admin-navbar.component.css'
|
||||
})
|
||||
export class AdminNavbarComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.crud {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
|
||||
#createBtn {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<section class="crud">
|
||||
<div class="row">
|
||||
<button id="createBtn" mat-raised-button color="primary" (click)="createNew()">Ajouter {{ label.toLowerCase() }}</button>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Filtrer</mat-label>
|
||||
<input matInput (keyup)="applyFilter($any($event.target).value)" placeholder="Rechercher par ID ou nom…">
|
||||
</mat-form-field>
|
||||
|
||||
<div class="mat-elevation-z2">
|
||||
<table mat-table [dataSource]="dataSource" matSort>
|
||||
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>ID</th>
|
||||
<td mat-cell *matCellDef="let el">{{ el.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>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="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
|
||||
<tr class="mat-row" *matNoDataRow>
|
||||
<td class="mat-cell" [attr.colspan]="displayedColumns.length">
|
||||
Aucune donnée ne correspond au filtre.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[5,10,25,100]" [pageSize]="10" aria-label="Pagination"></mat-paginator>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,164 @@
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {AfterViewInit, Component, inject, Input, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
||||
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {
|
||||
MatCell,
|
||||
MatCellDef,
|
||||
MatColumnDef,
|
||||
MatHeaderCell,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderRow, MatHeaderRowDef, MatNoDataRow, MatRow, MatRowDef,
|
||||
MatTable, MatTableDataSource
|
||||
} from '@angular/material/table';
|
||||
import {MatSort, MatSortModule} from '@angular/material/sort';
|
||||
import {MatPaginator, MatPaginatorModule} from '@angular/material/paginator';
|
||||
import {MatFormField, MatLabel} from '@angular/material/form-field';
|
||||
import {MatInput} from '@angular/material/input';
|
||||
import {MatButton, MatIconButton} from '@angular/material/button';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
import {PrestashopService} from '../../services/prestashop.serivce';
|
||||
import {debounceTime, Observable, Subject, takeUntil} from 'rxjs';
|
||||
import {PsItem} from '../../interfaces/ps-item';
|
||||
import {PsItemDialogComponent} from '../ps-item-dialog/ps-item-dialog.component';
|
||||
import {MatDialog} from '@angular/material/dialog';
|
||||
|
||||
|
||||
type Resource = 'categories' | 'manufacturers' | 'suppliers';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ps-admin-crud',
|
||||
standalone: true,
|
||||
templateUrl: './ps-admin-crud.component.html',
|
||||
styleUrls: ['./ps-admin-crud.component.css'],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatTable, MatColumnDef, MatHeaderCell, MatHeaderCellDef, MatCell, MatCellDef,
|
||||
MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef,
|
||||
MatSortModule, MatPaginatorModule,
|
||||
MatFormField, MatLabel, MatInput,
|
||||
MatButton, MatIconButton, MatIcon, MatNoDataRow
|
||||
]
|
||||
})
|
||||
export class PsAdminCrudComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
@Input({required: true}) resource!: Resource;
|
||||
@Input({required: true}) label!: string;
|
||||
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly ps = inject(PrestashopService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
dataSource = new MatTableDataSource<PsItem>([]);
|
||||
displayedColumns: string[] = ['id', 'name', 'actions'];
|
||||
|
||||
form = this.fb.group({name: ['', Validators.required]});
|
||||
editId: number | null = null;
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
@ViewChild(MatSort) sort!: MatSort;
|
||||
@ViewChild(MatTable) table!: MatTable<PsItem>;
|
||||
|
||||
private readonly filter$: Subject<string> = new Subject<string>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.dataSource.filterPredicate = (row, filter) => {
|
||||
const f = filter.trim().toLowerCase();
|
||||
return (
|
||||
String(row.id).toLowerCase().includes(f) ||
|
||||
String(row.name ?? '').toLowerCase().includes(f)
|
||||
);
|
||||
};
|
||||
this.filter$.pipe(debounceTime(150), takeUntil(this.destroy$))
|
||||
.subscribe(v => {
|
||||
this.dataSource.filter = (v ?? '').trim().toLowerCase();
|
||||
if (this.paginator) this.paginator.firstPage();
|
||||
});
|
||||
|
||||
this.reload();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.ps.list(this.resource).subscribe({
|
||||
next: items => {
|
||||
this.dataSource.data = items;
|
||||
// rafraîchir le rendu si nécessaire
|
||||
this.table?.renderRows?.();
|
||||
},
|
||||
error: e => alert('Erreur de chargement: ' + (e?.message || e))
|
||||
});
|
||||
}
|
||||
|
||||
createNew() {
|
||||
const ref = this.dialog.open(PsItemDialogComponent, {
|
||||
width: '400px',
|
||||
data: {label: this.label, title: `Créer ${this.label}`}
|
||||
});
|
||||
ref.afterClosed().subscribe((name: string | null) => {
|
||||
if (!name) return;
|
||||
this.ps.create(this.resource, name).subscribe({
|
||||
next: () => this.reload(),
|
||||
error: e => alert('Erreur: ' + (e?.message || e))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
startEdit(row: PsItem) {
|
||||
const ref = this.dialog.open(PsItemDialogComponent, {
|
||||
width: '400px',
|
||||
data: {label: this.label, name: row.name, title: `Modifier ${this.label} #${row.id}`}
|
||||
});
|
||||
ref.afterClosed().subscribe((name: string | null) => {
|
||||
if (!name) return;
|
||||
this.ps.update(this.resource, row.id, name).subscribe({
|
||||
next: () => this.reload(),
|
||||
error: e => alert('Erreur: ' + (e?.message || e))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.editId = null;
|
||||
this.form.reset({name: ''});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
const name = (this.form.value.name ?? '').trim();
|
||||
if (!name) return;
|
||||
|
||||
const req$: Observable<unknown> = this.editId
|
||||
? this.ps.update(this.resource, this.editId, name) as Observable<unknown>
|
||||
: this.ps.create(this.resource, name) as Observable<unknown>;
|
||||
|
||||
req$.subscribe({
|
||||
next: () => {
|
||||
this.cancelEdit();
|
||||
this.reload();
|
||||
},
|
||||
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
|
||||
});
|
||||
}
|
||||
|
||||
remove(row: PsItem) {
|
||||
if (!confirm(`Supprimer ${this.label.toLowerCase()} "#${row.id} ${row.name} ?`)) return;
|
||||
this.ps.delete(this.resource, row.id).subscribe({
|
||||
next: () => this.reload(),
|
||||
error: e => alert('Erreur: ' + (e?.message || e))
|
||||
});
|
||||
}
|
||||
|
||||
applyFilter(value: string) {
|
||||
this.filter$.next(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.full {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<h2 mat-dialog-title>{{ data.title || 'Élément' }}</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="confirm()" mat-dialog-content>
|
||||
<mat-form-field class="full">
|
||||
<mat-label>Nom de {{ data.label.toLowerCase() || '' }}</mat-label>
|
||||
<input matInput formControlName="name" autocomplete="off"/>
|
||||
</mat-form-field>
|
||||
<div mat-dialog-actions>
|
||||
<button mat-button type="button" (click)="cancel()">Annuler</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,54 @@
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {Component, inject, Inject} from '@angular/core';
|
||||
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {MatFormField, MatLabel} from '@angular/material/form-field';
|
||||
import {MatInput} from '@angular/material/input';
|
||||
import {MatButton} from '@angular/material/button';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions, MatDialogModule
|
||||
} from '@angular/material/dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ps-item-dialog',
|
||||
standalone: true,
|
||||
templateUrl: './ps-item-dialog.component.html',
|
||||
styleUrls: ['./ps-item-dialog.component.css'],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatInput,
|
||||
MatDialogActions,
|
||||
MatButton
|
||||
]
|
||||
})
|
||||
export class PsItemDialogComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
constructor(
|
||||
private readonly dialogRef: MatDialogRef<PsItemDialogComponent, string | null>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: { label: string; name?: string; title?: string }
|
||||
) {
|
||||
this.form = this.fb.group({name: [data?.name ?? '', Validators.required]});
|
||||
}
|
||||
|
||||
form = this.fb.group({name: ['', Validators.required]});
|
||||
|
||||
confirm() {
|
||||
const name = (this.form.value.name ?? '').trim();
|
||||
if (!name) return;
|
||||
this.dialogRef.close(name);
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.dialogRef.close(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.crud {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar .filter {
|
||||
margin-left: auto;
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<section class="crud">
|
||||
<div class="toolbar">
|
||||
<button mat-raised-button color="primary" (click)="create()">
|
||||
<mat-icon>add</mat-icon> Nouveau produit
|
||||
</button>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter">
|
||||
<mat-label>Filtrer</mat-label>
|
||||
<input matInput [formControl]="filterCtrl" placeholder="Nom, ID, catégorie, marque, fournisseur…">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="mat-elevation-z2">
|
||||
<table mat-table [dataSource]="dataSource" matSort>
|
||||
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>ID</th>
|
||||
<td mat-cell *matCellDef="let el">{{ el.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Nom</th>
|
||||
<td mat-cell *matCellDef="let el">{{ el.name }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="category">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Catégorie</th>
|
||||
<td mat-cell *matCellDef="let el">{{ el.categoryName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="manufacturer">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Marque</th>
|
||||
<td mat-cell *matCellDef="let el">{{ el.manufacturerName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="supplier">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Fournisseur</th>
|
||||
<td mat-cell *matCellDef="let el">{{ el.supplierName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="priceTtc">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Prix TTC (€)</th>
|
||||
<td mat-cell *matCellDef="let el">{{ el.priceTtc | number:'1.2-2' }}</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)="edit(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="displayed"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayed;"></tr>
|
||||
|
||||
<tr class="mat-row" *matNoDataRow>
|
||||
<td class="mat-cell" [attr.colspan]="displayed.length">Aucune donnée.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[5,10,25,100]" [pageSize]="10" aria-label="Pagination"></mat-paginator>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,169 @@
|
||||
import {Component, inject, OnInit, ViewChild} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {
|
||||
MatCell, MatCellDef, MatColumnDef, MatHeaderCell, MatHeaderCellDef,
|
||||
MatHeaderRow, MatHeaderRowDef, MatNoDataRow, MatRow, MatRowDef,
|
||||
MatTable, MatTableDataSource
|
||||
} from '@angular/material/table';
|
||||
import {MatPaginator, MatPaginatorModule} from '@angular/material/paginator';
|
||||
import {MatSort, MatSortModule} from '@angular/material/sort';
|
||||
import {MatFormField, MatLabel} from '@angular/material/form-field';
|
||||
import {MatInput} from '@angular/material/input';
|
||||
import {MatButton, MatIconButton} from '@angular/material/button';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
import {FormBuilder, ReactiveFormsModule} from '@angular/forms';
|
||||
import {MatDialog, MatDialogModule} from '@angular/material/dialog';
|
||||
|
||||
import {PsItem} from '../../interfaces/ps-item';
|
||||
import {ProductListItem} from '../../interfaces/product-list-item';
|
||||
import {PrestashopService} from '../../services/prestashop.serivce';
|
||||
import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/ps-product-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ps-product-crud',
|
||||
standalone: true,
|
||||
templateUrl: './ps-product-crud.component.html',
|
||||
styleUrls: ['./ps-product-crud.component.css'],
|
||||
imports: [
|
||||
CommonModule, ReactiveFormsModule,
|
||||
MatTable, MatColumnDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef,
|
||||
MatHeaderCell, MatHeaderCellDef, MatCell, MatCellDef, MatNoDataRow,
|
||||
MatSortModule, MatPaginatorModule,
|
||||
MatFormField, MatLabel, MatInput,
|
||||
MatButton, MatIconButton, MatIcon,
|
||||
MatDialogModule
|
||||
]
|
||||
})
|
||||
export class PsProductCrudComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly ps = inject(PrestashopService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
// référentiels
|
||||
categories: PsItem[] = [];
|
||||
manufacturers: PsItem[] = [];
|
||||
suppliers: PsItem[] = [];
|
||||
|
||||
// maps d’affichage
|
||||
private catMap = new Map<number, string>();
|
||||
private manMap = new Map<number, string>();
|
||||
private supMap = new Map<number, string>();
|
||||
|
||||
// table
|
||||
displayed: string[] = ['id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'actions'];
|
||||
dataSource = new MatTableDataSource<any>([]);
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
@ViewChild(MatSort) sort!: MatSort;
|
||||
@ViewChild(MatTable) table!: MatTable<any>;
|
||||
|
||||
// filtre
|
||||
filterCtrl = this.fb.control<string>('');
|
||||
|
||||
ngOnInit(): void {
|
||||
// charger référentiels en parallèle
|
||||
Promise.all([
|
||||
this.ps.list('categories').toPromise(),
|
||||
this.ps.list('manufacturers').toPromise(),
|
||||
this.ps.list('suppliers').toPromise()
|
||||
]).then(([cats, mans, sups]) => {
|
||||
this.categories = cats ?? [];
|
||||
this.catMap = new Map(this.categories.map(x => [x.id, x.name]));
|
||||
this.manufacturers = mans ?? [];
|
||||
this.manMap = new Map(this.manufacturers.map(x => [x.id, x.name]));
|
||||
this.suppliers = sups ?? [];
|
||||
this.supMap = new Map(this.suppliers.map(x => [x.id, x.name]));
|
||||
this.reload();
|
||||
});
|
||||
|
||||
// filtre client
|
||||
this.filterCtrl.valueChanges.subscribe(v => {
|
||||
this.dataSource.filter = (v ?? '').toString().trim().toLowerCase();
|
||||
if (this.paginator) this.paginator.firstPage();
|
||||
});
|
||||
this.dataSource.filterPredicate = (row: any, f: string) =>
|
||||
row.name?.toLowerCase().includes(f) ||
|
||||
String(row.id).includes(f) ||
|
||||
(row.categoryName?.toLowerCase().includes(f)) ||
|
||||
(row.manufacturerName?.toLowerCase().includes(f)) ||
|
||||
(row.supplierName?.toLowerCase().includes(f));
|
||||
}
|
||||
|
||||
private toTtc(ht: number, vat: number) {
|
||||
return Math.round(((ht * (1 + vat)) + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
private attachSortingAccessors() {
|
||||
this.dataSource.sortingDataAccessor = (item: any, property: string) => {
|
||||
switch (property) {
|
||||
case 'category':
|
||||
return (item.categoryName ?? '').toLowerCase();
|
||||
case 'manufacturer':
|
||||
return (item.manufacturerName ?? '').toLowerCase();
|
||||
case 'supplier':
|
||||
return (item.supplierName ?? '').toLowerCase();
|
||||
case 'priceTtc':
|
||||
return Number(item.priceTtc ?? 0);
|
||||
case 'name':
|
||||
return (item.name ?? '').toLowerCase();
|
||||
default:
|
||||
return item[property];
|
||||
}
|
||||
};
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
}
|
||||
|
||||
private bindProducts(p: (ProductListItem & { priceHt?: number })[]) {
|
||||
const vat = 0.20; // valeur fixe utilisée pour calcul TTC en liste
|
||||
this.dataSource.data = p.map(x => ({
|
||||
...x,
|
||||
categoryName: x.id_category_default ? (this.catMap.get(x.id_category_default) ?? '') : '',
|
||||
manufacturerName: x.id_manufacturer ? (this.manMap.get(x.id_manufacturer) ?? '') : '',
|
||||
supplierName: x.id_supplier ? (this.supMap.get(x.id_supplier) ?? '') : '',
|
||||
priceTtc: this.toTtc(x.priceHt ?? 0, vat)
|
||||
}));
|
||||
this.attachSortingAccessors();
|
||||
this.table?.renderRows?.();
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.ps.listProducts().subscribe(p => this.bindProducts(p));
|
||||
}
|
||||
|
||||
create() {
|
||||
const data: ProductDialogData = {
|
||||
mode: 'create',
|
||||
refs: {
|
||||
categories: this.categories,
|
||||
manufacturers: this.manufacturers,
|
||||
suppliers: this.suppliers
|
||||
}
|
||||
};
|
||||
this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => {
|
||||
if (ok) this.reload();
|
||||
});
|
||||
}
|
||||
|
||||
edit(row: ProductListItem & { priceHt?: number }) {
|
||||
const data: ProductDialogData = {
|
||||
mode: 'edit',
|
||||
productRow: row,
|
||||
refs: {
|
||||
categories: this.categories,
|
||||
manufacturers: this.manufacturers,
|
||||
suppliers: this.suppliers
|
||||
}
|
||||
};
|
||||
this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => {
|
||||
if (ok) this.reload();
|
||||
});
|
||||
}
|
||||
|
||||
remove(row: ProductListItem) {
|
||||
if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return;
|
||||
this.ps.deleteProduct(row.id).subscribe({
|
||||
next: () => this.reload(),
|
||||
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.col-12 {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
.col-6 {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.col-4 {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
.flags {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thumbs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.thumbs img {
|
||||
height: 64px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .2);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<h2 mat-dialog-title>{{ mode === 'create' ? 'Nouveau produit' : 'Modifier le produit' }}</h2>
|
||||
|
||||
<div mat-dialog-content class="grid" [formGroup]="form">
|
||||
<mat-form-field class="col-6">
|
||||
<mat-label>Nom du produit</mat-label>
|
||||
<input matInput formControlName="name" autocomplete="off">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="col-6">
|
||||
<mat-label>Catégorie</mat-label>
|
||||
<mat-select formControlName="categoryId">
|
||||
<mat-option [value]="null" disabled>Choisir…</mat-option>
|
||||
@for (c of categories; track c.id) {
|
||||
<mat-option [value]="c.id">{{ c.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="col-6">
|
||||
<mat-label>Marque</mat-label>
|
||||
<mat-select formControlName="manufacturerId">
|
||||
<mat-option [value]="null" disabled>Choisir…</mat-option>
|
||||
@for (m of manufacturers; track m.id) {
|
||||
<mat-option [value]="m.id">{{ m.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="col-6">
|
||||
<mat-label>Fournisseur</mat-label>
|
||||
<mat-select formControlName="supplierId">
|
||||
<mat-option [value]="null" disabled>Choisir…</mat-option>
|
||||
@for (s of suppliers; track s.id) {
|
||||
<mat-option [value]="s.id">{{ s.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="col-12">
|
||||
<mat-label>Description</mat-label>
|
||||
<textarea matInput rows="4" formControlName="description"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="col-12 flags">
|
||||
<mat-checkbox formControlName="complete">Complet</mat-checkbox>
|
||||
<mat-checkbox formControlName="hasManual">Notice</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label for="fileInput">Images du produit</label>
|
||||
<input type="file" multiple (change)="onFiles($event)">
|
||||
</div>
|
||||
|
||||
<div class="col-12" *ngIf="mode==='edit' && existingImageUrls.length">
|
||||
<div class="thumbs">
|
||||
@for (url of existingImageUrls; track url) {
|
||||
<img [src]="url" alt="Produit">
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-form-field class="col-4">
|
||||
<mat-label>Prix TTC (€)</mat-label>
|
||||
<input matInput type="number" step="0.01" min="0" formControlName="priceTtc">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="col-4">
|
||||
<mat-label>Quantité</mat-label>
|
||||
<input matInput type="number" step="1" min="0" formControlName="quantity">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div mat-dialog-actions>
|
||||
<button mat-button (click)="close()">Annuler</button>
|
||||
<button mat-raised-button color="primary" (click)="save()" [disabled]="form.invalid">
|
||||
{{ mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Component, Inject, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatFormField, MatLabel } from '@angular/material/form-field';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogTitle
|
||||
} from '@angular/material/dialog';
|
||||
|
||||
import { catchError, forkJoin, of, Observable } from 'rxjs';
|
||||
|
||||
import { PsItem } from '../../interfaces/ps-item';
|
||||
import { ProductListItem } from '../../interfaces/product-list-item';
|
||||
import {PrestashopService} from '../../services/prestashop.serivce';
|
||||
|
||||
export type ProductDialogData = {
|
||||
mode: 'create' | 'edit';
|
||||
refs: { categories: PsItem[]; manufacturers: PsItem[]; suppliers: PsItem[]; };
|
||||
productRow?: ProductListItem & { priceHt?: number };
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-ps-product-dialog',
|
||||
standalone: true,
|
||||
templateUrl: './ps-product-dialog.component.html',
|
||||
styleUrls: ['./ps-product-dialog.component.css'],
|
||||
imports: [
|
||||
CommonModule, ReactiveFormsModule,
|
||||
MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox,
|
||||
MatButton, MatDialogActions, MatDialogContent, MatDialogTitle
|
||||
]
|
||||
})
|
||||
export class PsProductDialogComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly ps = inject(PrestashopService);
|
||||
|
||||
constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: ProductDialogData,
|
||||
private readonly dialogRef: MatDialogRef<PsProductDialogComponent>
|
||||
) {}
|
||||
|
||||
mode!: 'create' | 'edit';
|
||||
categories: PsItem[] = [];
|
||||
manufacturers: PsItem[] = [];
|
||||
suppliers: PsItem[] = [];
|
||||
productRow?: ProductListItem & { priceHt?: number };
|
||||
|
||||
images: File[] = [];
|
||||
existingImageUrls: string[] = [];
|
||||
|
||||
// on conserve la dernière description chargée pour éviter l’écrasement à vide
|
||||
private lastLoadedDescription = '';
|
||||
|
||||
form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
categoryId: [null as number | null, Validators.required],
|
||||
manufacturerId: [null as number | null, Validators.required],
|
||||
supplierId: [null as number | null, Validators.required],
|
||||
complete: [false],
|
||||
hasManual: [false],
|
||||
priceTtc: [0, [Validators.required, Validators.min(0)]],
|
||||
quantity: [0, [Validators.required, Validators.min(0)]],
|
||||
});
|
||||
|
||||
private toTtc(ht: number) { return Math.round(((ht * 1.2) + Number.EPSILON) * 100) / 100; }
|
||||
|
||||
/** enlève <![CDATA[ ... ]]> si présent */
|
||||
private stripCdata(s: string): string {
|
||||
if (!s) return '';
|
||||
return s.startsWith('<![CDATA[') && s.endsWith(']]>') ? s.slice(9, -3) : s;
|
||||
}
|
||||
/** convertit du HTML en texte (pour le textarea) */
|
||||
private htmlToText(html: string): string {
|
||||
if (!html) return '';
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
return (div.textContent || div.innerText || '').trim();
|
||||
}
|
||||
/** nettoyage CDATA+HTML -> texte simple */
|
||||
private cleanForTextarea(src: string): string {
|
||||
return this.htmlToText(this.stripCdata(src ?? ''));
|
||||
}
|
||||
|
||||
/** sépare la description "contenu" des drapeaux + détecte Complet/Notice */
|
||||
private splitDescriptionFlags(desc: string) {
|
||||
const cleaned = this.cleanForTextarea(desc);
|
||||
const complete = /Complet\s*:\s*Oui/i.test(desc);
|
||||
const hasManual = /Notice\s*:\s*Oui/i.test(desc);
|
||||
const idx = cleaned.indexOf('Complet:');
|
||||
const base = (idx >= 0 ? cleaned.slice(0, idx) : cleaned).trim();
|
||||
return { base, complete, hasManual };
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.mode = this.data.mode;
|
||||
this.categories = this.data.refs.categories ?? [];
|
||||
this.manufacturers = this.data.refs.manufacturers ?? [];
|
||||
this.suppliers = this.data.refs.suppliers ?? [];
|
||||
this.productRow = this.data.productRow;
|
||||
|
||||
if (this.mode === 'edit' && this.productRow) {
|
||||
const r = this.productRow;
|
||||
|
||||
// patch immédiat depuis la ligne
|
||||
const immediateTtc = r.priceHt == null ? 0 : this.toTtc(r.priceHt);
|
||||
this.form.patchValue({
|
||||
name: r.name,
|
||||
categoryId: r.id_category_default ?? null,
|
||||
manufacturerId: r.id_manufacturer ?? null,
|
||||
supplierId: r.id_supplier ?? null,
|
||||
priceTtc: immediateTtc
|
||||
});
|
||||
|
||||
// patch final via API (tolérant aux erreurs)
|
||||
const details$ = this.ps.getProductDetails(r.id).pipe(
|
||||
catchError(() => of({
|
||||
id: r.id, name: r.name, description: '',
|
||||
id_manufacturer: r.id_manufacturer, id_supplier: r.id_supplier,
|
||||
id_category_default: r.id_category_default, priceHt: r.priceHt ?? 0
|
||||
}))
|
||||
);
|
||||
const qty$ = this.ps.getProductQuantity(r.id).pipe(catchError(() => of(0)));
|
||||
const imgs$ = this.ps.getProductImageUrls(r.id).pipe(catchError(() => of<string[]>([])));
|
||||
|
||||
forkJoin({ details: details$, qty: qty$, imgs: imgs$ })
|
||||
.subscribe(({ details, qty, imgs }) => {
|
||||
const ttc = this.toTtc(details.priceHt ?? 0);
|
||||
const { base, complete, hasManual } = this.splitDescriptionFlags(details.description ?? '');
|
||||
this.lastLoadedDescription = base;
|
||||
|
||||
this.form.patchValue({
|
||||
description: base,
|
||||
complete, hasManual,
|
||||
priceTtc: (ttc || this.form.value.priceTtc || 0),
|
||||
quantity: qty,
|
||||
categoryId: (details.id_category_default ?? this.form.value.categoryId) ?? null,
|
||||
manufacturerId: (details.id_manufacturer ?? this.form.value.manufacturerId) ?? null,
|
||||
supplierId: (details.id_supplier ?? this.form.value.supplierId) ?? null
|
||||
});
|
||||
this.existingImageUrls = imgs;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onFiles(ev: Event) {
|
||||
const fl = (ev.target as HTMLInputElement).files;
|
||||
this.images = fl ? Array.from(fl) : [];
|
||||
}
|
||||
|
||||
save() {
|
||||
if (this.form.invalid) return;
|
||||
|
||||
const v = this.form.getRawValue();
|
||||
const effectiveDescription = (v.description ?? '').trim() || this.lastLoadedDescription;
|
||||
|
||||
const dto = {
|
||||
name: v.name!,
|
||||
description: effectiveDescription,
|
||||
categoryId: +v.categoryId!,
|
||||
manufacturerId: +v.manufacturerId!,
|
||||
supplierId: +v.supplierId!,
|
||||
images: this.images,
|
||||
complete: !!v.complete,
|
||||
hasManual: !!v.hasManual,
|
||||
conditionLabel: undefined,
|
||||
priceTtc: Number(v.priceTtc ?? 0),
|
||||
vatRate: 0.2,
|
||||
quantity: Math.max(0, Number(v.quantity ?? 0))
|
||||
};
|
||||
|
||||
let op$: Observable<unknown>;
|
||||
if (this.mode === 'create' || !this.productRow) {
|
||||
op$ = this.ps.createProduct(dto) as Observable<unknown>;
|
||||
} else {
|
||||
op$ = this.ps.updateProduct(this.productRow.id, dto) as Observable<unknown>;
|
||||
}
|
||||
|
||||
op$.subscribe({
|
||||
next: () => this.dialogRef.close(true),
|
||||
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, CanMatchFn, Router, UrlTree, ActivatedRouteSnapshot, Route } from '@angular/router';
|
||||
import { AuthService } from '../services/auth/auth.service';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
function requireAdmin(url?: string): boolean | UrlTree {
|
||||
const authService: AuthService = inject(AuthService);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, CanMatchFn, Router, UrlTree, ActivatedRouteSnapshot, Route } from '@angular/router';
|
||||
import { AuthService } from '../services/auth/auth.service';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
function requireAuth(url?: string): boolean | UrlTree {
|
||||
const authService = inject(AuthService);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, UrlTree, CanActivateFn, CanMatchFn } from '@angular/router';
|
||||
import { AuthService } from '../services/auth/auth.service';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
function redirectIfLoggedIn(): boolean | UrlTree {
|
||||
const authService = inject(AuthService);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {HttpErrorResponse, HttpInterceptorFn} from '@angular/common/http';
|
||||
import {inject} from '@angular/core';
|
||||
import {AuthService} from '../services/auth/auth.service';
|
||||
import {AuthService} from '../services/auth.service';
|
||||
import {catchError, switchMap, throwError} from 'rxjs';
|
||||
|
||||
let isRefreshing = false;
|
||||
|
||||
7
client/src/app/interfaces/product-list-item.ts
Normal file
7
client/src/app/interfaces/product-list-item.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface ProductListItem {
|
||||
id: number;
|
||||
name: string;
|
||||
id_manufacturer?: number;
|
||||
id_supplier?: number;
|
||||
id_category_default?: number;
|
||||
}
|
||||
5
client/src/app/interfaces/ps-item.ts
Normal file
5
client/src/app/interfaces/ps-item.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface PsItem {
|
||||
id: number;
|
||||
name: string;
|
||||
active?: boolean;
|
||||
}
|
||||
15
client/src/app/interfaces/ps-product.ts
Normal file
15
client/src/app/interfaces/ps-product.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface PsProduct {
|
||||
name: string;
|
||||
description?: string; // texte saisi libre
|
||||
categoryId: number; // id_category_default + associations
|
||||
manufacturerId: number;
|
||||
supplierId: number;
|
||||
images?: File[]; // optionnel
|
||||
// Champs “hors Presta” injectés dans la description :
|
||||
conditionLabel?: string; // ex. "Occasion"
|
||||
complete?: boolean; // Complet
|
||||
hasManual?: boolean; // Notice
|
||||
priceTtc: number; // saisi côté UI (TTC)
|
||||
vatRate?: number; // ex: 0.20 (20%). Défaut: 0.20 si non fourni
|
||||
quantity: number; // stock souhaité (pour id_product_attribute = 0)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
<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">
|
||||
|
||||
<!-- 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>
|
||||
<input matInput
|
||||
id="title"
|
||||
name="title"
|
||||
formControlName="title"
|
||||
type="text"
|
||||
required>
|
||||
@if (isFieldInvalid('title')) {
|
||||
<mat-error>{{ getFieldError('title') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Description -->
|
||||
<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>
|
||||
@@ -1,427 +0,0 @@
|
||||
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';
|
||||
import {ImageService} from '../../services/app/image.service';
|
||||
import {ProductImageService} from '../../services/app/product_images.service';
|
||||
|
||||
@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;
|
||||
|
||||
imageFile: File | null = null;
|
||||
imagePreview: string | null = null;
|
||||
private imageUploadSubscription: Subscription | null = null;
|
||||
private imageLinkSubscription: Subscription | null = null;
|
||||
|
||||
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 imageService = inject(ImageService)
|
||||
private readonly productService = inject(ProductService);
|
||||
private readonly productImageService = inject(ProductImageService);
|
||||
|
||||
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();
|
||||
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) return;
|
||||
|
||||
this.isLoading = true;
|
||||
const raw = this.addProductForm.value;
|
||||
|
||||
// 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 {
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<app-admin-navbar></app-admin-navbar>
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {AdminNavbarComponent} from '../../components/navbar/admin-navbar/admin-navbar.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin',
|
||||
templateUrl: './admin.component.html',
|
||||
standalone: true,
|
||||
imports: [
|
||||
AdminNavbarComponent
|
||||
],
|
||||
styleUrls: ['./admin.component.scss']
|
||||
})
|
||||
export class AdminComponent{
|
||||
|
||||
}
|
||||
14
client/src/app/pages/admin/ps-admin/ps-admin.component.html
Normal file
14
client/src/app/pages/admin/ps-admin/ps-admin.component.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<div class="wrap">
|
||||
<h2>Administration Prestashop</h2>
|
||||
<mat-tab-group>
|
||||
<mat-tab label="Catégories">
|
||||
<app-ps-admin-crud [resource]="'categories'" [label]="'Catégorie'"></app-ps-admin-crud>
|
||||
</mat-tab>
|
||||
<mat-tab label="Marques">
|
||||
<app-ps-admin-crud [resource]="'manufacturers'" [label]="'Marque'"></app-ps-admin-crud>
|
||||
</mat-tab>
|
||||
<mat-tab label="Fournisseurs">
|
||||
<app-ps-admin-crud [resource]="'suppliers'" [label]="'Fournisseur'"></app-ps-admin-crud>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
16
client/src/app/pages/admin/ps-admin/ps-admin.component.ts
Normal file
16
client/src/app/pages/admin/ps-admin/ps-admin.component.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {MatTab, MatTabGroup} from '@angular/material/tabs';
|
||||
import {PsAdminCrudComponent} from '../../../components/ps-generic-crud/ps-admin-crud.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-ps-admin',
|
||||
templateUrl: './ps-admin.component.html',
|
||||
imports: [
|
||||
MatTabGroup,
|
||||
MatTab,
|
||||
PsAdminCrudComponent
|
||||
],
|
||||
styleUrls: ['./ps-admin.component.css']
|
||||
})
|
||||
export class PsAdminComponent {}
|
||||
@@ -1,11 +1,11 @@
|
||||
import {Component, inject, OnDestroy} from '@angular/core';
|
||||
import {MatError, MatFormField, MatLabel} from '@angular/material/form-field';
|
||||
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {AuthService} from '../../services/auth/auth.service';
|
||||
import {AuthService} from '../../../services/auth.service';
|
||||
import {Router} from '@angular/router';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {Credentials} from '../../interfaces/credentials';
|
||||
import {User} from '../../interfaces/user';
|
||||
import {Credentials} from '../../../interfaces/credentials';
|
||||
import {User} from '../../../interfaces/user';
|
||||
import {MatInput} from '@angular/material/input';
|
||||
import {MatButton} from '@angular/material/button';
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from '@angular/material/card';
|
||||
import {MatProgressSpinner} from '@angular/material/progress-spinner';
|
||||
import {MatDivider} from '@angular/material/divider';
|
||||
import {AuthService} from '../../services/auth/auth.service';
|
||||
import {AuthService} from '../../../services/auth.service';
|
||||
import {MatCheckbox} from '@angular/material/checkbox';
|
||||
import {MatButton} from '@angular/material/button';
|
||||
import {Subscription} from 'rxjs';
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Component, inject} from '@angular/core';
|
||||
import {MatButton} from '@angular/material/button';
|
||||
import {AuthService} from '../../services/auth/auth.service';
|
||||
import {AuthService} from '../../services/auth.service';
|
||||
import {Router, RouterLink} from '@angular/router';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -1,128 +1,5 @@
|
||||
/* ===== 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); }
|
||||
.wrap {
|
||||
padding: 16px;
|
||||
max-width: 1100px;
|
||||
margin: auto
|
||||
}
|
||||
|
||||
@@ -1,126 +1,4 @@
|
||||
<div class="generic-list">
|
||||
<!-- Header (bouton à droite) -->
|
||||
<div class="gl-header">
|
||||
<h3 class="gl-title">Gestion des produits</h3>
|
||||
<div class="gl-controls">
|
||||
<button mat-flat-button color="accent" (click)="onAdd()">
|
||||
<mat-icon>add</mat-icon> Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<section class="wrap">
|
||||
<h2>Gestion des produits</h2>
|
||||
<app-ps-product-crud></app-ps-product-crud>
|
||||
</section>
|
||||
|
||||
@@ -1,182 +1,15 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
ViewChild,
|
||||
AfterViewInit,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
OnInit,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { Product } from '../../interfaces/product';
|
||||
import { ProductService } from '../../services/app/product.service';
|
||||
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';
|
||||
import { Component } from '@angular/core';
|
||||
import {PsProductCrudComponent} from '../../components/ps-product-crud/ps-product-crud.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-products',
|
||||
templateUrl: './products.component.html',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatDialogModule,
|
||||
CurrencyPipe
|
||||
PsProductCrudComponent
|
||||
],
|
||||
styleUrls: ['./products.component.css']
|
||||
templateUrl: './products.component.html',
|
||||
styleUrl: './products.component.css'
|
||||
})
|
||||
export class ProductsComponent implements OnInit, AfterViewInit, OnChanges {
|
||||
export class ProductsComponent {
|
||||
|
||||
@Input() products: Product[] = [];
|
||||
@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 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 {
|
||||
if (!this.products || this.products.length === 0) {
|
||||
this.loadProducts();
|
||||
} else {
|
||||
this.dataSource.data = this.products;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['products']) {
|
||||
this.dataSource.data = this.products || [];
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@angular/material/card';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
import {MatButton} from '@angular/material/button';
|
||||
import {AuthService} from '../../services/auth/auth.service';
|
||||
import {AuthService} from '../../services/auth.service';
|
||||
import {User} from '../../interfaces/user';
|
||||
import {Router} from '@angular/router';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {inject, Injectable, signal} from '@angular/core';
|
||||
import {catchError, map, of, switchMap, tap} from 'rxjs';
|
||||
import {Credentials} from '../../interfaces/credentials';
|
||||
import {Credentials} from '../interfaces/credentials';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {User} from '../../interfaces/user';
|
||||
import {User} from '../interfaces/user';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -1,8 +1,8 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
import {Brand} from '../../interfaces/brand';
|
||||
import {CrudService} from '../crud.service';
|
||||
import {Brand} from '../interfaces/brand';
|
||||
import {CrudService} from './crud.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -1,8 +1,8 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
import {Category} from '../../interfaces/category';
|
||||
import {CrudService} from '../crud.service';
|
||||
import {Category} from '../interfaces/category';
|
||||
import {CrudService} from './crud.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -1,8 +1,8 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
import {Condition} from '../../interfaces/condition';
|
||||
import {CrudService} from '../crud.service';
|
||||
import {Condition} from '../interfaces/condition';
|
||||
import {CrudService} from './crud.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -1,7 +1,7 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
import {Image} from '../../interfaces/image';
|
||||
import {Image} from '../interfaces/image';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class ImageService {
|
||||
@@ -1,8 +1,8 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
import {Platform} from '../../interfaces/platform';
|
||||
import {CrudService} from '../crud.service';
|
||||
import {Platform} from '../interfaces/platform';
|
||||
import {CrudService} from './crud.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -1,234 +0,0 @@
|
||||
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'});
|
||||
}
|
||||
}
|
||||
726
client/src/app/services/prestashop.serivce.ts
Normal file
726
client/src/app/services/prestashop.serivce.ts
Normal file
@@ -0,0 +1,726 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http';
|
||||
import {forkJoin, map, of, switchMap, Observable} from 'rxjs';
|
||||
import {PsItem} from '../interfaces/ps-item';
|
||||
import {PsProduct} from '../interfaces/ps-product';
|
||||
import {ProductListItem} from '../interfaces/product-list-item';
|
||||
|
||||
type Resource = 'categories' | 'manufacturers' | 'suppliers';
|
||||
|
||||
const UPDATE_CFG: Record<Resource, {
|
||||
root: 'category' | 'manufacturer' | 'supplier';
|
||||
needsDefaultLang?: boolean;
|
||||
keepFields?: string[];
|
||||
nameIsMultilang?: boolean;
|
||||
}> = {
|
||||
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 PrestashopService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly base = '/ps'; // proxy Angular -> https://.../api
|
||||
|
||||
// -------- Utils
|
||||
/** Id de la catégorie d’accueil (Home) de la boutique: PS_HOME_CATEGORY */
|
||||
private getHomeCategoryId() {
|
||||
const params = new HttpParams()
|
||||
.set('display', '[value]')
|
||||
.set('filter[name]', 'PS_HOME_CATEGORY')
|
||||
.set('output_format', 'JSON');
|
||||
return this.http.get<any>(`${this.base}/configurations`, {params}).pipe(
|
||||
map(r => +r?.configurations?.[0]?.value || 2) // fallback 2
|
||||
);
|
||||
}
|
||||
|
||||
/** Id de la catégorie racine (PS_ROOT_CATEGORY) */
|
||||
private getRootCategoryId() {
|
||||
const params = new HttpParams()
|
||||
.set('display', '[value]')
|
||||
.set('filter[name]', 'PS_ROOT_CATEGORY')
|
||||
.set('output_format', 'JSON');
|
||||
return this.http.get<any>(`${this.base}/configurations`, {params}).pipe(
|
||||
map(r => +r?.configurations?.[0]?.value || 1) // fallback 1
|
||||
);
|
||||
}
|
||||
|
||||
private readonly headersXml = new HttpHeaders({'Content-Type': 'application/xml', 'Accept': 'application/xml'});
|
||||
|
||||
private escapeXml(v: string) {
|
||||
return String(v)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
.replaceAll('"', '"').replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/** Extrait un <id> y compris avec CDATA et attributs éventuels */
|
||||
private extractIdFromXml(xml: string): number | null {
|
||||
const s = String(xml);
|
||||
|
||||
// 1) prioritaire: id du noeud <product> (avant <associations>)
|
||||
const mProduct = s.match(
|
||||
/<product[\s\S]*?<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:\]\]>)?\s*<\/id>[\s\S]*?(?:<associations>|<\/product>)/i
|
||||
);
|
||||
if (mProduct) return +mProduct[1];
|
||||
|
||||
// 2) racine quelconque (tolère CDATA)
|
||||
const mRoot = s.match(
|
||||
/<prestashop[\s\S]*?<([a-z_]+)[^>]*>[\s\S]*?<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:\]\]>)?\s*<\/id>/i
|
||||
);
|
||||
if (mRoot) return +mRoot[2];
|
||||
|
||||
// 3) fallback: premier <id> numérique
|
||||
const mAny = s.match(/<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:\]\]>)?\s*<\/id>/i);
|
||||
if (mAny) return +mAny[1];
|
||||
|
||||
console.warn('[Presta] Impossible d’extraire <id> depuis la réponse:', xml);
|
||||
return 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
|
||||
.map(e => `<language id="${e.id}">${this.escapeXml(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 [];
|
||||
}
|
||||
|
||||
private round2(n: number) {
|
||||
return Math.round((n + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
private ttcToHt(priceTtc: number, rate = 0.2) {
|
||||
return this.round2(priceTtc / (1 + rate));
|
||||
}
|
||||
|
||||
// -------- Contexte, langues, fiscalité
|
||||
/** 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)
|
||||
);
|
||||
}
|
||||
|
||||
/** Premier tax_rule_group actif (fallback TVA) */
|
||||
private getFirstActiveTaxRulesGroupId() {
|
||||
const params = new HttpParams()
|
||||
.set('display', '[id,active]')
|
||||
.set('filter[active]', '1')
|
||||
.set('output_format', 'JSON');
|
||||
|
||||
return this.http.get<any>(`${this.base}/tax_rule_groups`, {params}).pipe(
|
||||
map(r => {
|
||||
const g = (r?.tax_rule_groups ?? [])[0];
|
||||
return g ? +g.id : 0;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Groupe de taxes par défaut (PS_TAX_DEFAULT_RULES_GROUP) avec fallback sur un groupe actif */
|
||||
private getDefaultTaxRulesGroupId() {
|
||||
const params = new HttpParams()
|
||||
.set('display', '[value]')
|
||||
.set('filter[name]', 'PS_TAX_DEFAULT_RULES_GROUP')
|
||||
.set('output_format', 'JSON');
|
||||
|
||||
return this.http.get<any>(`${this.base}/configurations`, {params}).pipe(
|
||||
switchMap(r => {
|
||||
const id = +r?.configurations?.[0]?.value;
|
||||
if (Number.isFinite(id) && id > 0) return of(id);
|
||||
return this.getFirstActiveTaxRulesGroupId();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Devise par défaut (PS_CURRENCY_DEFAULT) */
|
||||
private getDefaultCurrencyId() {
|
||||
const params = new HttpParams()
|
||||
.set('display', '[value]')
|
||||
.set('filter[name]', 'PS_CURRENCY_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 (pour création catégorie multilangue) */
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
/** Contexte boutique actif (1ère boutique active) */
|
||||
private getDefaultShopContext() {
|
||||
const params = new HttpParams()
|
||||
.set('display', '[id,id_shop_group,active]')
|
||||
.set('filter[active]', '1')
|
||||
.set('output_format', 'JSON');
|
||||
return this.http.get<any>(`${this.base}/shops`, {params}).pipe(
|
||||
map(r => {
|
||||
const s = (r?.shops ?? [])[0];
|
||||
return {idShop: s ? +s.id : 1, idShopGroup: s ? +s.id_shop_group : 1};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Objet complet pour update sûr (cat/manu/supplier) */
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
// -------- CRUD générique (categories/manufacturers/suppliers)
|
||||
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 => (r?.[resource] ?? []).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(resource: Resource, name: string) {
|
||||
const safeName = this.escapeXml(name);
|
||||
|
||||
if (resource === 'categories') {
|
||||
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: number) => ({id, value: safeName})))}
|
||||
${this.toLangBlock('link_rewrite', langIds.map((id: number) => ({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))
|
||||
);
|
||||
}
|
||||
|
||||
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(resource: Resource, id: number, newName: string) {
|
||||
const cfg = UPDATE_CFG[resource];
|
||||
const safeName = this.escapeXml(newName);
|
||||
|
||||
const defaultLangOr1$ = (cfg.needsDefaultLang ? this.getDefaultLangId() : 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;
|
||||
|
||||
let linkRewriteXml = '', idParentXml = '';
|
||||
if (resource === 'categories') {
|
||||
const lr = this.ensureArrayLang(obj?.link_rewrite);
|
||||
linkRewriteXml = lr.length ? this.toLangBlock('link_rewrite', lr)
|
||||
: 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(resource: Resource, id: number) {
|
||||
return this.http.delete(`${this.base}/${resource}/${id}`, {responseType: 'text'}).pipe(map(() => true));
|
||||
}
|
||||
|
||||
getXml(resource: Resource, id: number) {
|
||||
return this.http.get(`${this.base}/${resource}/${id}`, {responseType: 'text'});
|
||||
}
|
||||
|
||||
// -------- Produits (liste / détails)
|
||||
listProducts(query?: string) {
|
||||
let params = new HttpParams()
|
||||
.set('display', '[id,name,id_manufacturer,id_supplier,id_category_default,price]')
|
||||
.set('output_format', 'JSON');
|
||||
if (query?.trim()) params = params.set('filter[name]', `%[${query.trim()}]%`);
|
||||
return this.http.get<any>(`${this.base}/products`, {params}).pipe(
|
||||
map(r => (r?.products ?? []).map((p: any) => ({
|
||||
id: +p.id,
|
||||
name: Array.isArray(p.name) ? (p.name[0]?.value ?? '') : (p.name ?? ''),
|
||||
id_manufacturer: p?.id_manufacturer ? +p.id_manufacturer : undefined,
|
||||
id_supplier: p?.id_supplier ? +p.id_supplier : undefined,
|
||||
id_category_default: p?.id_category_default ? +p.id_category_default : undefined,
|
||||
priceHt: p?.price ? +p.price : 0
|
||||
}) as ProductListItem & { priceHt?: number }))
|
||||
);
|
||||
}
|
||||
|
||||
/** Détails produit (JSON full) + fallback XML pour description si vide */
|
||||
getProductDetails(id: number) {
|
||||
const params = new HttpParams().set('output_format', 'JSON').set('display', 'full');
|
||||
return this.http.get<any>(`${this.base}/products/${id}`, {params}).pipe(
|
||||
map(r => r?.product ?? r),
|
||||
switchMap(p => {
|
||||
let description = Array.isArray(p?.description) ? (p.description[0]?.value ?? '') : (p?.description ?? '');
|
||||
if (description && typeof description === 'string') {
|
||||
return of({
|
||||
id: +p.id,
|
||||
name: Array.isArray(p.name) ? (p.name[0]?.value ?? '') : (p.name ?? ''),
|
||||
description,
|
||||
id_manufacturer: p?.id_manufacturer ? +p.id_manufacturer : undefined,
|
||||
id_supplier: p?.id_supplier ? +p.id_supplier : undefined,
|
||||
id_category_default: p?.id_category_default ? +p.id_category_default : undefined,
|
||||
priceHt: p?.price ? +p.price : 0
|
||||
});
|
||||
}
|
||||
// Fallback XML : extraire <description>…</description> et enlever CDATA si présent
|
||||
return this.http.get(`${this.base}/products/${id}`, {responseType: 'text'}).pipe(
|
||||
map((xml: string) => {
|
||||
const m = xml.match(/<description>[\s\S]*?<language[^>]*>([\s\S]*?)<\/language>[\s\S]*?<\/description>/i);
|
||||
let descXml = m ? m[1] : '';
|
||||
if (descXml.startsWith('<![CDATA[') && descXml.endsWith(']]>')) {
|
||||
descXml = descXml.slice(9, -3);
|
||||
}
|
||||
return {
|
||||
id: +p.id,
|
||||
name: Array.isArray(p.name) ? (p.name[0]?.value ?? '') : (p.name ?? ''),
|
||||
description: descXml,
|
||||
id_manufacturer: p?.id_manufacturer ? +p.id_manufacturer : undefined,
|
||||
id_supplier: p?.id_supplier ? +p.id_supplier : undefined,
|
||||
id_category_default: p?.id_category_default ? +p.id_category_default : undefined,
|
||||
priceHt: p?.price ? +p.price : 0
|
||||
};
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// --- Images
|
||||
private getProductImageIds(productId: number) {
|
||||
return this.http.get<any>(`${this.base}/images/products/${productId}`, {responseType: 'json' as any}).pipe(
|
||||
map(r => {
|
||||
const arr = (r?.image ?? r?.images ?? []) as Array<any>;
|
||||
return Array.isArray(arr) ? arr.map(x => +x.id) : [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getProductImageUrls(productId: number) {
|
||||
return this.getProductImageIds(productId).pipe(
|
||||
map(ids => ids.map(idImg => `${this.base}/images/products/${productId}/${idImg}`))
|
||||
);
|
||||
}
|
||||
|
||||
uploadProductImage(productId: number, file: File) {
|
||||
const fd = new FormData();
|
||||
fd.append('image', file);
|
||||
return this.http.post(`${this.base}/images/products/${productId}`, fd);
|
||||
}
|
||||
|
||||
// --- Stock (quantité) — sans POST, uniquement PUT
|
||||
/** Lis la quantité (prend exactement la ligne stock_available utilisée par Presta) */
|
||||
getProductQuantity(productId: number) {
|
||||
// 1) on essaie d’abord de récupérer l’id SA via les associations du produit (le plus fiable)
|
||||
return this.http.get(`${this.base}/products/${productId}`, {responseType: 'text'}).pipe(
|
||||
switchMap(xml => {
|
||||
const m = xml.match(
|
||||
/<stock_availables[\s\S]*?<stock_available[^>]*>[\s\S]*?<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:\]\]>)?\s*<\/id>[\s\S]*?<\/stock_availables>/i
|
||||
);
|
||||
const saId = m ? +m[1] : null;
|
||||
if (saId) {
|
||||
// lire la ligne SA par son id → on lit quantity
|
||||
const params = new HttpParams()
|
||||
.set('display', '[id,quantity]')
|
||||
.set('filter[id]', `${saId}`)
|
||||
.set('output_format', 'JSON');
|
||||
return this.http.get<any>(`${this.base}/stock_availables`, {params}).pipe(
|
||||
map(r => {
|
||||
const sa = (r?.stock_availables ?? [])[0];
|
||||
return sa?.quantity != null ? +sa.quantity : 0;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 2) fallback: rechercher la ligne par id_product_attribute=0 (sans filtre shop → puis avec)
|
||||
let p1 = new HttpParams()
|
||||
.set('display', '[id,quantity,id_product_attribute]')
|
||||
.set('filter[id_product]', `${productId}`)
|
||||
.set('filter[id_product_attribute]', '0')
|
||||
.set('output_format', 'JSON');
|
||||
return this.http.get<any>(`${this.base}/stock_availables`, {params: p1}).pipe(
|
||||
switchMap(r => {
|
||||
const sa = (r?.stock_availables ?? [])[0];
|
||||
if (sa?.quantity != null) return of(+sa.quantity);
|
||||
|
||||
return this.getDefaultShopContext().pipe(
|
||||
switchMap(({idShop, idShopGroup}) => {
|
||||
const p2 = new HttpParams()
|
||||
.set('display', '[id,quantity,id_product_attribute]')
|
||||
.set('filter[id_product]', `${productId}`)
|
||||
.set('filter[id_product_attribute]', '0')
|
||||
.set('filter[id_shop]', `${idShop}`)
|
||||
.set('filter[id_shop_group]', `${idShopGroup}`)
|
||||
.set('output_format', 'JSON');
|
||||
|
||||
return this.http.get<any>(`${this.base}/stock_availables`, {params: p2}).pipe(
|
||||
map(r2 => {
|
||||
const sa2 = (r2?.stock_availables ?? [])[0];
|
||||
return sa2?.quantity != null ? +sa2.quantity : 0;
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** PUT quantité : on cible précisément la ligne SA (avec id_shop/id_shop_group réels) */
|
||||
private setProductQuantity(productId: number, quantity: number) {
|
||||
const q = Math.max(0, Math.trunc(quantity));
|
||||
|
||||
// Helper: fait un PUT en reprenant tous les champs utiles de la ligne SA existante
|
||||
const putFromRow = (row: any) => {
|
||||
const saId = +row.id;
|
||||
const idShop = row.id_shop ? +row.id_shop : undefined;
|
||||
const idShopGroup = row.id_shop_group ? +row.id_shop_group : undefined;
|
||||
|
||||
const extraShop =
|
||||
(idShop != null ? `<id_shop>${idShop}</id_shop>` : '') +
|
||||
(idShopGroup != null ? `<id_shop_group>${idShopGroup}</id_shop_group>` : '');
|
||||
|
||||
const xml = `<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<stock_available>
|
||||
<id>${saId}</id>
|
||||
<id_product>${productId}</id_product>
|
||||
<id_product_attribute>0</id_product_attribute>
|
||||
${extraShop}
|
||||
<quantity>${q}</quantity>
|
||||
<depends_on_stock>0</depends_on_stock>
|
||||
<out_of_stock>0</out_of_stock>
|
||||
</stock_available>
|
||||
</prestashop>`;
|
||||
|
||||
return this.http.put(`${this.base}/stock_availables/${saId}`, xml, {
|
||||
headers: this.headersXml,
|
||||
responseType: 'text'
|
||||
}).pipe(map(() => true));
|
||||
};
|
||||
|
||||
// 1) récupérer l’id SA via les associations du produit (le plus fiable)
|
||||
return this.http.get(`${this.base}/products/${productId}`, {responseType: 'text'}).pipe(
|
||||
switchMap(xml => {
|
||||
const m = xml.match(
|
||||
/<stock_availables[\s\S]*?<stock_available[^>]*>[\s\S]*?<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:\]\]>)?\s*<\/id>[\s\S]*?<\/stock_availables>/i
|
||||
);
|
||||
const saId = m ? +m[1] : null;
|
||||
if (saId) {
|
||||
const params = new HttpParams()
|
||||
.set('display', '[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]')
|
||||
.set('filter[id]', `${saId}`)
|
||||
.set('output_format', 'JSON');
|
||||
return this.http.get<any>(`${this.base}/stock_availables`, {params}).pipe(
|
||||
switchMap(r => {
|
||||
const row = (r?.stock_availables ?? [])[0];
|
||||
if (row?.id) return putFromRow(row);
|
||||
// si l’id renvoyé n’est pas lisible (peu probable), on bascule sur le fallback ci-dessous
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 2) fallback: rechercher la ligne par id_product_attribute=0 (sans filtre shop → puis avec)
|
||||
let p1 = new HttpParams()
|
||||
.set('display', '[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]')
|
||||
.set('filter[id_product]', `${productId}`)
|
||||
.set('filter[id_product_attribute]', '0')
|
||||
.set('output_format', 'JSON');
|
||||
|
||||
return this.http.get<any>(`${this.base}/stock_availables`, {params: p1}).pipe(
|
||||
switchMap(r => {
|
||||
const row = (r?.stock_availables ?? [])[0];
|
||||
if (row?.id) return putFromRow(row);
|
||||
|
||||
return this.getDefaultShopContext().pipe(
|
||||
switchMap(({idShop, idShopGroup}) => {
|
||||
const p2 = new HttpParams()
|
||||
.set('display', '[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]')
|
||||
.set('filter[id_product]', `${productId}`)
|
||||
.set('filter[id_product_attribute]', '0')
|
||||
.set('filter[id_shop]', `${idShop}`)
|
||||
.set('filter[id_shop_group]', `${idShopGroup}`)
|
||||
.set('output_format', 'JSON');
|
||||
|
||||
return this.http.get<any>(`${this.base}/stock_availables`, {params: p2}).pipe(
|
||||
switchMap(r2 => {
|
||||
const row2 = (r2?.stock_availables ?? [])[0];
|
||||
if (row2?.id) return putFromRow(row2);
|
||||
|
||||
console.warn('[Presta] Aucune ligne stock_available PUTtable trouvée pour product:', productId);
|
||||
return of(true); // on ne bloque pas le flux si la ligne est introuvable
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// --- Association fournisseur (product_suppliers)
|
||||
private findProductSupplierId(productId: number, supplierId: number) {
|
||||
const params = new HttpParams()
|
||||
.set('display', '[id,id_product,id_supplier,id_product_attribute]')
|
||||
.set('filter[id_product]', `${productId}`)
|
||||
.set('filter[id_supplier]', `${supplierId}`)
|
||||
.set('filter[id_product_attribute]', '0')
|
||||
.set('output_format', 'JSON');
|
||||
|
||||
return this.http.get<any>(`${this.base}/product_suppliers`, {params}).pipe(
|
||||
map(r => {
|
||||
const row = (r?.product_suppliers ?? [])[0];
|
||||
return row?.id ? +row.id : null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private upsertProductSupplier(productId: number, supplierId: number) {
|
||||
if (!supplierId) return of(true);
|
||||
|
||||
return forkJoin({
|
||||
curId: this.getDefaultCurrencyId(),
|
||||
psId: this.findProductSupplierId(productId, supplierId),
|
||||
}).pipe(
|
||||
switchMap(({curId, psId}) => {
|
||||
const body = (id?: number) => `<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<product_supplier>
|
||||
${id ? `<id>${id}</id>` : ''}
|
||||
<id_product>${productId}</id_product>
|
||||
<id_product_attribute>0</id_product_attribute>
|
||||
<id_supplier>${supplierId}</id_supplier>
|
||||
<id_currency>${curId}</id_currency>
|
||||
<product_supplier_reference></product_supplier_reference>
|
||||
<product_supplier_price_te>0</product_supplier_price_te>
|
||||
</product_supplier>
|
||||
</prestashop>`;
|
||||
|
||||
if (psId) {
|
||||
return this.http.put(`${this.base}/product_suppliers/${psId}`, body(psId), {
|
||||
headers: this.headersXml, responseType: 'text'
|
||||
}).pipe(map(() => true));
|
||||
} else {
|
||||
return this.http.post(`${this.base}/product_suppliers`, body(), {
|
||||
headers: this.headersXml, responseType: 'text'
|
||||
}).pipe(map(() => true));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// --- Create & Update produit
|
||||
private getProductForUpdate(id: number) {
|
||||
const params = new HttpParams().set('output_format', 'JSON').set('display', 'full');
|
||||
return this.http.get<any>(`${this.base}/products/${id}`, {params}).pipe(map(r => r?.product ?? r));
|
||||
}
|
||||
|
||||
createProduct(dto: PsProduct) {
|
||||
const priceHt = this.ttcToHt(dto.priceTtc, dto.vatRate);
|
||||
|
||||
return forkJoin({
|
||||
idLang: this.getDefaultLangId(),
|
||||
idTaxGroup: this.getDefaultTaxRulesGroupId(),
|
||||
shop: this.getDefaultShopContext(),
|
||||
homeCat: this.getHomeCategoryId(),
|
||||
rootCat: this.getRootCategoryId(),
|
||||
}).pipe(
|
||||
switchMap(({idLang, idTaxGroup, shop, homeCat, rootCat}) => {
|
||||
const desc = this.buildAugmentedDescription(dto);
|
||||
|
||||
const xml = `<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<product>
|
||||
<id_manufacturer>${dto.manufacturerId}</id_manufacturer>
|
||||
<id_supplier>${dto.supplierId}</id_supplier>
|
||||
<id_category_default>${dto.categoryId}</id_category_default>
|
||||
<id_shop_default>${shop.idShop}</id_shop_default>
|
||||
<id_tax_rules_group>${idTaxGroup}</id_tax_rules_group>
|
||||
|
||||
<type>standard</type>
|
||||
<product_type>standard</product_type>
|
||||
<state>1</state>
|
||||
<minimal_quantity>1</minimal_quantity>
|
||||
|
||||
<price>${priceHt}</price>
|
||||
<active>1</active>
|
||||
<visibility>both</visibility>
|
||||
<available_for_order>1</available_for_order>
|
||||
<show_price>1</show_price>
|
||||
<indexed>1</indexed>
|
||||
|
||||
<name><language id="${idLang}">${this.escapeXml(dto.name)}</language></name>
|
||||
<link_rewrite><language id="${idLang}">${this.escapeXml(this.slug(dto.name))}</language></link_rewrite>
|
||||
<description><language id="${idLang}">${this.escapeXml(desc)}</language></description>
|
||||
|
||||
<associations>
|
||||
<categories>
|
||||
<category><id>${dto.categoryId}</id></category>
|
||||
</categories>
|
||||
</associations>
|
||||
</product>
|
||||
</prestashop>`;
|
||||
|
||||
return this.http.post(`${this.base}/products`, xml, {headers: this.headersXml, responseType: 'text'})
|
||||
.pipe(map((res: string) => this.extractIdFromXml(res)));
|
||||
}),
|
||||
switchMap((productId) => {
|
||||
if (!productId) return of(null);
|
||||
const ops: Array<Observable<unknown>> = [];
|
||||
if (dto.images?.length) ops.push(forkJoin(dto.images.map(f => this.uploadProductImage(productId, f))));
|
||||
ops.push(this.setProductQuantity(productId, dto.quantity));
|
||||
if (dto.supplierId) ops.push(this.upsertProductSupplier(productId, dto.supplierId));
|
||||
return forkJoin(ops).pipe(map(() => productId));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateProduct(id: number, dto: PsProduct) {
|
||||
const priceHt = this.ttcToHt(dto.priceTtc, dto.vatRate);
|
||||
|
||||
return forkJoin({
|
||||
idLang: this.getDefaultLangId(),
|
||||
prod: this.getProductForUpdate(id),
|
||||
idTaxGroup: this.getDefaultTaxRulesGroupId(),
|
||||
shop: this.getDefaultShopContext(),
|
||||
homeCat: this.getHomeCategoryId(),
|
||||
rootCat: this.getRootCategoryId(),
|
||||
}).pipe(
|
||||
switchMap(({idLang, prod, idTaxGroup, shop, homeCat, rootCat}) => {
|
||||
const active = +prod?.active || 1;
|
||||
|
||||
const lr = Array.isArray(prod?.link_rewrite)
|
||||
? prod.link_rewrite.map((l: any) => ({id: +l.id, value: String(l.value ?? '')}))
|
||||
: [{id: idLang, value: this.slug(dto.name)}];
|
||||
|
||||
const lrXml = `<link_rewrite>${
|
||||
lr.map((e: {
|
||||
id: any;
|
||||
value: string;
|
||||
}) => `<language id="${e.id}">${this.escapeXml(e.value)}</language>`).join('')
|
||||
}</link_rewrite>`;
|
||||
|
||||
const desc = this.buildAugmentedDescription(dto);
|
||||
|
||||
const xml = `<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<product>
|
||||
<id>${id}</id>
|
||||
<active>${active}</active>
|
||||
|
||||
<id_manufacturer>${dto.manufacturerId}</id_manufacturer>
|
||||
<id_supplier>${dto.supplierId}</id_supplier>
|
||||
<id_category_default>${dto.categoryId}</id_category_default>
|
||||
<id_shop_default>${shop.idShop}</id_shop_default>
|
||||
<id_tax_rules_group>${idTaxGroup}</id_tax_rules_group>
|
||||
|
||||
<type>standard</type> <!-- NEW -->
|
||||
<product_type>standard</product_type><!-- NEW -->
|
||||
<state>1</state> <!-- NEW -->
|
||||
<minimal_quantity>1</minimal_quantity><!-- NEW -->
|
||||
|
||||
<price>${priceHt}</price>
|
||||
<visibility>both</visibility>
|
||||
<available_for_order>1</available_for_order>
|
||||
<show_price>1</show_price>
|
||||
<indexed>1</indexed>
|
||||
|
||||
<name><language id="${idLang}">${this.escapeXml(dto.name)}</language></name>
|
||||
${lrXml}
|
||||
<description><language id="${idLang}">${this.escapeXml(desc)}</language></description>
|
||||
|
||||
<associations>
|
||||
<categories>
|
||||
<category><id>${dto.categoryId}</id></category>
|
||||
</categories>
|
||||
</associations>
|
||||
</product>
|
||||
</prestashop>`;
|
||||
|
||||
return this.http.put(`${this.base}/products/${id}`, xml, {headers: this.headersXml, responseType: 'text'})
|
||||
.pipe(map(() => id));
|
||||
}),
|
||||
switchMap(productId => {
|
||||
const ops: Array<Observable<unknown>> = [this.setProductQuantity(productId, dto.quantity)];
|
||||
if (dto.supplierId) ops.push(this.upsertProductSupplier(productId, dto.supplierId));
|
||||
return forkJoin(ops).pipe(map(() => true));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
deleteProduct(id: number) {
|
||||
return this.http.delete(`${this.base}/products/${id}`, {responseType: 'text'}).pipe(map(() => true));
|
||||
}
|
||||
|
||||
// -------- Description augmentée
|
||||
private buildAugmentedDescription(dto: PsProduct): string {
|
||||
const parts: string[] = [];
|
||||
if (dto.description?.trim()) parts.push(dto.description.trim());
|
||||
|
||||
const flags: string[] = [];
|
||||
flags.push(`Complet: ${dto.complete ? 'Oui' : 'Non'}`);
|
||||
flags.push(`Notice: ${dto.hasManual ? 'Oui' : 'Non'}`);
|
||||
if (dto.conditionLabel) flags.push(`État: ${dto.conditionLabel}`);
|
||||
|
||||
parts.push('', flags.join(' | '));
|
||||
return parts.join('\n').trim();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
import {Product} from '../../interfaces/product';
|
||||
import {CrudService} from '../crud.service';
|
||||
import {Product} from '../interfaces/product';
|
||||
import {CrudService} from './crud.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -1,7 +1,7 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
import {CrudService} from '../crud.service';
|
||||
import {CrudService} from './crud.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
Reference in New Issue
Block a user