diff --git a/client/package.json b/client/package.json index bf1a994..12bc953 100644 --- a/client/package.json +++ b/client/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "ng serve", + "start": "ng serve --proxy-config proxy.conf.json", "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test" @@ -37,4 +37,4 @@ "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.5.2" } -} \ No newline at end of file +} diff --git a/client/src/app/admin-presta/admin-presta.module.ts b/client/src/app/admin-presta/admin-presta.module.ts deleted file mode 100644 index 61d2b82..0000000 --- a/client/src/app/admin-presta/admin-presta.module.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {NgModule} from '@angular/core'; -import {CommonModule} from '@angular/common'; -import {ReactiveFormsModule, FormsModule} from '@angular/forms'; - -// Angular Material (tous utilisés dans ces composants) -import {MatTabsModule} from '@angular/material/tabs'; -import {MatTableModule} from '@angular/material/table'; -import {MatFormFieldModule} from '@angular/material/form-field'; -import {MatInputModule} from '@angular/material/input'; -import {MatButtonModule} from '@angular/material/button'; -import {MatIconModule} from '@angular/material/icon'; - -// Composants de cette feature -import {PsCrudTabsComponent} from './ps-crud-tabs/ps-crud-tabs.component'; -import {CategoriesCrudComponent} from './categories-crud/categories-crud.component'; -import {ManufacturersCrudComponent} from './manufacturers-crud/manufacturers-crud.component'; -import {SuppliersCrudComponent} from './suppliers-crud/suppliers-crud.component'; - -@NgModule({ - declarations: [ - - ], - imports: [ - CommonModule, - ReactiveFormsModule, FormsModule, - MatTabsModule, MatTableModule, MatFormFieldModule, MatInputModule, - MatButtonModule, MatIconModule, PsCrudTabsComponent, CategoriesCrudComponent, ManufacturersCrudComponent, SuppliersCrudComponent - ], - exports: [PsCrudTabsComponent] // pour pouvoir l’utiliser ailleurs -}) -export class AdminPrestaModule { -} diff --git a/client/src/app/admin-presta/categories-crud/categories-crud.component.css b/client/src/app/admin-presta/categories-crud/categories-crud.component.css deleted file mode 100644 index a6fe2c5..0000000 --- a/client/src/app/admin-presta/categories-crud/categories-crud.component.css +++ /dev/null @@ -1,18 +0,0 @@ -.crud { - display: grid; - gap: 16px -} - -.row { - display: flex; - gap: 12px; - align-items: flex-end -} - -.grow { - flex: 1 -} - -table { - width: 100% -} diff --git a/client/src/app/admin-presta/categories-crud/categories-crud.component.html b/client/src/app/admin-presta/categories-crud/categories-crud.component.html deleted file mode 100644 index 542d81f..0000000 --- a/client/src/app/admin-presta/categories-crud/categories-crud.component.html +++ /dev/null @@ -1,36 +0,0 @@ -
-
- - Nom de la catégorie - - - - - -
- - - - - - - - - - - - - - - - - - - -
ID{{ el.id }}Nom{{ el.name }}Actions - - -
-
diff --git a/client/src/app/admin-presta/categories-crud/categories-crud.component.ts b/client/src/app/admin-presta/categories-crud/categories-crud.component.ts deleted file mode 100644 index e783492..0000000 --- a/client/src/app/admin-presta/categories-crud/categories-crud.component.ts +++ /dev/null @@ -1,99 +0,0 @@ -import {Component, inject, OnInit} from '@angular/core'; -import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; -import {PrestaService, PsItem} from '../../services/presta.serivce'; -import {map} from 'rxjs'; -import {MatIcon} from '@angular/material/icon'; -import {MatButton, MatIconButton} from '@angular/material/button'; -import { - MatCell, - MatCellDef, - MatColumnDef, - MatHeaderCell, MatHeaderCellDef, - MatHeaderRow, - MatHeaderRowDef, - MatRow, - MatRowDef, - MatTable -} from '@angular/material/table'; -import {MatFormField, MatLabel} from '@angular/material/form-field'; -import {MatInput} from '@angular/material/input'; -import {NgIf} from '@angular/common'; - -@Component({ - standalone : true, - selector: 'app-categories-crud', - templateUrl: './categories-crud.component.html', - styleUrls: ['./categories-crud.component.css'], - imports: [ - MatIcon, - MatIconButton, - MatHeaderRow, - MatRow, - MatRowDef, - MatHeaderRowDef, - ReactiveFormsModule, - MatFormField, - MatLabel, - MatInput, - MatButton, - MatTable, - MatColumnDef, - MatHeaderCell, - NgIf, - MatHeaderCellDef, - MatCellDef, - MatCell - ] -}) -export class CategoriesCrudComponent implements OnInit { - private readonly fb = inject(FormBuilder); - private readonly ps = inject(PrestaService); - - items: PsItem[] = []; - cols = ['id', 'name', 'actions']; - form = this.fb.group({name: ['', Validators.required]}); - editId: number | null = null; - - ngOnInit() { - this.reload(); - } - - reload() { - this.ps.list('categories').subscribe(data => this.items = data); - } - - startEdit(el: PsItem) { - this.editId = el.id; - this.form.patchValue({name: el.name}); - } - - cancelEdit() { - this.editId = null; - this.form.reset({name: ''}); - } - - onSubmit() { - const name = this.form.value.name!.trim(); - if (!name) return; - - const op$ = this.editId - ? this.ps.update('categories', this.editId, name).pipe(map(() => undefined)) - : this.ps.create('categories', name).pipe(map(() => undefined)); - - op$.subscribe({ - next: () => { - this.cancelEdit(); - this.reload(); - }, - error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e))) - }); - } - - remove(el: PsItem) { - if (!confirm(`Supprimer la catégorie "${el.name}" (#${el.id}) ?`)) return; - this.ps.delete('categories', el.id).subscribe({ - next: () => this.reload(), - error: e => alert('Erreur: ' + (e?.message || e)) - }); - } -} diff --git a/client/src/app/admin-presta/manufacturers-crud/manufacturers-crud.component.css b/client/src/app/admin-presta/manufacturers-crud/manufacturers-crud.component.css deleted file mode 100644 index a6fe2c5..0000000 --- a/client/src/app/admin-presta/manufacturers-crud/manufacturers-crud.component.css +++ /dev/null @@ -1,18 +0,0 @@ -.crud { - display: grid; - gap: 16px -} - -.row { - display: flex; - gap: 12px; - align-items: flex-end -} - -.grow { - flex: 1 -} - -table { - width: 100% -} diff --git a/client/src/app/admin-presta/manufacturers-crud/manufacturers-crud.component.html b/client/src/app/admin-presta/manufacturers-crud/manufacturers-crud.component.html deleted file mode 100644 index 1b4beff..0000000 --- a/client/src/app/admin-presta/manufacturers-crud/manufacturers-crud.component.html +++ /dev/null @@ -1,40 +0,0 @@ -
-
- - Nom de la marque - - - - - -
- - - - - - - - - - - - - - - - - - - -
ID{{ el.id }}Nom{{ el.name }}Actions - - -
-
diff --git a/client/src/app/admin-presta/manufacturers-crud/manufacturers-crud.component.ts b/client/src/app/admin-presta/manufacturers-crud/manufacturers-crud.component.ts deleted file mode 100644 index 658c661..0000000 --- a/client/src/app/admin-presta/manufacturers-crud/manufacturers-crud.component.ts +++ /dev/null @@ -1,99 +0,0 @@ -import {Component, inject, OnInit} from '@angular/core'; -import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; -import {PrestaService, PsItem} from '../../services/presta.serivce'; -import {map} from 'rxjs'; -import {MatFormField, MatLabel} from '@angular/material/form-field'; -import {MatIcon} from '@angular/material/icon'; -import {MatButton, MatIconButton} from '@angular/material/button'; -import { - MatCell, - MatCellDef, - MatColumnDef, - MatHeaderCell, MatHeaderCellDef, - MatHeaderRow, - MatHeaderRowDef, - MatRow, - MatRowDef, - MatTable -} from '@angular/material/table'; -import {MatInput} from '@angular/material/input'; -import {NgIf} from '@angular/common'; - -@Component({ - standalone: true, - selector: 'app-manufacturers-crud', - templateUrl: './manufacturers-crud.component.html', - imports: [ - MatIcon, - MatIconButton, - MatHeaderRow, - MatRow, - MatRowDef, - MatHeaderRowDef, - ReactiveFormsModule, - MatFormField, - MatLabel, - MatInput, - MatButton, - MatTable, - MatColumnDef, - MatHeaderCell, - NgIf, - MatHeaderCellDef, - MatCellDef, - MatCell - ], - styleUrls: ['./manufacturers-crud.component.css'] -}) -export class ManufacturersCrudComponent implements OnInit { - private readonly fb = inject(FormBuilder); - private readonly ps = inject(PrestaService); - - items: PsItem[] = []; - cols = ['id', 'name', 'actions']; - form = this.fb.group({name: ['', Validators.required]}); - editId: number | null = null; - - ngOnInit() { - this.reload(); - } - - reload() { - this.ps.list('manufacturers').subscribe(d => this.items = d); - } - - startEdit(el: PsItem) { - this.editId = el.id; - this.form.patchValue({name: el.name}); - } - - cancelEdit() { - this.editId = null; - this.form.reset({name: ''}); - } - - onSubmit() { - const name = this.form.value.name!.trim(); - if (!name) return; - - const op$ = this.editId - ? this.ps.update('manufacturers', this.editId, name).pipe(map(() => undefined)) - : this.ps.create('manufacturers', name).pipe(map(() => undefined)); - - op$.subscribe({ - next: () => { - this.cancelEdit(); - this.reload(); - }, - error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e))) - }); - } - - remove(el: PsItem) { - if (!confirm(`Supprimer la marque "${el.name}" (#${el.id}) ?`)) return; - this.ps.delete('manufacturers', el.id).subscribe({ - next: () => this.reload(), - error: e => alert('Erreur: ' + (e?.message || e)) - }); - } -} diff --git a/client/src/app/admin-presta/ps-crud-tabs/ps-crud-tabs.component.html b/client/src/app/admin-presta/ps-crud-tabs/ps-crud-tabs.component.html deleted file mode 100644 index 017ddcf..0000000 --- a/client/src/app/admin-presta/ps-crud-tabs/ps-crud-tabs.component.html +++ /dev/null @@ -1,8 +0,0 @@ -
-

PrestaShop — CRUD simplifié

