diff --git a/src/app/features/components/products/product-create/product-create.component.html b/src/app/features/components/products/product-create/product-create.component.html index f57c8af..e246ac8 100644 --- a/src/app/features/components/products/product-create/product-create.component.html +++ b/src/app/features/components/products/product-create/product-create.component.html @@ -9,19 +9,16 @@ - + [productForm]="productForm" + [allCategories]="(allCategories$ | async) || []" + [supplierOptions]="(supplierOptions$ | async) || []" + [allImages]="allImagesForForm" + [isLoading]="isLoading" + submitButtonText="Produkt erstellen" + (formSubmit)="onSubmit()" + (formCancel)="cancel()" + (filesSelected)="onFilesSelected($event)" + (setMainImage)="onSetMainImage($event)" + (deleteImage)="onDeleteImage($event)"> + diff --git a/src/app/features/components/products/product-create/product-create.component.ts b/src/app/features/components/products/product-create/product-create.component.ts index 316d59d..1ee3554 100644 --- a/src/app/features/components/products/product-create/product-create.component.ts +++ b/src/app/features/components/products/product-create/product-create.component.ts @@ -1,4 +1,5 @@ // /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'; @@ -9,25 +10,27 @@ import { ReactiveFormsModule, Validators, } from '@angular/forms'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { Observable, Subscription } from 'rxjs'; import { debounceTime, distinctUntilChanged, map, startWith, - tap, } from 'rxjs/operators'; -// Models, Services und UI-Komponenten importieren +// 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 { CardComponent } from '../../../../shared/components/ui/card/card.component'; -import { 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 { DomSanitizer, SafeUrl } from '@angular/platform-browser'; @Component({ selector: 'app-product-create', @@ -42,6 +45,7 @@ import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; styleUrls: ['./product-create.component.css'], }) export class ProductCreateComponent implements OnInit, OnDestroy { + // --- Injektionen --- private sanitizer = inject(DomSanitizer); private router = inject(Router); private productService = inject(ProductService); @@ -50,18 +54,17 @@ export class ProductCreateComponent implements OnInit, OnDestroy { private fb = inject(FormBuilder); private snackbarService = inject(SnackbarService); + // --- Komponenten-Status --- isLoading = true; productForm: FormGroup; - allCategories$!: Observable; supplierOptions$!: Observable; - private nameChangeSubscription?: Subscription; - mainImageFile: File | null = null; - additionalImageFiles: File[] = []; - mainImagePreview: string | null = null; - additionalImagesPreview: { name: string; url: SafeUrl }[] = []; + // --- Bild-Management State --- + newImageFiles: File[] = []; + mainImageIdentifier: string | null = null; + allImagesForForm: ImagePreview[] = []; constructor() { this.productForm = this.fb.group({ @@ -82,6 +85,7 @@ export class ProductCreateComponent implements OnInit, OnDestroy { }); } + // --- Lifecycle Hooks --- ngOnInit(): void { this.loadDropdownData(); this.subscribeToNameChanges(); @@ -90,8 +94,13 @@ export class ProductCreateComponent implements OnInit, OnDestroy { 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.supplierService.getAll().pipe( @@ -109,11 +118,13 @@ export class ProductCreateComponent implements OnInit, OnDestroy { return; } + // 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 erstellt'); + this.snackbarService.show('Produkt erfolgreich erstellt'); this.router.navigate(['/admin/products']); }, error: (err) => { @@ -127,9 +138,60 @@ export class ProductCreateComponent implements OnInit, OnDestroy { this.router.navigate(['/admin/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) + ); + + 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'); + + 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.value; + const formValue = this.productForm.getRawValue(); Object.keys(formValue).forEach((key) => { const value = formValue[key]; @@ -142,12 +204,18 @@ export class ProductCreateComponent implements OnInit, OnDestroy { } }); - if (this.mainImageFile) { - formData.append('MainImageFile', this.mainImageFile); + const mainImageFile = this.newImageFiles.find( + (f) => f.name === this.mainImageIdentifier + ); + if (mainImageFile) { + formData.append('MainImageFile', mainImageFile); } - this.additionalImageFiles.forEach((file) => { - formData.append('AdditionalImageFiles', file); - }); + + // 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; } @@ -155,8 +223,8 @@ export class ProductCreateComponent implements OnInit, OnDestroy { private subscribeToNameChanges(): void { this.nameChangeSubscription = this.productForm .get('name') - ?.valueChanges.pipe(debounceTime(300), distinctUntilChanged()) - .subscribe((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); @@ -164,61 +232,25 @@ export class ProductCreateComponent implements OnInit, OnDestroy { }); } - onMainFileSelected(file: File): void { - this.mainImageFile = file; - const reader = new FileReader(); - reader.onload = () => { - this.mainImagePreview = reader.result as string; - }; - reader.readAsDataURL(file); - } - private generateSlug(name: string): string { return name .toLowerCase() .replace(/\s+/g, '-') - .replace(/[äöüß]/g, (char) => { - switch (char) { - case 'ä': - return 'ae'; - case 'ö': - return 'oe'; - case 'ü': - return 'ue'; - case 'ß': - return 'ss'; - default: - return ''; - } - }) + .replace( + /[äöüß]/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); } - - onAdditionalFilesSelected(files: File[]): void { - this.additionalImageFiles.push(...files); - files.forEach((file) => { - const reader = new FileReader(); - reader.onload = () => { - this.additionalImagesPreview.push({ - name: file.name, - url: this.sanitizer.bypassSecurityTrustUrl(reader.result as string), - }); - }; - reader.readAsDataURL(file); - }); - } - - onNewImageRemoved(fileName: string): void { - this.additionalImageFiles = this.additionalImageFiles.filter( - (f) => f.name !== fileName - ); - this.additionalImagesPreview = this.additionalImagesPreview.filter( - (p) => p.name !== fileName - ); - } } diff --git a/src/app/features/components/products/product-edit/product-edit.component.html b/src/app/features/components/products/product-edit/product-edit.component.html index de70fff..2c0510e 100644 --- a/src/app/features/components/products/product-edit/product-edit.component.html +++ b/src/app/features/components/products/product-edit/product-edit.component.html @@ -1,3 +1,5 @@ + + Lade Produktdaten... @@ -10,17 +12,14 @@ [productForm]="productForm" [allCategories]="(allCategories$ | async) || []" [supplierOptions]="(supplierOptions$ | async) || []" - [existingImages]="existingImages" - [mainImagePreview]="mainImagePreview" - [additionalImagesPreview]="additionalImagesPreview" + [allImages]="allImagesForForm" [isLoading]="isLoading" submitButtonText="Änderungen speichern" (formSubmit)="onSubmit()" (formCancel)="cancel()" - (mainFileSelected)="onMainFileSelected($event)" - (additionalFilesSelected)="onAdditionalFilesSelected($event)" - (existingImageDeleted)="onExistingImageDeleted($event)" - (newImageRemoved)="onNewImageRemoved($event)" + (filesSelected)="onFilesSelected($event)" + (setMainImage)="onSetMainImage($event)" + (deleteImage)="onDeleteImage($event)" > 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 a6f00a8..32306e6 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,55 +1,33 @@ +// /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, SafeUrl } from '@angular/platform-browser'; +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, -} from 'rxjs/operators'; +import { map, startWith, debounceTime, distinctUntilChanged, finalize } from 'rxjs/operators'; -// Models -import { - AdminProduct, - ProductImage, -} from '../../../../core/models/product.model'; +// Models, Services und UI-Komponenten +import { AdminProduct, ProductImage } from '../../../../core/models/product.model'; import { Category } from '../../../../core/models/category.model'; - -// Services 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'; - -// UI Components import { CardComponent } from '../../../../shared/components/ui/card/card.component'; -import { 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'; @Component({ selector: 'app-product-edit', standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - CardComponent, - ProductFormComponent, - ], + imports: [ CommonModule, ReactiveFormsModule, CardComponent, ProductFormComponent ], templateUrl: './product-edit.component.html', styleUrls: ['./product-edit.component.css'], }) export class ProductEditComponent implements OnInit, OnDestroy { - // Service-Injektionen + // --- Injektionen --- private route = inject(ActivatedRoute); private router = inject(Router); private productService = inject(ProductService); @@ -59,26 +37,23 @@ export class ProductEditComponent implements OnInit, OnDestroy { private snackbarService = inject(SnackbarService); private sanitizer = inject(DomSanitizer); - // Komponenten-Status + // --- Komponenten-Status --- productId!: string; isLoading = true; productForm: FormGroup; private nameChangeSubscription?: Subscription; - - // Daten für Dropdowns und Formular allCategories$!: Observable; supplierOptions$!: Observable; - // State für Bild-Management + // --- NEUER STATE FÜR BILD-MANAGEMENT --- existingImages: ProductImage[] = []; - mainImageFile: File | null = null; - additionalImageFiles: File[] = []; - mainImagePreview: string | SafeUrl | null = null; - additionalImagesPreview: { name: string; url: SafeUrl }[] = []; + 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], // ID ist für das Update zwingend erforderlich + id: ['', Validators.required], name: ['', Validators.required], slug: ['', Validators.required], sku: ['', Validators.required], @@ -93,13 +68,10 @@ export class ProductEditComponent implements OnInit, OnDestroy { featuredDisplayOrder: [0], supplierId: [null], categorieIds: this.fb.array([]), - imagesToDelete: this.fb.array([]), // Wird für das Backend gefüllt + imagesToDelete: this.fb.array([]), }); } - get categorieIds(): FormArray { - return this.productForm.get('categorieIds') as FormArray; - } get imagesToDelete(): FormArray { return this.productForm.get('imagesToDelete') as FormArray; } @@ -107,12 +79,11 @@ export class ProductEditComponent implements OnInit, OnDestroy { ngOnInit(): void { const id = this.route.snapshot.paramMap.get('id'); if (!id) { - this.snackbarService.show('Produkt-ID fehlt. Umleitung zur Übersicht.'); + this.snackbarService.show('Produkt-ID fehlt.'); this.router.navigate(['/admin/products']); return; } this.productId = id; - this.loadDropdownData(); this.loadProductData(); this.subscribeToNameChanges(); @@ -120,21 +91,26 @@ export class ProductEditComponent implements OnInit, OnDestroy { 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.supplierService.getAll().pipe( - map((suppliers) => - suppliers.map((s) => ({ value: s.id, label: s.name || 'Unbenannt' })) - ), + map(suppliers => suppliers.map(s => ({ value: s.id, label: s.name || 'Unbenannt' }))), startWith([]) ); } loadProductData(): void { this.isLoading = true; - this.productService.getById(this.productId).subscribe({ + this.productService.getById(this.productId).pipe( + finalize(() => { this.isLoading = false; }) + ).subscribe({ next: (product) => { if (product) { this.populateForm(product); @@ -142,11 +118,10 @@ export class ProductEditComponent implements OnInit, OnDestroy { this.snackbarService.show('Produkt nicht gefunden.'); this.router.navigate(['/admin/products']); } - this.isLoading = false; }, - error: () => { + error: (err) => { this.snackbarService.show('Fehler beim Laden des Produkts.'); - this.isLoading = false; + console.error('Fehler beim Laden der Produktdaten:', err); this.router.navigate(['/admin/products']); }, }); @@ -154,15 +129,15 @@ export class ProductEditComponent implements OnInit, OnDestroy { populateForm(product: AdminProduct): void { this.productForm.patchValue(product); - - this.categorieIds.clear(); - product.categorieIds?.forEach((id) => - this.categorieIds.push(new FormControl(id)) - ); + 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.mainImagePreview = mainImage?.url ?? null; + 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 { @@ -171,78 +146,100 @@ export class ProductEditComponent implements OnInit, OnDestroy { 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(['/admin/products']); }, error: (err) => { - this.snackbarService.show( - 'Ein Fehler ist aufgetreten. Details siehe Konsole.' - ); + this.snackbarService.show('Ein Fehler ist aufgetreten.'); console.error(err); }, }); } - cancel(): void { - this.router.navigate(['/admin/products']); + cancel(): void { this.router.navigate(['/admin/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(); } - // Handler für Bild-Events von der Form-Komponente - onMainFileSelected(file: File): void { - this.mainImageFile = file; - const reader = new FileReader(); - reader.onload = () => { - this.mainImagePreview = this.sanitizer.bypassSecurityTrustUrl( - reader.result as string - ); - }; - reader.readAsDataURL(file); + onSetMainImage(identifier: string): void { + this.mainImageIdentifier = identifier; + this.rebuildAllImagesForForm(); } - onAdditionalFilesSelected(files: File[]): void { - this.additionalImageFiles.push(...files); - files.forEach((file) => { - const reader = new FileReader(); - reader.onload = () => { - this.additionalImagesPreview.push({ - name: file.name, - url: this.sanitizer.bypassSecurityTrustUrl(reader.result as string), - }); - }; - reader.readAsDataURL(file); + 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; } - onExistingImageDeleted(imageId: string): void { - if (!this.imagesToDelete.value.includes(imageId)) { - this.imagesToDelete.push(new FormControl(imageId)); - } - this.existingImages = this.existingImages.filter( - (img) => img.id !== imageId - ); - // Wenn das gelöschte Bild das Hauptbild war, leeren wir die Vorschau - if ( - this.mainImagePreview === - this.existingImages.find((i) => i.id === imageId)?.url - ) { - this.mainImagePreview = null; + 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; } - onNewImageRemoved(fileName: string): void { - this.additionalImageFiles = this.additionalImageFiles.filter( - (f) => f.name !== fileName - ); - this.additionalImagesPreview = this.additionalImagesPreview.filter( - (p) => p.name !== fileName - ); - } // Private Helfermethoden private prepareSubmissionData(): void { @@ -262,31 +259,7 @@ export class ProductEditComponent implements OnInit, OnDestroy { } } - 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()); - } - }); - - if (this.mainImageFile) - formData.append('MainImageFile', this.mainImageFile); - this.additionalImageFiles.forEach((file) => - formData.append('AdditionalImageFiles', file) - ); - - return formData; - } - - private subscribeToNameChanges(): void { + private subscribeToNameChanges(): void { const nameControl = this.productForm.get('name'); const slugControl = this.productForm.get('slug'); @@ -301,7 +274,7 @@ export class ProductEditComponent implements OnInit, OnDestroy { } } - private generateSlug(name: string): string { + private generateSlug(name: string): string { return name .toLowerCase() .replace(/\s+/g, '-') @@ -313,7 +286,7 @@ export class ProductEditComponent implements OnInit, OnDestroy { .replace(/-+/g, '-'); } - private generateSkuValue(name: string): string { + 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}`; @@ -322,4 +295,12 @@ export class ProductEditComponent implements OnInit, OnDestroy { private capitalizeFirstLetter(string: string): string { return string.charAt(0).toUpperCase() + string.slice(1); } + + + + + + + + } diff --git a/src/app/features/components/products/product-form/product-form.component.css b/src/app/features/components/products/product-form/product-form.component.css index 947b799..25ca13b 100644 --- a/src/app/features/components/products/product-form/product-form.component.css +++ b/src/app/features/components/products/product-form/product-form.component.css @@ -1,267 +1,114 @@ /* /src/app/features/admin/components/products/product-form/product-form.component.css */ -/* ========================================================================== - Globale Variablen & Grund-Layout - ========================================================================== */ - :host { - --form-spacing-vertical: 1.5rem; /* Vertikaler Abstand zwischen Formular-Sektionen */ - --form-spacing-horizontal: 1.5rem; /* Innenabstand der Sektionen */ - --grid-gap: 1.5rem; /* Abstand zwischen den Spalten und Karten */ + --form-spacing-vertical: 1.5rem; + --form-spacing-horizontal: 1.5rem; + --grid-gap: 1.5rem; --border-radius: 8px; - --text-color-secondary: #64748b; --background-color-light: #f8fafc; } -.edit-layout { - display: grid; - grid-template-columns: 2fr 1fr; /* 2/3 für Hauptinhalt, 1/3 für Sidebar */ - gap: var(--grid-gap); -} - -.main-content, -.sidebar-content { - display: flex; - flex-direction: column; - gap: var(--grid-gap); -} +.edit-layout { display: grid; grid-template-columns: 2fr 1fr; gap: var(--grid-gap); } +.main-content, .sidebar-content { display: flex; flex-direction: column; gap: var(--grid-gap); } +app-card { display: block; width: 100%; } +.form-section { padding: var(--form-spacing-horizontal); display: flex; flex-direction: column; gap: var(--form-spacing-vertical); } +h4[card-header] { margin-bottom: 0; } +.form-field { display: flex; flex-direction: column; gap: 0.5rem; } +.form-label { font-weight: 500; color: #334155; } +.required-indicator { color: var(--color-danger); margin-left: 4px; } +.form-hint { font-size: 0.875rem; color: var(--text-color-secondary); margin-top: -0.75rem; } +.input-with-button { display: flex; gap: 0.5rem; } +.input-with-button .form-input { flex-grow: 1; } +.price-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: var(--grid-gap); } /* ========================================================================== - Karten-Styling (app-card) + BILDER-MANAGEMENT STYLING (FINAL & KORRIGIERT) ========================================================================== */ - -/* Wir stylen die app-card von außen, um ein konsistentes Layout zu gewährleisten. */ -app-card { - display: block; /* Stellt sicher, dass die Karte den vollen Platz einnimmt */ - width: 100%; -} - -/* Stile, die innerhalb der Karten gelten */ -.form-section { - padding: var(--form-spacing-horizontal); - display: flex; - flex-direction: column; - gap: var(--form-spacing-vertical); -} - -h4[card-header] { - margin-bottom: 0; /* Entfernt Standard-Margin des Headers */ -} - -/* ========================================================================== - Allgemeine Formular-Elemente - ========================================================================== */ - -.form-field { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.form-label { - font-weight: 500; - color: #334155; -} - -.form-input, -input[type="text"], -input[type="number"] { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid var(--color-border); - border-radius: var(--border-radius); - transition: border-color 0.2s, box-shadow 0.2s; -} - -.form-input:focus, -input[type="text"]:focus, -input[type="number"]:focus { - outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.2); -} - -.form-hint { - font-size: 0.875rem; - color: var(--text-color-secondary); - margin-top: -0.75rem; /* Reduziert den Abstand nach oben */ -} - -.input-with-button { - display: flex; - gap: 0.5rem; -} - -.input-with-button .form-input { - flex-grow: 1; -} - -/* ========================================================================== - Spezifisches Sektions-Styling - ========================================================================== */ - -/* Preisgestaltung Grid */ -.price-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: var(--grid-gap); -} - -/* Bild-Management */ .image-upload-section { - padding-bottom: 1rem; border-bottom: 1px solid var(--color-border); -} -.image-upload-section:last-of-type { - border-bottom: none; - padding-bottom: 0; + padding-bottom: 1.5rem; } -.image-gallery { - margin-top: 1rem; -} +.gallery-title { font-size: 1rem; font-weight: 600; color: var(--text-color-secondary); margin-bottom: 0.25rem; } -.gallery-title { - font-size: 1rem; - font-weight: 600; - color: var(--text-color-secondary); - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--color-border); - margin-bottom: 1rem; +.image-gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 1.5rem; } .image-preview-container { - position: relative; + position: relative; /* Wichtig für die Positionierung des Buttons */ width: 100px; height: 100px; - border-radius: var(--border-radius); - overflow: hidden; - border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + overflow: visible; /* Erlaubt dem Button, leicht überzulappen */ + border: 3px solid transparent; + cursor: pointer; + transition: border-color 0.2s ease-in-out, transform 0.2s ease-in-out; +} + +.image-preview-container:hover { + transform: scale(1.05); +} + +.image-preview-container.is-main { + border-color: var(--color-primary); } .image-preview { width: 100%; height: 100%; object-fit: cover; + border-radius: var(--border-radius); /* Abgerundete Ecken für das Bild selbst */ } -.main-image-preview { - width: 100%; - height: auto; - max-height: 250px; - margin-bottom: 1rem; -} - -.main-image-badge { +/* Styling für den Löschen-Button als Overlay */ +.delete-overlay-button { position: absolute; - top: 8px; - left: 8px; - background-color: var(--color-primary); - color: white; - padding: 4px 8px; - border-radius: 16px; - font-size: 0.75rem; - font-weight: 500; -} - -.image-preview-container app-button { - position: absolute; - top: 4px; - right: 4px; - background-color: rgba(0, 0, 0, 0.5); - border-radius: 50%; - --button-icon-color: white; /* Angenommen, dein Button kann dies überschreiben */ - opacity: 0; - transition: opacity 0.2s ease-in-out; -} - -.image-preview-container:hover app-button { - opacity: 1; -} - -.additional-images-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); - gap: 1rem; - margin-top: 1rem; -} - -/* Kategorien-Auswahl */ -.multi-select-container { - border: 1px solid var(--color-border); - border-radius: var(--border-radius); -} - -.selected-pills { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - padding: 0.75rem; - border-bottom: 1px solid var(--color-border); -} - -.pill { + top: -8px; + right: -8px; + z-index: 10; display: flex; align-items: center; - gap: 0.5rem; - background-color: var(--background-color-light); - border: 1px solid var(--color-border); - padding: 0.25rem 0.75rem; - border-radius: 16px; - font-size: 0.875rem; + justify-content: center; + width: 26px; + height: 26px; + background-color: var(--color-danger); + color: white; + border: 2px solid var(--color-body-bg-lighter, white); + border-radius: 50%; + padding: 0; + cursor: pointer; + opacity: 0; + transform: scale(0.8); + transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; } -.pill app-icon { - cursor: pointer; +.image-preview-container:hover .delete-overlay-button { + opacity: 1; + transform: scale(1); +} + +.delete-overlay-button app-icon { width: 16px; height: 16px; } -.pill app-icon:hover { - color: var(--color-danger); -} - -.placeholder { - color: var(--text-color-secondary); -} - -.category-checkbox-group { - max-height: 200px; - overflow-y: auto; - padding: 0.75rem; -} - -.category-checkbox-group label { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.5rem 0; -} /* ========================================================================== - Aktions-Buttons - ========================================================================== */ - -.form-actions { - display: flex; - justify-content: flex-end; - gap: 1rem; - margin-top: var(--grid-gap); - padding-top: var(--grid-gap); - border-top: 1px solid var(--color-border); -} - -/* ========================================================================== - Responsives Design + Kategorien- & Formular-Aktionen ========================================================================== */ +.multi-select-container { border: 1px solid var(--color-border); border-radius: var(--border-radius); } +.selected-pills { display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.75rem; border-bottom: 1px solid var(--color-border); } +.pill { display: flex; align-items: center; gap: 0.5rem; background-color: var(--background-color-light); border: 1px solid var(--color-border); padding: 0.25rem 0.75rem; border-radius: 16px; font-size: 0.875rem; } +.pill app-icon { cursor: pointer; width: 16px; height: 16px; } +.pill app-icon:hover { color: var(--color-danger); } +.placeholder { color: var(--text-color-secondary); } +.category-checkbox-group { max-height: 200px; overflow-y: auto; padding: 0.75rem; } +.category-checkbox-group label { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0; } +.form-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: var(--grid-gap); padding-top: var(--grid-gap); border-top: 1px solid var(--color-border); } @media (max-width: 1024px) { - .edit-layout { - grid-template-columns: 1fr; /* Eine Spalte auf kleineren Bildschirmen */ - } -} - -.required-indicator { - color: var(--color-danger); - margin-left: 4px; -} + .edit-layout { grid-template-columns: 1fr; } +} \ No newline at end of file diff --git a/src/app/features/components/products/product-form/product-form.component.html b/src/app/features/components/products/product-form/product-form.component.html index 4fbb4f5..5d455b4 100644 --- a/src/app/features/components/products/product-form/product-form.component.html +++ b/src/app/features/components/products/product-form/product-form.component.html @@ -2,151 +2,93 @@ - - - - - - - Allgemein - - - - + + - - + + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + - - - Produktbilder - - - Hauptbild - Laden Sie hier das primäre Bild hoch. - - - - Zusätzliche Bilder - Weitere Bilder für die Produktdetailseite. - - - - - Vorschau - - - Hauptbild - - - - - - - - - + Klicken Sie auf ein Bild, um es als Hauptbild festzulegen. - + - - Preisgestaltung + + - - - - - - - - + + + - + - - - + - - - Status - - Aktiv (im Shop sichtbar) - - + + + + + + + + + + + - + Organisation - - - SKU (Artikelnummer) * + - - + + - - - - - - - - - - + + + - - + Kategorien @@ -167,35 +109,13 @@ - - - - Hervorheben - - Auf Startseite anzeigen - - - - - + - - - + Abbrechen - - {{ submitButtonText }} - + {{ submitButtonText }} \ No newline at end of file diff --git a/src/app/features/components/products/product-form/product-form.component.ts b/src/app/features/components/products/product-form/product-form.component.ts index fa3f55b..7f34caa 100644 --- a/src/app/features/components/products/product-form/product-form.component.ts +++ b/src/app/features/components/products/product-form/product-form.component.ts @@ -1,31 +1,43 @@ // /src/app/features/admin/components/products/product-form/product-form.component.ts -import { Component, Input, Output, EventEmitter, inject, OnChanges, SimpleChanges } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormGroup, FormArray, ReactiveFormsModule, FormControl } from '@angular/forms'; +import { Component, Input, Output, EventEmitter, inject } from '@angular/core'; +import { CommonModule, NgClass } from '@angular/common'; +import { + FormGroup, + FormArray, + ReactiveFormsModule, + FormControl, +} from '@angular/forms'; import { SafeUrl } from '@angular/platform-browser'; -// Models -import { ProductImage } from '../../../../core/models/product.model'; +// Models & UI Components import { Category } from '../../../../core/models/category.model'; - -// Services -import { SnackbarService } from '../../../../shared/services/snackbar.service'; - -// UI Components 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 { + 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'; + +// 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, CardComponent, ButtonComponent, @@ -34,69 +46,66 @@ import { SlideToggleComponent } from '../../../../shared/components/form/slide-t FormSelectComponent, FormTextareaComponent, SlideToggleComponent, + FormGroupComponent, ], templateUrl: './product-form.component.html', - styleUrls: ['./product-form.component.css'] + 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'; - - // Inputs & Outputs für Bilder - @Input() existingImages: ProductImage[] = []; - @Input() mainImagePreview: string | SafeUrl | null = null; - @Input() additionalImagesPreview: { name: string, url: string | SafeUrl }[] = []; + // 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() mainFileSelected = new EventEmitter(); - @Output() additionalFilesSelected = new EventEmitter(); - @Output() existingImageDeleted = new EventEmitter(); - @Output() newImageRemoved = 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); // GETTER FÜR DAS TEMPLATE - get categorieIds(): FormArray { return this.productForm.get('categorieIds') as FormArray; } - + get categorieIds(): FormArray { + return this.productForm.get('categorieIds') as FormArray; + } get hasImages(): boolean { - return !!this.mainImagePreview || this.additionalImagesPreview.length > 0 || this.existingImages.length > 0; - } - - // NEU: Getter, der die Filter-Logik aus dem Template entfernt - get additionalExistingImages(): ProductImage[] { - return this.existingImages.filter(image => !image.isMainImage); + return this.allImages && this.allImages.length > 0; } - // FORMULAR-INTERAKTIONEN - onSubmit(): void { this.formSubmit.emit(); } - cancel(): void { this.formCancel.emit(); } - - // BILD-MANAGEMENT - onMainFileChange(event: Event): void { - const file = (event.target as HTMLInputElement).files?.[0]; - if (file) this.mainFileSelected.emit(file); + // --- EVENT-HANDLER --- + onSubmit(): void { + this.formSubmit.emit(); } - - onAdditionalFilesChange(event: Event): void { + cancel(): void { + this.formCancel.emit(); + } + + onFilesSelected(event: Event): void { const files = (event.target as HTMLInputElement).files; - if (files) this.additionalFilesSelected.emit(Array.from(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 = ''; } - deleteExistingImage(imageId: string, event: Event): void { - event.preventDefault(); - this.existingImageDeleted.emit(imageId); + setAsMainImage(identifier: string): void { + this.setMainImage.emit(identifier); } - removeNewImage(fileName: string, event: Event): void { - event.preventDefault(); - this.newImageRemoved.emit(fileName); + requestImageDeletion(identifier: string, event: MouseEvent): void { + event.stopPropagation(); // Verhindert, dass gleichzeitig setAsMainImage gefeuert wird + this.deleteImage.emit(identifier); } - // KATEGORIE- & SKU-HELFER + // --- Helfermethoden (unverändert) --- generateSku(): void { const name = this.productForm.get('name')?.value || 'PROD'; const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, ''); @@ -114,7 +123,9 @@ export class ProductFormComponent { this.categorieIds.push(new FormControl(categoryId)); } } else { - const index = this.categorieIds.controls.findIndex(x => x.value === categoryId); + const index = this.categorieIds.controls.findIndex( + (x) => x.value === categoryId + ); if (index !== -1) this.categorieIds.removeAt(index); } } @@ -124,11 +135,13 @@ export class ProductFormComponent { } getCategoryName(categoryId: string): string { - return this.allCategories.find(c => c.id === categoryId)?.name || ''; + return this.allCategories.find((c) => c.id === categoryId)?.name || ''; } removeCategoryById(categoryId: string): void { - const index = this.categorieIds.controls.findIndex(x => x.value === categoryId); + const index = this.categorieIds.controls.findIndex( + (x) => x.value === categoryId + ); if (index !== -1) this.categorieIds.removeAt(index); } -} \ No newline at end of file +} diff --git a/src/app/shared/components/form/form-group/form-group.component.css b/src/app/shared/components/form/form-group/form-group.component.css index 7696d2b..f939424 100644 --- a/src/app/shared/components/form/form-group/form-group.component.css +++ b/src/app/shared/components/form/form-group/form-group.component.css @@ -23,7 +23,7 @@ font-size: 0.9rem; color: var(--color-text-light); margin-top: -0.75rem; /* Rücken wir näher an den Titel */ - margin-bottom: 1.5rem; + margin-bottom: 1rem; } .form-group-content { diff --git a/src/app/shared/components/form/form-group/form-group.component.html b/src/app/shared/components/form/form-group/form-group.component.html index d6c4290..b625e64 100644 --- a/src/app/shared/components/form/form-group/form-group.component.html +++ b/src/app/shared/components/form/form-group/form-group.component.html @@ -9,12 +9,6 @@ {{ description }} -
Lade Produktdaten...
Laden Sie hier das primäre Bild hoch.
Weitere Bilder für die Produktdetailseite.
Klicken Sie auf ein Bild, um es als Hauptbild festzulegen.