good
This commit is contained in:
@@ -12,16 +12,13 @@
|
|||||||
[productForm]="productForm"
|
[productForm]="productForm"
|
||||||
[allCategories]="(allCategories$ | async) || []"
|
[allCategories]="(allCategories$ | async) || []"
|
||||||
[supplierOptions]="(supplierOptions$ | async) || []"
|
[supplierOptions]="(supplierOptions$ | async) || []"
|
||||||
[existingImages]="[]"
|
[allImages]="allImagesForForm"
|
||||||
[mainImagePreview]="mainImagePreview"
|
|
||||||
[additionalImagesPreview]="additionalImagesPreview"
|
|
||||||
[isLoading]="isLoading"
|
[isLoading]="isLoading"
|
||||||
submitButtonText="Produkt erstellen"
|
submitButtonText="Produkt erstellen"
|
||||||
(formSubmit)="onSubmit()"
|
(formSubmit)="onSubmit()"
|
||||||
(formCancel)="cancel()"
|
(formCancel)="cancel()"
|
||||||
(mainFileSelected)="onMainFileSelected($event)"
|
(filesSelected)="onFilesSelected($event)"
|
||||||
(additionalFilesSelected)="onAdditionalFilesSelected($event)"
|
(setMainImage)="onSetMainImage($event)"
|
||||||
(newImageRemoved)="onNewImageRemoved($event)"
|
(deleteImage)="onDeleteImage($event)">
|
||||||
>
|
|
||||||
</app-product-form>
|
</app-product-form>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,79 +146,101 @@ 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) {
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
onNewImageRemoved(fileName: string): void {
|
this.newImageFiles
|
||||||
this.additionalImageFiles = this.additionalImageFiles.filter(
|
.filter(f => f.name !== this.mainImageIdentifier)
|
||||||
(f) => f.name !== fileName
|
.forEach(file => formData.append('AdditionalImageFiles', file));
|
||||||
);
|
|
||||||
this.additionalImagesPreview = this.additionalImagesPreview.filter(
|
return formData;
|
||||||
(p) => p.name !== fileName
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Private Helfermethoden
|
// Private Helfermethoden
|
||||||
private prepareSubmissionData(): void {
|
private prepareSubmissionData(): void {
|
||||||
const nameControl = this.productForm.get('name');
|
const nameControl = this.productForm.get('name');
|
||||||
@@ -262,30 +259,6 @@ 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 nameControl = this.productForm.get('name');
|
||||||
const slugControl = this.productForm.get('slug');
|
const slugControl = this.productForm.get('slug');
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
@@ -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 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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="hasImages" class="image-gallery">
|
<p class="gallery-hint">Klicken Sie auf ein Bild, um es als Hauptbild festzulegen.</p>
|
||||||
<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>
|
||||||
<div class="additional-images-grid">
|
</app-form-group>
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
|
|
||||||
<!-- 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>
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user