This commit is contained in:
Tizian.Breuch
2025-10-29 18:34:59 +01:00
parent b1b1c3173b
commit 9173f9b625
9 changed files with 437 additions and 654 deletions

View File

@@ -9,19 +9,16 @@
</div> </div>
<app-product-form <app-product-form
[productForm]="productForm" [productForm]="productForm"
[allCategories]="(allCategories$ | async) || []" [allCategories]="(allCategories$ | async) || []"
[supplierOptions]="(supplierOptions$ | async) || []" [supplierOptions]="(supplierOptions$ | async) || []"
[existingImages]="[]" [allImages]="allImagesForForm"
[mainImagePreview]="mainImagePreview" [isLoading]="isLoading"
[additionalImagesPreview]="additionalImagesPreview" submitButtonText="Produkt erstellen"
[isLoading]="isLoading" (formSubmit)="onSubmit()"
submitButtonText="Produkt erstellen" (formCancel)="cancel()"
(formSubmit)="onSubmit()" (filesSelected)="onFilesSelected($event)"
(formCancel)="cancel()" (setMainImage)="onSetMainImage($event)"
(mainFileSelected)="onMainFileSelected($event)" (deleteImage)="onDeleteImage($event)">
(additionalFilesSelected)="onAdditionalFilesSelected($event)" </app-product-form>
(newImageRemoved)="onNewImageRemoved($event)"
>
</app-product-form>
</ng-template> </ng-template>

View File

