Add product cards management with CRUD functionality and update routing
This commit is contained in:
@@ -8,6 +8,7 @@ import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard
|
|||||||
import {authOnlyCanActivate, authOnlyCanMatch} from './guards/auth-only.guard';
|
import {authOnlyCanActivate, authOnlyCanMatch} from './guards/auth-only.guard';
|
||||||
import {PsAdminComponent} from './pages/admin/ps-admin/ps-admin.component';
|
import {PsAdminComponent} from './pages/admin/ps-admin/ps-admin.component';
|
||||||
import {ProductsComponent} from './pages/products/products.component';
|
import {ProductsComponent} from './pages/products/products.component';
|
||||||
|
import {ProductsCardsComponent} from './pages/product-cards/products-cards.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
@@ -48,6 +49,12 @@ export const routes: Routes = [
|
|||||||
canMatch: [adminOnlyCanMatch],
|
canMatch: [adminOnlyCanMatch],
|
||||||
canActivate: [adminOnlyCanActivate]
|
canActivate: [adminOnlyCanActivate]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'cards',
|
||||||
|
component: ProductsCardsComponent,
|
||||||
|
canMatch: [adminOnlyCanMatch],
|
||||||
|
canActivate: [adminOnlyCanActivate]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
component: PsAdminComponent,
|
component: PsAdminComponent,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
.crud {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .filter {
|
||||||
|
margin-left: auto;
|
||||||
|
min-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-elevation-z2 {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 800px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prod-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prod-thumb {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-list-root {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-list-loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-paginator {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.toolbar {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .filter {
|
||||||
|
order: 2;
|
||||||
|
margin-left: 0;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prod-thumb {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
min-width: 720px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<section class="crud">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
(click)="create()"
|
||||||
|
[disabled]="isLoading">
|
||||||
|
<mat-icon>add</mat-icon> Ajouter une carte
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (selection.hasValue()) {
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="warn"
|
||||||
|
(click)="deleteSelected()"
|
||||||
|
[disabled]="isLoading">
|
||||||
|
<mat-icon>delete</mat-icon> Supprimer la sélection
|
||||||
|
</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 product-list-root">
|
||||||
|
@if (isLoading) {
|
||||||
|
<div class="product-list-loading-overlay">
|
||||||
|
<mat-spinner diameter="48"></mat-spinner>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<table mat-table [dataSource]="dataSource" matSort>
|
||||||
|
|
||||||
|
<!-- select column -->
|
||||||
|
<ng-container matColumnDef="select">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>
|
||||||
|
<mat-checkbox
|
||||||
|
[checked]="isAllSelected()"
|
||||||
|
[indeterminate]="isAnySelected() && !isAllSelected()"
|
||||||
|
(change)="masterToggle($event.checked)"
|
||||||
|
[disabled]="isLoading">
|
||||||
|
</mat-checkbox>
|
||||||
|
</th>
|
||||||
|
<td mat-cell *matCellDef="let el">
|
||||||
|
<mat-checkbox
|
||||||
|
[checked]="selection.isSelected(el)"
|
||||||
|
(change)="toggleSelection(el, $event.checked)"
|
||||||
|
[disabled]="isLoading">
|
||||||
|
</mat-checkbox>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- existing columns follow (id, name, ...) -->
|
||||||
|
|
||||||
|
<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="condition">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>État</th>
|
||||||
|
<td mat-cell *matCellDef="let el">{{ el.conditionLabel }}</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="quantity">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Quantité</th>
|
||||||
|
<td mat-cell *matCellDef="let el">{{ el.quantity }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||||
|
<td mat-cell *matCellDef="let el">
|
||||||
|
<button mat-icon-button
|
||||||
|
aria-label="edit"
|
||||||
|
(click)="edit(el)"
|
||||||
|
[disabled]="isLoading">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button
|
||||||
|
color="warn"
|
||||||
|
aria-label="delete"
|
||||||
|
(click)="remove(el)"
|
||||||
|
[disabled]="isLoading">
|
||||||
|
<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"
|
||||||
|
[disabled]="isLoading">
|
||||||
|
</mat-paginator>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import {Component, ViewChild} from '@angular/core';
|
||||||
|
import {
|
||||||
|
MatCell,
|
||||||
|
MatCellDef,
|
||||||
|
MatColumnDef,
|
||||||
|
MatHeaderCell, MatHeaderCellDef,
|
||||||
|
MatHeaderRow,
|
||||||
|
MatHeaderRowDef, MatRow, MatRowDef,
|
||||||
|
MatTable
|
||||||
|
} from '@angular/material/table';
|
||||||
|
import {MatPaginator} from '@angular/material/paginator';
|
||||||
|
import {MatSort} from '@angular/material/sort';
|
||||||
|
import {FormBuilder, ReactiveFormsModule} from '@angular/forms';
|
||||||
|
import {PrestashopService} from '../../services/prestashop.serivce';
|
||||||
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
|
import {PsProductCrudBase} from '../ps-product-crud-base/ps-product-crud-base.component';
|
||||||
|
import {ProductListItem} from '../../interfaces/product-list-item';
|
||||||
|
import {DecimalPipe} from '@angular/common';
|
||||||
|
import {MatButton, MatIconButton} from '@angular/material/button';
|
||||||
|
import {MatCheckbox} from '@angular/material/checkbox';
|
||||||
|
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
||||||
|
import {MatIcon} from '@angular/material/icon';
|
||||||
|
import {MatProgressSpinner} from '@angular/material/progress-spinner';
|
||||||
|
import {PsProductCardDialogComponent} from '../ps-product-card-dialog/ps-product-card-dialog.component';
|
||||||
|
import {ProductDialogData} from '../ps-product-dialog/ps-product-dialog.component';
|
||||||
|
import {catchError, finalize, forkJoin, of} from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-ps-product-crud-cards',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './ps-product-card-crud.component.html',
|
||||||
|
imports: [
|
||||||
|
DecimalPipe,
|
||||||
|
MatButton,
|
||||||
|
MatCell,
|
||||||
|
MatCellDef,
|
||||||
|
MatCheckbox,
|
||||||
|
MatColumnDef,
|
||||||
|
MatFormField,
|
||||||
|
MatHeaderCell,
|
||||||
|
MatHeaderRow,
|
||||||
|
MatHeaderRowDef,
|
||||||
|
MatIcon,
|
||||||
|
MatIconButton,
|
||||||
|
MatInput,
|
||||||
|
MatLabel,
|
||||||
|
MatPaginator,
|
||||||
|
MatProgressSpinner,
|
||||||
|
MatRow,
|
||||||
|
MatRowDef,
|
||||||
|
MatSort,
|
||||||
|
MatTable,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatHeaderCellDef
|
||||||
|
],
|
||||||
|
styleUrls: ['./ps-product-card-crud.component.css']
|
||||||
|
})
|
||||||
|
export class PsProductCrudCardsComponent extends PsProductCrudBase {
|
||||||
|
override displayed = ['select', 'id', 'name', 'category', 'condition', 'priceTtc', 'quantity', 'actions'];
|
||||||
|
|
||||||
|
@ViewChild(MatPaginator) declare paginator?: MatPaginator;
|
||||||
|
@ViewChild(MatSort) declare sort?: MatSort;
|
||||||
|
@ViewChild(MatTable) declare table?: MatTable<any>;
|
||||||
|
|
||||||
|
private readonly targetCategory = 'Cartes Pokémon';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
fb: FormBuilder,
|
||||||
|
ps: PrestashopService,
|
||||||
|
dialog: MatDialog
|
||||||
|
) {
|
||||||
|
super(fb, ps, dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getProductFilter(): ((p: ProductListItem) => boolean) | undefined {
|
||||||
|
return (item: ProductListItem) => {
|
||||||
|
const catId = item.id_category_default;
|
||||||
|
if (!catId) return false;
|
||||||
|
const cname = this.catMap.get(catId);
|
||||||
|
return cname === this.targetCategory;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
override reload() {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.ps.listProducts()
|
||||||
|
.pipe(finalize(() => {
|
||||||
|
this.isLoading = false;
|
||||||
|
}))
|
||||||
|
.subscribe({
|
||||||
|
next: p => {
|
||||||
|
const arr = p ?? [];
|
||||||
|
const filterFn = this.getProductFilter();
|
||||||
|
const filtered = filterFn ? arr.filter(filterFn) : arr;
|
||||||
|
|
||||||
|
if (!filtered.length) {
|
||||||
|
this.bindProducts([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagsCalls = filtered.map(prod =>
|
||||||
|
this.ps.getProductFlags(prod.id).pipe(catchError(() => of(null)))
|
||||||
|
);
|
||||||
|
|
||||||
|
forkJoin(flagsCalls).subscribe({
|
||||||
|
next: flagsArr => {
|
||||||
|
const merged = filtered.map((prod, i) => ({...prod, flags: flagsArr[i]}));
|
||||||
|
this.bindProducts(merged as (ProductListItem & { priceHt?: number })[]);
|
||||||
|
},
|
||||||
|
error: err => {
|
||||||
|
console.error('Erreur lors du chargement des flags', err);
|
||||||
|
this.bindProducts(filtered as (ProductListItem & { priceHt?: number })[]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: err => {
|
||||||
|
console.error('Erreur lors du chargement des produits', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override create() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
const data: ProductDialogData = {
|
||||||
|
mode: 'create',
|
||||||
|
refs: {categories: this.categories, manufacturers: this.manufacturers, suppliers: this.suppliers}
|
||||||
|
};
|
||||||
|
this.dialog.open(PsProductCardDialogComponent, {width: '900px', data})
|
||||||
|
.afterClosed()
|
||||||
|
.subscribe(ok => {
|
||||||
|
if (ok) this.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override edit(row: ProductListItem & { priceHt?: number }) {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
const data: ProductDialogData = {
|
||||||
|
mode: 'edit',
|
||||||
|
productRow: row,
|
||||||
|
refs: {categories: this.categories, manufacturers: this.manufacturers, suppliers: this.suppliers}
|
||||||
|
};
|
||||||
|
this.dialog.open(PsProductCardDialogComponent, {width: '900px', data})
|
||||||
|
.afterClosed()
|
||||||
|
.subscribe(ok => {
|
||||||
|
if (ok) this.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Nouveau : carrousel ===== */
|
||||||
|
|
||||||
|
.carousel {
|
||||||
|
grid-column: span 12;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-main {
|
||||||
|
position: relative;
|
||||||
|
min-height: 220px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-main img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 280px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-nav-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-nav-btn.left {
|
||||||
|
left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-nav-btn.right {
|
||||||
|
right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-placeholder {
|
||||||
|
text-align: center;
|
||||||
|
color: #757575;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-placeholder mat-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bouton de suppression (croix rouge) */
|
||||||
|
.carousel-delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-delete-btn mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #e53935;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bandeau de vignettes */
|
||||||
|
|
||||||
|
.carousel-thumbs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-item {
|
||||||
|
position: relative;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden; /* tu peux laisser comme ça */
|
||||||
|
border: 2px solid transparent;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bouton de suppression sur les vignettes */
|
||||||
|
.thumb-delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
min-width: 18px;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
line-height: 18px;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-delete-btn mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
|
||||||
|
color: #e53935; /* rouge discret mais lisible */
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-item.active {
|
||||||
|
border-color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #eeeeee;
|
||||||
|
color: #575656;
|
||||||
|
border: 1px dashed #bdbdbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-placeholder mat-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-root {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay plein écran dans le dialog pendant la sauvegarde */
|
||||||
|
.dialog-loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<h2 mat-dialog-title>{{ mode === 'create' ? 'Nouvelle carte' : 'Modifier la carte' }}</h2>
|
||||||
|
|
||||||
|
<div class="dialog-root">
|
||||||
|
<!-- Overlay de chargement -->
|
||||||
|
@if (isSaving) {
|
||||||
|
<div class="dialog-loading-overlay">
|
||||||
|
<mat-spinner diameter="48"></mat-spinner>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div mat-dialog-content class="grid" [formGroup]="form">
|
||||||
|
|
||||||
|
<!-- CARROUSEL IMAGES -->
|
||||||
|
<div class="col-12 carousel">
|
||||||
|
<div class="carousel-main">
|
||||||
|
|
||||||
|
<!-- Bouton précédent -->
|
||||||
|
<button mat-icon-button
|
||||||
|
class="carousel-nav-btn left"
|
||||||
|
(click)="prev()"
|
||||||
|
[disabled]="carouselItems.length <= 1">
|
||||||
|
<mat-icon>chevron_left</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Image principale ou placeholder -->
|
||||||
|
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
|
||||||
|
<img [src]="carouselItems[currentIndex].src" alt="Produit">
|
||||||
|
} @else {
|
||||||
|
<div class="carousel-placeholder" (click)="fileInput.click()">
|
||||||
|
<mat-icon>add_photo_alternate</mat-icon>
|
||||||
|
<span>Ajouter des images</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Bouton suivant -->
|
||||||
|
<button mat-icon-button
|
||||||
|
class="carousel-nav-btn right"
|
||||||
|
(click)="next()"
|
||||||
|
[disabled]="carouselItems.length <= 1">
|
||||||
|
<mat-icon>chevron_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Bouton de suppression (croix rouge) -->
|
||||||
|
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
|
||||||
|
<button mat-icon-button
|
||||||
|
class="carousel-delete-btn"
|
||||||
|
(click)="onDeleteCurrentImage()">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Bouton suivant -->
|
||||||
|
<button mat-icon-button
|
||||||
|
class="carousel-nav-btn right"
|
||||||
|
(click)="next()"
|
||||||
|
[disabled]="carouselItems.length <= 1">
|
||||||
|
<mat-icon>chevron_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bandeau de vignettes -->
|
||||||
|
<div class="carousel-thumbs">
|
||||||
|
@for (item of carouselItems; let i = $index; track item) {
|
||||||
|
<div class="thumb-item"
|
||||||
|
[class.active]="i === currentIndex"
|
||||||
|
(click)="onThumbClick(i)">
|
||||||
|
@if (!item.isPlaceholder) {
|
||||||
|
|
||||||
|
<!-- Bouton suppression vignette -->
|
||||||
|
<button mat-icon-button
|
||||||
|
class="thumb-delete-btn"
|
||||||
|
(click)="onDeleteThumb(i, $event)">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<img class="thumb-img" [src]="item.src" alt="Vignette produit">
|
||||||
|
} @else {
|
||||||
|
<div class="thumb-placeholder" (click)="fileInput.click()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input réel, caché -->
|
||||||
|
<input #fileInput type="file" multiple hidden (change)="onFiles($event)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input pour le nom du produit -->
|
||||||
|
<mat-form-field class="col-12">
|
||||||
|
<mat-label>Nom de la carte</mat-label>
|
||||||
|
<input matInput formControlName="name" autocomplete="off">
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Textarea pour la description -->
|
||||||
|
<mat-form-field class="col-12">
|
||||||
|
<mat-label>Description</mat-label>
|
||||||
|
<textarea matInput rows="4" formControlName="description"></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Sélecteur pour la catégorie -->
|
||||||
|
<mat-form-field class="col-6">
|
||||||
|
<mat-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>
|
||||||
|
|
||||||
|
<!-- Sélecteur pour l'état du produit -->
|
||||||
|
<mat-form-field class="col-6">
|
||||||
|
<mat-label>État</mat-label>
|
||||||
|
<mat-select formControlName="conditionLabel">
|
||||||
|
@for (opt of conditionOptions; track opt) {
|
||||||
|
<mat-option [value]="opt">{{ opt }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Inputs pour le prix -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Input pour la quantité -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button
|
||||||
|
(click)="close()"
|
||||||
|
[disabled]="isSaving">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
(click)="save()"
|
||||||
|
[disabled]="form.invalid || isSaving">
|
||||||
|
@if (!isSaving) {
|
||||||
|
Enregistrer
|
||||||
|
} @else {
|
||||||
|
Enregistrement...
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import {Component, Inject, OnInit, inject, OnDestroy} 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, MatIconButton} from '@angular/material/button';
|
||||||
|
import {
|
||||||
|
MatDialogRef,
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogTitle
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import {MatIcon} from '@angular/material/icon';
|
||||||
|
|
||||||
|
import {catchError, forkJoin, of, Observable, finalize} from 'rxjs';
|
||||||
|
|
||||||
|
import {PsItem} from '../../interfaces/ps-item';
|
||||||
|
import {ProductListItem} from '../../interfaces/product-list-item';
|
||||||
|
import {PrestashopService} from '../../services/prestashop.serivce';
|
||||||
|
import {MatProgressSpinner} from '@angular/material/progress-spinner';
|
||||||
|
|
||||||
|
export type ProductDialogData = {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
refs: { categories: PsItem[]; manufacturers: PsItem[]; suppliers: PsItem[]; };
|
||||||
|
productRow?: ProductListItem & { priceHt?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type CarouselItem = { src: string; isPlaceholder: boolean };
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-ps-product-dialog',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './ps-product-card-dialog.component.html',
|
||||||
|
styleUrls: ['./ps-product-card-dialog.component.css'],
|
||||||
|
imports: [
|
||||||
|
CommonModule, ReactiveFormsModule,
|
||||||
|
MatFormField, MatLabel, MatInput, MatSelectModule,
|
||||||
|
MatButton, MatDialogActions, MatDialogContent, MatDialogTitle,
|
||||||
|
MatIcon, MatIconButton, MatProgressSpinner
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class PsProductCardDialogComponent implements OnInit, OnDestroy {
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly ps = inject(PrestashopService);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: ProductDialogData,
|
||||||
|
private readonly dialogRef: MatDialogRef<PsProductCardDialogComponent>
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving = false;
|
||||||
|
|
||||||
|
mode!: 'create' | 'edit';
|
||||||
|
categories: PsItem[] = [];
|
||||||
|
manufacturers: PsItem[] = [];
|
||||||
|
suppliers: PsItem[] = [];
|
||||||
|
productRow?: ProductListItem & { priceHt?: number };
|
||||||
|
|
||||||
|
images: File[] = [];
|
||||||
|
existingImageUrls: string[] = [];
|
||||||
|
|
||||||
|
// Previews des fichiers nouvellement sélectionnés
|
||||||
|
previewUrls: string[] = [];
|
||||||
|
|
||||||
|
// items du carrousel (existants + nouveaux + placeholder)
|
||||||
|
carouselItems: CarouselItem[] = [];
|
||||||
|
currentIndex = 0;
|
||||||
|
|
||||||
|
// options possibles pour l'état (Neuf, Très bon état, etc.)
|
||||||
|
conditionOptions: string[] = [];
|
||||||
|
|
||||||
|
// on conserve la dernière description chargée pour éviter l’écrasement à vide
|
||||||
|
private lastLoadedDescription = '';
|
||||||
|
|
||||||
|
form = this.fb.group({
|
||||||
|
name: ['', Validators.required],
|
||||||
|
description: [''],
|
||||||
|
categoryId: [null as number | null, Validators.required],
|
||||||
|
manufacturerId: [null as number | null],
|
||||||
|
supplierId: [null as number | null],
|
||||||
|
complete: [true],
|
||||||
|
hasManual: [false],
|
||||||
|
conditionLabel: ['', Validators.required],
|
||||||
|
priceTtc: [0, [Validators.required, Validators.min(0.1)]],
|
||||||
|
quantity: [0, [Validators.required, Validators.min(1)]],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Helpers locaux ----------
|
||||||
|
|
||||||
|
private toTtc(ht: number) {
|
||||||
|
return Math.round(((ht * 1.2) + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** normalisation simple pour comparaison de labels (insensible à casse/accents) */
|
||||||
|
private normalizeLabel(s: string): string {
|
||||||
|
return String(s ?? '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replaceAll(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** enlève <![CDATA[ ... ]]> si présent */
|
||||||
|
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 ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.mode = this.data.mode;
|
||||||
|
this.productRow = this.data.productRow;
|
||||||
|
|
||||||
|
// Les refs viennent déjà triées du service
|
||||||
|
const rawCategories = this.data.refs.categories ?? [];
|
||||||
|
const rawManufacturers = this.data.refs.manufacturers ?? [];
|
||||||
|
const rawSuppliers = this.data.refs.suppliers ?? [];
|
||||||
|
|
||||||
|
const forbiddenCats = new Set(['racine', 'root', 'accueil', 'home']);
|
||||||
|
|
||||||
|
// on filtre seulement ici, tri déjà fait côté service
|
||||||
|
this.categories = rawCategories.filter(
|
||||||
|
c => !forbiddenCats.has(this.normalizeLabel(c.name))
|
||||||
|
);
|
||||||
|
this.manufacturers = rawManufacturers;
|
||||||
|
this.suppliers = rawSuppliers;
|
||||||
|
|
||||||
|
// charger les valeurs possibles pour l’état (Neuf, Très bon état, etc.)
|
||||||
|
this.ps.getConditionValues()
|
||||||
|
.pipe(catchError(() => of<string[]>([])))
|
||||||
|
.subscribe((opts: string[]) => this.conditionOptions = opts.filter(o =>
|
||||||
|
this.normalizeLabel(o) !== this.normalizeLabel('Neuf') &&
|
||||||
|
this.normalizeLabel(o) !== this.normalizeLabel('Très bon état') &&
|
||||||
|
this.normalizeLabel(o) !== this.normalizeLabel('Bon état') &&
|
||||||
|
this.normalizeLabel(o) !== this.normalizeLabel('Mauvais état') &&
|
||||||
|
this.normalizeLabel(o) !== this.normalizeLabel('Très mauvais état')
|
||||||
|
));
|
||||||
|
|
||||||
|
// ---- Mode édition : pré-remplissage ----
|
||||||
|
if (this.mode === 'edit' && this.productRow) {
|
||||||
|
const r = this.productRow;
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
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[]>([])));
|
||||||
|
const flags$ = this.ps.getProductFlags(r.id).pipe(
|
||||||
|
catchError(() => of({complete: false, hasManual: false, conditionLabel: undefined}))
|
||||||
|
);
|
||||||
|
|
||||||
|
forkJoin({details: details$, qty: qty$, imgs: imgs$, flags: flags$})
|
||||||
|
.subscribe(({details, qty, imgs, flags}) => {
|
||||||
|
const ttc = this.toTtc(details.priceHt ?? 0);
|
||||||
|
const baseDesc = this.cleanForTextarea(details.description ?? '');
|
||||||
|
this.lastLoadedDescription = baseDesc;
|
||||||
|
|
||||||
|
this.form.patchValue({
|
||||||
|
description: baseDesc,
|
||||||
|
complete: flags.complete,
|
||||||
|
hasManual: flags.hasManual,
|
||||||
|
conditionLabel: flags.conditionLabel || '',
|
||||||
|
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;
|
||||||
|
this.buildCarousel();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// mode création : uniquement placeholder au début
|
||||||
|
this.buildCarousel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Carrousel / gestion des fichiers --------
|
||||||
|
|
||||||
|
onFiles(ev: Event) {
|
||||||
|
const fl = (ev.target as HTMLInputElement).files;
|
||||||
|
|
||||||
|
// Nettoyage des anciens objectURL
|
||||||
|
for (let url of this.previewUrls) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
this.previewUrls = [];
|
||||||
|
this.images = [];
|
||||||
|
|
||||||
|
if (fl) {
|
||||||
|
this.images = Array.from(fl);
|
||||||
|
this.previewUrls = this.images.map(f => URL.createObjectURL(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.buildCarousel();
|
||||||
|
|
||||||
|
// si on a ajouté des images, se placer sur la première nouvelle
|
||||||
|
if (this.images.length && this.existingImageUrls.length + this.previewUrls.length > 0) {
|
||||||
|
this.currentIndex = this.existingImageUrls.length; // première nouvelle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCarousel() {
|
||||||
|
const items: CarouselItem[] = [
|
||||||
|
...this.existingImageUrls.map(u => ({src: u, isPlaceholder: false})),
|
||||||
|
...this.previewUrls.map(u => ({src: u, isPlaceholder: false}))
|
||||||
|
];
|
||||||
|
|
||||||
|
// placeholder en dernier
|
||||||
|
items.push({src: '', isPlaceholder: true});
|
||||||
|
|
||||||
|
this.carouselItems = items;
|
||||||
|
if (!this.carouselItems.length) {
|
||||||
|
this.currentIndex = 0;
|
||||||
|
} else if (this.currentIndex >= this.carouselItems.length) {
|
||||||
|
this.currentIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prev() {
|
||||||
|
if (!this.carouselItems.length) return;
|
||||||
|
this.currentIndex = (this.currentIndex - 1 + this.carouselItems.length) % this.carouselItems.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
if (!this.carouselItems.length) return;
|
||||||
|
this.currentIndex = (this.currentIndex + 1) % this.carouselItems.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
onThumbClick(index: number) {
|
||||||
|
if (index < 0 || index >= this.carouselItems.length) return;
|
||||||
|
this.currentIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
// Nettoyage des objectURL
|
||||||
|
for (let url of this.previewUrls) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Save / close --------
|
||||||
|
|
||||||
|
save() {
|
||||||
|
if (this.form.invalid || this.isSaving) return;
|
||||||
|
|
||||||
|
this.isSaving = true;
|
||||||
|
this.dialogRef.disableClose = true;
|
||||||
|
|
||||||
|
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: v.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$
|
||||||
|
.pipe(
|
||||||
|
finalize(() => {
|
||||||
|
// si la boîte de dialogue est encore ouverte, on réactive tout
|
||||||
|
this.isSaving = false;
|
||||||
|
this.dialogRef.disableClose = false;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: () => this.dialogRef.close(true),
|
||||||
|
error: (e: unknown) =>
|
||||||
|
alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrait l'id_image depuis une URL FO Presta (.../img/p/.../<id>.jpg) */
|
||||||
|
private extractImageIdFromUrl(url: string): number | null {
|
||||||
|
const m = /\/(\d+)\.(?:jpg|jpeg|png|gif)$/i.exec(url);
|
||||||
|
if (!m) return null;
|
||||||
|
const id = Number(m[1]);
|
||||||
|
return Number.isFinite(id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suppression générique d'une image à l'index donné (carrousel + vignettes) */
|
||||||
|
private deleteImageAtIndex(idx: number) {
|
||||||
|
if (!this.carouselItems.length) return;
|
||||||
|
|
||||||
|
const item = this.carouselItems[idx];
|
||||||
|
if (!item || item.isPlaceholder) return;
|
||||||
|
|
||||||
|
const existingCount = this.existingImageUrls.length;
|
||||||
|
|
||||||
|
// --- Cas 1 : image existante (déjà chez Presta) ---
|
||||||
|
if (idx < existingCount) {
|
||||||
|
if (!this.productRow) return; // sécurité
|
||||||
|
|
||||||
|
const url = this.existingImageUrls[idx];
|
||||||
|
const imageId = this.extractImageIdFromUrl(url);
|
||||||
|
if (!imageId) {
|
||||||
|
alert('Impossible de déterminer l’ID de l’image à supprimer.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm('Supprimer cette image du produit ?')) return;
|
||||||
|
|
||||||
|
this.ps.deleteProductImage(this.productRow.id, imageId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
// On la retire du tableau local et on reconstruit le carrousel
|
||||||
|
this.existingImageUrls.splice(idx, 1);
|
||||||
|
this.buildCarousel();
|
||||||
|
|
||||||
|
// Repositionnement de l’index si nécessaire
|
||||||
|
if (this.currentIndex >= this.carouselItems.length - 1) {
|
||||||
|
this.currentIndex = Math.max(0, this.carouselItems.length - 2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e: unknown) => {
|
||||||
|
alert('Erreur lors de la suppression de l’image : ' + (e instanceof Error ? e.message : String(e)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cas 2 : image locale (nouvelle) ---
|
||||||
|
const localIdx = idx - existingCount;
|
||||||
|
if (localIdx >= 0 && localIdx < this.previewUrls.length) {
|
||||||
|
if (!confirm('Retirer cette image de la sélection ?')) return;
|
||||||
|
|
||||||
|
this.previewUrls.splice(localIdx, 1);
|
||||||
|
this.images.splice(localIdx, 1);
|
||||||
|
this.buildCarousel();
|
||||||
|
|
||||||
|
if (this.currentIndex >= this.carouselItems.length - 1) {
|
||||||
|
this.currentIndex = Math.max(0, this.carouselItems.length - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// utilisée par la grande image
|
||||||
|
onDeleteCurrentImage() {
|
||||||
|
if (!this.carouselItems.length) return;
|
||||||
|
this.deleteImageAtIndex(this.currentIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// utilisée par la croix sur une vignette
|
||||||
|
onDeleteThumb(index: number, event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.deleteImageAtIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.isSaving) return;
|
||||||
|
this.dialogRef.close(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import {Directive, OnInit} from '@angular/core';
|
||||||
|
import {MatTable, MatTableDataSource} from '@angular/material/table';
|
||||||
|
import {MatPaginator} from '@angular/material/paginator';
|
||||||
|
import {MatSort} from '@angular/material/sort';
|
||||||
|
import {FormBuilder, FormControl} from '@angular/forms';
|
||||||
|
import {finalize, forkJoin} from 'rxjs';
|
||||||
|
import {SelectionModel} from '@angular/cdk/collections';
|
||||||
|
import {PsItem} from '../../interfaces/ps-item';
|
||||||
|
import {ProductListItem} from '../../interfaces/product-list-item';
|
||||||
|
import {PrestashopService} from '../../services/prestashop.serivce';
|
||||||
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
|
import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/ps-product-dialog.component';
|
||||||
|
|
||||||
|
@Directive()
|
||||||
|
export abstract class PsProductCrudBase implements OnInit {
|
||||||
|
protected readonly fb: FormBuilder;
|
||||||
|
protected readonly ps: PrestashopService;
|
||||||
|
protected readonly dialog: MatDialog;
|
||||||
|
|
||||||
|
categories: PsItem[] = [];
|
||||||
|
manufacturers: PsItem[] = [];
|
||||||
|
suppliers: PsItem[] = [];
|
||||||
|
conditions: string[] = [];
|
||||||
|
|
||||||
|
protected catMap = new Map<number, string>();
|
||||||
|
protected manMap = new Map<number, string>();
|
||||||
|
protected supMap = new Map<number, string>();
|
||||||
|
protected conditionMap = new Map<number, string>();
|
||||||
|
|
||||||
|
displayed: string[] = ['select', 'id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'quantity', 'actions'];
|
||||||
|
dataSource = new MatTableDataSource<any>([]);
|
||||||
|
paginator?: MatPaginator;
|
||||||
|
sort?: MatSort;
|
||||||
|
table?: MatTable<any>;
|
||||||
|
|
||||||
|
selection = new SelectionModel<any>(true, []);
|
||||||
|
filterCtrl!: FormControl;
|
||||||
|
private _isLoading = false;
|
||||||
|
|
||||||
|
get isLoading(): boolean {
|
||||||
|
return this._isLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
set isLoading(v: boolean) {
|
||||||
|
this._isLoading = v;
|
||||||
|
|
||||||
|
if (this.filterCtrl) {
|
||||||
|
if (this._isLoading) this.filterCtrl.disable({emitEvent: false});
|
||||||
|
else this.filterCtrl.enable({emitEvent: false});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected constructor(fb: FormBuilder, ps: PrestashopService, dialog: MatDialog) {
|
||||||
|
this.fb = fb;
|
||||||
|
this.ps = ps;
|
||||||
|
this.dialog = dialog;
|
||||||
|
this.filterCtrl = this.fb.control<string>('');
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
forkJoin({
|
||||||
|
categories: this.ps.list('categories'),
|
||||||
|
manufacturers: this.ps.list('manufacturers'),
|
||||||
|
suppliers: this.ps.list('suppliers'),
|
||||||
|
conditions: this.ps.getConditionValues()
|
||||||
|
}).subscribe({
|
||||||
|
next: ({categories, manufacturers, suppliers, conditions}) => {
|
||||||
|
this.categories = categories ?? [];
|
||||||
|
this.catMap = new Map(this.categories.map(x => [x.id, x.name]));
|
||||||
|
this.manufacturers = manufacturers ?? [];
|
||||||
|
this.manMap = new Map(this.manufacturers.map(x => [x.id, x.name]));
|
||||||
|
this.suppliers = suppliers ?? [];
|
||||||
|
this.supMap = new Map(this.suppliers.map(x => [x.id, x.name]));
|
||||||
|
this.conditions = conditions ?? [];
|
||||||
|
this.conditionMap = new Map((conditions ?? []).map((c, i) => [i, c]));
|
||||||
|
this.reload();
|
||||||
|
},
|
||||||
|
error: err => {
|
||||||
|
console.error('Erreur lors du chargement des référentiels', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.filterCtrl.valueChanges.subscribe((v: any) => {
|
||||||
|
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)) ||
|
||||||
|
String(row.quantity ?? '').includes(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toTtc(ht: number, vat: number) {
|
||||||
|
return Math.round(((ht * (1 + vat)) + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected 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();
|
||||||
|
case 'condition':
|
||||||
|
return (item.conditionLabel ?? '').toLowerCase();
|
||||||
|
default:
|
||||||
|
return item[property];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (this.paginator) this.dataSource.paginator = this.paginator;
|
||||||
|
if (this.sort) this.dataSource.sort = this.sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected bindProducts(p: (ProductListItem & { priceHt?: number })[]) {
|
||||||
|
const vat = 0.2;
|
||||||
|
this.selection.clear();
|
||||||
|
|
||||||
|
// map des données
|
||||||
|
const mapped = p.map(x => {
|
||||||
|
const anyX = x as any;
|
||||||
|
let conditionLabel = '';
|
||||||
|
if (typeof anyX.conditionLabel === 'string' && anyX.conditionLabel) {
|
||||||
|
conditionLabel = anyX.conditionLabel;
|
||||||
|
} else if (typeof anyX.condition === 'number') {
|
||||||
|
conditionLabel = this.conditionMap.get(anyX.condition) ?? '';
|
||||||
|
} else if (typeof anyX.conditionValue === 'number') {
|
||||||
|
conditionLabel = this.conditionMap.get(anyX.conditionValue) ?? '';
|
||||||
|
} else if (anyX.flags && typeof anyX.flags.conditionLabel === 'string') {
|
||||||
|
conditionLabel = anyX.flags.conditionLabel;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...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),
|
||||||
|
conditionLabel
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// appliquer les données
|
||||||
|
this.dataSource.data = mapped;
|
||||||
|
|
||||||
|
// réaffecter paginator/sort (surtout utile si viewChild n'était pas encore attaché)
|
||||||
|
if (this.paginator) this.dataSource.paginator = this.paginator;
|
||||||
|
if (this.sort) this.dataSource.sort = this.sort;
|
||||||
|
|
||||||
|
// (re)appliquer le filtre courant pour éviter qu'un filtre persistant masque les lignes
|
||||||
|
this.dataSource.filter = (this.filterCtrl?.value ?? '').toString().trim().toLowerCase();
|
||||||
|
|
||||||
|
// remettre les accessors/liaisons et forcer le rendu de la table après le cycle de détection
|
||||||
|
this.attachSortingAccessors();
|
||||||
|
// petit délai pour laisser Angular attacher les ViewChild avant renderRows
|
||||||
|
setTimeout(() => {
|
||||||
|
this.table?.renderRows?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getProductFilter(): ((p: ProductListItem) => boolean) | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.ps.listProducts()
|
||||||
|
.pipe(finalize(() => {
|
||||||
|
this.isLoading = false;
|
||||||
|
}))
|
||||||
|
.subscribe({
|
||||||
|
next: p => {
|
||||||
|
const arr = p ?? [];
|
||||||
|
const filterFn = this.getProductFilter();
|
||||||
|
const filtered = filterFn ? arr.filter(filterFn) : arr;
|
||||||
|
this.bindProducts(filtered as (ProductListItem & { priceHt?: number })[]);
|
||||||
|
},
|
||||||
|
error: err => {
|
||||||
|
console.error('Erreur lors du chargement des produits', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
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 }) {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
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 (this.isLoading) return;
|
||||||
|
if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return;
|
||||||
|
this.isLoading = true;
|
||||||
|
this.ps.deleteProduct(row.id).pipe(finalize(() => {
|
||||||
|
})).subscribe({
|
||||||
|
next: () => this.reload(),
|
||||||
|
error: (e: unknown) => {
|
||||||
|
this.isLoading = false;
|
||||||
|
alert('Erreur: ' + (e instanceof Error ? e.message : String(e)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// selection helpers (identiques)
|
||||||
|
protected getVisibleRows(): any[] {
|
||||||
|
const data = this.dataSource.filteredData || [];
|
||||||
|
if (!this.paginator) return data;
|
||||||
|
const start = this.paginator.pageIndex * this.paginator.pageSize;
|
||||||
|
return data.slice(start, start + this.paginator.pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
isAllSelected(): boolean {
|
||||||
|
const visible = this.getVisibleRows();
|
||||||
|
return visible.length > 0 && visible.every(r => this.selection.isSelected(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
isAnySelected(): boolean {
|
||||||
|
return this.selection.hasValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
masterToggle(checked: boolean) {
|
||||||
|
const visible = this.getVisibleRows();
|
||||||
|
if (checked) visible.forEach(r => this.selection.select(r));
|
||||||
|
else visible.forEach(r => this.selection.deselect(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelection(row: any, checked: boolean) {
|
||||||
|
if (checked) this.selection.select(row);
|
||||||
|
else this.selection.deselect(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSelected() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
const ids = this.selection.selected.map(s => s.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
if (!confirm(`Supprimer ${ids.length} produit(s) sélectionné(s) ?`)) return;
|
||||||
|
this.isLoading = true;
|
||||||
|
const calls = ids.map((id: number) => this.ps.deleteProduct(id));
|
||||||
|
forkJoin(calls).pipe(finalize(() => {
|
||||||
|
})).subscribe({
|
||||||
|
next: () => this.reload(),
|
||||||
|
error: (e: unknown) => {
|
||||||
|
this.isLoading = false;
|
||||||
|
alert('Erreur: ' + (e instanceof Error ? e.message : String(e)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,27 @@
|
|||||||
<section class="crud">
|
<section class="crud">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button mat-raised-button
|
<button mat-raised-button color="primary" (click)="create()" [disabled]="isLoading">
|
||||||
color="primary"
|
<mat-icon>add</mat-icon> Ajouter un produit
|
||||||
(click)="create()"
|
|
||||||
[disabled]="isLoading">
|
|
||||||
<mat-icon>add</mat-icon> Nouveau produit
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (selection.hasValue()) {
|
@if (selection.hasValue()) {
|
||||||
<button
|
<button mat-raised-button color="warn" (click)="deleteSelected()" [disabled]="isLoading">
|
||||||
mat-raised-button
|
|
||||||
color="warn"
|
|
||||||
(click)="deleteSelected()"
|
|
||||||
[disabled]="isLoading">
|
|
||||||
<mat-icon>delete</mat-icon> Supprimer la sélection
|
<mat-icon>delete</mat-icon> Supprimer la sélection
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="filter">
|
<mat-form-field appearance="outline" class="filter">
|
||||||
<mat-label>Filtrer</mat-label>
|
<mat-label>Filtrer</mat-label>
|
||||||
<input matInput
|
<input matInput [formControl]="filterCtrl" placeholder="Nom, ID, catégorie, marque, fournisseur…">
|
||||||
[formControl]="filterCtrl"
|
|
||||||
placeholder="Nom, ID, catégorie, marque, fournisseur…"
|
|
||||||
[disabled]="isLoading">
|
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mat-elevation-z2 product-list-root">
|
<div class="mat-elevation-z2 product-list-root">
|
||||||
@if (isLoading) {
|
@if (isLoading) {
|
||||||
<div class="product-list-loading-overlay">
|
<div class="product-list-loading-overlay">
|
||||||
<mat-spinner diameter="48"></mat-spinner>
|
<mat-spinner diameter="48"></mat-spinner>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<table mat-table [dataSource]="dataSource" matSort>
|
<table mat-table [dataSource]="dataSource" matSort>
|
||||||
|
|
||||||
<!-- select column -->
|
<!-- select column -->
|
||||||
@@ -53,61 +43,76 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- existing columns follow (id, name, ...) -->
|
<!-- colonne 'id' -->
|
||||||
|
@if (displayed.includes('id')) {
|
||||||
|
<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="id">
|
<!-- colonne 'name' -->
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>ID</th>
|
@if (displayed.includes('name')) {
|
||||||
<td mat-cell *matCellDef="let el">{{ el.id }}</td>
|
<ng-container matColumnDef="name">
|
||||||
</ng-container>
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Nom</th>
|
||||||
|
<td mat-cell *matCellDef="let el">{{ el.name }}</td>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
|
||||||
<ng-container matColumnDef="name">
|
<!-- colonne 'category' -->
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Nom</th>
|
@if (displayed.includes('category')) {
|
||||||
<td mat-cell *matCellDef="let el">{{ el.name }}</td>
|
<ng-container matColumnDef="category">
|
||||||
</ng-container>
|
<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="category">
|
<!-- colonne 'manufacturer' (conditionnelle) -->
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Catégorie</th>
|
@if (displayed.includes('manufacturer')) {
|
||||||
<td mat-cell *matCellDef="let el">{{ el.categoryName }}</td>
|
<ng-container matColumnDef="manufacturer">
|
||||||
</ng-container>
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Marque</th>
|
||||||
|
<td mat-cell *matCellDef="let el">{{ el.manufacturerName }}</td>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
|
||||||
<ng-container matColumnDef="manufacturer">
|
<!-- colonne 'supplier' (conditionnelle) -->
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Marque</th>
|
@if (displayed.includes('supplier')) {
|
||||||
<td mat-cell *matCellDef="let el">{{ el.manufacturerName }}</td>
|
<ng-container matColumnDef="supplier">
|
||||||
</ng-container>
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Fournisseur</th>
|
||||||
|
<td mat-cell *matCellDef="let el">{{ el.supplierName }}</td>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
|
||||||
<ng-container matColumnDef="supplier">
|
<!-- colonne 'priceTtc' -->
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Fournisseur</th>
|
@if (displayed.includes('priceTtc')) {
|
||||||
<td mat-cell *matCellDef="let el">{{ el.supplierName }}</td>
|
<ng-container matColumnDef="priceTtc">
|
||||||
</ng-container>
|
<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="priceTtc">
|
<!-- colonne 'quantity' -->
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Prix TTC (€)</th>
|
@if (displayed.includes('quantity')) {
|
||||||
<td mat-cell *matCellDef="let el">{{ el.priceTtc | number:'1.2-2' }}</td>
|
<ng-container matColumnDef="quantity">
|
||||||
</ng-container>
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Quantité</th>
|
||||||
|
<td mat-cell *matCellDef="let el">{{ el.quantity }}</td>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
|
||||||
<ng-container matColumnDef="quantity">
|
<!-- colonne 'actions' -->
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Quantité</th>
|
@if (displayed.includes('actions')) {
|
||||||
<td mat-cell *matCellDef="let el">{{ el.quantity }}</td>
|
<ng-container matColumnDef="actions">
|
||||||
</ng-container>
|
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||||
|
<td mat-cell *matCellDef="let el">
|
||||||
<ng-container matColumnDef="actions">
|
<button mat-icon-button aria-label="edit" (click)="edit(el)" [disabled]="isLoading">
|
||||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
<mat-icon>edit</mat-icon>
|
||||||
<td mat-cell *matCellDef="let el">
|
</button>
|
||||||
<button mat-icon-button
|
<button mat-icon-button color="warn" aria-label="delete" (click)="remove(el)" [disabled]="isLoading">
|
||||||
aria-label="edit"
|
<mat-icon>delete</mat-icon>
|
||||||
(click)="edit(el)"
|
</button>
|
||||||
[disabled]="isLoading">
|
</td>
|
||||||
<mat-icon>edit</mat-icon>
|
</ng-container>
|
||||||
</button>
|
}
|
||||||
<button mat-icon-button
|
|
||||||
color="warn"
|
|
||||||
aria-label="delete"
|
|
||||||
(click)="remove(el)"
|
|
||||||
[disabled]="isLoading">
|
|
||||||
<mat-icon>delete</mat-icon>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="displayed"></tr>
|
<tr mat-header-row *matHeaderRowDef="displayed"></tr>
|
||||||
<tr mat-row *matRowDef="let row; columns: displayed;"></tr>
|
<tr mat-row *matRowDef="let row; columns: displayed;"></tr>
|
||||||
|
|||||||
@@ -1,27 +1,18 @@
|
|||||||
import {Component, inject, OnInit, ViewChild} from '@angular/core';
|
import {Component, ViewChild} from '@angular/core';
|
||||||
import {CommonModule} from '@angular/common';
|
import {CommonModule} from '@angular/common';
|
||||||
import {
|
import {MatTable, MatTableModule} from '@angular/material/table';
|
||||||
MatCell, MatCellDef, MatColumnDef, MatHeaderCell, MatHeaderCellDef,
|
import {MatPaginator} from '@angular/material/paginator';
|
||||||
MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef,
|
import {MatSort} from '@angular/material/sort';
|
||||||
MatNoDataRow, MatTable, MatTableDataSource
|
import {ReactiveFormsModule, FormsModule, FormBuilder} from '@angular/forms';
|
||||||
} 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, FormsModule} from '@angular/forms';
|
|
||||||
import {MatDialog, MatDialogModule} from '@angular/material/dialog';
|
import {MatDialog, MatDialogModule} from '@angular/material/dialog';
|
||||||
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
|
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
|
||||||
import {forkJoin, finalize} from 'rxjs';
|
|
||||||
import {SelectionModel} from '@angular/cdk/collections';
|
|
||||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||||
|
import {MatButton, MatIconButton} from '@angular/material/button';
|
||||||
|
import {MatIcon} from '@angular/material/icon';
|
||||||
|
|
||||||
import {PsItem} from '../../interfaces/ps-item';
|
|
||||||
import {ProductListItem} from '../../interfaces/product-list-item';
|
|
||||||
import {PrestashopService} from '../../services/prestashop.serivce';
|
import {PrestashopService} from '../../services/prestashop.serivce';
|
||||||
import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/ps-product-dialog.component';
|
import {PsProductCrudBase} from '../ps-product-crud-base/ps-product-crud-base.component';
|
||||||
|
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-ps-product-crud',
|
selector: 'app-ps-product-crud',
|
||||||
@@ -29,238 +20,21 @@ import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/
|
|||||||
templateUrl: './ps-product-crud.component.html',
|
templateUrl: './ps-product-crud.component.html',
|
||||||
styleUrls: ['./ps-product-crud.component.css'],
|
styleUrls: ['./ps-product-crud.component.css'],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule, ReactiveFormsModule, FormsModule,
|
CommonModule, ReactiveFormsModule, FormsModule, MatTableModule,
|
||||||
MatTable, MatColumnDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef,
|
MatIconButton, MatIcon,
|
||||||
MatHeaderCell, MatHeaderCellDef, MatCell, MatCellDef, MatNoDataRow,
|
MatDialogModule, MatProgressSpinnerModule, MatCheckboxModule, MatFormField, MatButton, MatLabel, MatInput, MatSort, MatPaginator
|
||||||
MatSortModule, MatPaginatorModule,
|
|
||||||
MatFormField, MatLabel, MatInput,
|
|
||||||
MatButton, MatIconButton, MatIcon,
|
|
||||||
MatDialogModule,
|
|
||||||
MatProgressSpinnerModule,
|
|
||||||
MatCheckboxModule
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PsProductCrudComponent implements OnInit {
|
export class PsProductCrudComponent extends PsProductCrudBase {
|
||||||
private readonly fb = inject(FormBuilder);
|
@ViewChild(MatPaginator) declare paginator?: MatPaginator;
|
||||||
private readonly ps = inject(PrestashopService);
|
@ViewChild(MatSort) declare sort?: MatSort;
|
||||||
private readonly dialog = inject(MatDialog);
|
@ViewChild(MatTable) declare table?: MatTable<any>;
|
||||||
|
|
||||||
categories: PsItem[] = [];
|
constructor(
|
||||||
manufacturers: PsItem[] = [];
|
fb: FormBuilder,
|
||||||
suppliers: PsItem[] = [];
|
ps: PrestashopService,
|
||||||
|
dialog: MatDialog
|
||||||
private catMap = new Map<number, string>();
|
) {
|
||||||
private manMap = new Map<number, string>();
|
super(fb, ps, dialog);
|
||||||
private supMap = new Map<number, string>();
|
|
||||||
|
|
||||||
// added 'select' column first
|
|
||||||
displayed: string[] = ['select', 'id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'quantity', 'actions'];
|
|
||||||
dataSource = new MatTableDataSource<any>([]);
|
|
||||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
|
||||||
@ViewChild(MatSort) sort!: MatSort;
|
|
||||||
@ViewChild(MatTable) table!: MatTable<any>;
|
|
||||||
|
|
||||||
// selection model (multiple)
|
|
||||||
selection = new SelectionModel<any>(true, []);
|
|
||||||
|
|
||||||
filterCtrl = this.fb.control<string>('');
|
|
||||||
|
|
||||||
isLoading = false;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
forkJoin({
|
|
||||||
cats: this.ps.list('categories'),
|
|
||||||
mans: this.ps.list('manufacturers'),
|
|
||||||
sups: this.ps.list('suppliers')
|
|
||||||
}).subscribe({
|
|
||||||
next: ({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();
|
|
||||||
},
|
|
||||||
error: err => {
|
|
||||||
console.error('Erreur lors du chargement des référentiels', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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)) ||
|
|
||||||
String(row.quantity ?? '').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.2;
|
|
||||||
// clear selection because objects will be new after reload
|
|
||||||
this.selection.clear();
|
|
||||||
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.isLoading = true;
|
|
||||||
this.ps.listProducts()
|
|
||||||
.pipe(
|
|
||||||
finalize(() => {
|
|
||||||
this.isLoading = false;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe({
|
|
||||||
next: p => this.bindProducts(p),
|
|
||||||
error: err => {
|
|
||||||
console.error('Erreur lors du chargement des produits', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
create() {
|
|
||||||
if (this.isLoading) return;
|
|
||||||
|
|
||||||
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 }) {
|
|
||||||
if (this.isLoading) return;
|
|
||||||
|
|
||||||
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 (this.isLoading) return;
|
|
||||||
if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return;
|
|
||||||
|
|
||||||
this.isLoading = true;
|
|
||||||
this.ps.deleteProduct(row.id)
|
|
||||||
.pipe(
|
|
||||||
finalize(() => {
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe({
|
|
||||||
next: () => this.reload(),
|
|
||||||
error: (e: unknown) => {
|
|
||||||
this.isLoading = false;
|
|
||||||
alert('Erreur: ' + (e instanceof Error ? e.message : String(e)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Selection helpers ---
|
|
||||||
|
|
||||||
private getVisibleRows(): any[] {
|
|
||||||
const data = this.dataSource.filteredData || [];
|
|
||||||
if (!this.paginator) return data;
|
|
||||||
const start = this.paginator.pageIndex * this.paginator.pageSize;
|
|
||||||
return data.slice(start, start + this.paginator.pageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
isAllSelected(): boolean {
|
|
||||||
const visible = this.getVisibleRows();
|
|
||||||
return visible.length > 0 && visible.every(r => this.selection.isSelected(r));
|
|
||||||
}
|
|
||||||
|
|
||||||
isAnySelected(): boolean {
|
|
||||||
return this.selection.hasValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
masterToggle(checked: boolean) {
|
|
||||||
const visible = this.getVisibleRows();
|
|
||||||
if (checked) {
|
|
||||||
visible.forEach(r => this.selection.select(r));
|
|
||||||
} else {
|
|
||||||
visible.forEach(r => this.selection.deselect(r));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSelection(row: any, checked: boolean) {
|
|
||||||
if (checked) this.selection.select(row);
|
|
||||||
else this.selection.deselect(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteSelected() {
|
|
||||||
if (this.isLoading) return;
|
|
||||||
const ids = this.selection.selected.map(s => s.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
if (!confirm(`Supprimer ${ids.length} produit(s) sélectionné(s) ?`)) return;
|
|
||||||
|
|
||||||
this.isLoading = true;
|
|
||||||
const calls = ids.map((id: number) => this.ps.deleteProduct(id));
|
|
||||||
forkJoin(calls).pipe(finalize(() => {
|
|
||||||
// nothing extra, reload will clear selection
|
|
||||||
})).subscribe({
|
|
||||||
next: () => this.reload(),
|
|
||||||
error: (e: unknown) => {
|
|
||||||
this.isLoading = false;
|
|
||||||
alert('Erreur: ' + (e instanceof Error ? e.message : String(e)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,10 +143,17 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
|||||||
this.manufacturers = rawManufacturers;
|
this.manufacturers = rawManufacturers;
|
||||||
this.suppliers = rawSuppliers;
|
this.suppliers = rawSuppliers;
|
||||||
|
|
||||||
// charger les valeurs possibles pour l’état (Neuf, Très bon état, etc.)
|
|
||||||
this.ps.getConditionValues()
|
this.ps.getConditionValues()
|
||||||
.pipe(catchError(() => of<string[]>([])))
|
.pipe(catchError(() => of<string[]>([])))
|
||||||
.subscribe((opts: string[]) => this.conditionOptions = opts);
|
.subscribe((opts: string[]) => this.conditionOptions = opts.filter(o =>
|
||||||
|
this.normalizeLabel(o) !== this.normalizeLabel('Mint') &&
|
||||||
|
this.normalizeLabel(o) !== this.normalizeLabel('Near Mint') &&
|
||||||
|
this.normalizeLabel(o) !== this.normalizeLabel('Excellent') &&
|
||||||
|
this.normalizeLabel(o) !== this.normalizeLabel('Good') &&
|
||||||
|
this.normalizeLabel(o) !== this.normalizeLabel('Light Played') &&
|
||||||
|
this.normalizeLabel(o) !== this.normalizeLabel('Played') &&
|
||||||
|
this.normalizeLabel(o) !== this.normalizeLabel('Poor')
|
||||||
|
));
|
||||||
|
|
||||||
// ---- Mode édition : pré-remplissage ----
|
// ---- Mode édition : pré-remplissage ----
|
||||||
if (this.mode === 'edit' && this.productRow) {
|
if (this.mode === 'edit' && this.productRow) {
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export interface ProductListItem {
|
|||||||
id_manufacturer?: number;
|
id_manufacturer?: number;
|
||||||
id_supplier?: number;
|
id_supplier?: number;
|
||||||
id_category_default?: number;
|
id_category_default?: number;
|
||||||
|
condition?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
<br>
|
<br>
|
||||||
<div class="home-actions">
|
<div class="home-actions">
|
||||||
<button mat-flat-button [routerLink]="'/products'">Voir la liste des produits</button>
|
<button mat-flat-button [routerLink]="'/products'">Voir la liste des produits</button>
|
||||||
<button mat-raised-button [routerLink]="'/admin'">Gérer la base de données</button>
|
<button mat-raised-button [routerLink]="'/cards'">Voir la liste des cartes Pokémon</button>
|
||||||
|
<!-- <button mat-raised-button [routerLink]="'/admin'">Gérer la base de données</button> -->
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<h2>Gestion des produits</h2>
|
<h2>Gestion des produits</h2>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.wrap {
|
||||||
|
padding: 16px;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: auto
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<section class="wrap">
|
||||||
|
<h2>Gestion des cartes Pokémon</h2>
|
||||||
|
<app-ps-product-crud-cards></app-ps-product-crud-cards>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import {PsProductCrudCardsComponent} from '../../components/ps-product-card-crud/ps-product-card-crud.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-products-cards',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
PsProductCrudCardsComponent
|
||||||
|
],
|
||||||
|
templateUrl: './products-cards.component.html',
|
||||||
|
styleUrl: './products-cards.component.css'
|
||||||
|
})
|
||||||
|
export class ProductsCardsComponent {
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user