diff --git a/src/app/features/components/products/product-edit/product-edit.component.css b/src/app/features/components/products/product-edit/product-edit.component.css new file mode 100644 index 0000000..0e04d4c --- /dev/null +++ b/src/app/features/components/products/product-edit/product-edit.component.css @@ -0,0 +1,64 @@ +:host { display: block; } + +.form-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 1rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +h3[card-header] { margin: 0; } + +.form-section { margin-bottom: 1.5rem; } +.section-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 1.25rem; color: var(--color-text); } + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; +} + +.grid-col-span-2 { grid-column: 1 / -1; } + +.input-with-button { display: flex; gap: 0.5rem; } +.input-with-button input { flex-grow: 1; } + +.category-checkbox-group { + max-height: 150px; + overflow-y: auto; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + padding: 0.75rem; +} + +.checkbox-item { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +.checkbox-item input { margin: 0; } +.checkbox-item label { font-weight: normal; } + +.image-management { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 1.5rem; + align-items: start; +} + +.image-upload-fields { display: flex; flex-direction: column; gap: 1rem; } +.image-preview-grid { display: flex; flex-wrap: wrap; gap: 0.75rem; } +.image-preview-item { position: relative; } +.image-preview-item img { + width: 80px; height: 80px; object-fit: cover; + border-radius: var(--border-radius-md); + border: 2px solid var(--color-border); +} +.image-preview-item img.main-image { border-color: var(--color-success); } +.image-preview-item app-button { position: absolute; top: -10px; right: -10px; } + +.checkbox-group { display: flex; align-items: center; gap: 2rem; } +.form-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 2rem; } +.divider { border: none; border-top: 1px solid var(--color-border); margin: 2rem 0; } + +@media (max-width: 992px) { + .image-management { grid-template-columns: 1fr; } +} \ No newline at end of file diff --git a/src/app/features/components/products/product-edit/product-edit.component.html b/src/app/features/components/products/product-edit/product-edit.component.html new file mode 100644 index 0000000..51565fa --- /dev/null +++ b/src/app/features/components/products/product-edit/product-edit.component.html @@ -0,0 +1,127 @@ +
+
+

+ {{ isEditMode ? "Produkt bearbeiten" : "Neues Produkt erstellen" }} +

+
+ + +
+

Basis-Informationen

+
+ + +
+ +
+ + Generieren +
+
+ +
+
+
+ + +
+

Preis & Lager

+
+ + + + + +
+
+
+ + +
+

Zuweisungen

+
+ + +
+ +
+
+ + +
+
+
+
+
+
+ + +
+

Produktbilder

+ +
+
+ + +
+

Status & Sichtbarkeit

