From b1b1c3173bed1077cf26a47e6a45ef957c4e45ca Mon Sep 17 00:00:00 2001 From: "Tizian.Breuch" Date: Wed, 29 Oct 2025 14:04:14 +0100 Subject: [PATCH] ok --- .../product-create.component.css | 23 ++ .../product-create.component.html | 27 ++ .../product-create.component.ts | 224 +++++++++++ .../product-edit/product-edit.component.css | 63 +-- .../product-edit/product-edit.component.html | 130 ++---- .../product-edit/product-edit.component.ts | 371 +++++++++++------- .../product-form/product-form.component.css | 267 +++++++++++++ .../product-form/product-form.component.html | 201 ++++++++++ .../product-form/product-form.component.ts | 134 +++++++ .../product-list/product-list.component.ts | 138 ++----- .../product-new/product-new.component.css | 0 .../product-new/product-new.component.html | 1 - .../product-new/product-new.component.ts | 11 - .../components/products/products.routes.ts | 27 +- .../form/form-field/form-field.component.css | 42 +- .../form/form-field/form-field.component.html | 37 +- .../form/form-field/form-field.component.ts | 50 ++- .../form-textarea/form-textarea.component.css | 16 +- .../form-textarea.component.html | 33 +- .../form-textarea/form-textarea.component.ts | 48 ++- 20 files changed, 1346 insertions(+), 497 deletions(-) create mode 100644 src/app/features/components/products/product-create/product-create.component.css create mode 100644 src/app/features/components/products/product-create/product-create.component.html create mode 100644 src/app/features/components/products/product-create/product-create.component.ts create mode 100644 src/app/features/components/products/product-form/product-form.component.css create mode 100644 src/app/features/components/products/product-form/product-form.component.html create mode 100644 src/app/features/components/products/product-form/product-form.component.ts delete mode 100644 src/app/features/components/products/product-new/product-new.component.css delete mode 100644 src/app/features/components/products/product-new/product-new.component.html delete mode 100644 src/app/features/components/products/product-new/product-new.component.ts diff --git a/src/app/features/components/products/product-create/product-create.component.css b/src/app/features/components/products/product-create/product-create.component.css new file mode 100644 index 0000000..fd24f7f --- /dev/null +++ b/src/app/features/components/products/product-create/product-create.component.css @@ -0,0 +1,23 @@ +.form-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 1rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +h3[card-header] { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text); +} + +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 300px; + color: var(--text-color-secondary); +} diff --git a/src/app/features/components/products/product-create/product-create.component.html b/src/app/features/components/products/product-create/product-create.component.html new file mode 100644 index 0000000..f57c8af --- /dev/null +++ b/src/app/features/components/products/product-create/product-create.component.html @@ -0,0 +1,27 @@ + +
+

Lade Formulardaten...

+
+ + +
+

Neues Produkt erstellen

+
+ + + +
diff --git a/src/app/features/components/products/product-create/product-create.component.ts b/src/app/features/components/products/product-create/product-create.component.ts new file mode 100644 index 0000000..316d59d --- /dev/null +++ b/src/app/features/components/products/product-create/product-create.component.ts @@ -0,0 +1,224 @@ +// /src/app/features/admin/components/products/product-create/product-create.component.ts +import { Component, OnInit, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { + FormBuilder, + FormGroup, + FormArray, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { Observable, Subscription } from 'rxjs'; +import { + debounceTime, + distinctUntilChanged, + map, + startWith, + tap, +} from 'rxjs/operators'; + +// Models, Services und UI-Komponenten importieren +import { Category } from '../../../../core/models/category.model'; +import { ProductService } from '../../../services/product.service'; +import { CategoryService } from '../../../services/category.service'; +import { SupplierService } from '../../../services/supplier.service'; +import { SnackbarService } from '../../../../shared/services/snackbar.service'; +import { CardComponent } from '../../../../shared/components/ui/card/card.component'; +import { ProductFormComponent } from '../product-form/product-form.component'; +import { SelectOption } from '../../../../shared/components/form/form-select/form-select.component'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; + +@Component({ + selector: 'app-product-create', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + CardComponent, + ProductFormComponent, + ], + templateUrl: './product-create.component.html', + styleUrls: ['./product-create.component.css'], +}) +export class ProductCreateComponent implements OnInit, OnDestroy { + private sanitizer = inject(DomSanitizer); + private router = inject(Router); + private productService = inject(ProductService); + private categoryService = inject(CategoryService); + private supplierService = inject(SupplierService); + private fb = inject(FormBuilder); + private snackbarService = inject(SnackbarService); + + isLoading = true; + productForm: FormGroup; + + allCategories$!: Observable; + supplierOptions$!: Observable; + + private nameChangeSubscription?: Subscription; + + mainImageFile: File | null = null; + additionalImageFiles: File[] = []; + mainImagePreview: string | null = null; + additionalImagesPreview: { name: string; url: SafeUrl }[] = []; + + constructor() { + this.productForm = this.fb.group({ + name: ['', Validators.required], + slug: ['', Validators.required], + sku: ['', Validators.required], + price: [0, [Validators.required, Validators.min(0)]], + stockQuantity: [0, [Validators.required, Validators.min(0)]], + description: [''], + oldPrice: [null, [Validators.min(0)]], + purchasePrice: [null, [Validators.min(0)]], + weight: [null, [Validators.min(0)]], + isActive: [true], + isFeatured: [false], + featuredDisplayOrder: [0], + supplierId: [null], + categorieIds: this.fb.array([]), + }); + } + + ngOnInit(): void { + this.loadDropdownData(); + this.subscribeToNameChanges(); + this.isLoading = false; + } + + ngOnDestroy(): void { + this.nameChangeSubscription?.unsubscribe(); + } + + loadDropdownData(): void { + this.allCategories$ = this.categoryService.getAll(); + this.supplierOptions$ = this.supplierService.getAll().pipe( + map((suppliers) => + suppliers.map((s) => ({ value: s.id, label: s.name || 'Unbenannt' })) + ), + startWith([]) + ); + } + + onSubmit(): void { + if (this.productForm.invalid) { + this.productForm.markAllAsTouched(); + this.snackbarService.show('Bitte füllen Sie alle Pflichtfelder aus.'); + return; + } + + const formData = this.createFormData(); + + this.productService.create(formData).subscribe({ + next: () => { + this.snackbarService.show('Produkt erstellt'); + this.router.navigate(['/admin/products']); + }, + error: (err) => { + this.snackbarService.show('Ein Fehler ist aufgetreten.'); + console.error(err); + }, + }); + } + + cancel(): void { + this.router.navigate(['/admin/products']); + } + + private createFormData(): FormData { + const formData = new FormData(); + const formValue = this.productForm.value; + + Object.keys(formValue).forEach((key) => { + const value = formValue[key]; + if (key === 'categorieIds') { + (value as string[]).forEach((id) => + formData.append('CategorieIds', 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 { + this.nameChangeSubscription = this.productForm + .get('name') + ?.valueChanges.pipe(debounceTime(300), distinctUntilChanged()) + .subscribe((name) => { + if (name && !this.productForm.get('slug')?.dirty) { + const slug = this.generateSlug(name); + this.productForm.get('slug')?.setValue(slug); + } + }); + } + + onMainFileSelected(file: File): void { + this.mainImageFile = file; + const reader = new FileReader(); + reader.onload = () => { + this.mainImagePreview = reader.result as string; + }; + reader.readAsDataURL(file); + } + + private generateSlug(name: string): string { + return name + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[äöüß]/g, (char) => { + switch (char) { + case 'ä': + return 'ae'; + case 'ö': + return 'oe'; + case 'ü': + return 'ue'; + case 'ß': + return 'ss'; + default: + return ''; + } + }) + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-'); + } + + private capitalizeFirstLetter(string: string): string { + return string.charAt(0).toUpperCase() + string.slice(1); + } + + onAdditionalFilesSelected(files: File[]): void { + this.additionalImageFiles.push(...files); + files.forEach((file) => { + const reader = new FileReader(); + reader.onload = () => { + this.additionalImagesPreview.push({ + name: file.name, + url: this.sanitizer.bypassSecurityTrustUrl(reader.result as string), + }); + }; + reader.readAsDataURL(file); + }); + } + + onNewImageRemoved(fileName: string): void { + this.additionalImageFiles = this.additionalImageFiles.filter( + (f) => f.name !== fileName + ); + this.additionalImagesPreview = this.additionalImagesPreview.filter( + (p) => p.name !== fileName + ); + } +} diff --git a/src/app/features/components/products/product-edit/product-edit.component.css b/src/app/features/components/products/product-edit/product-edit.component.css index 0e04d4c..fd24f7f 100644 --- a/src/app/features/components/products/product-edit/product-edit.component.css +++ b/src/app/features/components/products/product-edit/product-edit.component.css @@ -1,5 +1,3 @@ -:host { display: block; } - .form-header { display: flex; justify-content: space-between; @@ -9,56 +7,17 @@ border-bottom: 1px solid var(--color-border); } -h3[card-header] { margin: 0; } - -.form-section { margin-bottom: 1.5rem; } -.section-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 1.25rem; color: var(--color-text); } - -.form-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 1.5rem; +h3[card-header] { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text); } -.grid-col-span-2 { grid-column: 1 / -1; } - -.input-with-button { display: flex; gap: 0.5rem; } -.input-with-button input { flex-grow: 1; } - -.category-checkbox-group { - max-height: 150px; - overflow-y: auto; - border: 1px solid var(--color-border); - border-radius: var(--border-radius-md); - padding: 0.75rem; +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 300px; + color: var(--text-color-secondary); } - -.checkbox-item { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } -.checkbox-item input { margin: 0; } -.checkbox-item label { font-weight: normal; } - -.image-management { - display: grid; - grid-template-columns: 1fr 2fr; - gap: 1.5rem; - align-items: start; -} - -.image-upload-fields { display: flex; flex-direction: column; gap: 1rem; } -.image-preview-grid { display: flex; flex-wrap: wrap; gap: 0.75rem; } -.image-preview-item { position: relative; } -.image-preview-item img { - width: 80px; height: 80px; object-fit: cover; - border-radius: var(--border-radius-md); - border: 2px solid var(--color-border); -} -.image-preview-item img.main-image { border-color: var(--color-success); } -.image-preview-item app-button { position: absolute; top: -10px; right: -10px; } - -.checkbox-group { display: flex; align-items: center; gap: 2rem; } -.form-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 2rem; } -.divider { border: none; border-top: 1px solid var(--color-border); margin: 2rem 0; } - -@media (max-width: 992px) { - .image-management { grid-template-columns: 1fr; } -} \ No newline at end of file diff --git a/src/app/features/components/products/product-edit/product-edit.component.html b/src/app/features/components/products/product-edit/product-edit.component.html index 15128c2..de70fff 100644 --- a/src/app/features/components/products/product-edit/product-edit.component.html +++ b/src/app/features/components/products/product-edit/product-edit.component.html @@ -1,108 +1,26 @@ - +
+

