diff --git a/client/src/app/components/admin-navbar/admin-navbar.component.html b/client/src/app/components/admin-navbar/admin-navbar.component.html index c9dba51..085256d 100644 --- a/client/src/app/components/admin-navbar/admin-navbar.component.html +++ b/client/src/app/components/admin-navbar/admin-navbar.component.html @@ -1,11 +1,11 @@ - + - + - + diff --git a/client/src/app/components/admin-navbar/admin-navbar.component.ts b/client/src/app/components/admin-navbar/admin-navbar.component.ts index 2fcac2c..a9eeb20 100644 --- a/client/src/app/components/admin-navbar/admin-navbar.component.ts +++ b/client/src/app/components/admin-navbar/admin-navbar.component.ts @@ -1,26 +1,18 @@ import { Component } from '@angular/core'; -import {MatAnchor, MatButton} from "@angular/material/button"; -import {MatIcon} from '@angular/material/icon'; -import {RouterLink, RouterLinkActive} from '@angular/router'; import {MatTab, MatTabGroup} from '@angular/material/tabs'; -import {BrandsListComponent} from '../brands-list/brands-list.component'; -import {PlatformsListComponent} from '../platforms-list/platforms-list.component'; -import {CategoriesListComponent} from '../categories-list/categories-list.component'; +import {PlatformListComponent} from '../platform-list/platform-list.component'; +import {CategoryListComponent} from '../category-list/category-list.component'; +import {BrandListComponent} from '../brand-list/brand-list.component'; @Component({ selector: 'app-admin-navbar', standalone: true, imports: [ - MatButton, - MatIcon, - RouterLinkActive, - RouterLink, - MatAnchor, MatTabGroup, MatTab, - BrandsListComponent, - PlatformsListComponent, - CategoriesListComponent + CategoryListComponent, + BrandListComponent, + PlatformListComponent ], templateUrl: './admin-navbar.component.html', styleUrl: './admin-navbar.component.css' diff --git a/client/src/app/components/brand-dialog/brand-dialog.component.html b/client/src/app/components/brand-dialog/brand-dialog.component.html deleted file mode 100644 index ca584db..0000000 --- a/client/src/app/components/brand-dialog/brand-dialog.component.html +++ /dev/null @@ -1,13 +0,0 @@ -

{{ brandExists ? 'Modifier la marque' : 'Nouvelle marque' }}

- - - - Nom - - - - - - - - diff --git a/client/src/app/components/brand-dialog/brand-dialog.component.ts b/client/src/app/components/brand-dialog/brand-dialog.component.ts deleted file mode 100644 index 8d9ce7d..0000000 --- a/client/src/app/components/brand-dialog/brand-dialog.component.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Component, Inject } from '@angular/core'; -import { - MatDialogRef, - MAT_DIALOG_DATA, - MatDialogTitle, - MatDialogContent, - MatDialogActions -} from '@angular/material/dialog'; -import { Brand } from '../../interfaces/brand'; -import {MatFormField, MatLabel} from '@angular/material/form-field'; -import {MatInput} from '@angular/material/input'; -import {FormsModule} from '@angular/forms'; -import {MatButton} from '@angular/material/button'; - -@Component({ - selector: 'app-brand-dialog', - standalone: true, - imports: [ - MatDialogTitle, - MatDialogContent, - MatFormField, - MatLabel, - MatInput, - FormsModule, - MatDialogActions, - MatButton - ], - templateUrl: './brand-dialog.component.html' -}) -export class BrandDialogComponent { - brand: Brand = { id: '', name: '' } - - constructor( - private readonly dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: { brand: Brand } - ) { - this.brand = { ...(data?.brand || { id: '', name: '' }) }; - } - - get brandExists(): boolean { - return !!this.data?.brand?.id; - } - - save() { - this.dialogRef.close(this.brand); - } - - cancel() { - this.dialogRef.close(); - } -} diff --git a/client/src/app/components/brand-dialog/brand-dialog.component.css b/client/src/app/components/brand-list/brand-list.component.css similarity index 100% rename from client/src/app/components/brand-dialog/brand-dialog.component.css rename to client/src/app/components/brand-list/brand-list.component.css diff --git a/client/src/app/components/brand-list/brand-list.component.html b/client/src/app/components/brand-list/brand-list.component.html new file mode 100644 index 0000000..d2e8ec9 --- /dev/null +++ b/client/src/app/components/brand-list/brand-list.component.html @@ -0,0 +1,6 @@ + + diff --git a/client/src/app/components/brand-list/brand-list.component.ts b/client/src/app/components/brand-list/brand-list.component.ts new file mode 100644 index 0000000..bd70479 --- /dev/null +++ b/client/src/app/components/brand-list/brand-list.component.ts @@ -0,0 +1,23 @@ +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/brands-list/brands-list.component.html b/client/src/app/components/brands-list/brands-list.component.html deleted file mode 100644 index c2a37e6..0000000 --- a/client/src/app/components/brands-list/brands-list.component.html +++ /dev/null @@ -1,44 +0,0 @@ -
-
- - - - - -
- - - - - - - - - - - - - - - - - -
Nom{{ brand.name }} - - -
- - - - @if (!brands || brands.length === 0) { -
- Aucune plateforme trouvée. -
- } -
diff --git a/client/src/app/components/brands-list/brands-list.component.ts b/client/src/app/components/brands-list/brands-list.component.ts deleted file mode 100644 index b7e426b..0000000 --- a/client/src/app/components/brands-list/brands-list.component.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { - Component, - Input, - Output, - EventEmitter, - ViewChild, - AfterViewInit, - OnChanges, - SimpleChanges, - OnInit, - inject -} from '@angular/core'; -import { - MatCell, MatCellDef, - MatColumnDef, - MatHeaderCell, - MatHeaderCellDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef, - MatTable, - MatTableDataSource -} from '@angular/material/table'; -import {MatPaginator} from '@angular/material/paginator'; -import {MatSort} from '@angular/material/sort'; -import {Brand} from '../../interfaces/brand'; -import {MatButton, MatIconButton} from '@angular/material/button'; -import {MatIcon} from '@angular/material/icon'; -import {MatFormField} from '@angular/material/form-field'; -import {MatInput} from '@angular/material/input'; -import {BrandService} from '../../services/app/brand.service'; -import {MatDialog} from '@angular/material/dialog'; -import { BrandDialogComponent } from '../brand-dialog/brand-dialog.component'; - -@Component({ - selector: 'app-brands-list', - templateUrl: './brands-list.component.html', - standalone: true, - imports: [ - MatButton, - MatIcon, - MatFormField, - MatInput, - MatTable, - MatColumnDef, - MatHeaderCell, - MatCell, - MatHeaderCellDef, - MatCellDef, - MatSort, - MatIconButton, - MatHeaderRow, - MatRow, - MatHeaderRowDef, - MatRowDef, - MatPaginator - ], - styleUrls: ['./brands-list.component.css'] -}) -export class BrandsListComponent implements OnInit, AfterViewInit, OnChanges { - - @Input() brands: Brand[] = []; - @Output() add = new EventEmitter(); - @Output() edit = new EventEmitter(); - @Output() delete = new EventEmitter(); - - displayedColumns: string[] = ['name', 'actions']; - dataSource = new MatTableDataSource([]); - - @ViewChild(MatPaginator) paginator!: MatPaginator; - @ViewChild(MatSort) sort!: MatSort; - - private readonly brandService: BrandService = inject(BrandService); - private readonly dialog = inject(MatDialog); - - ngOnInit(): void { - if (!this.brands || this.brands.length === 0) { - this.loadBrands(); - } else { - this.dataSource.data = this.brands; - } - } - - ngOnChanges(changes: SimpleChanges): void { - if (changes['brands']) { - this.dataSource.data = this.brands || []; - } - } - - ngAfterViewInit(): void { - this.dataSource.paginator = this.paginator; - this.dataSource.sort = this.sort; - } - - loadBrands() { - this.brandService.getBrands().subscribe({ - next: (brands:Brand[]) => { - this.brands = brands || [] - this.dataSource.data = this.brands; - console.log("Fetched brands:", this.brands); - }, - error: () => this.brands = [] - }); - } - - onAdd(): void { - const ref = this.dialog.open(BrandDialogComponent, { - data: { brand: { id: '', name: '', brand: undefined } }, - width: '420px' - }); - - ref.afterClosed().subscribe((result?: Brand) => { - if (result) { - this.add.emit(result); - this.brandService.addBrand(result).subscribe(() => this.loadBrands()); - } - }); - } - - onEdit(brand: Brand): void { - const ref = this.dialog.open(BrandDialogComponent, { - data: { brand: { ...brand } }, - width: '420px' - }); - - ref.afterClosed().subscribe((result?: Brand) => { - if (result) { - this.edit.emit(result); - this.brandService.updateBrand((brand as any).id, result).subscribe(() => this.loadBrands()); - } - }); - } - - onDelete(brand: Brand): void { - this.delete.emit(brand); - this.brandService.deleteBrand((brand as any).id).subscribe(() => this.loadBrands()); - } - - applyFilter(value: string): void { - this.dataSource.filter = (value || '').trim().toLowerCase(); - } -} diff --git a/client/src/app/components/categories-list/categories-list.component.css b/client/src/app/components/categories-list/categories-list.component.css deleted file mode 100644 index 87a4b85..0000000 --- a/client/src/app/components/categories-list/categories-list.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/categories-list/categories-list.component.html b/client/src/app/components/categories-list/categories-list.component.html deleted file mode 100644 index 3c5f4ef..0000000 --- a/client/src/app/components/categories-list/categories-list.component.html +++ /dev/null @@ -1,44 +0,0 @@ -
-
- - - - - -
- - - - - - - - - - - - - - - - - -
Nom{{ category.name }} - - -
- - - - @if (!categories || categories.length === 0) { -
- Aucune catégorie trouvée. -
- } -
diff --git a/client/src/app/components/categories-list/categories-list.component.ts b/client/src/app/components/categories-list/categories-list.component.ts deleted file mode 100644 index 5d9e0d4..0000000 --- a/client/src/app/components/categories-list/categories-list.component.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - Component, - Input, - Output, - EventEmitter, - ViewChild, - AfterViewInit, - OnChanges, - SimpleChanges, - OnInit, - inject -} from '@angular/core'; -import { - MatCell, MatCellDef, - MatColumnDef, - MatHeaderCell, - MatHeaderCellDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef, - MatTable, - MatTableDataSource -} from '@angular/material/table'; -import {MatPaginator} from '@angular/material/paginator'; -import {MatSort} from '@angular/material/sort'; -import {Category} from '../../interfaces/category'; -import {MatButton, MatIconButton} from '@angular/material/button'; -import {MatIcon} from '@angular/material/icon'; -import {MatFormField} from '@angular/material/form-field'; -import {MatInput} from '@angular/material/input'; -import {CategoryService} from '../../services/app/category.service'; -import {MatDialog} from '@angular/material/dialog'; -import { CategoryDialogComponent } from '../category-dialog/category-dialog.component'; - -@Component({ - selector: 'app-categories-list', - templateUrl: './categories-list.component.html', - standalone: true, - imports: [ - MatButton, - MatIcon, - MatFormField, - MatInput, - MatTable, - MatColumnDef, - MatHeaderCell, - MatCell, - MatHeaderCellDef, - MatCellDef, - MatSort, - MatIconButton, - MatHeaderRow, - MatRow, - MatHeaderRowDef, - MatRowDef, - MatPaginator - ], - styleUrls: ['./categories-list.component.css'] -}) -export class CategoriesListComponent implements OnInit, AfterViewInit, OnChanges { - - @Input() categories: Category[] = []; - @Output() add = new EventEmitter(); - @Output() edit = new EventEmitter(); - @Output() delete = new EventEmitter(); - - displayedColumns: string[] = ['name', 'actions']; - dataSource = new MatTableDataSource([]); - - @ViewChild(MatPaginator) paginator!: MatPaginator; - @ViewChild(MatSort) sort!: MatSort; - - private readonly categoryService: CategoryService = inject(CategoryService); - private readonly dialog = inject(MatDialog); - - ngOnInit(): void { - if (!this.categories || this.categories.length === 0) { - this.loadCategories(); - } else { - this.dataSource.data = this.categories; - } - } - - ngOnChanges(changes: SimpleChanges): void { - if (changes['categories']) { - this.dataSource.data = this.categories || []; - } - } - - ngAfterViewInit(): void { - this.dataSource.paginator = this.paginator; - this.dataSource.sort = this.sort; - } - - loadCategories() { - this.categoryService.getCategories().subscribe({ - next: (categories:Category[]) => { - this.categories = categories || [] - this.dataSource.data = this.categories; - }, - error: () => this.categories = [] - }); - } - - onAdd(): void { - const ref = this.dialog.open(CategoryDialogComponent, { - data: { category: { id: '', name: '' } }, - width: '420px' - }); - - ref.afterClosed().subscribe((result?: Category) => { - if (result) { - this.add.emit(result); - this.categoryService.addCategory(result).subscribe(() => this.loadCategories()); - } - }); - } - - onEdit(category: Category): void { - const ref = this.dialog.open(CategoryDialogComponent, { - data: { category: { ...category } }, - width: '420px' - }); - - ref.afterClosed().subscribe((result?: Category) => { - if (result) { - this.edit.emit(result); - this.categoryService.updateCategory((category as any).id, result).subscribe(() => this.loadCategories()); - } - }); - } - - onDelete(category: Category): void { - this.delete.emit(category); - this.categoryService.deleteCategory((category as any).id).subscribe(() => this.loadCategories()); - } - - applyFilter(value: string): void { - this.dataSource.filter = (value || '').trim().toLowerCase(); - } -} diff --git a/client/src/app/components/category-dialog/category-dialog.component.html b/client/src/app/components/category-dialog/category-dialog.component.html deleted file mode 100644 index 2a05c27..0000000 --- a/client/src/app/components/category-dialog/category-dialog.component.html +++ /dev/null @@ -1,13 +0,0 @@ -