+
+ Aktiv (im Shop sichtbar) + Hervorgehoben (z.B. auf Startseite) +
+
+ +
+
+ +
+ Abbrechen + + {{ isEditMode ? "Änderungen speichern" : "Produkt erstellen" }} + +
+
diff --git a/src/app/features/components/products/product-edit/product-edit.component.ts b/src/app/features/components/products/product-edit/product-edit.component.ts new file mode 100644 index 0000000..9d7e160 --- /dev/null +++ b/src/app/features/components/products/product-edit/product-edit.component.ts @@ -0,0 +1,202 @@ +// /src/app/features/admin/components/products/product-edit/product-edit.component.ts + +import { Component, OnInit, OnDestroy, inject, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, FormArray, FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Observable, Subscription } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, startWith } from 'rxjs/operators'; + +// ... (alle anderen Imports bleiben gleich) +import { AdminProduct, ProductImage } from '../../../../core/models/product.model'; +import { Category } from '../../../../core/models/category.model'; +import { Supplier } from '../../../../core/models/supplier.model'; +import { ProductService } from '../../../services/product.service'; +import { CategoryService } from '../../../services/category.service'; +import { SupplierService } from '../../../services/supplier.service'; +import { SnackbarService } from '../../../../shared/services/snackbar.service'; +import { CardComponent } from '../../../../shared/components/ui/card/card.component'; +import { ButtonComponent } from '../../../../shared/components/ui/button/button.component'; +import { IconComponent } from '../../../../shared/components/ui/icon/icon.component'; +import { FormFieldComponent } from '../../../../shared/components/form/form-field/form-field.component'; +import { FormSelectComponent, SelectOption } from '../../../../shared/components/form/form-select/form-select.component'; +import { FormTextareaComponent } from '../../../../shared/components/form/form-textarea/form-textarea.component'; +import { SlideToggleComponent } from '../../../../shared/components/form/slide-toggle/slide-toggle.component'; + +@Component({ + selector: 'app-product-edit', + standalone: true, + imports: [ CommonModule, ReactiveFormsModule, CardComponent, ButtonComponent, IconComponent, FormFieldComponent, FormSelectComponent, FormTextareaComponent, SlideToggleComponent ], + templateUrl: './product-edit.component.html', + styleUrl: './product-edit.component.css' +}) +export class ProductEditComponent implements OnInit, OnDestroy { + @Input() productId: string | null = null; + @Output() formClose = new EventEmitter(); + + private productService = inject(ProductService); + private categoryService = inject(CategoryService); + private supplierService = inject(SupplierService); + private fb = inject(FormBuilder); + private snackbarService = inject(SnackbarService); + + isEditMode = false; + productForm: FormGroup; + + allCategories$!: Observable; + supplierOptions$!: Observable; + + private nameChangeSubscription?: Subscription; + existingImages: ProductImage[] = []; + mainImageFile: File | null = null; + additionalImageFiles: File[] = []; + + constructor() { + this.productForm = this.fb.group({ + name: ['', Validators.required], slug: ['', Validators.required], sku: ['', Validators.required], + description: [''], price: [0, [Validators.required, Validators.min(0)]], + oldPrice: [null, [Validators.min(0)]], purchasePrice: [null, [Validators.min(0)]], + stockQuantity: [0, [Validators.required, Validators.min(0)]], weight: [null, [Validators.min(0)]], + isActive: [true], isFeatured: [false], featuredDisplayOrder: [0], + supplierId: [null], categorieIds: this.fb.array([]), imagesToDelete: this.fb.array([]) + }); + } + + get categorieIds(): FormArray { return this.productForm.get('categorieIds') as FormArray; } + get imagesToDelete(): FormArray { return this.productForm.get('imagesToDelete') as FormArray; } + + ngOnInit(): void { + this.isEditMode = !!this.productId; + this.loadDropdownData(); + this.subscribeToNameChanges(); + if (this.isEditMode && this.productId) { + this.productService.getById(this.productId).subscribe(product => { + this.selectProduct(product); + }); + } + } + + ngOnDestroy(): void { + this.nameChangeSubscription?.unsubscribe(); + } + + loadDropdownData(): void { + this.allCategories$ = this.categoryService.getAll(); + // --- HIER IST DIE KORREKTUR --- + this.supplierOptions$ = this.supplierService.getAll().pipe( + map(suppliers => suppliers.map(s => ({ value: s.id, label: s.name || 'Unbenannt' }))), + startWith([]) // Stellt sicher, dass das Observable sofort ein leeres Array ausgibt + ); + } + + selectProduct(product: AdminProduct): void { + this.productForm.patchValue(product); + this.categorieIds.clear(); + product.categorieIds?.forEach(id => this.categorieIds.push(this.fb.control(id))); + this.existingImages = product.images || []; + } + + onMainFileChange(event: Event): void { + const file = (event.target as HTMLInputElement).files?.[0]; + if (file) this.mainImageFile = file; + } + + onAdditionalFilesChange(event: Event): void { + const files = (event.target as HTMLInputElement).files; + if (files) this.additionalImageFiles = Array.from(files); + } + + deleteExistingImage(imageId: string, event: Event): void { + event.preventDefault(); + this.imagesToDelete.push(this.fb.control(imageId)); + this.existingImages = this.existingImages.filter(img => img.id !== imageId); + } + + onCategoryChange(event: Event): void { + const checkbox = event.target as HTMLInputElement; + const categoryId = checkbox.value; + if (checkbox.checked) { + if (!this.categorieIds.value.includes(categoryId)) this.categorieIds.push(new FormControl(categoryId)); + } else { + const index = this.categorieIds.controls.findIndex(x => x.value === categoryId); + if (index !== -1) this.categorieIds.removeAt(index); + } + } + + isCategorySelected(categoryId: string): boolean { + return this.categorieIds.value.includes(categoryId); + } + + generateSku(): void { + const name = this.productForm.get('name')?.value || 'PROD'; + const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, ''); + const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase(); + const sku = `${prefix}-${randomPart}`; + this.productForm.get('sku')?.setValue(sku); + this.snackbarService.show('Neue SKU generiert!'); + } + + onSubmit(): void { + if (this.productForm.invalid) { + this.productForm.markAllAsTouched(); + this.snackbarService.show('Bitte füllen Sie alle Pflichtfelder aus.'); + return; + } + + const formData = new FormData(); + const formValue = this.productForm.value; + + Object.keys(formValue).forEach((key) => { + const value = formValue[key]; + if (key === 'categorieIds' || key === 'imagesToDelete') { + (value as string[]).forEach((id) => formData.append(this.capitalizeFirstLetter(key), id)); + } else if (value !== null && value !== undefined && value !== '') { + if (['oldPrice', 'purchasePrice', 'weight'].includes(key) && value === '') return; + formData.append(this.capitalizeFirstLetter(key), value.toString()); + } + }); + + if (this.mainImageFile) formData.append('MainImageFile', this.mainImageFile); + this.additionalImageFiles.forEach((file) => formData.append('AdditionalImageFiles', file)); + + // KORREKTUR: Explizites Casten zu einem gemeinsamen Typ (any) hilft TypeScript + const operation: Observable = this.isEditMode + ? this.productService.update(this.productId!, formData) + : this.productService.create(formData); + + // KORREKTUR: Korrekte subscribe-Syntax mit Objekt + operation.subscribe({ + next: () => { + this.snackbarService.show(this.isEditMode ? 'Produkt erfolgreich aktualisiert' : 'Produkt erfolgreich erstellt'); + this.formClose.emit(); + }, + error: (err: any) => { + this.snackbarService.show('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.'); + console.error(err); + } + }); + } + + cancel(): void { + this.formClose.emit(); + } + + private subscribeToNameChanges(): void { + this.nameChangeSubscription = this.productForm.get('name')?.valueChanges.pipe(debounceTime(300), distinctUntilChanged()) + .subscribe((name) => { + if (name && !this.productForm.get('slug')?.dirty) { + const slug = this.generateSlug(name); + this.productForm.get('slug')?.setValue(slug); + } + }); + } + + private generateSlug(name: string): string { + return name.toLowerCase().replace(/\s+/g, '-').replace(/[äöüß]/g, (char) => { + switch (char) { case 'ä': return 'ae'; case 'ö': return 'oe'; case 'ü': return 'ue'; case 'ß': return 'ss'; default: return ''; } + }).replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-'); + } + + private capitalizeFirstLetter(string: string): string { + return string.charAt(0).toUpperCase() + string.slice(1); + } +} \ No newline at end of file diff --git a/src/app/features/components/products/product-list/product-list.component.css b/src/app/features/components/products/product-list/product-list.component.css index e69de29..7f1ad92 100644 --- a/src/app/features/components/products/product-list/product-list.component.css +++ b/src/app/features/components/products/product-list/product-list.component.css @@ -0,0 +1,55 @@ +/* /src/app/features/admin/components/products/product-list/product-list.component.css */ + +.table-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + gap: 1rem; +} + +app-search-bar { + flex-grow: 1; + max-width: 400px; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.column-filter-container { + position: relative; +} + +.column-filter-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.5rem; + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + box-shadow: var(--box-shadow-lg); + padding: 0.75rem; + z-index: 10; + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 180px; +} + +.column-filter-dropdown label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + padding: 0.25rem; + border-radius: var(--border-radius-sm); + white-space: nowrap; +} + +.column-filter-dropdown label:hover { + background-color: var(--color-body-bg-hover); +} \ No newline at end of file diff --git a/src/app/features/components/products/product-list/product-list.component.html b/src/app/features/components/products/product-list/product-list.component.html index 82085c7..5262761 100644 --- a/src/app/features/components/products/product-list/product-list.component.html +++ b/src/app/features/components/products/product-list/product-list.component.html @@ -1,227 +1,31 @@ -
-

