good
This commit is contained in:
@@ -9,19 +9,16 @@
|
||||
</div>
|
||||
|
||||
<app-product-form
|
||||
[productForm]="productForm"
|
||||
[allCategories]="(allCategories$ | async) || []"
|
||||
[supplierOptions]="(supplierOptions$ | async) || []"
|
||||
[existingImages]="[]"
|
||||
[mainImagePreview]="mainImagePreview"
|
||||
[additionalImagesPreview]="additionalImagesPreview"
|
||||
[isLoading]="isLoading"
|
||||
submitButtonText="Produkt erstellen"
|
||||
(formSubmit)="onSubmit()"
|
||||
(formCancel)="cancel()"
|
||||
(mainFileSelected)="onMainFileSelected($event)"
|
||||
(additionalFilesSelected)="onAdditionalFilesSelected($event)"
|
||||
(newImageRemoved)="onNewImageRemoved($event)"
|
||||
>
|
||||
</app-product-form>
|
||||
[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)">
|
||||
</app-product-form>
|
||||
</ng-template>
|
||||
|
||||
@@ -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<Category[]>;
|
||||
supplierOptions$!: Observable<SelectOption[]>;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<!-- /src/app/features/admin/components/products/product-edit/product-edit.component.html -->
|
||||
|
||||
<div *ngIf="isLoading; else formContent" class="loading-container">
|
||||
<p>Lade Produktdaten...</p>
|
||||
</div>
|
||||
@@ -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)"
|
||||
>
|
||||
</app-product-form>
|
||||
</ng-template>
|
||||
|
||||
@@ -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<Category[]>;
|
||||
supplierOptions$!: Observable<SelectOption[]>;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -2,151 +2,93 @@
|
||||
|
||||
<form [formGroup]="productForm" (ngSubmit)="onSubmit()" novalidate>
|
||||
<div class="edit-layout">
|
||||
|
||||
<!-- ================================================================= -->
|
||||
<!-- LINKE SPALTE (HAUPTINHALT) -->
|
||||
<!-- ================================================================= -->
|
||||
<div class="main-content">
|
||||
|
||||
<!-- Card: Allgemeine Produktinformationen -->
|
||||
<app-card>
|
||||
<h4 card-header>Allgemein</h4>
|
||||
<div class="form-section">
|
||||
<!-- Umgestellt auf die neue, intelligente Komponente -->
|
||||
<app-form-field
|
||||
label="Name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
[control]="productForm.get('name')">
|
||||
</app-form-field>
|
||||
|
||||
<app-form-group title="Allgemein" description="Hier kannst du Allgemeine PRodukt Informationen ändern....">
|
||||
|
||||
<app-form-field
|
||||
label="Slug"
|
||||
type="text"
|
||||
formControlName="slug"
|
||||
[control]="productForm.get('slug')">
|
||||
</app-form-field>
|
||||
<app-form-field label="Name" type="text" formControlName="name" [control]="productForm.get('name')"></app-form-field>
|
||||
<app-form-field label="Slug" type="text" formControlName="slug" [control]="productForm.get('slug')"></app-form-field>
|
||||
<app-form-textarea label="Beschreibung" [rows]="8" formControlName="description" [control]="productForm.get('description')"></app-form-textarea>
|
||||
|
||||
</app-form-group>
|
||||
|
||||
|
||||
<!-- Textarea bleibt eine eigene Komponente, da sie spezielle Eigenschaften hat -->
|
||||
<app-form-textarea
|
||||
label="Beschreibung"
|
||||
[rows]="8"
|
||||
formControlName="description"
|
||||
[control]="productForm.get('description')">
|
||||
</app-form-textarea>
|
||||
</div>
|
||||
</app-card>
|
||||
<!-- Card: Bild-Management (Komplett überarbeitet) -->
|
||||
<app-form-group title="Bild-Management" description="Wählen Sie Bilder für Ihr Produkt aus. Klicken Sie auf ein Bild um es es Hauptbild zu nutzen.">
|
||||
|
||||
<div>
|
||||
<input id="file-upload" type="file" accept="image/*" multiple (change)="onFilesSelected($event)" />
|
||||
</div>
|
||||
|
||||
|
||||
<div *ngIf="hasImages">
|
||||
|
||||
<div class="image-gallery-grid">
|
||||
<!-- Einheitliche Schleife mit neuem Löschen-Button -->
|
||||
<div
|
||||
*ngFor="let image of allImages"
|
||||
class="image-preview-container"
|
||||
(click)="setAsMainImage(image.identifier)"
|
||||
[ngClass]="{ 'is-main': image.isMainImage }">
|
||||
|
||||
<img [src]="image.url" [alt]="'Bildvorschau'" class="image-preview" />
|
||||
|
||||
<!-- Löschen-Button als Overlay für JEDES Bild -->
|
||||
<button
|
||||
type="button"
|
||||
class="delete-overlay-button"
|
||||
(click)="requestImageDeletion(image.identifier, $event)"
|
||||
aria-label="Bild entfernen">
|
||||
<app-icon iconName="x"></app-icon>
|
||||
</button>
|
||||
|
||||
<!-- Card: Bild-Management (bleibt unverändert, da es sich um file-inputs handelt) -->
|
||||
<app-card>
|
||||
<h4 card-header>Produktbilder</h4>
|
||||
<div class="form-section">
|
||||
<div class="image-upload-section">
|
||||
<label class="form-label">Hauptbild</label>
|
||||
<p class="form-hint">Laden Sie hier das primäre Bild hoch.</p>
|
||||
<input type="file" accept="image/*" (change)="onMainFileChange($event)" />
|
||||
</div>
|
||||
<div class="image-upload-section">
|
||||
<label class="form-label">Zusätzliche Bilder</label>
|
||||
<p class="form-hint">Weitere Bilder für die Produktdetailseite.</p>
|
||||
<input type="file" accept="image/*" multiple (change)="onAdditionalFilesChange($event)" />
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasImages" class="image-gallery">
|
||||
<h5 class="gallery-title">Vorschau</h5>
|
||||
<div *ngIf="mainImagePreview" class="image-preview-container main-image-preview">
|
||||
<img [src]="mainImagePreview" alt="Vorschau Hauptbild" class="image-preview" />
|
||||
<span class="main-image-badge">Hauptbild</span>
|
||||
</div>
|
||||
<div class="additional-images-grid">
|
||||
<div *ngFor="let image of additionalExistingImages" class="image-preview-container">
|
||||
<img [src]="image.url" [alt]="'Bild ' + image.id" class="image-preview" />
|
||||
<app-button buttonType="icon-danger" (click)="deleteExistingImage(image.id, $event)" iconName="delete"></app-button>
|
||||
</div>
|
||||
<div *ngFor="let preview of additionalImagesPreview" class="image-preview-container">
|
||||
<img [src]="preview.url" [alt]="preview.name" class="image-preview" />
|
||||
<app-button buttonType="icon-danger" (click)="removeNewImage(preview.name, $event)" iconName="delete"></app-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="gallery-hint">Klicken Sie auf ein Bild, um es als Hauptbild festzulegen.</p>
|
||||
</div>
|
||||
</app-card>
|
||||
</app-form-group>
|
||||
|
||||
<!-- Card: Preisgestaltung -->
|
||||
<app-card>
|
||||
<h4 card-header>Preisgestaltung</h4>
|
||||
<app-form-group title="Preisgestaltung" description="Produktpreise und Einkaufspreise festlegen.">
|
||||
|
||||
<div class="form-grid price-grid">
|
||||
<app-form-field
|
||||
label="Preis (€)"
|
||||
type="number"
|
||||
formControlName="price"
|
||||
[control]="productForm.get('price')">
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field
|
||||
label="Alter Preis (€)"
|
||||
type="number"
|
||||
formControlName="oldPrice"
|
||||
[control]="productForm.get('oldPrice')">
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field
|
||||
label="Einkaufspreis (€)"
|
||||
type="number"
|
||||
formControlName="purchasePrice"
|
||||
[control]="productForm.get('purchasePrice')">
|
||||
</app-form-field>
|
||||
<app-form-field label="Preis (€)" type="number" formControlName="price" [control]="productForm.get('price')"></app-form-field>
|
||||
<app-form-field label="Alter Preis (€)" type="number" formControlName="oldPrice" [control]="productForm.get('oldPrice')"></app-form-field>
|
||||
<app-form-field label="Einkaufspreis (€)" type="number" formControlName="purchasePrice" [control]="productForm.get('purchasePrice')"></app-form-field>
|
||||
</div>
|
||||
</app-card>
|
||||
</app-form-group>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================= -->
|
||||
<!-- RECHTE SPALTE (SEITENLEISTE) -->
|
||||
<!-- ================================================================= -->
|
||||
<!-- RECHTE SPALTE -->
|
||||
<div class="sidebar-content">
|
||||
<!-- Card: Status -->
|
||||
<app-card>
|
||||
<h4 card-header>Status</h4>
|
||||
<div class="form-section">
|
||||
<app-slide-toggle formControlName="isActive">Aktiv (im Shop sichtbar)</app-slide-toggle>
|
||||
</div>
|
||||
</app-card>
|
||||
<app-form-group title="Status" description="Ein Deaktiviertes Produkt ist im Shop nicht sichtbar. Hervorgehobene Produkte werden bevorzugt angezeigt.">
|
||||
|
||||
|
||||
<app-slide-toggle label="Aktiv (im Shop sichtbar)" labelPosition="right" formControlName="isActive"></app-slide-toggle>
|
||||
|
||||
|
||||
<app-slide-toggle label="Hervorheben" labelPosition="right" formControlName="isFeatured"></app-slide-toggle>
|
||||
<app-form-field *ngIf="productForm.get('isFeatured')?.value" label="Priorität" type="number" formControlName="featuredDisplayOrder" [control]="productForm.get('featuredDisplayOrder')"></app-form-field>
|
||||
|
||||
|
||||
</app-form-group>
|
||||
|
||||
<!-- Card: Organisation -->
|
||||
<app-form-group title="Organisation" description="">
|
||||
<app-card>
|
||||
<h4 card-header>Organisation</h4>
|
||||
<div class="form-section">
|
||||
<!-- SKU bleibt manuell, da es ein spezielles Layout mit einem Button hat -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">SKU (Artikelnummer) <span class="required-indicator">*</span></label>
|
||||
|
||||
<div class="input-with-button">
|
||||
<input type="text" class="form-input" formControlName="sku" />
|
||||
<app-button buttonType="icon" (click)="generateSku()" iconName="refresh-cw"></app-button>
|
||||
<app-form-field label="SKU (Artikelnummer)" type="text" formControlName="sku" [control]="productForm.get('sku')"></app-form-field>
|
||||
<app-button buttonType="icon" (click)="generateSku()" iconName="placeholder"></app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-form-field
|
||||
label="Lagerbestand"
|
||||
type="number"
|
||||
formControlName="stockQuantity"
|
||||
[control]="productForm.get('stockQuantity')">
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field
|
||||
label="Gewicht (kg)"
|
||||
type="number"
|
||||
formControlName="weight"
|
||||
[control]="productForm.get('weight')">
|
||||
</app-form-field>
|
||||
|
||||
<app-form-select
|
||||
label="Lieferant"
|
||||
[options]="supplierOptions"
|
||||
formControlName="supplierId">
|
||||
</app-form-select>
|
||||
<app-form-field label="Lagerbestand" type="number" formControlName="stockQuantity" [control]="productForm.get('stockQuantity')"></app-form-field>
|
||||
<app-form-field label="Gewicht (kg)" type="number" formControlName="weight" [control]="productForm.get('weight')"></app-form-field>
|
||||
<app-form-select label="Lieferant" [options]="supplierOptions" formControlName="supplierId"></app-form-select>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<!-- Card: Kategorien (bleibt unverändert, da es sich um eine komplexe Checkbox-Gruppe handelt) -->
|
||||
</app-form-group>
|
||||
<app-card>
|
||||
<h4 card-header>Kategorien</h4>
|
||||
<div class="form-section">
|
||||
@@ -167,35 +109,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<!-- Card: Hervorheben -->
|
||||
<app-card>
|
||||
<h4 card-header>Hervorheben</h4>
|
||||
<div class="form-section">
|
||||
<app-slide-toggle formControlName="isFeatured">Auf Startseite anzeigen</app-slide-toggle>
|
||||
|
||||
<app-form-field
|
||||
*ngIf="productForm.get('isFeatured')?.value"
|
||||
label="Anzeigereihenfolge"
|
||||
type="number"
|
||||
formControlName="featuredDisplayOrder"
|
||||
[control]="productForm.get('featuredDisplayOrder')">
|
||||
</app-form-field>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================= -->
|
||||
<!-- FORMULAR-AKTIONEN (SPEICHERN/ABBRECHEN) -->
|
||||
<!-- ================================================================= -->
|
||||
<!-- FORMULAR-AKTIONEN -->
|
||||
<div class="form-actions">
|
||||
<app-button buttonType="stroked" (click)="cancel()">Abbrechen</app-button>
|
||||
<app-button
|
||||
submitType="submit"
|
||||
buttonType="primary"
|
||||
[disabled]="!productForm.valid || isLoading">
|
||||
{{ submitButtonText }}
|
||||
</app-button>
|
||||
<app-button submitType="submit" buttonType="primary" [disabled]="!productForm.valid || isLoading">{{ submitButtonText }}</app-button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -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<void>();
|
||||
@Output() formCancel = new EventEmitter<void>();
|
||||
@Output() mainFileSelected = new EventEmitter<File>();
|
||||
@Output() additionalFilesSelected = new EventEmitter<File[]>();
|
||||
@Output() existingImageDeleted = new EventEmitter<string>();
|
||||
@Output() newImageRemoved = new EventEmitter<string>();
|
||||
@Output() filesSelected = new EventEmitter<File[]>(); // Sendet alle neu ausgewählten Dateien
|
||||
@Output() setMainImage = new EventEmitter<string>(); // Sendet den 'identifier' des Bildes
|
||||
@Output() deleteImage = new EventEmitter<string>(); // 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,12 +9,6 @@
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!--
|
||||
HIER IST DIE MAGIE:
|
||||
<ng-content> ist ein Platzhalter. Alles, was Sie in Demo2Component
|
||||
zwischen <app-form-group> und </app-form-group> schreiben,
|
||||
wird genau an dieser Stelle eingefügt.
|
||||
-->
|
||||
<div class="form-group-content">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user