{{ categoryExists ? 'Modifier la catégorie' : 'Nouvelle catégorie' }}

- - - - Nom - - - - - - - - diff --git a/client/src/app/components/category-dialog/category-dialog.component.ts b/client/src/app/components/category-dialog/category-dialog.component.ts deleted file mode 100644 index 9d864f2..0000000 --- a/client/src/app/components/category-dialog/category-dialog.component.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Component, Inject } from '@angular/core'; -import { - MatDialogRef, - MAT_DIALOG_DATA, - MatDialogTitle, - MatDialogContent, - MatDialogActions -} from '@angular/material/dialog'; -import { Category } from '../../interfaces/category'; -import {MatFormField, MatLabel} from '@angular/material/form-field'; -import {MatInput} from '@angular/material/input'; -import {FormsModule} from '@angular/forms'; -import {MatButton} from '@angular/material/button'; - -@Component({ - selector: 'app-category-dialog', - standalone: true, - imports: [ - MatDialogTitle, - MatDialogContent, - MatFormField, - MatLabel, - MatInput, - FormsModule, - MatDialogActions, - MatButton - ], - templateUrl: './category-dialog.component.html' -}) -export class CategoryDialogComponent { - category: Category = { id: '', name: '' } - - constructor( - private readonly dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: { category: Category } - ) { - this.category = { ...(data?.category || { id: '', name: '' }) }; - } - - get categoryExists(): boolean { - return !!this.data?.category?.id; - } - - save() { - this.dialogRef.close(this.category); - } - - cancel() { - this.dialogRef.close(); - } -} diff --git a/client/src/app/components/category-list/category-list.component.html b/client/src/app/components/category-list/category-list.component.html new file mode 100644 index 0000000..3783ce7 --- /dev/null +++ b/client/src/app/components/category-list/category-list.component.html @@ -0,0 +1,6 @@ + + diff --git a/client/src/app/components/category-list/category-list.component.ts b/client/src/app/components/category-list/category-list.component.ts new file mode 100644 index 0000000..ea41bf4 --- /dev/null +++ b/client/src/app/components/category-list/category-list.component.ts @@ -0,0 +1,23 @@ +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/brands-list/brands-list.component.css b/client/src/app/components/category-list/category.component.css similarity index 100% rename from client/src/app/components/brands-list/brands-list.component.css rename to client/src/app/components/category-list/category.component.css diff --git a/client/src/app/components/category-dialog/category-dialog.component.css b/client/src/app/components/generic-dialog/generic-dialog.component.css similarity index 100% rename from client/src/app/components/category-dialog/category-dialog.component.css rename to client/src/app/components/generic-dialog/generic-dialog.component.css diff --git a/client/src/app/components/generic-dialog/generic-dialog.component.html b/client/src/app/components/generic-dialog/generic-dialog.component.html new file mode 100644 index 0000000..0a49caa --- /dev/null +++ b/client/src/app/components/generic-dialog/generic-dialog.component.html @@ -0,0 +1,48 @@ +
+