Produkte verwalten

+ - -
- -

- {{ selectedProductId ? "Produkt bearbeiten" : "Neues Produkt erstellen" }} -

-

Basis-Informationen

-
-
- -
-
- -
- +
+ +
+
+ + Spalten + +
+ +
-
- -
-
-

Preis & Lager

-
- -
-
- -
-
- -
-
- -
-
- -
-
-

Zuweisungen

-
- -
-
- -
-
- -
-
-
-
-

Produktbilder

-
-

Bestehende Bilder:

-
-
- -
-
-
-
- -
-
- -
-
-

Status & Sichtbarkeit

-
- -
-
- -
-
- -
-

- - - - -
-

Bestehende Produkte

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
BildNameSKUPreisLagerbestandAktivAktionen
Keine Produkte gefunden.
- - - - {{ product.name }}
- Slug: {{ product.slug }} -
{{ product.sku }}{{ product.price | currency:'EUR' }}{{ product.stockQuantity }} - {{ product.isActive ? 'Ja' : 'Nein' }} - - - -
Lade Produkte...
\ No newline at end of file + + Neues Produkt + +
+
+ + \ No newline at end of file diff --git a/src/app/features/components/products/product-list/product-list.component.ts b/src/app/features/components/products/product-list/product-list.component.ts index c35b518..87ddd7c 100644 --- a/src/app/features/components/products/product-list/product-list.component.ts +++ b/src/app/features/components/products/product-list/product-list.component.ts @@ -1,282 +1,68 @@ -import { Component, OnInit, OnDestroy, inject } from '@angular/core'; +// /src/app/features/admin/components/products/product-list/product-list.component.ts + +import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { - FormBuilder, - FormGroup, - FormArray, - FormControl, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; -import { Observable, Subscription } from 'rxjs'; -import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; - -// Models -import { - AdminProduct, - ProductImage, -} from '../../../../core/models/product.model'; -import { Category } from '../../../../core/models/category.model'; -import { Supplier } from '../../../../core/models/supplier.model'; - -// Services -import { ProductService } from '../../../services/product.service'; -import { CategoryService } from '../../../services/category.service'; -import { SupplierService } from '../../../services/supplier.service'; +import { AdminProduct } from '../../../../core/models/product.model'; +import { GenericTableComponent, ColumnConfig } from '../../../../shared/components/data-display/generic-table/generic-table.component'; +import { SearchBarComponent } from '../../../../shared/components/layout/search-bar/search-bar.component'; +import { ButtonComponent } from '../../../../shared/components/ui/button/button.component'; +import { IconComponent } from '../../../../shared/components/ui/icon/icon.component'; @Component({ selector: 'app-product-list', standalone: true, - imports: [CommonModule, ReactiveFormsModule], + imports: [CommonModule, GenericTableComponent, SearchBarComponent, ButtonComponent, IconComponent], templateUrl: './product-list.component.html', + styleUrl: './product-list.component.css' }) -export class ProductListComponent implements OnInit, OnDestroy { - private productService = inject(ProductService); - private categoryService = inject(CategoryService); - private supplierService = inject(SupplierService); - private fb = inject(FormBuilder); +export class ProductListComponent implements OnChanges { + // --- Inputs & Outputs --- + @Input() products: (AdminProduct & { mainImage?: string; supplierName?: string })[] = []; + @Input() allColumns: ColumnConfig[] = []; + @Input() visibleColumns: ColumnConfig[] = []; + + @Output() addNew = new EventEmitter(); + @Output() editProduct = new EventEmitter(); + @Output() deleteProduct = new EventEmitter(); + @Output() columnsChange = new EventEmitter(); + @Output() searchChange = new EventEmitter(); - products$!: Observable; - allCategories$!: Observable; - allSuppliers$!: Observable; + filteredProducts: (AdminProduct & { mainImage?: string; supplierName?: string })[] = []; + isColumnFilterVisible = false; - productForm: FormGroup; - selectedProductId: string | null = null; - private nameChangeSubscription?: Subscription; + constructor() {} - // Eigenschaften für das Bild-Management - existingImages: ProductImage[] = []; - mainImageFile: File | null = null; - additionalImageFiles: File[] = []; - - constructor() { - this.productForm = this.fb.group({ - name: ['', Validators.required], - slug: ['', Validators.required], - sku: ['', Validators.required], - description: [''], - price: [0, [Validators.required, Validators.min(0)]], - oldPrice: [null, [Validators.min(0)]], - purchasePrice: [null, [Validators.min(0)]], - stockQuantity: [0, [Validators.required, Validators.min(0)]], - weight: [null, [Validators.min(0)]], - isActive: [true], - isFeatured: [false], - featuredDisplayOrder: [0], - supplierId: [null], - categorieIds: this.fb.array([]), - imagesToDelete: this.fb.array([]), // FormArray für die IDs der zu löschenden Bilder - }); + ngOnChanges(changes: SimpleChanges): void { + if (changes['products']) { + this.filteredProducts = this.products ? [...this.products] : []; + } } - // Getter für einfachen Zugriff auf FormArrays - get categorieIds(): FormArray { - return this.productForm.get('categorieIds') as FormArray; + onSearch(term: string): void { + this.searchChange.emit(term); } - get imagesToDelete(): FormArray { - return this.productForm.get('imagesToDelete') as FormArray; + toggleColumnFilter(): void { + this.isColumnFilterVisible = !this.isColumnFilterVisible; } - ngOnInit(): void { - this.loadInitialData(); - this.subscribeToNameChanges(); + isColumnVisible(columnKey: string): boolean { + return this.visibleColumns.some(c => c.key === columnKey); } - ngOnDestroy(): void { - this.nameChangeSubscription?.unsubscribe(); - } - - loadInitialData(): void { - this.products$ = this.productService.getAll(); - this.allCategories$ = this.categoryService.getAll(); - this.allSuppliers$ = this.supplierService.getAll(); - } - - selectProduct(product: AdminProduct): void { - this.selectedProductId = product.id; - this.productForm.patchValue(product); - - this.categorieIds.clear(); - product.categorieIds?.forEach((id) => - this.categorieIds.push(this.fb.control(id)) - ); - - this.existingImages = product.images || []; - } - - clearSelection(): void { - this.selectedProductId = null; - this.productForm.reset({ - name: '', - slug: '', - sku: '', - description: '', - price: 0, - oldPrice: null, - purchasePrice: null, - stockQuantity: 0, - weight: null, - isActive: true, - isFeatured: false, - featuredDisplayOrder: 0, - supplierId: null, - }); - this.categorieIds.clear(); - this.imagesToDelete.clear(); - this.existingImages = []; - this.mainImageFile = null; - this.additionalImageFiles = []; - } - - onMainFileChange(event: Event): void { - const file = (event.target as HTMLInputElement).files?.[0]; - if (file) this.mainImageFile = file; - } - - onAdditionalFilesChange(event: Event): void { - const files = (event.target as HTMLInputElement).files; - if (files) this.additionalImageFiles = Array.from(files); - } - - deleteExistingImage(imageId: string, event: Event): void { - event.preventDefault(); - this.imagesToDelete.push(this.fb.control(imageId)); - this.existingImages = this.existingImages.filter( - (img) => img.id !== imageId - ); - } - - onCategoryChange(event: Event): void { + onColumnToggle(column: ColumnConfig, event: Event): void { const checkbox = event.target as HTMLInputElement; - const categoryId = checkbox.value; + let newVisibleColumns: ColumnConfig[]; + if (checkbox.checked) { - if (!this.categorieIds.value.includes(categoryId)) { - this.categorieIds.push(new FormControl(categoryId)); - } - } else { - const index = this.categorieIds.controls.findIndex( - (x) => x.value === categoryId + newVisibleColumns = [...this.visibleColumns, column]; + newVisibleColumns.sort((a, b) => + this.allColumns.findIndex(c => c.key === a.key) - + this.allColumns.findIndex(c => c.key === b.key) ); - if (index !== -1) { - this.categorieIds.removeAt(index); - } - } - } - - isCategorySelected(categoryId: string): boolean { - return this.categorieIds.value.includes(categoryId); - } - - generateSku(): void { - const name = this.productForm.get('name')?.value || 'PROD'; - const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, ''); - const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase(); - const sku = `${prefix}-${randomPart}`; - this.productForm.get('sku')?.setValue(sku); - } - - onSubmit(): void { - if (this.productForm.invalid) return; - - const formData = new FormData(); - const formValue = this.productForm.value; - - Object.keys(formValue).forEach((key) => { - const value = formValue[key]; - if (key === 'categorieIds' || key === 'imagesToDelete') { - // FormArrays müssen speziell behandelt werden - (value as string[]).forEach((id) => - formData.append(this.capitalizeFirstLetter(key), id) - ); - } else if (value !== null && value !== undefined && value !== '') { - // Leere Strings für optionale number-Felder nicht mitsenden - if ( - ['oldPrice', 'purchasePrice', 'weight'].includes(key) && - value === '' - ) - return; - formData.append(this.capitalizeFirstLetter(key), value); - } - }); - - if (this.mainImageFile) { - formData.append('MainImageFile', this.mainImageFile); - } - this.additionalImageFiles.forEach((file) => { - formData.append('AdditionalImageFiles', file); - }); - - if (this.selectedProductId) { - formData.append('Id', this.selectedProductId); - this.productService - .update(this.selectedProductId, formData) - .subscribe(() => this.reset()); } else { - this.productService.create(formData).subscribe(() => this.reset()); + newVisibleColumns = this.visibleColumns.filter(c => c.key !== column.key); } + this.columnsChange.emit(newVisibleColumns); } - - onDelete(id: string): void { - if (confirm('Produkt wirklich löschen?')) { - this.productService.delete(id).subscribe(() => this.loadInitialData()); - } - } - - private reset(): void { - this.loadInitialData(); - this.clearSelection(); - } - - private subscribeToNameChanges(): void { - this.nameChangeSubscription = this.productForm - .get('name') - ?.valueChanges.pipe(debounceTime(300), distinctUntilChanged()) - .subscribe((name) => { - if (name && !this.productForm.get('slug')?.dirty) { - const slug = this.generateSlug(name); - this.productForm.get('slug')?.setValue(slug); - } - }); - } - - private generateSlug(name: string): string { - return name - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[äöüß]/g, (char) => { - switch (char) { - case 'ä': - return 'ae'; - case 'ö': - return 'oe'; - case 'ü': - return 'ue'; - case 'ß': - return 'ss'; - default: - return ''; - } - }) - .replace(/[^a-z0-9-]/g, '') - .replace(/-+/g, '-'); - } - - private capitalizeFirstLetter(string: string): string { - return string.charAt(0).toUpperCase() + string.slice(1); - } - - /** - * Sucht das Hauptbild aus der Bilderliste eines Produkts und gibt dessen URL zurück. - * Gibt eine Platzhalter-URL zurück, wenn kein Hauptbild gefunden wird. - * @param images Die Liste der Produktbilder. - * @returns Die URL des Hauptbildes oder eine Platzhalter-URL. - */ - getMainImageUrl(images?: ProductImage[]): string { - if (!images || images.length === 0) { - return ''; // Platzhalter, wenn gar keine Bilder vorhanden sind - } - const mainImage = images.find(img => img.isMainImage); - return mainImage?.url || images[0].url || ''; // Fallback auf das erste Bild, wenn kein Hauptbild markiert ist - } -} +} \ No newline at end of file diff --git a/src/app/features/components/products/product-page/product-page.component.css b/src/app/features/components/products/product-page/product-page.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/components/products/product-page/product-page.component.html b/src/app/features/components/products/product-page/product-page.component.html new file mode 100644 index 0000000..0353ccc --- /dev/null +++ b/src/app/features/components/products/product-page/product-page.component.html @@ -0,0 +1,21 @@ +
+

