From c10e6b4faace9b732f7fb6531bd2b29a6f38442b Mon Sep 17 00:00:00 2001 From: "Tizian.Breuch" Date: Tue, 25 Nov 2025 11:24:44 +0100 Subject: [PATCH] images works --- imageupdatefehler.txt | 5736 +++++++++++++++++ src/app/core/models/product.model.ts | 1 + .../product-category-dropdown.component.ts | 84 +- .../product-edit/product-edit.component.ts | 508 +- .../product-list/product-list.component.ts | 2 +- 5 files changed, 6019 insertions(+), 312 deletions(-) create mode 100644 imageupdatefehler.txt diff --git a/imageupdatefehler.txt b/imageupdatefehler.txt new file mode 100644 index 0000000..f3ea16c --- /dev/null +++ b/imageupdatefehler.txt @@ -0,0 +1,5736 @@ + Product Category Dropdown Error + +147.143 tokens +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +The error message "TypeError: control.registerOnChange is not a function" in Angular typically points to a misconfiguration when creating a custom form control component that implements the ControlValueAccessor interface. + +Based on the provided code for ProductCategoryDropdownComponent, the implementation of the ControlValue-Accessor interface itself appears correct. The component correctly provides the NG_VALUE_ACCESSOR, and the necessary methods (writeValue, registerOnChange, registerOnTouched) are present. + +Therefore, the error is most likely not in the component's code itself, but in how it's being used within a reactive form in the parent component's template or component class. + +Here are the most common causes for this error: +Incorrect formControlName or formControl Binding + +This is the most frequent reason for this error. It usually means that you are trying to bind a FormGroup or FormArray directly to an element that expects a FormControl. + + formControlName pointing to a FormGroup or FormArray: Your ProductCategoryDropdownComponent is designed to work with a FormControl that holds an array of values. If in your parent component's template, the formControlName you are assigning to points to a FormGroup or a FormArray instance instead of a FormControl, Angular won't be able to find the registerOnChange method, as it exists on FormControl but not on FormGroup or FormArray. + + Using formControlName instead of formGroupName or formArrayName: If your custom component is part of a larger form structure, ensure you are using the correct directive. If the control is part of a FormGroup, it should have a formControlName. If you are iterating over a FormArray of FormGroups, you might need to use formGroupName for nested groups. + +How to Fix It + +Without seeing the parent component's template and class, it's impossible to give a definitive solution. However, here's what you should check in the component that uses : + + Check the FormGroup initialization in your parent component's .ts file. Make sure the control you're binding to is a FormControl: + code TypeScript + + +import { Component } from '@angular/core'; +import { FormBuilder, FormGroup, FormControl } from '@angular/forms'; + +@Component({ + // ... +}) +export class ParentComponent { + myForm: FormGroup; + + constructor(private fb: FormBuilder) { + this.myForm = this.fb.group({ + // Ensure 'productCategories' is a FormControl + productCategories: new FormControl([]) // Initialize with an empty array + }); + } +} + + + +Check the template of your parent component (.html file). The formControlName must match the name of the FormControl in your FormGroup. +code Html + + +
+ + +
+ + + +Other Potential, Less Common Issues: + + Mismatched names: A simple typo in the formControlName in the template, making it not match the name in the FormGroup definition. + + Module issues: Although less likely with standalone components, ensure that if you are not using standalone components, the ReactiveFormsModule is imported into the appropriate module. + +In summary, the problem is very likely in how the ProductCategoryDropdownComponent is being integrated into a reactive form, specifically with an incorrect binding that leads Angular to look for registerOnChange on an object that isn't a FormControl. Carefully inspect the parent component's form definition and template. +Google Search Suggestions +Display of Search Suggestions is required when using Grounding with Google Search. Learn more +Google logo + +User + +// /src/app/shared/components/form/product-category-dropdown/product-category-dropdown.component.ts + +import { Component, Input, forwardRef, HostListener, ElementRef, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { StatusPillComponent } from '../../../../shared/components/ui/status-pill/status-pill.component'; + +// Eine wiederverwendbare Schnittstelle für die Optionen +export interface SelectOption { +value: any; +label: string; +} + +@Component({ +selector: 'app-product-category-dropdown', +standalone: true, +imports: [CommonModule, StatusPillComponent], +templateUrl: './product-category-dropdown.component.html', +styleUrls: ['./product-category-dropdown.component.css'], +providers: [ +{ +provide: NG_VALUE_ACCESSOR, +useExisting: forwardRef(() => ProductCategoryDropdownComponent), +multi: true, +}, +], +}) +export class ProductCategoryDropdownComponent implements ControlValueAccessor { +@Input() label = ''; +@Input() options: SelectOption[] = []; +@Input() placeholder = 'Bitte wählen...'; + +public isOpen = false; +public selectedValues: any[] = []; +private elementRef = inject(ElementRef); + +onChange: (value: any[]) => void = () => {}; +onTouched: () => void = () => {}; + +writeValue(values: any[]): void { this.selectedValues = Array.isArray(values) ? values : []; } +registerOnChange(fn: any): void { this.onChange = fn; } +registerOnTouched(fn: any): void { this.onTouched = fn; } + +@HostListener('document:click', ['$event']) +onDocumentClick(event: Event): void { +if (this.isOpen && !this.elementRef.nativeElement.contains(event.target)) { +this.closeDropdown(); +} +} + +toggleDropdown(event: Event): void { +event.stopPropagation(); +this.isOpen = !this.isOpen; +if (!this.isOpen) { this.onTouched(); } +} + +closeDropdown(): void { +if (this.isOpen) { +this.isOpen = false; +this.onTouched(); +} +} + +onOptionToggle(value: any): void { +const index = this.selectedValues.indexOf(value); +if (index > -1) { +this.selectedValues.splice(index, 1); +} else { +this.selectedValues.push(value); +} +this.onChange([...this.selectedValues]); // Wichtig: Neue Array-Instanz senden +} + +onPillRemove(value: any, event: any): void { // Typ korrigiert +event.stopPropagation(); +this.onOptionToggle(value); +} + +isSelected(value: any): boolean { return this.selectedValues.includes(value); } +getLabelForValue(value: any): string { return this.options.find(opt => opt.value === value)?.label || ''; } +} + +
+ + + + + + +
+
+ +
+
+
+Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Thank you for providing both the component's TypeScript file and its corresponding template. + +After reviewing both files, I can confirm that the code for ProductCategoryDropdownComponent and its template is correctly implemented as a custom form control. The ControlValueAccessor interface is properly set up. + +The error TypeError: control.registerOnChange is not a function is therefore not in the code you have provided. It is almost certainly in how you are using this component within a parent component's reactive form. + +This error occurs when you try to bind a FormGroup or a FormArray to a component that is expecting a FormControl. Angular's formControlName directive looks for a FormControl instance, and only FormControl has the registerOnChange method. FormGroup and FormArray do not. +How to Fix This in Your Parent Component + +You need to ensure that the form control you are binding to is initialized as a FormControl, not a FormGroup. + +Here is an example of the correct way to use your component. + +1. In your Parent Component's TypeScript file (e.g., parent.component.ts): + +Make sure the control (e.g., productCategories) is a FormControl initialized with an array. +code TypeScript + + +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, FormControl, ReactiveFormsModule } from '@angular/forms'; +import { ProductCategoryDropdownComponent, SelectOption } from './path/to/product-category-dropdown.component'; // Adjust path + +@Component({ + selector: 'app-parent', + standalone: true, + imports: [ReactiveFormsModule, ProductCategoryDropdownComponent], // Make sure to import both + templateUrl: './parent.component.html', +}) +export class ParentComponent implements OnInit { + myForm: FormGroup; + + // Your available options for the dropdown + categoryOptions: SelectOption[] = [ + { value: 'tech', label: 'Technology' }, + { value: 'health', label: 'Health & Wellness' }, + { value: 'books', label: 'Books' } + ]; + + constructor(private fb: FormBuilder) {} + + ngOnInit(): void { + this.myForm = this.fb.group({ + // ... other controls + + // CORRECT: This must be a FormControl. + // It will hold an array of the selected values (e.g., ['tech', 'books']). + productCategories: new FormControl(['tech']) // Initial value is an array + }); + } + + onSubmit() { + console.log(this.myForm.value); + // Expected output: { productCategories: ['tech'] } or similar + } +} + + + +2. In your Parent Component's Template file (e.g., parent.component.html): + +Bind the form and the control using formGroup and formControlName. +code Html + + +
+ + + + + + + + +
+ + + +What to Look For (The Likely Mistake) + +In your parent component, you probably have something like this, which is incorrect and causes the error: +code TypeScript + + +// INCORRECT - DO NOT DO THIS +this.myForm = this.fb.group({ + productCategories: this.fb.group({ // <--- MISTAKE: Using a FormGroup here + // ...some nested controls + }) +}); + +// OR THIS +this.myForm = this.fb.group({ + productCategories: this.fb.array([]) // <--- MISTAKE: Binding directly to a FormArray +}); + + + +In summary: Your custom component code is fine. Check the parent component where you use and ensure the formControlName points to a FormControl instance. +User + +
+
+
+ +code Code + + + + + + + + + + + + + + +
+ +
+ + +
+ + + +
+
+ + + + +
+ + + +
+
+
+ + + + + + +
+ +
+ Abbrechen + {{ submitButtonText }} +
+
+Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Thank you. This HTML template for ProductFormComponent is exactly what was needed. + +The problem is now clear, and as predicted, it is not in the dropdown component itself, but in how it's being used. + +Look at this line in your product-form.component.html: +code Html + + + + + + + +You are binding the dropdown to a form control named categorieIds. + +The error TypeError: control.registerOnChange is not a function means that in your product-form.component.ts file, the categorieIds field inside your productForm is not a FormControl. It is most likely defined as a FormGroup or FormArray. +The Cause of the Error + +In your product-form.component.ts, you probably have something like this, which is incorrect: +code TypeScript + + +// LIKELY INCORRECT CODE IN YOUR product-form.component.ts +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +// ... + +this.productForm = this.fb.group({ + name: ['', Validators.required], + slug: [''], + description: [''], + price: [0, Validators.required], + // ... other fields + + // THIS IS THE MISTAKE: + categorieIds: this.fb.array([]) // Using a FormArray here is wrong for this component + + // OR + + // categorieIds: this.fb.group({}) // Using a FormGroup is also wrong +}); + + + +When you use formControlName="categorieIds", Angular expects productForm.get('categorieIds') to be an instance of FormControl. If it's a FormArray or FormGroup, it doesn't have the .registerOnChange() method, causing the crash. +How to Fix It + +You must define categorieIds as a FormControl. This FormControl will manage an array of values, which is exactly what your custom dropdown component is designed to do. + +Change the definition of your productForm in product-form.component.ts to this: +code TypeScript + + +// CORRECTED CODE for product-form.component.ts +import { FormBuilder, FormGroup, FormControl, Validators } from '@angular/forms'; + +// ... + +ngOnInit(): void { // Or wherever you initialize your form + this.productForm = this.fb.group({ + // --- General --- + name: ['', Validators.required], + slug: [''], + description: [''], + + // --- Pricing --- + price: [0, [Validators.required, Validators.min(0)]], + oldPrice: [null], + purchasePrice: [null], + + // --- Status --- + isActive: [true], + isFeatured: [false], + featuredDisplayOrder: [100], + + // --- Organization --- + sku: [''], + stockQuantity: [0], + weight: [0], + supplierId: [null], + + // CORRECT DEFINITION: + // This is a FormControl that will hold an array of category IDs. + categorieIds: new FormControl([]) // Initialize with an empty array or default values + + // You can also use the FormBuilder syntax: + // categorieIds: [[]] // An array containing an empty array + }); + + // If you are editing a product, you would patch the value like this: + // const categoryIdsFromApi = [1, 5, 12]; + // this.productForm.patchValue({ + // categorieIds: categoryIdsFromApi + // }); +} + + + +By changing categorieIds to be a FormControl initialized with an array (new FormControl([])), you are providing the correct structure that the formControlName directive expects, which will resolve the error. +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Excellent. This new error, TypeError: categories.clear is not a function, is a classic follow-up problem and confirms that the previous fix was correct. + +This error tells us exactly what's wrong: You have code somewhere in your product-form.component.ts file that is still trying to treat the categorieIds form control as if it were a FormArray. +The Cause + + A FormArray is a collection of form controls. It has methods like .push(), .removeAt(), and .clear() to manage that collection. + + A FormControl holds a single value. That value can be a string, a number, a boolean, or even an array (like in your case). However, the FormControl itself does not have a .clear() method. To change its value, you use .setValue() or .reset(). + +You correctly changed categorieIds to be a FormControl, but you still have old code that was written when you thought it was a FormArray. +How to Find and Fix the Code + +Search in your product-form.component.ts file for the line that calls .clear(). It will look something like this: + +Likely Incorrect Code in product-form.component.ts: +code TypeScript + + +// You probably have a function to set or update categories +setFormCategories(productCategories: Category[]) { + // This line gets the control. You might have it cast as a FormArray. + const categoriesFormArray = this.productForm.get('categorieIds') as FormArray; + + // THIS IS THE LINE CAUSING THE ERROR: + categoriesFormArray.clear(); // .clear() does not exist on a FormControl + + // The old code would then loop and push new controls + productCategories.forEach(category => { + categoriesFormArray.push(new FormControl(category.id)); + }); +} + + + +The Solution + +You need to refactor this logic to work with a FormControl that holds an array value. The new code is much simpler. Instead of clearing and pushing, you just create an array of IDs and set the value of the control directly. + +Corrected Code for product-form.component.ts: +code TypeScript + + +// The corrected function +setFormCategories(productCategories: Category[]) { + // 1. Create an array of just the category IDs. + const categoryIds = productCategories.map(category => category.id); + + // 2. Get the form control. + const categoriesControl = this.productForm.get('categorieIds'); + + // 3. Set its value to the new array of IDs. + if (categoriesControl) { + categoriesControl.setValue(categoryIds); + } +} + + + +If you just need to clear the selection: + +Before (Incorrect): +code TypeScript + + +this.productForm.get('categorieIds').clear(); + + + +After (Correct): +code TypeScript + + +// Set the value to an empty array +this.productForm.get('categorieIds').setValue([]); + +// Or use reset, which also clears validation status +this.productForm.get('categorieIds').reset([]); + + + +Summary: + + Find the code in your .ts file where you call .clear() on the categorieIds control. + + Remove the .clear() call and any subsequent .push() calls in a loop. + + Replace that logic with a single call to this.productForm.get('categorieIds').setValue(your_array_of_ids). + +User + +// /src/app/features/admin/components/products/product-form/product-form.component.ts + +import { Component, Input, Output, EventEmitter, inject, SimpleChanges } from '@angular/core'; +import { CommonModule, NgClass } from '@angular/common'; +import { +FormGroup, +FormArray, +ReactiveFormsModule, +FormControl, +} from '@angular/forms'; +import { SafeUrl } from '@angular/platform-browser'; + +// Models & UI Components +import { Category } from '../../../../core/models/category.model'; +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'; +import { SnackbarService } from '../../../../shared/services/snackbar.service'; +import { FormGroupComponent } from '../../../../shared/components/form/form-group/form-group.component'; +import { StatusPillComponent } from '../../../../shared/components/ui/status-pill/status-pill.component'; +import { ProductCategoryDropdownComponent } from '../product-category-dropdown/product-category-dropdown.component'; + +// Interface für eine einheitliche Bild-Datenstruktur, die von der Elternkomponente kommt +export interface ImagePreview { +identifier: string; // Eindeutiger Bezeichner (ID für existierende, Dateiname für neue) +url: string | SafeUrl; +isMainImage: boolean; +} + +@Component({ +selector: 'app-product-form', +standalone: true, +imports: [ +CommonModule, +NgClass, +ReactiveFormsModule, +code Code + + +ButtonComponent, +IconComponent, +FormFieldComponent, +FormSelectComponent, +FormTextareaComponent, +SlideToggleComponent, +FormGroupComponent, + +ProductCategoryDropdownComponent + + + +], +templateUrl: './product-form.component.html', +styleUrls: ['./product-form.component.css'], +}) +export class ProductFormComponent { +// --- Inputs für Daten --- +@Input() productForm!: FormGroup; +@Input() allCategories: Category[] = []; +@Input() supplierOptions: SelectOption[] = []; +@Input() isLoading = false; +@Input() submitButtonText = 'Speichern'; + +// NEU: Empfängt eine einzige, kombinierte Bildliste von der Elternkomponente +@Input() allImages: ImagePreview[] = []; + +// --- Outputs für Aktionen --- +@Output() formSubmit = new EventEmitter(); +@Output() formCancel = new EventEmitter(); +@Output() filesSelected = new EventEmitter(); // Sendet alle neu ausgewählten Dateien +@Output() setMainImage = new EventEmitter(); // Sendet den 'identifier' des Bildes +@Output() deleteImage = new EventEmitter(); // Sendet den 'identifier' des Bildes + +private snackbarService = inject(SnackbarService); +public categoryOptions: SelectOption[] = []; + +// GETTER FÜR DAS TEMPLATE +get categorieIds(): FormArray { +return this.productForm.get('categorieIds') as FormArray; +} +get hasImages(): boolean { +return this.allImages && this.allImages.length > 0; +} + +// --- EVENT-HANDLER --- +onSubmit(): void { +this.formSubmit.emit(); +} +cancel(): void { +this.formCancel.emit(); +} + +ngOnChanges(changes: SimpleChanges): void { +if (changes['allCategories']) { +// Wandelt die rohen Kategoriedaten in das für die Komponente benötigte Format um +this.categoryOptions = this.allCategories +.filter(cat => !!cat.name) // Nur Kategorien mit Namen verwenden +.map(cat => ({ +value: cat.id, +label: cat.name! // Wir wissen jetzt, dass 'name' existiert +})); +} +} +onFilesSelected(event: Event): void { +const files = (event.target as HTMLInputElement).files; +if (files && files.length > 0) { +this.filesSelected.emit(Array.from(files)); +} +// Wichtig: Input-Wert zurücksetzen, damit die gleichen Dateien erneut ausgewählt werden können +(event.target as HTMLInputElement).value = ''; +} + +setAsMainImage(identifier: string): void { +this.setMainImage.emit(identifier); +} + +requestImageDeletion(identifier: string, event: MouseEvent): void { +event.stopPropagation(); // Verhindert, dass gleichzeitig setAsMainImage gefeuert wird +this.deleteImage.emit(identifier); +} + +// --- Helfermethoden (unverändert) --- +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!'); +} + +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); +} + +getCategoryName(categoryId: string): string { +return this.allCategories.find((c) => c.id === categoryId)?.name || ''; +} + +removeCategoryById(categoryId: string): void { +const index = this.categorieIds.controls.findIndex( +(x) => x.value === categoryId +); +if (index !== -1) this.categorieIds.removeAt(index); +} +} +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Of course. Thank you for posting the full component code. The source of the TypeError: categories.clear is not a function error is now perfectly clear, and the solution is straightforward. +The Core Problem + +Your ProductFormComponent is in a "half-migrated" state. You have correctly added the new to your template, but the TypeScript code (.ts file) still contains all the old logic that was used to manage categories with a FormArray and manual checkboxes. + +This old code is now conflicting with the new reality: categorieIds is a FormControl that holds an array of values, not a FormArray. +Step-by-Step Code Fixes + +You need to remove all the obsolete code that treats categorieIds as a FormArray. +1. The categorieIds Getter (The Main Culprit) + +This is the most critical piece of code to fix. It incorrectly tells the rest of your component that categorieIds is a FormArray. + +DELETE this entire getter: +code TypeScript + + +// /src/app/features/admin/components/products/product-form/product-form.component.ts + +// DELETE THIS ENTIRE BLOCK (Lines 81-83) +/* + get categorieIds(): FormArray { + return this.productForm.get('categorieIds') as FormArray; + } +*/ + + + +2. Obsolete Helper Functions + +Your new dropdown component (app-product-category-dropdown) now handles everything related to selecting, deselecting, and displaying categories. All the manual functions you wrote for this are no longer needed and are causing the errors. + +DELETE these four functions entirely: +code TypeScript + + +// /src/app/features/admin/components/products/product-form/product-form.component.ts + +// DELETE THIS FUNCTION +/* + 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); + } + } +*/ + +// DELETE THIS FUNCTION +/* + isCategorySelected(categoryId: string): boolean { + return this.categorieIds.value.includes(categoryId); + } +*/ + +// DELETE THIS FUNCTION (Likely unused by the new component) +/* + getCategoryName(categoryId: string): string { + return this.allCategories.find((c) => c.id === categoryId)?.name || ''; + } +*/ + +// DELETE THIS FUNCTION +/* + removeCategoryById(categoryId: string): void { + const index = this.categorieIds.controls.findIndex( + (x) => x.value === categoryId + ); + if (index !== -1) this.categorieIds.removeAt(index); + } +*/ + + + +The Cleaned-Up ProductFormComponent + +After removing all the obsolete code, your component's TypeScript file should look like this. Notice how much simpler it is. +code TypeScript + + +// /src/app/features/admin/components/products/product-form/product-form.component.ts (CORRECTED) + +import { Component, Input, Output, EventEmitter, inject, SimpleChanges } from '@angular/core'; +import { CommonModule, NgClass } from '@angular/common'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; // FormArray and FormControl no longer needed here +import { SafeUrl } from '@angular/platform-browser'; + +// Models & UI Components +import { Category } from '../../../../core/models/category.model'; +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'; +import { SnackbarService } from '../../../../shared/services/snackbar.service'; +import { FormGroupComponent } from '../../../../shared/components/form/form-group/form-group.component'; +import { ProductCategoryDropdownComponent } from '../product-category-dropdown/product-category-dropdown.component'; + +export interface ImagePreview { + identifier: string; + url: string | SafeUrl; + isMainImage: boolean; +} + +@Component({ + selector: 'app-product-form', + standalone: true, + imports: [ + CommonModule, NgClass, ReactiveFormsModule, ButtonComponent, IconComponent, + FormFieldComponent, FormSelectComponent, FormTextareaComponent, + SlideToggleComponent, FormGroupComponent, ProductCategoryDropdownComponent + ], + templateUrl: './product-form.component.html', + styleUrls: ['./product-form.component.css'], +}) +export class ProductFormComponent { + // --- Inputs --- + @Input() productForm!: FormGroup; + @Input() allCategories: Category[] = []; + @Input() supplierOptions: SelectOption[] = []; + @Input() isLoading = false; + @Input() submitButtonText = 'Speichern'; + @Input() allImages: ImagePreview[] = []; + + // --- Outputs --- + @Output() formSubmit = new EventEmitter(); + @Output() formCancel = new EventEmitter(); + @Output() filesSelected = new EventEmitter(); + @Output() setMainImage = new EventEmitter(); + @Output() deleteImage = new EventEmitter(); + + private snackbarService = inject(SnackbarService); + public categoryOptions: SelectOption[] = []; + + // --- GETTER --- + get hasImages(): boolean { + return this.allImages && this.allImages.length > 0; + } + + // --- LIFECYCLE HOOKS --- + ngOnChanges(changes: SimpleChanges): void { + if (changes['allCategories']) { + this.categoryOptions = this.allCategories + .filter(cat => !!cat.name) + .map(cat => ({ + value: cat.id, + label: cat.name! + })); + } + } + + // --- EVENT-HANDLERS --- + onSubmit(): void { this.formSubmit.emit(); } + cancel(): void { this.formCancel.emit(); } + + onFilesSelected(event: Event): void { + const files = (event.target as HTMLInputElement).files; + if (files && files.length > 0) { + this.filesSelected.emit(Array.from(files)); + } + (event.target as HTMLInputElement).value = ''; + } + + setAsMainImage(identifier: string): void { + this.setMainImage.emit(identifier); + } + + requestImageDeletion(identifier: string, event: MouseEvent): void { + event.stopPropagation(); + this.deleteImage.emit(identifier); + } + + 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!'); + } + + // ALL OBSOLETE CATEGORY HELPER FUNCTIONS HAVE BEEN REMOVED +} + + + +Summary of Why This Fix Works + + Single Source of Truth: Your productForm has a FormControl named categorieIds. Its value is an array, like ['cat-1', 'cat-2']. + + The Bridge: The component, via the ControlValueAccessor interface, acts as the bridge. It receives this array as its value and knows how to display it (as pills). + + Internal Logic: When you check or uncheck a box inside the dropdown, the component's internal logic (onOptionToggle) updates its internal list and emits the new array (['cat-1', 'cat-2', 'cat-3']) back to the FormControl. + + No Manual Work Needed: Because the dropdown handles all the UI interactions and data updates, you no longer need any of the manual FormArray methods (.push, .removeAt, .controls) in your ProductFormComponent. + +By removing the old, conflicting code, your form will now work as intended. +User + +// /src/app/features/admin/components/products/product-edit/product-edit.component.ts + +import { Component, OnInit, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { +FormBuilder, +FormGroup, +FormArray, +FormControl, +ReactiveFormsModule, +Validators, +} from '@angular/forms'; +import { DomSanitizer } from '@angular/platform-browser'; +import { Observable, Subscription } from 'rxjs'; +import { +map, +startWith, +debounceTime, +distinctUntilChanged, +finalize, +} from 'rxjs/operators'; + +// Models, Services und UI-Komponenten +import { +AdminProduct, +ProductImage, +} from '../../../../core/models/product.model'; +import { Category } from '../../../../core/models/category.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 { +ImagePreview, +ProductFormComponent, +} from '../product-form/product-form.component'; +import { SelectOption } from '../../../../shared/components/form/form-select/form-select.component'; + +@Component({ +selector: 'app-product-edit', +standalone: true, +imports: [CommonModule, ReactiveFormsModule, ProductFormComponent], +templateUrl: './product-edit.component.html', +styleUrls: ['./product-edit.component.css'], +}) +export class ProductEditComponent implements OnInit, OnDestroy { +// --- Injektionen --- +private route = inject(ActivatedRoute); +private router = inject(Router); +private productService = inject(ProductService); +private categoryService = inject(CategoryService); +private supplierService = inject(SupplierService); +private fb = inject(FormBuilder); +private snackbarService = inject(SnackbarService); +private sanitizer = inject(DomSanitizer); + +// --- Komponenten-Status --- +productId!: string; +isLoading = true; +productForm: FormGroup; +private nameChangeSubscription?: Subscription; +allCategories + + +!:Observable;supplierOptions!:Observable;supplierOptions + + + +!: Observable; + +// --- NEUER STATE FÜR BILD-MANAGEMENT --- +existingImages: ProductImage[] = []; +newImageFiles: File[] = []; +mainImageIdentifier: string | null = null; // Hält die ID oder den Dateinamen des Hauptbildes +allImagesForForm: ImagePreview[] = []; // Die kombinierte Liste für die Child-Komponente + +constructor() { +this.productForm = this.fb.group({ +id: ['', Validators.required], +name: ['', Validators.required], +slug: ['', Validators.required], +sku: ['', Validators.required], +price: [0, [Validators.required, Validators.min(0)]], +stockQuantity: [0, [Validators.required, Validators.min(0)]], +description: [''], +oldPrice: [null, [Validators.min(0)]], +purchasePrice: [null, [Validators.min(0)]], +weight: [null, [Validators.min(0)]], +isActive: [true], +isFeatured: [false], +featuredDisplayOrder: [0], +supplierId: [null], +categorieIds: new FormControl([]), +imagesToDelete: this.fb.array([]), +}); +} + +get imagesToDelete(): FormArray { +return this.productForm.get('imagesToDelete') as FormArray; +} + +ngOnInit(): void { +const id = this.route.snapshot.paramMap.get('id'); +if (!id) { +this.snackbarService.show('Produkt-ID fehlt.'); +this.router.navigate(['/shop/products']); +return; +} +this.productId = id; +this.loadDropdownData(); +this.loadProductData(); +this.subscribeToNameChanges(); +} + +ngOnDestroy(): void { +this.nameChangeSubscription?.unsubscribe(); +this.allImagesForForm.forEach((image) => { +if (typeof image.url === 'object') { +// Nur Object-URLs von neuen Bildern freigeben +URL.revokeObjectURL(image.url as string); +} +}); +} + +loadDropdownData(): void { +this.allCategories + + +=this.categoryService.getAll();this.supplierOptions=this.categoryService.getAll();this.supplierOptions + + + + = this.supplierService.getAll().pipe( +map((suppliers) => +suppliers.map((s) => ({ value: s.id, label: s.name || 'Unbenannt' })) +), +startWith([]) +); +} + +loadProductData(): void { +this.isLoading = true; +this.productService +.getById(this.productId) +.pipe( +finalize(() => { +this.isLoading = false; +}) +) +.subscribe({ +next: (product) => { +if (product) { +this.populateForm(product); +} else { +this.snackbarService.show('Produkt nicht gefunden.'); +this.router.navigate(['/shop/products']); +} +}, +error: (err) => { +this.snackbarService.show('Fehler beim Laden des Produkts.'); +console.error('Fehler beim Laden der Produktdaten:', err); +this.router.navigate(['/shop/products']); +}, +}); +} + +populateForm(product: AdminProduct): void { +this.productForm.patchValue(product); +const categories = this.productForm.get('categorieIds') as FormArray; +categories.clear(); +product.categorieIds?.forEach((id) => categories.push(new FormControl(id))); +code Code + + +this.existingImages = product.images || []; +const mainImage = this.existingImages.find((img) => img.isMainImage); +this.mainImageIdentifier = + mainImage?.id || + (this.existingImages.length > 0 ? this.existingImages[0].id : null); + +this.rebuildAllImagesForForm(); + + + +} + +onSubmit(): void { +if (this.productForm.invalid) { +this.productForm.markAllAsTouched(); +this.snackbarService.show('Bitte füllen Sie alle Pflichtfelder aus.'); +return; +} +this.prepareSubmissionData(); +const formData = this.createFormData(); +this.productService.update(this.productId, formData).subscribe({ +next: () => { +this.snackbarService.show('Produkt erfolgreich aktualisiert'); +this.router.navigate(['/shop/products']); +}, +error: (err) => { +this.snackbarService.show('Ein Fehler ist aufgetreten.'); +console.error(err); +}, +}); +} + +cancel(): void { +this.router.navigate(['/shop/products']); +} + +// --- NEUE EVENT-HANDLER FÜR BILDER --- +onFilesSelected(files: File[]): void { +this.newImageFiles.push(...files); +if (!this.mainImageIdentifier && this.newImageFiles.length > 0) { +this.mainImageIdentifier = this.newImageFiles[0].name; +} +this.rebuildAllImagesForForm(); +} + +onSetMainImage(identifier: string): void { +this.mainImageIdentifier = identifier; +this.rebuildAllImagesForForm(); +} + +onDeleteImage(identifier: string): void { +const isExisting = this.existingImages.some((img) => img.id === identifier); +if (isExisting) { +this.imagesToDelete.push(new FormControl(identifier)); +this.existingImages = this.existingImages.filter( +(img) => img.id !== identifier +); +} else { +this.newImageFiles = this.newImageFiles.filter( +(file) => file.name !== identifier +); +} +code Code + + +if (this.mainImageIdentifier === identifier) { + const firstExisting = + this.existingImages.length > 0 ? this.existingImages[0].id : null; + const firstNew = + this.newImageFiles.length > 0 ? this.newImageFiles[0].name : null; + this.mainImageIdentifier = firstExisting || firstNew; +} +this.rebuildAllImagesForForm(); + + + +} + +// --- Private Helfermethoden --- +private rebuildAllImagesForForm(): void { +this.allImagesForForm.forEach((image) => { +if (typeof image.url === 'object') +URL.revokeObjectURL(image.url as string); +}); +code Code + + +const combined: ImagePreview[] = []; +this.existingImages + .filter((img) => !!img.url) // Stellt sicher, dass nur Bilder mit URL verwendet werden + .forEach((img) => { + combined.push({ + identifier: img.id, + url: img.url!, + isMainImage: img.id === this.mainImageIdentifier, + }); + }); + +this.newImageFiles.forEach((file) => { + combined.push({ + identifier: file.name, + url: this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(file)), + isMainImage: file.name === this.mainImageIdentifier, + }); +}); + +this.allImagesForForm = combined; + + + +} + +private createFormData(): FormData { +const formData = new FormData(); +const formValue = this.productForm.getRawValue(); +code Code + + +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 !== '') { + formData.append(this.capitalizeFirstLetter(key), value.toString()); + } +}); + +const mainImageFile = this.newImageFiles.find( + (f) => f.name === this.mainImageIdentifier +); +if (mainImageFile) { + formData.append('MainImageFile', mainImageFile); +} else if (this.mainImageIdentifier) { + formData.append('MainImageId', this.mainImageIdentifier); +} + +this.newImageFiles + .filter((f) => f.name !== this.mainImageIdentifier) + .forEach((file) => formData.append('AdditionalImageFiles', file)); + +return formData; + + + +} + +// Private Helfermethoden +private prepareSubmissionData(): void { +const nameControl = this.productForm.get('name'); +const slugControl = this.productForm.get('slug'); +const skuControl = this.productForm.get('sku'); +code Code + + +if (nameControl && slugControl && !slugControl.value) { + slugControl.setValue(this.generateSlug(nameControl.value), { + emitEvent: false, + }); +} +if (nameControl && skuControl && !skuControl.value) { + skuControl.setValue(this.generateSkuValue(nameControl.value), { + emitEvent: false, + }); +} + + + +} + +private subscribeToNameChanges(): void { +const nameControl = this.productForm.get('name'); +const slugControl = this.productForm.get('slug'); +code Code + + +if (nameControl && slugControl) { + this.nameChangeSubscription = nameControl.valueChanges + .pipe(debounceTime(400), distinctUntilChanged()) + .subscribe((name) => { + if (name && !slugControl.dirty) { + slugControl.setValue(this.generateSlug(name)); + } + }); +} + + + +} + +private generateSlug(name: string): string { +return name +.toLowerCase() +.replace(/\s+/g, '-') +.replace( +/[äöüß]/g, +(char) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[char] || '') +) +.replace(/[^a-z0-9-]/g, '') +.replace(/-+/g, '-'); +} + +private generateSkuValue(name: string): string { +const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, ''); +const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase(); +return ${prefix}-${randomPart}; +} + +private capitalizeFirstLetter(string: string): string { +return string.charAt(0).toUpperCase() + string.slice(1); +} +} +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +You have found the final piece of the puzzle! This is exactly where the error TypeError: categories.clear is not a function is coming from. + +You have successfully updated the form's initialization to use a FormControl for categorieIds, but the code that populates the form with existing product data is still using the old FormArray logic. +The Exact Problem + +The error is in your populateForm function within product-edit.component.ts. + +Let's look at the specific lines: +code TypeScript + + +// /src/app/features/admin/components/products/product-edit/product-edit.component.ts + +populateForm(product: AdminProduct): void { + // This first line is good. It sets most of the form values. + this.productForm.patchValue(product); + + // --- THE PROBLEM IS HERE --- + + // 1. You are incorrectly telling TypeScript that this is a FormArray. + const categories = this.productForm.get('categorieIds') as FormArray; + + // 2. This is the line that crashes. A FormControl does not have a .clear() method. + categories.clear(); + + // 3. This code would also fail, as you cannot .push() to a FormControl. + product.categorieIds?.forEach((id) => categories.push(new FormControl(id))); + + // ... rest of the function +} + + + +The good news is that the fix is incredibly simple. The first line, this.productForm.patchValue(product), already handles setting the value for your categorieIds FormControl, assuming your product object has a categorieIds property that is an array. + +You just need to delete the obsolete code. +The Solution + +Delete the three incorrect lines from your populateForm function. + +Change this: +code TypeScript + + +// BEFORE (INCORRECT) +populateForm(product: AdminProduct): void { + this.productForm.patchValue(product); + const categories = this.productForm.get('categorieIds') as FormArray; + categories.clear(); + product.categorieIds?.forEach((id) => categories.push(new FormControl(id))); + + this.existingImages = product.images || []; + const mainImage = this.existingImages.find((img) => img.isMainImage); + this.mainImageIdentifier = + mainImage?.id || + (this.existingImages.length > 0 ? this.existingImages[0].id : null); + + this.rebuildAllImagesForForm(); +} + + + +To this: +code TypeScript + + +// AFTER (CORRECT) +populateForm(product: AdminProduct): void { + // This single line handles ALL form controls, including setting + // the 'categorieIds' FormControl to the array from the product object. + this.productForm.patchValue(product); + + // The obsolete FormArray logic has been removed. + + this.existingImages = product.images || []; + const mainImage = this.existingImages.find((img) => img.isMainImage); + this.mainImageIdentifier = + mainImage?.id || + (this.existingImages.length > 0 ? this.existingImages[0].id : null); + + this.rebuildAllImagesForForm(); +} + + + +By removing those three lines, your application will stop trying to call FormArray methods on a FormControl, and all of your errors will be resolved. Your custom dropdown component will now correctly receive the array of category IDs and display them. +User + +// /src/app/features/admin/components/products/product-create/product-create.component.ts + +import { Component, OnInit, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { +FormBuilder, +FormGroup, +FormArray, +ReactiveFormsModule, +Validators, +FormControl, +} from '@angular/forms'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { Observable, Subscription } from 'rxjs'; +import { +debounceTime, +distinctUntilChanged, +map, +startWith, +} from 'rxjs/operators'; + +// Models, Services und UI-Komponenten +import { Category } from '../../../../core/models/category.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 { +ImagePreview, +ProductFormComponent, +} from '../product-form/product-form.component'; +import { SelectOption } from '../../../../shared/components/form/form-select/form-select.component'; + +@Component({ +selector: 'app-product-create', +standalone: true, +imports: [ +CommonModule, +ReactiveFormsModule, +code Code + + +ProductFormComponent, + + + +], +templateUrl: './product-create.component.html', +styleUrls: ['./product-create.component.css'], +}) +export class ProductCreateComponent implements OnInit, OnDestroy { +// --- Injektionen --- +private sanitizer = inject(DomSanitizer); +private router = inject(Router); +private productService = inject(ProductService); +private categoryService = inject(CategoryService); +private supplierService = inject(SupplierService); +private fb = inject(FormBuilder); +private snackbarService = inject(SnackbarService); + +// --- Komponenten-Status --- +isLoading = true; +productForm: FormGroup; +allCategories + + +!:Observable;supplierOptions!:Observable;supplierOptions + + + +!: Observable; +private nameChangeSubscription?: Subscription; + +// --- Bild-Management State --- +newImageFiles: File[] = []; +mainImageIdentifier: string | null = null; +allImagesForForm: ImagePreview[] = []; + +constructor() { +this.productForm = this.fb.group({ +name: ['', Validators.required], +slug: ['', Validators.required], +sku: ['', Validators.required], +price: [0, [Validators.required, Validators.min(0)]], +stockQuantity: [0, [Validators.required, Validators.min(0)]], +description: [''], +oldPrice: [null, [Validators.min(0)]], +purchasePrice: [null, [Validators.min(0)]], +weight: [null, [Validators.min(0)]], +isActive: [true], +isFeatured: [false], +featuredDisplayOrder: [0], +supplierId: [null], +categorieIds: new FormControl([]), +}); +} + +// --- Lifecycle Hooks --- +ngOnInit(): void { +this.loadDropdownData(); +this.subscribeToNameChanges(); +this.isLoading = false; +} + +ngOnDestroy(): void { +this.nameChangeSubscription?.unsubscribe(); +// BEST PRACTICE: Temporäre Bild-URLs aus dem Speicher entfernen, um Memory Leaks zu vermeiden +this.allImagesForForm.forEach((image) => +URL.revokeObjectURL(image.url as string) +); +} + +// --- Öffentliche Methoden & Event-Handler --- +loadDropdownData(): void { +this.allCategories + + +=this.categoryService.getAll();this.supplierOptions=this.categoryService.getAll();this.supplierOptions + + + + = this.supplierService.getAll().pipe( +map((suppliers) => +suppliers.map((s) => ({ value: s.id, label: s.name || 'Unbenannt' })) +), +startWith([]) +); +} + +onSubmit(): void { +if (this.productForm.invalid) { +this.productForm.markAllAsTouched(); +this.snackbarService.show('Bitte füllen Sie alle Pflichtfelder aus.'); +return; +} +code Code + + +// NEU: Stellt sicher, dass Slug/SKU vor dem Senden generiert werden, falls sie leer sind +this.prepareSubmissionData(); +const formData = this.createFormData(); + +this.productService.create(formData).subscribe({ + next: () => { + this.snackbarService.show('Produkt erfolgreich erstellt'); + this.router.navigate(['/shop/products']); + }, + error: (err) => { + this.snackbarService.show('Ein Fehler ist aufgetreten.'); + console.error(err); + }, +}); + + + +} + +cancel(): void { +this.router.navigate(['/shop/products']); +} + +onFilesSelected(files: File[]): void { +this.newImageFiles.push(...files); +if (!this.mainImageIdentifier && this.newImageFiles.length > 0) { +this.mainImageIdentifier = this.newImageFiles[0].name; +} +this.rebuildAllImagesForForm(); +} + +onSetMainImage(identifier: string): void { +this.mainImageIdentifier = identifier; +this.rebuildAllImagesForForm(); +} + +onDeleteImage(identifier: string): void { +this.newImageFiles = this.newImageFiles.filter( +(file) => file.name !== identifier +); +if (this.mainImageIdentifier === identifier) { +this.mainImageIdentifier = +this.newImageFiles.length > 0 ? this.newImageFiles[0].name : null; +} +this.rebuildAllImagesForForm(); +} + +// --- Private Helfermethoden --- +private rebuildAllImagesForForm(): void { +// Alte URLs freigeben, um Memory Leaks zu verhindern +this.allImagesForForm.forEach((image) => +URL.revokeObjectURL(image.url as string) +); +code Code + + +this.allImagesForForm = this.newImageFiles.map((file) => ({ + identifier: file.name, + url: this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(file)), + isMainImage: file.name === this.mainImageIdentifier, +})); + + + +} + +private prepareSubmissionData(): void { +const name = this.productForm.get('name')?.value; +const slugControl = this.productForm.get('slug'); +const skuControl = this.productForm.get('sku'); +code Code + + +if (name && slugControl && !slugControl.value) { + slugControl.setValue(this.generateSlug(name), { emitEvent: false }); +} +if (name && skuControl && !skuControl.value) { + skuControl.setValue(this.generateSkuValue(name), { emitEvent: false }); +} + + + +} + +private createFormData(): FormData { +const formData = new FormData(); +const formValue = this.productForm.getRawValue(); +code Code + + +Object.keys(formValue).forEach((key) => { + const value = formValue[key]; + if (key === 'categorieIds') { + (value as string[]).forEach((id) => + formData.append('CategorieIds', id) + ); + } else if (value !== null && value !== undefined && value !== '') { + formData.append(this.capitalizeFirstLetter(key), value.toString()); + } +}); + +const mainImageFile = this.newImageFiles.find( + (f) => f.name === this.mainImageIdentifier +); +if (mainImageFile) { + formData.append('MainImageFile', mainImageFile); +} + +// KORREKTUR: Die Logik für 'MainImageId' wurde entfernt, da sie hier nicht relevant ist. + +this.newImageFiles + .filter((f) => f.name !== this.mainImageIdentifier) + .forEach((file) => formData.append('AdditionalImageFiles', file)); + +return formData; + + + +} + +private subscribeToNameChanges(): void { +this.nameChangeSubscription = this.productForm +.get('name') +?.valueChanges.pipe(debounceTime(400), distinctUntilChanged()) +.subscribe((name: any) => { +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) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[char] || '') +) +.replace(/[^a-z0-9-]/g, '') +.replace(/-+/g, '-'); +} + +private generateSkuValue(name: string): string { +const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, ''); +const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase(); +return ${prefix}-${randomPart}; +} + +private capitalizeFirstLetter(string: string): string { +return string.charAt(0).toUpperCase() + string.slice(1); +} +} +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +You've done an excellent job here. This code is perfectly correct and already incorporates all the fixes we've discussed. + +There are no errors in this product-create.component.ts file. + +Let's quickly review why this component is implemented correctly, as it demonstrates a complete understanding of the solution: + + Correct Form Initialization: In your constructor, you correctly define categorieIds as a FormControl that will hold an array. This was the key to fixing the original registerOnChange error. + code TypeScript + + +constructor() { + this.productForm = this.fb.group({ + // ... other controls + categorieIds: new FormControl([]), // PERFECT + }); +} + + + +No Obsolete Logic: Unlike the product-edit.component before the fix, this file has no leftover code that treats categorieIds as a FormArray. There are no calls to .clear(), .push(), or .removeAt(). + +Correct Data Submission: In your createFormData method, you correctly get the value of the categorieIds control. The formValue.categorieIds is the array of selected IDs, which you then correctly loop through to append to the FormData. +code TypeScript + + + private createFormData(): FormData { + // ... + if (key === 'categorieIds') { + (value as string[]).forEach((id) => // CORRECT: Iterating the array value + formData.append('CategorieIds', id) + ); + } + // ... + } + + + +You have successfully refactored your product feature. Your implementation is now robust and correct across the board: + + The ProductFormComponent is a clean, reusable child component. + + The ProductEditComponent correctly initializes and populates the form control. + + The ProductCreateComponent correctly initializes the form control for a new product. + +Congratulations, your components are now correctly wired up +User + +
+
+
+ +code Code + + + + + + + + + + + + + + +
+ +
+ + +
+ + + +
+
+ + + + +
+ + + +
+
+
+ + + + + + +
+ +
+ Abbrechen + {{ submitButtonText }} +
+
+ +// /src/app/features/admin/components/products/product-form/product-form.component.ts (CORRECTED) + +import { Component, Input, Output, EventEmitter, inject, SimpleChanges } from '@angular/core'; +import { CommonModule, NgClass } from '@angular/common'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; // FormArray and FormControl no longer needed here +import { SafeUrl } from '@angular/platform-browser'; + +// Models & UI Components +import { Category } from '../../../../core/models/category.model'; +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'; +import { SnackbarService } from '../../../../shared/services/snackbar.service'; +import { FormGroupComponent } from '../../../../shared/components/form/form-group/form-group.component'; +import { ProductCategoryDropdownComponent } from '../product-category-dropdown/product-category-dropdown.component'; + +export interface ImagePreview { +identifier: string; +url: string | SafeUrl; +isMainImage: boolean; +} + +@Component({ +selector: 'app-product-form', +standalone: true, +imports: [ +CommonModule, NgClass, ReactiveFormsModule, ButtonComponent, IconComponent, +FormFieldComponent, FormSelectComponent, FormTextareaComponent, +SlideToggleComponent, FormGroupComponent, ProductCategoryDropdownComponent +], +templateUrl: './product-form.component.html', +styleUrls: ['./product-form.component.css'], +}) +export class ProductFormComponent { +// --- Inputs --- +@Input() productForm!: FormGroup; +@Input() allCategories: Category[] = []; +@Input() supplierOptions: SelectOption[] = []; +@Input() isLoading = false; +@Input() submitButtonText = 'Speichern'; +@Input() allImages: ImagePreview[] = []; + +// --- Outputs --- +@Output() formSubmit = new EventEmitter(); +@Output() formCancel = new EventEmitter(); +@Output() filesSelected = new EventEmitter(); +@Output() setMainImage = new EventEmitter(); +@Output() deleteImage = new EventEmitter(); + +private snackbarService = inject(SnackbarService); +public categoryOptions: SelectOption[] = []; + +// --- GETTER --- +get hasImages(): boolean { +return this.allImages && this.allImages.length > 0; +} + +// --- LIFECYCLE HOOKS --- +ngOnChanges(changes: SimpleChanges): void { +if (changes['allCategories']) { +this.categoryOptions = this.allCategories +.filter(cat => !!cat.name) +.map(cat => ({ +value: cat.id, +label: cat.name! +})); +} +} + +// --- EVENT-HANDLERS --- +onSubmit(): void { this.formSubmit.emit(); } +cancel(): void { this.formCancel.emit(); } + +onFilesSelected(event: Event): void { +const files = (event.target as HTMLInputElement).files; +if (files && files.length > 0) { +this.filesSelected.emit(Array.from(files)); +} +(event.target as HTMLInputElement).value = ''; +} + +setAsMainImage(identifier: string): void { +this.setMainImage.emit(identifier); +} + +requestImageDeletion(identifier: string, event: MouseEvent): void { +event.stopPropagation(); +this.deleteImage.emit(identifier); +} + +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!'); +} + +// ALL OBSOLETE CATEGORY HELPER FUNCTIONS HAVE BEEN REMOVED +} + +drop down für lieferant soll wenn es einen wert hat gefüllt werden +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Of course. I see what you're asking. You want the "Lieferant" (Supplier) dropdown to visually indicate that it has a value, likely by making the floating label move up and stay up, just like a regular text input would. + +Based on the structure of your other form components in product-form.component.html, the solution is very simple. +The Problem + +Your other custom form components, like , receive a [control] input property: +code Html + + + + + + + +This [control] input is likely used inside your custom components to check the control's state (e.g., if it has a value) and apply the correct CSS classes to the label. + +Your component for the supplier is missing this [control] input. + +Current (Incorrect) Code: +code Html + + + + + + + +The Solution + +You just need to add the [control] binding to your component, passing it the corresponding FormControl instance from your productForm. + +Add the [control] binding like this: +code Html + + + + + + + + + +By adding [control]="productForm.get('supplierId')", you give the app-form-select component the information it needs to check if a supplierId has been selected. It will then be able to apply the correct styling to show that the field is "filled". +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +You've correctly identified the next error. This is a great example of how fixing one thing can reveal the next step in the process. + +The error message is very precise and tells you exactly what is wrong: + +Can't bind to 'control' since it isn't a known property of 'app-form-select'. + +This means that in your parent component's template (product-form.component.html), you tried to bind to a property named control: +code Html + + + + + + + +But when Angular looked at the definition of the FormSelectComponent (form-select.component.ts), it could not find a property decorated with @Input() called control. + +Looking at the code you provided for FormSelectComponent, we can confirm this. It only has inputs for label and options: +code TypeScript + + +// form-select.component.ts +export class FormSelectComponent implements ControlValueAccessor { + @Input() label: string = ''; + @Input() options: SelectOption[] = []; + // There is no @Input() for 'control' here + // ... +} + + + +The Solution + +You need to add the missing @Input() property to your FormSelectComponent. This will make it consistent with your other custom form components like app-form-field. +1. Add the @Input() to form-select.component.ts + +Open form-select.component.ts and add a new @Input() for the control. It's best practice to type it as AbstractControl. +code TypeScript + + +// /path/to/form-select.component.ts + +import { Component, Input, forwardRef, HostListener, ElementRef } from '@angular/core'; +// Import AbstractControl from @angular/forms +import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormsModule, AbstractControl } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { animate, style, transition, trigger } from '@angular/animations'; + +// ... interface ... + +@Component({ + // ... selector, imports, etc. ... +}) +export class FormSelectComponent implements ControlValueAccessor { + @Input() label: string = ''; + @Input() options: SelectOption[] = []; + + // ADD THIS LINE + @Input() control: AbstractControl | null = null; + + // ... rest of the component code ... +} + + + +2. Use the New control Property in form-select.component.html + +Now that the component can receive the control object, you can use it in its template (form-select.component.html) to apply the correct class for the floating label. + +I don't have your form-select.component.html file, but it likely has a floating label. You need to add a class binding that checks if the control has a value. + +Your template likely has a structure similar to this. Find the label or a container div and add the [class.has-value] binding: +code Html + + + + + +
+ + + + + + + + +
+ +
+
+ + + +The key part is [class.has-value]="control?.value != null". This checks if the FormControl that you passed in has a value that is not null or undefined. Your CSS can then use the .has-value class to make the label float, just like it does for your other form components. + +By making these two changes, you will fix the error and achieve the desired UI behavior for your supplier dropdown. +User +
+ + + +
+Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Excellent. Thank you for providing the template for your FormSelectComponent. + +The good news is that your template is already set up correctly. + +Look at this line: +code Html + + + + + + +Your component's label is already configured to have the class has-value whenever the component's internal value property is not null. This logic is perfect and does exactly what you need. +The Real Problem (and the Simple Solution) + +This confirms that the problem is not in your component's template logic. The error message you received is the key: + +Can't bind to 'control' since it isn't a known property of 'app-form-select'. + +This error is happening because in your parent form (product-form.component.html), you are trying to pass a [control] property to , but the child component (FormSelectComponent) doesn't have an @Input() to receive it. + +Your template form-select.component.html does not need any changes. + +The only change you need to make is in the corresponding TypeScript file, form-select.component.ts. +The Fix + +Add the @Input() control property to your form-select.component.ts file. This will fix the binding error in the parent component, and your existing template logic will handle the rest. + +Open form-select.component.ts and add the missing input: +code TypeScript + + +// /src/app/shared/components/form/form-select/form-select.component.ts + +import { Component, Input, forwardRef, HostListener, ElementRef } from '@angular/core'; +// Make sure to import AbstractControl +import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormsModule, AbstractControl } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { animate, style, transition, trigger } from '@angular/animations'; + +// ... interface ... + +@Component({ + selector: 'app-form-select', + standalone: true, + // ... imports, templateUrl, etc. +}) +export class FormSelectComponent implements ControlValueAccessor { + @Input() label: string = ''; + @Input() options: SelectOption[] = []; + + // ADD THIS LINE TO FIX THE ERROR + @Input() control: AbstractControl | null = null; + + // ... rest of your component code is fine ... + isOpen = false; + selectedLabel: string | null = null; + controlId = `form-select-${Math.random().toString(36).substring(2)}`; + value: any = null; + onChange: (value: any) => void = () => {}; + onTouched: () => void = () => {}; + disabled = false; + + // ... constructor and other methods ... +} + + + +By adding this one line to the TypeScript file, you make the binding in product-form.component.html valid, which resolves the error. Your component's template will then work correctly using its internal value property, just as you designed it to. +User + +import { Component, Input, forwardRef, HostListener, ElementRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormsModule, AbstractControl } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { animate, style, transition, trigger } from '@angular/animations'; + +// Interface bleibt unverändert +export interface SelectOption { +value: any; +label: string; +} + +@Component({ +selector: 'app-form-select', +standalone: true, +imports: [CommonModule, ReactiveFormsModule, FormsModule], +templateUrl: './form-select.component.html', +styleUrl: './form-select.component.css', +providers: [ +{ +provide: NG_VALUE_ACCESSOR, +useExisting: forwardRef(() => FormSelectComponent), +multi: true, +}, +], +animations: [ +trigger('dropdownAnimation', [ +// :enter ist ein Alias für den Übergang von "void => *" (wenn ngIf true wird) +transition(':enter', [ +style({ opacity: 0, transform: 'translateY(-10px)' }), // Start-Zustand (unsichtbar) +animate('0.2s ease-out', style({ opacity: 1, transform: 'translateY(0)' })) // End-Zustand (sichtbar) +]), +// :leave ist ein Alias für den Übergang von " => void" (wenn *ngIf false wird) +transition(':leave', [ +animate('0.2s ease-in', style({ opacity: 0, transform: 'translateY(-10px)' })) // Animiert zum unsichtbaren Zustand +]) +]) +] +}) +export class FormSelectComponent implements ControlValueAccessor { +@Input() label: string = ''; +@Input() options: SelectOption[] = []; +@Input() control: AbstractControl | null = null; + +// NEU: Zustand für das Dropdown-Menü und das angezeigte Label +isOpen = false; +selectedLabel: string | null = null; + +controlId = form-select-${Math.random().toString(36).substring(2)}; +value: any = null; +onChange: (value: any) => void = () => {}; +onTouched: () => void = () => {}; +disabled = false; + +constructor(private elementRef: ElementRef) {} + +// NEU: Schließt das Dropdown, wenn außerhalb des Elements geklickt wird +@HostListener('document:click', ['$event']) +onDocumentClick(event: MouseEvent): void { +// Diese Logik ist jetzt sicher, weil Klicks innerhalb der Komponente sie nie erreichen +if (!this.elementRef.nativeElement.contains(event.target)) { +this.isOpen = false; +} +} + +// ÜBERARBEITET: writeValue aktualisiert jetzt auch das sichtbare Label +writeValue(value: any): void { +this.value = value; +const selectedOption = this.options.find(opt => opt.value === value); +this.selectedLabel = selectedOption ? selectedOption.label : null; +} + +registerOnChange(fn: any): void { this.onChange = fn; } +registerOnTouched(fn: any): void { this.onTouched = fn; } +setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; } + +// NEU: Methode zum Öffnen/Schließen des Menüs +// ÜBERARBEITET: Nimmt das Event entgegen und stoppt es +toggleDropdown(event: MouseEvent): void { +event.stopPropagation(); // <-- WICHTIGSTE KORREKTUR +if (!this.disabled) { +this.isOpen = !this.isOpen; +if (!this.isOpen) { +this.onTouched(); +} +} +} + +// ÜBERARBEITET: Nimmt das Event entgegen und stoppt es +selectOption(option: SelectOption, event: MouseEvent): void { +event.stopPropagation(); // <-- WICHTIGE KORREKTUR +if (!this.disabled) { +this.value = option.value; +this.selectedLabel = option.label; +this.onChange(this.value); +this.onTouched(); +this.isOpen = false; +} +} +} +
+ + + +
+ +/* ================================================================================= + + STILE FÜR DIE CUSTOM SELECT KOMPONENTE + + ================================================================================= */ + +:host { +display: block; +} + +/* Der Haupt-Wrapper */ +.custom-select-wrapper { +position: relative; +} + +.form-input { +/* Layout & Box-Modell */ +width: 100%; +padding: 0.85rem 1rem; +border: 1px solid var(--color-border); +border-radius: var(--border-radius-md); + +/* Aussehen & Typografie / +background-color: var(--color-surface); +color: var(--color-text); +font-size: 1rem; +font-family: inherit; / Erbt die Schriftart von der Seite */ + +/* Verhalten */ +transition: border-color var(--transition-speed); +} + +/* Das "schwebende" Label (angepasste Logik) / +.form-label { +/ Positionierung / +position: absolute; +top: 50%; / Vertikal zentrieren (Schritt 1) / +left: 1rem; / Linken Abstand wie beim Input-Padding halten / +transform: translateY( +-50% +); / Vertikal zentrieren (Schritt 2 - Feinjustierung) / +border-radius: 4px; +/ Aussehen & Typografie / +color: var(--color-text-light); +background-color: var( +--color-surface +); / WICHTIG: Überdeckt die Input-Linie beim Schweben */ +padding: 0 0.25rem; + +/* Verhalten / +transition: all 0.2s ease-out; / Animiert alle Änderungen (top, font-size, color) / +pointer-events: none; / Erlaubt Klicks "durch" das Label auf das Input-Feld darunter */ +} + +/* Basis-Stile für den sichtbaren Teil (der Button) / +.form-input.select-display { +width: 100%; +text-align: left; +display: flex; +justify-content: space-between; +align-items: center; +padding-right: 2.5rem; / Platz für den Pfeil */ +} + +/* Button-Reset, damit er wie ein Input aussieht */ +button.form-input { +-webkit-appearance: none; +-moz-appearance: none; +appearance: none; +background-color: var(--color-surface); +border: 1px solid var(--color-border); +font-family: inherit; +font-size: inherit; +margin: 0; +color: var(--color-text); +} + +/* ================================================================================= + + KORRIGIERTER BLOCK FÜR DIE NACHRÜCK-ANIMATION + + ================================================================================= */ + +/* + + KORREKTUR 1: Machen den Container zu einer Flexbox. + + Das ist der Schlüssel, damit der Browser die Position des zweiten Elements animiert, + + während das erste schrumpft. + */ + .placeholder-text { + color: var(--color-text-light); + display: flex; + align-items: center; + } + +/* + + KORREKTUR 2: Wir stellen sicher, dass das erste span (das Label) auch ein Flex-Item ist, + + das schrumpfen kann. Der Rest der Animation bleibt gleich. + */ + .placeholder-text .label { + display: inline-block; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + color: transparent; + padding-right: 8px; + max-width: 100px; + transition: 0.3s; + } + +/* + + Der Trigger bleibt gleich: Bei Fokus schrumpft das Label auf 0, + + und die Flexbox schiebt den "wählen..."-Text sanft an seine neue Position. + / + .select-display:focus .placeholder-text .label { + max-width: 0; + padding-right: 0; + } + / ================================================================================= */ + +/* Ausgewählter Text */ +.selected-text { +color: var(--color-text); +} + +/* Fokus-Zustand */ +.select-display:focus { +outline: none; +border-color: var(--color-primary); +} + +/* "Floating"-Label-Logik */ +.select-display:focus ~ .form-label, +.form-label.has-value { +top: 0; +font-size: 0.8rem; +color: var(--color-primary); +} + +/* ================================================================================= + + STILE FÜR DEN PFEIL UND DAS DROPDOWN-MENÜ + + ================================================================================= / + / ... (der Rest der CSS-Datei bleibt unverändert) ... */ + .select-display::after { + content: ""; + position: absolute; + top: 50%; + right: 1rem; + transform: translateY(-50%); + width: 0.8em; + height: 0.5em; + background-color: var(--color-text-light); + clip-path: polygon(100% 0%, 0 0%, 50% 100%); + transition: transform 0.2s ease-out, background-color var(--transition-speed); + } + +.select-display:focus::after { +background-color: var(--color-primary); +} + +.custom-select-wrapper.is-open .select-display::after { +transform: translateY(-50%) rotate(180deg); +} + +.options-list { +position: absolute; +top: calc(100% + 4px); +left: 0; +right: 0; +list-style: none; +margin: 0; +padding: 0.5rem 0; +background-color: var(--color-surface); +border: 1px solid var(--color-border); +border-radius: var(--border-radius-md); +box-shadow: var(--box-shadow-md); +z-index: 1000; +max-height: 220px; +overflow-y: auto; +animation: fadeInDown 0.2s ease-out forwards; +} + +.option-item { +padding: 0.75rem 1rem; +cursor: pointer; +transition: background-color 0.15s ease-out; +} + +.option-item:hover { +background-color: var(--color-body-bg-hover); +} + +@keyframes fadeInDown { +from { +opacity: 0; +transform: translateY(-10px); +} +to { +opacity: 1; +transform: translateY(0); +} +} + +irgendwas passt nicht wegen der animation es sieht so aus als wäre was ausgewählt aber man kann es nicht sehen +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Vielen Dank für die Bereitstellung aller relevanten Dateien. Sie haben den Fehler perfekt eingekreist. + +Das Problem ist ein visueller Konflikt, der durch eine sehr komplexe und fehleranfällige CSS-Animation im Platzhalter verursacht wird. Ihre Logik im TypeScript ist korrekt, aber das CSS spielt Ihnen einen Streich. +Die Ursache des Problems + +Die Animation in Ihrem CSS ist darauf ausgelegt, eine clevere Illusion zu erzeugen: + + Die Illusion: Im Ruhezustand sehen Sie den Text "Lieferant wählen...". Sie denken, das ist ein einfacher Text. In Wirklichkeit sind es aber zwei übereinander liegende Elemente: + + Das schwebende Label .form-label, das "Lieferant" anzeigt. + + Ein transparenter (.placeholder-text .label) im Input, der ebenfalls "Lieferant" enthält. Sie sehen diesen Text nie, weil er color: transparent hat. + + Ein sichtbarer , der nur " wählen..." anzeigt. + + Der Konflikt: Wenn ein Wert ausgewählt wird (z. B. "DHL"), passiert Folgendes: + + Ihr TypeScript setzt selectedLabel auf "DHL". + + Das *ngIf im HTML entfernt den gesamten Platzhalter-Block. + + Das schwebende Label .form-label bekommt die Klasse .has-value und fährt korrekt nach oben. + + Hier liegt das Problem: Das mit dem Text "DHL" wird zwar im Button gerendert, aber die komplexen CSS-Regeln für den Platzhalter (insbesondere das Zusammenspiel von transparent, flexbox und max-width) führen dazu, dass dieser neue Text nicht korrekt angezeigt wird. Er ist wahrscheinlich unsichtbar oder wird aus dem sichtbaren Bereich geschoben. + +Die Lösung: Vereinfachung für mehr Stabilität + +Die beste Lösung besteht darin, diese fragile Animations-Illusion zu entfernen und durch einen sauberen, standardmäßigen Ansatz zu ersetzen. Das schwebende Label (form-label) übernimmt bereits die Aufgabe, das Feld zu beschriften. Der Platzhalter muss dies nicht wiederholen. +Schritt 1: Vereinfachen Sie das HTML (form-select.component.html) + +Ändern Sie den #placeholder-Block. Entfernen Sie die komplizierte Struktur mit den zwei -Elementen und ersetzen Sie sie durch einen einzigen, einfachen Platzhalter. + +Ändern Sie diesen Block: +code Html + + + + + + {{ label }} wählen... + + + + + +Zu diesem Block: +code Html + + + + + Bitte wählen... + + + + +Schritt 2: Entfernen Sie das überflüssige CSS (form-select.component.css) + +Da die HTML-Struktur für die Animation nun entfernt wurde, können Sie auch den gesamten CSS-Block, der diese Animation steuert, löschen. Das macht Ihre CSS-Datei sauberer und verhindert zukünftige Konflikte. + +Löschen Sie diesen gesamten Block aus Ihrer CSS-Datei: +code CSS + + +/* ================================================================================= + * KORRIGIERTER BLOCK FÜR DIE NACHRÜCK-ANIMATION -> DIESEN GANZEN BLOCK LÖSCHEN + * ================================================================================= */ + +/* + * KORREKTUR 1: Machen den Container zu einer Flexbox. + * Das ist der Schlüssel, damit der Browser die Position des zweiten Elements animiert, + * während das erste schrumpft. +*/ +.placeholder-text { + color: var(--color-text-light); + display: flex; + align-items: center; +} + +/* + * KORREKTUR 2: Wir stellen sicher, dass das erste span (das Label) auch ein Flex-Item ist, + * das schrumpfen kann. Der Rest der Animation bleibt gleich. +*/ +.placeholder-text .label { + display: inline-block; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + color: transparent; + padding-right: 8px; + max-width: 100px; + transition: 0.3s; +} + +/* + * Der Trigger bleibt gleich: Bei Fokus schrumpft das Label auf 0, + * und die Flexbox schiebt den "wählen..."-Text sanft an seine neue Position. +*/ +.select-display:focus .placeholder-text .label { + max-width: 0; + padding-right: 0; +} +/* ================================================================================= */ + + + +Fügen Sie stattdessen eine einfache Regel für den neuen Platzhalter hinzu: +code CSS + + +/* Fügen Sie dies an einer passenden Stelle hinzu */ +.placeholder-text { + color: var(--color-text-light); +} + + + +Ergebnis + +Nach diesen Änderungen wird Ihre Komponente wie folgt funktionieren: + + Leerer Zustand: Das schwebende Label "Lieferant" ist in der Mitte. Im Input-Feld steht der Platzhalter "Bitte wählen...". + + Fokus: Das schwebende Label "Lieferant" fährt nach oben. Der Platzhalter "Bitte wählen..." bleibt sichtbar, bis eine Auswahl getroffen wird. + + Ausgewählter Zustand: Das schwebende Label "Lieferant" ist oben. Im Input-Feld steht der ausgewählte Wert (z. B. "DHL"). + +Dieser Ansatz ist wesentlich stabiler, leichter zu warten und liefert das professionelle Ergebnis, das Sie anstreben, ohne den visuellen Fehler. +User + +kann ich nicht besser initial hier den wert setzen wenn die seite läd ? : +// /src/app/features/admin/components/products/product-edit/product-edit.component.ts + +import { Component, OnInit, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { +FormBuilder, +FormGroup, +FormArray, +FormControl, +ReactiveFormsModule, +Validators, +} from '@angular/forms'; +import { DomSanitizer } from '@angular/platform-browser'; +import { Observable, Subscription } from 'rxjs'; +import { +map, +startWith, +debounceTime, +distinctUntilChanged, +finalize, +} from 'rxjs/operators'; + +// Models, Services und UI-Komponenten +import { +AdminProduct, +ProductImage, +} from '../../../../core/models/product.model'; +import { Category } from '../../../../core/models/category.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 { +ImagePreview, +ProductFormComponent, +} from '../product-form/product-form.component'; +import { SelectOption } from '../../../../shared/components/form/form-select/form-select.component'; + +@Component({ +selector: 'app-product-edit', +standalone: true, +imports: [CommonModule, ReactiveFormsModule, ProductFormComponent], +templateUrl: './product-edit.component.html', +styleUrls: ['./product-edit.component.css'], +}) +export class ProductEditComponent implements OnInit, OnDestroy { +// --- Injektionen --- +private route = inject(ActivatedRoute); +private router = inject(Router); +private productService = inject(ProductService); +private categoryService = inject(CategoryService); +private supplierService = inject(SupplierService); +private fb = inject(FormBuilder); +private snackbarService = inject(SnackbarService); +private sanitizer = inject(DomSanitizer); + +// --- Komponenten-Status --- +productId!: string; +isLoading = true; +productForm: FormGroup; +private nameChangeSubscription?: Subscription; +allCategories + + +!:Observable;supplierOptions!:Observable;supplierOptions + + + +!: Observable; + +// --- NEUER STATE FÜR BILD-MANAGEMENT --- +existingImages: ProductImage[] = []; +newImageFiles: File[] = []; +mainImageIdentifier: string | null = null; // Hält die ID oder den Dateinamen des Hauptbildes +allImagesForForm: ImagePreview[] = []; // Die kombinierte Liste für die Child-Komponente + +constructor() { +this.productForm = this.fb.group({ +id: ['', Validators.required], +name: ['', Validators.required], +slug: ['', Validators.required], +sku: ['', Validators.required], +price: [0, [Validators.required, Validators.min(0)]], +stockQuantity: [0, [Validators.required, Validators.min(0)]], +description: [''], +oldPrice: [null, [Validators.min(0)]], +purchasePrice: [null, [Validators.min(0)]], +weight: [null, [Validators.min(0)]], +isActive: [true], +isFeatured: [false], +featuredDisplayOrder: [0], +supplierId: [null], +categorieIds: new FormControl([]), +imagesToDelete: this.fb.array([]), +}); +} + +get imagesToDelete(): FormArray { +return this.productForm.get('imagesToDelete') as FormArray; +} + +ngOnInit(): void { +const id = this.route.snapshot.paramMap.get('id'); +if (!id) { +this.snackbarService.show('Produkt-ID fehlt.'); +this.router.navigate(['/shop/products']); +return; +} +this.productId = id; +this.loadDropdownData(); +this.loadProductData(); +this.subscribeToNameChanges(); +} + +ngOnDestroy(): void { +this.nameChangeSubscription?.unsubscribe(); +this.allImagesForForm.forEach((image) => { +if (typeof image.url === 'object') { +// Nur Object-URLs von neuen Bildern freigeben +URL.revokeObjectURL(image.url as string); +} +}); +} + +loadDropdownData(): void { +this.allCategories + + +=this.categoryService.getAll();this.supplierOptions=this.categoryService.getAll();this.supplierOptions + + + + = this.supplierService.getAll().pipe( +map((suppliers) => +suppliers.map((s) => ({ value: s.id, label: s.name || 'Unbenannt' })) +), +startWith([]) +); +} + +loadProductData(): void { +this.isLoading = true; +this.productService +.getById(this.productId) +.pipe( +finalize(() => { +this.isLoading = false; +}) +) +.subscribe({ +next: (product) => { +if (product) { +this.populateForm(product); +} else { +this.snackbarService.show('Produkt nicht gefunden.'); +this.router.navigate(['/shop/products']); +} +}, +error: (err) => { +this.snackbarService.show('Fehler beim Laden des Produkts.'); +console.error('Fehler beim Laden der Produktdaten:', err); +this.router.navigate(['/shop/products']); +}, +}); +} + +// AFTER (CORRECT) +populateForm(product: AdminProduct): void { +// This single line handles ALL form controls, including setting +// the 'categorieIds' FormControl to the array from the product object. +this.productForm.patchValue(product); + +// The obsolete FormArray logic has been removed. + +this.existingImages = product.images || []; +const mainImage = this.existingImages.find((img) => img.isMainImage); +this.mainImageIdentifier = +mainImage?.id || +(this.existingImages.length > 0 ? this.existingImages[0].id : null); + +this.rebuildAllImagesForForm(); +} + +onSubmit(): void { +if (this.productForm.invalid) { +this.productForm.markAllAsTouched(); +this.snackbarService.show('Bitte füllen Sie alle Pflichtfelder aus.'); +return; +} +this.prepareSubmissionData(); +const formData = this.createFormData(); +this.productService.update(this.productId, formData).subscribe({ +next: () => { +this.snackbarService.show('Produkt erfolgreich aktualisiert'); +this.router.navigate(['/shop/products']); +}, +error: (err) => { +this.snackbarService.show('Ein Fehler ist aufgetreten.'); +console.error(err); +}, +}); +} + +cancel(): void { +this.router.navigate(['/shop/products']); +} + +// --- NEUE EVENT-HANDLER FÜR BILDER --- +onFilesSelected(files: File[]): void { +this.newImageFiles.push(...files); +if (!this.mainImageIdentifier && this.newImageFiles.length > 0) { +this.mainImageIdentifier = this.newImageFiles[0].name; +} +this.rebuildAllImagesForForm(); +} + +onSetMainImage(identifier: string): void { +this.mainImageIdentifier = identifier; +this.rebuildAllImagesForForm(); +} + +onDeleteImage(identifier: string): void { +const isExisting = this.existingImages.some((img) => img.id === identifier); +if (isExisting) { +this.imagesToDelete.push(new FormControl(identifier)); +this.existingImages = this.existingImages.filter( +(img) => img.id !== identifier +); +} else { +this.newImageFiles = this.newImageFiles.filter( +(file) => file.name !== identifier +); +} +code Code + + +if (this.mainImageIdentifier === identifier) { + const firstExisting = + this.existingImages.length > 0 ? this.existingImages[0].id : null; + const firstNew = + this.newImageFiles.length > 0 ? this.newImageFiles[0].name : null; + this.mainImageIdentifier = firstExisting || firstNew; +} +this.rebuildAllImagesForForm(); + + + +} + +// --- Private Helfermethoden --- +private rebuildAllImagesForForm(): void { +this.allImagesForForm.forEach((image) => { +if (typeof image.url === 'object') +URL.revokeObjectURL(image.url as string); +}); +code Code + + +const combined: ImagePreview[] = []; +this.existingImages + .filter((img) => !!img.url) // Stellt sicher, dass nur Bilder mit URL verwendet werden + .forEach((img) => { + combined.push({ + identifier: img.id, + url: img.url!, + isMainImage: img.id === this.mainImageIdentifier, + }); + }); + +this.newImageFiles.forEach((file) => { + combined.push({ + identifier: file.name, + url: this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(file)), + isMainImage: file.name === this.mainImageIdentifier, + }); +}); + +this.allImagesForForm = combined; + + + +} + +private createFormData(): FormData { +const formData = new FormData(); +const formValue = this.productForm.getRawValue(); +code Code + + +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 !== '') { + formData.append(this.capitalizeFirstLetter(key), value.toString()); + } +}); + +const mainImageFile = this.newImageFiles.find( + (f) => f.name === this.mainImageIdentifier +); +if (mainImageFile) { + formData.append('MainImageFile', mainImageFile); +} else if (this.mainImageIdentifier) { + formData.append('MainImageId', this.mainImageIdentifier); +} + +this.newImageFiles + .filter((f) => f.name !== this.mainImageIdentifier) + .forEach((file) => formData.append('AdditionalImageFiles', file)); + +return formData; + + + +} + +// Private Helfermethoden +private prepareSubmissionData(): void { +const nameControl = this.productForm.get('name'); +const slugControl = this.productForm.get('slug'); +const skuControl = this.productForm.get('sku'); +code Code + + +if (nameControl && slugControl && !slugControl.value) { + slugControl.setValue(this.generateSlug(nameControl.value), { + emitEvent: false, + }); +} +if (nameControl && skuControl && !skuControl.value) { + skuControl.setValue(this.generateSkuValue(nameControl.value), { + emitEvent: false, + }); +} + + + +} + +private subscribeToNameChanges(): void { +const nameControl = this.productForm.get('name'); +const slugControl = this.productForm.get('slug'); +code Code + + +if (nameControl && slugControl) { + this.nameChangeSubscription = nameControl.valueChanges + .pipe(debounceTime(400), distinctUntilChanged()) + .subscribe((name) => { + if (name && !slugControl.dirty) { + slugControl.setValue(this.generateSlug(name)); + } + }); +} + + + +} + +private generateSlug(name: string): string { +return name +.toLowerCase() +.replace(/\s+/g, '-') +.replace( +/[äöüß]/g, +(char) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[char] || '') +) +.replace(/[^a-z0-9-]/g, '') +.replace(/-+/g, '-'); +} + +private generateSkuValue(name: string): string { +const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, ''); +const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase(); +return ${prefix}-${randomPart}; +} + +private capitalizeFirstLetter(string: string): string { +return string.charAt(0).toUpperCase() + string.slice(1); +} +} +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Ja, das ist eine ausgezeichnete Frage und ein sehr wichtiger Punkt. Die Antwort ist: Sie machen es in Ihrem Code bereits genau richtig! + +Ihr Gedanke, den Wert beim Laden der Seite zu setzen, ist absolut korrekt, und Ihre populateForm-Methode tut genau das. + +Lassen Sie uns die Zeile ansehen, die diese Aufgabe erledigt: +code TypeScript + + +// /src/app/features/admin/components/products/product-edit/product-edit.component.ts + +populateForm(product: AdminProduct): void { + // DIESE EINE ZEILE SETZT ALLE WERTE, INKLUSIVE DER 'supplierId' + this.productForm.patchValue(product); + + // ... der Rest des Codes für die Bilder +} + + + +Warum Ihr aktueller Code bereits die beste Lösung ist + + patchValue ist der korrekte Weg: Die Methode patchValue von Angulars FormGroup ist genau dafür gemacht. Sie nimmt ein Objekt (product von Ihrer API) und setzt automatisch den Wert für jeden FormControl, dessen Name mit einer Eigenschaft im Objekt übereinstimmt. + + Wenn Ihr product-Objekt also supplierId: 'dhl-123' enthält, findet patchValue den FormControl namens supplierId in Ihrer productForm und setzt dessen Wert auf 'dhl-123'. + + Der Wert wird gesetzt: Ihr Code funktioniert. Das Problem, das Sie sehen, ist nicht, dass der Wert nicht im Formularmodell ankommt. Er ist dort vorhanden. Das Problem ist rein visuell: Die app-form-select-Komponente hat den neuen Wert zwar intern erhalten, aber ihr fehlerhaftes CSS hat verhindert, dass er korrekt angezeigt wird. + +Die Unterscheidung zwischen Datenfluss und Anzeige + +Es ist hilfreich, die beiden Probleme klar zu trennen: + + Problem 1: Daten setzen (Logik) + + Wo: In product-edit.component.ts. + + Was: Den Wert aus dem product-Objekt in den supplierId-FormControl laden. + + Status: Bereits gelöst. Ihre populateForm-Methode mit patchValue ist perfekt. Hier müssen Sie nichts ändern. + + Problem 2: Daten anzeigen (Visuell) + + Wo: Innerhalb der app-form-select-Komponente (HTML & CSS). + + Was: Wenn der supplierId-FormControl einen Wert hat, soll dieser Wert sichtbar sein und das Label nach oben schweben. + + Status: Das war der eigentliche Fehler. Die komplexe CSS-Animation hat die Anzeige des Wertes verhindert. + +Stellen Sie es sich wie bei einem Auto vor: Ihr Motor (product-edit.component) liefert die korrekte Geschwindigkeit, aber die Nadel auf dem Tacho (app-form-select) war kaputt und hat 0 angezeigt. Ihr Plan sollte nicht sein, den Motor zu ändern, sondern den Tacho zu reparieren. +Zusammenfassung und nächste Schritte + + Ihr product-edit.component.ts ist korrekt. Lassen Sie die populateForm-Methode genau so, wie sie ist. Sie erledigt die "initiale Wertsetzung" bereits auf die bestmögliche Weise. + + Fokussieren Sie sich auf die Korrektur der app-form-select-Komponente, wie in meiner vorherigen Antwort beschrieben: + + Vereinfachen Sie den #placeholder-Block im HTML der Komponente. + + Entfernen Sie den dazugehörigen, fehlerhaften CSS-Animationsblock. + +Wenn Sie diese visuellen Korrekturen in der app-form-select-Komponente vornehmen, wird der Wert, den Ihre populateForm-Methode korrekt setzt, auch endlich sichtbar. +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Ausgezeichnete Frage. Das ist der Kern des Problems, und die Antwort darauf ist nicht sofort offensichtlich, aber sehr wichtig für das Verständnis von Angular. + +Sie haben alles richtig gemacht, aber Sie sind auf ein klassisches Timing-Problem gestoßen, das oft als "Race Condition" bezeichnet wird. + +Hier ist die genaue Abfolge der Ereignisse, die den Fehler verursacht: + + Seite lädt: Ihr ProductEditComponent wird initialisiert. + + Zwei Anfragen starten: In ngOnInit rufen Sie zwei Methoden auf, die beide asynchrone API-Aufrufe starten: + + loadProductData(): Holt das Produkt (z.B. { ..., supplierId: 'dhl-123', ... }). + + loadDropdownData(): Holt die Liste aller möglichen Lieferanten (z.B. [{ value: 'dhl-123', label: 'DHL' }, ...]). + + Das Rennen beginnt: Diese beiden Anfragen laufen parallel. Es gibt keine Garantie, welche zuerst fertig wird. + + Das Problem-Szenario (passiert sehr oft): + + loadProductData() ist schneller. Die Anfrage ist fertig. + + Ihr Code führt this.populateForm(product) aus. + + Darin wird this.productForm.patchValue(product) aufgerufen. + + Angular sieht, dass der Wert des supplierId-FormControls sich auf 'dhl-123' geändert hat. + + WICHTIG: Angular ruft sofort die writeValue-Methode in Ihrer app-form-select-Komponente auf: writeValue('dhl-123'). + + Innerhalb von writeValue('dhl-123'): + + Zu diesem exakten Zeitpunkt ist die zweite Anfrage, loadDropdownData(), noch nicht fertig. + + Das bedeutet, dass das @Input() options in Ihrer app-form-select-Komponente noch der leere Standardwert ist: []. + + Ihr Code führt diese Zeile aus: this.options.find(opt => opt.value === 'dhl-123'). + + Das ist dasselbe wie: [].find(...). Das Ergebnis ist natürlich undefined. + + Folglich wird this.selectedLabel auf null gesetzt. + + Ein paar Millisekunden später: + + Die loadDropdownData()-Anfrage wird fertig. + + Das supplierOptions$-Observable liefert die Daten, und das @Input() options Ihrer Komponente wird endlich mit der vollen Liste der Lieferanten aktualisiert. + + Aber es ist zu spät. Die writeValue-Methode wird nicht automatisch erneut aufgerufen, nur weil sich ein anderes @Input geändert hat. + +Das Ergebnis ist genau das, was Sie sehen: + + Das Label schwebt nach oben, weil value korrekt auf 'dhl-123' gesetzt ist ([class.has-value]="value !== null" funktioniert). + + Im Feld wird nichts angezeigt, weil selectedLabel auf null gesetzt wurde und nie wieder aktualisiert wurde. + +Die Lösung: Machen Sie die Komponente reaktiv + +Die app-form-select-Komponente muss intelligent genug sein, um ihr selectedLabel neu zu berechnen, wenn sich entweder der value oder die options ändern. + +Die eleganteste Lösung dafür ist, selectedLabel von einer einfachen Eigenschaft in einen Getter umzuwandeln. Ein Getter wird bei jedem Change-Detection-Zyklus von Angular neu ausgewertet. +Ändern Sie Ihre form-select.component.ts + +1. Entfernen Sie die selectedLabel-Eigenschaft. + +2. Ersetzen Sie sie durch einen get selectedLabel() Getter. + +3. Passen Sie writeValue und selectOption an (sie müssen den Label-Text nicht mehr manuell setzen). + +Hier ist der korrigierte Code für form-select.component.ts: +code TypeScript + + +// /src/app/shared/components/form/form-select/form-select.component.ts (KORRIGIERT) + +@Component({ + // ... +}) +export class FormSelectComponent implements ControlValueAccessor { + @Input() label: string = ''; + @Input() options: SelectOption[] = []; + @Input() control: AbstractControl | null = null; + + isOpen = false; + + // -- ENTFERNEN SIE DIESE ZEILE -- + // selectedLabel: string | null = null; + + controlId = `form-select-${Math.random().toString(36).substring(2)}`; + value: any = null; + onChange: (value: any) => void = () => {}; + onTouched: () => void = () => {}; + disabled = false; + + // ++ FÜGEN SIE DIESEN GETTER HINZU ++ + // Dieser Getter wird immer dann neu berechnet, wenn Angular die Ansicht prüft. + // Er hat immer Zugriff auf die aktuellsten `options` und den aktuellsten `value`. + get selectedLabel(): string | null { + const selectedOption = this.options.find(opt => opt.value === this.value); + return selectedOption ? selectedOption.label : null; + } + + constructor(private elementRef: ElementRef) {} + + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + if (!this.elementRef.nativeElement.contains(event.target)) { + this.isOpen = false; + } + } + + // KORRIGIERT: writeValue setzt jetzt nur noch den Wert. + writeValue(value: any): void { + this.value = value; + // Die Zeile, die `selectedLabel` gesetzt hat, wird nicht mehr benötigt. + } + + registerOnChange(fn: any): void { this.onChange = fn; } + registerOnTouched(fn: any): void { this.onTouched = fn; } + setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; } + + toggleDropdown(event: MouseEvent): void { + event.stopPropagation(); + if (!this.disabled) { + this.isOpen = !this.isOpen; + if (!this.isOpen) { this.onTouched(); } + } + } + + // KORRIGIERT: selectOption setzt jetzt nur noch den Wert. + selectOption(option: SelectOption, event: MouseEvent): void { + event.stopPropagation(); + if (!this.disabled) { + this.value = option.value; + // Die Zeile, die `selectedLabel` gesetzt hat, wird nicht mehr benötigt. + this.onChange(this.value); + this.onTouched(); + this.isOpen = false; + } + } +} + + + +Mit dieser Änderung ist es egal, in welcher Reihenfolge die Daten ankommen. Sobald beide (value und options) vorhanden sind, wird der selectedLabel-Getter bei der nächsten Überprüfung durch Angular den korrekten Label-Text finden und in Ihrem Template anzeigen. Das Timing-Problem ist damit gelöst. +User + +ok mein nächstes problem ist wenn ich direkt mehrere bilder hochlade funktioniert es, ein eizelnes bild auch, aber wenn ich erst eins hochlade und später eins hinzufügen will kommt ein error : +code Code + + +
+ +
+ + +
+ + + +
+
+ + + +// /src/app/features/admin/components/products/product-form/product-form.component.ts (CORRECTED) + +import { Component, Input, Output, EventEmitter, inject, SimpleChanges } from '@angular/core'; +import { CommonModule, NgClass } from '@angular/common'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; // FormArray and FormControl no longer needed here +import { SafeUrl } from '@angular/platform-browser'; + +// Models & UI Components +import { Category } from '../../../../core/models/category.model'; +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'; +import { SnackbarService } from '../../../../shared/services/snackbar.service'; +import { FormGroupComponent } from '../../../../shared/components/form/form-group/form-group.component'; +import { ProductCategoryDropdownComponent } from '../product-category-dropdown/product-category-dropdown.component'; + +export interface ImagePreview { +identifier: string; +url: string | SafeUrl; +isMainImage: boolean; +} + +@Component({ +selector: 'app-product-form', +standalone: true, +imports: [ +CommonModule, NgClass, ReactiveFormsModule, ButtonComponent, IconComponent, +FormFieldComponent, FormSelectComponent, FormTextareaComponent, +SlideToggleComponent, FormGroupComponent, ProductCategoryDropdownComponent +], +templateUrl: './product-form.component.html', +styleUrls: ['./product-form.component.css'], +}) +export class ProductFormComponent { +// --- Inputs --- +@Input() productForm!: FormGroup; +@Input() allCategories: Category[] = []; +@Input() supplierOptions: SelectOption[] = []; +@Input() isLoading = false; +@Input() submitButtonText = 'Speichern'; +@Input() allImages: ImagePreview[] = []; + +// --- Outputs --- +@Output() formSubmit = new EventEmitter(); +@Output() formCancel = new EventEmitter(); +@Output() filesSelected = new EventEmitter(); +@Output() setMainImage = new EventEmitter(); +@Output() deleteImage = new EventEmitter(); + +private snackbarService = inject(SnackbarService); +public categoryOptions: SelectOption[] = []; + +// --- GETTER --- +get hasImages(): boolean { +return this.allImages && this.allImages.length > 0; +} + +// --- LIFECYCLE HOOKS --- +ngOnChanges(changes: SimpleChanges): void { +if (changes['allCategories']) { +this.categoryOptions = this.allCategories +.filter(cat => !!cat.name) +.map(cat => ({ +value: cat.id, +label: cat.name! +})); +} +} + +// --- EVENT-HANDLERS --- +onSubmit(): void { this.formSubmit.emit(); } +cancel(): void { this.formCancel.emit(); } + +onFilesSelected(event: Event): void { +const files = (event.target as HTMLInputElement).files; +if (files && files.length > 0) { +this.filesSelected.emit(Array.from(files)); +} +(event.target as HTMLInputElement).value = ''; +} + +setAsMainImage(identifier: string): void { +this.setMainImage.emit(identifier); +} + +requestImageDeletion(identifier: string, event: MouseEvent): void { +event.stopPropagation(); +this.deleteImage.emit(identifier); +} + +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!'); +} + +// ALL OBSOLETE CATEGORY HELPER FUNCTIONS HAVE BEEN REMOVED +} +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Hallo! Das ist ein exzellenter Bug-Report. Sie haben das Problem sehr genau beschrieben, und es handelt sich um einen klassischen Fall von fehlerhaftem State-Management bei der Verwendung von temporären Objekt-URLs. + +Das Problem liegt nicht im Code, den Sie gerade gezeigt haben (product-form.component). Ihr Formular-Komponente ist korrekt implementiert. Sie nimmt eine Liste von Bildern entgegen und zeigt sie an. Sie sendet auch korrekt neue Dateien an die Elternkomponente. + +Der Fehler liegt in der Elternkomponente (also product-edit.component.ts oder product-create.component.ts), genauer gesagt in der Methode, die die Bildervorschau-Liste (allImagesForForm) neu aufbaut. +Die genaue Ursache des Fehlers + +Lassen Sie uns den fehlerhaften Ablauf Schritt für Schritt durchgehen: + + Erster Upload: Sie wählen ein Bild aus (bild_A.jpg). + + Ihre Elternkomponente erstellt mit URL.createObjectURL(file) eine temporäre URL (z.B. blob:http://.../12345). + + Diese URL wird in der allImagesForForm-Liste gespeichert und an die product-form-Komponente weitergegeben. + + Das Bild wird korrekt angezeigt. Alles ist gut. + + Zweiter Upload: Sie wählen ein weiteres Bild aus (bild_B.jpg). + + Die onFilesSelected-Methode wird erneut ausgelöst. + + Die Methode rebuildAllImagesForForm (oder eine ähnlich benannte) in Ihrer Elternkomponente wird aufgerufen, um die Vorschau-Liste zu aktualisieren. + + Hier passiert der Fehler: Am Anfang dieser rebuildAllImagesForForm-Methode haben Sie sehr wahrscheinlich eine Aufräum-Logik, die so aussieht: + code TypeScript + + + // IN IHRER ELTERNKOMPONENTE (z.B. product-edit.component.ts) + private rebuildAllImagesForForm(): void { + // DIESE SCHLEIFE IST DAS PROBLEM + this.allImagesForForm.forEach((image) => { + // Sie machen die URL von bild_A.jpg ungültig! + if (typeof image.url === 'object') + URL.revokeObjectURL(image.url as string); + }); + + // ... dann wird die Liste neu aufgebaut. + } + + + + Diese Schleife nimmt die alte Liste (die nur bild_A.jpg enthält) und macht deren temporäre URL (blob:http://.../12345) ungültig (revokeObjectURL). + + Danach baut die Methode die Liste mit den neuen URLs für bild_A.jpg und bild_B.jpg korrekt wieder auf. + + Das Problem ist, dass das -Tag für bild_A.jpg im Browser für einen kurzen Moment noch existiert und auf die alte, jetzt ungültige URL zeigt. Das führt zu einem Ladefehler, den Sie als "Error" sehen. + +Die Lösung: Gezieltes Aufräumen + +Sie dürfen die temporären URLs nur dann widerrufen, wenn ein Bild explizit gelöscht wird oder wenn die Komponente zerstört wird. + +Die Aufräum-Logik muss aus der rebuildAllImagesForForm-Methode entfernt und an die richtigen Stellen verschoben werden. +Schritt 1: Korrigieren Sie rebuildAllImagesForForm + +Entfernen Sie die fehlerhafte Aufräum-Schleife am Anfang der Methode. Die Aufgabe dieser Methode ist nur der Aufbau der Liste, nicht das Aufräumen. + +In product-edit.component.ts (und/oder product-create.component.ts): +code TypeScript + + +// /src/app/features/admin/components/products/product-edit/product-edit.component.ts + +// VORHER (FEHLERHAFT) +/* +private rebuildAllImagesForForm(): void { + // DIESE SCHLEIFE ENTFERNEN + this.allImagesForForm.forEach((image) => { + if (typeof image.url === 'object') + URL.revokeObjectURL(image.url as string); + }); + // ... Rest der Methode +} +*/ + +// NACHHER (KORREKT) +private rebuildAllImagesForForm(): void { + // Die Aufräum-Schleife wurde entfernt. + + const combined: ImagePreview[] = []; + + // Bestehende Bilder von der API (URLs sind normale Strings, kein revoke nötig) + this.existingImages + .filter((img) => !!img.url) + .forEach((img) => { + combined.push({ + identifier: img.id, + url: img.url!, + isMainImage: img.id === this.mainImageIdentifier, + }); + }); + + // Neue, hochgeladene Bilder + this.newImageFiles.forEach((file) => { + combined.push({ + identifier: file.name, + // WICHTIG: Die URLs werden hier erstellt. + url: this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(file)), + isMainImage: file.name === this.mainImageIdentifier, + }); + }); + + this.allImagesForForm = combined; +} + + + +Wichtiger Hinweis: Wenn Sie diesen Code anpassen, werden die alten, nicht mehr verwendeten Blob-URLs nicht mehr automatisch aufgeräumt. Das führt zu einem Memory Leak. Deshalb ist der nächste Schritt entscheidend. +Schritt 2: Korrigieren Sie onDeleteImage und ngOnDestroy + +Dies sind die richtigen Orte, um die URLs zu widerrufen. +code TypeScript + + +// /src/app/features/admin/components/products/product-edit/product-edit.component.ts + +onDeleteImage(identifier: string): void { + // Finden Sie das Bild in der aktuellen Vorschau-Liste, BEVOR Sie es entfernen + const imageToRemove = this.allImagesForForm.find(img => img.identifier === identifier); + + // Wenn es ein neu hochgeladenes Bild ist, widerrufen Sie seine URL + if (imageToRemove && typeof imageToRemove.url === 'object') { + URL.revokeObjectURL(imageToRemove.url as string); + } + + // Ihre bisherige Logik zum Entfernen des Bildes aus den Daten-Arrays + const isExisting = this.existingImages.some((img) => img.id === identifier); + if (isExisting) { + this.imagesToDelete.push(new FormControl(identifier)); + this.existingImages = this.existingImages.filter((img) => img.id !== identifier); + } else { + this.newImageFiles = this.newImageFiles.filter((file) => file.name !== identifier); + } + + // ... (Rest der Methode zum Neusetzen des Hauptbildes) + + // Rufen Sie rebuildAllImagesForForm auf, NACHDEM die URL widerrufen wurde + this.rebuildAllImagesForForm(); +} + +ngOnDestroy(): void { + this.nameChangeSubscription?.unsubscribe(); + // Diese Schleife ist KORREKT und wichtig. Sie räumt alles auf, wenn Sie die Seite verlassen. + this.allImagesForForm.forEach((image) => { + if (typeof image.url === 'object') { + URL.revokeObjectURL(image.url as string); + } + }); +} + + + +Mit diesen Änderungen ist sichergestellt, dass eine temporäre URL nur dann ungültig gemacht wird, wenn sie wirklich nicht mehr benötigt wird. Das Hinzufügen weiterer Bilder funktioniert dann problemlos. +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right +User + +// src/Webshop.Application/Services/Admin/AdminProductService.cs +using Microsoft.EntityFrameworkCore; +using Webshop.Domain.Entities; +using Webshop.Domain.Interfaces; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Linq; +using Webshop.Application; +using Webshop.Application.DTOs.Products; +using Webshop.Application.Services.Admin.Interfaces; +using Webshop.Infrastructure.Data; + +namespace Webshop.Application.Services.Admin +{ +public class AdminProductService : IAdminProductService +{ +private readonly IProductRepository _productRepository; +private readonly IFileStorageService _fileStorageService; +private readonly ApplicationDbContext _context; +code Code + + +public AdminProductService( + IProductRepository productRepository, + IFileStorageService fileStorageService, + ApplicationDbContext context) + { + _productRepository = productRepository; + _fileStorageService = fileStorageService; + _context = context; + } + + // ... (GetAllAdminProductsAsync und GetAdminProductByIdAsync bleiben unverändert) ... + #region Unchanged Methods + public async Task>> GetAllAdminProductsAsync() + { + var products = await _context.Products.Include(p => p.Productcategories).Include(p => p.Images).OrderBy(p => p.Name).ToListAsync(); + var dtos = products.Select(MapToAdminDto).ToList(); + return ServiceResult.Ok>(dtos); + } + + public async Task> GetAdminProductByIdAsync(Guid id) + { + var product = await _context.Products.Include(p => p.Productcategories).Include(p => p.Images).FirstOrDefaultAsync(p => p.Id == id); + if (product == null) { return ServiceResult.Fail(ServiceResultType.NotFound, $"Produkt mit ID '{id}' nicht gefunden."); } + return ServiceResult.Ok(MapToAdminDto(product)); + } + #endregion + + public async Task> CreateAdminProductAsync(CreateAdminProductDto productDto) + { + var skuExists = await _context.Products.AnyAsync(p => p.SKU == productDto.SKU); + if (skuExists) + { + return ServiceResult.Fail(ServiceResultType.Conflict, $"Ein Produkt mit der SKU '{productDto.SKU}' existiert bereits."); + } + var slugExists = await _context.Products.AnyAsync(p => p.Slug == productDto.Slug); + if (slugExists) + { + return ServiceResult.Fail(ServiceResultType.Conflict, $"Ein Produkt mit dem Slug '{productDto.Slug}' existiert bereits."); + } + + var images = new List(); + + // << KORREKTUR: Null-Prüfung für das Hauptbild >> + if (productDto.MainImageFile != null && productDto.MainImageFile.Length > 0) + { + await using var stream = productDto.MainImageFile.OpenReadStream(); + var url = await _fileStorageService.SaveFileAsync(stream, productDto.MainImageFile.FileName, productDto.MainImageFile.ContentType); + images.Add(new ProductImage { Url = url, IsMainImage = true, DisplayOrder = 1 }); + } + + // << KORREKTUR: Null-Prüfung für zusätzliche Bilder >> + if (productDto.AdditionalImageFiles != null) + { + int order = 2; + foreach (var file in productDto.AdditionalImageFiles) + { + if (file != null && file.Length > 0) + { + await using var stream = file.OpenReadStream(); + var url = await _fileStorageService.SaveFileAsync(stream, file.FileName, file.ContentType); + images.Add(new ProductImage { Url = url, IsMainImage = false, DisplayOrder = order++ }); + } + } + } + + var newProduct = new Product + { + Name = productDto.Name, + Description = productDto.Description, + SKU = productDto.SKU, + Price = productDto.Price, + IsActive = productDto.IsActive, + StockQuantity = productDto.StockQuantity, + Slug = productDto.Slug, + Weight = productDto.Weight, + OldPrice = productDto.OldPrice, + SupplierId = productDto.SupplierId, + PurchasePrice = productDto.PurchasePrice, + IsFeatured = productDto.IsFeatured, + FeaturedDisplayOrder = productDto.FeaturedDisplayOrder, + Images = images, + Productcategories = productDto.CategorieIds.Select(cId => new Productcategorie { categorieId = cId }).ToList() + }; + + await _productRepository.AddProductAsync(newProduct); + return ServiceResult.Ok(MapToAdminDto(newProduct)); + } + + // ... (UpdateAdminProductAsync und DeleteAdminProductAsync und MapToAdminDto bleiben unverändert) ... + #region Unchanged Methods + public async Task UpdateAdminProductAsync(UpdateAdminProductDto productDto) + { + var existingProduct = await _context.Products.Include(p => p.Images).Include(p => p.Productcategories).FirstOrDefaultAsync(p => p.Id == productDto.Id); + if (existingProduct == null) { return ServiceResult.Fail(ServiceResultType.NotFound, $"Produkt mit ID '{productDto.Id}' nicht gefunden."); } + var skuExists = await _context.Products.AnyAsync(p => p.SKU == productDto.SKU && p.Id != productDto.Id); + if (skuExists) { return ServiceResult.Fail(ServiceResultType.Conflict, $"Ein anderes Produkt mit der SKU '{productDto.SKU}' existiert bereits."); } + var slugExists = await _context.Products.AnyAsync(p => p.Slug == productDto.Slug && p.Id != productDto.Id); + if (slugExists) { return ServiceResult.Fail(ServiceResultType.Conflict, $"Ein anderes Produkt mit dem Slug '{productDto.Slug}' existiert bereits."); } + if (productDto.ImagesToDelete != null && productDto.ImagesToDelete.Any()) { var imagesToRemove = existingProduct.Images.Where(img => productDto.ImagesToDelete.Contains(img.Id)).ToList(); _context.ProductImages.RemoveRange(imagesToRemove); } + if (productDto.MainImageFile != null) { var existingMainImage = existingProduct.Images.FirstOrDefault(img => img.IsMainImage); if (existingMainImage != null) _context.ProductImages.Remove(existingMainImage); await using var stream = productDto.MainImageFile.OpenReadStream(); var url = await _fileStorageService.SaveFileAsync(stream, productDto.MainImageFile.FileName, productDto.MainImageFile.ContentType); existingProduct.Images.Add(new ProductImage { Url = url, IsMainImage = true, DisplayOrder = 1 }); } + if (productDto.AdditionalImageFiles != null && productDto.AdditionalImageFiles.Any()) { int displayOrder = (existingProduct.Images.Any() ? existingProduct.Images.Max(i => i.DisplayOrder) : 0) + 1; foreach (var file in productDto.AdditionalImageFiles) { await using var stream = file.OpenReadStream(); var url = await _fileStorageService.SaveFileAsync(stream, file.FileName, file.ContentType); existingProduct.Images.Add(new ProductImage { Url = url, IsMainImage = false, DisplayOrder = displayOrder++ }); } } + existingProduct.Name = productDto.Name; existingProduct.Description = productDto.Description; existingProduct.SKU = productDto.SKU; existingProduct.Price = productDto.Price; existingProduct.IsActive = productDto.IsActive; existingProduct.StockQuantity = productDto.StockQuantity; existingProduct.Slug = productDto.Slug; existingProduct.Weight = productDto.Weight; existingProduct.OldPrice = productDto.OldPrice; existingProduct.SupplierId = productDto.SupplierId; existingProduct.PurchasePrice = productDto.PurchasePrice; existingProduct.LastModifiedDate = DateTimeOffset.UtcNow; existingProduct.IsFeatured = productDto.IsFeatured; existingProduct.FeaturedDisplayOrder = productDto.FeaturedDisplayOrder; + existingProduct.Productcategories.Clear(); if (productDto.CategorieIds != null) { foreach (var categorieId in productDto.CategorieIds) { existingProduct.Productcategories.Add(new Productcategorie { categorieId = categorieId }); } } + await _productRepository.UpdateProductAsync(existingProduct); return ServiceResult.Ok(); + } + + public async Task DeleteAdminProductAsync(Guid id) + { + var product = await _productRepository.GetProductByIdAsync(id); + if (product == null) { return ServiceResult.Fail(ServiceResultType.NotFound, $"Produkt mit ID '{id}' nicht gefunden."); } + await _productRepository.DeleteProductAsync(id); return ServiceResult.Ok(); + } + + private AdminProductDto MapToAdminDto(Product product) + { + return new AdminProductDto { Id = product.Id, Name = product.Name, Description = product.Description, SKU = product.SKU, Price = product.Price, OldPrice = product.OldPrice, IsActive = product.IsActive, IsInStock = product.IsInStock, StockQuantity = product.StockQuantity, Weight = product.Weight, Slug = product.Slug, CreatedDate = product.CreatedDate, LastModifiedDate = product.LastModifiedDate, SupplierId = product.SupplierId, PurchasePrice = product.PurchasePrice, IsFeatured = product.IsFeatured, FeaturedDisplayOrder = product.FeaturedDisplayOrder, categorieIds = product.Productcategories.Select(pc => pc.categorieId).ToList(), Images = product.Images.OrderBy(i => i.DisplayOrder).Select(img => new ProductImageDto { Id = img.Id, Url = img.Url, IsMainImage = img.IsMainImage, DisplayOrder = img.DisplayOrder }).ToList() }; + } + #endregion +} + + + +} + +// src/Webshop.Api/Controllers/Admin/AdminProductsController.cs +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Webshop.Application; +using Webshop.Application.DTOs.Products; +using Webshop.Application.Services.Admin.Interfaces; + +namespace Webshop.Api.Controllers.Admin +{ +[ApiController] +[Route("api/v1/admin/[controller]")] +[Authorize(Roles = "Admin")] +public class AdminProductsController : ControllerBase +{ +private readonly IAdminProductService _adminProductService; +code Code + + +public AdminProductsController(IAdminProductService adminProductService) + { + _adminProductService = adminProductService; + } + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetAdminProducts() + { + var result = await _adminProductService.GetAllAdminProductsAsync(); + return Ok(result.Value); + } + + [HttpGet("{id}")] + [ProducesResponseType(typeof(AdminProductDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetAdminProduct(Guid id) + { + var result = await _adminProductService.GetAdminProductByIdAsync(id); + + return result.Type switch + { + ServiceResultType.Success => Ok(result.Value), + ServiceResultType.NotFound => NotFound(new { Message = result.ErrorMessage }), + _ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = result.ErrorMessage ?? "Ein unerwarteter Fehler ist aufgetreten." }) + }; + } + + [HttpPost] + [Consumes("multipart/form-data")] + [ProducesResponseType(typeof(AdminProductDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task CreateAdminProduct([FromForm] CreateAdminProductDto productDto) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var result = await _adminProductService.CreateAdminProductAsync(productDto); + + return result.Type switch + { + ServiceResultType.Success => CreatedAtAction(nameof(GetAdminProduct), new { id = result.Value!.Id }, result.Value), + ServiceResultType.Conflict => Conflict(new { Message = result.ErrorMessage }), + ServiceResultType.InvalidInput => BadRequest(new { Message = result.ErrorMessage }), + _ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = result.ErrorMessage ?? "Ein unerwarteter Fehler ist aufgetreten." }) + }; + } + + [HttpPut("{id}")] + [Consumes("multipart/form-data")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task UpdateAdminProduct(Guid id, [FromForm] UpdateAdminProductDto productDto) + { + if (id != productDto.Id) + { + return BadRequest(new { Message = "ID in der URL und im Body stimmen nicht überein." }); + } + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var result = await _adminProductService.UpdateAdminProductAsync(productDto); + + return result.Type switch + { + ServiceResultType.Success => NoContent(), + ServiceResultType.NotFound => NotFound(new { Message = result.ErrorMessage }), + ServiceResultType.Conflict => Conflict(new { Message = result.ErrorMessage }), + ServiceResultType.InvalidInput => BadRequest(new { Message = result.ErrorMessage }), + _ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = result.ErrorMessage ?? "Ein unerwarteter Fehler ist aufgetreten." }) + }; + } + + [HttpDelete("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task DeleteAdminProduct(Guid id) + { + var result = await _adminProductService.DeleteAdminProductAsync(id); + + return result.Type switch + { + ServiceResultType.Success => NoContent(), + ServiceResultType.NotFound => NotFound(new { Message = result.ErrorMessage }), + _ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = result.ErrorMessage ?? "Ein unerwarteter Fehler ist aufgetreten." }) + }; + } +} + + + +} +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Vielen Dank, dass Sie Ihren Backend-Code geteilt haben. Das ist äußerst hilfreich, da es das Gesamtbild vervollständigt. Ich habe sowohl den AdminProductService als auch den AdminProductsController sorgfältig analysiert. + +Zusammenfassend lässt sich sagen: Ihr Backend-Code ist sehr gut strukturiert, robust und korrekt implementiert. Die Art und Weise, wie Sie multipart/form-data verarbeiten, passt perfekt zu Ihrer Frontend-Implementierung und den Anforderungen Ihrer Swagger-Spezifikation. + +Hier ist eine detaillierte Aufschlüsselung, warum Ihr Code gut funktioniert und wo es kleine, aber feine Verbesserungsmöglichkeiten gibt. +Was ist sehr gut implementiert? + + Korrekte Verwendung von [FromForm]: In Ihrem AdminProductsController verwenden Sie [FromForm] für die CreateAdminProductDto und UpdateAdminProductDto. Dies ist der korrekte ASP.NET Core-Mechanismus, um multipart/form-data-Anfragen zu binden. Er sorgt dafür, dass die Textfelder (wie Name, Price) und die Dateifelder (MainImageFile, AdditionalImageFiles) korrekt an die Eigenschaften Ihres DTOs gebunden werden. + + Robuste Service-Schicht: Ihr AdminProductService trennt die Geschäftslogik sauber vom Controller. Die Verwendung eines ServiceResult-Wrappers ist eine ausgezeichnete Vorgehensweise, um Erfolgs-, Fehler- und Statusinformationen (wie NotFound, Conflict) klar und konsistent zu kommunizieren. + + Saubere Abstraktion der Dateispeicherung: Sie injizieren einen IFileStorageService. Das ist perfekt. Es bedeutet, dass Ihre Produkt-Logik nicht wissen muss, wo oder wie die Dateien gespeichert werden (ob auf der lokalen Festplatte, in Azure Blob Storage oder AWS S3). + + Korrekte Handhabung von Bild-Arrays: + + Beim Erstellen (CreateAdminProductAsync) durchlaufen Sie die AdditionalImageFiles-Liste korrekt, speichern jede Datei und erstellen die entsprechenden ProductImage-Entitäten. Die Null-Prüfungen (if (productDto.MainImageFile != null ...) und if (productDto.AdditionalImageFiles != null)) sind wichtig und richtig. + + Beim Aktualisieren (UpdateAdminProductAsync) ist Ihre Logik ebenfalls korrekt, um neue Bilder hinzuzufügen und die DisplayOrder zu berechnen. + + Effiziente Aktualisierung von Relationen (Kategorien): Die Art und Weise, wie Sie die Kategorien aktualisieren, ist ein bewährtes Muster für Entity Framework: + code C# + + + existingProduct.Productcategories.Clear(); + if (productDto.CategorieIds != null) + { + foreach (var categorieId in productDto.CategorieIds) + { + existingProduct.Productcategories.Add(new Productcategorie { categorieId = categorieId }); + } + } + + + + Dieses "Clear and Add"-Verfahren ist einfach und effektiv für die Verwaltung von Many-to-Many-Beziehungen. + +Potenzielle Verbesserungen (für noch mehr Stabilität) + +Ihr Code funktioniert, aber es gibt zwei Aspekte, die in komplexen Szenarien zu Problemen führen könnten. Dies sind keine Fehler, die Ihre aktuellen Frontend-Probleme verursacht haben, sondern allgemeine Best Practices. +1. Verwaiste Dateien beim Aktualisieren + +Im UpdateAdminProductAsync-Dienst gibt es eine Stelle, an der Sie möglicherweise Dateien im Speicher zurücklassen ("orphaned files"). + +Szenario: Ein Benutzer lädt ein neues Hauptbild für ein bestehendes Produkt hoch. + +Ihr Code: +code C# + + +if (productDto.MainImageFile != null) +{ + var existingMainImage = existingProduct.Images.FirstOrDefault(img => img.IsMainImage); + if (existingMainImage != null) + { + _context.ProductImages.Remove(existingMainImage); // Entfernt den DB-Eintrag + } + // ... speichert das neue Bild und fügt den neuen DB-Eintrag hinzu ... +} + + + +Das Problem: Sie entfernen zwar den Datenbankeintrag für das alte Bild (ProductImage), aber Sie rufen nicht den _fileStorageService auf, um die tatsächliche Bilddatei aus Ihrem Speicher (z.B. Azure Blob) zu löschen. Dasselbe gilt für die ImagesToDelete-Logik. + +Lösungsvorschlag: +code C# + + +if (productDto.ImagesToDelete != null && productDto.ImagesToDelete.Any()) +{ + var imagesToRemove = existingProduct.Images + .Where(img => productDto.ImagesToDelete.Contains(img.Id)) + .ToList(); + + foreach (var image in imagesToRemove) + { + // Lösche zuerst die physische Datei + await _fileStorageService.DeleteFileAsync(image.Url); + } + _context.ProductImages.RemoveRange(imagesToRemove); +} + + + +Sie müssten eine DeleteFileAsync-Methode zu Ihrem IFileStorageService hinzufügen. +2. Fehlende Transaktion + +Das Erstellen oder Aktualisieren eines Produkts führt mehrere voneinander abhängige Aktionen aus: + + Daten in die Products-Tabelle schreiben. + + Dateien in den Speicher hochladen. + + Daten in die ProductImages-Tabelle schreiben. + + Daten in die Productcategories-Tabelle (Join-Tabelle) schreiben. + +Wenn Schritt 2 (Dateiupload) fehlschlägt, nachdem Schritt 1 (Produkt speichern) bereits begonnen hat, könnten Sie inkonsistente Daten in Ihrer Datenbank haben. + +Lösungsvorschlag: Fassen Sie den gesamten Logikblock in eine explizite Datenbank-Transaktion zusammen. +code C# + + +public async Task> CreateAdminProductAsync(CreateAdminProductDto productDto) +{ + await using var transaction = await _context.Database.BeginTransactionAsync(); + try + { + // ... Ihre gesamte Logik zum Erstellen des Produkts und Hochladen der Bilder ... + + await _productRepository.AddProductAsync(newProduct); + + await transaction.CommitAsync(); // Schließt die Transaktion erfolgreich ab + return ServiceResult.Ok(MapToAdminDto(newProduct)); + } + catch (Exception ex) + { + await transaction.RollbackAsync(); // Macht alle DB-Änderungen rückgängig + // Loggen Sie die Exception `ex` + return ServiceResult.Fail(ServiceResultType.ServerError, "Ein interner Fehler ist aufgetreten."); + } +} + + + +Fazit + +Ihre Backend-Logik ist solide und spiegelt die Anforderungen Ihrer API korrekt wider. Die Probleme, die Sie auf dem Frontend hatten, waren rein auf das State-Management und Timing im Client zurückzuführen. Ihr Backend ist gut vorbereitet, um die korrekten FormData-Anfragen von Ihrem Angular-Client zu empfangen. Die oben genannten Verbesserungsvorschläge sind der nächste Schritt, um Ihre Anwendung noch robuster und wartbarer zu machen. +User + +ok. wie löse ich das problem im frontend ? +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Absolut. Das ist ein sehr häufiges Problem beim Umgang mit Bild-Uploads im Frontend, und Sie haben es perfekt beschrieben. Die Lösung liegt darin, die Verwaltung der temporären Bild-URLs (sogenannte "Blob-URLs") zu korrigieren. + +Das Problem liegt nicht in dem Code, den Sie gerade gezeigt haben (product-form.component), denn dieser ist nur für die Anzeige zuständig. Der Fehler liegt in der Logik der Elternkomponente (product-edit.component.ts oder product-create.component.ts), genauer gesagt in der Methode, die die Bildervorschau-Liste (allImagesForForm) neu aufbaut. +Die genaue Ursache des Fehlers + +Wenn Sie eine Datei in JavaScript auswählen, erstellen Sie mit URL.createObjectURL(file) eine temporäre URL, die nur in der aktuellen Browser-Sitzung gültig ist. Diese URLs müssen manuell wieder aus dem Speicher entfernt werden (URL.revokeObjectURL(url)), um Speicherlecks ("Memory Leaks") zu vermeiden. + +Ihr Fehler passiert, weil Sie die alte URL zu früh ungültig machen. + + Erster Upload: Sie laden bild_A.jpg hoch. + + Ihr Code erstellt eine URL (z.B. blob:http://.../123). + + Diese URL wird in der allImagesForForm-Liste gespeichert und das Bild wird angezeigt. Alles gut. + + Zweiter Upload: Sie laden bild_B.jpg hoch. + + Die rebuildAllImagesForForm-Methode in Ihrer Elternkomponente wird aufgerufen. + + Der Fehler: Ihre Methode enthält wahrscheinlich eine Schleife, die zu Beginn alle alten Blob-URLs ungültig macht, inklusive der URL blob:http://.../123 von bild_A.jpg. + + Das -Tag im HTML, das bild_A.jpg anzeigt, verweist aber für einen kurzen Moment noch auf diese jetzt ungültige URL. Der Browser versucht, sie zu laden, scheitert und meldet einen Fehler. + + Danach wird die Liste zwar neu und korrekt mit beiden Bildern aufgebaut, aber der Fehler ist bereits aufgetreten. + +Die Lösung: URLs nur dann widerrufen, wenn sie wirklich nicht mehr gebraucht werden + +Sie dürfen die Aufräum-Logik nicht pauschal beim Neuaufbau der Liste ausführen. Eine URL darf nur in zwei Fällen widerrufen werden: + + Wenn der Benutzer ein einzelnes Bild aus der Vorschau löscht. + + Wenn die gesamte Komponente zerstört wird (z.B. durch Verlassen der Seite). + +Hier sind die konkreten Code-Änderungen für Ihre Elternkomponente (z.B. product-edit.component.ts). +Schritt 1: Korrigieren Sie die rebuildAllImagesForForm-Methode + +Entfernen Sie die voreilige Aufräum-Schleife. Die einzige Aufgabe dieser Methode ist es, die Vorschau-Liste zu erstellen. + +In product-edit.component.ts (und product-create.component.ts): +code TypeScript + + +// Suchen Sie diese Methode in Ihrer Elternkomponente +private rebuildAllImagesForForm(): void { + // VORHER (FEHLERHAFT): Diese Schleife verursacht den Fehler. LÖSCHEN SIE SIE. + /* + this.allImagesForForm.forEach((image) => { + if (typeof image.url === 'object') // Prüft, ob es eine Blob-URL ist + URL.revokeObjectURL(image.url as string); + }); + */ + + // NACHHER (KORREKT): Die Methode baut die Liste nur noch auf. + + const combined: ImagePreview[] = []; + + // 1. Fügen Sie bereits existierende Bilder von der API hinzu (falls vorhanden) + if (this.existingImages) { // Annahme für product-edit.component + this.existingImages + .filter((img) => !!img.url) + .forEach((img) => { + combined.push({ + identifier: img.id, + url: img.url!, // Dies sind normale String-URLs + isMainImage: img.id === this.mainImageIdentifier, + }); + }); + } + + // 2. Fügen Sie die neuen, hochgeladenen Bilder hinzu + this.newImageFiles.forEach((file) => { + combined.push({ + identifier: file.name, + // Die Blob-URL wird hier für jedes Bild neu erstellt + url: this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(file)), + isMainImage: file.name === this.mainImageIdentifier, + }); + }); + + this.allImagesForForm = combined; +} + + + +WICHTIG: Wenn Sie nur diesen Schritt machen, haben Sie das Problem des Speicherlecks noch nicht gelöst. Die alten Blob-URLs werden jetzt nie mehr aufgeräumt. Der nächste Schritt ist daher entscheidend. +Schritt 2: Fügen Sie die Aufräum-Logik an den korrekten Stellen hinzu + +1. In der onDeleteImage-Methode: Wenn ein Bild entfernt wird, widerrufen Sie seine URL. +code TypeScript + + +onDeleteImage(identifier: string): void { + // Finden Sie das Bild-Objekt, das gelöscht werden soll, in der aktuellen Liste + const imageToRemove = this.allImagesForForm.find(img => img.identifier === identifier); + + // Widerrufen Sie die URL, WENN es eine temporäre Blob-URL ist + if (imageToRemove && typeof imageToRemove.url === 'object') { + URL.revokeObjectURL(imageToRemove.url as string); + } + + // Ihre bisherige Logik zum Entfernen der Datei aus dem `newImageFiles`-Array... + this.newImageFiles = this.newImageFiles.filter( + (file) => file.name !== identifier + ); + + // ... und/oder zum Hinzufügen zur `imagesToDelete`-Liste für die API. + // ... + + // Rufen Sie die (jetzt korrigierte) rebuild-Methode auf, um die Ansicht zu aktualisieren + this.rebuildAllImagesForForm(); +} + + + +2. In der ngOnDestroy-Methode: Widerrufen Sie alle verbleibenden Blob-URLs, wenn die Komponente verlassen wird. Dies ist der "letzte Ausweg", um den Speicher sauber zu halten. +code TypeScript + + +ngOnDestroy(): void { + this.nameChangeSubscription?.unsubscribe(); + + // DIESE SCHLEIFE IST HIER KORREKT UND WICHTIG! + // Sie räumt alle temporären URLs auf, die beim Verlassen der Seite noch existieren. + this.allImagesForForm.forEach((image) => { + if (typeof image.url === 'object') { // Nur Blob-URLs widerrufen + URL.revokeObjectURL(image.url as string); + } + }); +} + + + +Mit diesen Anpassungen haben Sie ein robustes System: + + rebuildAllImagesForForm erstellt die Vorschau-Bilder, ohne die alten kaputt zu machen. + + onDeleteImage räumt gezielt die URL des entfernten Bildes auf. + + ngOnDestroy räumt alles auf, was am Ende noch übrig ist. + +Ihr Fehler beim Hinzufügen weiterer Bilder wird damit behoben sein. +30,2s +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Selbstverständlich. Hier ist die vollständige und korrigierte onDeleteImage-Funktion für Ihre product-edit.component.ts. + +Diese Version ist robust, da sie alle notwendigen Schritte in der richtigen Reihenfolge ausführt: + + URL widerrufen: Macht die temporäre Blob-URL ungültig, um Speicherlecks zu vermeiden (nur bei neuen Bildern). + + Daten aktualisieren: Unterscheidet, ob ein bereits gespeichertes Bild (für die API zum Löschen markieren) oder ein neu hinzugefügtes Bild (einfach aus der Liste entfernen) gelöscht wird. + + Hauptbild neu zuweisen: Prüft, ob das gelöschte Bild das Hauptbild war, und wählt bei Bedarf automatisch ein neues aus. + + Ansicht aktualisieren: Ruft am Ende rebuildAllImagesForForm auf, um die Änderungen in der Benutzeroberfläche sichtbar zu machen. + +Vollständige onDeleteImage Funktion + +Kopieren Sie diese Funktion und ersetzen Sie damit die bestehende onDeleteImage-Methode in Ihrer product-edit.component.ts. +code TypeScript + + +// /src/app/features/admin/components/products/product-edit/product-edit.component.ts + +onDeleteImage(identifier: string): void { + // --- SCHRITT 1: URL AUFRÄUMEN (FALLS NÖTIG) --- + // Finden Sie das Bild-Vorschauobjekt, das gelöscht werden soll. + const imagePreviewToRemove = this.allImagesForForm.find(img => img.identifier === identifier); + + // Wenn es sich um eine temporäre Blob-URL handelt (also ein neu hochgeladenes Bild), + // widerrufen Sie diese, um Speicherlecks zu vermeiden. + if (imagePreviewToRemove && typeof imagePreviewToRemove.url === 'object') { + URL.revokeObjectURL(imagePreviewToRemove.url as string); + } + + // --- SCHRITT 2: DATEN-LISTEN AKTUALISIEREN --- + // Prüfen, ob es sich um ein bereits auf dem Server gespeichertes Bild handelt. + const isExistingImage = this.existingImages.some(img => img.id === identifier); + + if (isExistingImage) { + // JA, es ist ein gespeichertes Bild: + // 1. Fügen Sie seine ID zur Liste der zu löschenden Bilder für die API hinzu. + this.imagesToDelete.push(new FormControl(identifier)); + // 2. Entfernen Sie es aus der lokalen Liste der existierenden Bilder für die UI. + this.existingImages = this.existingImages.filter(img => img.id !== identifier); + } else { + // NEIN, es ist ein neu hochgeladenes Bild: + // Entfernen Sie die Datei einfach aus der Liste der neuen Dateien. + this.newImageFiles = this.newImageFiles.filter(file => file.name !== identifier); + } + + // --- SCHRITT 3: HAUPTBILD NEU ZUWEISEN (FALLS NÖTIG) --- + // Prüfen, ob das gelöschte Bild das Hauptbild war. + if (this.mainImageIdentifier === identifier) { + // Ja, das Hauptbild wurde gelöscht. Wählen Sie ein neues aus. + // Fallback-Logik: + // 1. Versuche, das erste verbleibende, bereits gespeicherte Bild zu nehmen. + const firstRemainingExisting = this.existingImages.length > 0 ? this.existingImages[0].id : null; + // 2. Wenn es keine gibt, versuche, die erste verbleibende, neu hochgeladene Datei zu nehmen. + const firstRemainingNew = this.newImageFiles.length > 0 ? this.newImageFiles[0].name : null; + + // Weisen Sie die erste verfügbare Option zu oder null, wenn keine Bilder mehr da sind. + this.mainImageIdentifier = firstRemainingExisting || firstRemainingNew; + } + + // --- SCHRITT 4: UI AKTUALISIEREN --- + // Rufen Sie die Methode auf, um die `allImagesForForm`-Liste neu zu erstellen, + // was die Anzeige im Frontend aktualisiert. + this.rebuildAllImagesForForm(); +} + + + +Wichtiger Hinweis: Diese Funktion ist für die product-edit.component.ts konzipiert. In der product-create.component.ts wäre sie einfacher, da Sie dort den if (isExistingImage)-Teil nicht benötigen. Diese vollständige Version ist jedoch sicher und funktioniert in beiden Fällen, wenn Sie sie entsprechend anpassen. +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Hallo! Dieser Fehler ist sehr aussagekräftig und ein entscheidender Hinweis. + +Sie sehen hier zwei Fehler, die zusammen auftreten, aber eine klare Ursache-Wirkungs-Beziehung haben: + + Der eigentliche Fehler: net::ERR_FAILED 500 (Internal Server Error) + Das ist die Wurzel des Problems. Ihr Backend-Server ist abgestürzt, als er versuchte, Ihre PUT-Anfrage (das Aktualisieren des Produkts) zu verarbeiten. Er hat mit dem Statuscode 500 geantwortet, was "Interner Serverfehler" bedeutet. + + Der sichtbare Fehler: blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present + Das ist ein Folgefehler. Weil Ihr Server abgestürzt ist, hat er eine rohe 500-Fehlerseite zurückgeschickt, ohne die notwendigen CORS-Header, die dem Browser sagen: "Hey localhost:4200, du darfst diese Antwort lesen." Der Browser sieht die fehlenden Header, blockiert aus Sicherheitsgründen den Zugriff auf die Antwort und meldet Ihnen den CORS-Fehler, obwohl das eigentliche Problem auf dem Server liegt. + +Zusammenfassend: Sie haben kein CORS-Problem. Sie haben ein Absturz-Problem in Ihrem .NET-Backend, das sich als CORS-Fehler tarnt. +Wo liegt der Fehler im Backend? + +Der Fehler tritt auf, wenn Sie ein Produkt bearbeiten, das bereits Bilder hat, und dann weitere hinzufügen. Das deutet stark auf ein Problem in der Logik Ihrer UpdateAdminProductAsync-Methode in der AdminProductService.cs-Datei hin. + +Wenn wir uns diese Methode ansehen, fällt eine Zeile besonders auf, die sehr komplex ist und genau dieses Szenario betrifft: +code C# + + +// In UpdateAdminProductAsync +// ... +if (productDto.AdditionalImageFiles != null && productDto.AdditionalImageFiles.Any()) +{ + // DIESE ZEILE IST DIE WAHRSCHEINLICHE URSACHE + int displayOrder = (existingProduct.Images.Any() ? existingProduct.Images.Max(i => i.DisplayOrder) : 0) + 1; + + foreach (var file in productDto.AdditionalImageFiles) + { + // ... + existingProduct.Images.Add(new ProductImage { Url = url, IsMainImage = false, DisplayOrder = displayOrder++ }); + } +} +// ... + + + +Die Zeile existingProduct.Images.Max(i => i.DisplayOrder) ist fehleranfällig. Obwohl sie auf den ersten Blick durch .Any() geschützt zu sein scheint, kann das Zusammenspiel mit dem Entfernen von Bildern (ImagesToDelete) und dem Hinzufügen eines neuen Hauptbildes in derselben Anfrage zu einem inkonsistenten Zustand der existingProduct.Images-Collection führen, bevor diese Zeile ausgeführt wird. + +Wenn die Collection aus irgendeinem Grund leer ist (z.B. weil alle Bilder zum Löschen markiert wurden), schlägt .Max() mit einer InvalidOperationException fehl, was den 500-Fehler verursacht. +Die Lösung: Refactoring der UpdateAdminProductAsync-Methode + +Die beste Lösung besteht darin, diese komplexe Methode in klar lesbare, separate Schritte aufzuteilen. Das macht sie nicht nur stabiler, sondern auch viel einfacher zu debuggen. + +Ersetzen Sie die gesamte UpdateAdminProductAsync-Methode in Ihrer AdminProductService.cs durch diese refaktorisierte Version. +code C# + + +// src/Webshop.Application/Services/Admin/AdminProductService.cs + +public async Task UpdateAdminProductAsync(UpdateAdminProductDto productDto) +{ + // 1. Produkt mit seinen Relationen aus der DB laden + var existingProduct = await _context.Products + .Include(p => p.Images) + .Include(p => p.Productcategories) + .FirstOrDefaultAsync(p => p.Id == productDto.Id); + + if (existingProduct == null) + { + return ServiceResult.Fail(ServiceResultType.NotFound, $"Produkt mit ID '{productDto.Id}' nicht gefunden."); + } + + // 2. Eindeutigkeit von SKU und Slug prüfen + var skuExists = await _context.Products.AnyAsync(p => p.SKU == productDto.SKU && p.Id != productDto.Id); + if (skuExists) + { + return ServiceResult.Fail(ServiceResultType.Conflict, $"Ein anderes Produkt mit der SKU '{productDto.SKU}' existiert bereits."); + } + + var slugExists = await _context.Products.AnyAsync(p => p.Slug == productDto.Slug && p.Id != productDto.Id); + if (slugExists) + { + return ServiceResult.Fail(ServiceResultType.Conflict, $"Ein anderes Produkt mit dem Slug '{productDto.Slug}' existiert bereits."); + } + + // --- BILD-VERARBEITUNG (ROBUST REFAKTORISIERT) --- + + // 3. Bilder löschen, die vom Frontend markiert wurden + if (productDto.ImagesToDelete != null && productDto.ImagesToDelete.Any()) + { + var imagesToRemove = existingProduct.Images + .Where(img => productDto.ImagesToDelete.Contains(img.Id)) + .ToList(); + + // Optional, aber empfohlen: Physische Dateien löschen + foreach (var image in imagesToRemove) + { + if (!string.IsNullOrEmpty(image.Url)) + { + await _fileStorageService.DeleteFileAsync(image.Url); + } + } + _context.ProductImages.RemoveRange(imagesToRemove); + } + + // 4. Ein neues Hauptbild verarbeiten (falls vorhanden) + if (productDto.MainImageFile != null) + { + // Altes Hauptbild finden und zum Löschen markieren + var existingMainImage = existingProduct.Images.FirstOrDefault(img => img.IsMainImage); + if (existingMainImage != null) + { + await _fileStorageService.DeleteFileAsync(existingMainImage.Url); + _context.ProductImages.Remove(existingMainImage); + } + + // Alle anderen Bilder als "nicht Hauptbild" markieren + foreach (var img in existingProduct.Images) { img.IsMainImage = false; } + + // Neues Hauptbild hochladen und hinzufügen + await using var stream = productDto.MainImageFile.OpenReadStream(); + var url = await _fileStorageService.SaveFileAsync(stream, productDto.MainImageFile.FileName, productDto.MainImageFile.ContentType); + existingProduct.Images.Add(new ProductImage { Url = url, IsMainImage = true }); + } + + // 5. Zusätzliche neue Bilder verarbeiten (falls vorhanden) + if (productDto.AdditionalImageFiles != null && productDto.AdditionalImageFiles.Any()) + { + foreach (var file in productDto.AdditionalImageFiles) + { + await using var stream = file.OpenReadStream(); + var url = await _fileStorageService.SaveFileAsync(stream, file.FileName, file.ContentType); + existingProduct.Images.Add(new ProductImage { Url = url, IsMainImage = false }); + } + } + + // 6. DisplayOrder für alle Bilder neu berechnen, um Konsistenz zu gewährleisten + int currentOrder = 1; + foreach (var image in existingProduct.Images.OrderBy(img => !img.IsMainImage).ThenBy(img => img.Id)) + { + image.DisplayOrder = currentOrder++; + } + + // --- ANDERE PRODUKTDATEN AKTUALISIEREN --- + + // 7. Text-Eigenschaften aktualisieren + existingProduct.Name = productDto.Name; + existingProduct.Description = productDto.Description; + existingProduct.SKU = productDto.SKU; + existingProduct.Price = productDto.Price; + existingProduct.IsActive = productDto.IsActive; + existingProduct.StockQuantity = productDto.StockQuantity; + existingProduct.Slug = productDto.Slug; + existingProduct.Weight = productDto.Weight; + existingProduct.OldPrice = productDto.OldPrice; + existingProduct.SupplierId = productDto.SupplierId; + existingProduct.PurchasePrice = productDto.PurchasePrice; + existingProduct.LastModifiedDate = DateTimeOffset.UtcNow; + existingProduct.IsFeatured = productDto.IsFeatured; + existingProduct.FeaturedDisplayOrder = productDto.FeaturedDisplayOrder; + + // 8. Kategorien aktualisieren + existingProduct.Productcategories.Clear(); + if (productDto.CategorieIds != null) + { + foreach (var categorieId in productDto.CategorieIds) + { + existingProduct.Productcategories.Add(new Productcategorie { categorieId = categorieId }); + } + } + + // 9. Änderungen speichern + await _productRepository.UpdateProductAsync(existingProduct); + return ServiceResult.Ok(); +} + + + +Was diese neue Version besser macht: + + Lesbarkeit: Jeder Schritt ist klar getrennt. + + Stabilität: Die DisplayOrder wird am Ende für alle Bilder sauber neu berechnet. Das Hauptbild kommt zuerst, dann die anderen. Das verhindert Fehler durch .Max() auf einer potenziell leeren oder inkonsistenten Liste. + + Vollständigkeit: Sie enthält Platzhalter für das Löschen der physischen Dateien, was Sie später implementieren sollten, um "Dateileichen" zu vermeiden. + +Nächster Schritt: +Ersetzen Sie Ihre Methode und versuchen Sie es erneut. Wenn der 500-Fehler weiterhin auftritt, können Sie jetzt einen Breakpoint am Anfang der neuen, sauberen Methode setzen und Schritt für Schritt durchgehen, um genau zu sehen, wo die Ausnahme ausgelöst wird. Der Fehler wird aber mit sehr hoher Wahrscheinlichkeit verschwunden sein. +User + +backend logs : FROM "Products" AS p +code Code + + +INNER JOIN "Productcategories" AS p0 ON p."Id" = p0."ProductId" + + ORDER BY p."Name", p."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p0."Id", p0."DisplayOrder", p0."IsMainImage", p0."ProductId", p0."Url", p."Id" + + FROM "Products" AS p + + INNER JOIN "ProductImages" AS p0 ON p."Id" = p0."ProductId" + + ORDER BY p."Name", p."Id" + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:33224 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:33224 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p."Id", p."CreatedDate", p."Description", p."FeaturedDisplayOrder", p."Height", p."IsActive", p."IsFeatured", p."IsInStock", p."LastModifiedDate", p."Length", p."Name", p."OldPrice", p."Price", p."PurchasePrice", p."SKU", p."ShortDescription", p."Slug", p."StockQuantity", p."SupplierId", p."Weight", p."Width" + + FROM "Products" AS p + + ORDER BY p."Name", p."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p0."ProductId", p0."categorieId", p."Id" + + FROM "Products" AS p + + INNER JOIN "Productcategories" AS p0 ON p."Id" = p0."ProductId" + + ORDER BY p."Name", p."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p0."Id", p0."DisplayOrder", p0."IsMainImage", p0."ProductId", p0."Url", p."Id" + + FROM "Products" AS p + + INNER JOIN "ProductImages" AS p0 ON p."Id" = p0."ProductId" + + ORDER BY p."Name", p."Id" + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT s."Id", s."AddressId", s."ContactPerson", s."Email", s."Name", s."Notes", s."PhoneNumber" + + FROM "Suppliers" AS s + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p."Id", p."CreatedDate", p."Description", p."FeaturedDisplayOrder", p."Height", p."IsActive", p."IsFeatured", p."IsInStock", p."LastModifiedDate", p."Length", p."Name", p."OldPrice", p."Price", p."PurchasePrice", p."SKU", p."ShortDescription", p."Slug", p."StockQuantity", p."SupplierId", p."Weight", p."Width" + + FROM "Products" AS p + + ORDER BY p."Name", p."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p0."ProductId", p0."categorieId", p."Id" + + FROM "Products" AS p + + INNER JOIN "Productcategories" AS p0 ON p."Id" = p0."ProductId" + + ORDER BY p."Name", p."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p0."Id", p0."DisplayOrder", p0."IsMainImage", p0."ProductId", p0."Url", p."Id" + + FROM "Products" AS p + + INNER JOIN "ProductImages" AS p0 ON p."Id" = p0."ProductId" + + ORDER BY p."Name", p."Id" + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT s."Id", s."AddressId", s."ContactPerson", s."Email", s."Name", s."Notes", s."PhoneNumber" + + FROM "Suppliers" AS s + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p."Id", p."CreatedDate", p."Description", p."FeaturedDisplayOrder", p."Height", p."IsActive", p."IsFeatured", p."IsInStock", p."LastModifiedDate", p."Length", p."Name", p."OldPrice", p."Price", p."PurchasePrice", p."SKU", p."ShortDescription", p."Slug", p."StockQuantity", p."SupplierId", p."Weight", p."Width" + + FROM "Products" AS p + + ORDER BY p."Name", p."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p0."ProductId", p0."categorieId", p."Id" + + FROM "Products" AS p + + INNER JOIN "Productcategories" AS p0 ON p."Id" = p0."ProductId" + + ORDER BY p."Name", p."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p0."Id", p0."DisplayOrder", p0."IsMainImage", p0."ProductId", p0."Url", p."Id" + + FROM "Products" AS p + + INNER JOIN "ProductImages" AS p0 ON p."Id" = p0."ProductId" + + ORDER BY p."Name", p."Id" + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT s."Id", s."AddressId", s."ContactPerson", s."Email", s."Name", s."Notes", s."PhoneNumber" + + FROM "Suppliers" AS s + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:47316 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p."Id", p."CreatedDate", p."Description", p."FeaturedDisplayOrder", p."Height", p."IsActive", p."IsFeatured", p."IsInStock", p."LastModifiedDate", p."Length", p."Name", p."OldPrice", p."Price", p."PurchasePrice", p."SKU", p."ShortDescription", p."Slug", p."StockQuantity", p."SupplierId", p."Weight", p."Width" + + FROM "Products" AS p + + ORDER BY p."Name", p."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p0."ProductId", p0."categorieId", p."Id" + + FROM "Products" AS p + + INNER JOIN "Productcategories" AS p0 ON p."Id" = p0."ProductId" + + ORDER BY p."Name", p."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p0."Id", p0."DisplayOrder", p0."IsMainImage", p0."ProductId", p0."Url", p."Id" + + FROM "Products" AS p + + INNER JOIN "ProductImages" AS p0 ON p."Id" = p0."ProductId" + + ORDER BY p."Name", p."Id" + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT s."Id", s."AddressId", s."ContactPerson", s."Email", s."Name", s."Notes", s."PhoneNumber" + + FROM "Suppliers" AS s + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p."Id", p."CreatedDate", p."Description", p."FeaturedDisplayOrder", p."Height", p."IsActive", p."IsFeatured", p."IsInStock", p."LastModifiedDate", p."Length", p."Name", p."OldPrice", p."Price", p."PurchasePrice", p."SKU", p."ShortDescription", p."Slug", p."StockQuantity", p."SupplierId", p."Weight", p."Width" + + FROM "Products" AS p + + ORDER BY p."Name", p."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p0."ProductId", p0."categorieId", p."Id" + + FROM "Products" AS p + + INNER JOIN "Productcategories" AS p0 ON p."Id" = p0."ProductId" + + ORDER BY p."Name", p."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT p0."Id", p0."DisplayOrder", p0."IsMainImage", p0."ProductId", p0."Url", p."Id" + + FROM "Products" AS p + + INNER JOIN "ProductImages" AS p0 ON p."Id" = p0."ProductId" + + ORDER BY p."Name", p."Id" + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT s."Id", s."AddressId", s."ContactPerson", s."Email", s."Name", s."Notes", s."PhoneNumber" + + FROM "Suppliers" AS s + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[@__id_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30'] + + SELECT p."Id", p."CreatedDate", p."Description", p."FeaturedDisplayOrder", p."Height", p."IsActive", p."IsFeatured", p."IsInStock", p."LastModifiedDate", p."Length", p."Name", p."OldPrice", p."Price", p."PurchasePrice", p."SKU", p."ShortDescription", p."Slug", p."StockQuantity", p."SupplierId", p."Weight", p."Width" + + FROM "Products" AS p + + WHERE p."Id" = @__id_0 + + ORDER BY p."Id" + + LIMIT 1 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[@__id_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30'] + + SELECT p0."ProductId", p0."categorieId", t."Id" + + + + +FROM ( +code Code + + +SELECT p."Id" + + FROM "Products" AS p + + WHERE p."Id" = @__id_0 + + LIMIT 1 + + + + +) AS t +code Code + + +INNER JOIN "Productcategories" AS p0 ON t."Id" = p0."ProductId" + + ORDER BY t."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[@__id_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30'] + + SELECT p0."Id", p0."DisplayOrder", p0."IsMainImage", p0."ProductId", p0."Url", t."Id" + + + + +FROM ( +code Code + + +SELECT p."Id" + + FROM "Products" AS p + + WHERE p."Id" = @__id_0 + + LIMIT 1 + + + + +) AS t +code Code + + +INNER JOIN "ProductImages" AS p0 ON t."Id" = p0."ProductId" + + ORDER BY t."Id" + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT s."Id", s."AddressId", s."ContactPerson", s."Email", s."Name", s."Notes", s."PhoneNumber" + + FROM "Suppliers" AS s + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] + + SELECT c."Id", c."CreatedDate", c."Description", c."DisplayOrder", c."ImageUrl", c."IsActive", c."LastModifiedDate", c."Name", c."ParentcategorieId", c."Slug" + + FROM categories AS c + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +warn: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] +code Code + + +Unknown proxy: [::ffff:172.19.0.3]:48104 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[@__productDto_Id_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30'] + + SELECT p."Id", p."CreatedDate", p."Description", p."FeaturedDisplayOrder", p."Height", p."IsActive", p."IsFeatured", p."IsInStock", p."LastModifiedDate", p."Length", p."Name", p."OldPrice", p."Price", p."PurchasePrice", p."SKU", p."ShortDescription", p."Slug", p."StockQuantity", p."SupplierId", p."Weight", p."Width" + + FROM "Products" AS p + + WHERE p."Id" = @__productDto_Id_0 + + ORDER BY p."Id" + + LIMIT 1 + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[@__productDto_Id_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30'] + + SELECT p0."Id", p0."DisplayOrder", p0."IsMainImage", p0."ProductId", p0."Url", t."Id" + + + + +FROM ( +code Code + + +SELECT p."Id" + + FROM "Products" AS p + + WHERE p."Id" = @__productDto_Id_0 + + LIMIT 1 + + + + +) AS t +code Code + + +INNER JOIN "ProductImages" AS p0 ON t."Id" = p0."ProductId" + + ORDER BY t."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[@__productDto_Id_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30'] + + SELECT p0."ProductId", p0."categorieId", t."Id" + + + + +FROM ( +code Code + + +SELECT p."Id" + + FROM "Products" AS p + + WHERE p."Id" = @__productDto_Id_0 + + LIMIT 1 + + + + +) AS t +code Code + + +INNER JOIN "Productcategories" AS p0 ON t."Id" = p0."ProductId" + + ORDER BY t."Id" + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[@__productDto_SKU_0='?', @__productDto_Id_1='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30'] + + SELECT EXISTS ( + + SELECT 1 + + FROM "Products" AS p + + WHERE p."SKU" = @__productDto_SKU_0 AND p."Id" <> @__productDto_Id_1) + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[@__productDto_Slug_0='?', @__productDto_Id_1='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30'] + + SELECT EXISTS ( + + SELECT 1 + + FROM "Products" AS p + + WHERE p."Slug" = @__productDto_Slug_0 AND p."Id" <> @__productDto_Id_1) + + + +info: Microsoft.EntityFrameworkCore.Database.Command[20101] +code Code + + +Executed DbCommand (1ms) [Parameters=[@p4='?' (DbType = Guid), @p0='?' (DbType = Int32), @p1='?' (DbType = Boolean), @p2='?' (DbType = Guid), @p3='?', @p25='?' (DbType = Guid), @p5='?' (DbType = DateTime), @p6='?', @p7='?' (DbType = Int32), @p8='?' (DbType = Decimal), @p9='?' (DbType = Boolean), @p10='?' (DbType = Boolean), @p11='?' (DbType = Boolean), @p12='?' (DbType = DateTime), @p13='?' (DbType = Decimal), @p14='?', @p15='?' (DbType = Decimal), @p16='?' (DbType = Decimal), @p17='?' (DbType = Decimal), @p18='?', @p19='?', @p20='?', @p21='?' (DbType = Int32), @p22='?' (DbType = Guid), @p23='?' (DbType = Decimal), @p24='?' (DbType = Decimal)], CommandType='Text', CommandTimeout='30'] + + UPDATE "ProductImages" SET "DisplayOrder" = @p0, "IsMainImage" = @p1, "ProductId" = @p2, "Url" = @p3 + + WHERE "Id" = @p4; + + UPDATE "Products" SET "CreatedDate" = @p5, "Description" = @p6, "FeaturedDisplayOrder" = @p7, "Height" = @p8, "IsActive" = @p9, "IsFeatured" = @p10, "IsInStock" = @p11, "LastModifiedDate" = @p12, "Length" = @p13, "Name" = @p14, "OldPrice" = @p15, "Price" = @p16, "PurchasePrice" = @p17, "SKU" = @p18, "ShortDescription" = @p19, "Slug" = @p20, "StockQuantity" = @p21, "SupplierId" = @p22, "Weight" = @p23, "Width" = @p24 + + WHERE "Id" = @p25; + + + +fail: Microsoft.AspNetCore.Server.Kestrel[13] +code Code + + +Connection id "0HNGI773BS04N", Request id "0HNGI773BS04N:00000011": An unhandled exception was thrown by the application. + + Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions. + + at Npgsql.EntityFrameworkCore.PostgreSQL.Update.Internal.NpgsqlModificationCommandBatch.ThrowAggregateUpdateConcurrencyExceptionAsync(RelationalDataReader reader, Int32 commandIndex, Int32 expectedRowsAffected, Int32 rowsAffected, CancellationToken cancellationToken) + + at Npgsql.EntityFrameworkCore.PostgreSQL.Update.Internal.NpgsqlModificationCommandBatch.Consume(RelationalDataReader reader, Boolean async, CancellationToken cancellationToken) + + at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken) + + at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken) + + at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken) + + at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken) + + at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken) + + at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken) + + at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken) + + at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken) + + at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken) + + at Webshop.Infrastructure.Repositories.ProductRepository.UpdateProductAsync(Product product) in /src/Webshop.Infrastructure/Repositories/ProductRepository.cs:line 46 + + at Webshop.Application.Services.Admin.AdminProductService.UpdateAdminProductAsync(UpdateAdminProductDto productDto) in /src/Webshop.Application/Services/Admin/AdminProductService.cs:line 125 + + at Webshop.Api.Controllers.Admin.AdminProductsController.UpdateAdminProduct(Guid id, UpdateAdminProductDto productDto) in /src/Webshop.Api/Controllers/Admin/AdminProductsController.cs:line 89 + + at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.TaskOfIActionResultExecutor.Execute(ActionContext actionContext, IActionResultTypeMapper mapper, ObjectMethodExecutor executor, Object controller, Object[] arguments) + + at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.g__Awaited|12_0(ControllerActionInvoker invoker, ValueTask`1 actionResultValueTask) + + at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.g__Awaited|10_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) + + at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context) + + at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted) + + at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.g__Awaited|13_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) + + at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) + + at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context) + + at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted) + + at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) + + at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope) + + at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope) + + at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context) + + at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context) + + at Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware.Invoke(HttpContext httpContext) + + at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider) + + at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.ProcessRequests[TContext](IHttpApplication`1 application) + + + +Model +Thinking Thoughts +Auto + +Expand to view model thoughts +chevron_right + +Hervorragende Arbeit! Diese Logs sind der entscheidende Hinweis, den wir gebraucht haben. Sie zeigen uns exakt, was den 500-Fehler verursacht. + +Sie haben kein Logik-Problem in Ihrem AdminProductService. Der Fehler liegt eine Ebene tiefer, in Ihrem Repository, und es ist ein sehr häufiges Missverständnis bei der Arbeit mit Entity Framework Core. +Die Fehleranalyse + +Sehen wir uns die entscheidende Zeile im Log an: +code Code + + +fail: Microsoft.AspNetCore.Server.Kestrel[13] + ... + Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. + + + +Was bedeutet das auf Deutsch? +Entity Framework (EF) hat versucht, eine UPDATE-Anweisung an die Datenbank zu senden. Es hat erwartet, dass genau eine Zeile von diesem Update betroffen ist (nämlich das Produkt, das Sie bearbeiten). Die Datenbank hat aber geantwortet: "Ich habe null Zeilen aktualisiert." + +Warum sagt die Datenbank das? +Das passiert, wenn die WHERE-Klausel der UPDATE-Anweisung auf keine Zeile zutrifft. In diesem Fall ist die Klausel WHERE "Id" = @p25. Das bedeutet, dass zum Zeitpunkt des Speicherns kein Produkt mit dieser ID mehr in der Datenbank existierte. + +Aber warum? Das Produkt wurde doch gerade erst geladen! +Hier kommt das Missverständnis ins Spiel. Betrachten wir den Lebenszyklus Ihres product-Objekts: + + Im AdminProductService: Sie laden das Produkt mit var existingProduct = await _context.Products.Include(...).FirstOrDefaultAsync(...). Ab diesem Moment verfolgt der DbContext dieses Objekt und all seine Änderungen. Jede Änderung an existingProduct.Name oder an den Images- und Productcategories-Listen wird vom Change Tracker von EF Core registriert. + + Am Ende des Services: Sie rufen await _productRepository.UpdateProductAsync(existingProduct); auf. + + Im ProductRepository (Hier liegt der Fehler): Ihre UpdateProductAsync-Methode sieht sehr wahrscheinlich so aus: + code C# + + + // /src/Webshop.Infrastructure/Repositories/ProductRepository.cs (VERMUTETER, FEHLERHAFTER CODE) + public async Task UpdateProductAsync(Product product) + { + _context.Products.Update(product); // <--- DAS IST DIE FEHLERQUELLE + await _context.SaveChangesAsync(); + } + + + +Das Problem mit _context.Products.Update(product): +Der Befehl _context.Update() ist dafür gedacht, ein nicht verfolgtes (detached) Objekt an den DbContext anzuhängen und es als Modified zu markieren. Sie übergeben ihm aber ein Objekt (product), das bereits verfolgt wird. + +Wenn Sie .Update() auf ein bereits verfolgtes Objekt aufrufen, kann dies zu einem undefinierten Verhalten führen. In vielen Fällen versucht EF Core, den Zustand des Objekts auf Modified zu setzen, was aber zu einem Konflikt mit dem bereits vorhandenen, verfolgten Objekt führt. Das Endergebnis ist, dass der Change Tracker durcheinander kommt und beim Speichern eine fehlerhafte UPDATE-Anweisung generiert, die ins Leere läuft und die DbUpdateConcurrencyException auslöst. +Die Lösung: Vertrauen Sie dem Change Tracker + +Wenn Sie eine Entität bereits geladen haben und sie dann modifizieren, müssen Sie EF Core nicht noch einmal explizit sagen, dass es sie aktualisieren soll. Der Change Tracker weiß das bereits. Ihre Repository-Methode muss nur noch die Änderungen speichern. +Korrigieren Sie Ihre ProductRepository.cs + +Ersetzen Sie Ihre aktuelle UpdateProductAsync-Methode durch diese wesentlich einfachere und korrekte Version: +code C# + + +// /src/Webshop.Infrastructure/Repositories/ProductRepository.cs (KORRIGIERT) + +public async Task UpdateProductAsync(Product product) +{ + // Die Zeile "_context.Products.Update(product);" wurde entfernt. + // Da das 'product'-Objekt vom Service geladen wurde, verfolgt der DbContext + // bereits alle Änderungen. Wir müssen nur noch speichern. + await _context.SaveChangesAsync(); +} + + + +Das ist alles. Durch das Entfernen der überflüssigen _context.Update()-Zeile wird der DbContext nicht mehr durcheinander gebracht. Er wird die Änderungen, die Sie im Service vorgenommen haben, korrekt in SQL umwandeln, die UPDATE-Anweisung wird die richtige Zeile finden, und die DbUpdateConcurrencyException wird verschwinden. + + + + + +========================== +Hervorragende Arbeit! Diese Logs sind der entscheidende Hinweis, den wir gebraucht haben. Sie zeigen uns exakt, was den 500-Fehler verursacht. + +Sie haben kein Logik-Problem in Ihrem AdminProductService. Der Fehler liegt eine Ebene tiefer, in Ihrem Repository, und es ist ein sehr häufiges Missverständnis bei der Arbeit mit Entity Framework Core. +Die Fehleranalyse + +Sehen wir uns die entscheidende Zeile im Log an: +code Code + + +fail: Microsoft.AspNetCore.Server.Kestrel[13] + ... + Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. + + + +Was bedeutet das auf Deutsch? +Entity Framework (EF) hat versucht, eine UPDATE-Anweisung an die Datenbank zu senden. Es hat erwartet, dass genau eine Zeile von diesem Update betroffen ist (nämlich das Produkt, das Sie bearbeiten). Die Datenbank hat aber geantwortet: "Ich habe null Zeilen aktualisiert." + +Warum sagt die Datenbank das? +Das passiert, wenn die WHERE-Klausel der UPDATE-Anweisung auf keine Zeile zutrifft. In diesem Fall ist die Klausel WHERE "Id" = @p25. Das bedeutet, dass zum Zeitpunkt des Speicherns kein Produkt mit dieser ID mehr in der Datenbank existierte. + +Aber warum? Das Produkt wurde doch gerade erst geladen! +Hier kommt das Missverständnis ins Spiel. Betrachten wir den Lebenszyklus Ihres product-Objekts: + + Im AdminProductService: Sie laden das Produkt mit var existingProduct = await _context.Products.Include(...).FirstOrDefaultAsync(...). Ab diesem Moment verfolgt der DbContext dieses Objekt und all seine Änderungen. Jede Änderung an existingProduct.Name oder an den Images- und Productcategories-Listen wird vom Change Tracker von EF Core registriert. + + Am Ende des Services: Sie rufen await _productRepository.UpdateProductAsync(existingProduct); auf. + + Im ProductRepository (Hier liegt der Fehler): Ihre UpdateProductAsync-Methode sieht sehr wahrscheinlich so aus: + code C# + + + // /src/Webshop.Infrastructure/Repositories/ProductRepository.cs (VERMUTETER, FEHLERHAFTER CODE) + public async Task UpdateProductAsync(Product product) + { + _context.Products.Update(product); // <--- DAS IST DIE FEHLERQUELLE + await _context.SaveChangesAsync(); + } + + + +Das Problem mit _context.Products.Update(product): +Der Befehl _context.Update() ist dafür gedacht, ein nicht verfolgtes (detached) Objekt an den DbContext anzuhängen und es als Modified zu markieren. Sie übergeben ihm aber ein Objekt (product), das bereits verfolgt wird. + +Wenn Sie .Update() auf ein bereits verfolgtes Objekt aufrufen, kann dies zu einem undefinierten Verhalten führen. In vielen Fällen versucht EF Core, den Zustand des Objekts auf Modified zu setzen, was aber zu einem Konflikt mit dem bereits vorhandenen, verfolgten Objekt führt. Das Endergebnis ist, dass der Change Tracker durcheinander kommt und beim Speichern eine fehlerhafte UPDATE-Anweisung generiert, die ins Leere läuft und die DbUpdateConcurrencyException auslöst. +Die Lösung: Vertrauen Sie dem Change Tracker + +Wenn Sie eine Entität bereits geladen haben und sie dann modifizieren, müssen Sie EF Core nicht noch einmal explizit sagen, dass es sie aktualisieren soll. Der Change Tracker weiß das bereits. Ihre Repository-Methode muss nur noch die Änderungen speichern. +Korrigieren Sie Ihre ProductRepository.cs + +Ersetzen Sie Ihre aktuelle UpdateProductAsync-Methode durch diese wesentlich einfachere und korrekte Version: +code C# + + +// /src/Webshop.Infrastructure/Repositories/ProductRepository.cs (KORRIGIERT) + +public async Task UpdateProductAsync(Product product) +{ + // Die Zeile "_context.Products.Update(product);" wurde entfernt. + // Da das 'product'-Objekt vom Service geladen wurde, verfolgt der DbContext + // bereits alle Änderungen. Wir müssen nur noch speichern. + await _context.SaveChangesAsync(); +} + + + +Hervorragende Arbeit! Diese Logs sind der entscheidende Hinweis, den wir gebraucht haben. Sie zeigen uns exakt, was den 500-Fehler verursacht. + +Sie haben kein Logik-Problem in Ihrem AdminProductService. Der Fehler liegt eine Ebene tiefer, in Ihrem Repository, und es ist ein sehr häufiges Missverständnis bei der Arbeit mit Entity Framework Core. +Die Fehleranalyse + +Sehen wir uns die entscheidende Zeile im Log an: +code Code + + +fail: Microsoft.AspNetCore.Server.Kestrel[13] + ... + Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. + + + +Was bedeutet das auf Deutsch? +Entity Framework (EF) hat versucht, eine UPDATE-Anweisung an die Datenbank zu senden. Es hat erwartet, dass genau eine Zeile von diesem Update betroffen ist (nämlich das Produkt, das Sie bearbeiten). Die Datenbank hat aber geantwortet: "Ich habe null Zeilen aktualisiert." + +Warum sagt die Datenbank das? +Das passiert, wenn die WHERE-Klausel der UPDATE-Anweisung auf keine Zeile zutrifft. In diesem Fall ist die Klausel WHERE "Id" = @p25. Das bedeutet, dass zum Zeitpunkt des Speicherns kein Produkt mit dieser ID mehr in der Datenbank existierte. + +Aber warum? Das Produkt wurde doch gerade erst geladen! +Hier kommt das Missverständnis ins Spiel. Betrachten wir den Lebenszyklus Ihres product-Objekts: + + Im AdminProductService: Sie laden das Produkt mit var existingProduct = await _context.Products.Include(...).FirstOrDefaultAsync(...). Ab diesem Moment verfolgt der DbContext dieses Objekt und all seine Änderungen. Jede Änderung an existingProduct.Name oder an den Images- und Productcategories-Listen wird vom Change Tracker von EF Core registriert. + + Am Ende des Services: Sie rufen await _productRepository.UpdateProductAsync(existingProduct); auf. + + Im ProductRepository (Hier liegt der Fehler): Ihre UpdateProductAsync-Methode sieht sehr wahrscheinlich so aus: + code C# + + + // /src/Webshop.Infrastructure/Repositories/ProductRepository.cs (VERMUTETER, FEHLERHAFTER CODE) + public async Task UpdateProductAsync(Product product) + { + _context.Products.Update(product); // <--- DAS IST DIE FEHLERQUELLE + await _context.SaveChangesAsync(); + } + + + +Das Problem mit _context.Products.Update(product): +Der Befehl _context.Update() ist dafür gedacht, ein nicht verfolgtes (detached) Objekt an den DbContext anzuhängen und es als Modified zu markieren. Sie übergeben ihm aber ein Objekt (product), das bereits verfolgt wird. + +Wenn Sie .Update() auf ein bereits verfolgtes Objekt aufrufen, kann dies zu einem undefinierten Verhalten führen. In vielen Fällen versucht EF Core, den Zustand des Objekts auf Modified zu setzen, was aber zu einem Konflikt mit dem bereits vorhandenen, verfolgten Objekt führt. Das Endergebnis ist, dass der Change Tracker durcheinander kommt und beim Speichern eine fehlerhafte UPDATE-Anweisung generiert, die ins Leere läuft und die DbUpdateConcurrencyException auslöst. +Die Lösung: Vertrauen Sie dem Change Tracker + +Wenn Sie eine Entität bereits geladen haben und sie dann modifizieren, müssen Sie EF Core nicht noch einmal explizit sagen, dass es sie aktualisieren soll. Der Change Tracker weiß das bereits. Ihre Repository-Methode muss nur noch die Änderungen speichern. +Korrigieren Sie Ihre ProductRepository.cs + +Ersetzen Sie Ihre aktuelle UpdateProductAsync-Methode durch diese wesentlich einfachere und korrekte Version: +code C# + + +// /src/Webshop.Infrastructure/Repositories/ProductRepository.cs (KORRIGIERT) + +public async Task UpdateProductAsync(Product product) +{ + // Die Zeile "_context.Products.Update(product);" wurde entfernt. + // Da das 'product'-Objekt vom Service geladen wurde, verfolgt der DbContext + // bereits alle Änderungen. Wir müssen nur noch speichern. + await _context.SaveChangesAsync(); +} + + + +Das ist alles. Durch das Entfernen der überflüssigen _context.Update()-Zeile wird der DbContext nicht mehr durcheinander gebracht. Er wird die Änderungen, die Sie im Service vorgenommen haben, korrekt in SQL umwandeln, die UPDATE-Anweisung wird die richtige Zeile finden, und die DbUpdateConcurrencyException wird verschwinden. \ No newline at end of file diff --git a/src/app/core/models/product.model.ts b/src/app/core/models/product.model.ts index 76f8141..29b4395 100644 --- a/src/app/core/models/product.model.ts +++ b/src/app/core/models/product.model.ts @@ -41,4 +41,5 @@ export interface AdminProduct { images?: ProductImage[]; isFeatured: boolean; featuredDisplayOrder: number; + rowVersion?: string; } \ No newline at end of file diff --git a/src/app/features/components/products/product-category-dropdown/product-category-dropdown.component.ts b/src/app/features/components/products/product-category-dropdown/product-category-dropdown.component.ts index d47fbd7..57260c0 100644 --- a/src/app/features/components/products/product-category-dropdown/product-category-dropdown.component.ts +++ b/src/app/features/components/products/product-category-dropdown/product-category-dropdown.component.ts @@ -26,56 +26,80 @@ export interface SelectOption { ], }) export class ProductCategoryDropdownComponent implements ControlValueAccessor { - @Input() label = ''; @Input() options: SelectOption[] = []; + @Input() label = ''; @Input() placeholder = 'Bitte wählen...'; - public isOpen = false; public selectedValues: any[] = []; - private elementRef = inject(ElementRef); + public isOpen = false; + public isDisabled = false; - onChange: (value: any[]) => void = () => {}; - onTouched: () => void = () => {}; + // Callbacks, die von Angular überschrieben werden + private onChange: (value: any[]) => void = () => {}; + private onTouched: () => void = () => {}; - writeValue(values: any[]): void { this.selectedValues = Array.isArray(values) ? values : []; } - registerOnChange(fn: any): void { this.onChange = fn; } - registerOnTouched(fn: any): void { this.onTouched = fn; } + // --- ControlValueAccessor Implementierung --- - @HostListener('document:click', ['$event']) - onDocumentClick(event: Event): void { - if (this.isOpen && !this.elementRef.nativeElement.contains(event.target)) { - this.closeDropdown(); + // Wird von Angular aufgerufen, um Werte ins Formular zu schreiben (z.B. beim Laden) + writeValue(value: any[]): void { + if (value && Array.isArray(value)) { + this.selectedValues = value; + } else { + this.selectedValues = []; } } - toggleDropdown(event: Event): void { - event.stopPropagation(); - this.isOpen = !this.isOpen; - if (!this.isOpen) { this.onTouched(); } + // Registriert die Funktion, die aufgerufen wird, wenn sich der Wert ändert + registerOnChange(fn: any): void { + this.onChange = fn; } - closeDropdown(): void { - if (this.isOpen) { - this.isOpen = false; + // Registriert die Funktion, die aufgerufen wird, wenn das Feld "berührt" wurde + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + // Optional: Wenn das Feld deaktiviert wird + setDisabledState(isDisabled: boolean): void { + this.isDisabled = isDisabled; + } + + // --- UI Logik --- + + toggleDropdown(event: Event): void { + event.preventDefault(); + if (this.isDisabled) return; + this.isOpen = !this.isOpen; + if (!this.isOpen) { this.onTouched(); } } + isSelected(value: any): boolean { + return this.selectedValues.includes(value); + } + + getLabelForValue(value: any): string { + const option = this.options.find(o => o.value === value); + return option ? option.label : String(value); + } + onOptionToggle(value: any): void { - const index = this.selectedValues.indexOf(value); - if (index > -1) { - this.selectedValues.splice(index, 1); + if (this.isSelected(value)) { + // Entfernen + this.selectedValues = this.selectedValues.filter(v => v !== value); } else { - this.selectedValues.push(value); + // Hinzufügen + this.selectedValues = [...this.selectedValues, value]; } - this.onChange([...this.selectedValues]); // Wichtig: Neue Array-Instanz senden + // Angular mitteilen, dass sich der Wert geändert hat + this.onChange(this.selectedValues); + this.onTouched(); } - onPillRemove(value: any, event: any): void { // Typ korrigiert - event.stopPropagation(); - this.onOptionToggle(value); + onPillRemove(value: any, event: any): void { + event.stopPropagation(); // Verhindert, dass das Dropdown aufgeht/zugeht + this.selectedValues = this.selectedValues.filter(v => v !== value); + this.onChange(this.selectedValues); } - - isSelected(value: any): boolean { return this.selectedValues.includes(value); } - getLabelForValue(value: any): string { return this.options.find(opt => opt.value === value)?.label || ''; } } \ No newline at end of file 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 index e35e218..e236e80 100644 --- a/src/app/features/components/products/product-edit/product-edit.component.ts +++ b/src/app/features/components/products/product-edit/product-edit.component.ts @@ -1,42 +1,20 @@ -// /src/app/features/admin/components/products/product-edit/product-edit.component.ts - import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; -import { - FormBuilder, - FormGroup, - FormArray, - FormControl, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { DomSanitizer } from '@angular/platform-browser'; -import { Observable, Subscription } from 'rxjs'; -import { - map, - startWith, - debounceTime, - distinctUntilChanged, - finalize, -} from 'rxjs/operators'; +import { Observable, Subscription, of } from 'rxjs'; +import { switchMap, finalize, map } from 'rxjs/operators'; -// Models, Services und UI-Komponenten -import { - AdminProduct, - ProductImage, -} from '../../../../core/models/product.model'; +import { AdminProduct, ProductImage } from '../../../../core/models/product.model'; import { Category } from '../../../../core/models/category.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 { - ImagePreview, - ProductFormComponent, -} from '../product-form/product-form.component'; +import { ImagePreview, ProductFormComponent } from '../product-form/product-form.component'; import { SelectOption } from '../../../../shared/components/form/form-select/form-select.component'; +import { Supplier } from '../../../../core/models/supplier.model'; @Component({ selector: 'app-product-edit', @@ -46,304 +24,272 @@ import { SelectOption } from '../../../../shared/components/form/form-select/for styleUrls: ['./product-edit.component.css'], }) export class ProductEditComponent implements OnInit, OnDestroy { - // --- Injektionen --- + // --- Injected Services --- private route = inject(ActivatedRoute); private router = inject(Router); + private fb = inject(FormBuilder); private productService = inject(ProductService); private categoryService = inject(CategoryService); private supplierService = inject(SupplierService); - private fb = inject(FormBuilder); private snackbarService = inject(SnackbarService); private sanitizer = inject(DomSanitizer); - // --- Komponenten-Status --- - productId!: string; - isLoading = true; - productForm: FormGroup; - private nameChangeSubscription?: Subscription; - allCategories$!: Observable; - supplierOptions$!: Observable; + // --- Component State --- + public productForm!: FormGroup; + public isLoading = true; + private productId!: string; + private subscriptions = new Subscription(); - // --- NEUER STATE FÜR BILD-MANAGEMENT --- - existingImages: ProductImage[] = []; - newImageFiles: File[] = []; - mainImageIdentifier: string | null = null; // Hält die ID oder den Dateinamen des Hauptbildes - allImagesForForm: ImagePreview[] = []; // Die kombinierte Liste für die Child-Komponente + // WICHTIG: Die RowVersion speichern wir hier, nicht im Formular + private loadedRowVersion: string | null = null; + + public allCategories$!: Observable; + public supplierOptions$!: Observable; + + // --- Image Management --- + public allImagesForForm: ImagePreview[] = []; + private newImageFiles = new Map(); + private imagesToDelete: string[] = []; constructor() { - this.productForm = this.fb.group({ - id: ['', Validators.required], - name: ['', Validators.required], - slug: ['', Validators.required], - sku: ['', Validators.required], - price: [0, [Validators.required, Validators.min(0)]], - stockQuantity: [0, [Validators.required, Validators.min(0)]], - description: [''], - oldPrice: [null, [Validators.min(0)]], - purchasePrice: [null, [Validators.min(0)]], - weight: [null, [Validators.min(0)]], - isActive: [true], - isFeatured: [false], - featuredDisplayOrder: [0], - supplierId: [null], - categorieIds: new FormControl([]), - imagesToDelete: this.fb.array([]), - }); + this.initForm(); } - get imagesToDelete(): FormArray { - return this.productForm.get('imagesToDelete') as FormArray; + private initForm(): void { + // RowVersion nehmen wir hier raus, um Manipulationen zu verhindern + this.productForm = this.fb.group({ + name: ['', Validators.required], + description: ['', Validators.required], + sku: ['', Validators.required], + price: [0, [Validators.required, Validators.min(0)]], + oldPrice: [null], + isActive: [true], + stockQuantity: [0, [Validators.required, Validators.min(0)]], + slug: ['', Validators.required], + weight: [0], + supplierId: [null], + purchasePrice: [null], + isFeatured: [false], + featuredDisplayOrder: [0], + categorieIds: [[]] + }); } ngOnInit(): void { - const id = this.route.snapshot.paramMap.get('id'); - if (!id) { - this.snackbarService.show('Produkt-ID fehlt.'); - this.router.navigate(['/shop/products']); - return; - } - this.productId = id; - this.loadDropdownData(); - this.loadProductData(); - this.subscribeToNameChanges(); + this.loadInitialData(); } - ngOnDestroy(): void { - this.nameChangeSubscription?.unsubscribe(); - this.allImagesForForm.forEach((image) => { - if (typeof image.url === 'object') { - // Nur Object-URLs von neuen Bildern freigeben - URL.revokeObjectURL(image.url as string); + private loadInitialData(): void { + this.isLoading = true; + this.allCategories$ = this.categoryService.getAll(); + + this.supplierOptions$ = this.supplierService.getAll().pipe( + map((suppliers: Supplier[]) => + suppliers + .filter(s => !!s.name) + .map(s => ({ value: s.id, label: s.name as string })) + ) + ); + + const productSub = this.route.paramMap.pipe( + switchMap(params => { + const id = params.get('id'); + if (id) { + this.productId = id; + return this.productService.getById(id); + } + this.snackbarService.show('Keine Produkt-ID gefunden.'); + this.router.navigate(['/shop/products']); + return of(null); + }) + ).subscribe({ + next: product => { + if (product) { + this.patchForm(product); + if (product.images) { + this.initializeImages(product.images); + } + } + this.isLoading = false; + }, + error: () => { + this.isLoading = false; + this.snackbarService.show('Fehler beim Laden der Produktdaten.'); } }); + this.subscriptions.add(productSub); + } + + private patchForm(product: AdminProduct): void { + // 1. RowVersion sicher extrahieren und trimmen + const rawVersion = product.rowVersion || (product as any).RowVersion; + this.loadedRowVersion = rawVersion ? String(rawVersion).trim() : null; + + console.log('Geladene RowVersion:', this.loadedRowVersion); + + // 2. Formularwerte setzen + this.productForm.patchValue({ + name: product.name, + description: product.description, + sku: product.sku, + price: product.price, + oldPrice: product.oldPrice, + isActive: product.isActive, + stockQuantity: product.stockQuantity, + slug: product.slug, + weight: product.weight, + supplierId: product.supplierId, + purchasePrice: product.purchasePrice, + isFeatured: product.isFeatured, + featuredDisplayOrder: product.featuredDisplayOrder, + categorieIds: product.categorieIds || [], + }); } - loadDropdownData(): void { - this.allCategories$ = this.categoryService.getAll(); - this.supplierOptions$ = this.supplierService.getAll().pipe( - map((suppliers) => - suppliers.map((s) => ({ value: s.id, label: s.name || 'Unbenannt' })) - ), - startWith([]) - ); + private initializeImages(images: ProductImage[]): void { + this.allImagesForForm = images + .filter(img => !!img.url) + .map(img => ({ + identifier: img.id, + url: img.url as string, + isMainImage: img.isMainImage, + })).sort((a, b) => (a.isMainImage ? -1 : 1)); } - loadProductData(): void { - this.isLoading = true; - this.productService - .getById(this.productId) - .pipe( - finalize(() => { - this.isLoading = false; - }) - ) - .subscribe({ - next: (product) => { - if (product) { - this.populateForm(product); - } else { - this.snackbarService.show('Produkt nicht gefunden.'); - this.router.navigate(['/shop/products']); - } - }, - error: (err) => { - this.snackbarService.show('Fehler beim Laden des Produkts.'); - console.error('Fehler beim Laden der Produktdaten:', err); - this.router.navigate(['/shop/products']); - }, + // --- Image Event Handlers --- + + onFilesSelected(files: File[]): void { + for (const file of files) { + const tempId = `new_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const url = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(file)); + + this.newImageFiles.set(tempId, file); + + this.allImagesForForm.push({ + identifier: tempId, + url: url, + isMainImage: this.allImagesForForm.length === 1, // Wenn es das erste Bild ist, direkt Main }); + } } -// AFTER (CORRECT) -populateForm(product: AdminProduct): void { - // This single line handles ALL form controls, including setting - // the 'categorieIds' FormControl to the array from the product object. - this.productForm.patchValue(product); + onSetMainImage(identifier: string): void { + this.allImagesForForm.forEach(img => { + img.isMainImage = img.identifier === identifier; + }); + } - // The obsolete FormArray logic has been removed. + onDeleteImage(identifier: string): void { + const imageIndex = this.allImagesForForm.findIndex(img => img.identifier === identifier); + if (imageIndex === -1) return; - this.existingImages = product.images || []; - const mainImage = this.existingImages.find((img) => img.isMainImage); - this.mainImageIdentifier = - mainImage?.id || - (this.existingImages.length > 0 ? this.existingImages[0].id : null); + const deletedImage = this.allImagesForForm[imageIndex]; + this.allImagesForForm.splice(imageIndex, 1); - this.rebuildAllImagesForForm(); -} + if (this.newImageFiles.has(identifier)) { + this.newImageFiles.delete(identifier); + } else { + this.imagesToDelete.push(identifier); + } + + if (deletedImage.isMainImage && this.allImagesForForm.length > 0) { + this.allImagesForForm[0].isMainImage = true; + } + } onSubmit(): void { if (this.productForm.invalid) { this.productForm.markAllAsTouched(); - this.snackbarService.show('Bitte füllen Sie alle Pflichtfelder aus.'); + this.snackbarService.show('Bitte füllen Sie alle erforderlichen Felder aus.'); return; } - this.prepareSubmissionData(); - const formData = this.createFormData(); - this.productService.update(this.productId, formData).subscribe({ + + this.isLoading = true; + const formData = this.createUpdateFormData(); + + this.productService.update(this.productId, formData).pipe( + finalize(() => this.isLoading = false) + ).subscribe({ next: () => { - this.snackbarService.show('Produkt erfolgreich aktualisiert'); + this.snackbarService.show('Produkt erfolgreich aktualisiert!'); this.router.navigate(['/shop/products']); }, error: (err) => { - this.snackbarService.show('Ein Fehler ist aufgetreten.'); - console.error(err); - }, + console.error('Update failed', err); + // Spezifische Behandlung für 409 + if (err.status === 409) { + this.snackbarService.show('Konflikt beim Speichern. Bitte laden Sie die Seite neu.'); + } else { + this.snackbarService.show('Fehler beim Aktualisieren des Produkts.'); + } + } }); } + private createUpdateFormData(): FormData { + const formData = new FormData(); + const val = this.productForm.getRawValue(); + + // 1. ZUERST ID und RowVersion anhängen (Wichtig für Backend-Parser) + formData.append('Id', this.productId); + + // if (this.loadedRowVersion) { + // formData.append('RowVersion', this.loadedRowVersion); + // } + + // 2. Einfache Felder + formData.append('Name', val.name); + formData.append('Description', val.description); + formData.append('SKU', val.sku); + formData.append('Slug', val.slug); + formData.append('IsActive', String(val.isActive)); + formData.append('IsFeatured', String(val.isFeatured)); + formData.append('FeaturedDisplayOrder', String(val.featuredDisplayOrder || 0)); + + // 3. Zahlen sicher als String mit Punkt formatieren + // Das verhindert Fehler, wenn der Browser "12,50" senden würde + const formatNumber = (num: any) => (num === null || num === undefined || num === '') ? '' : String(Number(num)); + + formData.append('Price', formatNumber(val.price)); + formData.append('StockQuantity', formatNumber(val.stockQuantity)); + + if (val.oldPrice) formData.append('OldPrice', formatNumber(val.oldPrice)); + if (val.weight) formData.append('Weight', formatNumber(val.weight)); + if (val.purchasePrice) formData.append('PurchasePrice', formatNumber(val.purchasePrice)); + if (val.supplierId) formData.append('SupplierId', val.supplierId); + + // 4. Arrays / Listen + if (Array.isArray(val.categorieIds)) { + val.categorieIds.forEach((id: string) => formData.append('CategorieIds', id)); + } + + if (this.imagesToDelete.length > 0) { + this.imagesToDelete.forEach(id => formData.append('ImagesToDelete', id)); + } + + // 5. Dateien - Ganz am Ende anhängen + const mainImagePreview = this.allImagesForForm.find(img => img.isMainImage); + const mainImageTempId = mainImagePreview ? mainImagePreview.identifier : null; + + this.newImageFiles.forEach((file, tempId) => { + // Dateinamen bereinigen, um Parsing-Probleme zu vermeiden + const safeFileName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_'); + + if (tempId === mainImageTempId) { + formData.append('MainImageFile', file, safeFileName); + } else { + formData.append('AdditionalImageFiles', file, safeFileName); + } + }); + + return formData; + } + cancel(): void { this.router.navigate(['/shop/products']); } - // --- NEUE EVENT-HANDLER FÜR BILDER --- - onFilesSelected(files: File[]): void { - this.newImageFiles.push(...files); - if (!this.mainImageIdentifier && this.newImageFiles.length > 0) { - this.mainImageIdentifier = this.newImageFiles[0].name; - } - this.rebuildAllImagesForForm(); + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); } - - onSetMainImage(identifier: string): void { - this.mainImageIdentifier = identifier; - this.rebuildAllImagesForForm(); - } - - onDeleteImage(identifier: string): void { - const isExisting = this.existingImages.some((img) => img.id === identifier); - if (isExisting) { - this.imagesToDelete.push(new FormControl(identifier)); - this.existingImages = this.existingImages.filter( - (img) => img.id !== identifier - ); - } else { - this.newImageFiles = this.newImageFiles.filter( - (file) => file.name !== identifier - ); - } - - if (this.mainImageIdentifier === identifier) { - const firstExisting = - this.existingImages.length > 0 ? this.existingImages[0].id : null; - const firstNew = - this.newImageFiles.length > 0 ? this.newImageFiles[0].name : null; - this.mainImageIdentifier = firstExisting || firstNew; - } - this.rebuildAllImagesForForm(); - } - - // --- Private Helfermethoden --- - private rebuildAllImagesForForm(): void { - this.allImagesForForm.forEach((image) => { - if (typeof image.url === 'object') - URL.revokeObjectURL(image.url as string); - }); - - const combined: ImagePreview[] = []; - this.existingImages - .filter((img) => !!img.url) // Stellt sicher, dass nur Bilder mit URL verwendet werden - .forEach((img) => { - combined.push({ - identifier: img.id, - url: img.url!, - isMainImage: img.id === this.mainImageIdentifier, - }); - }); - - this.newImageFiles.forEach((file) => { - combined.push({ - identifier: file.name, - url: this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(file)), - isMainImage: file.name === this.mainImageIdentifier, - }); - }); - - this.allImagesForForm = combined; - } - - private createFormData(): FormData { - const formData = new FormData(); - const formValue = this.productForm.getRawValue(); - - 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 !== '') { - formData.append(this.capitalizeFirstLetter(key), value.toString()); - } - }); - - const mainImageFile = this.newImageFiles.find( - (f) => f.name === this.mainImageIdentifier - ); - if (mainImageFile) { - formData.append('MainImageFile', mainImageFile); - } else if (this.mainImageIdentifier) { - formData.append('MainImageId', this.mainImageIdentifier); - } - - this.newImageFiles - .filter((f) => f.name !== this.mainImageIdentifier) - .forEach((file) => formData.append('AdditionalImageFiles', file)); - - return formData; - } - - // Private Helfermethoden - private prepareSubmissionData(): void { - const nameControl = this.productForm.get('name'); - const slugControl = this.productForm.get('slug'); - const skuControl = this.productForm.get('sku'); - - if (nameControl && slugControl && !slugControl.value) { - slugControl.setValue(this.generateSlug(nameControl.value), { - emitEvent: false, - }); - } - if (nameControl && skuControl && !skuControl.value) { - skuControl.setValue(this.generateSkuValue(nameControl.value), { - emitEvent: false, - }); - } - } - - private subscribeToNameChanges(): void { - const nameControl = this.productForm.get('name'); - const slugControl = this.productForm.get('slug'); - - if (nameControl && slugControl) { - this.nameChangeSubscription = nameControl.valueChanges - .pipe(debounceTime(400), distinctUntilChanged()) - .subscribe((name) => { - if (name && !slugControl.dirty) { - slugControl.setValue(this.generateSlug(name)); - } - }); - } - } - - private generateSlug(name: string): string { - return name - .toLowerCase() - .replace(/\s+/g, '-') - .replace( - /[äöüß]/g, - (char) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[char] || '') - ) - .replace(/[^a-z0-9-]/g, '') - .replace(/-+/g, '-'); - } - - private generateSkuValue(name: string): string { - const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, ''); - const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase(); - return `${prefix}-${randomPart}`; - } - - 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.ts b/src/app/features/components/products/product-list/product-list.component.ts index ab600e2..072c3b0 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 @@ -16,7 +16,7 @@ import { IconComponent } from '../../../../shared/components/ui/icon/icon.compon @Component({ selector: 'app-product-list', standalone: true, - imports: [CommonModule, CurrencyPipe, GenericTableComponent, SearchBarComponent, ButtonComponent, IconComponent], + imports: [CommonModule, GenericTableComponent, SearchBarComponent, ButtonComponent, IconComponent], providers: [DatePipe], templateUrl: './product-list.component.html', styleUrl: './product-list.component.css'