- - - - - -
diff --git a/client/src/app/admin-presta/ps-crud-tabs/ps-crud-tabs.component.ts b/client/src/app/admin-presta/ps-crud-tabs/ps-crud-tabs.component.ts deleted file mode 100644 index a80b0fd..0000000 --- a/client/src/app/admin-presta/ps-crud-tabs/ps-crud-tabs.component.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Component } from '@angular/core'; -import {MatTab, MatTabGroup} from '@angular/material/tabs'; -import {CategoriesCrudComponent} from '../categories-crud/categories-crud.component'; -import {ManufacturersCrudComponent} from '../manufacturers-crud/manufacturers-crud.component'; -import {SuppliersCrudComponent} from '../suppliers-crud/suppliers-crud.component'; - -@Component({ - standalone: true, - selector: 'app-ps-crud-tabs', - templateUrl: './ps-crud-tabs.component.html', - imports: [ - MatTabGroup, - MatTab, - CategoriesCrudComponent, - ManufacturersCrudComponent, - SuppliersCrudComponent - ], - styleUrls: ['./ps-crud-tabs.component.css'] -}) -export class PsCrudTabsComponent {} diff --git a/client/src/app/admin-presta/suppliers-crud/suppliers-crud.component.css b/client/src/app/admin-presta/suppliers-crud/suppliers-crud.component.css deleted file mode 100644 index a6fe2c5..0000000 --- a/client/src/app/admin-presta/suppliers-crud/suppliers-crud.component.css +++ /dev/null @@ -1,18 +0,0 @@ -.crud { - display: grid; - gap: 16px -} - -.row { - display: flex; - gap: 12px; - align-items: flex-end -} - -.grow { - flex: 1 -} - -table { - width: 100% -} diff --git a/client/src/app/admin-presta/suppliers-crud/suppliers-crud.component.html b/client/src/app/admin-presta/suppliers-crud/suppliers-crud.component.html deleted file mode 100644 index e6118f6..0000000 --- a/client/src/app/admin-presta/suppliers-crud/suppliers-crud.component.html +++ /dev/null @@ -1,40 +0,0 @@ -
-
- - Nom du fournisseur - - - - - -
- - - - - - - - - - - - - - - - - - - -
ID{{ el.id }}Nom{{ el.name }}Actions - - -
-
diff --git a/client/src/app/admin-presta/suppliers-crud/suppliers-crud.component.ts b/client/src/app/admin-presta/suppliers-crud/suppliers-crud.component.ts deleted file mode 100644 index f9364a1..0000000 --- a/client/src/app/admin-presta/suppliers-crud/suppliers-crud.component.ts +++ /dev/null @@ -1,99 +0,0 @@ -import {Component, inject, OnInit} from '@angular/core'; -import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; -import {PrestaService, PsItem} from '../../services/presta.serivce'; -import {map} from 'rxjs'; -import {MatIcon} from '@angular/material/icon'; -import {MatButton, MatIconButton} from '@angular/material/button'; -import { - MatCell, - MatCellDef, - MatColumnDef, - MatHeaderCell, MatHeaderCellDef, - MatHeaderRow, - MatHeaderRowDef, - MatRow, - MatRowDef, - MatTable -} from '@angular/material/table'; -import {MatFormField, MatLabel} from '@angular/material/form-field'; -import {MatInput} from '@angular/material/input'; -import {NgIf} from '@angular/common'; - -@Component({ - standalone: true, - selector: 'app-suppliers-crud', - templateUrl: './suppliers-crud.component.html', - imports: [ - MatIcon, - MatIconButton, - MatHeaderRow, - MatRow, - MatRowDef, - MatHeaderRowDef, - ReactiveFormsModule, - MatFormField, - MatLabel, - MatInput, - MatButton, - MatTable, - MatColumnDef, - MatHeaderCell, - NgIf, - MatHeaderCellDef, - MatCellDef, - MatCell - ], - styleUrls: ['./suppliers-crud.component.css'] -}) -export class SuppliersCrudComponent implements OnInit { - private readonly fb = inject(FormBuilder); - private readonly ps = inject(PrestaService); - - items: PsItem[] = []; - cols = ['id', 'name', 'actions']; - form = this.fb.group({name: ['', Validators.required]}); - editId: number | null = null; - - ngOnInit() { - this.reload(); - } - - reload() { - this.ps.list('suppliers').subscribe(d => this.items = d); - } - - startEdit(el: PsItem) { - this.editId = el.id; - this.form.patchValue({name: el.name}); - } - - cancelEdit() { - this.editId = null; - this.form.reset({name: ''}); - } - - onSubmit() { - const name = this.form.value.name!.trim(); - if (!name) return; - - const op$ = this.editId - ? this.ps.update('suppliers', this.editId, name).pipe(map(() => undefined)) - : this.ps.create('suppliers', name).pipe(map(() => undefined)); - - op$.subscribe({ - next: () => { - this.cancelEdit(); - this.reload(); - }, - error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e))) - }); - } - - remove(el: PsItem) { - if (!confirm(`Supprimer le fournisseur "${el.name}" (#${el.id}) ?`)) return; - this.ps.delete('suppliers', el.id).subscribe({ - next: () => this.reload(), - error: e => alert('Erreur: ' + (e?.message || e)) - }); - } -} diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 03410b1..4da18e2 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; -import {MainNavbarComponent} from './components/navbar/main-navbar/main-navbar.component'; +import {MainNavbarComponent} from './components/main-navbar/main-navbar.component'; @Component({ selector: 'app-root', diff --git a/client/src/app/app.config.ts b/client/src/app/app.config.ts index b855b9b..ffdf2b5 100644 --- a/client/src/app/app.config.ts +++ b/client/src/app/app.config.ts @@ -5,7 +5,7 @@ import {routes} from './app.routes'; import {provideHttpClient, withInterceptors} from '@angular/common/http'; import {provideAnimationsAsync} from '@angular/platform-browser/animations/async'; import {authTokenInterceptor} from './interceptors/auth-token.interceptor'; -import {AuthService} from './services/auth/auth.service'; +import {AuthService} from './services/auth.service'; import {catchError, firstValueFrom, of} from 'rxjs'; export const appConfig: ApplicationConfig = { diff --git a/client/src/app/app.routes.ts b/client/src/app/app.routes.ts index 58db7e1..dd6b6a7 100644 --- a/client/src/app/app.routes.ts +++ b/client/src/app/app.routes.ts @@ -1,15 +1,13 @@ import {Routes} from '@angular/router'; import {HomeComponent} from './pages/home/home.component'; -import {RegisterComponent} from './pages/register/register.component'; -import {LoginComponent} from './pages/login/login.component'; +import {RegisterComponent} from './pages/auth/register/register.component'; +import {LoginComponent} from './pages/auth/login/login.component'; import {ProfileComponent} from './pages/profile/profile.component'; import {guestOnlyCanActivate, guestOnlyCanMatch} from './guards/guest-only.guard'; -import {authOnlyCanActivate, authOnlyCanMatch} from './guards/auth-only.guard'; -import {AdminComponent} from './pages/admin/admin.component'; import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard'; +import {authOnlyCanMatch} from './guards/auth-only.guard'; +import {PsAdminComponent} from './pages/admin/ps-admin/ps-admin.component'; import {ProductsComponent} from './pages/products/products.component'; -import {AddProductComponent} from './pages/add-product/add-product.component'; -import {PsCrudTabsComponent} from './admin-presta/ps-crud-tabs/ps-crud-tabs.component'; export const routes: Routes = [ { @@ -44,29 +42,17 @@ export const routes: Routes = [ canMatch: [authOnlyCanMatch], canActivate: [authOnlyCanMatch] }, - { - path: 'admin', - component: AdminComponent, - canMatch: [adminOnlyCanMatch], - canActivate: [adminOnlyCanActivate] - }, { path: 'products', component: ProductsComponent, canMatch: [authOnlyCanMatch], - canActivate: [authOnlyCanActivate], + canActivate: [authOnlyCanMatch] }, { - path: 'products/add', - component: AddProductComponent, - canMatch: [authOnlyCanMatch], - canActivate: [authOnlyCanActivate], - }, - { - path: 'prestashop', - component: PsCrudTabsComponent, - canMatch: [authOnlyCanMatch], - canActivate: [authOnlyCanActivate], + path: 'admin', + component: PsAdminComponent, + canMatch: [adminOnlyCanMatch], + canActivate: [adminOnlyCanActivate] }, { path: '**', diff --git a/client/src/app/components/dialog/confirm-dialog/confirm-dialog.component.css b/client/src/app/components/dialog/confirm-dialog/confirm-dialog.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/app/components/dialog/confirm-dialog/confirm-dialog.component.html b/client/src/app/components/dialog/confirm-dialog/confirm-dialog.component.html deleted file mode 100644 index 76403eb..0000000 --- a/client/src/app/components/dialog/confirm-dialog/confirm-dialog.component.html +++ /dev/null @@ -1,8 +0,0 @@ -

{{ data.title ?? 'Confirmation' }}

- -

{{ data.message ?? 'Êtes-vous sûr·e ?' }}

-
- - - - diff --git a/client/src/app/components/dialog/confirm-dialog/confirm-dialog.component.ts b/client/src/app/components/dialog/confirm-dialog/confirm-dialog.component.ts deleted file mode 100644 index a65c0d1..0000000 --- a/client/src/app/components/dialog/confirm-dialog/confirm-dialog.component.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Component, Inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { MatButtonModule } from '@angular/material/button'; - -@Component({ - selector: 'app-confirm-dialog', - standalone: true, - imports: [CommonModule, MatDialogModule, MatButtonModule], - templateUrl: './confirm-dialog.component.html', -}) -export class ConfirmDialogComponent { - constructor( - private readonly dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: { title?: string; message?: string } - ) {} - - close(result: boolean) { - this.dialogRef.close(result); - } -} diff --git a/client/src/app/components/dialog/generic-dialog/generic-dialog.component.css b/client/src/app/components/dialog/generic-dialog/generic-dialog.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/app/components/dialog/generic-dialog/generic-dialog.component.html b/client/src/app/components/dialog/generic-dialog/generic-dialog.component.html deleted file mode 100644 index 7f39b11..0000000 --- a/client/src/app/components/dialog/generic-dialog/generic-dialog.component.html +++ /dev/null @@ -1,48 +0,0 @@ -
-

{{ data.title ?? 'Edit' }}

- - - @for (f of fields; track $index) { - - @if (f.type === 'checkbox') { - - {{ f.label }} - - } @else if (f.type === 'select') { - - {{ f.label }} - - - @let opts = (f.options ?? (f.options$ | async) ?? []); - - @for (opt of opts; track $index) { - - {{ f.displayKey ? opt?.[f.displayKey] : (opt?.name ?? opt?.label ?? opt) }} - - } - - - } @else { - - {{ f.label }} - - @if (f.type === 'textarea') { - - } @else { - - } - - } - - } - - - - - - -
diff --git a/client/src/app/components/dialog/generic-dialog/generic-dialog.component.ts b/client/src/app/components/dialog/generic-dialog/generic-dialog.component.ts deleted file mode 100644 index 9f9ccef..0000000 --- a/client/src/app/components/dialog/generic-dialog/generic-dialog.component.ts +++ /dev/null @@ -1,178 +0,0 @@ -import {Component, Inject, OnInit, OnDestroy} from '@angular/core'; -import {CommonModule} from '@angular/common'; -import {FormBuilder, FormGroup, ReactiveFormsModule} from '@angular/forms'; -import {MatDialogModule, MatDialogRef, MAT_DIALOG_DATA} from '@angular/material/dialog'; -import {MatFormFieldModule} from '@angular/material/form-field'; -import {MatInputModule} from '@angular/material/input'; -import {MatSelectModule} from '@angular/material/select'; -import {MatCheckboxModule} from '@angular/material/checkbox'; -import {MatButtonModule} from '@angular/material/button'; -import {Subscription} from 'rxjs'; - -@Component({ - selector: 'app-generic-dialog', - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - MatDialogModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatCheckboxModule, - MatButtonModule - ], - templateUrl: './generic-dialog.component.html', -}) -export class GenericDialogComponent implements OnInit, OnDestroy { - form!: FormGroup; - fields: any[] = []; - compareFns = new Map boolean>(); - optionsCache = new Map(); - private readonly subscriptions: Subscription[] = []; - - constructor( - private readonly fb: FormBuilder, - private readonly dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: { title?: string; fields?: any[]; model?: any } - ) { - this.fields = data?.fields ?? []; - } - - ngOnInit(): void { - const model = this.data?.model ?? {}; - const controls: { [key: string]: any[] } = {}; - - for (const f of this.fields) { - if (f.options && Array.isArray(f.options)) { - this.optionsCache.set(f.key, f.options); - } - - if (f.options$ && typeof f.options$.subscribe === 'function') { - try { - const sub = (f.options$ as any).subscribe((opts: any[]) => { - this.optionsCache.set(f.key, opts || []); - console.log(`[GenericDialog] options for "${f.key}":`, opts); - }, (err: any) => { - console.warn(`[GenericDialog] error loading options for "${f.key}":`, err); - }); - this.subscriptions.push(sub); - } catch (err) { - console.warn(`[GenericDialog] cannot subscribe to options$ for "${f.key}":`, err); - } - } - - let value = model?.[f.key]; - - if (f.type === 'checkbox') { - value = !!value; - } else if (f.type === 'select') { - if (value && typeof value === 'object' && f.valueKey) { - value = value[f.valueKey] ?? value; - } - - if (value === null || value === undefined) { - const idKey = `${f.key}Id`; - if (model[idKey] !== undefined) { - value = model[idKey]; - } - } - - if ((value === null || value === undefined) && f.key === 'brand') { - const platBrand = model?.platform?.brand; - if (platBrand) { - value = f.valueKey ? platBrand[f.valueKey] ?? platBrand : platBrand; - } - } - - if ((value === null || value === undefined) && f.key === 'condition') { - const cond = model?.condition; - if (cond) { - value = f.valueKey ? cond[f.valueKey] ?? cond : cond; - } - } - } else { - value = value ?? f.default ?? null; - } - - console.log(`[GenericDialog] field "${f.key}" computed initial value:`, value, 'valueKey:', f.valueKey); - - controls[f.key] = [value ?? (f.default ?? null)]; - - const valueKey = f.valueKey; - this.compareFns.set(f.key, (a: any, b: any) => { - if (a === null || a === undefined || b === null || b === undefined) { - return a === b; - } - if (valueKey) { - const aval = (typeof a === 'object') ? (a[valueKey] ?? a) : a; - const bval = (typeof b === 'object') ? (b[valueKey] ?? b) : b; - return String(aval) === String(bval); - } - return a === b; - }); - } - - this.form = this.fb.group(controls); - console.log('[GenericDialog] form initial value:', this.form.value); - - for (const f of this.fields) { - if (f.type === 'select') { - const ctrl = this.form.get(f.key); - if (ctrl) { - const sub = ctrl.valueChanges.subscribe((v) => { - console.log(`[GenericDialog] form control "${f.key}" valueChanges ->`, v); - }); - this.subscriptions.push(sub); - } - } - } - } - - save(): void { - if (this.form.invalid) { - this.form.markAllAsTouched(); - return; - } - - const raw = this.form.value; - const payload: any = {...raw}; - - for (const f of this.fields) { - if (f.type === 'select') { - const val = raw[f.key]; - if (f.valueKey) { - const opts = this.optionsCache.get(f.key) ?? []; - const found = opts.find((o: any) => String(o[f.valueKey]) === String(val)); - if (found) { - payload[f.key] = found; - } else if (val === null || val === undefined) { - payload[f.key] = null; - } else { - payload[f.key] = {[f.valueKey]: val}; - } - } else { - const opts = this.optionsCache.get(f.key) ?? []; - const found = opts.find((o: any) => o === val || JSON.stringify(o) === JSON.stringify(val)); - payload[f.key] = found ?? val; - } - } - } - - this.dialogRef.close(payload); - } - - close(): void { - this.dialogRef.close(null); - } - - ngOnDestroy(): void { - for (let subscription of this.subscriptions) { - subscription.unsubscribe(); - } - } - - getCompareFn(field: any) { - return this.compareFns.get(field?.key) ?? ((a: any, b: any) => a === b); - } -} diff --git a/client/src/app/components/list/brand-list/brand-list.component.css b/client/src/app/components/list/brand-list/brand-list.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/app/components/list/brand-list/brand-list.component.html b/client/src/app/components/list/brand-list/brand-list.component.html deleted file mode 100644 index 1dd9dd7..0000000 --- a/client/src/app/components/list/brand-list/brand-list.component.html +++ /dev/null @@ -1,9 +0,0 @@ - - diff --git a/client/src/app/components/list/brand-list/brand-list.component.ts b/client/src/app/components/list/brand-list/brand-list.component.ts deleted file mode 100644 index 98e6acc..0000000 --- a/client/src/app/components/list/brand-list/brand-list.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - Component, inject -} from '@angular/core'; -import {BrandService} from '../../../services/app/brand.service'; -import {GenericListComponent} from '../generic-list/generic-list.component'; - -@Component({ - selector: 'app-brand-list', - templateUrl: './brand-list.component.html', - standalone: true, - imports: [ - GenericListComponent - ], - styleUrls: ['./brand-list.component.css'] -}) -export class BrandListComponent { - - brandService: BrandService = inject(BrandService) - - fields = [ - {key: 'name', label: 'Nom', sortable: true} - ]; -} diff --git a/client/src/app/components/list/category-list/category-list.component.html b/client/src/app/components/list/category-list/category-list.component.html deleted file mode 100644 index 69a9f5e..0000000 --- a/client/src/app/components/list/category-list/category-list.component.html +++ /dev/null @@ -1,9 +0,0 @@ - - diff --git a/client/src/app/components/list/category-list/category-list.component.ts b/client/src/app/components/list/category-list/category-list.component.ts deleted file mode 100644 index 09387e5..0000000 --- a/client/src/app/components/list/category-list/category-list.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - Component, inject -} from '@angular/core'; -import {GenericListComponent} from '../generic-list/generic-list.component'; -import {CategoryService} from '../../../services/app/category.service'; - -@Component({ - selector: 'app-category-list', - templateUrl: './category-list.component.html', - standalone: true, - imports: [ - GenericListComponent - ], - styleUrls: ['./category.component.css'] -}) -export class CategoryListComponent { - - categoryService: CategoryService = inject(CategoryService) - - fields = [ - {key: 'name', label: 'Nom', sortable: true} - ]; -} diff --git a/client/src/app/components/list/category-list/category.component.css b/client/src/app/components/list/category-list/category.component.css deleted file mode 100644 index 87a4b85..0000000 --- a/client/src/app/components/list/category-list/category.component.css +++ /dev/null @@ -1,65 +0,0 @@ -:host { - display: block; - box-sizing: border-box; - width: 100%; -} - -.container { - max-width: 900px; - margin: 0 auto; - width: 100%; -} - -.toolbar { - display: flex; - gap: 12px; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.filter { - max-width: 240px; - width: 100%; -} - -table { - width: 100%; - overflow: auto; -} - -td, th { - word-break: break-word; - white-space: normal; -} - -.actions-cell { - display: flex; - gap: 8px; - justify-content: flex-end; - min-width: 120px; -} - -button.mat-icon-button { - width: 40px; - height: 40px; -} - -.no-brands { - text-align: center; - margin-top: 16px; - color: rgba(0,0,0,0.6); - padding: 8px 12px; -} - -@media (max-width: 600px) { - .toolbar { - flex-direction: column; - align-items: stretch; - gap: 8px; - } - - .actions-cell { - min-width: 0; - } -} diff --git a/client/src/app/components/list/generic-list/generic-list.component.css b/client/src/app/components/list/generic-list/generic-list.component.css deleted file mode 100644 index 2324320..0000000 --- a/client/src/app/components/list/generic-list/generic-list.component.css +++ /dev/null @@ -1,171 +0,0 @@ -/* ===== Container centré ===== */ -.generic-list { - display: flex; - flex-direction: column; - gap: 1rem; - padding: 1rem clamp(1rem, 3vw, 3rem); - max-width: 1200px; - margin: 0 auto; -} - -/* ===== Header ===== */ -.gl-header { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; - border-bottom: 1px solid rgba(0, 0, 0, .08); - padding-bottom: .75rem; -} - -.gl-title { - margin: 0; - font-size: clamp(1.1rem, 1.3rem + 0.3vw, 1.6rem); - font-weight: 600; -} - -/* ===== Cartes (filtre, tableau, pagination) partagent le même style ===== */ -.gl-block { - border: 1px solid rgba(0, 0, 0, .08); - border-radius: 12px; - background: var(--gl-surface, #fff); - box-shadow: 0 1px 2px rgba(0, 0, 0, .04), - 0 2px 8px rgba(0, 0, 0, .06); -} - -/* ===== Barre de filtre ===== */ -.gl-filter-bar { - padding: .75rem; -} - -.gl-filter { - display: block; - width: 100%; - max-width: none; -} - -/* ===== Tableau ===== */ -.gl-table-wrapper { - overflow: auto; - -webkit-overflow-scrolling: touch; - padding: 0.25rem 0.5rem; -} - -.gl-table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - min-width: 600px; -} - -/* Header sticky */ -.gl-table th[mat-header-cell] { - position: sticky; - top: 0; - z-index: 2; - background: inherit; - box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .08); -} - -/* Cellules */ -.gl-table th[mat-header-cell], -.gl-table td[mat-cell] { - padding: 14px 18px; - vertical-align: middle; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; -} - -/* Zebra + hover */ -.gl-table tr.mat-mdc-row:nth-child(odd) td[mat-cell] { - background: rgba(0, 0, 0, .015); -} - -.gl-table tr.mat-mdc-row:hover td[mat-cell] { - background: rgba(0, 0, 0, .035); -} - -/* Actions */ -.actions-cell { - display: flex; - align-items: center; - justify-content: center; - gap: .4rem; -} - -.actions-cell .mat-mdc-icon-button { - width: 40px; - height: 40px; -} - -/* ===== Pagination ===== */ -.gl-paginator-wrap { - padding: .25rem .5rem; -} - -.gl-paginator { - margin-top: .25rem; - padding-top: .5rem; - border-top: 1px solid rgba(0, 0, 0, .08); - display: flex; - justify-content: flex-end; -} - -/* ===== Responsive ===== */ -@media (max-width: 799px) { - .generic-list { - padding: 0.75rem 1rem; - } - - .gl-header { - flex-direction: column; - align-items: stretch; - gap: 0.75rem; - } - - .gl-table { - min-width: 0; - } - - .gl-table th[mat-header-cell], - .gl-table td[mat-cell] { - white-space: normal; - padding: 10px 12px; - } - - .actions-cell { - justify-content: flex-start; - } -} - -/* ===== Dark mode ===== */ -@media (prefers-color-scheme: dark) { - .gl-block { - background: #1b1b1b; - border-color: rgba(255, 255, 255, .08); - box-shadow: 0 1px 2px rgba(0, 0, 0, .6), - 0 2px 8px rgba(0, 0, 0, .45); - } - - .gl-header { - border-bottom-color: rgba(255, 255, 255, .08); - } - - .gl-table th[mat-header-cell] { - box-shadow: inset 0 -1px 0 rgba(255, 255, 255, .08); - } - - .gl-table tr.mat-mdc-row:nth-child(odd) td[mat-cell] { - background: rgba(255, 255, 255, .025); - } - - .gl-table tr.mat-mdc-row:hover td[mat-cell] { - background: rgba(255, 255, 255, .06); - } - - .gl-paginator { - border-top-color: rgba(255, 255, 255, .08); - } -} diff --git a/client/src/app/components/list/generic-list/generic-list.component.html b/client/src/app/components/list/generic-list/generic-list.component.html deleted file mode 100644 index b490c2e..0000000 --- a/client/src/app/components/list/generic-list/generic-list.component.html +++ /dev/null @@ -1,86 +0,0 @@ -
-
-

{{ title }}

- -
- -
-
- -
- - Rechercher - - -
- -
- - @for (col of (fields ?? []); track $index) { - - - - - - } - - - - - - - - -
- {{ col.label }} - - {{ displayValue(element, col) }} - Actions - - -
-
- -
- -
-
diff --git a/client/src/app/components/list/generic-list/generic-list.component.ts b/client/src/app/components/list/generic-list/generic-list.component.ts deleted file mode 100644 index 495332a..0000000 --- a/client/src/app/components/list/generic-list/generic-list.component.ts +++ /dev/null @@ -1,219 +0,0 @@ -import {Component, Input, Output, EventEmitter, ViewChild, AfterViewInit, OnInit} from '@angular/core'; -import {MatTableDataSource, MatTableModule} from '@angular/material/table'; -import {MatPaginator, MatPaginatorModule} from '@angular/material/paginator'; -import {MatSort, MatSortModule} from '@angular/material/sort'; -import {MatDialog, MatDialogModule} from '@angular/material/dialog'; -import {MatButtonModule} from '@angular/material/button'; -import {CommonModule} from '@angular/common'; -import {CrudService} from '../../../services/crud.service'; -import {GenericDialogComponent} from '../../dialog/generic-dialog/generic-dialog.component'; -import {MatFormField, MatInput, MatLabel} from '@angular/material/input'; -import {MatIcon} from '@angular/material/icon'; -import {ConfirmDialogComponent} from '../../dialog/confirm-dialog/confirm-dialog.component'; -import {MatChip} from '@angular/material/chips'; - -type Field = { - key: string; - label: string; - sortable?: boolean; - displayKey?: string; - displayFn?: (value: any, element?: any) => string; - sortKey?: string | ((item: any) => any); - sortFn?: (a: any, b: any) => number; -}; - -@Component({ - selector: 'app-generic-list', - standalone: true, - imports: [CommonModule, MatTableModule, MatPaginatorModule, MatSortModule, MatDialogModule, MatButtonModule, MatInput, MatLabel, MatFormField, MatIcon, MatChip], - templateUrl: './generic-list.component.html', - styleUrl: './generic-list.component.css' -}) -export class GenericListComponent implements OnInit, AfterViewInit { - @Input() service!: CrudService; - @Input() fields?: Field[]; - @Input() title = ''; - @Input() addTitle?: string; - @Input() editTitle?: string; - @Input() deleteTitle?: string; - @Input() idKey = 'id'; - @Input() dialogComponent: any = GenericDialogComponent; - @Output() add = new EventEmitter(); - @Output() edit = new EventEmitter(); - @Output() delete = new EventEmitter(); - - dataSource = new MatTableDataSource([]); - displayedColumns: string[] = []; - - @ViewChild(MatPaginator) paginator!: MatPaginator; - @ViewChild(MatSort) sort!: MatSort; - - constructor(private readonly dialog: MatDialog) { - } - - ngOnInit() { - this.fields = this.fields ?? []; - this.displayedColumns = this.fields.map(f => f.key).concat(['actions']); - this.load(); - } - - ngAfterViewInit() { - - this.dataSource.sortingDataAccessor = (data: any, sortHeaderId: string) => { - const field = this.fields!.find(f => f.key === sortHeaderId); - if (!field) { - const raw = getByPath(data, sortHeaderId) ?? data?.[sortHeaderId]; - return raw == null ? '' : String(raw); - } - - if (field.sortKey) { - if (typeof field.sortKey === 'function') { - const v = field.sortKey(data); - return v == null ? '' : String(v); - } - const v = getByPath(data, field.sortKey as string); - return v == null ? '' : String(v); - } - - const val = getByPath(data, field.key); - if (val == null) return ''; - if (typeof val === 'object') { - if (field.displayKey && val[field.displayKey] != null) return String(val[field.displayKey]); - const candidates = ['name', 'title', 'label', 'id']; - for (const k of candidates) { - if (val[k] != null) return String(val[k]); - } - try { - return JSON.stringify(val); - } catch { - return String(val); - } - } - return String(val); - }; - - this.dataSource.sort = this.sort; - this.dataSource.paginator = this.paginator; - - const originalSortData = this.dataSource.sortData; - this.dataSource.sortData = (data: T[], sort: MatSort) => { - if (!sort || !sort.active || sort.direction === '') return originalSortData.call(this.dataSource, data, sort); - const field = this.fields!.find(f => f.key === sort.active); - if (field?.sortFn) { - const dir = sort.direction === 'asc' ? 1 : -1; - return [...data].sort((a, b) => dir * field.sortFn!(a, b)); - } - return originalSortData.call(this.dataSource, data, sort); - }; - } - - load() { - this.service.getAll().subscribe(items => { - console.debug('Loaded items from service:', items); - this.dataSource.data = (items as any[]).map(item => { - const normalizedId = getByPath(item, this.idKey) ?? item?.[this.idKey] ?? item?.id ?? item?._id ?? item?.platformId ?? null; - return {...item, [this.idKey]: normalizedId}; - }) as T[]; - }); - } - - applyFilter(value: string) { - this.dataSource.filter = (value || '').trim().toLowerCase(); - } - - openDialog(item: any | null) { - const originalId = item ? (getByPath(item, this.idKey) ?? item?.[this.idKey] ?? item?.id ?? item?._id) : null; - - const dialogTitle = item - ? (this.editTitle ?? 'Modifier') - : (this.addTitle ?? 'Ajouter'); - - const dialogRef = this.dialog.open(this.dialogComponent, { - width: '420px', - data: { - item: item ? {...item} : {}, - fields: this.fields, - title: dialogTitle, - originalId - } - }); - - dialogRef.afterClosed().subscribe((result: any) => { - if (!result) return; - - if (item) { - const idToUpdate = originalId ?? getByPath(result, this.idKey) ?? result?.[this.idKey] ?? result?.id ?? result?._id; - if (idToUpdate == null) { - console.error('Cannot update: id is null/undefined for item', {item, result}); - return; - } - this.service.update(idToUpdate, result).subscribe(() => { - this.edit.emit(result); - this.load(); - }); - } else { - this.service.add(result).subscribe(() => { - this.add.emit(result); - this.load(); - }); - } - }); - } - - remove(item: any) { - const id = getByPath(item, this.idKey) ?? item[this.idKey] ?? item?.id ?? item?._id; - if (id == null) { - console.error('Cannot delete: id is null/undefined for item', item); - return; - } - - const dialogRef = this.dialog.open(ConfirmDialogComponent, { - width: '380px', - data: { - title: this.deleteTitle ?? 'Confirmer la suppression', - message: 'Voulez-vous vraiment supprimer cet élément ? Cette action est irréversible.' - } - }); - - dialogRef.afterClosed().subscribe((confirmed: boolean) => { - if (!confirmed) return; - this.service.delete(id).subscribe(() => { - this.delete.emit(item); - this.load(); - }); - }); - } - - displayValue(element: any, field: Field): string { - const val = getByPath(element, field.key); - if (field.displayFn) { - try { - return String(field.displayFn(val, element) ?? ''); - } catch { - return ''; - } - } - if (val == null) return ''; - if (typeof val === 'object') { - if (field.displayKey && val[field.displayKey] != null) return String(val[field.displayKey]); - const candidates = ['name', 'title', 'label', 'id']; - for (const k of candidates) { - if (val[k] != null) return String(val[k]); - } - try { - return JSON.stringify(val); - } catch { - return String(val); - } - } - return String(val); - } - - protected readonly HTMLInputElement = HTMLInputElement; -} - -function getByPath(obj: any, path: string | undefined): any { - if (!obj || !path) return undefined; - if (typeof path !== 'string') return undefined; - return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj); -} diff --git a/client/src/app/components/list/platform-list/platform-list.component.css b/client/src/app/components/list/platform-list/platform-list.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/app/components/list/platform-list/platform-list.component.html b/client/src/app/components/list/platform-list/platform-list.component.html deleted file mode 100644 index 7d45967..0000000 --- a/client/src/app/components/list/platform-list/platform-list.component.html +++ /dev/null @@ -1,9 +0,0 @@ - - diff --git a/client/src/app/components/list/platform-list/platform-list.component.ts b/client/src/app/components/list/platform-list/platform-list.component.ts deleted file mode 100644 index 1af718d..0000000 --- a/client/src/app/components/list/platform-list/platform-list.component.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - Component, - inject -} from '@angular/core'; -import {PlatformService} from '../../../services/app/platform.service'; -import {GenericListComponent} from '../generic-list/generic-list.component'; -import {BrandService} from '../../../services/app/brand.service'; - -@Component({ - selector: 'app-platform-list', - templateUrl: './platform-list.component.html', - standalone: true, - imports: [ - GenericListComponent - ], - styleUrls: ['./platform-list.component.css'] -}) -export class PlatformListComponent { - - platformService: PlatformService = inject(PlatformService) - brandService: BrandService = inject(BrandService); - - fields = [ - {key: 'name', label: 'Nom', sortable: true}, - { - key: 'brand', - label: 'Marque', - type: 'select', - options$: this.brandService.getAll(), - displayKey: 'name', - sortable: true, - sortKey: 'brand.name' - } - ]; -} diff --git a/client/src/app/components/navbar/main-navbar/main-navbar.component.css b/client/src/app/components/main-navbar/main-navbar.component.css similarity index 100% rename from client/src/app/components/navbar/main-navbar/main-navbar.component.css rename to client/src/app/components/main-navbar/main-navbar.component.css diff --git a/client/src/app/components/navbar/main-navbar/main-navbar.component.html b/client/src/app/components/main-navbar/main-navbar.component.html similarity index 87% rename from client/src/app/components/navbar/main-navbar/main-navbar.component.html rename to client/src/app/components/main-navbar/main-navbar.component.html index 3744d3f..bf5757c 100644 --- a/client/src/app/components/navbar/main-navbar/main-navbar.component.html +++ b/client/src/app/components/main-navbar/main-navbar.component.html @@ -10,10 +10,6 @@ @if (authService.hasRole('Administrator')) { - + + + + Filtrer + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
ID{{ el.id }}Nom{{ el.name }}Actions + + +
+ Aucune donnée ne correspond au filtre. +
+ + +
+ diff --git a/client/src/app/components/ps-generic-crud/ps-admin-crud.component.ts b/client/src/app/components/ps-generic-crud/ps-admin-crud.component.ts new file mode 100644 index 0000000..639652b --- /dev/null +++ b/client/src/app/components/ps-generic-crud/ps-admin-crud.component.ts @@ -0,0 +1,164 @@ +import {CommonModule} from '@angular/common'; +import {AfterViewInit, Component, inject, Input, OnDestroy, OnInit, ViewChild} from '@angular/core'; +import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; +import { + MatCell, + MatCellDef, + MatColumnDef, + MatHeaderCell, + MatHeaderCellDef, + MatHeaderRow, MatHeaderRowDef, MatNoDataRow, MatRow, MatRowDef, + MatTable, MatTableDataSource +} from '@angular/material/table'; +import {MatSort, MatSortModule} from '@angular/material/sort'; +import {MatPaginator, MatPaginatorModule} from '@angular/material/paginator'; +import {MatFormField, MatLabel} from '@angular/material/form-field'; +import {MatInput} from '@angular/material/input'; +import {MatButton, MatIconButton} from '@angular/material/button'; +import {MatIcon} from '@angular/material/icon'; +import {PrestashopService} from '../../services/prestashop.serivce'; +import {debounceTime, Observable, Subject, takeUntil} from 'rxjs'; +import {PsItem} from '../../interfaces/ps-item'; +import {PsItemDialogComponent} from '../ps-item-dialog/ps-item-dialog.component'; +import {MatDialog} from '@angular/material/dialog'; + + +type Resource = 'categories' | 'manufacturers' | 'suppliers'; + +@Component({ + selector: 'app-ps-admin-crud', + standalone: true, + templateUrl: './ps-admin-crud.component.html', + styleUrls: ['./ps-admin-crud.component.css'], + imports: [ + CommonModule, + ReactiveFormsModule, + MatTable, MatColumnDef, MatHeaderCell, MatHeaderCellDef, MatCell, MatCellDef, + MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef, + MatSortModule, MatPaginatorModule, + MatFormField, MatLabel, MatInput, + MatButton, MatIconButton, MatIcon, MatNoDataRow + ] +}) +export class PsAdminCrudComponent implements OnInit, AfterViewInit, OnDestroy { + + @Input({required: true}) resource!: Resource; + @Input({required: true}) label!: string; + + private readonly fb = inject(FormBuilder); + private readonly ps = inject(PrestashopService); + private readonly dialog = inject(MatDialog); + private readonly destroy$ = new Subject(); + + dataSource = new MatTableDataSource([]); + displayedColumns: string[] = ['id', 'name', 'actions']; + + form = this.fb.group({name: ['', Validators.required]}); + editId: number | null = null; + + @ViewChild(MatPaginator) paginator!: MatPaginator; + @ViewChild(MatSort) sort!: MatSort; + @ViewChild(MatTable) table!: MatTable; + + private readonly filter$: Subject = new Subject(); + + ngOnInit(): void { + this.dataSource.filterPredicate = (row, filter) => { + const f = filter.trim().toLowerCase(); + return ( + String(row.id).toLowerCase().includes(f) || + String(row.name ?? '').toLowerCase().includes(f) + ); + }; + this.filter$.pipe(debounceTime(150), takeUntil(this.destroy$)) + .subscribe(v => { + this.dataSource.filter = (v ?? '').trim().toLowerCase(); + if (this.paginator) this.paginator.firstPage(); + }); + + this.reload(); + } + + ngAfterViewInit(): void { + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + reload(): void { + this.ps.list(this.resource).subscribe({ + next: items => { + this.dataSource.data = items; + // rafraîchir le rendu si nécessaire + this.table?.renderRows?.(); + }, + error: e => alert('Erreur de chargement: ' + (e?.message || e)) + }); + } + + createNew() { + const ref = this.dialog.open(PsItemDialogComponent, { + width: '400px', + data: {label: this.label, title: `Créer ${this.label}`} + }); + ref.afterClosed().subscribe((name: string | null) => { + if (!name) return; + this.ps.create(this.resource, name).subscribe({ + next: () => this.reload(), + error: e => alert('Erreur: ' + (e?.message || e)) + }); + }); + } + + startEdit(row: PsItem) { + const ref = this.dialog.open(PsItemDialogComponent, { + width: '400px', + data: {label: this.label, name: row.name, title: `Modifier ${this.label} #${row.id}`} + }); + ref.afterClosed().subscribe((name: string | null) => { + if (!name) return; + this.ps.update(this.resource, row.id, name).subscribe({ + next: () => this.reload(), + error: e => alert('Erreur: ' + (e?.message || e)) + }); + }); + } + + cancelEdit() { + this.editId = null; + this.form.reset({name: ''}); + } + + onSubmit() { + const name = (this.form.value.name ?? '').trim(); + if (!name) return; + + const req$: Observable = this.editId + ? this.ps.update(this.resource, this.editId, name) as Observable + : this.ps.create(this.resource, name) as Observable; + + req$.subscribe({ + next: () => { + this.cancelEdit(); + this.reload(); + }, + error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e))) + }); + } + + remove(row: PsItem) { + if (!confirm(`Supprimer ${this.label.toLowerCase()} "#${row.id} ${row.name} ?`)) return; + this.ps.delete(this.resource, row.id).subscribe({ + next: () => this.reload(), + error: e => alert('Erreur: ' + (e?.message || e)) + }); + } + + applyFilter(value: string) { + this.filter$.next(value); + } +} diff --git a/client/src/app/components/ps-item-dialog/ps-item-dialog.component.css b/client/src/app/components/ps-item-dialog/ps-item-dialog.component.css new file mode 100644 index 0000000..ff72129 --- /dev/null +++ b/client/src/app/components/ps-item-dialog/ps-item-dialog.component.css @@ -0,0 +1,3 @@ +.full { + width: 100%; +} diff --git a/client/src/app/components/ps-item-dialog/ps-item-dialog.component.html b/client/src/app/components/ps-item-dialog/ps-item-dialog.component.html new file mode 100644 index 0000000..4861373 --- /dev/null +++ b/client/src/app/components/ps-item-dialog/ps-item-dialog.component.html @@ -0,0 +1,11 @@ +

{{ data.title || 'Élément' }}

+
+ + Nom de {{ data.label.toLowerCase() || '' }} + + +
+ + +
+
diff --git a/client/src/app/components/ps-item-dialog/ps-item-dialog.component.ts b/client/src/app/components/ps-item-dialog/ps-item-dialog.component.ts new file mode 100644 index 0000000..548fbe1 --- /dev/null +++ b/client/src/app/components/ps-item-dialog/ps-item-dialog.component.ts @@ -0,0 +1,54 @@ +import {CommonModule} from '@angular/common'; +import {Component, inject, Inject} from '@angular/core'; +import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; +import {MatFormField, MatLabel} from '@angular/material/form-field'; +import {MatInput} from '@angular/material/input'; +import {MatButton} from '@angular/material/button'; +import { + MatDialogRef, + MAT_DIALOG_DATA, + MatDialogTitle, + MatDialogContent, + MatDialogActions, MatDialogModule +} from '@angular/material/dialog'; + +@Component({ + selector: 'app-ps-item-dialog', + standalone: true, + templateUrl: './ps-item-dialog.component.html', + styleUrls: ['./ps-item-dialog.component.css'], + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatDialogTitle, + MatDialogContent, + MatFormField, + MatLabel, + MatInput, + MatDialogActions, + MatButton + ] +}) +export class PsItemDialogComponent { + private readonly fb = inject(FormBuilder); + + constructor( + private readonly dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { label: string; name?: string; title?: string } + ) { + this.form = this.fb.group({name: [data?.name ?? '', Validators.required]}); + } + + form = this.fb.group({name: ['', Validators.required]}); + + confirm() { + const name = (this.form.value.name ?? '').trim(); + if (!name) return; + this.dialogRef.close(name); + } + + cancel() { + this.dialogRef.close(null); + } +} diff --git a/client/src/app/components/ps-product-crud/ps-product-crud.component.css b/client/src/app/components/ps-product-crud/ps-product-crud.component.css new file mode 100644 index 0000000..c87c629 --- /dev/null +++ b/client/src/app/components/ps-product-crud/ps-product-crud.component.css @@ -0,0 +1,19 @@ +.crud { + display: grid; + gap: 16px; +} + +.toolbar { + display: flex; + gap: 12px; + align-items: center; +} + +.toolbar .filter { + margin-left: auto; + min-width: 360px; +} + +table { + width: 100%; +} diff --git a/client/src/app/components/ps-product-crud/ps-product-crud.component.html b/client/src/app/components/ps-product-crud/ps-product-crud.component.html new file mode 100644 index 0000000..0dd31e9 --- /dev/null +++ b/client/src/app/components/ps-product-crud/ps-product-crud.component.html @@ -0,0 +1,64 @@ +
+
+ + + + Filtrer + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ el.id }}Nom{{ el.name }}Catégorie{{ el.categoryName }}Marque{{ el.manufacturerName }}Fournisseur{{ el.supplierName }}Prix TTC (€){{ el.priceTtc | number:'1.2-2' }}Actions + + +
Aucune donnée.
+ + +
+
diff --git a/client/src/app/components/ps-product-crud/ps-product-crud.component.ts b/client/src/app/components/ps-product-crud/ps-product-crud.component.ts new file mode 100644 index 0000000..2593825 --- /dev/null +++ b/client/src/app/components/ps-product-crud/ps-product-crud.component.ts @@ -0,0 +1,169 @@ +import {Component, inject, OnInit, ViewChild} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import { + MatCell, MatCellDef, MatColumnDef, MatHeaderCell, MatHeaderCellDef, + MatHeaderRow, MatHeaderRowDef, MatNoDataRow, MatRow, MatRowDef, + MatTable, MatTableDataSource +} from '@angular/material/table'; +import {MatPaginator, MatPaginatorModule} from '@angular/material/paginator'; +import {MatSort, MatSortModule} from '@angular/material/sort'; +import {MatFormField, MatLabel} from '@angular/material/form-field'; +import {MatInput} from '@angular/material/input'; +import {MatButton, MatIconButton} from '@angular/material/button'; +import {MatIcon} from '@angular/material/icon'; +import {FormBuilder, ReactiveFormsModule} from '@angular/forms'; +import {MatDialog, MatDialogModule} from '@angular/material/dialog'; + +import {PsItem} from '../../interfaces/ps-item'; +import {ProductListItem} from '../../interfaces/product-list-item'; +import {PrestashopService} from '../../services/prestashop.serivce'; +import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/ps-product-dialog.component'; + +@Component({ + selector: 'app-ps-product-crud', + standalone: true, + templateUrl: './ps-product-crud.component.html', + styleUrls: ['./ps-product-crud.component.css'], + imports: [ + CommonModule, ReactiveFormsModule, + MatTable, MatColumnDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef, + MatHeaderCell, MatHeaderCellDef, MatCell, MatCellDef, MatNoDataRow, + MatSortModule, MatPaginatorModule, + MatFormField, MatLabel, MatInput, + MatButton, MatIconButton, MatIcon, + MatDialogModule + ] +}) +export class PsProductCrudComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly ps = inject(PrestashopService); + private readonly dialog = inject(MatDialog); + + // référentiels + categories: PsItem[] = []; + manufacturers: PsItem[] = []; + suppliers: PsItem[] = []; + + // maps d’affichage + private catMap = new Map(); + private manMap = new Map(); + private supMap = new Map(); + + // table + displayed: string[] = ['id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'actions']; + dataSource = new MatTableDataSource([]); + @ViewChild(MatPaginator) paginator!: MatPaginator; + @ViewChild(MatSort) sort!: MatSort; + @ViewChild(MatTable) table!: MatTable; + + // filtre + filterCtrl = this.fb.control(''); + + ngOnInit(): void { + // charger référentiels en parallèle + Promise.all([ + this.ps.list('categories').toPromise(), + this.ps.list('manufacturers').toPromise(), + this.ps.list('suppliers').toPromise() + ]).then(([cats, mans, sups]) => { + this.categories = cats ?? []; + this.catMap = new Map(this.categories.map(x => [x.id, x.name])); + this.manufacturers = mans ?? []; + this.manMap = new Map(this.manufacturers.map(x => [x.id, x.name])); + this.suppliers = sups ?? []; + this.supMap = new Map(this.suppliers.map(x => [x.id, x.name])); + this.reload(); + }); + + // filtre client + this.filterCtrl.valueChanges.subscribe(v => { + this.dataSource.filter = (v ?? '').toString().trim().toLowerCase(); + if (this.paginator) this.paginator.firstPage(); + }); + this.dataSource.filterPredicate = (row: any, f: string) => + row.name?.toLowerCase().includes(f) || + String(row.id).includes(f) || + (row.categoryName?.toLowerCase().includes(f)) || + (row.manufacturerName?.toLowerCase().includes(f)) || + (row.supplierName?.toLowerCase().includes(f)); + } + + private toTtc(ht: number, vat: number) { + return Math.round(((ht * (1 + vat)) + Number.EPSILON) * 100) / 100; + } + + private attachSortingAccessors() { + this.dataSource.sortingDataAccessor = (item: any, property: string) => { + switch (property) { + case 'category': + return (item.categoryName ?? '').toLowerCase(); + case 'manufacturer': + return (item.manufacturerName ?? '').toLowerCase(); + case 'supplier': + return (item.supplierName ?? '').toLowerCase(); + case 'priceTtc': + return Number(item.priceTtc ?? 0); + case 'name': + return (item.name ?? '').toLowerCase(); + default: + return item[property]; + } + }; + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + + private bindProducts(p: (ProductListItem & { priceHt?: number })[]) { + const vat = 0.20; // valeur fixe utilisée pour calcul TTC en liste + this.dataSource.data = p.map(x => ({ + ...x, + categoryName: x.id_category_default ? (this.catMap.get(x.id_category_default) ?? '') : '', + manufacturerName: x.id_manufacturer ? (this.manMap.get(x.id_manufacturer) ?? '') : '', + supplierName: x.id_supplier ? (this.supMap.get(x.id_supplier) ?? '') : '', + priceTtc: this.toTtc(x.priceHt ?? 0, vat) + })); + this.attachSortingAccessors(); + this.table?.renderRows?.(); + } + + reload() { + this.ps.listProducts().subscribe(p => this.bindProducts(p)); + } + + create() { + const data: ProductDialogData = { + mode: 'create', + refs: { + categories: this.categories, + manufacturers: this.manufacturers, + suppliers: this.suppliers + } + }; + this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => { + if (ok) this.reload(); + }); + } + + edit(row: ProductListItem & { priceHt?: number }) { + const data: ProductDialogData = { + mode: 'edit', + productRow: row, + refs: { + categories: this.categories, + manufacturers: this.manufacturers, + suppliers: this.suppliers + } + }; + this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => { + if (ok) this.reload(); + }); + } + + remove(row: ProductListItem) { + if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return; + this.ps.deleteProduct(row.id).subscribe({ + next: () => this.reload(), + error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e))) + }); + } +} diff --git a/client/src/app/components/ps-product-dialog/ps-product-dialog.component.css b/client/src/app/components/ps-product-dialog/ps-product-dialog.component.css new file mode 100644 index 0000000..eaf109d --- /dev/null +++ b/client/src/app/components/ps-product-dialog/ps-product-dialog.component.css @@ -0,0 +1,36 @@ +.grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(12, 1fr); + align-items: start; +} + +.col-12 { + grid-column: span 12; +} + +.col-6 { + grid-column: span 6; +} + +.col-4 { + grid-column: span 4; +} + +.flags { + display: flex; + gap: 16px; + align-items: center; +} + +.thumbs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.thumbs img { + height: 64px; + border-radius: 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, .2); +} diff --git a/client/src/app/components/ps-product-dialog/ps-product-dialog.component.html b/client/src/app/components/ps-product-dialog/ps-product-dialog.component.html new file mode 100644 index 0000000..9ca0750 --- /dev/null +++ b/client/src/app/components/ps-product-dialog/ps-product-dialog.component.html @@ -0,0 +1,78 @@ +

{{ mode === 'create' ? 'Nouveau produit' : 'Modifier le produit' }}

+ +
+ + Nom du produit + + + + + Catégorie + + Choisir… + @for (c of categories; track c.id) { + {{ c.name }} + } + + + + + Marque + + Choisir… + @for (m of manufacturers; track m.id) { + {{ m.name }} + } + + + + + Fournisseur + + Choisir… + @for (s of suppliers; track s.id) { + {{ s.name }} + } + + + + + Description + + + +
+ Complet + Notice +
+ +
+ + +
+ +
+
+ @for (url of existingImageUrls; track url) { + Produit + } +
+
+ + + Prix TTC (€) + + + + + Quantité + + +
+ +
+ + +
diff --git a/client/src/app/components/ps-product-dialog/ps-product-dialog.component.ts b/client/src/app/components/ps-product-dialog/ps-product-dialog.component.ts new file mode 100644 index 0000000..aea6f59 --- /dev/null +++ b/client/src/app/components/ps-product-dialog/ps-product-dialog.component.ts @@ -0,0 +1,195 @@ +import { Component, Inject, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatButton } from '@angular/material/button'; +import { + MatDialogRef, + MAT_DIALOG_DATA, + MatDialogActions, + MatDialogContent, + MatDialogTitle +} from '@angular/material/dialog'; + +import { catchError, forkJoin, of, Observable } from 'rxjs'; + +import { PsItem } from '../../interfaces/ps-item'; +import { ProductListItem } from '../../interfaces/product-list-item'; +import {PrestashopService} from '../../services/prestashop.serivce'; + +export type ProductDialogData = { + mode: 'create' | 'edit'; + refs: { categories: PsItem[]; manufacturers: PsItem[]; suppliers: PsItem[]; }; + productRow?: ProductListItem & { priceHt?: number }; +}; + +@Component({ + selector: 'app-ps-product-dialog', + standalone: true, + templateUrl: './ps-product-dialog.component.html', + styleUrls: ['./ps-product-dialog.component.css'], + imports: [ + CommonModule, ReactiveFormsModule, + MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox, + MatButton, MatDialogActions, MatDialogContent, MatDialogTitle + ] +}) +export class PsProductDialogComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly ps = inject(PrestashopService); + + constructor( + @Inject(MAT_DIALOG_DATA) public data: ProductDialogData, + private readonly dialogRef: MatDialogRef + ) {} + + mode!: 'create' | 'edit'; + categories: PsItem[] = []; + manufacturers: PsItem[] = []; + suppliers: PsItem[] = []; + productRow?: ProductListItem & { priceHt?: number }; + + images: File[] = []; + existingImageUrls: string[] = []; + + // on conserve la dernière description chargée pour éviter l’écrasement à vide + private lastLoadedDescription = ''; + + form = this.fb.group({ + name: ['', Validators.required], + description: [''], + categoryId: [null as number | null, Validators.required], + manufacturerId: [null as number | null, Validators.required], + supplierId: [null as number | null, Validators.required], + complete: [false], + hasManual: [false], + priceTtc: [0, [Validators.required, Validators.min(0)]], + quantity: [0, [Validators.required, Validators.min(0)]], + }); + + private toTtc(ht: number) { return Math.round(((ht * 1.2) + Number.EPSILON) * 100) / 100; } + + /** enlève si présent */ + private stripCdata(s: string): string { + if (!s) return ''; + return s.startsWith('') ? s.slice(9, -3) : s; + } + /** convertit du HTML en texte (pour le textarea) */ + private htmlToText(html: string): string { + if (!html) return ''; + const div = document.createElement('div'); + div.innerHTML = html; + return (div.textContent || div.innerText || '').trim(); + } + /** nettoyage CDATA+HTML -> texte simple */ + private cleanForTextarea(src: string): string { + return this.htmlToText(this.stripCdata(src ?? '')); + } + + /** sépare la description "contenu" des drapeaux + détecte Complet/Notice */ + private splitDescriptionFlags(desc: string) { + const cleaned = this.cleanForTextarea(desc); + const complete = /Complet\s*:\s*Oui/i.test(desc); + const hasManual = /Notice\s*:\s*Oui/i.test(desc); + const idx = cleaned.indexOf('Complet:'); + const base = (idx >= 0 ? cleaned.slice(0, idx) : cleaned).trim(); + return { base, complete, hasManual }; + } + + ngOnInit(): void { + this.mode = this.data.mode; + this.categories = this.data.refs.categories ?? []; + this.manufacturers = this.data.refs.manufacturers ?? []; + this.suppliers = this.data.refs.suppliers ?? []; + this.productRow = this.data.productRow; + + if (this.mode === 'edit' && this.productRow) { + const r = this.productRow; + + // patch immédiat depuis la ligne + const immediateTtc = r.priceHt == null ? 0 : this.toTtc(r.priceHt); + this.form.patchValue({ + name: r.name, + categoryId: r.id_category_default ?? null, + manufacturerId: r.id_manufacturer ?? null, + supplierId: r.id_supplier ?? null, + priceTtc: immediateTtc + }); + + // patch final via API (tolérant aux erreurs) + const details$ = this.ps.getProductDetails(r.id).pipe( + catchError(() => of({ + id: r.id, name: r.name, description: '', + id_manufacturer: r.id_manufacturer, id_supplier: r.id_supplier, + id_category_default: r.id_category_default, priceHt: r.priceHt ?? 0 + })) + ); + const qty$ = this.ps.getProductQuantity(r.id).pipe(catchError(() => of(0))); + const imgs$ = this.ps.getProductImageUrls(r.id).pipe(catchError(() => of([]))); + + forkJoin({ details: details$, qty: qty$, imgs: imgs$ }) + .subscribe(({ details, qty, imgs }) => { + const ttc = this.toTtc(details.priceHt ?? 0); + const { base, complete, hasManual } = this.splitDescriptionFlags(details.description ?? ''); + this.lastLoadedDescription = base; + + this.form.patchValue({ + description: base, + complete, hasManual, + priceTtc: (ttc || this.form.value.priceTtc || 0), + quantity: qty, + categoryId: (details.id_category_default ?? this.form.value.categoryId) ?? null, + manufacturerId: (details.id_manufacturer ?? this.form.value.manufacturerId) ?? null, + supplierId: (details.id_supplier ?? this.form.value.supplierId) ?? null + }); + this.existingImageUrls = imgs; + }); + } + } + + onFiles(ev: Event) { + const fl = (ev.target as HTMLInputElement).files; + this.images = fl ? Array.from(fl) : []; + } + + save() { + if (this.form.invalid) return; + + const v = this.form.getRawValue(); + const effectiveDescription = (v.description ?? '').trim() || this.lastLoadedDescription; + + const dto = { + name: v.name!, + description: effectiveDescription, + categoryId: +v.categoryId!, + manufacturerId: +v.manufacturerId!, + supplierId: +v.supplierId!, + images: this.images, + complete: !!v.complete, + hasManual: !!v.hasManual, + conditionLabel: undefined, + priceTtc: Number(v.priceTtc ?? 0), + vatRate: 0.2, + quantity: Math.max(0, Number(v.quantity ?? 0)) + }; + + let op$: Observable; + if (this.mode === 'create' || !this.productRow) { + op$ = this.ps.createProduct(dto) as Observable; + } else { + op$ = this.ps.updateProduct(this.productRow.id, dto) as Observable; + } + + op$.subscribe({ + next: () => this.dialogRef.close(true), + error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e))) + }); + } + + close() { + this.dialogRef.close(false); + } +} diff --git a/client/src/app/guards/admin-only.guard.ts b/client/src/app/guards/admin-only.guard.ts index 498aa06..2886547 100644 --- a/client/src/app/guards/admin-only.guard.ts +++ b/client/src/app/guards/admin-only.guard.ts @@ -1,6 +1,6 @@ import { inject } from '@angular/core'; import { CanActivateFn, CanMatchFn, Router, UrlTree, ActivatedRouteSnapshot, Route } from '@angular/router'; -import { AuthService } from '../services/auth/auth.service'; +import { AuthService } from '../services/auth.service'; function requireAdmin(url?: string): boolean | UrlTree { const authService: AuthService = inject(AuthService); diff --git a/client/src/app/guards/auth-only.guard.ts b/client/src/app/guards/auth-only.guard.ts index 9e6473c..8d41988 100644 --- a/client/src/app/guards/auth-only.guard.ts +++ b/client/src/app/guards/auth-only.guard.ts @@ -1,6 +1,6 @@ import { inject } from '@angular/core'; import { CanActivateFn, CanMatchFn, Router, UrlTree, ActivatedRouteSnapshot, Route } from '@angular/router'; -import { AuthService } from '../services/auth/auth.service'; +import { AuthService } from '../services/auth.service'; function requireAuth(url?: string): boolean | UrlTree { const authService = inject(AuthService); diff --git a/client/src/app/guards/guest-only.guard.ts b/client/src/app/guards/guest-only.guard.ts index e37c6f0..8c67084 100644 --- a/client/src/app/guards/guest-only.guard.ts +++ b/client/src/app/guards/guest-only.guard.ts @@ -1,6 +1,6 @@ import { inject } from '@angular/core'; import { Router, UrlTree, CanActivateFn, CanMatchFn } from '@angular/router'; -import { AuthService } from '../services/auth/auth.service'; +import { AuthService } from '../services/auth.service'; function redirectIfLoggedIn(): boolean | UrlTree { const authService = inject(AuthService); diff --git a/client/src/app/interceptors/auth-token.interceptor.ts b/client/src/app/interceptors/auth-token.interceptor.ts index e219fc1..592c453 100644 --- a/client/src/app/interceptors/auth-token.interceptor.ts +++ b/client/src/app/interceptors/auth-token.interceptor.ts @@ -1,6 +1,6 @@ import {HttpErrorResponse, HttpInterceptorFn} from '@angular/common/http'; import {inject} from '@angular/core'; -import {AuthService} from '../services/auth/auth.service'; +import {AuthService} from '../services/auth.service'; import {catchError, switchMap, throwError} from 'rxjs'; let isRefreshing = false; diff --git a/client/src/app/interfaces/product-list-item.ts b/client/src/app/interfaces/product-list-item.ts new file mode 100644 index 0000000..c2e1beb --- /dev/null +++ b/client/src/app/interfaces/product-list-item.ts @@ -0,0 +1,7 @@ +export interface ProductListItem { + id: number; + name: string; + id_manufacturer?: number; + id_supplier?: number; + id_category_default?: number; +} diff --git a/client/src/app/interfaces/ps-item.ts b/client/src/app/interfaces/ps-item.ts new file mode 100644 index 0000000..86cfff4 --- /dev/null +++ b/client/src/app/interfaces/ps-item.ts @@ -0,0 +1,5 @@ +export interface PsItem { + id: number; + name: string; + active?: boolean; +} diff --git a/client/src/app/interfaces/ps-product.ts b/client/src/app/interfaces/ps-product.ts new file mode 100644 index 0000000..9317ec9 --- /dev/null +++ b/client/src/app/interfaces/ps-product.ts @@ -0,0 +1,15 @@ +export interface PsProduct { + name: string; + description?: string; // texte saisi libre + categoryId: number; // id_category_default + associations + manufacturerId: number; + supplierId: number; + images?: File[]; // optionnel + // Champs “hors Presta” injectés dans la description : + conditionLabel?: string; // ex. "Occasion" + complete?: boolean; // Complet + hasManual?: boolean; // Notice + priceTtc: number; // saisi côté UI (TTC) + vatRate?: number; // ex: 0.20 (20%). Défaut: 0.20 si non fourni + quantity: number; // stock souhaité (pour id_product_attribute = 0) +} diff --git a/client/src/app/pages/add-product/add-product.component.css b/client/src/app/pages/add-product/add-product.component.css deleted file mode 100644 index 552673e..0000000 --- a/client/src/app/pages/add-product/add-product.component.css +++ /dev/null @@ -1,32 +0,0 @@ -.auth-wrap { - min-height: 100vh; - display: grid; - place-items: center; - padding: 16px; -} - -.auth-card { - width: 100%; - max-width: 520px; -} - -.form-grid { - display: grid; - gap: 16px; - margin-top: 16px; -} - -.actions { - display: flex; - margin: 8px; - - button { - display: inline-flex; - align-items: center; - gap: 8px; - } -} - -.ml-8 { - margin-left: 8px; -} diff --git a/client/src/app/pages/add-product/add-product.component.html b/client/src/app/pages/add-product/add-product.component.html deleted file mode 100644 index 98d5c35..0000000 --- a/client/src/app/pages/add-product/add-product.component.html +++ /dev/null @@ -1,160 +0,0 @@ -
- - - Ajouter un produit - - - -
- - -
- - - @if (imagePreview) { -
- Aperçu -
- } -
- - - - Titre - - @if (isFieldInvalid('title')) { - {{ getFieldError('title') }} - } - - - - - Description - - @if (isFieldInvalid('description')) { - {{ getFieldError('description') }} - } - - - - - Catégorie - - @for (category of categories; track category.id) { - {{ category.name }} - } - - - - - - État - - @for (condition of conditions; track condition.id) { - {{ condition.displayName }} - } - - - - - - Marque - - @for (brand of filteredBrands; track brand.id) { - {{ brand.name }} - } - - - - - - Plateforme - - @for (platform of filteredPlatforms; track platform.id) { - {{ platform.name }} - } - - - - - - Complet - - @if (isFieldInvalid('complete')) { -
{{ getFieldError('complete') }}
- } - - - - Avec notice - - @if (isFieldInvalid('manual')) { -
{{ getFieldError('manual') }}
- } - - - - Prix TTC - - @if (isFieldInvalid('price')) { - {{ getFieldError('price') }} - } - - - - - Quantité - - @if (isFieldInvalid('quantity')) { - {{ getFieldError('quantity') }} - } - - - -
- -
-
-
- - - - - - Voir la liste des produits - - -
-
diff --git a/client/src/app/pages/add-product/add-product.component.ts b/client/src/app/pages/add-product/add-product.component.ts deleted file mode 100644 index 03859b9..0000000 --- a/client/src/app/pages/add-product/add-product.component.ts +++ /dev/null @@ -1,427 +0,0 @@ -import {Component, inject, OnDestroy, OnInit} from '@angular/core'; -import { - AbstractControl, - FormBuilder, - FormGroup, - FormsModule, - ReactiveFormsModule, ValidatorFn, - Validators -} from "@angular/forms"; -import {MatButton} from "@angular/material/button"; -import { - MatCard, - MatCardActions, - MatCardContent, - MatCardHeader, - MatCardTitle -} from "@angular/material/card"; -import {MatCheckbox} from "@angular/material/checkbox"; -import {MatDivider} from "@angular/material/divider"; -import {MatError, MatFormField, MatLabel} from "@angular/material/form-field"; -import {MatInput} from "@angular/material/input"; -import {MatProgressSpinner} from "@angular/material/progress-spinner"; -import {MatOption, MatSelect} from '@angular/material/select'; -import {Router, RouterLink} from '@angular/router'; -import {Subscription} from 'rxjs'; -import {BrandService} from '../../services/app/brand.service'; -import {Brand} from '../../interfaces/brand'; -import {PlatformService} from '../../services/app/platform.service'; -import {Platform} from '../../interfaces/platform'; -import {Category} from '../../interfaces/category'; -import {CategoryService} from '../../services/app/category.service'; -import {ConditionService} from '../../services/app/condition.service'; -import {Condition} from '../../interfaces/condition'; -import {ProductService} from '../../services/app/product.service'; -import {CdkTextareaAutosize} from '@angular/cdk/text-field'; -import {ImageService} from '../../services/app/image.service'; -import {ProductImageService} from '../../services/app/product_images.service'; - -@Component({ - selector: 'app-add-product', - standalone: true, - imports: [ - FormsModule, - MatButton, - MatCard, - MatCardActions, - MatCardContent, - MatCardHeader, - MatCardTitle, - MatCheckbox, - MatDivider, - MatError, - MatFormField, - MatInput, - MatLabel, - MatProgressSpinner, - ReactiveFormsModule, - MatSelect, - MatOption, - RouterLink, - CdkTextareaAutosize - ], - templateUrl: './add-product.component.html', - styleUrl: './add-product.component.css' -}) -export class AddProductComponent implements OnInit, OnDestroy { - - addProductForm: FormGroup; - isSubmitted = false; - isLoading = false; - - imageFile: File | null = null; - imagePreview: string | null = null; - private imageUploadSubscription: Subscription | null = null; - private imageLinkSubscription: Subscription | null = null; - - brands: Brand[] = []; - platforms: Platform[] = []; - categories: Category[] = []; - conditions: Condition[] = []; - - filteredBrands: Brand[] = []; - filteredPlatforms: Platform[] = []; - - private addProductSubscription: Subscription | null = null; - - private brandControlSubscription: Subscription | null = null; - private platformControlSubscription: Subscription | null = null; - - private brandSubscription: Subscription | null = null; - private platformSubscription: Subscription | null = null; - private categorySubscription: Subscription | null = null; - private conditionSubscription: Subscription | null = null; - - private readonly brandService: BrandService = inject(BrandService); - private readonly platformService = inject(PlatformService); - private readonly categoryService = inject(CategoryService); - private readonly conditionService = inject(ConditionService); - private readonly imageService = inject(ImageService) - private readonly productService = inject(ProductService); - private readonly productImageService = inject(ProductImageService); - - private readonly router: Router = inject(Router); - - constructor(private readonly formBuilder: FormBuilder) { - this.addProductForm = this.formBuilder.group({ - title: ['', [ - Validators.required, - Validators.minLength(3), - Validators.maxLength(50), - Validators.pattern(/^[\p{L}\p{N}\s]+$/u) - ]], - description: ['', [ - Validators.required, - Validators.minLength(10), - Validators.maxLength(255), - Validators.pattern(/^[\p{L}\p{N}\s]+$/u) - ]], - category: ['', [ - Validators.required - ]], - condition: ['', [ - Validators.required - ]], - // stocker des ids (string|number) dans les controls - brand: ['', [ - Validators.required - ]], - platform: ['', [ - Validators.required - ]], - complete: [true], - manual: [true], - price: ['', [ - Validators.required, - Validators.pattern(/^\d+([.,]\d{1,2})?$/), - this.priceRangeValidator(0, 9999) - ]], - quantity: ['', [ - Validators.required, - Validators.min(1), - Validators.max(999), - Validators.pattern(/^\d+$/) - ]] - }, - ); - } - - private normalizeIds>(items: T[] | undefined, idKey = 'id'): T[] { - return (items || []).map((it, i) => ({ - ...it, - [idKey]: (it[idKey] ?? i) - })); - } - - private getPlatformBrandId(platform: any): string | number | undefined { - if (!platform) return undefined; - const maybe = platform.brand ?? platform['brand_id'] ?? platform['brandId']; - if (maybe == null) return undefined; - - if (typeof maybe === 'object') { - if (maybe.id != null) return maybe.id; - if (maybe.name != null) { - const found = this.brands.find(b => - String(b.name).toLowerCase() === String(maybe.name).toLowerCase() - || String(b.id) === String(maybe.name) - ); - return found?.id; - } - return undefined; - } - - const asStr = String(maybe); - const match = this.brands.find(b => - String(b.id) === asStr || String(b.name).toLowerCase() === asStr.toLowerCase() - ); - return match?.id ?? maybe; - } - - private priceRangeValidator(min: number, max: number): ValidatorFn { - return (control: AbstractControl) => { - const val = control.value; - if (val === null || val === undefined || val === '') return null; - const normalized = String(val).replace(',', '.').trim(); - const num = Number.parseFloat(normalized); - if (Number.isNaN(num)) return {pattern: true}; - return (num < min || num > max) ? {range: {min, max, actual: num}} : null; - }; - } - - ngOnInit(): void { - - this.brandSubscription = this.brandService.getAll().subscribe({ - next: (brands: Brand[]) => { - this.brands = this.normalizeIds(brands, 'id'); - this.filteredBrands = [...this.brands]; - }, - error: (error: any) => { - console.error('Error fetching brands:', error); - }, - complete: () => { - console.log('Finished fetching brands:', this.brands); - } - }); - - this.platformSubscription = this.platformService.getAll().subscribe({ - next: (platforms: Platform[]) => { - this.platforms = this.normalizeIds(platforms, 'id'); - this.filteredPlatforms = [...this.platforms]; - }, - error: (error: any) => { - console.error('Error fetching platforms:', error); - }, - complete: () => { - console.log('Finished fetching platforms:', this.platforms); - } - }); - - this.categorySubscription = this.categoryService.getAll().subscribe({ - next: (categories: Category[]) => { - this.categories = this.normalizeIds(categories, 'id'); - }, - error: (error: any) => { - console.error('Error fetching categories:', error); - }, - complete: () => { - console.log('Finished fetching categories:', this.categories); - } - }); - - this.conditionSubscription = this.conditionService.getAll().subscribe({ - next: (conditions: Condition[]) => { - this.conditions = this.normalizeIds(conditions, 'id'); - }, - error: (error) => { - console.error('Error fetching conditions:', error); - }, - complete: () => { - console.log('Finished fetching conditions:', this.conditions); - } - }); - - const brandControl = this.addProductForm.get('brand'); - const platformControl = this.addProductForm.get('platform'); - - this.brandControlSubscription = brandControl?.valueChanges.subscribe((brandId) => { - if (brandId != null && brandId !== '') { - const brandIdStr = String(brandId); - this.filteredPlatforms = this.platforms.filter(p => { - const pBid = this.getPlatformBrandId(p); - return pBid != null && String(pBid) === brandIdStr; - }); - const curPlatformId = platformControl?.value; - if (curPlatformId != null && !this.filteredPlatforms.some(p => String(p.id) === String(curPlatformId))) { - platformControl?.setValue(null); - } - } else { - this.filteredPlatforms = [...this.platforms]; - } - }) ?? null; - - this.platformControlSubscription = platformControl?.valueChanges.subscribe((platformId) => { - if (platformId != null && platformId !== '') { - const platformObj = this.platforms.find(p => String(p.id) === String(platformId)); - const pBrandId = this.getPlatformBrandId(platformObj); - if (pBrandId != null) { - const pBrandIdStr = String(pBrandId); - this.filteredBrands = this.brands.filter(b => String(b.id) === pBrandIdStr); - const curBrandId = brandControl?.value; - if (curBrandId != null && String(curBrandId) !== pBrandIdStr) { - brandControl?.setValue(null); - } - } else { - this.filteredBrands = [...this.brands]; - } - } else { - this.filteredBrands = [...this.brands]; - } - }) ?? null; - } - - ngOnDestroy(): void { - this.addProductSubscription?.unsubscribe(); - this.brandControlSubscription?.unsubscribe(); - this.platformControlSubscription?.unsubscribe(); - this.brandSubscription?.unsubscribe(); - this.platformSubscription?.unsubscribe(); - this.categorySubscription?.unsubscribe(); - this.conditionSubscription?.unsubscribe(); - this.imageUploadSubscription?.unsubscribe(); - this.imageLinkSubscription?.unsubscribe(); - } - - onFileSelected(event: Event) { - const input = event.target as HTMLInputElement; - if (!input.files || input.files.length === 0) { - this.imageFile = null; - this.imagePreview = null; - return; - } - const file = input.files[0]; - this.imageFile = file; - - const reader = new FileReader(); - reader.onload = () => { - this.imagePreview = String(reader.result); - }; - reader.readAsDataURL(file); - } - - onProductAdd() { - this.isSubmitted = true; - - if (!this.addProductForm.valid) return; - - this.isLoading = true; - const raw = this.addProductForm.value; - - // parsing price/quantity etc (même logique que l'original) - const priceStr = raw.price ?? ''; - const priceNum = Number(String(priceStr).replace(',', '.').trim()); - if (Number.isNaN(priceNum)) { - this.isLoading = false; - this.addProductForm.get('price')?.setErrors({pattern: true}); - return; - } - const quantityNum = Number(raw.quantity); - - const brandId = raw.brand; - const brandObj = this.brands.find(b => String(b.id) === String(brandId)) ?? {id: brandId, name: undefined}; - - const platformId = raw.platform; - const foundPlatform = this.platforms.find(p => String(p.id) === String(platformId)); - const platformObj = { - ...(foundPlatform ?? {id: platformId, name: undefined}), - brand: foundPlatform?.brand ? (typeof foundPlatform.brand === 'object' ? foundPlatform.brand : (this.brands.find(b => String(b.id) === String(foundPlatform.brand)) ?? brandObj)) : brandObj - }; - - const payload = { - ...raw, - price: priceNum, - quantity: quantityNum, - brand: brandObj, - platform: platformObj - }; - - // 1) créer le produit - this.addProductSubscription = this.productService.add(payload).subscribe({ - next: (createdProduct: any) => { - const productId = createdProduct?.id; - if (!this.imageFile) { - // pas d'image => fin - this.afterSuccessfulAdd(); - return; - } - - // 2) upload de l'image - this.imageUploadSubscription = this.imageService.add(this.imageFile).subscribe({ - next: (uploadedImage: { id: any; }) => { - const imageId = uploadedImage?.id; - if (!productId || imageId == null) { - console.error('Missing productId or imageId after upload'); - this.afterSuccessfulAdd(); // navigation quand même ou gérer l'erreur - return; - } - // 3) lier image <-> product - this.imageLinkSubscription = this.productImageService.link(productId, imageId).subscribe({ - next: () => { - this.afterSuccessfulAdd(); - }, - error: (error: any) => { - console.error('Error linking image to product:', error); - alert('Produit ajouté, mais la liaison de l\'image a échoué.'); - this.afterSuccessfulAdd(); - } - }); - }, - error: (error: any) => { - console.error('Error uploading image:', error); - alert('Produit ajouté, mais l\'upload de l\'image a échoué.'); - this.afterSuccessfulAdd(); - } - }); - }, - error: (error: any) => { - console.error("Error adding product:", error); - alert("Une erreur est survenue lors de l'ajout du produit."); - this.isLoading = false; - } - }); - } - - private afterSuccessfulAdd() { - this.addProductForm.reset(); - this.imageFile = null; - this.imagePreview = null; - this.isSubmitted = false; - this.isLoading = false; - alert("Produit ajouté avec succès !"); - this.router.navigate(['/products']).then(); - } - - isFieldInvalid(fieldName: string): boolean { - const field = this.addProductForm.get(fieldName); - return Boolean(field && field.invalid && (field.dirty || field.touched || this.isSubmitted)); - } - - getFieldError(fieldName: string): string { - const field = this.addProductForm.get(fieldName); - - if (field && field.errors) { - if (field.errors['required']) return `Ce champ est obligatoire`; - if (field.errors['email']) return `Format d'email invalide`; - if (field.errors['minlength']) return `Minimum ${field.errors['minlength'].requiredLength} caractères`; - if (field.errors['maxlength']) return `Maximum ${field.errors['maxlength'].requiredLength} caractères`; - } - return ''; - } - - compareById = (a: any, b: any) => { - if (a == null || b == null) return a === b; - if (typeof a !== 'object' || typeof b !== 'object') { - return String(a) === String(b); - } - return String(a.id ?? a) === String(b.id ?? b); - }; -} diff --git a/client/src/app/pages/admin/admin.component.css b/client/src/app/pages/admin/admin.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/app/pages/admin/admin.component.html b/client/src/app/pages/admin/admin.component.html deleted file mode 100644 index 3481ed4..0000000 --- a/client/src/app/pages/admin/admin.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/client/src/app/pages/admin/admin.component.ts b/client/src/app/pages/admin/admin.component.ts deleted file mode 100644 index a8cf236..0000000 --- a/client/src/app/pages/admin/admin.component.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Component } from '@angular/core'; -import {AdminNavbarComponent} from '../../components/navbar/admin-navbar/admin-navbar.component'; - -@Component({ - selector: 'app-admin', - templateUrl: './admin.component.html', - standalone: true, - imports: [ - AdminNavbarComponent - ], - styleUrls: ['./admin.component.scss'] -}) -export class AdminComponent{ - -} diff --git a/client/src/app/admin-presta/ps-crud-tabs/ps-crud-tabs.component.css b/client/src/app/pages/admin/ps-admin/ps-admin.component.css similarity index 100% rename from client/src/app/admin-presta/ps-crud-tabs/ps-crud-tabs.component.css rename to client/src/app/pages/admin/ps-admin/ps-admin.component.css diff --git a/client/src/app/pages/admin/ps-admin/ps-admin.component.html b/client/src/app/pages/admin/ps-admin/ps-admin.component.html new file mode 100644 index 0000000..866f2d3 --- /dev/null +++ b/client/src/app/pages/admin/ps-admin/ps-admin.component.html @@ -0,0 +1,14 @@ +
+

Administration Prestashop

+ + + + + + + + + + + +
diff --git a/client/src/app/pages/admin/ps-admin/ps-admin.component.ts b/client/src/app/pages/admin/ps-admin/ps-admin.component.ts new file mode 100644 index 0000000..ec55efb --- /dev/null +++ b/client/src/app/pages/admin/ps-admin/ps-admin.component.ts @@ -0,0 +1,16 @@ +import { Component } from '@angular/core'; +import {MatTab, MatTabGroup} from '@angular/material/tabs'; +import {PsAdminCrudComponent} from '../../../components/ps-generic-crud/ps-admin-crud.component'; + +@Component({ + standalone: true, + selector: 'app-ps-admin', + templateUrl: './ps-admin.component.html', + imports: [ + MatTabGroup, + MatTab, + PsAdminCrudComponent + ], + styleUrls: ['./ps-admin.component.css'] +}) +export class PsAdminComponent {} diff --git a/client/src/app/pages/login/login.component.css b/client/src/app/pages/auth/login/login.component.css similarity index 100% rename from client/src/app/pages/login/login.component.css rename to client/src/app/pages/auth/login/login.component.css diff --git a/client/src/app/pages/login/login.component.html b/client/src/app/pages/auth/login/login.component.html similarity index 100% rename from client/src/app/pages/login/login.component.html rename to client/src/app/pages/auth/login/login.component.html diff --git a/client/src/app/pages/login/login.component.ts b/client/src/app/pages/auth/login/login.component.ts similarity index 90% rename from client/src/app/pages/login/login.component.ts rename to client/src/app/pages/auth/login/login.component.ts index 664a266..1d03977 100644 --- a/client/src/app/pages/login/login.component.ts +++ b/client/src/app/pages/auth/login/login.component.ts @@ -1,11 +1,11 @@ import {Component, inject, OnDestroy} from '@angular/core'; import {MatError, MatFormField, MatLabel} from '@angular/material/form-field'; import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; -import {AuthService} from '../../services/auth/auth.service'; +import {AuthService} from '../../../services/auth.service'; import {Router} from '@angular/router'; import {Subscription} from 'rxjs'; -import {Credentials} from '../../interfaces/credentials'; -import {User} from '../../interfaces/user'; +import {Credentials} from '../../../interfaces/credentials'; +import {User} from '../../../interfaces/user'; import {MatInput} from '@angular/material/input'; import {MatButton} from '@angular/material/button'; diff --git a/client/src/app/pages/register/register.component.css b/client/src/app/pages/auth/register/register.component.css similarity index 100% rename from client/src/app/pages/register/register.component.css rename to client/src/app/pages/auth/register/register.component.css diff --git a/client/src/app/pages/register/register.component.html b/client/src/app/pages/auth/register/register.component.html similarity index 100% rename from client/src/app/pages/register/register.component.html rename to client/src/app/pages/auth/register/register.component.html diff --git a/client/src/app/pages/register/register.component.ts b/client/src/app/pages/auth/register/register.component.ts similarity index 98% rename from client/src/app/pages/register/register.component.ts rename to client/src/app/pages/auth/register/register.component.ts index bece378..ec7e16e 100644 --- a/client/src/app/pages/register/register.component.ts +++ b/client/src/app/pages/auth/register/register.component.ts @@ -21,7 +21,7 @@ import { } from '@angular/material/card'; import {MatProgressSpinner} from '@angular/material/progress-spinner'; import {MatDivider} from '@angular/material/divider'; -import {AuthService} from '../../services/auth/auth.service'; +import {AuthService} from '../../../services/auth.service'; import {MatCheckbox} from '@angular/material/checkbox'; import {MatButton} from '@angular/material/button'; import {Subscription} from 'rxjs'; diff --git a/client/src/app/pages/not-found/not-found.component.css b/client/src/app/pages/errors/not-found/not-found.component.css similarity index 100% rename from client/src/app/pages/not-found/not-found.component.css rename to client/src/app/pages/errors/not-found/not-found.component.css diff --git a/client/src/app/pages/not-found/not-found.component.html b/client/src/app/pages/errors/not-found/not-found.component.html similarity index 100% rename from client/src/app/pages/not-found/not-found.component.html rename to client/src/app/pages/errors/not-found/not-found.component.html diff --git a/client/src/app/pages/not-found/not-found.component.ts b/client/src/app/pages/errors/not-found/not-found.component.ts similarity index 100% rename from client/src/app/pages/not-found/not-found.component.ts rename to client/src/app/pages/errors/not-found/not-found.component.ts diff --git a/client/src/app/pages/home/home.component.ts b/client/src/app/pages/home/home.component.ts index 69c31a4..c52a690 100644 --- a/client/src/app/pages/home/home.component.ts +++ b/client/src/app/pages/home/home.component.ts @@ -1,6 +1,6 @@ import {Component, inject} from '@angular/core'; import {MatButton} from '@angular/material/button'; -import {AuthService} from '../../services/auth/auth.service'; +import {AuthService} from '../../services/auth.service'; import {Router, RouterLink} from '@angular/router'; @Component({ diff --git a/client/src/app/pages/products/products.component.css b/client/src/app/pages/products/products.component.css index dd8b416..625265e 100644 --- a/client/src/app/pages/products/products.component.css +++ b/client/src/app/pages/products/products.component.css @@ -1,128 +1,5 @@ -/* ===== Container centré ===== */ -.generic-list { - display: flex; - flex-direction: column; - gap: 1rem; - padding: 1rem clamp(1rem, 3vw, 3rem); - max-width: 1200px; - margin: 0 auto; -} - -/* ===== Header ===== */ -.gl-header { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; - border-bottom: 1px solid rgba(0, 0, 0, .08); - padding-bottom: .75rem; -} - -.gl-title { - margin: 0; - font-size: clamp(1.1rem, 1.3rem + 0.3vw, 1.6rem); - font-weight: 600; -} - -/* ===== Cartes (filtre, table, pagination) ===== */ -.gl-block { - border: 1px solid rgba(0,0,0,.08); - border-radius: 12px; - background: var(--gl-surface, #fff); - box-shadow: - 0 1px 2px rgba(0,0,0,.04), - 0 2px 8px rgba(0,0,0,.06); -} - -/* ===== Barre de filtre ===== */ -.gl-filter-bar { padding: .75rem; } -.gl-filter { display: block; width: 100%; max-width: none; } - -/* ===== Tableau ===== */ -.gl-table-wrapper { - overflow: auto; - -webkit-overflow-scrolling: touch; - padding: 0.25rem 0.5rem; /* espace interne pour éviter l'effet "collé" */ -} - -.gl-table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - min-width: 720px; /* permet le scroll horizontal si trop de colonnes */ -} - -.gl-table th[mat-header-cell] { - position: sticky; - top: 0; - z-index: 2; - background: inherit; - box-shadow: inset 0 -1px 0 rgba(0,0,0,.08); -} - -.gl-table th[mat-header-cell], -.gl-table td[mat-cell] { - padding: 14px 18px; - vertical-align: middle; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Zebra + hover */ -.gl-table tr.mat-mdc-row:nth-child(odd) td[mat-cell] { background: rgba(0,0,0,.015); } -.gl-table tr.mat-mdc-row:hover td[mat-cell] { background: rgba(0,0,0,.035); } - -/* Actions */ -.actions-head { width: 1%; white-space: nowrap; } -.actions-cell { - display: flex; - align-items: center; - justify-content: center; - gap: .4rem; -} -.actions-cell .mat-mdc-icon-button { width: 40px; height: 40px; } - -/* ===== Pagination ===== */ -.gl-paginator-wrap { padding: .25rem .5rem; } -.gl-paginator { - margin-top: .25rem; - padding-top: .5rem; - border-top: 1px solid rgba(0,0,0,.08); - display: flex; - justify-content: flex-end; -} - -/* ===== Empty state ===== */ -.no-products { - padding: 1rem; - text-align: center; - color: rgba(0,0,0,.6); -} - -/* ===== Responsive ===== */ -@media (max-width: 799px) { - .generic-list { padding: 0.75rem 1rem; } - .gl-table { min-width: 0; } - .gl-table th[mat-header-cell], - .gl-table td[mat-cell] { white-space: normal; padding: 10px 12px; } - .actions-cell { justify-content: flex-start; } -} - -/* ===== Dark mode ===== */ -@media (prefers-color-scheme: dark) { - .gl-block { - background: #1b1b1b; - border-color: rgba(255,255,255,.08); - box-shadow: - 0 1px 2px rgba(0,0,0,.6), - 0 2px 8px rgba(0,0,0,.45); - } - .gl-header { border-bottom-color: rgba(255,255,255,.08); } - .gl-table th[mat-header-cell] { box-shadow: inset 0 -1px 0 rgba(255,255,255,.08); } - .gl-table tr.mat-mdc-row:nth-child(odd) td[mat-cell] { background: rgba(255,255,255,.025); } - .gl-table tr.mat-mdc-row:hover td[mat-cell] { background: rgba(255,255,255,.06); } - .gl-paginator { border-top-color: rgba(255,255,255,.08); } - .no-products { color: rgba(255,255,255,.7); } +.wrap { + padding: 16px; + max-width: 1100px; + margin: auto } diff --git a/client/src/app/pages/products/products.component.html b/client/src/app/pages/products/products.component.html index 930ee47..e396762 100644 --- a/client/src/app/pages/products/products.component.html +++ b/client/src/app/pages/products/products.component.html @@ -1,126 +1,4 @@ -
- -
-

Gestion des produits

-
- -
-
- - -
- - Rechercher - - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Nom{{ product.title }}Description{{ product.description }}Catégorie{{ product.category.name }}Plateforme{{ product.platform.name }}État{{ product.condition.displayName }}Complet - @if (product.complete) { - check_circle - } @else { - cancel - } - Notice - @if (product.manual) { - check_circle - } @else { - cancel - } - Prix{{ product.price | currency:'EUR' }}Quantité{{ product.quantity }}Actions - - -
-
- - -
- - -
- - @if (!products || products.length === 0) { -
Aucun produit trouvé.
- } -
+
+

Gestion des produits

+ +
diff --git a/client/src/app/pages/products/products.component.ts b/client/src/app/pages/products/products.component.ts index a11bcb6..31d8ac2 100644 --- a/client/src/app/pages/products/products.component.ts +++ b/client/src/app/pages/products/products.component.ts @@ -1,182 +1,15 @@ -import { - Component, - Input, - Output, - EventEmitter, - ViewChild, - AfterViewInit, - OnChanges, - SimpleChanges, - OnInit, - inject -} from '@angular/core'; -import { Product } from '../../interfaces/product'; -import { ProductService } from '../../services/app/product.service'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { Router } from '@angular/router'; -import { CurrencyPipe } from '@angular/common'; -import { ConfirmDialogComponent } from '../../components/dialog/confirm-dialog/confirm-dialog.component'; - -import { MatTableModule, MatTableDataSource } from '@angular/material/table'; -import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator'; -import { MatSortModule, MatSort } from '@angular/material/sort'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import {GenericDialogComponent} from '../../components/dialog/generic-dialog/generic-dialog.component'; -import {CategoryService} from '../../services/app/category.service'; -import {PlatformService} from '../../services/app/platform.service'; -import {ConditionService} from '../../services/app/condition.service'; -import {BrandService} from '../../services/app/brand.service'; +import { Component } from '@angular/core'; +import {PsProductCrudComponent} from '../../components/ps-product-crud/ps-product-crud.component'; @Component({ selector: 'app-products', - templateUrl: './products.component.html', standalone: true, imports: [ - MatTableModule, - MatPaginatorModule, - MatSortModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - MatIconModule, - MatDialogModule, - CurrencyPipe + PsProductCrudComponent ], - styleUrls: ['./products.component.css'] + templateUrl: './products.component.html', + styleUrl: './products.component.css' }) -export class ProductsComponent implements OnInit, AfterViewInit, OnChanges { +export class ProductsComponent { - @Input() products: Product[] = []; - @Output() add = new EventEmitter(); - @Output() edit = new EventEmitter(); - @Output() delete = new EventEmitter(); - - displayedColumns: string[] = ['title', 'description', 'category', 'platform', 'condition', 'complete', 'manual', 'price', 'quantity', 'actions']; - dataSource = new MatTableDataSource([]); - - @ViewChild(MatPaginator) paginator!: MatPaginator; - @ViewChild(MatSort) sort!: MatSort; - - private readonly productService: ProductService = inject(ProductService); - private readonly categoryService: CategoryService = inject(CategoryService); - private readonly brandService: BrandService = inject(BrandService); - private readonly platformService: PlatformService = inject(PlatformService); - private readonly conditionService: ConditionService = inject(ConditionService); - - private readonly dialog: MatDialog = inject(MatDialog); - private readonly router: Router = inject(Router); - - private readonly productFields = [ - { key: 'title', label: 'Nom', type: 'text', sortable: true }, - { key: 'description', label: 'Description', type: 'textarea' }, - { key: 'category', label: 'Catégorie', type: 'select', options$: this.categoryService.getAll(), valueKey: 'id', displayKey: 'name', sortable: true }, - { key: 'brand', label: 'Marque', type: 'select', options$: this.brandService.getAll(), valueKey: 'id', displayKey: 'name', sortable: true }, - { key: 'platform', label: 'Plateforme', type: 'select', options$: this.platformService.getAll(), valueKey: 'id', displayKey: 'name', sortable: true }, - { key: 'condition', label: 'État', type: 'select', options$: this.conditionService.getAll(), valueKey: 'name', displayKey: 'displayName', sortable: true }, - { key: 'complete', label: 'Complet', type: 'checkbox' }, - { key: 'manual', label: 'Notice', type: 'checkbox' }, - { key: 'price', label: 'Prix', type: 'number', sortable: true }, - { key: 'quantity', label: 'Quantité', type: 'number', sortable: true } - ]; - - ngOnInit(): void { - if (!this.products || this.products.length === 0) { - this.loadProducts(); - } else { - this.dataSource.data = this.products; - } - } - - ngOnChanges(changes: SimpleChanges): void { - if (changes['products']) { - this.dataSource.data = this.products || []; - } - } - - ngAfterViewInit(): void { - this.dataSource.paginator = this.paginator; - this.dataSource.sort = this.sort; - this.dataSource.sortingDataAccessor = (item: Product, property: string) => { - switch (property) { - case 'category': - return item.category?.name ?? ''; - case 'platform': - return item.platform?.name ?? ''; - case 'condition': - return item.condition?.displayName ?? ''; - case 'complete': - return item.complete ? 1 : 0; - case 'manualIncluded': - return item.manualIncluded ? 1 : 0; - case 'price': - return item.price ?? 0; - case 'quantity': - return item.quantity ?? 0; - case 'title': - return item.title ?? ''; - case 'description': - return item.description ?? ''; - default: - return (item as any)[property]; - } - }; - } - - loadProducts() { - this.productService.getAll().subscribe({ - next: (products: Product[]) => { - this.products = products || [] - this.dataSource.data = this.products; - }, - error: () => this.products = [] - }); - } - - onAdd(): void { - this.router.navigate(['/products/add']).then(); - } - - onEdit(product: Product): void { - console.log('[Products] open edit dialog for product:', product); - const ref = this.dialog.open(GenericDialogComponent, { - width: '600px', - data: { - title: `Modifier : ${product.title}`, - fields: this.productFields, - model: { ...product } - } - }); - - ref.afterClosed().subscribe((result: any) => { - if (!result) return; - this.productService.update(product.id, result).subscribe({ - next: () => this.loadProducts(), - error: (err) => console.error('Erreur update product:', err) - }); - }); - } - - onDelete(product: Product): void { - const ref = this.dialog.open(ConfirmDialogComponent, { - width: '420px', - data: { - title: 'Supprimer le produit', - message: `Voulez-vous vraiment supprimer « ${product.title} » ?` - } - }); - - ref.afterClosed().subscribe((confirmed: boolean) => { - if (confirmed) { - this.delete.emit(product); - this.productService.delete(product.id).subscribe(() => this.loadProducts()); - } - }); - } - - applyFilter(value: string): void { - this.dataSource.filter = (value || '').trim().toLowerCase(); - } } diff --git a/client/src/app/pages/profile/profile.component.ts b/client/src/app/pages/profile/profile.component.ts index 1529198..4c287eb 100644 --- a/client/src/app/pages/profile/profile.component.ts +++ b/client/src/app/pages/profile/profile.component.ts @@ -9,7 +9,7 @@ import { } from '@angular/material/card'; import {MatIcon} from '@angular/material/icon'; import {MatButton} from '@angular/material/button'; -import {AuthService} from '../../services/auth/auth.service'; +import {AuthService} from '../../services/auth.service'; import {User} from '../../interfaces/user'; import {Router} from '@angular/router'; diff --git a/client/src/app/services/auth/auth.service.ts b/client/src/app/services/auth.service.ts similarity index 95% rename from client/src/app/services/auth/auth.service.ts rename to client/src/app/services/auth.service.ts index aedd398..4063814 100644 --- a/client/src/app/services/auth/auth.service.ts +++ b/client/src/app/services/auth.service.ts @@ -1,8 +1,8 @@ import {inject, Injectable, signal} from '@angular/core'; import {catchError, map, of, switchMap, tap} from 'rxjs'; -import {Credentials} from '../../interfaces/credentials'; +import {Credentials} from '../interfaces/credentials'; import {HttpClient} from '@angular/common/http'; -import {User} from '../../interfaces/user'; +import {User} from '../interfaces/user'; @Injectable({ providedIn: 'root' diff --git a/client/src/app/services/app/brand.service.ts b/client/src/app/services/brand.service.ts similarity index 91% rename from client/src/app/services/app/brand.service.ts rename to client/src/app/services/brand.service.ts index 44426c5..12df4f8 100644 --- a/client/src/app/services/app/brand.service.ts +++ b/client/src/app/services/brand.service.ts @@ -1,8 +1,8 @@ import {inject, Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; -import {Brand} from '../../interfaces/brand'; -import {CrudService} from '../crud.service'; +import {Brand} from '../interfaces/brand'; +import {CrudService} from './crud.service'; @Injectable({ providedIn: 'root' diff --git a/client/src/app/services/app/category.service.ts b/client/src/app/services/category.service.ts similarity index 91% rename from client/src/app/services/app/category.service.ts rename to client/src/app/services/category.service.ts index 2f3147c..8864a05 100644 --- a/client/src/app/services/app/category.service.ts +++ b/client/src/app/services/category.service.ts @@ -1,8 +1,8 @@ import {inject, Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; -import {Category} from '../../interfaces/category'; -import {CrudService} from '../crud.service'; +import {Category} from '../interfaces/category'; +import {CrudService} from './crud.service'; @Injectable({ providedIn: 'root' diff --git a/client/src/app/services/app/condition.service.ts b/client/src/app/services/condition.service.ts similarity index 91% rename from client/src/app/services/app/condition.service.ts rename to client/src/app/services/condition.service.ts index 9c269fd..baad1c3 100644 --- a/client/src/app/services/app/condition.service.ts +++ b/client/src/app/services/condition.service.ts @@ -1,8 +1,8 @@ import {inject, Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; -import {Condition} from '../../interfaces/condition'; -import {CrudService} from '../crud.service'; +import {Condition} from '../interfaces/condition'; +import {CrudService} from './crud.service'; @Injectable({ providedIn: 'root' diff --git a/client/src/app/services/app/image.service.ts b/client/src/app/services/image.service.ts similarity index 91% rename from client/src/app/services/app/image.service.ts rename to client/src/app/services/image.service.ts index 45bebbc..453734c 100644 --- a/client/src/app/services/app/image.service.ts +++ b/client/src/app/services/image.service.ts @@ -1,7 +1,7 @@ import {inject, Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; -import {Image} from '../../interfaces/image'; +import {Image} from '../interfaces/image'; @Injectable({providedIn: 'root'}) export class ImageService { diff --git a/client/src/app/services/app/platform.service.ts b/client/src/app/services/platform.service.ts similarity index 91% rename from client/src/app/services/app/platform.service.ts rename to client/src/app/services/platform.service.ts index 3bfa156..55f86c8 100644 --- a/client/src/app/services/app/platform.service.ts +++ b/client/src/app/services/platform.service.ts @@ -1,8 +1,8 @@ import {inject, Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; -import {Platform} from '../../interfaces/platform'; -import {CrudService} from '../crud.service'; +import {Platform} from '../interfaces/platform'; +import {CrudService} from './crud.service'; @Injectable({ providedIn: 'root' diff --git a/client/src/app/services/presta.serivce.ts b/client/src/app/services/presta.serivce.ts deleted file mode 100644 index ad2a858..0000000 --- a/client/src/app/services/presta.serivce.ts +++ /dev/null @@ -1,234 +0,0 @@ -import {inject, Injectable} from '@angular/core'; -import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http'; -import {map, switchMap} from 'rxjs'; - -export interface PsItem { - id: number; - name: string; - active?: boolean; -} - -type Resource = 'categories' | 'manufacturers' | 'suppliers'; - -const UPDATE_CFG: Record = { - categories: { - root: 'category', - needsDefaultLang: true, - keepFields: ['active', 'id_parent', 'link_rewrite'], - nameIsMultilang: true - }, - manufacturers: {root: 'manufacturer', needsDefaultLang: false, keepFields: ['active'], nameIsMultilang: false}, - suppliers: {root: 'supplier', needsDefaultLang: false, keepFields: ['active'], nameIsMultilang: false}, -}; - -@Injectable({providedIn: 'root'}) -export class PrestaService { - private readonly http = inject(HttpClient); - private readonly base = '/ps'; // proxy Angular -> https://.../api - - // ---------- Utils ---------- - private readonly headersXml = new HttpHeaders({ - 'Content-Type': 'application/xml', - 'Accept': 'application/xml' - }); - - private escapeXml(v: string) { - return String(v) - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); - } - - private extractIdFromXml(xml: string): number | null { - const m = /(\d+)<\/id>/.exec(String(xml)); - return m ? +m[1] : null; - } - - private slug(str: string) { - return String(str) - .toLowerCase() - .normalize('NFD') - .replaceAll(/[\u0300-\u036f]/g, '') - .replaceAll(/[^a-z0-9]+/g, '-') - .replaceAll(/(^-|-$)/g, ''); - } - - private toLangBlock(tag: string, entries: Array<{ id: number; value: string }>) { - const inner = entries - .filter(e => e.value !== undefined && e.value !== null) - .map(e => `${this.escapeXml(String(e.value))}`) - .join(''); - return `<${tag}>${inner}`; - } - - private ensureArrayLang(v: any): Array<{ id: number; value: string }> { - if (Array.isArray(v)) return v.map(x => ({id: +x.id, value: String(x.value ?? '')})); - return []; - } - - // ---------- Langues / lecture objets ---------- - /** ID langue par défaut (PS_LANG_DEFAULT) */ - getDefaultLangId() { - const params = new HttpParams() - .set('display', '[value]') - .set('filter[name]', 'PS_LANG_DEFAULT') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/configurations`, {params}).pipe( - map(r => +r?.configurations?.[0]?.value || 1) - ); - } - - /** IDs des langues actives (utile pour create catégories) */ - getActiveLangIds() { - const params = new HttpParams() - .set('display', '[id,active]') - .set('filter[active]', '1') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/languages`, {params}).pipe( - map(r => (r?.languages ?? []).map((l: any) => +l.id as number)) - ); - } - - /** Récupère l'objet complet (JSON) pour un update sûr */ - private getOne(resource: Resource, id: number) { - const params = new HttpParams().set('output_format', 'JSON').set('display', 'full'); - return this.http.get(`${this.base}/${resource}/${id}`, {params}).pipe( - map(r => r?.category ?? r?.manufacturer ?? r?.supplier ?? r) - ); - } - - // ---------- LIST ---------- - list(resource: Resource) { - const params = new HttpParams() - .set('display', '[id,name,active]') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/${resource}`, {params}).pipe( - map(r => { - const arr = r?.[resource] ?? []; - return arr.map((x: any) => ({ - id: +x.id, - name: Array.isArray(x.name) ? (x.name[0]?.value ?? '') : (x.name ?? ''), - active: x.active === undefined ? undefined : !!+x.active - }) as PsItem); - }) - ); - } - - // ---------- CREATE ---------- - create(resource: Resource, name: string) { - const safeName = this.escapeXml(name); - - if (resource === 'categories') { - // Catégories: champs multilingues -> remplir toutes les langues actives - return this.getActiveLangIds().pipe( - switchMap((langIds) => { - const xml = - ` - - 2 - 1 - ${this.toLangBlock('name', langIds.map((id: any) => ({id, value: safeName})))} - ${this.toLangBlock('link_rewrite', langIds.map((id: any) => ({id, value: this.slug(name)})))} - -`; - return this.http.post(`${this.base}/categories`, xml, { - headers: this.headersXml, - responseType: 'text' - }); - }), - map(res => this.extractIdFromXml(res)) - ); - } - - // Marques / Fournisseurs : name simple - const xml = - resource === 'manufacturers' - ? `1${safeName}` - : `1${safeName}`; - - return this.http.post(`${this.base}/${resource}`, xml, { - headers: this.headersXml, - responseType: 'text' - }).pipe( - map((res: string) => this.extractIdFromXml(res)) - ); - } - - // ---------- UPDATE (générique) ---------- - update(resource: Resource, id: number, newName: string) { - const cfg = UPDATE_CFG[resource]; - const safeName = this.escapeXml(newName); - - const defaultLang$ = cfg.needsDefaultLang ? this.getDefaultLangId() : undefined; - - // petit trick pour typer proprement si pas besoin de langue - const defaultLangOr1$ = defaultLang$ ?? this.getDefaultLangId().pipe(map(() => 1)); - - return defaultLangOr1$.pipe( - switchMap((idLang: number) => - this.getOne(resource, id).pipe( - switchMap(obj => { - const root = cfg.root; - const active = obj?.active === undefined ? 1 : +obj.active; - - // Catégories: garder slug existant (obligatoire en PUT) + id_parent - let linkRewriteXml = ''; - let idParentXml = ''; - if (resource === 'categories') { - const lr = this.ensureArrayLang(obj?.link_rewrite); - if (lr.length) { - linkRewriteXml = this.toLangBlock('link_rewrite', lr); - } else { - // fallback: si pas d'info multilang, au moins la langue défaut - linkRewriteXml = this.toLangBlock('link_rewrite', [{id: idLang, value: this.slug(newName)}]); - } - if (obj?.id_parent) idParentXml = `${+obj.id_parent}`; - } - - const nameXml = cfg.nameIsMultilang - ? `${safeName}` - : `${safeName}`; - - const body = - ` - <${root}> - ${id} - ${active} - ${idParentXml} - ${nameXml} - ${linkRewriteXml} - -`; - - return this.http.put(`${this.base}/${resource}/${id}`, body, { - headers: this.headersXml, - responseType: 'text' - }); - }) - ) - ), - map(() => true) - ); - } - - // ---------- DELETE ---------- - delete(resource: Resource, id: number) { - return this.http.delete(`${this.base}/${resource}/${id}`, {responseType: 'text'}) - .pipe(map(() => true)); - } - - // ---------- (optionnel) READ XML brut ---------- - getXml(resource: Resource, id: number) { - return this.http.get(`${this.base}/${resource}/${id}`, {responseType: 'text'}); - } -} diff --git a/client/src/app/services/prestashop.serivce.ts b/client/src/app/services/prestashop.serivce.ts new file mode 100644 index 0000000..ba33c17 --- /dev/null +++ b/client/src/app/services/prestashop.serivce.ts @@ -0,0 +1,726 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http'; +import {forkJoin, map, of, switchMap, Observable} from 'rxjs'; +import {PsItem} from '../interfaces/ps-item'; +import {PsProduct} from '../interfaces/ps-product'; +import {ProductListItem} from '../interfaces/product-list-item'; + +type Resource = 'categories' | 'manufacturers' | 'suppliers'; + +const UPDATE_CFG: Record = { + categories: { + root: 'category', + needsDefaultLang: true, + keepFields: ['active', 'id_parent', 'link_rewrite'], + nameIsMultilang: true + }, + manufacturers: {root: 'manufacturer', needsDefaultLang: false, keepFields: ['active'], nameIsMultilang: false}, + suppliers: {root: 'supplier', needsDefaultLang: false, keepFields: ['active'], nameIsMultilang: false}, +}; + +@Injectable({providedIn: 'root'}) +export class PrestashopService { + private readonly http = inject(HttpClient); + private readonly base = '/ps'; // proxy Angular -> https://.../api + + // -------- Utils + /** Id de la catégorie d’accueil (Home) de la boutique: PS_HOME_CATEGORY */ + private getHomeCategoryId() { + const params = new HttpParams() + .set('display', '[value]') + .set('filter[name]', 'PS_HOME_CATEGORY') + .set('output_format', 'JSON'); + return this.http.get(`${this.base}/configurations`, {params}).pipe( + map(r => +r?.configurations?.[0]?.value || 2) // fallback 2 + ); + } + + /** Id de la catégorie racine (PS_ROOT_CATEGORY) */ + private getRootCategoryId() { + const params = new HttpParams() + .set('display', '[value]') + .set('filter[name]', 'PS_ROOT_CATEGORY') + .set('output_format', 'JSON'); + return this.http.get(`${this.base}/configurations`, {params}).pipe( + map(r => +r?.configurations?.[0]?.value || 1) // fallback 1 + ); + } + + private readonly headersXml = new HttpHeaders({'Content-Type': 'application/xml', 'Accept': 'application/xml'}); + + private escapeXml(v: string) { + return String(v) + .replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') + .replaceAll('"', '"').replaceAll("'", '''); + } + + /** Extrait un y compris avec CDATA et attributs éventuels */ + private extractIdFromXml(xml: string): number | null { + const s = String(xml); + + // 1) prioritaire: id du noeud (avant ) + const mProduct = s.match( + /]*>\s*(?:)?\s*<\/id>[\s\S]*?(?:|<\/product>)/i + ); + if (mProduct) return +mProduct[1]; + + // 2) racine quelconque (tolère CDATA) + const mRoot = s.match( + /]*>[\s\S]*?]*>\s*(?:)?\s*<\/id>/i + ); + if (mRoot) return +mRoot[2]; + + // 3) fallback: premier numérique + const mAny = s.match(/]*>\s*(?:)?\s*<\/id>/i); + if (mAny) return +mAny[1]; + + console.warn('[Presta] Impossible d’extraire depuis la réponse:', xml); + return null; + } + + private slug(str: string) { + return String(str).toLowerCase().normalize('NFD') + .replaceAll(/[\u0300-\u036f]/g, '') + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/(^-|-$)/g, ''); + } + + private toLangBlock(tag: string, entries: Array<{ id: number; value: string }>) { + const inner = entries + .map(e => `${this.escapeXml(e.value)}`) + .join(''); + return `<${tag}>${inner}`; + } + + private ensureArrayLang(v: any): Array<{ id: number; value: string }> { + if (Array.isArray(v)) return v.map(x => ({id: +x.id, value: String(x.value ?? '')})); + return []; + } + + private round2(n: number) { + return Math.round((n + Number.EPSILON) * 100) / 100; + } + + private ttcToHt(priceTtc: number, rate = 0.2) { + return this.round2(priceTtc / (1 + rate)); + } + + // -------- Contexte, langues, fiscalité + /** Langue par défaut (PS_LANG_DEFAULT) */ + getDefaultLangId() { + const params = new HttpParams() + .set('display', '[value]') + .set('filter[name]', 'PS_LANG_DEFAULT') + .set('output_format', 'JSON'); + return this.http.get(`${this.base}/configurations`, {params}).pipe( + map(r => +r?.configurations?.[0]?.value || 1) + ); + } + + /** Premier tax_rule_group actif (fallback TVA) */ + private getFirstActiveTaxRulesGroupId() { + const params = new HttpParams() + .set('display', '[id,active]') + .set('filter[active]', '1') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/tax_rule_groups`, {params}).pipe( + map(r => { + const g = (r?.tax_rule_groups ?? [])[0]; + return g ? +g.id : 0; + }) + ); + } + + /** Groupe de taxes par défaut (PS_TAX_DEFAULT_RULES_GROUP) avec fallback sur un groupe actif */ + private getDefaultTaxRulesGroupId() { + const params = new HttpParams() + .set('display', '[value]') + .set('filter[name]', 'PS_TAX_DEFAULT_RULES_GROUP') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/configurations`, {params}).pipe( + switchMap(r => { + const id = +r?.configurations?.[0]?.value; + if (Number.isFinite(id) && id > 0) return of(id); + return this.getFirstActiveTaxRulesGroupId(); + }) + ); + } + + /** Devise par défaut (PS_CURRENCY_DEFAULT) */ + private getDefaultCurrencyId() { + const params = new HttpParams() + .set('display', '[value]') + .set('filter[name]', 'PS_CURRENCY_DEFAULT') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/configurations`, {params}).pipe( + map(r => +r?.configurations?.[0]?.value || 1) + ); + } + + /** IDs des langues actives (pour création catégorie multilangue) */ + getActiveLangIds() { + const params = new HttpParams().set('display', '[id,active]').set('filter[active]', '1').set('output_format', 'JSON'); + return this.http.get(`${this.base}/languages`, {params}).pipe( + map(r => (r?.languages ?? []).map((l: any) => +l.id)) + ); + } + + /** Contexte boutique actif (1ère boutique active) */ + private getDefaultShopContext() { + const params = new HttpParams() + .set('display', '[id,id_shop_group,active]') + .set('filter[active]', '1') + .set('output_format', 'JSON'); + return this.http.get(`${this.base}/shops`, {params}).pipe( + map(r => { + const s = (r?.shops ?? [])[0]; + return {idShop: s ? +s.id : 1, idShopGroup: s ? +s.id_shop_group : 1}; + }) + ); + } + + /** Objet complet pour update sûr (cat/manu/supplier) */ + private getOne(resource: Resource, id: number) { + const params = new HttpParams().set('output_format', 'JSON').set('display', 'full'); + return this.http.get(`${this.base}/${resource}/${id}`, {params}).pipe( + map(r => r?.category ?? r?.manufacturer ?? r?.supplier ?? r) + ); + } + + // -------- CRUD générique (categories/manufacturers/suppliers) + list(resource: Resource) { + const params = new HttpParams().set('display', '[id,name,active]').set('output_format', 'JSON'); + return this.http.get(`${this.base}/${resource}`, {params}).pipe( + map(r => (r?.[resource] ?? []).map((x: any) => ({ + id: +x.id, + name: Array.isArray(x.name) ? (x.name[0]?.value ?? '') : (x.name ?? ''), + active: x.active === undefined ? undefined : !!+x.active + }) as PsItem)) + ); + } + + create(resource: Resource, name: string) { + const safeName = this.escapeXml(name); + + if (resource === 'categories') { + return this.getActiveLangIds().pipe( + switchMap(langIds => { + const xml = ` + + 2 + 1 + ${this.toLangBlock('name', langIds.map((id: number) => ({id, value: safeName})))} + ${this.toLangBlock('link_rewrite', langIds.map((id: number) => ({id, value: this.slug(name)})))} + +`; + return this.http.post(`${this.base}/categories`, xml, {headers: this.headersXml, responseType: 'text'}); + }), + map(res => this.extractIdFromXml(res)) + ); + } + + const xml = resource === 'manufacturers' + ? `1${safeName}` + : `1${safeName}`; + + return this.http.post(`${this.base}/${resource}`, xml, {headers: this.headersXml, responseType: 'text'}) + .pipe(map((res: string) => this.extractIdFromXml(res))); + } + + update(resource: Resource, id: number, newName: string) { + const cfg = UPDATE_CFG[resource]; + const safeName = this.escapeXml(newName); + + const defaultLangOr1$ = (cfg.needsDefaultLang ? this.getDefaultLangId() : this.getDefaultLangId().pipe(map(() => 1))); + return defaultLangOr1$.pipe( + switchMap((idLang: number) => this.getOne(resource, id).pipe( + switchMap(obj => { + const root = cfg.root; + const active = obj?.active === undefined ? 1 : +obj.active; + + let linkRewriteXml = '', idParentXml = ''; + if (resource === 'categories') { + const lr = this.ensureArrayLang(obj?.link_rewrite); + linkRewriteXml = lr.length ? this.toLangBlock('link_rewrite', lr) + : this.toLangBlock('link_rewrite', [{id: idLang, value: this.slug(newName)}]); + if (obj?.id_parent) idParentXml = `${+obj.id_parent}`; + } + + const nameXml = cfg.nameIsMultilang + ? `${safeName}` + : `${safeName}`; + + const body = ` + <${root}> + ${id} + ${active} + ${idParentXml} + ${nameXml} + ${linkRewriteXml} + +`; + + return this.http.put(`${this.base}/${resource}/${id}`, body, { + headers: this.headersXml, + responseType: 'text' + }); + }) + )), + map(() => true) + ); + } + + delete(resource: Resource, id: number) { + return this.http.delete(`${this.base}/${resource}/${id}`, {responseType: 'text'}).pipe(map(() => true)); + } + + getXml(resource: Resource, id: number) { + return this.http.get(`${this.base}/${resource}/${id}`, {responseType: 'text'}); + } + + // -------- Produits (liste / détails) + listProducts(query?: string) { + let params = new HttpParams() + .set('display', '[id,name,id_manufacturer,id_supplier,id_category_default,price]') + .set('output_format', 'JSON'); + if (query?.trim()) params = params.set('filter[name]', `%[${query.trim()}]%`); + return this.http.get(`${this.base}/products`, {params}).pipe( + map(r => (r?.products ?? []).map((p: any) => ({ + id: +p.id, + name: Array.isArray(p.name) ? (p.name[0]?.value ?? '') : (p.name ?? ''), + id_manufacturer: p?.id_manufacturer ? +p.id_manufacturer : undefined, + id_supplier: p?.id_supplier ? +p.id_supplier : undefined, + id_category_default: p?.id_category_default ? +p.id_category_default : undefined, + priceHt: p?.price ? +p.price : 0 + }) as ProductListItem & { priceHt?: number })) + ); + } + + /** Détails produit (JSON full) + fallback XML pour description si vide */ + getProductDetails(id: number) { + const params = new HttpParams().set('output_format', 'JSON').set('display', 'full'); + return this.http.get(`${this.base}/products/${id}`, {params}).pipe( + map(r => r?.product ?? r), + switchMap(p => { + let description = Array.isArray(p?.description) ? (p.description[0]?.value ?? '') : (p?.description ?? ''); + if (description && typeof description === 'string') { + return of({ + id: +p.id, + name: Array.isArray(p.name) ? (p.name[0]?.value ?? '') : (p.name ?? ''), + description, + id_manufacturer: p?.id_manufacturer ? +p.id_manufacturer : undefined, + id_supplier: p?.id_supplier ? +p.id_supplier : undefined, + id_category_default: p?.id_category_default ? +p.id_category_default : undefined, + priceHt: p?.price ? +p.price : 0 + }); + } + // Fallback XML : extraire et enlever CDATA si présent + return this.http.get(`${this.base}/products/${id}`, {responseType: 'text'}).pipe( + map((xml: string) => { + const m = xml.match(/[\s\S]*?]*>([\s\S]*?)<\/language>[\s\S]*?<\/description>/i); + let descXml = m ? m[1] : ''; + if (descXml.startsWith('')) { + descXml = descXml.slice(9, -3); + } + return { + id: +p.id, + name: Array.isArray(p.name) ? (p.name[0]?.value ?? '') : (p.name ?? ''), + description: descXml, + id_manufacturer: p?.id_manufacturer ? +p.id_manufacturer : undefined, + id_supplier: p?.id_supplier ? +p.id_supplier : undefined, + id_category_default: p?.id_category_default ? +p.id_category_default : undefined, + priceHt: p?.price ? +p.price : 0 + }; + }) + ); + }) + ); + } + + // --- Images + private getProductImageIds(productId: number) { + return this.http.get(`${this.base}/images/products/${productId}`, {responseType: 'json' as any}).pipe( + map(r => { + const arr = (r?.image ?? r?.images ?? []) as Array; + return Array.isArray(arr) ? arr.map(x => +x.id) : []; + }) + ); + } + + getProductImageUrls(productId: number) { + return this.getProductImageIds(productId).pipe( + map(ids => ids.map(idImg => `${this.base}/images/products/${productId}/${idImg}`)) + ); + } + + uploadProductImage(productId: number, file: File) { + const fd = new FormData(); + fd.append('image', file); + return this.http.post(`${this.base}/images/products/${productId}`, fd); + } + + // --- Stock (quantité) — sans POST, uniquement PUT + /** Lis la quantité (prend exactement la ligne stock_available utilisée par Presta) */ + getProductQuantity(productId: number) { + // 1) on essaie d’abord de récupérer l’id SA via les associations du produit (le plus fiable) + return this.http.get(`${this.base}/products/${productId}`, {responseType: 'text'}).pipe( + switchMap(xml => { + const m = xml.match( + /]*>[\s\S]*?]*>\s*(?:)?\s*<\/id>[\s\S]*?<\/stock_availables>/i + ); + const saId = m ? +m[1] : null; + if (saId) { + // lire la ligne SA par son id → on lit quantity + const params = new HttpParams() + .set('display', '[id,quantity]') + .set('filter[id]', `${saId}`) + .set('output_format', 'JSON'); + return this.http.get(`${this.base}/stock_availables`, {params}).pipe( + map(r => { + const sa = (r?.stock_availables ?? [])[0]; + return sa?.quantity != null ? +sa.quantity : 0; + }) + ); + } + + // 2) fallback: rechercher la ligne par id_product_attribute=0 (sans filtre shop → puis avec) + let p1 = new HttpParams() + .set('display', '[id,quantity,id_product_attribute]') + .set('filter[id_product]', `${productId}`) + .set('filter[id_product_attribute]', '0') + .set('output_format', 'JSON'); + return this.http.get(`${this.base}/stock_availables`, {params: p1}).pipe( + switchMap(r => { + const sa = (r?.stock_availables ?? [])[0]; + if (sa?.quantity != null) return of(+sa.quantity); + + return this.getDefaultShopContext().pipe( + switchMap(({idShop, idShopGroup}) => { + const p2 = new HttpParams() + .set('display', '[id,quantity,id_product_attribute]') + .set('filter[id_product]', `${productId}`) + .set('filter[id_product_attribute]', '0') + .set('filter[id_shop]', `${idShop}`) + .set('filter[id_shop_group]', `${idShopGroup}`) + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/stock_availables`, {params: p2}).pipe( + map(r2 => { + const sa2 = (r2?.stock_availables ?? [])[0]; + return sa2?.quantity != null ? +sa2.quantity : 0; + }) + ); + }) + ); + }) + ); + }) + ); + } + + /** PUT quantité : on cible précisément la ligne SA (avec id_shop/id_shop_group réels) */ + private setProductQuantity(productId: number, quantity: number) { + const q = Math.max(0, Math.trunc(quantity)); + + // Helper: fait un PUT en reprenant tous les champs utiles de la ligne SA existante + const putFromRow = (row: any) => { + const saId = +row.id; + const idShop = row.id_shop ? +row.id_shop : undefined; + const idShopGroup = row.id_shop_group ? +row.id_shop_group : undefined; + + const extraShop = + (idShop != null ? `${idShop}` : '') + + (idShopGroup != null ? `${idShopGroup}` : ''); + + const xml = ` + + ${saId} + ${productId} + 0 + ${extraShop} + ${q} + 0 + 0 + +`; + + return this.http.put(`${this.base}/stock_availables/${saId}`, xml, { + headers: this.headersXml, + responseType: 'text' + }).pipe(map(() => true)); + }; + + // 1) récupérer l’id SA via les associations du produit (le plus fiable) + return this.http.get(`${this.base}/products/${productId}`, {responseType: 'text'}).pipe( + switchMap(xml => { + const m = xml.match( + /]*>[\s\S]*?]*>\s*(?:)?\s*<\/id>[\s\S]*?<\/stock_availables>/i + ); + const saId = m ? +m[1] : null; + if (saId) { + const params = new HttpParams() + .set('display', '[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]') + .set('filter[id]', `${saId}`) + .set('output_format', 'JSON'); + return this.http.get(`${this.base}/stock_availables`, {params}).pipe( + switchMap(r => { + const row = (r?.stock_availables ?? [])[0]; + if (row?.id) return putFromRow(row); + // si l’id renvoyé n’est pas lisible (peu probable), on bascule sur le fallback ci-dessous + return of(null); + }) + ); + } + + // 2) fallback: rechercher la ligne par id_product_attribute=0 (sans filtre shop → puis avec) + let p1 = new HttpParams() + .set('display', '[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]') + .set('filter[id_product]', `${productId}`) + .set('filter[id_product_attribute]', '0') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/stock_availables`, {params: p1}).pipe( + switchMap(r => { + const row = (r?.stock_availables ?? [])[0]; + if (row?.id) return putFromRow(row); + + return this.getDefaultShopContext().pipe( + switchMap(({idShop, idShopGroup}) => { + const p2 = new HttpParams() + .set('display', '[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]') + .set('filter[id_product]', `${productId}`) + .set('filter[id_product_attribute]', '0') + .set('filter[id_shop]', `${idShop}`) + .set('filter[id_shop_group]', `${idShopGroup}`) + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/stock_availables`, {params: p2}).pipe( + switchMap(r2 => { + const row2 = (r2?.stock_availables ?? [])[0]; + if (row2?.id) return putFromRow(row2); + + console.warn('[Presta] Aucune ligne stock_available PUTtable trouvée pour product:', productId); + return of(true); // on ne bloque pas le flux si la ligne est introuvable + }) + ); + }) + ); + }) + ); + }) + ); + } + + // --- Association fournisseur (product_suppliers) + private findProductSupplierId(productId: number, supplierId: number) { + const params = new HttpParams() + .set('display', '[id,id_product,id_supplier,id_product_attribute]') + .set('filter[id_product]', `${productId}`) + .set('filter[id_supplier]', `${supplierId}`) + .set('filter[id_product_attribute]', '0') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/product_suppliers`, {params}).pipe( + map(r => { + const row = (r?.product_suppliers ?? [])[0]; + return row?.id ? +row.id : null; + }) + ); + } + + private upsertProductSupplier(productId: number, supplierId: number) { + if (!supplierId) return of(true); + + return forkJoin({ + curId: this.getDefaultCurrencyId(), + psId: this.findProductSupplierId(productId, supplierId), + }).pipe( + switchMap(({curId, psId}) => { + const body = (id?: number) => ` + + ${id ? `${id}` : ''} + ${productId} + 0 + ${supplierId} + ${curId} + + 0 + +`; + + if (psId) { + return this.http.put(`${this.base}/product_suppliers/${psId}`, body(psId), { + headers: this.headersXml, responseType: 'text' + }).pipe(map(() => true)); + } else { + return this.http.post(`${this.base}/product_suppliers`, body(), { + headers: this.headersXml, responseType: 'text' + }).pipe(map(() => true)); + } + }) + ); + } + + // --- Create & Update produit + private getProductForUpdate(id: number) { + const params = new HttpParams().set('output_format', 'JSON').set('display', 'full'); + return this.http.get(`${this.base}/products/${id}`, {params}).pipe(map(r => r?.product ?? r)); + } + + createProduct(dto: PsProduct) { + const priceHt = this.ttcToHt(dto.priceTtc, dto.vatRate); + + return forkJoin({ + idLang: this.getDefaultLangId(), + idTaxGroup: this.getDefaultTaxRulesGroupId(), + shop: this.getDefaultShopContext(), + homeCat: this.getHomeCategoryId(), + rootCat: this.getRootCategoryId(), + }).pipe( + switchMap(({idLang, idTaxGroup, shop, homeCat, rootCat}) => { + const desc = this.buildAugmentedDescription(dto); + + const xml = ` + + ${dto.manufacturerId} + ${dto.supplierId} + ${dto.categoryId} + ${shop.idShop} + ${idTaxGroup} + + standard + standard + 1 + 1 + + ${priceHt} + 1 + both + 1 + 1 + 1 + + ${this.escapeXml(dto.name)} + ${this.escapeXml(this.slug(dto.name))} + ${this.escapeXml(desc)} + + + + ${dto.categoryId} + + + +`; + + return this.http.post(`${this.base}/products`, xml, {headers: this.headersXml, responseType: 'text'}) + .pipe(map((res: string) => this.extractIdFromXml(res))); + }), + switchMap((productId) => { + if (!productId) return of(null); + const ops: Array> = []; + if (dto.images?.length) ops.push(forkJoin(dto.images.map(f => this.uploadProductImage(productId, f)))); + ops.push(this.setProductQuantity(productId, dto.quantity)); + if (dto.supplierId) ops.push(this.upsertProductSupplier(productId, dto.supplierId)); + return forkJoin(ops).pipe(map(() => productId)); + }) + ); + } + + updateProduct(id: number, dto: PsProduct) { + const priceHt = this.ttcToHt(dto.priceTtc, dto.vatRate); + + return forkJoin({ + idLang: this.getDefaultLangId(), + prod: this.getProductForUpdate(id), + idTaxGroup: this.getDefaultTaxRulesGroupId(), + shop: this.getDefaultShopContext(), + homeCat: this.getHomeCategoryId(), + rootCat: this.getRootCategoryId(), + }).pipe( + switchMap(({idLang, prod, idTaxGroup, shop, homeCat, rootCat}) => { + const active = +prod?.active || 1; + + const lr = Array.isArray(prod?.link_rewrite) + ? prod.link_rewrite.map((l: any) => ({id: +l.id, value: String(l.value ?? '')})) + : [{id: idLang, value: this.slug(dto.name)}]; + + const lrXml = `${ + lr.map((e: { + id: any; + value: string; + }) => `${this.escapeXml(e.value)}`).join('') + }`; + + const desc = this.buildAugmentedDescription(dto); + + const xml = ` + + ${id} + ${active} + + ${dto.manufacturerId} + ${dto.supplierId} + ${dto.categoryId} + ${shop.idShop} + ${idTaxGroup} + + standard + standard + 1 + 1 + + ${priceHt} + both + 1 + 1 + 1 + + ${this.escapeXml(dto.name)} + ${lrXml} + ${this.escapeXml(desc)} + + + + ${dto.categoryId} + + + +`; + + return this.http.put(`${this.base}/products/${id}`, xml, {headers: this.headersXml, responseType: 'text'}) + .pipe(map(() => id)); + }), + switchMap(productId => { + const ops: Array> = [this.setProductQuantity(productId, dto.quantity)]; + if (dto.supplierId) ops.push(this.upsertProductSupplier(productId, dto.supplierId)); + return forkJoin(ops).pipe(map(() => true)); + }) + ); + } + + deleteProduct(id: number) { + return this.http.delete(`${this.base}/products/${id}`, {responseType: 'text'}).pipe(map(() => true)); + } + + // -------- Description augmentée + private buildAugmentedDescription(dto: PsProduct): string { + const parts: string[] = []; + if (dto.description?.trim()) parts.push(dto.description.trim()); + + const flags: string[] = []; + flags.push(`Complet: ${dto.complete ? 'Oui' : 'Non'}`); + flags.push(`Notice: ${dto.hasManual ? 'Oui' : 'Non'}`); + if (dto.conditionLabel) flags.push(`État: ${dto.conditionLabel}`); + + parts.push('', flags.join(' | ')); + return parts.join('\n').trim(); + } +} diff --git a/client/src/app/services/app/product.service.ts b/client/src/app/services/product.service.ts similarity index 91% rename from client/src/app/services/app/product.service.ts rename to client/src/app/services/product.service.ts index afc48c2..a207dcc 100644 --- a/client/src/app/services/app/product.service.ts +++ b/client/src/app/services/product.service.ts @@ -1,8 +1,8 @@ import {inject, Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; -import {Product} from '../../interfaces/product'; -import {CrudService} from '../crud.service'; +import {Product} from '../interfaces/product'; +import {CrudService} from './crud.service'; @Injectable({ providedIn: 'root' diff --git a/client/src/app/services/app/product_images.service.ts b/client/src/app/services/product_images.service.ts similarity index 95% rename from client/src/app/services/app/product_images.service.ts rename to client/src/app/services/product_images.service.ts index bf215e2..581687b 100644 --- a/client/src/app/services/app/product_images.service.ts +++ b/client/src/app/services/product_images.service.ts @@ -1,7 +1,7 @@ import {inject, Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; -import {CrudService} from '../crud.service'; +import {CrudService} from './crud.service'; @Injectable({ providedIn: 'root'