Produktverwaltung

+ + + + + + +
\ No newline at end of file diff --git a/src/app/features/components/products/product-page/product-page.component.ts b/src/app/features/components/products/product-page/product-page.component.ts new file mode 100644 index 0000000..e7945f8 --- /dev/null +++ b/src/app/features/components/products/product-page/product-page.component.ts @@ -0,0 +1,131 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Observable } from 'rxjs'; +import { AdminProduct, ProductImage } from '../../../../core/models/product.model'; +import { ProductService } from '../../../services/product.service'; +import { ProductListComponent } from '../product-list/product-list.component'; +import { ProductEditComponent } from '../product-edit/product-edit.component'; +import { SnackbarService } from '../../../../shared/services/snackbar.service'; +import { StorageService } from '../../../../core/services/storage.service'; +import { ColumnConfig } from '../../../../shared/components/data-display/generic-table/generic-table.component'; +import { SupplierService } from '../../../services/supplier.service'; +import { DatePipe } from '@angular/common'; + +@Component({ + selector: 'app-products-page', + standalone: true, + imports: [CommonModule, ProductListComponent, ProductEditComponent], + providers: [DatePipe], + templateUrl: './product-page.component.html', + styleUrls: ['./product-page.component.css'], +}) +export class ProductsPageComponent implements OnInit { + private productService = inject(ProductService); + private supplierService = inject(SupplierService); + private snackbar = inject(SnackbarService); + private storageService = inject(StorageService); + private datePipe = inject(DatePipe); + + private readonly TABLE_SETTINGS_KEY = 'product-table-columns'; + + allProducts: (AdminProduct & { mainImage?: string; supplierName?: string })[] = []; + filteredProducts: (AdminProduct & { mainImage?: string; supplierName?: string })[] = []; + + currentView: 'list' | 'edit' | 'create' = 'list'; + selectedProductId: string | null = null; + + // --- VOLLSTÄNDIGE SPALTEN-DEFINITION --- + readonly allTableColumns: ColumnConfig[] = [ + { key: 'mainImage', title: 'Bild', type: 'image' }, + { key: 'name', title: 'Name', type: 'text', subKey: 'sku' }, + { key: 'price', title: 'Preis', type: 'currency', cssClass: 'text-right' }, + { key: 'stockQuantity', title: 'Lager', type: 'text', cssClass: 'text-right' }, + { key: 'supplierName', title: 'Lieferant', type: 'text' }, + { key: 'isActive', title: 'Aktiv', type: 'status' }, + { key: 'isFeatured', title: 'Hervorgehoben', type: 'status' }, + { key: 'weight', title: 'Gewicht (kg)', type: 'text', cssClass: 'text-right' }, + { key: 'purchasePrice', title: 'EK-Preis', type: 'currency', cssClass: 'text-right' }, + { key: 'oldPrice', title: 'Alter Preis', type: 'currency', cssClass: 'text-right' }, + { key: 'createdDate', title: 'Erstellt am', type: 'text' }, + { key: 'lastModifiedDate', title: 'Geändert am', type: 'text' }, + { key: 'actions', title: 'Aktionen', type: 'actions', cssClass: 'text-right' } + ]; + visibleTableColumns: ColumnConfig[] = []; + + constructor() { + this.loadTableSettings(); + } + + ngOnInit(): void { + this.loadProducts(); + } + + loadProducts(): void { + this.productService.getAll().subscribe(products => { + this.supplierService.getAll().subscribe(suppliers => { + + this.allProducts = products.map(p => { + const supplier = suppliers.find(s => s.id === p.supplierId); + return { + ...p, + mainImage: this.getMainImageUrl(p.images), + supplierName: supplier?.name || '-', + + // --- HIER IST DIE KORREKTUR --- + createdDate: this.datePipe.transform(p.createdDate, 'dd.MM.yyyy HH:mm') || '-', + + // Wenn lastModifiedDate existiert, transformiere es. Wenn das Ergebnis null ist, + // gib einen leeren String oder einen Platzhalter zurück. Wenn es nicht existiert, bleibt es undefined. + lastModifiedDate: p.lastModifiedDate + ? (this.datePipe.transform(p.lastModifiedDate, 'dd.MM.yyyy HH:mm') || '-') + : undefined + }; + }); + // --- ENDE DER KORREKTUR --- + + this.filteredProducts = [...this.allProducts]; + }); + }); + } + + + onSearch(term: any): void { + const lowerTerm = term.toLowerCase(); + this.filteredProducts = this.allProducts.filter(p => + p.name?.toLowerCase().includes(lowerTerm) || + p.sku?.toLowerCase().includes(lowerTerm) || + p.supplierName?.toLowerCase().includes(lowerTerm) + ); + } + + onAddNew(): void { this.currentView = 'create'; this.selectedProductId = null; } + onEditProduct(productId: string): void { this.currentView = 'edit'; this.selectedProductId = productId; } + onFormClose(): void { this.currentView = 'list'; this.selectedProductId = null; this.loadProducts(); } + onDeleteProduct(productId: string): void { + if (confirm('Möchten Sie dieses Produkt wirklich endgültig löschen?')) { + this.productService.delete(productId).subscribe(() => { + this.snackbar.show('Produkt erfolgreich gelöscht.'); + this.loadProducts(); + }); + } + } + + private loadTableSettings(): void { + const savedKeys = this.storageService.getItem(this.TABLE_SETTINGS_KEY); + const defaultKeys = ['mainImage', 'name', 'price', 'stockQuantity', 'isActive', 'actions']; + const keysToUse = (savedKeys && savedKeys.length > 0) ? savedKeys : defaultKeys; + this.visibleTableColumns = this.allTableColumns.filter(c => keysToUse.includes(c.key)); + } + + onColumnsChange(newVisibleColumns: ColumnConfig[]): void { + this.visibleTableColumns = newVisibleColumns; + const visibleKeys = newVisibleColumns.map(c => c.key); + this.storageService.setItem(this.TABLE_SETTINGS_KEY, visibleKeys); + } + + private getMainImageUrl(images?: ProductImage[]): string { + if (!images || images.length === 0) return 'https://via.placeholder.com/50'; + const mainImage = images.find(img => img.isMainImage); + return mainImage?.url || images[0]?.url || 'https://via.placeholder.com/50'; + } +} diff --git a/src/app/features/components/products/products.routes.ts b/src/app/features/components/products/products.routes.ts index be8288b..192b3d4 100644 --- a/src/app/features/components/products/products.routes.ts +++ b/src/app/features/components/products/products.routes.ts @@ -1,10 +1,17 @@ import { Routes } from '@angular/router'; import { ProductListComponent } from './product-list/product-list.component'; +import { GenericTableComponent } from '../../../shared/components/data-display/generic-table/generic-table.component'; +import { ProductsPageComponent } from './product-page/product-page.component'; export const PRODUCTS_ROUTES: Routes = [ { path: '', - component: ProductListComponent, + component: ProductsPageComponent, + title: '', + }, + { + path: '1', + component: GenericTableComponent, title: '', }, ]; diff --git a/src/app/shared/components/data-display/generic-table/generic-table.component.css b/src/app/shared/components/data-display/generic-table/generic-table.component.css new file mode 100644 index 0000000..4bb1977 --- /dev/null +++ b/src/app/shared/components/data-display/generic-table/generic-table.component.css @@ -0,0 +1,99 @@ +/* /src/app/shared/components/table/generic-table/generic-table.component.css */ + +:host { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.table-container { + overflow-x: auto; + flex-grow: 1; + min-width: 0; +} + +.modern-table { + width: 100%; + border-collapse: collapse; + white-space: nowrap; +} + +.modern-table thead th { + padding: 0.75rem 1.5rem; + color: var(--color-text-light); + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + text-align: left; + border-bottom: 2px solid var(--color-border); +} + +.modern-table tbody tr { + transition: background-color var(--transition-speed); + border-bottom: 1px solid var(--color-border); +} + +.modern-table tbody tr:last-of-type { + border-bottom: none; +} + +.modern-table tbody tr:hover { + background-color: var(--color-body-bg-hover); +} + +.modern-table tbody td { + padding: 1rem 1.5rem; + vertical-align: middle; +} + +/* Spezifische Zell-Stile */ +.user-cell { + display: flex; + align-items: center; + gap: 1rem; +} + +.user-cell img { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; +} + +.user-name { + font-weight: 600; + color: var(--color-text); +} + +.user-email { + font-size: 0.9rem; + color: var(--color-text-light); +} + +.amount { + font-weight: 600; +} + +.mono { + font-family: "Courier New", Courier, monospace; +} + +/* Verwendet die von dir definierte Klasse für die rechten Aktionen */ +.actions-cell { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +/* Hilfsklasse für rechtsbündigen Text */ +.text-right { + text-align: right; +} + +.no-data-cell { + text-align: center; + padding: 2rem; + color: var(--color-text-light); +} \ No newline at end of file diff --git a/src/app/shared/components/data-display/generic-table/generic-table.component.html b/src/app/shared/components/data-display/generic-table/generic-table.component.html new file mode 100644 index 0000000..4aa2bdb --- /dev/null +++ b/src/app/shared/components/data-display/generic-table/generic-table.component.html @@ -0,0 +1,77 @@ + + +
+ + + + + + + + + + + + + + +
{{ col.title }}
+ + + + +
+ + {{ getProperty(item, col.key) }} + +
+ {{ getProperty(item, col.subKey) }} +
+
+
+ + + {{ getProperty(item, col.key) | currency:'EUR' }} + + + + + + + + +
+ +
+
{{ getProperty(item, col.key) }}
+
{{ getProperty(item, col.subKey!) }}
+
+
+
+ + + Bild + + + +
+ + + +
+
+ +
+
Keine Daten gefunden.
+
+
+ +
\ No newline at end of file diff --git a/src/app/shared/components/data-display/generic-table/generic-table.component.ts b/src/app/shared/components/data-display/generic-table/generic-table.component.ts new file mode 100644 index 0000000..f0876f0 --- /dev/null +++ b/src/app/shared/components/data-display/generic-table/generic-table.component.ts @@ -0,0 +1,53 @@ +// /src/app/shared/components/table/generic-table/generic-table.component.ts + +import { Component, Input, Output, EventEmitter, SimpleChanges, OnChanges, OnInit } from '@angular/core'; +import { CommonModule, CurrencyPipe } from '@angular/common'; +import { StatusPillComponent } from '../../ui/status-pill/status-pill.component'; +import { ButtonComponent } from '../../ui/button/button.component'; +import { PaginatorComponent } from '../paginator/paginator.component'; + +export type ColumnType = 'text' | 'currency' | 'status' | 'image-text' | 'image' | 'actions'; + +export interface ColumnConfig { + key: string; + title: string; + type: ColumnType; + imageKey?: string; + subKey?: string; + cssClass?: string; +} + +@Component({ + selector: 'app-generic-table', + standalone: true, + imports: [ CommonModule, CurrencyPipe, StatusPillComponent, ButtonComponent, PaginatorComponent ], + templateUrl: './generic-table.component.html', + styleUrl: './generic-table.component.css', +}) +export class GenericTableComponent implements OnChanges, OnInit { + @Input() data: any[] = []; + @Input() columns: ColumnConfig[] = []; + @Input() itemsPerPage = 10; + + @Output() view = new EventEmitter(); + @Output() edit = new EventEmitter(); + @Output() delete = new EventEmitter(); + + public displayedData: any[] = []; + public currentPage = 1; + + ngOnInit(): void { this.updatePagination(); } + ngOnChanges(changes: SimpleChanges): void { if (changes['data']) { this.currentPage = 1; this.updatePagination(); } } + onPageChange(newPage: number): void { this.currentPage = newPage; this.updatePagination(); } + + private updatePagination(): void { + const startIndex = (this.currentPage - 1) * this.itemsPerPage; + const endIndex = startIndex + this.itemsPerPage; + this.displayedData = this.data.slice(startIndex, endIndex); + } + + getProperty(item: any, key: string): any { + if (!key) return ''; + return key.split('.').reduce((obj, part) => obj && obj[part], item); + } +} \ No newline at end of file diff --git a/src/app/shared/components/form/form-field/form-field.component.ts b/src/app/shared/components/form/form-field/form-field.component.ts index c98fe57..8f0cd2d 100644 --- a/src/app/shared/components/form/form-field/form-field.component.ts +++ b/src/app/shared/components/form/form-field/form-field.component.ts @@ -1,57 +1,66 @@ +// /src/app/shared/components/form/form-field/form-field.component.ts + import { Component, Input, forwardRef } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { - FormsModule, - ControlValueAccessor, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; +import { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; @Component({ selector: 'app-form-field', standalone: true, imports: [ CommonModule, - FormsModule, // Wichtig für [(ngModel)] im Template + FormsModule, + ReactiveFormsModule // <-- WICHTIG: Hinzufügen, um mit AbstractControl zu arbeiten ], templateUrl: './form-field.component.html', styleUrl: './form-field.component.css', providers: [ { - // Stellt diese Komponente als "Value Accessor" zur Verfügung provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FormFieldComponent), multi: true, }, ], }) -// Die Komponente implementiert die ControlValueAccessor-Schnittstelle -export class FormFieldComponent implements ControlValueAccessor { +export class FormFieldComponent { + // --- KORREKTUR: Erweitere die erlaubten Typen --- + @Input() type: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' = 'text'; @Input() label: string = ''; - @Input() type: 'text' | 'email' | 'password' = 'text'; + + // Neuer Input, um das FormControl für die Fehleranzeige zu erhalten + @Input() control?: AbstractControl; + @Input() showErrors = true; // Standardmäßig Fehler anzeigen - controlId = `form-field-${Math.random().toString(36)}`; + controlId = `form-field-${Math.random().toString(36).substring(2, 9)}`; - // --- Eigenschaften für ControlValueAccessor --- - value: string = ''; + // --- Eigenschaften & Methoden für ControlValueAccessor --- + value: string | number = ''; onChange: (value: any) => void = () => {}; onTouched: () => void = () => {}; disabled = false; - // --- Methoden, die von Angular Forms aufgerufen werden --- - writeValue(value: any): void { this.value = value; } - registerOnChange(fn: any): void { this.onChange = fn; } - registerOnTouched(fn: any): void { this.onTouched = fn; } - setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; } -} + + // Hilfsfunktion für das Template, um Fehler zu finden + get errorMessage(): string | null { + if (!this.control || !this.control.errors || (!this.control.touched && !this.control.dirty)) { + return null; + } + if (this.control.hasError('required')) return 'Dieses Feld ist erforderlich.'; + if (this.control.hasError('email')) return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.'; + if (this.control.hasError('min')) return `Der Wert muss mindestens ${this.control.errors['min'].min} sein.`; + // ... weitere Fehlermeldungen hier + return 'Ungültige Eingabe.'; + } +} \ No newline at end of file diff --git a/src/app/shared/components/ui/status-pill/status-pill.component.css b/src/app/shared/components/ui/status-pill/status-pill.component.css index f840d06..ac41598 100644 --- a/src/app/shared/components/ui/status-pill/status-pill.component.css +++ b/src/app/shared/components/ui/status-pill/status-pill.component.css @@ -42,4 +42,10 @@ :host-context(body.dark-theme) .pill-danger { color: #fca5a5; background-color: #991b1b; border-color: #ef4444; } .pill-info { color: #1d4ed8; background-color: #eff6ff; border-color: #bfdbfe; } -:host-context(body.dark-theme) .pill-info { color: #93c5fd; background-color: #1e40af; border-color: #3b82f6; } \ No newline at end of file +:host-context(body.dark-theme) .pill-info { color: #93c5fd; background-color: #1e40af; border-color: #3b82f6; } + +.pill-active { color: #15803d; background-color: #ecfdf5; border-color: #bbf7d0; } +:host-context(body.dark-theme) .pill-active { color: #a7f3d0; background-color: #166534; border-color: #22c55e; } + +.pill-inactive { color: rgb(168, 168, 168); background-color: #ececec; border-color: #777777; } +:host-context(body.dark-theme) .pill-inactive { color: rgb(168, 168, 168); background-color: #ececec; border-color: #777777; } \ No newline at end of file diff --git a/src/app/shared/components/ui/status-pill/status-pill.component.ts b/src/app/shared/components/ui/status-pill/status-pill.component.ts index 56cc86a..95b72b5 100644 --- a/src/app/shared/components/ui/status-pill/status-pill.component.ts +++ b/src/app/shared/components/ui/status-pill/status-pill.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnChanges } from '@angular/core'; +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { CommonModule, NgClass } from '@angular/common'; // import { OrderStatus } from '../../../../core/types/order'; @@ -11,7 +11,7 @@ import { CommonModule, NgClass } from '@angular/common'; }) export class StatusPillComponent implements OnChanges { // Nimmt jetzt den neuen, sprechenden Status entgegen - @Input() status: any = 'info'; + @Input() status: string | boolean = 'info'; // Diese Eigenschaften werden vom Template verwendet public displayText = ''; @@ -22,13 +22,22 @@ export class StatusPillComponent implements OnChanges { ['completed', { text: 'Abgeschlossen', css: 'pill-success' }], ['processing', { text: 'In Bearbeitung', css: 'pill-warning' }], ['cancelled', { text: 'Storniert', css: 'pill-danger' }], - ['info', { text: 'Info', css: 'pill-info' }] + ['info', { text: 'Info', css: 'pill-info' }], + ['active', { text: 'Ja', css: 'pill-active' }], + ['inactive', { text: 'Nein', css: 'pill-inactive' }] ]); - ngOnChanges(): void { - // Wenn sich der Input-Status ändert, aktualisieren wir Text und Klasse - const details = this.statusMap.get(this.status) || this.statusMap.get('info')!; - this.displayText = details.text; - this.cssClass = details.css; + ngOnChanges(changes: SimpleChanges): void { + if (changes['status']) { + let statusKey = this.status; + + if (typeof statusKey === 'boolean') { + statusKey = statusKey ? 'active' : 'inactive'; + } + + const details = this.statusMap.get(statusKey as string) || { text: statusKey.toString(), css: 'pill-secondary' }; + this.displayText = details.text; + this.cssClass = details.css; + } } } \ No newline at end of file