ok
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<!-- /src/app/features/admin/components/products/product-create/product-create.component.html -->
|
||||
<div *ngIf="isLoading; else formContent" class="loading-container">
|
||||
<p>Lade Formulardaten...</p>
|
||||
</div>
|
||||
|
||||
<ng-template #formContent>
|
||||
<div class="form-header">
|
||||
<h3 card-header>Neues Produkt erstellen</h3>
|
||||
</div>
|
||||
|
||||
<app-product-form
|
||||
[productForm]="productForm"
|
||||
[allCategories]="(allCategories$ | async) || []"
|
||||
[supplierOptions]="(supplierOptions$ | async) || []"
|
||||
[existingImages]="[]"
|
||||
[mainImagePreview]="mainImagePreview"
|
||||
[additionalImagesPreview]="additionalImagesPreview"
|
||||
[isLoading]="isLoading"
|
||||
submitButtonText="Produkt erstellen"
|
||||
(formSubmit)="onSubmit()"
|
||||
(formCancel)="cancel()"
|
||||
(mainFileSelected)="onMainFileSelected($event)"
|
||||
(additionalFilesSelected)="onAdditionalFilesSelected($event)"
|
||||
(newImageRemoved)="onNewImageRemoved($event)"
|
||||
>
|
||||
</app-product-form>
|
||||
</ng-template>
|
||||
@@ -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<Category[]>;
|
||||
supplierOptions$!: Observable<SelectOption[]>;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
.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; }
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 300px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
@@ -1,108 +1,26 @@
|
||||
<!-- /src/app/features/admin/components/products/product-edit/product-edit.component.html -->
|
||||
|
||||
<app-card>
|
||||
<div *ngIf="isLoading; else formContent" class="loading-container">
|
||||
<div *ngIf="isLoading; else formContent" class="loading-container">
|
||||
<p>Lade Produktdaten...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #formContent>
|
||||
<form [formGroup]="productForm" (ngSubmit)="onSubmit()" novalidate>
|
||||
<ng-template #formContent>
|
||||
<div class="form-header">
|
||||
<h3 card-header>{{ isEditMode ? "Produkt bearbeiten" : "Neues Produkt erstellen" }}</h3>
|
||||
<h3 card-header>Produkt bearbeiten</h3>
|
||||
</div>
|
||||
|
||||
<div class="edit-layout">
|
||||
<!-- LINKE SPALTE -->
|
||||
<div class="main-content">
|
||||
<app-card>
|
||||
<h4 card-header>Allgemein</h4>
|
||||
<div class="form-section">
|
||||
<app-form-field label="Name"><input type="text" formControlName="name"></app-form-field>
|
||||
<app-form-field label="Slug"><input type="text" formControlName="slug"></app-form-field>
|
||||
<app-form-textarea label="Beschreibung" [rows]="8" formControlName="description"></app-form-textarea>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<app-card>
|
||||
<h4 card-header>Produktbilder</h4>
|
||||
<!-- ... (Bild-Management bleibt gleich) ... -->
|
||||
</app-card>
|
||||
|
||||
<app-card>
|
||||
<h4 card-header>Preisgestaltung</h4>
|
||||
<div class="form-grid price-grid">
|
||||
<!-- KORREKTUR: Wrapper entfernt, formControlName auf die Komponente -->
|
||||
<app-form-field label="Preis (€)" type="number" formControlName="price"></app-form-field>
|
||||
<app-form-field label="Alter Preis (€)" type="number" formControlName="oldPrice"></app-form-field>
|
||||
<app-form-field label="Einkaufspreis (€)" type="number" formControlName="purchasePrice"></app-form-field>
|
||||
</div>
|
||||
</app-card>
|
||||
</div>
|
||||
|
||||
<!-- RECHTE SPALTE -->
|
||||
<div class="sidebar-content">
|
||||
<app-card>
|
||||
<h4 card-header>Status</h4>
|
||||
<div class="form-section">
|
||||
<app-slide-toggle formControlName="isActive">Aktiv (im Shop sichtbar)</app-slide-toggle>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<app-card>
|
||||
<h4 card-header>Organisation</h4>
|
||||
<div class="form-section">
|
||||
<div class="form-field">
|
||||
<label class="form-label">SKU (Artikelnummer)</label>
|
||||
<div class="input-with-button">
|
||||
<input type="text" class="form-input" formControlName="sku" />
|
||||
<app-button buttonType="icon" (click)="generateSku()" iconName="placeholder"></app-button>
|
||||
</div>
|
||||
</div>
|
||||
<app-form-field label="Lagerbestand" type="number" formControlName="stockQuantity"></app-form-field>
|
||||
<app-form-field label="Gewicht (kg)" type="number" formControlName="weight"></app-form-field>
|
||||
<app-form-select label="Lieferant" [options]="(supplierOptions$ | async) || []" formControlName="supplierId"></app-form-select>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<app-card>
|
||||
<h4 card-header>Kategorien</h4>
|
||||
<div class="form-section">
|
||||
<div class="multi-select-container">
|
||||
<div class="selected-pills">
|
||||
<span *ngFor="let catId of categorieIds.value" class="pill">
|
||||
{{ getCategoryName(catId) }}
|
||||
<app-icon iconName="x" (click)="removeCategoryById(catId)"></app-icon>
|
||||
</span>
|
||||
<span *ngIf="categorieIds.length === 0" class="placeholder">Keine ausgewählt</span>
|
||||
</div>
|
||||
<div class="category-checkbox-group">
|
||||
<label *ngFor="let category of allCategories$ | async">
|
||||
<input type="checkbox" [value]="category.id" [checked]="isCategorySelected(category.id)" (change)="onCategoryChange($event)">
|
||||
{{ category.name }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<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">
|
||||
<input type="number" formControlName="featuredDisplayOrder">
|
||||
</app-form-field>
|
||||
</div>
|
||||
</app-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<app-button buttonType="stroked" (click)="cancel()">Abbrechen</app-button>
|
||||
<app-button submitType="submit" buttonType="primary" [disabled]="productForm.invalid || isLoading">
|
||||
{{ isEditMode ? "Änderungen speichern" : "Produkt erstellen" }}
|
||||
</app-button>
|
||||
</div>
|
||||
</form>
|
||||
</ng-template>
|
||||
</app-card>
|
||||
<app-product-form
|
||||
[productForm]="productForm"
|
||||
[allCategories]="(allCategories$ | async) || []"
|
||||
[supplierOptions]="(supplierOptions$ | async) || []"
|
||||
[existingImages]="existingImages"
|
||||
[mainImagePreview]="mainImagePreview"
|
||||
[additionalImagesPreview]="additionalImagesPreview"
|
||||
[isLoading]="isLoading"
|
||||
submitButtonText="Änderungen speichern"
|
||||
(formSubmit)="onSubmit()"
|
||||
(formCancel)="cancel()"
|
||||
(mainFileSelected)="onMainFileSelected($event)"
|
||||
(additionalFilesSelected)="onAdditionalFilesSelected($event)"
|
||||
(existingImageDeleted)="onExistingImageDeleted($event)"
|
||||
(newImageRemoved)="onNewImageRemoved($event)"
|
||||
>
|
||||
</app-product-form>
|
||||
</ng-template>
|
||||
|
||||
@@ -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<Category[]>;
|
||||
supplierOptions$!: Observable<SelectOption[]>;
|
||||
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;
|
||||
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();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -170,35 +172,20 @@ export class ProductEditComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
const formValue = this.productForm.value;
|
||||
this.prepareSubmissionData();
|
||||
const formData = this.createFormData();
|
||||
|
||||
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());
|
||||
}
|
||||
});
|
||||
|
||||
if (this.mainImageFile) formData.append('MainImageFile', this.mainImageFile);
|
||||
this.additionalImageFiles.forEach((file) => formData.append('AdditionalImageFiles', file));
|
||||
|
||||
const operation: Observable<any> = 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -206,20 +193,130 @@ export class ProductEditComponent implements OnInit, OnDestroy {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<!-- /src/app/features/admin/components/products/product-form/product-form.component.html -->
|
||||
|
||||
<form [formGroup]="productForm" (ngSubmit)="onSubmit()" novalidate>
|
||||
<div class="edit-layout">
|
||||
|
||||
<!-- ================================================================= -->
|
||||
<!-- LINKE SPALTE (HAUPTINHALT) -->
|
||||
<!-- ================================================================= -->
|
||||
<div class="main-content">
|
||||
|
||||
<!-- Card: Allgemeine Produktinformationen -->
|
||||
<app-card>
|
||||
<h4 card-header>Allgemein</h4>
|
||||
<div class="form-section">
|
||||
<!-- Umgestellt auf die neue, intelligente Komponente -->
|
||||
<app-form-field
|
||||
label="Name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
[control]="productForm.get('name')">
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field
|
||||
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-textarea
|
||||
label="Beschreibung"
|
||||
[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-card>
|
||||
<h4 card-header>Produktbilder</h4>
|
||||
<div class="form-section">
|
||||
<div class="image-upload-section">
|
||||
<label class="form-label">Hauptbild</label>
|
||||
<p class="form-hint">Laden Sie hier das primäre Bild hoch.</p>
|
||||
<input type="file" accept="image/*" (change)="onMainFileChange($event)" />
|
||||
</div>
|
||||
<div class="image-upload-section">
|
||||
<label class="form-label">Zusätzliche Bilder</label>
|
||||
<p class="form-hint">Weitere Bilder für die Produktdetailseite.</p>
|
||||
<input type="file" accept="image/*" multiple (change)="onAdditionalFilesChange($event)" />
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasImages" class="image-gallery">
|
||||
<h5 class="gallery-title">Vorschau</h5>
|
||||
<div *ngIf="mainImagePreview" class="image-preview-container main-image-preview">
|
||||
<img [src]="mainImagePreview" alt="Vorschau Hauptbild" class="image-preview" />
|
||||
<span class="main-image-badge">Hauptbild</span>
|
||||
</div>
|
||||
<div class="additional-images-grid">
|
||||
<div *ngFor="let image of additionalExistingImages" class="image-preview-container">
|
||||
<img [src]="image.url" [alt]="'Bild ' + image.id" class="image-preview" />
|
||||
<app-button buttonType="icon-danger" (click)="deleteExistingImage(image.id, $event)" iconName="delete"></app-button>
|
||||
</div>
|
||||
<div *ngFor="let preview of additionalImagesPreview" class="image-preview-container">
|
||||
<img [src]="preview.url" [alt]="preview.name" class="image-preview" />
|
||||
<app-button buttonType="icon-danger" (click)="removeNewImage(preview.name, $event)" iconName="delete"></app-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<!-- Card: Preisgestaltung -->
|
||||
<app-card>
|
||||
<h4 card-header>Preisgestaltung</h4>
|
||||
<div class="form-grid price-grid">
|
||||
<app-form-field
|
||||
label="Preis (€)"
|
||||
type="number"
|
||||
formControlName="price"
|
||||
[control]="productForm.get('price')">
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field
|
||||
label="Alter Preis (€)"
|
||||
type="number"
|
||||
formControlName="oldPrice"
|
||||
[control]="productForm.get('oldPrice')">
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field
|
||||
label="Einkaufspreis (€)"
|
||||
type="number"
|
||||
formControlName="purchasePrice"
|
||||
[control]="productForm.get('purchasePrice')">
|
||||
</app-form-field>
|
||||
</div>
|
||||
</app-card>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================= -->
|
||||
<!-- RECHTE SPALTE (SEITENLEISTE) -->
|
||||
<!-- ================================================================= -->
|
||||
<div class="sidebar-content">
|
||||
<!-- Card: Status -->
|
||||
<app-card>
|
||||
<h4 card-header>Status</h4>
|
||||
<div class="form-section">
|
||||
<app-slide-toggle formControlName="isActive">Aktiv (im Shop sichtbar)</app-slide-toggle>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<!-- Card: Organisation -->
|
||||
<app-card>
|
||||
<h4 card-header>Organisation</h4>
|
||||
<div class="form-section">
|
||||
<!-- SKU bleibt manuell, da es ein spezielles Layout mit einem Button hat -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">SKU (Artikelnummer) <span class="required-indicator">*</span></label>
|
||||
<div class="input-with-button">
|
||||
<input type="text" class="form-input" formControlName="sku" />
|
||||
<app-button buttonType="icon" (click)="generateSku()" iconName="refresh-cw"></app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-form-field
|
||||
label="Lagerbestand"
|
||||
type="number"
|
||||
formControlName="stockQuantity"
|
||||
[control]="productForm.get('stockQuantity')">
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field
|
||||
label="Gewicht (kg)"
|
||||
type="number"
|
||||
formControlName="weight"
|
||||
[control]="productForm.get('weight')">
|
||||
</app-form-field>
|
||||
|
||||
<app-form-select
|
||||
label="Lieferant"
|
||||
[options]="supplierOptions"
|
||||
formControlName="supplierId">
|
||||
</app-form-select>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<!-- Card: Kategorien (bleibt unverändert, da es sich um eine komplexe Checkbox-Gruppe handelt) -->
|
||||
<app-card>
|
||||
<h4 card-header>Kategorien</h4>
|
||||
<div class="form-section">
|
||||
<div class="multi-select-container">
|
||||
<div class="selected-pills">
|
||||
<span *ngFor="let catId of categorieIds.value" class="pill">
|
||||
{{ getCategoryName(catId) }}
|
||||
<app-icon iconName="x" (click)="removeCategoryById(catId)"></app-icon>
|
||||
</span>
|
||||
<span *ngIf="categorieIds.length === 0" class="placeholder">Keine ausgewählt</span>
|
||||
</div>
|
||||
<div class="category-checkbox-group">
|
||||
<label *ngFor="let category of allCategories">
|
||||
<input type="checkbox" [value]="category.id" [checked]="isCategorySelected(category.id)" (change)="onCategoryChange($event)" />
|
||||
{{ category.name }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<!-- Card: Hervorheben -->
|
||||
<app-card>
|
||||
<h4 card-header>Hervorheben</h4>
|
||||
<div class="form-section">
|
||||
<app-slide-toggle formControlName="isFeatured">Auf Startseite anzeigen</app-slide-toggle>
|
||||
|
||||
<app-form-field
|
||||
*ngIf="productForm.get('isFeatured')?.value"
|
||||
label="Anzeigereihenfolge"
|
||||
type="number"
|
||||
formControlName="featuredDisplayOrder"
|
||||
[control]="productForm.get('featuredDisplayOrder')">
|
||||
</app-form-field>
|
||||
</div>
|
||||
</app-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================= -->
|
||||
<!-- FORMULAR-AKTIONEN (SPEICHERN/ABBRECHEN) -->
|
||||
<!-- ================================================================= -->
|
||||
<div class="form-actions">
|
||||
<app-button buttonType="stroked" (click)="cancel()">Abbrechen</app-button>
|
||||
<app-button
|
||||
submitType="submit"
|
||||
buttonType="primary"
|
||||
[disabled]="!productForm.valid || isLoading">
|
||||
{{ submitButtonText }}
|
||||
</app-button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -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<void>();
|
||||
@Output() formCancel = new EventEmitter<void>();
|
||||
@Output() mainFileSelected = new EventEmitter<File>();
|
||||
@Output() additionalFilesSelected = new EventEmitter<File[]>();
|
||||
@Output() existingImageDeleted = new EventEmitter<string>();
|
||||
@Output() newImageRemoved = new EventEmitter<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -45,63 +31,32 @@ export class ProductListComponent implements OnInit {
|
||||
|
||||
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) =>
|
||||
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<string[]>(
|
||||
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<string[]>(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';
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<p>product-new works!</p>
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -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'
|
||||
title: 'Produktübersicht',
|
||||
},
|
||||
{
|
||||
path: 'new',
|
||||
component: ProductEditComponent,
|
||||
title: 'Neues Produkt erstellen'
|
||||
path: 'create',
|
||||
component: ProductCreateComponent,
|
||||
title: 'Product | Create',
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
path: 'edit/:id',
|
||||
component: ProductEditComponent,
|
||||
title: 'Produkt bearbeiten'
|
||||
title: 'Product | Edit',
|
||||
},
|
||||
];
|
||||
@@ -46,12 +46,16 @@
|
||||
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 */
|
||||
@@ -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);
|
||||
}
|
||||
@@ -101,3 +107,17 @@
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<div class="form-field">
|
||||
<!-- /src/app/shared/components/form/form-field/form-field.component.html -->
|
||||
|
||||
<div class="form-field-wrapper">
|
||||
<div class="form-field">
|
||||
<input
|
||||
[type]="type"
|
||||
class="form-input"
|
||||
@@ -9,5 +12,15 @@
|
||||
(ngModelChange)="onChange($event)"
|
||||
(blur)="onTouched()">
|
||||
|
||||
<label [for]="controlId" class="form-label">{{ label }}</label>
|
||||
<label [for]="controlId" class="form-label">
|
||||
{{ label }}
|
||||
<!-- Der Indikator wird jetzt nur bei Bedarf angezeigt -->
|
||||
<span *ngIf="isRequired" class="required-indicator">*</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Anzeige für Validierungsfehler -->
|
||||
<div *ngIf="showErrors && errorMessage" class="error-message">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
// NEU: Getter, der automatisch prüft, ob das Feld ein Pflichtfeld ist.
|
||||
get isRequired(): boolean {
|
||||
if (!this.control) {
|
||||
return false;
|
||||
}
|
||||
registerOnChange(fn: any): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: any): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
setDisabledState?(isDisabled: boolean): void {
|
||||
this.disabled = isDisabled;
|
||||
// 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; }
|
||||
}
|
||||
@@ -77,3 +77,17 @@ border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
<div class="form-field">
|
||||
<!-- /src/app/shared/components/form/form-textarea/form-textarea.component.html -->
|
||||
|
||||
<div class="form-field-wrapper">
|
||||
<div class="form-field">
|
||||
<textarea
|
||||
class="form-input"
|
||||
[id]="controlId"
|
||||
placeholder=" "
|
||||
[rows]="rows"
|
||||
placeholder=" "
|
||||
[disabled]="disabled"
|
||||
[(ngModel)]="value"
|
||||
(ngModelChange)="onChange($event)"
|
||||
(blur)="onTouched()"
|
||||
[disabled]="disabled"></textarea>
|
||||
(blur)="onTouched()"></textarea>
|
||||
|
||||
<label [for]="controlId" class="form-label">{{ label }}</label>
|
||||
<label [for]="controlId" class="form-label">
|
||||
{{ label }}
|
||||
<span *ngIf="isRequired" class="required-indicator">*</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div *ngIf="showErrors && errorMessage" class="error-message">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
// Eindeutige ID für die Verknüpfung
|
||||
controlId = `form-textarea-${Math.random().toString(36).substring(2)}`;
|
||||
// NEU: Hinzufügen des 'control' Inputs, genau wie in form-field
|
||||
@Input() control?: AbstractControl | null;
|
||||
|
||||
// --- Logik für ControlValueAccessor ---
|
||||
@Input() showErrors = true;
|
||||
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user