Compare commits
3 Commits
b79068623f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0844b41f | ||
|
|
e0beed6c6e | ||
|
|
1a111b420d |
@@ -8,6 +8,7 @@ import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard
|
||||
import {authOnlyCanActivate, authOnlyCanMatch} from './guards/auth-only.guard';
|
||||
import {PsAdminComponent} from './pages/admin/ps-admin/ps-admin.component';
|
||||
import {ProductsComponent} from './pages/products/products.component';
|
||||
import {ProductsCardsComponent} from './pages/product-cards/products-cards.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -48,6 +49,12 @@ export const routes: Routes = [
|
||||
canMatch: [adminOnlyCanMatch],
|
||||
canActivate: [adminOnlyCanActivate]
|
||||
},
|
||||
{
|
||||
path: 'cards',
|
||||
component: ProductsCardsComponent,
|
||||
canMatch: [adminOnlyCanMatch],
|
||||
canActivate: [adminOnlyCanActivate]
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
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,83 +1,119 @@
|
||||
<section class="crud">
|
||||
<div class="toolbar">
|
||||
<button mat-raised-button
|
||||
color="primary"
|
||||
(click)="create()"
|
||||
[disabled]="isLoading">
|
||||
<mat-icon>add</mat-icon> Nouveau produit
|
||||
<button mat-raised-button color="primary" (click)="create()" [disabled]="isLoading">
|
||||
<mat-icon>add</mat-icon> Ajouter un produit
|
||||
</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…"
|
||||
[disabled]="isLoading">
|
||||
<input matInput [formControl]="filterCtrl" placeholder="Nom, ID, catégorie, marque, fournisseur…">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="mat-elevation-z2 product-list-root">
|
||||
<!-- Overlay de chargement -->
|
||||
<div class="product-list-loading-overlay" *ngIf="isLoading">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
|
||||
@if (isLoading) {
|
||||
<div class="product-list-loading-overlay">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
}
|
||||
<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="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>
|
||||
<!-- 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">
|
||||
<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>
|
||||
<mat-checkbox
|
||||
[checked]="selection.isSelected(el)"
|
||||
(change)="toggleSelection(el, $event.checked)"
|
||||
[disabled]="isLoading">
|
||||
</mat-checkbox>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- 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>
|
||||
}
|
||||
|
||||
<!-- colonne 'name' -->
|
||||
@if (displayed.includes('name')) {
|
||||
<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>
|
||||
}
|
||||
|
||||
<!-- colonne 'category' -->
|
||||
@if (displayed.includes('category')) {
|
||||
<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>
|
||||
}
|
||||
|
||||
<!-- colonne 'manufacturer' (conditionnelle) -->
|
||||
@if (displayed.includes('manufacturer')) {
|
||||
<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>
|
||||
}
|
||||
|
||||
<!-- colonne 'supplier' (conditionnelle) -->
|
||||
@if (displayed.includes('supplier')) {
|
||||
<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>
|
||||
}
|
||||
|
||||
<!-- colonne 'priceTtc' -->
|
||||
@if (displayed.includes('priceTtc')) {
|
||||
<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>
|
||||
}
|
||||
|
||||
<!-- colonne 'quantity' -->
|
||||
@if (displayed.includes('quantity')) {
|
||||
<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>
|
||||
}
|
||||
|
||||
<!-- colonne 'actions' -->
|
||||
@if (displayed.includes('actions')) {
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import {Component, inject, OnInit, ViewChild} from '@angular/core';
|
||||
import {Component, ViewChild} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {
|
||||
MatCell, MatCellDef, MatColumnDef, MatHeaderCell, MatHeaderCellDef,
|
||||
MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef,
|
||||
MatNoDataRow, 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 {MatTable, MatTableModule} from '@angular/material/table';
|
||||
import {MatPaginator} from '@angular/material/paginator';
|
||||
import {MatSort} from '@angular/material/sort';
|
||||
import {ReactiveFormsModule, FormsModule, FormBuilder} from '@angular/forms';
|
||||
import {MatDialog, MatDialogModule} from '@angular/material/dialog';
|
||||
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
|
||||
import {forkJoin, finalize} from 'rxjs';
|
||||
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 {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({
|
||||
selector: 'app-ps-product-crud',
|
||||
@@ -27,180 +20,21 @@ import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/
|
||||
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,
|
||||
MatProgressSpinnerModule
|
||||
CommonModule, ReactiveFormsModule, FormsModule, MatTableModule,
|
||||
MatIconButton, MatIcon,
|
||||
MatDialogModule, MatProgressSpinnerModule, MatCheckboxModule, MatFormField, MatButton, MatLabel, MatInput, MatSort, MatPaginator
|
||||
]
|
||||
})
|
||||
export class PsProductCrudComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly ps = inject(PrestashopService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
export class PsProductCrudComponent extends PsProductCrudBase {
|
||||
@ViewChild(MatPaginator) declare paginator?: MatPaginator;
|
||||
@ViewChild(MatSort) declare sort?: MatSort;
|
||||
@ViewChild(MatTable) declare table?: MatTable<any>;
|
||||
|
||||
categories: PsItem[] = [];
|
||||
manufacturers: PsItem[] = [];
|
||||
suppliers: PsItem[] = [];
|
||||
|
||||
private catMap = new Map<number, string>();
|
||||
private manMap = new Map<number, string>();
|
||||
private supMap = new Map<number, string>();
|
||||
|
||||
displayed: string[] = ['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>;
|
||||
|
||||
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;
|
||||
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)));
|
||||
}
|
||||
});
|
||||
constructor(
|
||||
fb: FormBuilder,
|
||||
ps: PrestashopService,
|
||||
dialog: MatDialog
|
||||
) {
|
||||
super(fb, ps, dialog);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,10 +143,17 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
||||
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);
|
||||
.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 ----
|
||||
if (this.mode === 'edit' && this.productRow) {
|
||||
|
||||
@@ -4,4 +4,5 @@ export interface ProductListItem {
|
||||
id_manufacturer?: number;
|
||||
id_supplier?: number;
|
||||
id_category_default?: number;
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<br>
|
||||
<div class="home-actions">
|
||||
<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>
|
||||
} @else {
|
||||
<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 {
|
||||
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
container_name: gameovergne-mysql
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: gameovergne_app
|
||||
MYSQL_USER: gameovergne
|
||||
MYSQL_PASSWORD: gameovergne
|
||||
volumes:
|
||||
- ./mysql-data:/var/lib/mysql
|
||||
ports:
|
||||
- "3366:3306"
|
||||
networks:
|
||||
- gameovergne
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-proot"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
spring:
|
||||
image: registry.vincent-guillet.fr/gameovergne-api:dev-latest
|
||||
container_name: gameovergne-api
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/gameovergne_app?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||
SPRING_DATASOURCE_USERNAME: gameovergne
|
||||
SPRING_DATASOURCE_PASSWORD: gameovergne
|
||||
PRESTASHOP_API_KEY: 2AQPG13MJ8X117U6FJ5NGHPS93HE34AB
|
||||
SERVER_PORT: 3000
|
||||
networks:
|
||||
- traefik
|
||||
- gameovergne
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik
|
||||
|
||||
- traefik.http.routers.gameovergne-api.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne-api`)
|
||||
- traefik.http.routers.gameovergne-api.entrypoints=edge
|
||||
- traefik.http.routers.gameovergne-api.service=gameovergne-api
|
||||
- traefik.http.services.gameovergne-api.loadbalancer.server.port=3000
|
||||
- traefik.http.routers.gameovergne-api.middlewares=gameovergne-api-stripprefix
|
||||
- traefik.http.middlewares.gameovergne-api-stripprefix.stripprefix.prefixes=/gameovergne-api
|
||||
|
||||
angular:
|
||||
image: registry.vincent-guillet.fr/gameovergne-client:dev-latest
|
||||
container_name: gameovergne-client
|
||||
depends_on:
|
||||
- spring
|
||||
networks:
|
||||
- gameovergne
|
||||
- traefik
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik
|
||||
|
||||
- traefik.http.routers.gameovergne-client.rule=Host(`dev.vincent-guillet.fr`) && (Path(`/gameovergne`) || PathPrefix(`/gameovergne/`))
|
||||
- traefik.http.routers.gameovergne-client.entrypoints=edge
|
||||
- traefik.http.routers.gameovergne-client.service=gameovergne-client
|
||||
- traefik.http.routers.gameovergne-client.middlewares=gameovergne-slash,gameovergne-client-stripprefix
|
||||
|
||||
- traefik.http.middlewares.gameovergne-slash.redirectregex.regex=^https?://([^/]+)/gameovergne$$
|
||||
- traefik.http.middlewares.gameovergne-slash.redirectregex.replacement=https://$${1}/gameovergne/
|
||||
- traefik.http.middlewares.gameovergne-slash.redirectregex.permanent=true
|
||||
|
||||
- traefik.http.middlewares.gameovergne-client-stripprefix.stripprefix.prefixes=/gameovergne
|
||||
|
||||
- traefik.http.services.gameovergne-client.loadbalancer.server.port=80
|
||||
|
||||
- traefik.http.routers.gameovergne-ps.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne/ps`)
|
||||
- traefik.http.routers.gameovergne-ps.entrypoints=edge
|
||||
- traefik.http.routers.gameovergne-ps.service=gameovergne-client
|
||||
- traefik.http.routers.gameovergne-ps.middlewares=gameovergne-client-stripprefix
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
gameovergne:
|
||||
external: true
|
||||
Reference in New Issue
Block a user