{{ 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$ | async) ?? f.options ?? []; + + @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/generic-dialog/generic-dialog.component.ts b/client/src/app/components/generic-dialog/generic-dialog.component.ts new file mode 100644 index 0000000..66b19b5 --- /dev/null +++ b/client/src/app/components/generic-dialog/generic-dialog.component.ts @@ -0,0 +1,68 @@ +import {Component, Inject, OnInit} from '@angular/core'; +import {FormBuilder, FormGroup, ReactiveFormsModule} from '@angular/forms'; +import {MatDialogRef, MAT_DIALOG_DATA, MatDialogModule} from '@angular/material/dialog'; +import {CommonModule} from '@angular/common'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; +import {MatButtonModule} from '@angular/material/button'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatSelectModule} from '@angular/material/select'; +import {Observable} from 'rxjs'; + +type Field = { + key: string; + label: string; + type?: 'text' | 'number' | 'textarea' | 'checkbox' | 'select'; + options?: any[]; // options statiques + options$?: Observable; // options dynamiques + displayKey?: string; // clé à afficher (ex: 'name') + valueKey?: string; // clé valeur (ex: 'id'), si absente la valeur entière de l'objet est utilisée +}; + +@Component({ + selector: 'app-generic-dialog', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatCheckboxModule, + MatSelectModule + ], + templateUrl: './generic-dialog.component.html' +}) +export class GenericDialogComponent implements OnInit { + form!: FormGroup; + fields: Field[] = []; + + constructor( + private readonly fb: FormBuilder, + private readonly dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { item?: any; fields?: Field[]; title?: string } + ) { + } + + ngOnInit(): void { + this.fields = this.data?.fields ?? []; + this.form = this.fb.group({}); + for (const f of this.fields) { + const initial = + this.data?.item?.[f.key] ?? + (f.type === 'checkbox' ? false : (f.type === 'select' ? null : '')); + this.form.addControl(f.key, this.fb.control(initial)); + } + } + + save(): void { + if (this.form.valid) { + this.dialogRef.close({...this.data?.item, ...this.form.value}); + } + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/client/src/app/components/generic-list/generic-list.component.css b/client/src/app/components/generic-list/generic-list.component.css new file mode 100644 index 0000000..1175006 --- /dev/null +++ b/client/src/app/components/generic-list/generic-list.component.css @@ -0,0 +1,131 @@ +/* ===== 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/generic-list/generic-list.component.html b/client/src/app/components/generic-list/generic-list.component.html new file mode 100644 index 0000000..0bb8c80 --- /dev/null +++ b/client/src/app/components/generic-list/generic-list.component.html @@ -0,0 +1,86 @@ +
+
+

{{ title }}

+ +
+ +
+
+ +
+ + Filtrer + + +
+ +
+ + @for (col of (fields ?? []); track $index) { + + + + + + } + + + + + + + + +
+ {{ col.label }} + + {{ displayValue(element, col) }} + Actions + + +
+
+ +
+ +
+
diff --git a/client/src/app/components/generic-list/generic-list.component.ts b/client/src/app/components/generic-list/generic-list.component.ts new file mode 100644 index 0000000..36792cb --- /dev/null +++ b/client/src/app/components/generic-list/generic-list.component.ts @@ -0,0 +1,207 @@ +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 '../generic-dialog/generic-dialog.component'; +import {MatFormField, MatInput, MatLabel} from '@angular/material/input'; +import {MatIcon} from '@angular/material/icon'; + +type Field = { + key: string; + label: string; + sortable?: boolean; + displayKey?: string; + displayFn?: (value: any, element?: any) => string; + // nouveau : clé de tri (peut être chemin 'brand.name') ou fonction personnalisée + 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], + 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() 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.displayedColumns = this.fields.map(f => f.key).concat(['actions']); + this.load(); + } + + ngAfterViewInit() { + // configure le sortingDataAccessor avant d'attacher le sort + this.dataSource.sortingDataAccessor = (data: any, sortHeaderId: string) => { + // trouver le Field correspondant (ou utiliser la clé brute) + const field = this.fields.find(f => f.key === sortHeaderId); + if (!field) { + const raw = data?.[sortHeaderId]; + return raw == null ? '' : String(raw); + } + + // priorité : sortFn sur le field (géré par sort comparator plus bas) + if (field.sortKey) { + if (typeof field.sortKey === 'function') { + const v = field.sortKey(data); + return v == null ? '' : String(v); + } + const v = getByPath(data, field.sortKey); + return v == null ? '' : String(v); + } + + // fallback : valeur simple ou displayKey si object + const val = 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); + }; + + // attacher le MatSort + this.dataSource.sort = this.sort; + + // si un Field a sortFn définie, adapter le comparator global pour l'utiliser + 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); + }; + } + + // DEBUG + normalisation de l'id pour éviter 'undefined' lors de update + load() { + this.service.getAll().subscribe(items => { + console.debug('Loaded items from service:', items); + this.dataSource.data = (items as any[]).map(item => { + const normalizedId = 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 ? (item[this.idKey] ?? item?.id ?? item?._id) : null; + + const dialogRef = this.dialog.open(this.dialogComponent, { + width: '420px', + data: { + item: item ? {...item} : {}, + fields: this.fields, + title: item ? 'Modifier' : 'Ajouter', + originalId + } + }); + + dialogRef.afterClosed().subscribe((result: any) => { + if (!result) return; + + if (item) { + const idToUpdate = originalId ?? 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 = item[this.idKey] ?? item?.id ?? item?._id; + if (id == null) { + console.error('Cannot delete: id is null/undefined for item', item); + return; + } + this.service.delete(id).subscribe(() => { + this.delete.emit(item); + this.load(); + }); + } + + displayValue(element: any, field: Field): string { + const val = 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); + } + + trackByField(_index: number, field: Field) { + return field?.key ?? _index; + } + + protected readonly HTMLInputElement = HTMLInputElement; +} + +/** Helpers */ +function getByPath(obj: any, path: string): any { + if (!obj || !path) return undefined; + return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj); +} diff --git a/client/src/app/components/platform-dialog/platform-dialog.component.html b/client/src/app/components/platform-dialog/platform-dialog.component.html deleted file mode 100644 index e236f44..0000000 --- a/client/src/app/components/platform-dialog/platform-dialog.component.html +++ /dev/null @@ -1,21 +0,0 @@ -

{{ platformExists ? 'Modifier la plateforme' : 'Nouvelle plateforme' }}

- - - - Nom - - - - Marque - - @for (brand of brands; track brand.id) { - {{ brand.name }} - } - - - - - - - - diff --git a/client/src/app/components/platform-dialog/platform-dialog.component.ts b/client/src/app/components/platform-dialog/platform-dialog.component.ts deleted file mode 100644 index cc3883a..0000000 --- a/client/src/app/components/platform-dialog/platform-dialog.component.ts +++ /dev/null @@ -1,74 +0,0 @@ -import {Component, inject, Inject, OnInit} from '@angular/core'; -import {MatButton} from "@angular/material/button"; -import { - MAT_DIALOG_DATA, - MatDialogActions, - MatDialogContent, - MatDialogRef, - MatDialogTitle -} from "@angular/material/dialog"; -import {MatFormField, MatLabel} from "@angular/material/form-field"; -import {MatInput} from "@angular/material/input"; -import {FormsModule, ReactiveFormsModule} from "@angular/forms"; -import {Brand} from '../../interfaces/brand'; -import {Platform} from '../../interfaces/platform'; -import {MatOption} from '@angular/material/core'; -import {MatSelect} from '@angular/material/select'; -import {BrandService} from '../../services/app/brand.service'; - -@Component({ - selector: 'app-platform-dialog', - standalone: true, - imports: [ - MatButton, - MatDialogActions, - MatDialogContent, - MatDialogTitle, - MatFormField, - MatInput, - MatLabel, - ReactiveFormsModule, - FormsModule, - MatOption, - MatSelect - ], - templateUrl: './platform-dialog.component.html', - styleUrl: './platform-dialog.component.css' -}) -export class PlatformDialogComponent implements OnInit { - - private readonly brandService: BrandService = inject(BrandService); - - platform: Platform = { id: '', name: '', brand: undefined }; - brands: Brand[] = []; - - constructor( - private readonly dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: { platform: Platform } - ) { - this.platform = { ...data.platform }; - } - - ngOnInit(): void { - this.loadBrands(); - } - - get platformExists(): boolean { - return !!this.data?.platform?.id; - } - - loadBrands() { - this.brandService.getBrands().subscribe({ - next: (brands:Brand[]) => this.brands = brands || [], - error: () => this.brands = [] - }); - } - - save() { - this.dialogRef.close(this.platform); - } - - cancel() { - this.dialogRef.close(); - } -} diff --git a/client/src/app/components/platform-dialog/platform-dialog.component.css b/client/src/app/components/platform-list/platform-list.component.css similarity index 100% rename from client/src/app/components/platform-dialog/platform-dialog.component.css rename to client/src/app/components/platform-list/platform-list.component.css diff --git a/client/src/app/components/platform-list/platform-list.component.html b/client/src/app/components/platform-list/platform-list.component.html new file mode 100644 index 0000000..a6463d1 --- /dev/null +++ b/client/src/app/components/platform-list/platform-list.component.html @@ -0,0 +1,6 @@ + + diff --git a/client/src/app/components/platform-list/platform-list.component.ts b/client/src/app/components/platform-list/platform-list.component.ts new file mode 100644 index 0000000..eba19e8 --- /dev/null +++ b/client/src/app/components/platform-list/platform-list.component.ts @@ -0,0 +1,36 @@ +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(), // transmet les brands dynamiquement + displayKey: 'name', // affiche brand.name dans le select + // valueKey: 'id' // uncommenter si backend attend l'id au lieu de l'objet + sortable: true, + sortKey: 'brand.name' // permet de trier par brand.name + } + ]; +} diff --git a/client/src/app/components/platforms-list/platforms-list.component.css b/client/src/app/components/platforms-list/platforms-list.component.css deleted file mode 100644 index aeccac5..0000000 --- a/client/src/app/components/platforms-list/platforms-list.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-platforms { - 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/platforms-list/platforms-list.component.html b/client/src/app/components/platforms-list/platforms-list.component.html deleted file mode 100644 index 01dc89f..0000000 --- a/client/src/app/components/platforms-list/platforms-list.component.html +++ /dev/null @@ -1,50 +0,0 @@ -
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - -
Nom{{ platform.name }}Marque{{ platform.brand.name }} - - -
- - - - @if (!platforms || platforms.length === 0) { -
- Aucune plateforme trouvée. -
- } -
diff --git a/client/src/app/components/platforms-list/platforms-list.component.ts b/client/src/app/components/platforms-list/platforms-list.component.ts deleted file mode 100644 index 309315f..0000000 --- a/client/src/app/components/platforms-list/platforms-list.component.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { - Component, - Input, - Output, - EventEmitter, - ViewChild, - AfterViewInit, - OnChanges, - SimpleChanges, - OnInit, - inject -} from '@angular/core'; -import { - MatCell, MatCellDef, - MatColumnDef, - MatHeaderCell, - MatHeaderCellDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef, - MatTable, - MatTableDataSource -} from '@angular/material/table'; -import {MatPaginator} from '@angular/material/paginator'; -import {MatSort} from '@angular/material/sort'; -import {Platform} from '../../interfaces/platform'; -import {MatButton, MatIconButton} from '@angular/material/button'; -import {MatIcon} from '@angular/material/icon'; -import {MatFormField} from '@angular/material/form-field'; -import {MatInput} from '@angular/material/input'; -import {PlatformService} from '../../services/app/platform.service'; -import {MatDialog} from '@angular/material/dialog'; -import { PlatformDialogComponent } from '../platform-dialog/platform-dialog.component'; - -@Component({ - selector: 'app-platforms-list', - templateUrl: './platforms-list.component.html', - standalone: true, - imports: [ - MatButton, - MatIcon, - MatFormField, - MatInput, - MatTable, - MatColumnDef, - MatHeaderCell, - MatCell, - MatHeaderCellDef, - MatCellDef, - MatSort, - MatIconButton, - MatHeaderRow, - MatRow, - MatHeaderRowDef, - MatRowDef, - MatPaginator - ], - styleUrls: ['./platforms-list.component.css'] -}) -export class PlatformsListComponent implements OnInit, AfterViewInit, OnChanges { - - @Input() platforms: Platform[] = []; - @Output() add = new EventEmitter(); - @Output() edit = new EventEmitter(); - @Output() delete = new EventEmitter(); - - displayedColumns: string[] = ['name', 'brand', 'actions']; - dataSource = new MatTableDataSource([]); - - @ViewChild(MatPaginator) paginator!: MatPaginator; - @ViewChild(MatSort) sort!: MatSort; - - private readonly platformService: PlatformService = inject(PlatformService); - private readonly dialog = inject(MatDialog); - - ngOnInit(): void { - if (!this.platforms || this.platforms.length === 0) { - this.loadPlatforms(); - } else { - this.dataSource.data = this.platforms; - } - } - - ngOnChanges(changes: SimpleChanges): void { - if (changes['platforms']) { - this.dataSource.data = this.platforms || []; - } - } - - ngAfterViewInit(): void { - this.dataSource.paginator = this.paginator; - this.dataSource.sort = this.sort; - } - - loadPlatforms() { - this.platformService.getPlatforms().subscribe({ - next: (platforms:Platform[]) => { - this.platforms = platforms || [] - this.dataSource.data = this.platforms; - console.log("Fetched platforms:", this.platforms); - }, - error: () => this.platforms = [] - }); - } - - onAdd(): void { - const ref = this.dialog.open(PlatformDialogComponent, { - data: { platform: { id: '', name: '', brand: undefined } }, - width: '420px' - }); - - ref.afterClosed().subscribe((result?: Platform) => { - if (result) { - this.add.emit(result); - this.platformService.addPlatform(result).subscribe(() => this.loadPlatforms()); - } - }); - } - - onEdit(platform: Platform): void { - const ref = this.dialog.open(PlatformDialogComponent, { - data: { platform: { ...platform } }, - width: '420px' - }); - - ref.afterClosed().subscribe((result?: Platform) => { - if (result) { - this.edit.emit(result); - this.platformService.updatePlatform((platform as any).id, result).subscribe(() => this.loadPlatforms()); - } - }); - } - - onDelete(platform: Platform): void { - this.delete.emit(platform); - this.platformService.deletePlatform((platform as any).id).subscribe(() => this.loadPlatforms()); - } - - applyFilter(value: string): void { - this.dataSource.filter = (value || '').trim().toLowerCase(); - } -} diff --git a/client/src/app/components/product-dialog/product-dialog.component.css b/client/src/app/components/product-dialog/product-dialog.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/app/components/product-dialog/product-dialog.component.html b/client/src/app/components/product-dialog/product-dialog.component.html deleted file mode 100644 index 0958e21..0000000 --- a/client/src/app/components/product-dialog/product-dialog.component.html +++ /dev/null @@ -1,13 +0,0 @@ -

{{ productExists ? 'Modifier la plateforme' : 'Nouvelle plateforme' }}

- - - - Nom - - - - - - - - diff --git a/client/src/app/components/product-dialog/product-dialog.component.ts b/client/src/app/components/product-dialog/product-dialog.component.ts deleted file mode 100644 index 1fad355..0000000 --- a/client/src/app/components/product-dialog/product-dialog.component.ts +++ /dev/null @@ -1,62 +0,0 @@ -import {Component, Inject} from '@angular/core'; -import { - MatDialogRef, - MAT_DIALOG_DATA, - MatDialogTitle, - MatDialogContent, - MatDialogActions -} from '@angular/material/dialog'; -import {Product} from '../../interfaces/product'; -import {MatFormField, MatLabel} from '@angular/material/form-field'; -import {MatInput} from '@angular/material/input'; -import {FormsModule} from '@angular/forms'; -import {MatButton} from '@angular/material/button'; - -@Component({ - selector: 'app-product-dialog', - standalone: true, - imports: [ - MatDialogTitle, - MatDialogContent, - MatFormField, - MatLabel, - MatInput, - FormsModule, - MatDialogActions, - MatButton - ], - templateUrl: './product-dialog.component.html' -}) -export class ProductDialogComponent { - product: Product = { - id: '', - title: '', - description: '', - price: 0, - quantity: 0, - complete: false, - manualIncluded: false, - category: undefined, - platform: undefined, - condition: undefined - }; - - constructor( - private readonly dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: { product: Product } - ) { - this.product = {...(data?.product || {id: '', name: ''})}; - } - - get productExists(): boolean { - return !!this.data?.product?.id; - } - - save() { - this.dialogRef.close(this.product); - } - - cancel() { - this.dialogRef.close(); - } -} diff --git a/client/src/app/components/products-list/products-list.component.css b/client/src/app/components/products-list/products-list.component.css index e69de29..27c854b 100644 --- a/client/src/app/components/products-list/products-list.component.css +++ b/client/src/app/components/products-list/products-list.component.css @@ -0,0 +1,122 @@ +/* ===== 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: flex-end; /* bouton à droite */ + gap: 1rem; + flex-wrap: wrap; + border-bottom: 1px solid rgba(0,0,0,.08); + padding-bottom: .75rem; +} + +/* ===== 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); } +} diff --git a/client/src/app/components/products-list/products-list.component.html b/client/src/app/components/products-list/products-list.component.html index 83556fd..b579034 100644 --- a/client/src/app/components/products-list/products-list.component.html +++ b/client/src/app/components/products-list/products-list.component.html @@ -1,104 +1,125 @@ -
-
- +
+ +
+
+ +
+
- - + +
+ + Rechercher +
- + +
+
- - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - -
Nom{{ product.title }}Nom{{ product.title }}Description{{ product.description }}Description{{ product.description }}Catégorie{{ product.category.name }}Catégorie{{ product.category.name }}Plateforme{{ product.platform.name }}Plateforme{{ product.platform.name }}État{{ product.condition.displayName }}État{{ product.condition.displayName }}Complet - @if (product.complete) { - check_circle - } @else { - cancel - } - Complet + @if (product.complete) { + check_circle + } @else { + cancel + } + Notice - @if (product.manual) { - check_circle - } @else { - cancel - } - Notice + @if (product.manual) { + check_circle + } @else { + cancel + } + Prix{{ product.price | currency:'EUR' }}Prix{{ product.price | currency:'EUR' }}Quantité{{ product.quantity }}Quantité{{ product.quantity }} - - - Actions + + +
+ + + +
- + +
+ + +
@if (!products || products.length === 0) { -
- Aucun produit trouvé. -
+
Aucun produit trouvé.
}
diff --git a/client/src/app/components/products-list/products-list.component.ts b/client/src/app/components/products-list/products-list.component.ts index 48a03e9..95499ac 100644 --- a/client/src/app/components/products-list/products-list.component.ts +++ b/client/src/app/components/products-list/products-list.component.ts @@ -20,9 +20,8 @@ import {MatSort} from '@angular/material/sort'; import {Product} from '../../interfaces/product'; import {ProductService} from '../../services/app/product.service'; import {MatDialog} from '@angular/material/dialog'; -import {ProductDialogComponent} from '../product-dialog/product-dialog.component'; import {MatButton, MatIconButton} from '@angular/material/button'; -import {MatFormField} from '@angular/material/form-field'; +import {MatFormField, MatLabel} from '@angular/material/form-field'; import {MatIcon} from '@angular/material/icon'; import {MatInput} from '@angular/material/input'; import {CurrencyPipe} from '@angular/common'; @@ -49,7 +48,8 @@ import {CurrencyPipe} from '@angular/common'; MatSort, MatTable, MatHeaderCellDef, - CurrencyPipe + CurrencyPipe, + MatLabel ], styleUrls: ['./products-list.component.css'] }) @@ -100,31 +100,11 @@ export class ProductsListComponent implements OnInit, AfterViewInit, OnChanges { } onAdd(): void { - const ref = this.dialog.open(ProductDialogComponent, { - data: {product: {id: '', name: '', brand: undefined}}, - width: '420px' - }); - ref.afterClosed().subscribe((result?: Product) => { - if (result) { - this.add.emit(result); - this.productService.addProduct(result).subscribe(() => this.loadProducts()); - } - }); } onEdit(product: Product): void { - const ref = this.dialog.open(ProductDialogComponent, { - data: {product: {...product}}, - width: '420px' - }); - ref.afterClosed().subscribe((result?: Product) => { - if (result) { - this.edit.emit(result); - this.productService.updateProduct((product as any).id, result).subscribe(() => this.loadProducts()); - } - }); } onDelete(product: Product): void { diff --git a/client/src/app/pages/add-product/add-product.component.ts b/client/src/app/pages/add-product/add-product.component.ts index ac6f109..694fa28 100644 --- a/client/src/app/pages/add-product/add-product.component.ts +++ b/client/src/app/pages/add-product/add-product.component.ts @@ -178,12 +178,12 @@ export class AddProductComponent implements OnInit, OnDestroy { ngOnInit(): void { - this.brandSubscription = this.brandService.getBrands().subscribe({ + this.brandSubscription = this.brandService.getAll().subscribe({ next: (brands: Brand[]) => { this.brands = this.normalizeIds(brands, 'id'); this.filteredBrands = [...this.brands]; }, - error: (error) => { + error: (error: any) => { console.error('Error fetching brands:', error); }, complete: () => { @@ -191,12 +191,12 @@ export class AddProductComponent implements OnInit, OnDestroy { } }); - this.platformSubscription = this.platformService.getPlatforms().subscribe({ + this.platformSubscription = this.platformService.getAll().subscribe({ next: (platforms: Platform[]) => { this.platforms = this.normalizeIds(platforms, 'id'); this.filteredPlatforms = [...this.platforms]; }, - error: (error) => { + error: (error: any) => { console.error('Error fetching platforms:', error); }, complete: () => { @@ -204,11 +204,11 @@ export class AddProductComponent implements OnInit, OnDestroy { } }); - this.categorySubscription = this.categoryService.getCategories().subscribe({ + this.categorySubscription = this.categoryService.getAll().subscribe({ next: (categories: Category[]) => { this.categories = this.normalizeIds(categories, 'id'); }, - error: (error) => { + error: (error: any) => { console.error('Error fetching categories:', error); }, complete: () => { diff --git a/client/src/app/services/app/brand.service.ts b/client/src/app/services/app/brand.service.ts index e7801aa..44426c5 100644 --- a/client/src/app/services/app/brand.service.ts +++ b/client/src/app/services/app/brand.service.ts @@ -1,31 +1,32 @@ 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'; @Injectable({ providedIn: 'root' }) -export class BrandService { - +export class BrandService implements CrudService { private readonly http = inject(HttpClient); private readonly BASE_URL = 'http://localhost:3000/api/app/brands'; - getBrands() { + getAll(): Observable { return this.http.get(this.BASE_URL, {withCredentials: true}); } - addBrand(brand: Brand) { - console.log("Adding brand:", brand); - return this.http.post(this.BASE_URL, brand, {withCredentials: true}); + add(item: Brand): Observable { + console.log('Adding brand:', item); + return this.http.post(this.BASE_URL, item, {withCredentials: true}); } - updateBrand(id: string, brand: Brand) { - console.log("Updating brand:", id, brand); - return this.http.put(`${this.BASE_URL}/${id}`, brand, {withCredentials: true}); + update(id: string | number, item: Brand): Observable { + console.log('Updating brand:', id, item); + return this.http.put(`${this.BASE_URL}/${id}`, item, {withCredentials: true}); } - deleteBrand(id: string) { - console.log("Deleting brand:", id); - return this.http.delete(`${this.BASE_URL}/${id}`, {withCredentials: true}); + delete(id: string | number): Observable { + console.log('Deleting brand:', id); + return this.http.delete(`${this.BASE_URL}/${id}`, {withCredentials: true}); } } diff --git a/client/src/app/services/app/category.service.ts b/client/src/app/services/app/category.service.ts index 0c47cb3..2f3147c 100644 --- a/client/src/app/services/app/category.service.ts +++ b/client/src/app/services/app/category.service.ts @@ -1,31 +1,32 @@ 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'; @Injectable({ providedIn: 'root' }) -export class CategoryService { - +export class CategoryService implements CrudService { private readonly http = inject(HttpClient); private readonly BASE_URL = 'http://localhost:3000/api/app/categories'; - getCategories() { + getAll(): Observable { return this.http.get(this.BASE_URL, {withCredentials: true}); } - addCategory(category: Category) { - console.log("Adding category:", category); - return this.http.post(this.BASE_URL, category, {withCredentials: true}); + add(item: Category): Observable { + console.log('Adding category:', item); + return this.http.post(this.BASE_URL, item, {withCredentials: true}); } - updateCategory(id: string, category: Category) { - console.log("Updating category:", id, category); - return this.http.put(`${this.BASE_URL}/${id}`, category, {withCredentials: true}); + update(id: string | number, item: Category): Observable { + console.log('Updating category:', id, item); + return this.http.put(`${this.BASE_URL}/${id}`, item, {withCredentials: true}); } - deleteCategory(id: string) { - console.log("Deleting category:", id); - return this.http.delete(`${this.BASE_URL}/${id}`, {withCredentials: true}); + delete(id: string | number): Observable { + console.log('Deleting category:', id); + return this.http.delete(`${this.BASE_URL}/${id}`, {withCredentials: true}); } } diff --git a/client/src/app/services/app/platform.service.ts b/client/src/app/services/app/platform.service.ts index 8147153..3bfa156 100644 --- a/client/src/app/services/app/platform.service.ts +++ b/client/src/app/services/app/platform.service.ts @@ -1,31 +1,32 @@ 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'; @Injectable({ providedIn: 'root' }) -export class PlatformService { - +export class PlatformService implements CrudService { private readonly http = inject(HttpClient); private readonly BASE_URL = 'http://localhost:3000/api/app/platforms'; - getPlatforms() { + getAll(): Observable { return this.http.get(this.BASE_URL, {withCredentials: true}); } - addPlatform(platform: Platform) { - console.log("Adding platform:", platform); - return this.http.post(this.BASE_URL, platform, {withCredentials: true}); + add(item: Platform): Observable { + console.log('Adding platform:', item); + return this.http.post(this.BASE_URL, item, {withCredentials: true}); } - updatePlatform(id: string, platform: Platform) { - console.log("Updating platform:", id, platform); - return this.http.put(`${this.BASE_URL}/${id}`, platform, {withCredentials: true}); + update(id: string | number, item: Platform): Observable { + console.log('Updating platform:', id, item); + return this.http.put(`${this.BASE_URL}/${id}`, item, {withCredentials: true}); } - deletePlatform(id: string) { - console.log("Deleting platform:", id); - return this.http.delete(`${this.BASE_URL}/${id}`, {withCredentials: true}); + delete(id: string | number): Observable { + console.log('Deleting platform:', id); + return this.http.delete(`${this.BASE_URL}/${id}`, {withCredentials: true}); } } diff --git a/client/src/app/services/crud.service.ts b/client/src/app/services/crud.service.ts new file mode 100644 index 0000000..8f3e81d --- /dev/null +++ b/client/src/app/services/crud.service.ts @@ -0,0 +1,8 @@ +import { Observable } from 'rxjs'; + +export interface CrudService { + getAll(): Observable; + add(item: T): Observable; + update(id: string | number, item: T): Observable; + delete(id: string | number): Observable; +}