Lade Produktdaten...

+
- -
-

Lade Produktdaten...

+ +
+

Produkt bearbeiten

- - -
-
-

{{ isEditMode ? "Produkt bearbeiten" : "Neues Produkt erstellen" }}

-
- -
- -
- -

Allgemein

-
- - - -
-
- - -

Produktbilder

- -
- - -

Preisgestaltung

-
- - - - -
-
-
- - - -
- -
- Abbrechen - - {{ isEditMode ? "Änderungen speichern" : "Produkt erstellen" }} - -
-
-
- \ No newline at end of file + + +
diff --git a/src/app/features/components/products/product-edit/product-edit.component.ts b/src/app/features/components/products/product-edit/product-edit.component.ts index 1c38ec3..a6f00a8 100644 --- a/src/app/features/components/products/product-edit/product-edit.component.ts +++ b/src/app/features/components/products/product-edit/product-edit.component.ts @@ -1,16 +1,29 @@ -// /src/app/features/admin/components/products/product-edit/product-edit.component.ts - import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; -import { FormBuilder, FormGroup, FormArray, FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { + FormBuilder, + FormGroup, + FormArray, + FormControl, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { Observable, Subscription } from 'rxjs'; -import { debounceTime, distinctUntilChanged, map, startWith, tap } from 'rxjs/operators'; +import { + map, + startWith, + debounceTime, + distinctUntilChanged, +} from 'rxjs/operators'; // Models -import { AdminProduct, ProductImage } from '../../../../core/models/product.model'; +import { + AdminProduct, + ProductImage, +} from '../../../../core/models/product.model'; import { Category } from '../../../../core/models/category.model'; -import { Supplier } from '../../../../core/models/supplier.model'; // Services import { ProductService } from '../../../services/product.service'; @@ -18,23 +31,25 @@ import { CategoryService } from '../../../services/category.service'; import { SupplierService } from '../../../services/supplier.service'; import { SnackbarService } from '../../../../shared/services/snackbar.service'; -// Wiederverwendbare UI- & Form-Komponenten +// UI Components import { CardComponent } from '../../../../shared/components/ui/card/card.component'; -import { ButtonComponent } from '../../../../shared/components/ui/button/button.component'; -import { IconComponent } from '../../../../shared/components/ui/icon/icon.component'; -import { FormFieldComponent } from '../../../../shared/components/form/form-field/form-field.component'; -import { FormSelectComponent, SelectOption } from '../../../../shared/components/form/form-select/form-select.component'; -import { FormTextareaComponent } from '../../../../shared/components/form/form-textarea/form-textarea.component'; -import { SlideToggleComponent } from '../../../../shared/components/form/slide-toggle/slide-toggle.component'; +import { ProductFormComponent } from '../product-form/product-form.component'; +import { SelectOption } from '../../../../shared/components/form/form-select/form-select.component'; @Component({ selector: 'app-product-edit', standalone: true, - imports: [ CommonModule, ReactiveFormsModule, CardComponent, ButtonComponent, IconComponent, FormFieldComponent, FormSelectComponent, FormTextareaComponent, SlideToggleComponent ], + imports: [ + CommonModule, + ReactiveFormsModule, + CardComponent, + ProductFormComponent, + ], templateUrl: './product-edit.component.html', - styleUrl: './product-edit.component.css' + styleUrls: ['./product-edit.component.css'], }) export class ProductEditComponent implements OnInit, OnDestroy { + // Service-Injektionen private route = inject(ActivatedRoute); private router = inject(Router); private productService = inject(ProductService); @@ -42,53 +57,65 @@ export class ProductEditComponent implements OnInit, OnDestroy { private supplierService = inject(SupplierService); private fb = inject(FormBuilder); private snackbarService = inject(SnackbarService); + private sanitizer = inject(DomSanitizer); - productId: string | null = null; - isEditMode = false; + // Komponenten-Status + productId!: string; isLoading = true; productForm: FormGroup; - + private nameChangeSubscription?: Subscription; + + // Daten für Dropdowns und Formular allCategories$!: Observable; supplierOptions$!: Observable; - allCategories: Category[] = []; - private nameChangeSubscription?: Subscription; + // State für Bild-Management existingImages: ProductImage[] = []; mainImageFile: File | null = null; additionalImageFiles: File[] = []; + mainImagePreview: string | SafeUrl | null = null; + additionalImagesPreview: { name: string; url: SafeUrl }[] = []; constructor() { this.productForm = this.fb.group({ - name: ['', Validators.required], slug: ['', Validators.required], sku: ['', Validators.required], - description: [''], price: [0, [Validators.required, Validators.min(0)]], - oldPrice: [null, [Validators.min(0)]], purchasePrice: [null, [Validators.min(0)]], - stockQuantity: [0, [Validators.required, Validators.min(0)]], weight: [null, [Validators.min(0)]], - isActive: [true], isFeatured: [false], featuredDisplayOrder: [0], - supplierId: [null], categorieIds: this.fb.array([]), imagesToDelete: this.fb.array([]) + id: ['', Validators.required], // ID ist für das Update zwingend erforderlich + name: ['', Validators.required], + slug: ['', Validators.required], + sku: ['', Validators.required], + price: [0, [Validators.required, Validators.min(0)]], + stockQuantity: [0, [Validators.required, Validators.min(0)]], + description: [''], + oldPrice: [null, [Validators.min(0)]], + purchasePrice: [null, [Validators.min(0)]], + weight: [null, [Validators.min(0)]], + isActive: [true], + isFeatured: [false], + featuredDisplayOrder: [0], + supplierId: [null], + categorieIds: this.fb.array([]), + imagesToDelete: this.fb.array([]), // Wird für das Backend gefüllt }); } - get categorieIds(): FormArray { return this.productForm.get('categorieIds') as FormArray; } - get imagesToDelete(): FormArray { return this.productForm.get('imagesToDelete') as FormArray; } + get categorieIds(): FormArray { + return this.productForm.get('categorieIds') as FormArray; + } + get imagesToDelete(): FormArray { + return this.productForm.get('imagesToDelete') as FormArray; + } ngOnInit(): void { - this.productId = this.route.snapshot.paramMap.get('id'); - this.isEditMode = !!this.productId; - - this.loadDropdownData(); - this.subscribeToNameChanges(); - - if (this.isEditMode && this.productId) { - this.isLoading = true; - this.productService.getById(this.productId).subscribe(product => { - if (product) { - this.populateForm(product); - } - this.isLoading = false; - }); - } else { - this.isLoading = false; + const id = this.route.snapshot.paramMap.get('id'); + if (!id) { + this.snackbarService.show('Produkt-ID fehlt. Umleitung zur Übersicht.'); + this.router.navigate(['/admin/products']); + return; } + this.productId = id; + + this.loadDropdownData(); + this.loadProductData(); + this.subscribeToNameChanges(); } ngOnDestroy(): void { @@ -96,71 +123,46 @@ export class ProductEditComponent implements OnInit, OnDestroy { } loadDropdownData(): void { - this.allCategories$ = this.categoryService.getAll().pipe( - tap(categories => this.allCategories = categories) - ); + this.allCategories$ = this.categoryService.getAll(); this.supplierOptions$ = this.supplierService.getAll().pipe( - map(suppliers => suppliers.map(s => ({ value: s.id, label: s.name || 'Unbenannt' }))), + map((suppliers) => + suppliers.map((s) => ({ value: s.id, label: s.name || 'Unbenannt' })) + ), startWith([]) ); } - + + loadProductData(): void { + this.isLoading = true; + this.productService.getById(this.productId).subscribe({ + next: (product) => { + if (product) { + this.populateForm(product); + } else { + this.snackbarService.show('Produkt nicht gefunden.'); + this.router.navigate(['/admin/products']); + } + this.isLoading = false; + }, + error: () => { + this.snackbarService.show('Fehler beim Laden des Produkts.'); + this.isLoading = false; + this.router.navigate(['/admin/products']); + }, + }); + } + populateForm(product: AdminProduct): void { this.productForm.patchValue(product); + this.categorieIds.clear(); - product.categorieIds?.forEach(id => this.categorieIds.push(this.fb.control(id))); + product.categorieIds?.forEach((id) => + this.categorieIds.push(new FormControl(id)) + ); + this.existingImages = product.images || []; - } - - onMainFileChange(event: Event): void { - const file = (event.target as HTMLInputElement).files?.[0]; - if (file) this.mainImageFile = file; - } - - onAdditionalFilesChange(event: Event): void { - const files = (event.target as HTMLInputElement).files; - if (files) this.additionalImageFiles = Array.from(files); - } - - deleteExistingImage(imageId: string, event: Event): void { - event.preventDefault(); - this.imagesToDelete.push(this.fb.control(imageId)); - this.existingImages = this.existingImages.filter(img => img.id !== imageId); - } - - onCategoryChange(event: Event): void { - const checkbox = event.target as HTMLInputElement; - const categoryId = checkbox.value; - if (checkbox.checked) { - if (!this.categorieIds.value.includes(categoryId)) this.categorieIds.push(new FormControl(categoryId)); - } else { - const index = this.categorieIds.controls.findIndex(x => x.value === categoryId); - if (index !== -1) this.categorieIds.removeAt(index); - } - } - - isCategorySelected(categoryId: string): boolean { - return this.categorieIds.value.includes(categoryId); - } - - getCategoryName(categoryId: string): string { - return this.allCategories.find(c => c.id === categoryId)?.name || ''; - } - - removeCategoryById(categoryId: string): void { - const index = this.categorieIds.controls.findIndex(x => x.value === categoryId); - if (index !== -1) { - this.categorieIds.removeAt(index); - } - } - - generateSku(): void { - const name = this.productForm.get('name')?.value || 'PROD'; - const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, ''); - const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase(); - const sku = `${prefix}-${randomPart}`; - this.productForm.get('sku')?.setValue(sku); - this.snackbarService.show('Neue SKU generiert!'); + const mainImage = this.existingImages.find((img) => img.isMainImage); + this.mainImagePreview = mainImage?.url ?? null; } onSubmit(): void { @@ -169,60 +171,155 @@ export class ProductEditComponent implements OnInit, OnDestroy { this.snackbarService.show('Bitte füllen Sie alle Pflichtfelder aus.'); return; } - - const formData = new FormData(); - const formValue = this.productForm.value; - 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 !== '') { - if (['oldPrice', 'purchasePrice', 'weight'].includes(key) && value === '') return; - formData.append(this.capitalizeFirstLetter(key), value.toString()); - } - }); + this.prepareSubmissionData(); + const formData = this.createFormData(); - if (this.mainImageFile) formData.append('MainImageFile', this.mainImageFile); - this.additionalImageFiles.forEach((file) => formData.append('AdditionalImageFiles', file)); - - const operation: Observable = this.isEditMode - ? this.productService.update(this.productId!, formData) - : this.productService.create(formData); - - operation.subscribe({ + this.productService.update(this.productId, formData).subscribe({ next: () => { - this.snackbarService.show(this.isEditMode ? 'Produkt aktualisiert' : 'Produkt erstellt'); + this.snackbarService.show('Produkt erfolgreich aktualisiert'); this.router.navigate(['/admin/products']); }, - error: (err: any) => { - this.snackbarService.show('Ein Fehler ist aufgetreten.'); + error: (err) => { + this.snackbarService.show( + 'Ein Fehler ist aufgetreten. Details siehe Konsole.' + ); console.error(err); - } + }, }); } cancel(): void { this.router.navigate(['/admin/products']); } - - private subscribeToNameChanges(): void { - this.nameChangeSubscription = this.productForm.get('name')?.valueChanges.pipe(debounceTime(300), distinctUntilChanged()) - .subscribe((name) => { - if (name && !this.productForm.get('slug')?.dirty) { - const slug = this.generateSlug(name); - this.productForm.get('slug')?.setValue(slug); - } + + // Handler für Bild-Events von der Form-Komponente + onMainFileSelected(file: File): void { + this.mainImageFile = file; + const reader = new FileReader(); + reader.onload = () => { + this.mainImagePreview = this.sanitizer.bypassSecurityTrustUrl( + reader.result as string + ); + }; + reader.readAsDataURL(file); + } + + 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); + }); + } + + onExistingImageDeleted(imageId: string): void { + if (!this.imagesToDelete.value.includes(imageId)) { + this.imagesToDelete.push(new FormControl(imageId)); + } + this.existingImages = this.existingImages.filter( + (img) => img.id !== imageId + ); + // Wenn das gelöschte Bild das Hauptbild war, leeren wir die Vorschau + if ( + this.mainImagePreview === + this.existingImages.find((i) => i.id === imageId)?.url + ) { + this.mainImagePreview = null; + } + } + + onNewImageRemoved(fileName: string): void { + this.additionalImageFiles = this.additionalImageFiles.filter( + (f) => f.name !== fileName + ); + this.additionalImagesPreview = this.additionalImagesPreview.filter( + (p) => p.name !== fileName + ); + } + + // Private Helfermethoden + private prepareSubmissionData(): void { + const nameControl = this.productForm.get('name'); + const slugControl = this.productForm.get('slug'); + const skuControl = this.productForm.get('sku'); + + if (nameControl && slugControl && !slugControl.value) { + slugControl.setValue(this.generateSlug(nameControl.value), { + emitEvent: false, }); + } + if (nameControl && skuControl && !skuControl.value) { + skuControl.setValue(this.generateSkuValue(nameControl.value), { + emitEvent: false, + }); + } + } + + private 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 { + const nameControl = this.productForm.get('name'); + const slugControl = this.productForm.get('slug'); + + if (nameControl && slugControl) { + this.nameChangeSubscription = nameControl.valueChanges + .pipe(debounceTime(400), distinctUntilChanged()) + .subscribe((name) => { + if (name && !slugControl.dirty) { + slugControl.setValue(this.generateSlug(name)); + } + }); + } } private generateSlug(name: string): string { - return name.toLowerCase().replace(/\s+/g, '-').replace(/[äöüß]/g, (char) => { - switch (char) { case 'ä': return 'ae'; case 'ö': return 'oe'; case 'ü': return 'ue'; case 'ß': return 'ss'; default: return ''; } - }).replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-'); + return name + .toLowerCase() + .replace(/\s+/g, '-') + .replace( + /[äöüß]/g, + (char) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[char] || '') + ) + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-'); + } + + private generateSkuValue(name: string): string { + const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, ''); + const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase(); + return `${prefix}-${randomPart}`; } private capitalizeFirstLetter(string: string): string { return string.charAt(0).toUpperCase() + string.slice(1); } -} \ No newline at end of file +} diff --git a/src/app/features/components/products/product-form/product-form.component.css b/src/app/features/components/products/product-form/product-form.component.css new file mode 100644 index 0000000..947b799 --- /dev/null +++ b/src/app/features/components/products/product-form/product-form.component.css @@ -0,0 +1,267 @@ +/* /src/app/features/admin/components/products/product-form/product-form.component.css */ + +/* ========================================================================== + Globale Variablen & Grund-Layout + ========================================================================== */ + +:host { + --form-spacing-vertical: 1.5rem; /* Vertikaler Abstand zwischen Formular-Sektionen */ + --form-spacing-horizontal: 1.5rem; /* Innenabstand der Sektionen */ + --grid-gap: 1.5rem; /* Abstand zwischen den Spalten und Karten */ + --border-radius: 8px; + + --text-color-secondary: #64748b; + --background-color-light: #f8fafc; +} + +.edit-layout { + display: grid; + grid-template-columns: 2fr 1fr; /* 2/3 für Hauptinhalt, 1/3 für Sidebar */ + gap: var(--grid-gap); +} + +.main-content, +.sidebar-content { + display: flex; + flex-direction: column; + gap: var(--grid-gap); +} + +/* ========================================================================== + Karten-Styling (app-card) + ========================================================================== */ + +/* Wir stylen die app-card von außen, um ein konsistentes Layout zu gewährleisten. */ +app-card { + display: block; /* Stellt sicher, dass die Karte den vollen Platz einnimmt */ + width: 100%; +} + +/* Stile, die innerhalb der Karten gelten */ +.form-section { + padding: var(--form-spacing-horizontal); + display: flex; + flex-direction: column; + gap: var(--form-spacing-vertical); +} + +h4[card-header] { + margin-bottom: 0; /* Entfernt Standard-Margin des Headers */ +} + +/* ========================================================================== + Allgemeine Formular-Elemente + ========================================================================== */ + +.form-field { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-label { + font-weight: 500; + color: #334155; +} + +.form-input, +input[type="text"], +input[type="number"] { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-input:focus, +input[type="text"]:focus, +input[type="number"]:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.2); +} + +.form-hint { + font-size: 0.875rem; + color: var(--text-color-secondary); + margin-top: -0.75rem; /* Reduziert den Abstand nach oben */ +} + +.input-with-button { + display: flex; + gap: 0.5rem; +} + +.input-with-button .form-input { + flex-grow: 1; +} + +/* ========================================================================== + Spezifisches Sektions-Styling + ========================================================================== */ + +/* Preisgestaltung Grid */ +.price-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--grid-gap); +} + +/* Bild-Management */ +.image-upload-section { + padding-bottom: 1rem; + border-bottom: 1px solid var(--color-border); +} +.image-upload-section:last-of-type { + border-bottom: none; + padding-bottom: 0; +} + +.image-gallery { + margin-top: 1rem; +} + +.gallery-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-color-secondary); + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); + margin-bottom: 1rem; +} + +.image-preview-container { + position: relative; + width: 100px; + height: 100px; + border-radius: var(--border-radius); + overflow: hidden; + border: 1px solid var(--color-border); +} + +.image-preview { + width: 100%; + height: 100%; + object-fit: cover; +} + +.main-image-preview { + width: 100%; + height: auto; + max-height: 250px; + margin-bottom: 1rem; +} + +.main-image-badge { + position: absolute; + top: 8px; + left: 8px; + background-color: var(--color-primary); + color: white; + padding: 4px 8px; + border-radius: 16px; + font-size: 0.75rem; + font-weight: 500; +} + +.image-preview-container app-button { + position: absolute; + top: 4px; + right: 4px; + background-color: rgba(0, 0, 0, 0.5); + border-radius: 50%; + --button-icon-color: white; /* Angenommen, dein Button kann dies überschreiben */ + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +.image-preview-container:hover app-button { + opacity: 1; +} + +.additional-images-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +/* Kategorien-Auswahl */ +.multi-select-container { + border: 1px solid var(--color-border); + border-radius: var(--border-radius); +} + +.selected-pills { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.75rem; + border-bottom: 1px solid var(--color-border); +} + +.pill { + 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; +} + +/* ========================================================================== + Aktions-Buttons + ========================================================================== */ + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: var(--grid-gap); + padding-top: var(--grid-gap); + border-top: 1px solid var(--color-border); +} + +/* ========================================================================== + Responsives Design + ========================================================================== */ + +@media (max-width: 1024px) { + .edit-layout { + grid-template-columns: 1fr; /* Eine Spalte auf kleineren Bildschirmen */ + } +} + +.required-indicator { + color: var(--color-danger); + margin-left: 4px; +} diff --git a/src/app/features/components/products/product-form/product-form.component.html b/src/app/features/components/products/product-form/product-form.component.html new file mode 100644 index 0000000..4fbb4f5 --- /dev/null +++ b/src/app/features/components/products/product-form/product-form.component.html @@ -0,0 +1,201 @@ + + +
+
+ + + + +
+ + + +

