Compare commits
20 Commits
9763289c2f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| b79068623f | |||
|
|
3eed3d251f | ||
| 7dcc85ac95 | |||
| ec9eb0dc7d | |||
| 01cafd5904 | |||
| 321e2fd546 | |||
| 3026f0a13f | |||
| 52d17e5ad8 | |||
| 2803e910bd | |||
| 653ce83c33 | |||
| ce618deecf | |||
| 5331ce7866 | |||
|
|
6f6d033be3 | ||
|
|
ff8536b448 | ||
|
|
60593f6c11 | ||
|
|
1708c1bead | ||
|
|
dc33d762a1 | ||
|
|
e04cac3345 | ||
|
|
00f45ae6c7 | ||
|
|
1a5d3a570a |
@@ -46,7 +46,7 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // autoriser les preflight
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/api/users/**").authenticated()
|
||||
.requestMatchers("/api/app/**").permitAll()
|
||||
.requestMatchers("/api/app/**").authenticated()
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
@@ -61,16 +61,26 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOriginPatterns(Arrays.asList(
|
||||
"http://localhost:4200",
|
||||
"http://127.0.0.1:4200",
|
||||
"https://dev.vincent-guillet.fr"
|
||||
));
|
||||
config.setAllowedMethods(Arrays.asList("GET","POST","PUT","DELETE","OPTIONS"));
|
||||
config.setAllowedHeaders(Arrays.asList("Authorization","Content-Type","Accept"));
|
||||
config.setExposedHeaders(Arrays.asList("Authorization"));
|
||||
|
||||
// IMPORTANT : origins explicites, sans path
|
||||
config.setAllowedOrigins(Arrays.asList(
|
||||
"http://localhost:4200",
|
||||
"http://127.0.0.1:4200",
|
||||
"https://dev.vincent-guillet.fr",
|
||||
"https://projets.vincent-guillet.fr"
|
||||
));
|
||||
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
// Autoriser tous les headers côté requête (plus robuste)
|
||||
config.setAllowedHeaders(Arrays.asList("*"));
|
||||
|
||||
// Autoriser les méthodes classiques
|
||||
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
|
||||
// Headers que le client *voit* dans la réponse
|
||||
config.setExposedHeaders(Arrays.asList("Authorization", "Content-Type"));
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
|
||||
@@ -17,12 +17,6 @@ import java.util.Arrays;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@CrossOrigin(
|
||||
origins = "https://dev.vincent-guillet.fr",
|
||||
allowCredentials = "true",
|
||||
allowedHeaders = "*",
|
||||
methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.OPTIONS}
|
||||
)
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
import {APP_INITIALIZER, ApplicationConfig, inject, provideZoneChangeDetection} from '@angular/core';
|
||||
import {
|
||||
APP_INITIALIZER,
|
||||
ApplicationConfig,
|
||||
inject,
|
||||
provideZoneChangeDetection,
|
||||
importProvidersFrom
|
||||
} from '@angular/core';
|
||||
import {provideRouter} from '@angular/router';
|
||||
|
||||
import {BrowserModule} from '@angular/platform-browser';
|
||||
import {APP_BASE_HREF} from '@angular/common';
|
||||
import {routes} from './app.routes';
|
||||
import {provideHttpClient, withInterceptors} from '@angular/common/http';
|
||||
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
|
||||
import {authTokenInterceptor} from './interceptors/auth-token.interceptor';
|
||||
import {AuthService} from './services/auth.service';
|
||||
import {catchError, firstValueFrom, of} from 'rxjs';
|
||||
import {environment} from '../environments/environment';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({eventCoalescing: true}),
|
||||
importProvidersFrom(BrowserModule),
|
||||
{provide: APP_BASE_HREF, useValue: environment.hrefBase},
|
||||
provideRouter(routes),
|
||||
provideAnimationsAsync(),
|
||||
provideHttpClient(withInterceptors([
|
||||
authTokenInterceptor
|
||||
])
|
||||
),
|
||||
provideHttpClient(withInterceptors([authTokenInterceptor])),
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
multi: true,
|
||||
useFactory: () => {
|
||||
const auth = inject(AuthService);
|
||||
return () => firstValueFrom(auth.bootstrapSession().pipe(
|
||||
catchError(err => of(null))
|
||||
)
|
||||
);
|
||||
return () =>
|
||||
firstValueFrom(
|
||||
auth.bootstrapSession().pipe(
|
||||
catchError(() => of(null))
|
||||
)
|
||||
);
|
||||
}
|
||||
}, provideAnimationsAsync()
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import {LoginComponent} from './pages/auth/login/login.component';
|
||||
import {ProfileComponent} from './pages/profile/profile.component';
|
||||
import {guestOnlyCanActivate, guestOnlyCanMatch} from './guards/guest-only.guard';
|
||||
import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard';
|
||||
import {authOnlyCanMatch} from './guards/auth-only.guard';
|
||||
import {authOnlyCanActivate, authOnlyCanMatch} from './guards/auth-only.guard';
|
||||
import {PsAdminComponent} from './pages/admin/ps-admin/ps-admin.component';
|
||||
import {ProductsComponent} from './pages/products/products.component';
|
||||
|
||||
@@ -40,13 +40,13 @@ export const routes: Routes = [
|
||||
path: 'profile',
|
||||
component: ProfileComponent,
|
||||
canMatch: [authOnlyCanMatch],
|
||||
canActivate: [authOnlyCanMatch]
|
||||
canActivate: [authOnlyCanActivate]
|
||||
},
|
||||
{
|
||||
path: 'products',
|
||||
component: ProductsComponent,
|
||||
canMatch: [authOnlyCanMatch],
|
||||
canActivate: [authOnlyCanMatch]
|
||||
canMatch: [adminOnlyCanMatch],
|
||||
canActivate: [adminOnlyCanActivate]
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
/* src/app/components/main-navbar/main-navbar.component.css */
|
||||
/* Ajout prise en charge safe-area et meilleure gestion des overflow */
|
||||
|
||||
.mat-toolbar {
|
||||
/* protège contre les zones sensibles (notch / status bar) */
|
||||
padding-top: constant(safe-area-inset-top);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* wrapper principal */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
@@ -8,19 +21,22 @@
|
||||
gap: 12px;
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
min-height: 56px; /* assure une hauteur minimale utile sur mobile */
|
||||
}
|
||||
|
||||
/* marque / titre */
|
||||
.brand {
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
min-width: 0; /* autorise le shrink */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* actions (boutons, menu utilisateur) */
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -28,16 +44,47 @@
|
||||
flex: 0 1 auto;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0; /* important pour permettre la réduction des enfants */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* icône dans mat-menu */
|
||||
.mat-menu-item mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Empêcher les boutons de dépasser et couper le texte avec ellipsis */
|
||||
.nav-actions button {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Angular Material place le texte dans .mat-button-wrapper — on le tronque proprement */
|
||||
.nav-actions button .mat-button-wrapper {
|
||||
display: inline-block;
|
||||
max-width: calc(100% - 56px); /* espace pour icônes + padding */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Ajustements spécifiques pour petits écrans */
|
||||
@media (max-width: 720px) {
|
||||
.mat-toolbar {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.container {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
@@ -55,6 +102,7 @@
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 4px; /* espace pour le scroll horizontal */
|
||||
}
|
||||
|
||||
.nav-actions button {
|
||||
@@ -62,6 +110,11 @@
|
||||
font-size: 0.95rem;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.nav-actions button .mat-button-wrapper {
|
||||
max-width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.nav-actions mat-icon {
|
||||
|
||||
@@ -47,6 +47,21 @@ th, td {
|
||||
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;
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
<section class="crud">
|
||||
<div class="toolbar">
|
||||
<button mat-raised-button color="primary" (click)="create()">
|
||||
<button mat-raised-button
|
||||
color="primary"
|
||||
(click)="create()"
|
||||
[disabled]="isLoading">
|
||||
<mat-icon>add</mat-icon> Nouveau produit
|
||||
</button>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter">
|
||||
<mat-label>Filtrer</mat-label>
|
||||
<input matInput [formControl]="filterCtrl" placeholder="Nom, ID, catégorie, marque, fournisseur…">
|
||||
<input matInput
|
||||
[formControl]="filterCtrl"
|
||||
placeholder="Nom, ID, catégorie, marque, fournisseur…"
|
||||
[disabled]="isLoading">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="mat-elevation-z2">
|
||||
<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>
|
||||
|
||||
<table mat-table [dataSource]="dataSource" matSort>
|
||||
|
||||
<ng-container matColumnDef="id">
|
||||
@@ -51,8 +62,19 @@
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let el">
|
||||
<button mat-icon-button (click)="edit(el)" aria-label="edit"><mat-icon>edit</mat-icon></button>
|
||||
<button mat-icon-button color="warn" (click)="remove(el)" aria-label="delete"><mat-icon>delete</mat-icon></button>
|
||||
<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>
|
||||
|
||||
@@ -60,10 +82,17 @@
|
||||
<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>
|
||||
<td class="mat-cell" [attr.colspan]="displayed.length">
|
||||
Aucune donnée.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[5,10,25,100]" [pageSize]="10" aria-label="Pagination"></mat-paginator>
|
||||
<mat-paginator
|
||||
[pageSizeOptions]="[5,10,25,100]"
|
||||
[pageSize]="10"
|
||||
aria-label="Pagination"
|
||||
[disabled]="isLoading">
|
||||
</mat-paginator>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -13,7 +13,8 @@ import {MatButton, MatIconButton} from '@angular/material/button';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
import {FormBuilder, ReactiveFormsModule} from '@angular/forms';
|
||||
import {MatDialog, MatDialogModule} from '@angular/material/dialog';
|
||||
import {forkJoin} from 'rxjs';
|
||||
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
|
||||
import {forkJoin, finalize} from 'rxjs';
|
||||
|
||||
import {PsItem} from '../../interfaces/ps-item';
|
||||
import {ProductListItem} from '../../interfaces/product-list-item';
|
||||
@@ -32,7 +33,8 @@ import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/
|
||||
MatSortModule, MatPaginatorModule,
|
||||
MatFormField, MatLabel, MatInput,
|
||||
MatButton, MatIconButton, MatIcon,
|
||||
MatDialogModule
|
||||
MatDialogModule,
|
||||
MatProgressSpinnerModule
|
||||
]
|
||||
})
|
||||
export class PsProductCrudComponent implements OnInit {
|
||||
@@ -40,26 +42,24 @@ export class PsProductCrudComponent implements OnInit {
|
||||
private readonly ps = inject(PrestashopService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
// référentiels
|
||||
categories: PsItem[] = [];
|
||||
manufacturers: PsItem[] = [];
|
||||
suppliers: PsItem[] = [];
|
||||
|
||||
// maps d’affichage
|
||||
private catMap = new Map<number, string>();
|
||||
private manMap = new Map<number, string>();
|
||||
private supMap = new Map<number, string>();
|
||||
|
||||
// table
|
||||
displayed: string[] = ['id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'quantity', 'actions'];
|
||||
dataSource = new MatTableDataSource<any>([]);
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
@ViewChild(MatSort) sort!: MatSort;
|
||||
@ViewChild(MatTable) table!: MatTable<any>;
|
||||
|
||||
// filtre
|
||||
filterCtrl = this.fb.control<string>('');
|
||||
|
||||
isLoading = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
forkJoin({
|
||||
cats: this.ps.list('categories'),
|
||||
@@ -73,6 +73,7 @@ export class PsProductCrudComponent implements OnInit {
|
||||
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 => {
|
||||
@@ -80,7 +81,6 @@ export class PsProductCrudComponent implements OnInit {
|
||||
}
|
||||
});
|
||||
|
||||
// filtre client
|
||||
this.filterCtrl.valueChanges.subscribe(v => {
|
||||
this.dataSource.filter = (v ?? '').toString().trim().toLowerCase();
|
||||
if (this.paginator) this.paginator.firstPage();
|
||||
@@ -133,10 +133,24 @@ export class PsProductCrudComponent implements OnInit {
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.ps.listProducts().subscribe(p => this.bindProducts(p));
|
||||
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: {
|
||||
@@ -145,12 +159,16 @@ export class PsProductCrudComponent implements OnInit {
|
||||
suppliers: this.suppliers
|
||||
}
|
||||
};
|
||||
this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => {
|
||||
if (ok) this.reload();
|
||||
});
|
||||
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,
|
||||
@@ -160,16 +178,29 @@ export class PsProductCrudComponent implements OnInit {
|
||||
suppliers: this.suppliers
|
||||
}
|
||||
};
|
||||
this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => {
|
||||
if (ok) this.reload();
|
||||
});
|
||||
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.ps.deleteProduct(row.id).subscribe({
|
||||
next: () => this.reload(),
|
||||
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
|
||||
});
|
||||
|
||||
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)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,24 @@
|
||||
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 {
|
||||
@@ -85,15 +103,42 @@
|
||||
}
|
||||
|
||||
.thumb-item {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
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;
|
||||
}
|
||||
@@ -118,3 +163,19 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,137 +1,183 @@
|
||||
<h2 mat-dialog-title>{{ mode === 'create' ? 'Nouveau produit' : 'Modifier le produit' }}</h2>
|
||||
|
||||
<div mat-dialog-content class="grid" [formGroup]="form">
|
||||
<div class="dialog-root">
|
||||
<!-- Overlay de chargement -->
|
||||
@if (isSaving) {
|
||||
<div class="dialog-loading-overlay">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- CARROUSEL IMAGES -->
|
||||
<div class="col-12 carousel">
|
||||
<div class="carousel-main">
|
||||
<div mat-dialog-content class="grid" [formGroup]="form">
|
||||
|
||||
<!-- 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>
|
||||
<!-- CARROUSEL IMAGES -->
|
||||
<div class="col-12 carousel">
|
||||
<div class="carousel-main">
|
||||
|
||||
<!-- 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 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>
|
||||
|
||||
<!-- Bouton suivant -->
|
||||
<button mat-icon-button
|
||||
class="carousel-nav-btn right"
|
||||
(click)="next()"
|
||||
[disabled]="carouselItems.length <= 1">
|
||||
<mat-icon>chevron_right</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>
|
||||
|
||||
<!-- 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) {
|
||||
<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>
|
||||
}
|
||||
<!-- Input pour le nom du produit -->
|
||||
<mat-form-field class="col-12">
|
||||
<mat-label>Nom du produit</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>
|
||||
|
||||
<!-- Sélecteur pour la marque -->
|
||||
<mat-form-field class="col-6">
|
||||
<mat-label>Marque</mat-label>
|
||||
<mat-select formControlName="manufacturerId">
|
||||
<mat-option [value]="null" disabled>Choisir…</mat-option>
|
||||
@for (m of manufacturers; track m.id) {
|
||||
<mat-option [value]="m.id">{{ m.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Sélecteur pour la plateforme (Fournisseur) -->
|
||||
<mat-form-field class="col-6">
|
||||
<mat-label>Plateforme</mat-label>
|
||||
<mat-select formControlName="supplierId">
|
||||
<mat-option [value]="null" disabled>Choisir…</mat-option>
|
||||
@for (s of suppliers; track s.id) {
|
||||
<mat-option [value]="s.id">{{ s.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Checkboxes pour Complet/Notice -->
|
||||
<div class="col-12 flags">
|
||||
<mat-checkbox formControlName="complete">Complet</mat-checkbox>
|
||||
<mat-checkbox formControlName="hasManual">Notice</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- Input réel, caché -->
|
||||
<input #fileInput type="file" multiple hidden (change)="onFiles($event)">
|
||||
<!-- 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>
|
||||
|
||||
<!-- Input pour le nom du produit -->
|
||||
<mat-form-field class="col-12">
|
||||
<mat-label>Nom du produit</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>
|
||||
|
||||
<!-- Sélecteur pour la marque -->
|
||||
<mat-form-field class="col-6">
|
||||
<mat-label>Marque</mat-label>
|
||||
<mat-select formControlName="manufacturerId">
|
||||
<mat-option [value]="null" disabled>Choisir…</mat-option>
|
||||
@for (m of manufacturers; track m.id) {
|
||||
<mat-option [value]="m.id">{{ m.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Sélecteur pour la plateforme (Fournisseur) -->
|
||||
<mat-form-field class="col-6">
|
||||
<mat-label>Plateforme</mat-label>
|
||||
<mat-select formControlName="supplierId">
|
||||
<mat-option [value]="null" disabled>Choisir…</mat-option>
|
||||
@for (s of suppliers; track s.id) {
|
||||
<mat-option [value]="s.id">{{ s.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Checkboxes pour Complet/Notice -->
|
||||
<div class="col-12 flags">
|
||||
<mat-checkbox formControlName="complete">Complet</mat-checkbox>
|
||||
<mat-checkbox formControlName="hasManual">Notice</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Actions -->
|
||||
<div mat-dialog-actions>
|
||||
<button mat-button (click)="close()">Annuler</button>
|
||||
<button mat-raised-button color="primary" (click)="save()" [disabled]="form.invalid">
|
||||
{{ mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button
|
||||
(click)="close()"
|
||||
[disabled]="isSaving">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button mat-raised-button
|
||||
color="primary"
|
||||
(click)="save()"
|
||||
[disabled]="form.invalid || isSaving">
|
||||
@if (!isSaving) {
|
||||
Enregistrer
|
||||
} @else {
|
||||
Enregistrement...
|
||||
}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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 {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,
|
||||
@@ -13,13 +13,14 @@ import {
|
||||
MatDialogContent,
|
||||
MatDialogTitle
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
|
||||
import { catchError, forkJoin, of, Observable } from 'rxjs';
|
||||
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 {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';
|
||||
@@ -38,7 +39,7 @@ type CarouselItem = { src: string; isPlaceholder: boolean };
|
||||
CommonModule, ReactiveFormsModule,
|
||||
MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox,
|
||||
MatButton, MatDialogActions, MatDialogContent, MatDialogTitle,
|
||||
MatIcon, MatIconButton
|
||||
MatIcon, MatIconButton, MatProgressSpinner
|
||||
]
|
||||
})
|
||||
export class PsProductDialogComponent implements OnInit, OnDestroy {
|
||||
@@ -48,7 +49,10 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: ProductDialogData,
|
||||
private readonly dialogRef: MatDialogRef<PsProductDialogComponent>
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
|
||||
mode!: 'create' | 'edit';
|
||||
categories: PsItem[] = [];
|
||||
@@ -167,11 +171,11 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
||||
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 }))
|
||||
catchError(() => of({complete: false, hasManual: false, conditionLabel: undefined}))
|
||||
);
|
||||
|
||||
forkJoin({ details: details$, qty: qty$, imgs: imgs$, flags: flags$ })
|
||||
.subscribe(({ details, qty, imgs, flags }) => {
|
||||
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;
|
||||
@@ -203,7 +207,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
||||
const fl = (ev.target as HTMLInputElement).files;
|
||||
|
||||
// Nettoyage des anciens objectURL
|
||||
for(let url of this.previewUrls) {
|
||||
for (let url of this.previewUrls) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
this.previewUrls = [];
|
||||
@@ -224,12 +228,12 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
||||
|
||||
private buildCarousel() {
|
||||
const items: CarouselItem[] = [
|
||||
...this.existingImageUrls.map(u => ({ src: u, isPlaceholder: false })),
|
||||
...this.previewUrls.map(u => ({ src: u, isPlaceholder: false }))
|
||||
...this.existingImageUrls.map(u => ({src: u, isPlaceholder: false})),
|
||||
...this.previewUrls.map(u => ({src: u, isPlaceholder: false}))
|
||||
];
|
||||
|
||||
// placeholder en dernier
|
||||
items.push({ src: '', isPlaceholder: true });
|
||||
items.push({src: '', isPlaceholder: true});
|
||||
|
||||
this.carouselItems = items;
|
||||
if (!this.carouselItems.length) {
|
||||
@@ -261,10 +265,13 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Save / close inchangés (à part dto.images) --------
|
||||
// -------- Save / close --------
|
||||
|
||||
save() {
|
||||
if (this.form.invalid) return;
|
||||
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;
|
||||
@@ -275,7 +282,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
||||
categoryId: +v.categoryId!,
|
||||
manufacturerId: +v.manufacturerId!,
|
||||
supplierId: +v.supplierId!,
|
||||
images: this.images, // toujours les fichiers sélectionnés
|
||||
images: this.images,
|
||||
complete: !!v.complete,
|
||||
hasManual: !!v.hasManual,
|
||||
conditionLabel: v.conditionLabel || undefined,
|
||||
@@ -291,13 +298,99 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
||||
op$ = this.ps.updateProduct(this.productRow.id, dto) as Observable<unknown>;
|
||||
}
|
||||
|
||||
op$.subscribe({
|
||||
next: () => this.dialogRef.close(true),
|
||||
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +516,16 @@ export class PrestashopService {
|
||||
);
|
||||
}
|
||||
|
||||
deleteProductImage(productId: number, imageId: number) {
|
||||
// Presta : DELETE /images/products/{id_product}/{id_image}
|
||||
return this.http.delete(
|
||||
`${this.base}/images/products/${productId}/${imageId}`,
|
||||
{ responseType: 'text' }
|
||||
).pipe(
|
||||
map(() => true)
|
||||
);
|
||||
}
|
||||
|
||||
// -------- Stock (quantité) — gestion fine via stock_availables
|
||||
|
||||
getProductQuantity(productId: number) {
|
||||
|
||||
@@ -2,4 +2,5 @@ export const environment = {
|
||||
production: true,
|
||||
apiUrl: '/gameovergne-api/api',
|
||||
psUrl: '/gameovergne-api/api/ps',
|
||||
hrefBase: '/gameovergne/',
|
||||
};
|
||||
|
||||
@@ -2,4 +2,5 @@ export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://localhost:3000/api',
|
||||
psUrl: '/ps',
|
||||
hrefBase: '/',
|
||||
};
|
||||
@@ -10,6 +10,6 @@
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body class="mat-typography">
|
||||
<app-root></app-root>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
version: "3.9"
|
||||
|
||||
# docker-compose.dev.yml
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
@@ -9,11 +8,10 @@ services:
|
||||
MYSQL_DATABASE: gameovergne_app
|
||||
MYSQL_USER: gameovergne
|
||||
MYSQL_PASSWORD: gameovergne
|
||||
# 🔒 Persistant + accessible depuis l'extérieur
|
||||
volumes:
|
||||
- ./mysql-data:/var/lib/mysql
|
||||
ports:
|
||||
- "3366:3306" # pour te connecter depuis ton Mac / un client SQL
|
||||
- "3366:3306"
|
||||
networks:
|
||||
- gameovergne
|
||||
restart: unless-stopped
|
||||
@@ -35,16 +33,14 @@ services:
|
||||
SPRING_DATASOURCE_PASSWORD: gameovergne
|
||||
PRESTASHOP_API_KEY: 2AQPG13MJ8X117U6FJ5NGHPS93HE34AB
|
||||
SERVER_PORT: 3000
|
||||
# pense bien à avoir prestashop.base-url / prestashop.basic-auth dans application.properties ou via env si besoin
|
||||
networks:
|
||||
- gameovergne
|
||||
- traefik
|
||||
- gameovergne
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik
|
||||
|
||||
# API sous /gameovergne-api
|
||||
- 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
|
||||
@@ -65,24 +61,19 @@ services:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik
|
||||
|
||||
# FRONT sous /gameovergne (avec et sans slash final)
|
||||
- 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
|
||||
|
||||
# Redirige /gameovergne vers /gameovergne/
|
||||
- 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
|
||||
|
||||
# Enlève /gameovergne avant d'envoyer vers Nginx (le conteneur Angular)
|
||||
- traefik.http.middlewares.gameovergne-client-stripprefix.stripprefix.prefixes=/gameovergne
|
||||
|
||||
# Service vers Nginx (port 80 dans le conteneur)
|
||||
- traefik.http.services.gameovergne-client.loadbalancer.server.port=80
|
||||
|
||||
# Proxy Presta via /gameovergne/ps -> même service, même StripPrefix
|
||||
- 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
|
||||
@@ -92,4 +83,4 @@ networks:
|
||||
traefik:
|
||||
external: true
|
||||
gameovergne:
|
||||
driver: bridge
|
||||
external: true
|
||||
86
docker-compose.prod.yml
Normal file
86
docker-compose.prod.yml
Normal file
@@ -0,0 +1,86 @@
|
||||
# docker-compose.prod.yml
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
container_name: gameovergne-mysql-prod
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: gameovergne_app
|
||||
MYSQL_USER: gameovergne
|
||||
MYSQL_PASSWORD: gameovergne
|
||||
volumes:
|
||||
- ./mysql-data-prod:/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:prod-latest
|
||||
container_name: gameovergne-api-prod
|
||||
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(`projets.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:prod-latest
|
||||
container_name: gameovergne-client-prod
|
||||
depends_on:
|
||||
- spring
|
||||
networks:
|
||||
- gameovergne
|
||||
- traefik
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik
|
||||
|
||||
- traefik.http.routers.gameovergne-client.rule=Host(`projets.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(`projets.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
|
||||
85
docker-compose.yml.OLD
Normal file
85
docker-compose.yml.OLD
Normal file
@@ -0,0 +1,85 @@
|
||||
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
|
||||
129
jenkinsfile
129
jenkinsfile
@@ -1,72 +1,119 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
tools {
|
||||
maven 'mvn'
|
||||
nodejs 'npm'
|
||||
}
|
||||
agent none
|
||||
|
||||
environment {
|
||||
JAVA_HOME = '/opt/java/openjdk'
|
||||
PATH = "${JAVA_HOME}/bin:${env.PATH}"
|
||||
SPRING_IMAGE_NAME = 'spring-jenkins'
|
||||
ANGULAR_IMAGE_NAME = 'angular-jenkins'
|
||||
IMAGE_TAG = 'latest'
|
||||
REGISTRY = 'registry.vincent-guillet.fr'
|
||||
API_IMAGE_DEV = "${REGISTRY}/gameovergne-api:dev-latest"
|
||||
CLIENT_IMAGE_DEV = "${REGISTRY}/gameovergne-client:dev-latest"
|
||||
API_IMAGE_PROD = "${REGISTRY}/gameovergne-api:prod-latest"
|
||||
CLIENT_IMAGE_PROD = "${REGISTRY}/gameovergne-client:prod-latest"
|
||||
COMPOSE_PROJECT = 'gameovergne-app'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout sur la branche dev') {
|
||||
steps {
|
||||
git branch: 'dev', url: 'https://gitea.vincent-guillet.fr/vincentguillet/gameovergne-app.git'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Maven Build') {
|
||||
// Build & push images (toujours sur ct-home-dev)
|
||||
stage('Build & Push Docker Images') {
|
||||
agent { label 'ct-home-dev' }
|
||||
|
||||
steps {
|
||||
// Multi-branch friendly : Jenkins fait le checkout de la branche courante
|
||||
checkout scm
|
||||
|
||||
script {
|
||||
// Choix des tags selon la branche
|
||||
if (env.BRANCH_NAME == 'main') {
|
||||
env.API_IMAGE = API_IMAGE_PROD
|
||||
env.CLIENT_IMAGE = CLIENT_IMAGE_PROD
|
||||
} else {
|
||||
env.API_IMAGE = API_IMAGE_DEV
|
||||
env.CLIENT_IMAGE = CLIENT_IMAGE_DEV
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Build API -----
|
||||
dir('api') {
|
||||
sh 'mvn clean package -DskipTests'
|
||||
sh """
|
||||
echo "=== Build image API ${API_IMAGE} ==="
|
||||
docker build -t ${API_IMAGE} .
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Angular Build') {
|
||||
steps {
|
||||
// ----- Build Client -----
|
||||
dir('client') {
|
||||
sh 'npm install'
|
||||
sh 'npm run build'
|
||||
sh """
|
||||
echo "=== Build image CLIENT ${CLIENT_IMAGE} ==="
|
||||
docker build -t ${CLIENT_IMAGE} .
|
||||
"""
|
||||
}
|
||||
|
||||
// ----- Push vers registry -----
|
||||
sh """
|
||||
echo "=== Push images vers ${REGISTRY} ==="
|
||||
docker push ${API_IMAGE}
|
||||
docker push ${CLIENT_IMAGE}
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
stage('Spring Docker Build') {
|
||||
steps {
|
||||
sh 'docker build -t registry.vincent-guillet.fr/gameovergne-api:dev-latest ./api'
|
||||
// Déploiement DEV (ct-home-dev, branche dev)
|
||||
stage('Deploy DEV') {
|
||||
when {
|
||||
branch 'dev'
|
||||
}
|
||||
}
|
||||
agent { label 'ct-home-dev' }
|
||||
|
||||
stage('Angular Docker Build') {
|
||||
steps {
|
||||
sh 'docker build -t registry.vincent-guillet.fr/gameovergne-client:dev-latest ./client'
|
||||
}
|
||||
}
|
||||
checkout scm
|
||||
|
||||
stage('Deployment') {
|
||||
steps {
|
||||
withEnv([
|
||||
"DOCKER_HOST=unix:///var/run/docker.sock",
|
||||
"COMPOSE_PROJECT_NAME=${env.COMPOSE_PROJECT}"
|
||||
]) {
|
||||
sh '''
|
||||
echo "=== Nettoyage des anciens conteneurs nommés ==="
|
||||
sh """
|
||||
echo "=== [DEV] Nettoyage anciens conteneurs ==="
|
||||
docker rm -f gameovergne-api gameovergne-client 2>/dev/null || true
|
||||
|
||||
echo "=== docker-compose down sur le projet courant ==="
|
||||
docker-compose down -v || true
|
||||
echo "=== [DEV] docker compose down ==="
|
||||
docker compose -f docker-compose.dev.yml down -v || true
|
||||
|
||||
echo "=== (Re)création de la stack MySQL + Spring + Angular ==="
|
||||
docker-compose up -d mysql spring angular
|
||||
'''
|
||||
echo "=== [DEV] docker compose pull ==="
|
||||
docker compose -f docker-compose.dev.yml pull
|
||||
|
||||
echo "=== [DEV] docker compose up (force recreate) ==="
|
||||
docker compose -f docker-compose.dev.yml up -d --force-recreate mysql spring angular
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Déploiement PROD (ct-home-projets, branche main)
|
||||
stage('Deploy PROD') {
|
||||
when {
|
||||
branch 'main'
|
||||
}
|
||||
agent { label 'ct-home-projets' }
|
||||
|
||||
steps {
|
||||
checkout scm
|
||||
|
||||
withEnv([
|
||||
"DOCKER_HOST=unix:///var/run/docker.sock",
|
||||
"COMPOSE_PROJECT_NAME=${env.COMPOSE_PROJECT}"
|
||||
]) {
|
||||
sh """
|
||||
echo "=== [PROD] Nettoyage anciens conteneurs ==="
|
||||
docker rm -f gameovergne-api-prod gameovergne-client-prod 2>/dev/null || true
|
||||
|
||||
echo "=== [PROD] docker compose down ==="
|
||||
docker compose -f docker-compose.prod.yml down || true
|
||||
|
||||
echo "=== [PROD] docker compose pull ==="
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
|
||||
echo "=== [PROD] docker compose up (force recreate) ==="
|
||||
docker compose -f docker-compose.prod.yml up -d --force-recreate mysql spring angular
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user