Compare commits

...

3 Commits

Author SHA1 Message Date
Vincent Guillet
de0844b41f Add product cards management with CRUD functionality and update routing 2025-12-23 16:28:54 +01:00
Vincent Guillet
e0beed6c6e remove unused file 2025-12-23 10:30:44 +01:00
Vincent Guillet
1a111b420d Add selection functionality to product list with delete option 2025-12-23 10:30:11 +01:00
17 changed files with 1544 additions and 341 deletions

View File

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

View File

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

View File

@@ -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>&nbsp;Ajouter une carte
</button>
@if (selection.hasValue()) {
<button
mat-raised-button
color="warn"
(click)="deleteSelected()"
[disabled]="isLoading">
<mat-icon>delete</mat-icon>&nbsp;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>

View File

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

View File

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

View File

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

View File

@@ -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 lID de limage à 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 lindex 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 limage : ' + (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);
}
}

View File

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

View File

@@ -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>&nbsp;Nouveau produit
<button mat-raised-button color="primary" (click)="create()" [disabled]="isLoading">
<mat-icon>add</mat-icon>&nbsp;Ajouter un produit
</button>
@if (selection.hasValue()) {
<button mat-raised-button color="warn" (click)="deleteSelected()" [disabled]="isLoading">
<mat-icon>delete</mat-icon>&nbsp;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>

View File

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

View File

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

View File

@@ -4,4 +4,5 @@ export interface ProductListItem {
id_manufacturer?: number;
id_supplier?: number;
id_category_default?: number;
condition?: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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