Allgemein

+
+ + + + + + + + + + +
+
+ + + +

Produktbilder

+
+
+ +

Laden Sie hier das primäre Bild hoch.

+ +
+
+ +

Weitere Bilder für die Produktdetailseite.

+ +
+
+ +
+ + + +

Preisgestaltung

+
+ + + + + + + + +
+
+
+ + + + + +
+ + + + +
+ Abbrechen + + {{ submitButtonText }} + +
+
\ No newline at end of file diff --git a/src/app/features/components/products/product-form/product-form.component.ts b/src/app/features/components/products/product-form/product-form.component.ts new file mode 100644 index 0000000..fa3f55b --- /dev/null +++ b/src/app/features/components/products/product-form/product-form.component.ts @@ -0,0 +1,134 @@ +// /src/app/features/admin/components/products/product-form/product-form.component.ts + +import { Component, Input, Output, EventEmitter, inject, OnChanges, SimpleChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormGroup, FormArray, ReactiveFormsModule, FormControl } from '@angular/forms'; +import { SafeUrl } from '@angular/platform-browser'; + +// Models +import { ProductImage } from '../../../../core/models/product.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 { ButtonComponent } from '../../../../shared/components/ui/button/button.component'; +import { IconComponent } from '../../../../shared/components/ui/icon/icon.component'; +import { FormFieldComponent } from '../../../../shared/components/form/form-field/form-field.component'; +import { FormSelectComponent, SelectOption } from '../../../../shared/components/form/form-select/form-select.component'; +import { FormTextareaComponent } from '../../../../shared/components/form/form-textarea/form-textarea.component'; +import { SlideToggleComponent } from '../../../../shared/components/form/slide-toggle/slide-toggle.component'; + +@Component({ + selector: 'app-product-form', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + CardComponent, + ButtonComponent, + IconComponent, + FormFieldComponent, + FormSelectComponent, + FormTextareaComponent, + SlideToggleComponent, + ], + templateUrl: './product-form.component.html', + styleUrls: ['./product-form.component.css'] +}) +export class ProductFormComponent { + @Input() productForm!: FormGroup; + @Input() allCategories: Category[] = []; + @Input() supplierOptions: SelectOption[] = []; + @Input() isLoading = false; + @Input() submitButtonText = 'Speichern'; + + // Inputs & Outputs für Bilder + @Input() existingImages: ProductImage[] = []; + @Input() mainImagePreview: string | SafeUrl | null = null; + @Input() additionalImagesPreview: { name: string, url: string | SafeUrl }[] = []; + + @Output() formSubmit = new EventEmitter(); + @Output() formCancel = new EventEmitter(); + @Output() mainFileSelected = new EventEmitter(); + @Output() additionalFilesSelected = new EventEmitter(); + @Output() existingImageDeleted = new EventEmitter(); + @Output() newImageRemoved = new EventEmitter(); + + private snackbarService = inject(SnackbarService); + + // GETTER FÜR DAS TEMPLATE + get categorieIds(): FormArray { return this.productForm.get('categorieIds') as FormArray; } + + get hasImages(): boolean { + return !!this.mainImagePreview || this.additionalImagesPreview.length > 0 || this.existingImages.length > 0; + } + + // NEU: Getter, der die Filter-Logik aus dem Template entfernt + get additionalExistingImages(): ProductImage[] { + return this.existingImages.filter(image => !image.isMainImage); + } + + // FORMULAR-INTERAKTIONEN + onSubmit(): void { this.formSubmit.emit(); } + cancel(): void { this.formCancel.emit(); } + + // BILD-MANAGEMENT + onMainFileChange(event: Event): void { + const file = (event.target as HTMLInputElement).files?.[0]; + if (file) this.mainFileSelected.emit(file); + } + + onAdditionalFilesChange(event: Event): void { + const files = (event.target as HTMLInputElement).files; + if (files) this.additionalFilesSelected.emit(Array.from(files)); + } + + deleteExistingImage(imageId: string, event: Event): void { + event.preventDefault(); + this.existingImageDeleted.emit(imageId); + } + + removeNewImage(fileName: string, event: Event): void { + event.preventDefault(); + this.newImageRemoved.emit(fileName); + } + + // KATEGORIE- & SKU-HELFER + generateSku(): void { + const name = this.productForm.get('name')?.value || 'PROD'; + const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, ''); + const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase(); + const sku = `${prefix}-${randomPart}`; + this.productForm.get('sku')?.setValue(sku); + this.snackbarService.show('Neue SKU generiert!'); + } + + onCategoryChange(event: Event): void { + const checkbox = event.target as HTMLInputElement; + const categoryId = checkbox.value; + if (checkbox.checked) { + if (!this.categorieIds.value.includes(categoryId)) { + this.categorieIds.push(new FormControl(categoryId)); + } + } else { + const index = this.categorieIds.controls.findIndex(x => x.value === categoryId); + if (index !== -1) this.categorieIds.removeAt(index); + } + } + + isCategorySelected(categoryId: string): boolean { + return this.categorieIds.value.includes(categoryId); + } + + getCategoryName(categoryId: string): string { + return this.allCategories.find(c => c.id === categoryId)?.name || ''; + } + + removeCategoryById(categoryId: string): void { + const index = this.categorieIds.controls.findIndex(x => x.value === categoryId); + if (index !== -1) this.categorieIds.removeAt(index); + } +} \ No newline at end of file diff --git a/src/app/features/components/products/product-list/product-list.component.ts b/src/app/features/components/products/product-list/product-list.component.ts index cb13706..1a30056 100644 --- a/src/app/features/components/products/product-list/product-list.component.ts +++ b/src/app/features/components/products/product-list/product-list.component.ts @@ -3,18 +3,12 @@ import { Component, OnInit, inject } from '@angular/core'; import { Router } from '@angular/router'; import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common'; -import { - AdminProduct, - ProductImage, -} from '../../../../core/models/product.model'; +import { AdminProduct, ProductImage } from '../../../../core/models/product.model'; import { ProductService } from '../../../services/product.service'; import { SupplierService } from '../../../services/supplier.service'; import { SnackbarService } from '../../../../shared/services/snackbar.service'; import { StorageService } from '../../../../core/services/storage.service'; -import { - GenericTableComponent, - ColumnConfig, -} from '../../../../shared/components/data-display/generic-table/generic-table.component'; +import { GenericTableComponent, ColumnConfig } from '../../../../shared/components/data-display/generic-table/generic-table.component'; import { SearchBarComponent } from '../../../../shared/components/layout/search-bar/search-bar.component'; import { ButtonComponent } from '../../../../shared/components/ui/button/button.component'; import { IconComponent } from '../../../../shared/components/ui/icon/icon.component'; @@ -22,18 +16,10 @@ import { IconComponent } from '../../../../shared/components/ui/icon/icon.compon @Component({ selector: 'app-product-list', standalone: true, - imports: [ - CommonModule, - CurrencyPipe, - DatePipe, - GenericTableComponent, - SearchBarComponent, - ButtonComponent, - IconComponent, - ], + imports: [CommonModule, CurrencyPipe, DatePipe, GenericTableComponent, SearchBarComponent, ButtonComponent, IconComponent], providers: [DatePipe], templateUrl: './product-list.component.html', - styleUrl: './product-list.component.css', + styleUrl: './product-list.component.css' }) export class ProductListComponent implements OnInit { private productService = inject(ProductService); @@ -42,66 +28,35 @@ export class ProductListComponent implements OnInit { private snackbar = inject(SnackbarService); private storageService = inject(StorageService); private datePipe = inject(DatePipe); - + private readonly TABLE_SETTINGS_KEY = 'product-table-columns'; - allProducts: (AdminProduct & { - mainImage?: string; - supplierName?: string; - })[] = []; - filteredProducts: (AdminProduct & { - mainImage?: string; - supplierName?: string; - })[] = []; + allProducts: (AdminProduct & { mainImage?: string; supplierName?: string })[] = []; + filteredProducts: (AdminProduct & { mainImage?: string; supplierName?: string })[] = []; isColumnFilterVisible = false; readonly allTableColumns: ColumnConfig[] = [ { key: 'mainImage', title: 'Bild', type: 'image' }, { key: 'name', title: 'Name', type: 'text', subKey: 'sku' }, { key: 'price', title: 'Preis', type: 'currency', cssClass: 'text-right' }, - { - key: 'stockQuantity', - title: 'Lager', - type: 'text', - cssClass: 'text-right', - }, + { key: 'stockQuantity', title: 'Lager', type: 'text', cssClass: 'text-right' }, { key: 'supplierName', title: 'Lieferant', type: 'text' }, { key: 'isActive', title: 'Aktiv', type: 'status' }, { key: 'id', title: 'ID', type: 'text' }, { key: 'description', title: 'Beschreibung', type: 'text' }, - { - key: 'oldPrice', - title: 'Alter Preis', - type: 'currency', - cssClass: 'text-right', - }, - { - key: 'purchasePrice', - title: 'Einkaufspreis', - type: 'currency', - cssClass: 'text-right', - }, + { key: 'oldPrice', title: 'Alter Preis', type: 'currency', cssClass: 'text-right' }, + { key: 'purchasePrice', title: 'Einkaufspreis', type: 'currency', cssClass: 'text-right' }, { key: 'isInStock', title: 'Auf Lager', type: 'status' }, - { key: 'weight', title: 'Gewicht', type: 'number', cssClass: 'text-right' }, + { key: 'weight', title: 'Gewicht', type: 'text', cssClass: 'text-right' }, { key: 'slug', title: 'Slug', type: 'text' }, - { key: 'createdDate', title: 'Erstellt am', type: 'date' }, - { key: 'lastModifiedDate', title: 'Zuletzt geändert', type: 'date' }, + { key: 'createdDate', title: 'Erstellt am', type: 'text' }, + { key: 'lastModifiedDate', title: 'Zuletzt geändert', type: 'text' }, { key: 'supplierId', title: 'Lieferanten-ID', type: 'text' }, { key: 'categorieIds', title: 'Kategorie-IDs', type: 'text' }, { key: 'isFeatured', title: 'Hervorgehoben', type: 'status' }, - { - key: 'featuredDisplayOrder', - title: 'Anzeigereihenfolge (hervorgehoben)', - type: 'number', - cssClass: 'text-right', - }, - { - key: 'actions', - title: 'Aktionen', - type: 'actions', - cssClass: 'text-right', - }, - ]; + { key: 'featuredDisplayOrder', title: 'Anzeigereihenfolge (hervorgehoben)', type: 'number', cssClass: 'text-right' }, + { key: 'actions', title: 'Aktionen', type: 'actions', cssClass: 'text-right' } +]; visibleTableColumns: ColumnConfig[] = []; constructor() { @@ -113,16 +68,15 @@ export class ProductListComponent implements OnInit { } loadProducts(): void { - this.productService.getAll().subscribe((products) => { - this.supplierService.getAll().subscribe((suppliers) => { - this.allProducts = products.map((p) => { - const supplier = suppliers.find((s) => s.id === p.supplierId); + this.productService.getAll().subscribe(products => { + this.supplierService.getAll().subscribe(suppliers => { + this.allProducts = products.map(p => { + const supplier = suppliers.find(s => s.id === p.supplierId); return { ...p, mainImage: this.getMainImageUrl(p.images), supplierName: supplier?.name || '-', - createdDate: - this.datePipe.transform(p.createdDate, 'dd.MM.yyyy HH:mm') || '-', + createdDate: this.datePipe.transform(p.createdDate, 'dd.MM.yyyy HH:mm') || '-', }; }); this.onSearch(''); @@ -132,19 +86,18 @@ export class ProductListComponent implements OnInit { onSearch(term: string): void { const lowerTerm = term.toLowerCase(); - this.filteredProducts = this.allProducts.filter( - (p) => - p.name?.toLowerCase().includes(lowerTerm) || - p.sku?.toLowerCase().includes(lowerTerm) + this.filteredProducts = this.allProducts.filter(p => + p.name?.toLowerCase().includes(lowerTerm) || + p.sku?.toLowerCase().includes(lowerTerm) ); } onAddNew(): void { - this.router.navigate(['/shop/products/new']); + this.router.navigate(['/shop/products/create']); } onEditProduct(productId: string): void { - this.router.navigate(['/shop/products', productId]); + this.router.navigate(['/shop/products/edit', productId]); } onDeleteProduct(productId: string): void { @@ -157,26 +110,14 @@ export class ProductListComponent implements OnInit { } private loadTableSettings(): void { - const savedKeys = this.storageService.getItem( - this.TABLE_SETTINGS_KEY - ); - const defaultKeys = [ - 'mainImage', - 'name', - 'price', - 'stockQuantity', - 'isActive', - 'actions', - ]; - const keysToUse = - savedKeys && savedKeys.length > 0 ? savedKeys : defaultKeys; - this.visibleTableColumns = this.allTableColumns.filter((c) => - keysToUse.includes(c.key) - ); + const savedKeys = this.storageService.getItem(this.TABLE_SETTINGS_KEY); + const defaultKeys = ['mainImage', 'name', 'price', 'stockQuantity', 'isActive', 'actions']; + const keysToUse = (savedKeys && savedKeys.length > 0) ? savedKeys : defaultKeys; + this.visibleTableColumns = this.allTableColumns.filter(c => keysToUse.includes(c.key)); } private saveTableSettings(): void { - const visibleKeys = this.visibleTableColumns.map((c) => c.key); + const visibleKeys = this.visibleTableColumns.map(c => c.key); this.storageService.setItem(this.TABLE_SETTINGS_KEY, visibleKeys); } @@ -185,29 +126,26 @@ export class ProductListComponent implements OnInit { } isColumnVisible(columnKey: string): boolean { - return this.visibleTableColumns.some((c) => c.key === columnKey); + return this.visibleTableColumns.some(c => c.key === columnKey); } onColumnToggle(column: ColumnConfig, event: Event): void { const checkbox = event.target as HTMLInputElement; if (checkbox.checked) { this.visibleTableColumns.push(column); - this.visibleTableColumns.sort( - (a, b) => - this.allTableColumns.findIndex((c) => c.key === a.key) - - this.allTableColumns.findIndex((c) => c.key === b.key) + this.visibleTableColumns.sort((a, b) => + this.allTableColumns.findIndex(c => c.key === a.key) - + this.allTableColumns.findIndex(c => c.key === b.key) ); } else { - this.visibleTableColumns = this.visibleTableColumns.filter( - (c) => c.key !== column.key - ); + this.visibleTableColumns = this.visibleTableColumns.filter(c => c.key !== column.key); } this.saveTableSettings(); } getMainImageUrl(images?: ProductImage[]): string { if (!images || images.length === 0) return 'https://via.placeholder.com/50'; - const mainImage = images.find((img) => img.isMainImage); + const mainImage = images.find(img => img.isMainImage); return mainImage?.url || images[0]?.url || 'https://via.placeholder.com/50'; } -} +} \ No newline at end of file diff --git a/src/app/features/components/products/product-new/product-new.component.css b/src/app/features/components/products/product-new/product-new.component.css deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/features/components/products/product-new/product-new.component.html b/src/app/features/components/products/product-new/product-new.component.html deleted file mode 100644 index 2ea317e..0000000 --- a/src/app/features/components/products/product-new/product-new.component.html +++ /dev/null @@ -1 +0,0 @@ -

