images works

This commit is contained in:
Tizian.Breuch
2025-11-25 11:24:44 +01:00
parent 2491b0142d
commit c10e6b4faa
5 changed files with 6019 additions and 312 deletions

5736
imageupdatefehler.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -41,4 +41,5 @@ export interface AdminProduct {
images?: ProductImage[]; images?: ProductImage[];
isFeatured: boolean; isFeatured: boolean;
featuredDisplayOrder: number; featuredDisplayOrder: number;
rowVersion?: string;
} }

View File

@@ -26,56 +26,80 @@ export interface SelectOption {
], ],
}) })
export class ProductCategoryDropdownComponent implements ControlValueAccessor { export class ProductCategoryDropdownComponent implements ControlValueAccessor {
@Input() label = '';
@Input() options: SelectOption[] = []; @Input() options: SelectOption[] = [];
@Input() label = '';
@Input() placeholder = 'Bitte wählen...'; @Input() placeholder = 'Bitte wählen...';
public isOpen = false;
public selectedValues: any[] = []; public selectedValues: any[] = [];
private elementRef = inject(ElementRef); public isOpen = false;
public isDisabled = false;
onChange: (value: any[]) => void = () => {}; // Callbacks, die von Angular überschrieben werden
onTouched: () => void = () => {}; private onChange: (value: any[]) => void = () => {};
private onTouched: () => void = () => {};
writeValue(values: any[]): void { this.selectedValues = Array.isArray(values) ? values : []; } // --- ControlValueAccessor Implementierung ---
registerOnChange(fn: any): void { this.onChange = fn; }
registerOnTouched(fn: any): void { this.onTouched = fn; }
@HostListener('document:click', ['$event']) // Wird von Angular aufgerufen, um Werte ins Formular zu schreiben (z.B. beim Laden)
onDocumentClick(event: Event): void { writeValue(value: any[]): void {
if (this.isOpen && !this.elementRef.nativeElement.contains(event.target)) { if (value && Array.isArray(value)) {
this.closeDropdown(); this.selectedValues = value;
} else {
this.selectedValues = [];
} }
} }
toggleDropdown(event: Event): void { // Registriert die Funktion, die aufgerufen wird, wenn sich der Wert ändert
event.stopPropagation(); registerOnChange(fn: any): void {
this.isOpen = !this.isOpen; this.onChange = fn;
if (!this.isOpen) { this.onTouched(); }
} }
closeDropdown(): void { // Registriert die Funktion, die aufgerufen wird, wenn das Feld "berührt" wurde
if (this.isOpen) { registerOnTouched(fn: any): void {
this.isOpen = false; 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(); 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 { onOptionToggle(value: any): void {
const index = this.selectedValues.indexOf(value); if (this.isSelected(value)) {
if (index > -1) { // Entfernen
this.selectedValues.splice(index, 1); this.selectedValues = this.selectedValues.filter(v => v !== value);
} else { } 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 onPillRemove(value: any, event: any): void {
event.stopPropagation(); event.stopPropagation(); // Verhindert, dass das Dropdown aufgeht/zugeht
this.onOptionToggle(value); 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 || ''; }
} }

View File

@@ -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 { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
FormBuilder,
FormGroup,
FormArray,
FormControl,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { Observable, Subscription } from 'rxjs'; import { Observable, Subscription, of } from 'rxjs';
import { import { switchMap, finalize, map } from 'rxjs/operators';
map,
startWith,
debounceTime,
distinctUntilChanged,
finalize,
} 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 { Category } from '../../../../core/models/category.model';
import { ProductService } from '../../../services/product.service'; import { ProductService } from '../../../services/product.service';
import { CategoryService } from '../../../services/category.service'; import { CategoryService } from '../../../services/category.service';
import { SupplierService } from '../../../services/supplier.service'; import { SupplierService } from '../../../services/supplier.service';
import { SnackbarService } from '../../../../shared/services/snackbar.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 { SelectOption } from '../../../../shared/components/form/form-select/form-select.component';
import { Supplier } from '../../../../core/models/supplier.model';
@Component({ @Component({
selector: 'app-product-edit', selector: 'app-product-edit',
@@ -46,304 +24,272 @@ import { SelectOption } from '../../../../shared/components/form/form-select/for
styleUrls: ['./product-edit.component.css'], styleUrls: ['./product-edit.component.css'],
}) })
export class ProductEditComponent implements OnInit, OnDestroy { export class ProductEditComponent implements OnInit, OnDestroy {
// --- Injektionen --- // --- Injected Services ---
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private fb = inject(FormBuilder);
private productService = inject(ProductService); private productService = inject(ProductService);
private categoryService = inject(CategoryService); private categoryService = inject(CategoryService);
private supplierService = inject(SupplierService); private supplierService = inject(SupplierService);
private fb = inject(FormBuilder);
private snackbarService = inject(SnackbarService); private snackbarService = inject(SnackbarService);
private sanitizer = inject(DomSanitizer); private sanitizer = inject(DomSanitizer);
// --- Komponenten-Status --- // --- Component State ---
productId!: string; public productForm!: FormGroup;
isLoading = true; public isLoading = true;
productForm: FormGroup; private productId!: string;
private nameChangeSubscription?: Subscription; private subscriptions = new Subscription();
allCategories$!: Observable<Category[]>;
supplierOptions$!: Observable<SelectOption[]>;
// --- NEUER STATE FÜR BILD-MANAGEMENT --- // WICHTIG: Die RowVersion speichern wir hier, nicht im Formular
existingImages: ProductImage[] = []; private loadedRowVersion: string | null = null;
newImageFiles: File[] = [];
mainImageIdentifier: string | null = null; // Hält die ID oder den Dateinamen des Hauptbildes public allCategories$!: Observable<Category[]>;
allImagesForForm: ImagePreview[] = []; // Die kombinierte Liste für die Child-Komponente public supplierOptions$!: Observable<SelectOption[]>;
// --- Image Management ---
public allImagesForForm: ImagePreview[] = [];
private newImageFiles = new Map<string, File>();
private imagesToDelete: string[] = [];
constructor() { constructor() {
this.productForm = this.fb.group({ this.initForm();
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 { private initForm(): void {
return this.productForm.get('imagesToDelete') as FormArray; // 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 { ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id'); this.loadInitialData();
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 { private loadInitialData(): void {
this.nameChangeSubscription?.unsubscribe(); this.isLoading = true;
this.allImagesForForm.forEach((image) => { this.allCategories$ = this.categoryService.getAll();
if (typeof image.url === 'object') {
// Nur Object-URLs von neuen Bildern freigeben this.supplierOptions$ = this.supplierService.getAll().pipe(
URL.revokeObjectURL(image.url as string); 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 { private initializeImages(images: ProductImage[]): void {
this.allCategories$ = this.categoryService.getAll(); this.allImagesForForm = images
this.supplierOptions$ = this.supplierService.getAll().pipe( .filter(img => !!img.url)
map((suppliers) => .map(img => ({
suppliers.map((s) => ({ value: s.id, label: s.name || 'Unbenannt' })) identifier: img.id,
), url: img.url as string,
startWith([]) isMainImage: img.isMainImage,
); })).sort((a, b) => (a.isMainImage ? -1 : 1));
} }
loadProductData(): void { // --- Image Event Handlers ---
this.isLoading = true;
this.productService onFilesSelected(files: File[]): void {
.getById(this.productId) for (const file of files) {
.pipe( const tempId = `new_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
finalize(() => { const url = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(file));
this.isLoading = false;
}) this.newImageFiles.set(tempId, file);
)
.subscribe({ this.allImagesForForm.push({
next: (product) => { identifier: tempId,
if (product) { url: url,
this.populateForm(product); isMainImage: this.allImagesForForm.length === 1, // Wenn es das erste Bild ist, direkt Main
} 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) onSetMainImage(identifier: string): void {
populateForm(product: AdminProduct): void { this.allImagesForForm.forEach(img => {
// This single line handles ALL form controls, including setting img.isMainImage = img.identifier === identifier;
// the 'categorieIds' FormControl to the array from the product object. });
this.productForm.patchValue(product); }
// 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 deletedImage = this.allImagesForForm[imageIndex];
const mainImage = this.existingImages.find((img) => img.isMainImage); this.allImagesForForm.splice(imageIndex, 1);
this.mainImageIdentifier =
mainImage?.id ||
(this.existingImages.length > 0 ? this.existingImages[0].id : null);
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 { onSubmit(): void {
if (this.productForm.invalid) { if (this.productForm.invalid) {
this.productForm.markAllAsTouched(); 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; return;
} }
this.prepareSubmissionData();
const formData = this.createFormData(); this.isLoading = true;
this.productService.update(this.productId, formData).subscribe({ const formData = this.createUpdateFormData();
this.productService.update(this.productId, formData).pipe(
finalize(() => this.isLoading = false)
).subscribe({
next: () => { next: () => {
this.snackbarService.show('Produkt erfolgreich aktualisiert'); this.snackbarService.show('Produkt erfolgreich aktualisiert!');
this.router.navigate(['/shop/products']); this.router.navigate(['/shop/products']);
}, },
error: (err) => { error: (err) => {
this.snackbarService.show('Ein Fehler ist aufgetreten.'); console.error('Update failed', err);
console.error(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 { cancel(): void {
this.router.navigate(['/shop/products']); this.router.navigate(['/shop/products']);
} }
// --- NEUE EVENT-HANDLER FÜR BILDER --- ngOnDestroy(): void {
onFilesSelected(files: File[]): void { this.subscriptions.unsubscribe();
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
);
}
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);
} }
} }

View File

@@ -16,7 +16,7 @@ import { IconComponent } from '../../../../shared/components/ui/icon/icon.compon
@Component({ @Component({
selector: 'app-product-list', selector: 'app-product-list',
standalone: true, standalone: true,
imports: [CommonModule, CurrencyPipe, GenericTableComponent, SearchBarComponent, ButtonComponent, IconComponent], imports: [CommonModule, GenericTableComponent, SearchBarComponent, ButtonComponent, IconComponent],
providers: [DatePipe], providers: [DatePipe],
templateUrl: './product-list.component.html', templateUrl: './product-list.component.html',
styleUrl: './product-list.component.css' styleUrl: './product-list.component.css'