@@ -1,4 +1,5 @@
// /src/app/features/admin/components/products/product-create/product-create.component.ts // /src/app/features/admin/components/products/product-create/product-create.component.ts
import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -9,25 +10,27 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
Validators, Validators,
} from '@angular/forms'; } from '@angular/forms';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { Observable, Subscription } from 'rxjs'; import { Observable, Subscription } from 'rxjs';
import { import {
debounceTime, debounceTime,
distinctUntilChanged, distinctUntilChanged,
map, map,
startWith, startWith,
tap,
} from 'rxjs/operators'; } from 'rxjs/operators';
// Models, Services und UI-Komponenten importieren // Models, Services und UI-Komponenten
import { Category } from '../../../../core/models/category.model'; import { Category } from '../../../../core/models/category.model';
import { ProductService } from '../../../services/product.service'; import { ProductService } from '../../../services/product.service';
import { CategoryService } from '../../../services/category.service'; import { CategoryService } from '../../../services/category.service';
import { SupplierService } from '../../../services/supplier.service'; import { SupplierService } from '../../../services/supplier.service';
import { SnackbarService } from '../../../../shared/services/snackbar.service'; import { SnackbarService } from '../../../../shared/services/snackbar.service';
import { CardComponent } from '../../../../shared/components/ui/card/card.component'; import { 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 { SelectOption } from '../../../../shared/components/form/form-select/form-select.component';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
@Component({ @Component({
selector: 'app-product-create', selector: 'app-product-create',
@@ -42,6 +45,7 @@ import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
styleUrls: ['./product-create.component.css'], styleUrls: ['./product-create.component.css'],
}) })
export class ProductCreateComponent implements OnInit, OnDestroy { export class ProductCreateComponent implements OnInit, OnDestroy {
// --- Injektionen ---
private sanitizer = inject(DomSanitizer); private sanitizer = inject(DomSanitizer);
private router = inject(Router); private router = inject(Router);
private productService = inject(ProductService); private productService = inject(ProductService);
@@ -50,18 +54,17 @@ export class ProductCreateComponent implements OnInit, OnDestroy {
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
private snackbarService = inject(SnackbarService); private snackbarService = inject(SnackbarService);
// --- Komponenten-Status ---
isLoading = true; isLoading = true;
productForm: FormGroup; productForm: FormGroup;
allCategories$!: Observable<Category[]>; allCategories$!: Observable<Category[]>;
supplierOptions$!: Observable<SelectOption[]>; supplierOptions$!: Observable<SelectOption[]>;
private nameChangeSubscription?: Subscription; private nameChangeSubscription?: Subscription;
mainImageFile: File | null = null; // --- Bild-Management State ---
additionalImageFiles: File[] = []; newImageFiles: File[] = [];
mainImagePreview: string | null = null; mainImageIdentifier: string | null = null;
additionalImagesPreview: { name: string; url: SafeUrl }[] = []; allImagesForForm: ImagePreview[] = [];
constructor() { constructor() {
this.productForm = this.fb.group({ this.productForm = this.fb.group({
@@ -82,6 +85,7 @@ export class ProductCreateComponent implements OnInit, OnDestroy {
}); });
} }
// --- Lifecycle Hooks ---
ngOnInit(): void { ngOnInit(): void {
this.loadDropdownData(); this.loadDropdownData();
this.subscribeToNameChanges(); this.subscribeToNameChanges();
@@ -90,8 +94,13 @@ export class ProductCreateComponent implements OnInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.nameChangeSubscription?.unsubscribe(); 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 { loadDropdownData(): void {
this.allCategories$ = this.categoryService.getAll(); this.allCategories$ = this.categoryService.getAll();
this.supplierOptions$ = this.supplierService.getAll().pipe( this.supplierOptions$ = this.supplierService.getAll().pipe(
@@ -109,11 +118,13 @@ export class ProductCreateComponent implements OnInit, OnDestroy {
return; return;
} }
// NEU: Stellt sicher, dass Slug/SKU vor dem Senden generiert werden, falls sie leer sind
this.prepareSubmissionData();
const formData = this.createFormData(); const formData = this.createFormData();
this.productService.create(formData).subscribe({ this.productService.create(formData).subscribe({
next: () => { next: () => {
this.snackbarService.show('Produkt erstellt'); this.snackbarService.show('Produkt erfolgreich erstellt');
this.router.navigate(['/admin/products']); this.router.navigate(['/admin/products']);
}, },
error: (err) => { error: (err) => {
@@ -127,9 +138,60 @@ export class ProductCreateComponent implements OnInit, OnDestroy {
this.router.navigate(['/admin/products']); 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 { private createFormData(): FormData {
const formData = new FormData(); const formData = new FormData();
const formValue = this.productForm.value; const formValue = this.productForm.getRawValue();
Object.keys(formValue).forEach((key) => { Object.keys(formValue).forEach((key) => {
const value = formValue[key]; const value = formValue[key];
@@ -142,12 +204,18 @@ export class ProductCreateComponent implements OnInit, OnDestroy {
} }
}); });
if (this.mainImageFile) { const mainImageFile = this.newImageFiles.find(
formData.append('MainImageFile', this.mainImageFile); (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; return formData;
} }
@@ -155,8 +223,8 @@ export class ProductCreateComponent implements OnInit, OnDestroy {
private subscribeToNameChanges(): void { private subscribeToNameChanges(): void {
this.nameChangeSubscription = this.productForm this.nameChangeSubscription = this.productForm
.get('name') .get('name')
?.valueChanges.pipe(debounceTime(300), distinctUntilChanged()) ?.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
.subscribe((name) => { .subscribe((name: any) => {
if (name && !this.productForm.get('slug')?.dirty) { if (name && !this.productForm.get('slug')?.dirty) {
const slug = this.generateSlug(name); const slug = this.generateSlug(name);
this.productForm.get('slug')?.setValue(slug); 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 { private generateSlug(name: string): string {
return name return name
.toLowerCase() .toLowerCase()
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.replace(/[äöüß]/g, (char) => { .replace(
switch (char) { /[äöüß]/g,
case 'ä': (char) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[char] || '')
return 'ae'; )
case 'ö':
return 'oe';
case 'ü':
return 'ue';
case 'ß':
return 'ss';
default:
return '';
}
})
.replace(/[^a-z0-9-]/g, '') .replace(/[^a-z0-9-]/g, '')
.replace(/-+/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 { private capitalizeFirstLetter(string: string): string {
return string.charAt(0).toUpperCase() + string.slice(1); 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
);
}
} }

View File

@@ -1,3 +1,5 @@
<!-- /src/app/features/admin/components/products/product-edit/product-edit.component.html -->
<div *ngIf="isLoading; else formContent" class="loading-container"> <div *ngIf="isLoading; else formContent" class="loading-container">
<p>Lade Produktdaten...</p> <p>Lade Produktdaten...</p>
</div> </div>
@@ -10,17 +12,14 @@
[productForm]="productForm" [productForm]="productForm"
[allCategories]="(allCategories$ | async) || []" [allCategories]="(allCategories$ | async) || []"
[supplierOptions]="(supplierOptions$ | async) || []" [supplierOptions]="(supplierOptions$ | async) || []"
[existingImages]="existingImages" [allImages]="allImagesForForm"
[mainImagePreview]="mainImagePreview"
[additionalImagesPreview]="additionalImagesPreview"
[isLoading]="isLoading" [isLoading]="isLoading"
submitButtonText="Änderungen speichern" submitButtonText="Änderungen speichern"
(formSubmit)="onSubmit()" (formSubmit)="onSubmit()"
(formCancel)="cancel()" (formCancel)="cancel()"
(mainFileSelected)="onMainFileSelected($event)" (filesSelected)="onFilesSelected($event)"
(additionalFilesSelected)="onAdditionalFilesSelected($event)" (setMainImage)="onSetMainImage($event)"
(existingImageDeleted)="onExistingImageDeleted($event)" (deleteImage)="onDeleteImage($event)"
(newImageRemoved)="onNewImageRemoved($event)"
> >
</app-product-form> </app-product-form>
</ng-template> </ng-template>

View File

@@ -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 { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { import { FormBuilder, FormGroup, FormArray, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
FormBuilder, import { DomSanitizer } from '@angular/platform-browser';
FormGroup,
FormArray,
FormControl,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { Observable, Subscription } from 'rxjs'; import { Observable, Subscription } from 'rxjs';
import { import { map, startWith, debounceTime, distinctUntilChanged, finalize } from 'rxjs/operators';
map,
startWith,
debounceTime,
distinctUntilChanged,
} from 'rxjs/operators';
// Models // Models, Services und UI-Komponenten
import { import { AdminProduct, ProductImage } from '../../../../core/models/product.model';
AdminProduct,
ProductImage,
} from '../../../../core/models/product.model';
import { Category } from '../../../../core/models/category.model'; import { Category } from '../../../../core/models/category.model';
// Services
import { ProductService } from '../../../services/product.service'; import { ProductService } from '../../../services/product.service';
import { CategoryService } from '../../../services/category.service'; import { CategoryService } from '../../../services/category.service';
import { SupplierService } from '../../../services/supplier.service'; import { SupplierService } from '../../../services/supplier.service';
import { SnackbarService } from '../../../../shared/services/snackbar.service'; import { SnackbarService } from '../../../../shared/services/snackbar.service';
// UI Components
import { CardComponent } from '../../../../shared/components/ui/card/card.component'; 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 { SelectOption } from '../../../../shared/components/form/form-select/form-select.component';
@Component({ @Component({
selector: 'app-product-edit', selector: 'app-product-edit',
standalone: true, standalone: true,
imports: [ imports: [ CommonModule, ReactiveFormsModule, CardComponent, ProductFormComponent ],
CommonModule,
ReactiveFormsModule,
CardComponent,
ProductFormComponent,
],
templateUrl: './product-edit.component.html', templateUrl: './product-edit.component.html',
styleUrls: ['./product-edit.component.css'], styleUrls: ['./product-edit.component.css'],
}) })
export class ProductEditComponent implements OnInit, OnDestroy { export class ProductEditComponent implements OnInit, OnDestroy {
// Service-Injektionen // --- Injektionen ---
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private productService = inject(ProductService); private productService = inject(ProductService);
@@ -59,26 +37,23 @@ export class ProductEditComponent implements OnInit, OnDestroy {
private snackbarService = inject(SnackbarService); private snackbarService = inject(SnackbarService);
private sanitizer = inject(DomSanitizer); private sanitizer = inject(DomSanitizer);
// Komponenten-Status // --- Komponenten-Status ---
productId!: string; productId!: string;
isLoading = true; isLoading = true;
productForm: FormGroup; productForm: FormGroup;
private nameChangeSubscription?: Subscription; private nameChangeSubscription?: Subscription;
// Daten für Dropdowns und Formular
allCategories$!: Observable<Category[]>; allCategories$!: Observable<Category[]>;
supplierOptions$!: Observable<SelectOption[]>; supplierOptions$!: Observable<SelectOption[]>;
// State für Bild-Management // --- NEUER STATE FÜR BILD-MANAGEMENT ---
existingImages: ProductImage[] = []; existingImages: ProductImage[] = [];
mainImageFile: File | null = null; newImageFiles: File[] = [];
additionalImageFiles: File[] = []; mainImageIdentifier: string | null = null; // Hält die ID oder den Dateinamen des Hauptbildes
mainImagePreview: string | SafeUrl | null = null; allImagesForForm: ImagePreview[] = []; // Die kombinierte Liste für die Child-Komponente
additionalImagesPreview: { name: string; url: SafeUrl }[] = [];
constructor() { constructor() {
this.productForm = this.fb.group({ this.productForm = this.fb.group({
id: ['', Validators.required], // ID ist für das Update zwingend erforderlich id: ['', Validators.required],
name: ['', Validators.required], name: ['', Validators.required],
slug: ['', Validators.required], slug: ['', Validators.required],
sku: ['', Validators.required], sku: ['', Validators.required],
@@ -93,13 +68,10 @@ export class ProductEditComponent implements OnInit, OnDestroy {
featuredDisplayOrder: [0], featuredDisplayOrder: [0],
supplierId: [null], supplierId: [null],
categorieIds: this.fb.array([]), 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 { get imagesToDelete(): FormArray {
return this.productForm.get('imagesToDelete') as FormArray; return this.productForm.get('imagesToDelete') as FormArray;
} }
@@ -107,12 +79,11 @@ export class ProductEditComponent implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id'); const id = this.route.snapshot.paramMap.get('id');
if (!id) { if (!id) {
this.snackbarService.show('Produkt-ID fehlt. Umleitung zur Übersicht.'); this.snackbarService.show('Produkt-ID fehlt.');
this.router.navigate(['/admin/products']); this.router.navigate(['/admin/products']);
return; return;
} }
this.productId = id; this.productId = id;
this.loadDropdownData(); this.loadDropdownData();
this.loadProductData(); this.loadProductData();
this.subscribeToNameChanges(); this.subscribeToNameChanges();
@@ -120,21 +91,26 @@ export class ProductEditComponent implements OnInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.nameChangeSubscription?.unsubscribe(); 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 { loadDropdownData(): void {
this.allCategories$ = this.categoryService.getAll(); this.allCategories$ = this.categoryService.getAll();
this.supplierOptions$ = this.supplierService.getAll().pipe( this.supplierOptions$ = this.supplierService.getAll().pipe(
map((suppliers) => map(suppliers => suppliers.map(s => ({ value: s.id, label: s.name || 'Unbenannt' }))),
suppliers.map((s) => ({ value: s.id, label: s.name || 'Unbenannt' }))
),
startWith([]) startWith([])
); );
} }
loadProductData(): void { loadProductData(): void {
this.isLoading = true; this.isLoading = true;
this.productService.getById(this.productId).subscribe({ this.productService.getById(this.productId).pipe(
finalize(() => { this.isLoading = false; })
).subscribe({
next: (product) => { next: (product) => {
if (product) { if (product) {
this.populateForm(product); this.populateForm(product);
@@ -142,11 +118,10 @@ export class ProductEditComponent implements OnInit, OnDestroy {
this.snackbarService.show('Produkt nicht gefunden.'); this.snackbarService.show('Produkt nicht gefunden.');
this.router.navigate(['/admin/products']); this.router.navigate(['/admin/products']);
} }
this.isLoading = false;
}, },
error: () => { error: (err) => {
this.snackbarService.show('Fehler beim Laden des Produkts.'); this.snackbarService.show('Fehler beim Laden des Produkts.');
this.isLoading = false; console.error('Fehler beim Laden der Produktdaten:', err);
this.router.navigate(['/admin/products']); this.router.navigate(['/admin/products']);
}, },
}); });
@@ -154,15 +129,15 @@ export class ProductEditComponent implements OnInit, OnDestroy {
populateForm(product: AdminProduct): void { populateForm(product: AdminProduct): void {
this.productForm.patchValue(product); this.productForm.patchValue(product);
const categories = this.productForm.get('categorieIds') as FormArray;
this.categorieIds.clear(); categories.clear();
product.categorieIds?.forEach((id) => product.categorieIds?.forEach(id => categories.push(new FormControl(id)));
this.categorieIds.push(new FormControl(id))
);
this.existingImages = product.images || []; this.existingImages = product.images || [];
const mainImage = this.existingImages.find((img) => img.isMainImage); const mainImage = this.existingImages.find(img => img.isMainImage);
this.mainImagePreview = mainImage?.url ?? null; this.mainImageIdentifier = mainImage?.id || (this.existingImages.length > 0 ? this.existingImages[0].id : null);
this.rebuildAllImagesForForm();
} }
onSubmit(): void { onSubmit(): void {
@@ -171,78 +146,100 @@ export class ProductEditComponent implements OnInit, OnDestroy {
this.snackbarService.show('Bitte füllen Sie alle Pflichtfelder aus.'); this.snackbarService.show('Bitte füllen Sie alle Pflichtfelder aus.');
return; return;
} }
this.prepareSubmissionData(); this.prepareSubmissionData();
const formData = this.createFormData(); const formData = this.createFormData();
this.productService.update(this.productId, formData).subscribe({ this.productService.update(this.productId, formData).subscribe({
next: () => { next: () => {
this.snackbarService.show('Produkt erfolgreich aktualisiert'); this.snackbarService.show('Produkt erfolgreich aktualisiert');
this.router.navigate(['/admin/products']); this.router.navigate(['/admin/products']);
}, },
error: (err) => { error: (err) => {
this.snackbarService.show( this.snackbarService.show('Ein Fehler ist aufgetreten.');
'Ein Fehler ist aufgetreten. Details siehe Konsole.'
);
console.error(err); console.error(err);
}, },
}); });
} }
cancel(): void { cancel(): void { this.router.navigate(['/admin/products']); }
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 onSetMainImage(identifier: string): void {
onMainFileSelected(file: File): void { this.mainImageIdentifier = identifier;
this.mainImageFile = file; this.rebuildAllImagesForForm();
const reader = new FileReader();
reader.onload = () => {
this.mainImagePreview = this.sanitizer.bypassSecurityTrustUrl(
reader.result as string
);
};
reader.readAsDataURL(file);
} }
onAdditionalFilesSelected(files: File[]): void { onDeleteImage(identifier: string): void {
this.additionalImageFiles.push(...files); const isExisting = this.existingImages.some(img => img.id === identifier);
files.forEach((file) => { if (isExisting) {
const reader = new FileReader(); this.imagesToDelete.push(new FormControl(identifier));
reader.onload = () => { this.existingImages = this.existingImages.filter(img => img.id !== identifier);
this.additionalImagesPreview.push({ } else {
name: file.name, this.newImageFiles = this.newImageFiles.filter(file => file.name !== identifier);
url: this.sanitizer.bypassSecurityTrustUrl(reader.result as string), }
});
}; if (this.mainImageIdentifier === identifier) {
reader.readAsDataURL(file); 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 { private createFormData(): FormData {
if (!this.imagesToDelete.value.includes(imageId)) { const formData = new FormData();
this.imagesToDelete.push(new FormControl(imageId)); const formValue = this.productForm.getRawValue();
}
this.existingImages = this.existingImages.filter( Object.keys(formValue).forEach((key) => {
(img) => img.id !== imageId const value = formValue[key];
); if (key === 'categorieIds' || key === 'imagesToDelete') {
// Wenn das gelöschte Bild das Hauptbild war, leeren wir die Vorschau (value as string[]).forEach((id) => formData.append(this.capitalizeFirstLetter(key), id));
if ( } else if (value !== null && value !== undefined && value !== '') {
this.mainImagePreview === formData.append(this.capitalizeFirstLetter(key), value.toString());
this.existingImages.find((i) => i.id === imageId)?.url }
) { });
this.mainImagePreview = null;
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 Helfermethoden
private prepareSubmissionData(): void { private prepareSubmissionData(): void {
@@ -262,31 +259,7 @@ export class ProductEditComponent implements OnInit, OnDestroy {
} }
} }
private createFormData(): FormData { private subscribeToNameChanges(): void {
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 {
const nameControl = this.productForm.get('name'); const nameControl = this.productForm.get('name');
const slugControl = this.productForm.get('slug'); 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 return name
.toLowerCase() .toLowerCase()
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
@@ -313,7 +286,7 @@ export class ProductEditComponent implements OnInit, OnDestroy {
.replace(/-+/g, '-'); .replace(/-+/g, '-');
} }
private generateSkuValue(name: string): string { private generateSkuValue(name: string): string {
const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, ''); const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, '');
const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase(); const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase();
return `${prefix}-${randomPart}`; return `${prefix}-${randomPart}`;
@@ -322,4 +295,12 @@ export class ProductEditComponent implements OnInit, OnDestroy {
private capitalizeFirstLetter(string: string): string { private capitalizeFirstLetter(string: string): string {
return string.charAt(0).toUpperCase() + string.slice(1); return string.charAt(0).toUpperCase() + string.slice(1);
} }
} }

View File

@@ -1,267 +1,114 @@
/* /src/app/features/admin/components/products/product-form/product-form.component.css */ /* /src/app/features/admin/components/products/product-form/product-form.component.css */
/* ==========================================================================
Globale Variablen & Grund-Layout
========================================================================== */
:host { :host {
--form-spacing-vertical: 1.5rem; /* Vertikaler Abstand zwischen Formular-Sektionen */ --form-spacing-vertical: 1.5rem;
--form-spacing-horizontal: 1.5rem; /* Innenabstand der Sektionen */ --form-spacing-horizontal: 1.5rem;
--grid-gap: 1.5rem; /* Abstand zwischen den Spalten und Karten */ --grid-gap: 1.5rem;
--border-radius: 8px; --border-radius: 8px;
--text-color-secondary: #64748b; --text-color-secondary: #64748b;
--background-color-light: #f8fafc; --background-color-light: #f8fafc;
} }
.edit-layout { .edit-layout { display: grid; grid-template-columns: 2fr 1fr; gap: var(--grid-gap); }
display: grid; .main-content, .sidebar-content { display: flex; flex-direction: column; gap: var(--grid-gap); }
grid-template-columns: 2fr 1fr; /* 2/3 für Hauptinhalt, 1/3 für Sidebar */ app-card { display: block; width: 100%; }
gap: var(--grid-gap); .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; }
.main-content, .form-label { font-weight: 500; color: #334155; }
.sidebar-content { .required-indicator { color: var(--color-danger); margin-left: 4px; }
display: flex; .form-hint { font-size: 0.875rem; color: var(--text-color-secondary); margin-top: -0.75rem; }
flex-direction: column; .input-with-button { display: flex; gap: 0.5rem; }
gap: var(--grid-gap); .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 { .image-upload-section {
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} padding-bottom: 1.5rem;
.image-upload-section:last-of-type {
border-bottom: none;
padding-bottom: 0;
} }
.image-gallery { .gallery-title { font-size: 1rem; font-weight: 600; color: var(--text-color-secondary); margin-bottom: 0.25rem; }
margin-top: 1rem;
}
.gallery-title { .image-gallery-grid {
font-size: 1rem; display: grid;
font-weight: 600; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
color: var(--text-color-secondary); gap: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1rem;
} }
.image-preview-container { .image-preview-container {
position: relative; position: relative; /* Wichtig für die Positionierung des Buttons */
width: 100px; width: 100px;
height: 100px; height: 100px;
border-radius: var(--border-radius); border-radius: var(--border-radius-md);
overflow: hidden; overflow: visible; /* Erlaubt dem Button, leicht überzulappen */
border: 1px solid var(--color-border); 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 { .image-preview {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
border-radius: var(--border-radius); /* Abgerundete Ecken für das Bild selbst */
} }
.main-image-preview { /* Styling für den Löschen-Button als Overlay */
width: 100%; .delete-overlay-button {
height: auto;
max-height: 250px;
margin-bottom: 1rem;
}
.main-image-badge {
position: absolute; position: absolute;
top: 8px; top: -8px;
left: 8px; right: -8px;
background-color: var(--color-primary); z-index: 10;
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 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; justify-content: center;
background-color: var(--background-color-light); width: 26px;
border: 1px solid var(--color-border); height: 26px;
padding: 0.25rem 0.75rem; background-color: var(--color-danger);
border-radius: 16px; color: white;
font-size: 0.875rem; 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 { .image-preview-container:hover .delete-overlay-button {
cursor: pointer; opacity: 1;
transform: scale(1);
}
.delete-overlay-button app-icon {
width: 16px; width: 16px;
height: 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 Kategorien- & Formular-Aktionen
========================================================================== */
.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
========================================================================== */ ========================================================================== */
.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) { @media (max-width: 1024px) {
.edit-layout { .edit-layout { grid-template-columns: 1fr; }
grid-template-columns: 1fr; /* Eine Spalte auf kleineren Bildschirmen */
}
}
.required-indicator {
color: var(--color-danger);
margin-left: 4px;
} }

View File

@@ -2,151 +2,93 @@
<form [formGroup]="productForm" (ngSubmit)="onSubmit()" novalidate> <form [formGroup]="productForm" (ngSubmit)="onSubmit()" novalidate>
<div class="edit-layout"> <div class="edit-layout">
<!-- ================================================================= -->
<!-- LINKE SPALTE (HAUPTINHALT) -->
<!-- ================================================================= -->
<div class="main-content"> <div class="main-content">
<!-- Card: Allgemeine Produktinformationen --> <!-- 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-field <app-form-group title="Allgemein" description="Hier kannst du Allgemeine PRodukt Informationen ändern....">
label="Slug"
type="text"
formControlName="slug"
[control]="productForm.get('slug')">
</app-form-field>
<!-- Textarea bleibt eine eigene Komponente, da sie spezielle Eigenschaften hat --> <app-form-field label="Name" type="text" formControlName="name" [control]="productForm.get('name')"></app-form-field>
<app-form-textarea <app-form-field label="Slug" type="text" formControlName="slug" [control]="productForm.get('slug')"></app-form-field>
label="Beschreibung" <app-form-textarea label="Beschreibung" [rows]="8" formControlName="description" [control]="productForm.get('description')"></app-form-textarea>
[rows]="8"
formControlName="description"
[control]="productForm.get('description')">
</app-form-textarea>
</div>
</app-card>
<!-- Card: Bild-Management (bleibt unverändert, da es sich um file-inputs handelt) --> </app-form-group>
<app-card>
<h4 card-header>Produktbilder</h4>
<div class="form-section"> <!-- Card: Bild-Management (Komplett überarbeitet) -->
<div class="image-upload-section"> <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.">
<label class="form-label">Hauptbild</label>
<p class="form-hint">Laden Sie hier das primäre Bild hoch.</p> <div>
<input type="file" accept="image/*" (change)="onMainFileChange($event)" /> <input id="file-upload" type="file" accept="image/*" multiple (change)="onFilesSelected($event)" />
</div> </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> <div *ngIf="hasImages">
<input type="file" accept="image/*" multiple (change)="onAdditionalFilesChange($event)" />
</div> <div class="image-gallery-grid">
</div> <!-- Einheitliche Schleife mit neuem Löschen-Button -->
<div *ngIf="hasImages" class="image-gallery"> <div
<h5 class="gallery-title">Vorschau</h5> *ngFor="let image of allImages"
<div *ngIf="mainImagePreview" class="image-preview-container main-image-preview"> class="image-preview-container"
<img [src]="mainImagePreview" alt="Vorschau Hauptbild" class="image-preview" /> (click)="setAsMainImage(image.identifier)"
<span class="main-image-badge">Hauptbild</span> [ngClass]="{ 'is-main': image.isMainImage }">
</div>
<div class="additional-images-grid"> <img [src]="image.url" [alt]="'Bildvorschau'" class="image-preview" />
<div *ngFor="let image of additionalExistingImages" class="image-preview-container">
<img [src]="image.url" [alt]="'Bild ' + image.id" class="image-preview" /> <!-- Löschen-Button als Overlay für JEDES Bild -->
<app-button buttonType="icon-danger" (click)="deleteExistingImage(image.id, $event)" iconName="delete"></app-button> <button
</div> type="button"
<div *ngFor="let preview of additionalImagesPreview" class="image-preview-container"> class="delete-overlay-button"
<img [src]="preview.url" [alt]="preview.name" class="image-preview" /> (click)="requestImageDeletion(image.identifier, $event)"
<app-button buttonType="icon-danger" (click)="removeNewImage(preview.name, $event)" iconName="delete"></app-button> aria-label="Bild entfernen">
<app-icon iconName="x"></app-icon>
</button>
</div> </div>
</div> </div>
<p class="gallery-hint">Klicken Sie auf ein Bild, um es als Hauptbild festzulegen.</p>
</div> </div>
</app-card> </app-form-group>
<!-- Card: Preisgestaltung --> <!-- Card: Preisgestaltung -->
<app-card> <app-form-group title="Preisgestaltung" description="Produktpreise und Einkaufspreise festlegen.">
<h4 card-header>Preisgestaltung</h4>
<div class="form-grid price-grid"> <div class="form-grid price-grid">
<app-form-field <app-form-field label="Preis (€)" type="number" formControlName="price" [control]="productForm.get('price')"></app-form-field>
label="Preis (€)" <app-form-field label="Alter Preis (€)" type="number" formControlName="oldPrice" [control]="productForm.get('oldPrice')"></app-form-field>
type="number" <app-form-field label="Einkaufspreis (€)" type="number" formControlName="purchasePrice" [control]="productForm.get('purchasePrice')"></app-form-field>
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> </div>
</app-card> </app-form-group>
</div> </div>
<!-- ================================================================= --> <!-- RECHTE SPALTE -->
<!-- RECHTE SPALTE (SEITENLEISTE) -->
<!-- ================================================================= -->
<div class="sidebar-content"> <div class="sidebar-content">
<!-- Card: Status --> <app-form-group title="Status" description="Ein Deaktiviertes Produkt ist im Shop nicht sichtbar. Hervorgehobene Produkte werden bevorzugt angezeigt.">
<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>
<!-- Card: Organisation -->
<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>
<app-form-group title="Organisation" description="">
<app-card> <app-card>
<h4 card-header>Organisation</h4> <h4 card-header>Organisation</h4>
<div class="form-section"> <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"> <div class="input-with-button">
<input type="text" class="form-input" formControlName="sku" /> <app-form-field label="SKU (Artikelnummer)" type="text" formControlName="sku" [control]="productForm.get('sku')"></app-form-field>
<app-button buttonType="icon" (click)="generateSku()" iconName="refresh-cw"></app-button> <app-button buttonType="icon" (click)="generateSku()" iconName="placeholder"></app-button>
</div> </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-field <app-form-select label="Lieferant" [options]="supplierOptions" formControlName="supplierId"></app-form-select>
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> </div>
</app-card> </app-card>
</app-form-group>
<!-- Card: Kategorien (bleibt unverändert, da es sich um eine komplexe Checkbox-Gruppe handelt) -->
<app-card> <app-card>
<h4 card-header>Kategorien</h4> <h4 card-header>Kategorien</h4>
<div class="form-section"> <div class="form-section">
@@ -168,34 +110,12 @@
</div> </div>
</app-card> </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>
</div> </div>
<!-- ================================================================= --> <!-- FORMULAR-AKTIONEN -->
<!-- FORMULAR-AKTIONEN (SPEICHERN/ABBRECHEN) -->
<!-- ================================================================= -->
<div class="form-actions"> <div class="form-actions">
<app-button buttonType="stroked" (click)="cancel()">Abbrechen</app-button> <app-button buttonType="stroked" (click)="cancel()">Abbrechen</app-button>
<app-button <app-button submitType="submit" buttonType="primary" [disabled]="!productForm.valid || isLoading">{{ submitButtonText }}</app-button>
submitType="submit"
buttonType="primary"
[disabled]="!productForm.valid || isLoading">
{{ submitButtonText }}
</app-button>
</div> </div>
</form> </form>

View File

@@ -1,31 +1,43 @@
// /src/app/features/admin/components/products/product-form/product-form.component.ts // /src/app/features/admin/components/products/product-form/product-form.component.ts
import { Component, Input, Output, EventEmitter, inject, OnChanges, SimpleChanges } from '@angular/core'; import { Component, Input, Output, EventEmitter, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule, NgClass } from '@angular/common';
import { FormGroup, FormArray, ReactiveFormsModule, FormControl } from '@angular/forms'; import {
FormGroup,
FormArray,
ReactiveFormsModule,
FormControl,
} from '@angular/forms';
import { SafeUrl } from '@angular/platform-browser'; import { SafeUrl } from '@angular/platform-browser';
// Models // Models & UI Components
import { ProductImage } from '../../../../core/models/product.model';
import { Category } from '../../../../core/models/category.model'; 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 { CardComponent } from '../../../../shared/components/ui/card/card.component';
import { ButtonComponent } from '../../../../shared/components/ui/button/button.component'; import { ButtonComponent } from '../../../../shared/components/ui/button/button.component';
import { IconComponent } from '../../../../shared/components/ui/icon/icon.component'; import { IconComponent } from '../../../../shared/components/ui/icon/icon.component';
import { FormFieldComponent } from '../../../../shared/components/form/form-field/form-field.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 { FormTextareaComponent } from '../../../../shared/components/form/form-textarea/form-textarea.component';
import { SlideToggleComponent } from '../../../../shared/components/form/slide-toggle/slide-toggle.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({ @Component({
selector: 'app-product-form', selector: 'app-product-form',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
NgClass,
ReactiveFormsModule, ReactiveFormsModule,
CardComponent, CardComponent,
ButtonComponent, ButtonComponent,
@@ -34,69 +46,66 @@ import { SlideToggleComponent } from '../../../../shared/components/form/slide-t
FormSelectComponent, FormSelectComponent,
FormTextareaComponent, FormTextareaComponent,
SlideToggleComponent, SlideToggleComponent,
FormGroupComponent,
], ],
templateUrl: './product-form.component.html', templateUrl: './product-form.component.html',
styleUrls: ['./product-form.component.css'] styleUrls: ['./product-form.component.css'],
}) })
export class ProductFormComponent { export class ProductFormComponent {
// --- Inputs für Daten ---
@Input() productForm!: FormGroup; @Input() productForm!: FormGroup;
@Input() allCategories: Category[] = []; @Input() allCategories: Category[] = [];
@Input() supplierOptions: SelectOption[] = []; @Input() supplierOptions: SelectOption[] = [];
@Input() isLoading = false; @Input() isLoading = false;
@Input() submitButtonText = 'Speichern'; @Input() submitButtonText = 'Speichern';
// Inputs & Outputs für Bilder // NEU: Empfängt eine einzige, kombinierte Bildliste von der Elternkomponente
@Input() existingImages: ProductImage[] = []; @Input() allImages: ImagePreview[] = [];
@Input() mainImagePreview: string | SafeUrl | null = null;
@Input() additionalImagesPreview: { name: string, url: string | SafeUrl }[] = [];
// --- Outputs für Aktionen ---
@Output() formSubmit = new EventEmitter<void>(); @Output() formSubmit = new EventEmitter<void>();
@Output() formCancel = new EventEmitter<void>(); @Output() formCancel = new EventEmitter<void>();
@Output() mainFileSelected = new EventEmitter<File>(); @Output() filesSelected = new EventEmitter<File[]>(); // Sendet alle neu ausgewählten Dateien
@Output() additionalFilesSelected = new EventEmitter<File[]>(); @Output() setMainImage = new EventEmitter<string>(); // Sendet den 'identifier' des Bildes
@Output() existingImageDeleted = new EventEmitter<string>(); @Output() deleteImage = new EventEmitter<string>(); // Sendet den 'identifier' des Bildes
@Output() newImageRemoved = new EventEmitter<string>();
private snackbarService = inject(SnackbarService); private snackbarService = inject(SnackbarService);
// GETTER FÜR DAS TEMPLATE // 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 { get hasImages(): boolean {
return !!this.mainImagePreview || this.additionalImagesPreview.length > 0 || this.existingImages.length > 0; return this.allImages && this.allImages.length > 0;
} }
// NEU: Getter, der die Filter-Logik aus dem Template entfernt // --- EVENT-HANDLER ---
get additionalExistingImages(): ProductImage[] { onSubmit(): void {
return this.existingImages.filter(image => !image.isMainImage); this.formSubmit.emit();
}
cancel(): void {
this.formCancel.emit();
} }
// FORMULAR-INTERAKTIONEN onFilesSelected(event: Event): void {
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);
}
onAdditionalFilesChange(event: Event): void {
const files = (event.target as HTMLInputElement).files; 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 { setAsMainImage(identifier: string): void {
event.preventDefault(); this.setMainImage.emit(identifier);
this.existingImageDeleted.emit(imageId);
} }
removeNewImage(fileName: string, event: Event): void { requestImageDeletion(identifier: string, event: MouseEvent): void {
event.preventDefault(); event.stopPropagation(); // Verhindert, dass gleichzeitig setAsMainImage gefeuert wird
this.newImageRemoved.emit(fileName); this.deleteImage.emit(identifier);
} }
// KATEGORIE- & SKU-HELFER // --- Helfermethoden (unverändert) ---
generateSku(): void { generateSku(): void {
const name = this.productForm.get('name')?.value || 'PROD'; const name = this.productForm.get('name')?.value || 'PROD';
const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, ''); const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, '');
@@ -114,7 +123,9 @@ export class ProductFormComponent {
this.categorieIds.push(new FormControl(categoryId)); this.categorieIds.push(new FormControl(categoryId));
} }
} else { } 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); if (index !== -1) this.categorieIds.removeAt(index);
} }
} }
@@ -124,11 +135,13 @@ export class ProductFormComponent {
} }
getCategoryName(categoryId: string): string { 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 { 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); if (index !== -1) this.categorieIds.removeAt(index);
} }
} }

View File

@@ -23,7 +23,7 @@
font-size: 0.9rem; font-size: 0.9rem;
color: var(--color-text-light); color: var(--color-text-light);
margin-top: -0.75rem; /* Rücken wir näher an den Titel */ margin-top: -0.75rem; /* Rücken wir näher an den Titel */
margin-bottom: 1.5rem; margin-bottom: 1rem;
} }
.form-group-content { .form-group-content {

View File

@@ -9,12 +9,6 @@
{{ description }} {{ description }}
</p> </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"> <div class="form-group-content">
<ng-content></ng-content> <ng-content></ng-content>
</div> </div>