product-new works!

diff --git a/src/app/features/components/products/product-new/product-new.component.ts b/src/app/features/components/products/product-new/product-new.component.ts deleted file mode 100644 index ce59b77..0000000 --- a/src/app/features/components/products/product-new/product-new.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-product-new', - imports: [], - templateUrl: './product-new.component.html', - styleUrl: './product-new.component.css' -}) -export class ProductNewComponent { - -} diff --git a/src/app/features/components/products/products.routes.ts b/src/app/features/components/products/products.routes.ts index 36ef617..107317c 100644 --- a/src/app/features/components/products/products.routes.ts +++ b/src/app/features/components/products/products.routes.ts @@ -3,21 +3,22 @@ import { Routes } from '@angular/router'; import { ProductListComponent } from './product-list/product-list.component'; import { ProductEditComponent } from './product-edit/product-edit.component'; +import { ProductCreateComponent } from './product-create/product-create.component'; export const PRODUCTS_ROUTES: Routes = [ - { - path: '', - component: ProductListComponent, - title: 'Produktübersicht' + { + path: '', + component: ProductListComponent, + title: 'Produktübersicht', }, - { - path: 'new', - component: ProductEditComponent, - title: 'Neues Produkt erstellen' + { + path: 'create', + component: ProductCreateComponent, + title: 'Product | Create', }, - { - path: ':id', - component: ProductEditComponent, - title: 'Produkt bearbeiten' + { + path: 'edit/:id', + component: ProductEditComponent, + title: 'Product | Edit', }, -]; \ No newline at end of file +]; diff --git a/src/app/shared/components/form/form-field/form-field.component.css b/src/app/shared/components/form/form-field/form-field.component.css index 475211f..cdf60d5 100644 --- a/src/app/shared/components/form/form-field/form-field.component.css +++ b/src/app/shared/components/form/form-field/form-field.component.css @@ -23,7 +23,7 @@ padding: 0.85rem 1rem; border: 1px solid var(--color-border); border-radius: var(--border-radius-md); - + /* Aussehen & Typografie */ background-color: var(--color-surface); color: var(--color-text); @@ -46,14 +46,18 @@ position: absolute; top: 50%; /* Vertikal zentrieren (Schritt 1) */ left: 1rem; /* Linken Abstand wie beim Input-Padding halten */ - transform: translateY(-50%); /* Vertikal zentrieren (Schritt 2 - Feinjustierung) */ + transform: translateY( + -50% + ); /* Vertikal zentrieren (Schritt 2 - Feinjustierung) */ border-radius: 4px; /* Aussehen & Typografie */ color: var(--color-text-light); - background-color: var(--color-surface); /* WICHTIG: Überdeckt die Input-Linie beim Schweben */ + background-color: var( + --color-surface + ); /* WICHTIG: Überdeckt die Input-Linie beim Schweben */ padding: 0 0.25rem; - + /* Verhalten */ transition: all 0.2s ease-out; /* Animiert alle Änderungen (top, font-size, color) */ pointer-events: none; /* Erlaubt Klicks "durch" das Label auf das Input-Feld darunter */ @@ -75,7 +79,9 @@ .form-input:focus ~ .form-label, .form-input:not(:placeholder-shown) ~ .form-label { top: 0; - transform: translateY(-50%); /* Bleibt für die Zentrierung auf der Border-Linie wichtig */ + transform: translateY( + -50% + ); /* Bleibt für die Zentrierung auf der Border-Linie wichtig */ font-size: 0.8rem; color: var(--color-primary); } @@ -87,12 +93,12 @@ * und die richtige Farbe bekommt. */ .form-input:-webkit-autofill, -.form-input:-webkit-autofill:hover, -.form-input:-webkit-autofill:focus, +.form-input:-webkit-autofill:hover, +.form-input:-webkit-autofill:focus, .form-input:-webkit-autofill:active { - /* OPTIONAL: Überschreibt den unschönen gelben/blauen Autofill-Hintergrund */ - -webkit-box-shadow: 0 0 0 30px var(--color-surface) inset !important; - -webkit-text-fill-color: var(--color-text) !important; + /* OPTIONAL: Überschreibt den unschönen gelben/blauen Autofill-Hintergrund */ + -webkit-box-shadow: 0 0 0 30px var(--color-surface) inset !important; + -webkit-text-fill-color: var(--color-text) !important; } .form-input:-webkit-autofill ~ .form-label { @@ -100,4 +106,18 @@ transform: translateY(-50%); font-size: 0.8rem; color: var(--color-primary); -} \ No newline at end of file +} + +.required-indicator { + color: var(--color-danger); + font-weight: bold; + margin-left: 2px; +} + +/* Styling für die Fehlermeldung */ +.error-message { + color: var(--color-danger); + font-size: 0.875rem; + margin-top: 0.25rem; + padding-left: 0.25rem; +} diff --git a/src/app/shared/components/form/form-field/form-field.component.html b/src/app/shared/components/form/form-field/form-field.component.html index f7f9baf..9ca2787 100644 --- a/src/app/shared/components/form/form-field/form-field.component.html +++ b/src/app/shared/components/form/form-field/form-field.component.html @@ -1,13 +1,26 @@ -
- - - + + +
+
+ + + +
+ + +
+ {{ errorMessage }} +
\ No newline at end of file diff --git a/src/app/shared/components/form/form-field/form-field.component.ts b/src/app/shared/components/form/form-field/form-field.component.ts index 8f0cd2d..d1f06a7 100644 --- a/src/app/shared/components/form/form-field/form-field.component.ts +++ b/src/app/shared/components/form/form-field/form-field.component.ts @@ -2,7 +2,7 @@ import { Component, Input, forwardRef } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; +import { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, Validators } from '@angular/forms'; // Validators importieren @Component({ selector: 'app-form-field', @@ -10,10 +10,10 @@ import { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } imports: [ CommonModule, FormsModule, - ReactiveFormsModule // <-- WICHTIG: Hinzufügen, um mit AbstractControl zu arbeiten + ReactiveFormsModule ], templateUrl: './form-field.component.html', - styleUrl: './form-field.component.css', + styleUrls: ['./form-field.component.css'], providers: [ { provide: NG_VALUE_ACCESSOR, @@ -23,44 +23,42 @@ import { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } ], }) export class FormFieldComponent { - // --- KORREKTUR: Erweitere die erlaubten Typen --- @Input() type: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' = 'text'; @Input() label: string = ''; - - // Neuer Input, um das FormControl für die Fehleranzeige zu erhalten - @Input() control?: AbstractControl; - @Input() showErrors = true; // Standardmäßig Fehler anzeigen + @Input() control?: AbstractControl | null; + @Input() showErrors = true; controlId = `form-field-${Math.random().toString(36).substring(2, 9)}`; - - // --- Eigenschaften & Methoden für ControlValueAccessor --- value: string | number = ''; onChange: (value: any) => void = () => {}; onTouched: () => void = () => {}; disabled = false; - writeValue(value: any): void { - this.value = value; - } - registerOnChange(fn: any): void { - this.onChange = fn; - } - registerOnTouched(fn: any): void { - this.onTouched = fn; - } - setDisabledState?(isDisabled: boolean): void { - this.disabled = isDisabled; + // NEU: Getter, der automatisch prüft, ob das Feld ein Pflichtfeld ist. + get isRequired(): boolean { + if (!this.control) { + return false; + } + // hasValidator prüft, ob ein bestimmter Validator auf dem Control gesetzt ist. + return this.control.hasValidator(Validators.required); } - // Hilfsfunktion für das Template, um Fehler zu finden get errorMessage(): string | null { if (!this.control || !this.control.errors || (!this.control.touched && !this.control.dirty)) { return null; } - if (this.control.hasError('required')) return 'Dieses Feld ist erforderlich.'; - if (this.control.hasError('email')) return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.'; - if (this.control.hasError('min')) return `Der Wert muss mindestens ${this.control.errors['min'].min} sein.`; - // ... weitere Fehlermeldungen hier + const errors = this.control.errors; + if (errors['required']) return 'Dieses Feld ist erforderlich.'; + if (errors['email']) return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.'; + if (errors['min']) return `Der Wert muss mindestens ${errors['min'].min} sein.`; + if (errors['max']) return `Der Wert darf maximal ${errors['max'].max} sein.`; + if (errors['minlength']) return `Mindestens ${errors['minlength'].requiredLength} Zeichen erforderlich.`; + return 'Ungültige Eingabe.'; } + + writeValue(value: any): void { this.value = value; } + registerOnChange(fn: any): void { this.onChange = fn; } + registerOnTouched(fn: any): void { this.onTouched = fn; } + setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; } } \ No newline at end of file diff --git a/src/app/shared/components/form/form-textarea/form-textarea.component.css b/src/app/shared/components/form/form-textarea/form-textarea.component.css index 89e3a9b..821d37d 100644 --- a/src/app/shared/components/form/form-textarea/form-textarea.component.css +++ b/src/app/shared/components/form/form-textarea/form-textarea.component.css @@ -76,4 +76,18 @@ border-radius: 4px; top: 0; font-size: 0.8rem; color: var(--color-primary); -} \ No newline at end of file +} + +.required-indicator { + color: var(--color-danger); + font-weight: bold; + margin-left: 2px; +} + +/* Styling für die Fehlermeldung */ +.error-message { + color: var(--color-danger); + font-size: 0.875rem; + margin-top: 0.25rem; + padding-left: 0.25rem; +} diff --git a/src/app/shared/components/form/form-textarea/form-textarea.component.html b/src/app/shared/components/form/form-textarea/form-textarea.component.html index 980ed7e..ff64793 100644 --- a/src/app/shared/components/form/form-textarea/form-textarea.component.html +++ b/src/app/shared/components/form/form-textarea/form-textarea.component.html @@ -1,13 +1,24 @@ -
- + + +
+
+ + + +
- +
+ {{ errorMessage }} +
\ No newline at end of file diff --git a/src/app/shared/components/form/form-textarea/form-textarea.component.ts b/src/app/shared/components/form/form-textarea/form-textarea.component.ts index 8b407bf..736412b 100644 --- a/src/app/shared/components/form/form-textarea/form-textarea.component.ts +++ b/src/app/shared/components/form/form-textarea/form-textarea.component.ts @@ -1,38 +1,54 @@ +// /src/app/shared/components/form/form-textarea/form-textarea.component.ts + import { Component, Input, forwardRef } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; +import { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, Validators } from '@angular/forms'; @Component({ selector: 'app-form-textarea', standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - FormsModule // Wichtig für [(ngModel)] - ], + imports: [ CommonModule, FormsModule, ReactiveFormsModule ], templateUrl: './form-textarea.component.html', - styleUrl: './form-textarea.component.css', + styleUrls: ['./form-textarea.component.css'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FormTextareaComponent), - multi: true - } - ] + multi: true, + }, + ], }) -export class FormTextareaComponent implements ControlValueAccessor { +export class FormTextareaComponent { @Input() label: string = ''; - @Input() rows = 3; // Standardanzahl der Zeilen + @Input() rows: number = 4; + + // NEU: Hinzufügen des 'control' Inputs, genau wie in form-field + @Input() control?: AbstractControl | null; + + @Input() showErrors = true; - // Eindeutige ID für die Verknüpfung - controlId = `form-textarea-${Math.random().toString(36).substring(2)}`; - - // --- Logik für ControlValueAccessor --- + controlId = `form-textarea-${Math.random().toString(36).substring(2, 9)}`; value: string = ''; onChange: (value: any) => void = () => {}; onTouched: () => void = () => {}; disabled = false; + get isRequired(): boolean { + if (!this.control) return false; + return this.control.hasValidator(Validators.required); + } + + get errorMessage(): string | null { + if (!this.control || !this.control.errors || (!this.control.touched && !this.control.dirty)) { + return null; + } + const errors = this.control.errors; + if (errors['required']) return 'Dieses Feld ist erforderlich.'; + if (errors['minlength']) return `Mindestens ${errors['minlength'].requiredLength} Zeichen erforderlich.`; + + return 'Ungültige Eingabe.'; + } + writeValue(value: any): void { this.value = value; } registerOnChange(fn: any): void { this.onChange = fn; } registerOnTouched(fn: any): void { this.onTouched = fn; }