ok
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
:host { display: block; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<form [formGroup]="productForm" (ngSubmit)="onSubmit()" novalidate>
|
||||||
|
<div class="form-header">
|
||||||
|
<h3 card-header>
|
||||||
|
{{ isEditMode ? "Produkt bearbeiten" : "Neues Produkt erstellen" }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sektion 1: Basis-Informationen -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h4 class="section-title">Basis-Informationen</h4>
|
||||||
|
<div class="form-grid">
|
||||||
|
<app-form-field class="grid-col-span-2" label="Name"
|
||||||
|
><input type="text" formControlName="name"
|
||||||
|
/></app-form-field>
|
||||||
|
<app-form-field label="Slug (automatisch generiert)"
|
||||||
|
><input type="text" formControlName="slug"
|
||||||
|
/></app-form-field>
|
||||||
|
<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="secondary" (click)="generateSku()"
|
||||||
|
>Generieren</app-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<app-form-textarea
|
||||||
|
class="grid-col-span-2"
|
||||||
|
label="Beschreibung"
|
||||||
|
[rows]="5"
|
||||||
|
formControlName="description"
|
||||||
|
></app-form-textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="divider" />
|
||||||
|
|
||||||
|
<!-- Sektion 2: Preis & Lager -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h4 class="section-title">Preis & Lager</h4>
|
||||||
|
<div class="form-grid">
|
||||||
|
<app-form-field label="Preis (€)"
|
||||||
|
><input type="number" formControlName="price"
|
||||||
|
/></app-form-field>
|
||||||
|
<app-form-field label="Alter Preis (€)"
|
||||||
|
><input type="number" formControlName="oldPrice"
|
||||||
|
/></app-form-field>
|
||||||
|
<app-form-field label="Einkaufspreis (€)"
|
||||||
|
><input type="number" formControlName="purchasePrice"
|
||||||
|
/></app-form-field>
|
||||||
|
<app-form-field label="Lagerbestand"
|
||||||
|
><input type="number" formControlName="stockQuantity"
|
||||||
|
/></app-form-field>
|
||||||
|
<app-form-field label="Gewicht (kg)"
|
||||||
|
><input type="number" formControlName="weight"
|
||||||
|
/></app-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="divider" />
|
||||||
|
|
||||||
|
<!-- Sektion 3: Zuweisungen -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h4 class="section-title">Zuweisungen</h4>
|
||||||
|
<div class="form-grid">
|
||||||
|
<!-- KORREKTUR: Fallback auf leeres Array für den async-Pipe -->
|
||||||
|
<app-form-select
|
||||||
|
label="Lieferant"
|
||||||
|
[options]="(supplierOptions$ | async) || []"
|
||||||
|
formControlName="supplierId"
|
||||||
|
></app-form-select>
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label">Kategorien</label>
|
||||||
|
<div class="category-checkbox-group">
|
||||||
|
<div
|
||||||
|
*ngFor="let category of allCategories$ | async"
|
||||||
|
class="checkbox-item"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[id]="'cat-' + category.id"
|
||||||
|
[value]="category.id"
|
||||||
|
[checked]="isCategorySelected(category.id)"
|
||||||
|
(change)="onCategoryChange($event)"
|
||||||
|
/>
|
||||||
|
<label [for]="'cat-' + category.id">{{ category.name }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="divider" />
|
||||||
|
|
||||||
|
<!-- Sektion 4: Bilder -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h4 class="section-title">Produktbilder</h4>
|
||||||
|
<!-- ... (Bild-Management-Logik hier einfügen) ... -->
|
||||||
|
</div>
|
||||||
|
<hr class="divider" />
|
||||||
|
|
||||||
|
<!-- Sektion 5: Status & Sichtbarkeit -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h4 class="section-title">Status & Sichtbarkeit</h4>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<app-slide-toggle formControlName="isActive"
|
||||||
|
>Aktiv (im Shop sichtbar)</app-slide-toggle
|
||||||
|
>
|
||||||
|
<app-slide-toggle formControlName="isFeatured"
|
||||||
|
>Hervorgehoben (z.B. auf Startseite)</app-slide-toggle
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid" style="margin-top: 1rem">
|
||||||
|
<app-form-field label="Anzeigereihenfolge"
|
||||||
|
><input type="number" formControlName="featuredDisplayOrder"
|
||||||
|
/></app-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<app-button buttonType="stroked" (click)="cancel()">Abbrechen</app-button>
|
||||||
|
<app-button
|
||||||
|
submitType="submit"
|
||||||
|
buttonType="primary"
|
||||||
|
[disabled]="productForm.invalid"
|
||||||
|
>
|
||||||
|
{{ isEditMode ? "Änderungen speichern" : "Produkt erstellen" }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// /src/app/features/admin/components/products/product-edit/product-edit.component.ts
|
||||||
|
|
||||||
|
import { Component, OnInit, OnDestroy, inject, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormBuilder, FormGroup, FormArray, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { Observable, Subscription } from 'rxjs';
|
||||||
|
import { debounceTime, distinctUntilChanged, map, startWith } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// ... (alle anderen Imports bleiben gleich)
|
||||||
|
import { AdminProduct, ProductImage } from '../../../../core/models/product.model';
|
||||||
|
import { Category } from '../../../../core/models/category.model';
|
||||||
|
import { Supplier } from '../../../../core/models/supplier.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 { 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-edit',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ CommonModule, ReactiveFormsModule, CardComponent, ButtonComponent, IconComponent, FormFieldComponent, FormSelectComponent, FormTextareaComponent, SlideToggleComponent ],
|
||||||
|
templateUrl: './product-edit.component.html',
|
||||||
|
styleUrl: './product-edit.component.css'
|
||||||
|
})
|
||||||
|
export class ProductEditComponent implements OnInit, OnDestroy {
|
||||||
|
@Input() productId: string | null = null;
|
||||||
|
@Output() formClose = new EventEmitter<void>();
|
||||||
|
|
||||||
|
private productService = inject(ProductService);
|
||||||
|
private categoryService = inject(CategoryService);
|
||||||
|
private supplierService = inject(SupplierService);
|
||||||
|
private fb = inject(FormBuilder);
|
||||||
|
private snackbarService = inject(SnackbarService);
|
||||||
|
|
||||||
|
isEditMode = false;
|
||||||
|
productForm: FormGroup;
|
||||||
|
|
||||||
|
allCategories$!: Observable<Category[]>;
|
||||||
|
supplierOptions$!: Observable<SelectOption[]>;
|
||||||
|
|
||||||
|
private nameChangeSubscription?: Subscription;
|
||||||
|
existingImages: ProductImage[] = [];
|
||||||
|
mainImageFile: File | null = null;
|
||||||
|
additionalImageFiles: File[] = [];
|
||||||
|
|
||||||
|
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([])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get categorieIds(): FormArray { return this.productForm.get('categorieIds') as FormArray; }
|
||||||
|
get imagesToDelete(): FormArray { return this.productForm.get('imagesToDelete') as FormArray; }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.isEditMode = !!this.productId;
|
||||||
|
this.loadDropdownData();
|
||||||
|
this.subscribeToNameChanges();
|
||||||
|
if (this.isEditMode && this.productId) {
|
||||||
|
this.productService.getById(this.productId).subscribe(product => {
|
||||||
|
this.selectProduct(product);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.nameChangeSubscription?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDropdownData(): void {
|
||||||
|
this.allCategories$ = this.categoryService.getAll();
|
||||||
|
// --- HIER IST DIE KORREKTUR ---
|
||||||
|
this.supplierOptions$ = this.supplierService.getAll().pipe(
|
||||||
|
map(suppliers => suppliers.map(s => ({ value: s.id, label: s.name || 'Unbenannt' }))),
|
||||||
|
startWith([]) // Stellt sicher, dass das Observable sofort ein leeres Array ausgibt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectProduct(product: AdminProduct): void {
|
||||||
|
this.productForm.patchValue(product);
|
||||||
|
this.categorieIds.clear();
|
||||||
|
product.categorieIds?.forEach(id => this.categorieIds.push(this.fb.control(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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!');
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
if (this.productForm.invalid) {
|
||||||
|
this.productForm.markAllAsTouched();
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.mainImageFile) formData.append('MainImageFile', this.mainImageFile);
|
||||||
|
this.additionalImageFiles.forEach((file) => formData.append('AdditionalImageFiles', file));
|
||||||
|
|
||||||
|
// KORREKTUR: Explizites Casten zu einem gemeinsamen Typ (any) hilft TypeScript
|
||||||
|
const operation: Observable<any> = this.isEditMode
|
||||||
|
? this.productService.update(this.productId!, formData)
|
||||||
|
: this.productService.create(formData);
|
||||||
|
|
||||||
|
// KORREKTUR: Korrekte subscribe-Syntax mit Objekt
|
||||||
|
operation.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackbarService.show(this.isEditMode ? 'Produkt erfolgreich aktualisiert' : 'Produkt erfolgreich erstellt');
|
||||||
|
this.formClose.emit();
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.snackbarService.show('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
this.formClose.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/* /src/app/features/admin/components/products/product-list/product-list.component.css */
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
app-search-bar {
|
||||||
|
flex-grow: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-filter-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-filter-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
box-shadow: var(--box-shadow-lg);
|
||||||
|
padding: 0.75rem;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-filter-dropdown label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-filter-dropdown label:hover {
|
||||||
|
background-color: var(--color-body-bg-hover);
|
||||||
|
}
|
||||||
@@ -1,227 +1,31 @@
|
|||||||
<div>
|
<!-- /src/app/features/admin/components/products/product-list/product-list.component.html -->
|
||||||
<h1>Produkte verwalten</h1>
|
|
||||||
|
|
||||||
<!-- Das Formular bleibt unverändert und ist bereits korrekt -->
|
<div class="table-header">
|
||||||
<form [formGroup]="productForm" (ngSubmit)="onSubmit()">
|
<app-search-bar placeholder="Produkte nach Name oder SKU suchen..." (search)="onSearch($event)"></app-search-bar>
|
||||||
<!-- ... (Dein komplettes Formular hier) ... -->
|
<div class="header-actions">
|
||||||
<h3>
|
<div class="column-filter-container">
|
||||||
{{ selectedProductId ? "Produkt bearbeiten" : "Neues Produkt erstellen" }}
|
<app-button buttonType="stroked" (click)="toggleColumnFilter()" iconName="filter">
|
||||||
</h3>
|
Spalten
|
||||||
<h4>Basis-Informationen</h4>
|
</app-button>
|
||||||
<div><label>Name:</label><input type="text" formControlName="name" /></div>
|
<div class="column-filter-dropdown" *ngIf="isColumnFilterVisible">
|
||||||
<div>
|
<!-- Iteriert jetzt über allColumns, das vom Parent kommt -->
|
||||||
<label>Slug (automatisch generiert):</label
|
<label *ngFor="let col of allColumns">
|
||||||
><input type="text" formControlName="slug" />
|
<input
|
||||||
</div>
|
type="checkbox"
|
||||||
<div>
|
[checked]="isColumnVisible(col.key)"
|
||||||
<label>SKU (Artikelnummer):</label>
|
(change)="onColumnToggle(col, $event)">
|
||||||
<div style="display: flex; align-items: center; gap: 10px">
|
{{ col.title }}
|
||||||
<input
|
</label>
|
||||||
id="sku"
|
|
||||||
type="text"
|
|
||||||
formControlName="sku"
|
|
||||||
style="flex-grow: 1"
|
|
||||||
/><button type="button" (click)="generateSku()">Generieren</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<app-button buttonType="primary" (click)="addNew.emit()">
|
||||||
<label>Beschreibung:</label
|
<app-icon iconName="plus"></app-icon> Neues Produkt
|
||||||
><textarea formControlName="description" rows="5"></textarea>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
</div>
|
||||||
<h4>Preis & Lager</h4>
|
<app-generic-table
|
||||||
<div>
|
[data]="filteredProducts"
|
||||||
<label>Preis (€):</label><input type="number" formControlName="price" />
|
[columns]="visibleColumns"
|
||||||
</div>
|
(edit)="editProduct.emit($event.id)"
|
||||||
<div>
|
(delete)="deleteProduct.emit($event.id)">
|
||||||
<label>Alter Preis (€) (optional):</label
|
</app-generic-table>
|
||||||
><input type="number" formControlName="oldPrice" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Einkaufspreis (€) (optional):</label
|
|
||||||
><input type="number" formControlName="purchasePrice" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Lagerbestand:</label
|
|
||||||
><input type="number" formControlName="stockQuantity" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Gewicht (in kg) (optional):</label
|
|
||||||
><input type="number" formControlName="weight" />
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h4>Zuweisungen</h4>
|
|
||||||
<div>
|
|
||||||
<label>Lieferant:</label
|
|
||||||
><select formControlName="supplierId">
|
|
||||||
<option [ngValue]="null">-- Kein Lieferant --</option>
|
|
||||||
<option
|
|
||||||
*ngFor="let supplier of allSuppliers$ | async"
|
|
||||||
[value]="supplier.id"
|
|
||||||
>
|
|
||||||
{{ supplier.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Kategorien:</label>
|
|
||||||
<div
|
|
||||||
class="category-checkbox-group"
|
|
||||||
style="
|
|
||||||
height: 100px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
padding: 5px;
|
|
||||||
margin-top: 5px;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div *ngFor="let category of allCategories$ | async">
|
|
||||||
<label
|
|
||||||
><input
|
|
||||||
type="checkbox"
|
|
||||||
[value]="category.id"
|
|
||||||
[checked]="isCategorySelected(category.id)"
|
|
||||||
(change)="onCategoryChange($event)"
|
|
||||||
/>{{ category.name }}</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h4>Produktbilder</h4>
|
|
||||||
<div *ngIf="selectedProductId && existingImages.length > 0">
|
|
||||||
<p>Bestehende Bilder:</p>
|
|
||||||
<div
|
|
||||||
style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 15px"
|
|
||||||
>
|
|
||||||
<div *ngFor="let img of existingImages" style="position: relative">
|
|
||||||
<img
|
|
||||||
[src]="img.url"
|
|
||||||
[alt]="productForm.get('name')?.value"
|
|
||||||
style="
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 2px solid;
|
|
||||||
"
|
|
||||||
[style.borderColor]="img.isMainImage ? 'green' : 'gray'"
|
|
||||||
/><button
|
|
||||||
(click)="deleteExistingImage(img.id, $event)"
|
|
||||||
style="
|
|
||||||
position: absolute;
|
|
||||||
top: -5px;
|
|
||||||
right: -5px;
|
|
||||||
background: red;
|
|
||||||
color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 20px;
|
|
||||||
text-align: center;
|
|
||||||
padding: 0;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
X
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="main-image">Hauptbild (ersetzt bestehendes Hauptbild)</label
|
|
||||||
><input
|
|
||||||
id="main-image"
|
|
||||||
type="file"
|
|
||||||
(change)="onMainFileChange($event)"
|
|
||||||
accept="image/*"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="additional-images">Zusätzliche Bilder hinzufügen</label
|
|
||||||
><input
|
|
||||||
id="additional-images"
|
|
||||||
type="file"
|
|
||||||
(change)="onAdditionalFilesChange($event)"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<h4>Status & Sichtbarkeit</h4>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
><input type="checkbox" formControlName="isActive" /> Aktiv (im Shop
|
|
||||||
sichtbar)</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
><input type="checkbox" formControlName="isFeatured" /> Hervorgehoben
|
|
||||||
(z.B. auf Startseite)</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Anzeigereihenfolge (Hervorgehoben):</label
|
|
||||||
><input type="number" formControlName="featuredDisplayOrder" />
|
|
||||||
</div>
|
|
||||||
<br /><br />
|
|
||||||
<button type="submit" [disabled]="productForm.invalid">
|
|
||||||
{{ selectedProductId ? "Aktualisieren" : "Erstellen" }}
|
|
||||||
</button>
|
|
||||||
<button type="button" *ngIf="selectedProductId" (click)="clearSelection()">
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
<h2>Bestehende Produkte</h2>
|
|
||||||
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<thead>
|
|
||||||
<tr >
|
|
||||||
<th style="padding: 8px; border: 1px solid #ddd;">Bild</th>
|
|
||||||
<th style="padding: 8px; border: 1px solid #ddd;">Name</th>
|
|
||||||
<th style="padding: 8px; border: 1px solid #ddd;">SKU</th>
|
|
||||||
<th style="padding: 8px; border: 1px solid #ddd;">Preis</th>
|
|
||||||
<th style="padding: 8px; border: 1px solid #ddd;">Lagerbestand</th>
|
|
||||||
<th style="padding: 8px; border: 1px solid #ddd;">Aktiv</th>
|
|
||||||
<th style="padding: 8px; border: 1px solid #ddd;">Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<ng-container *ngIf="products$ | async as products; else loading">
|
|
||||||
<tr *ngIf="products.length === 0">
|
|
||||||
<td colspan="7" style="text-align: center; padding: 16px;">Keine Produkte gefunden.</td>
|
|
||||||
</tr>
|
|
||||||
<tr *ngFor="let product of products">
|
|
||||||
<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">
|
|
||||||
<!-- +++ HIER IST DIE KORREKTUR +++ -->
|
|
||||||
<img
|
|
||||||
[src]="getMainImageUrl(product.images)"
|
|
||||||
[alt]="product.name"
|
|
||||||
style="width: 50px; height: 50px; object-fit: cover;">
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px; border: 1px solid #ddd;">
|
|
||||||
<strong>{{ product.name }}</strong><br>
|
|
||||||
<small style="color: #777;">Slug: {{ product.slug }}</small>
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px; border: 1px solid #ddd;">{{ product.sku }}</td>
|
|
||||||
<td style="padding: 8px; border: 1px solid #ddd;">{{ product.price | currency:'EUR' }}</td>
|
|
||||||
<td style="padding: 8px; border: 1px solid #ddd;">{{ product.stockQuantity }}</td>
|
|
||||||
<td style="padding: 8px; border: 1px solid #ddd; text-align: center;" [style.color]="product.isActive ? 'green' : 'red'">
|
|
||||||
{{ product.isActive ? 'Ja' : 'Nein' }}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px; border: 1px solid #ddd; width: 150px; text-align: center;">
|
|
||||||
<button (click)="selectProduct(product)">Bearbeiten</button>
|
|
||||||
<button (click)="onDelete(product.id)" style="margin-left: 5px; background-color: #dc3545; color: white;">Löschen</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</ng-container>
|
|
||||||
<ng-template #loading>
|
|
||||||
<tr>
|
|
||||||
<td colspan="7" style="text-align: center; padding: 16px;">Lade Produkte...</td>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
@@ -1,282 +1,68 @@
|
|||||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
// /src/app/features/admin/components/products/product-list/product-list.component.ts
|
||||||
|
|
||||||
|
import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import { AdminProduct } from '../../../../core/models/product.model';
|
||||||
FormBuilder,
|
import { GenericTableComponent, ColumnConfig } from '../../../../shared/components/data-display/generic-table/generic-table.component';
|
||||||
FormGroup,
|
import { SearchBarComponent } from '../../../../shared/components/layout/search-bar/search-bar.component';
|
||||||
FormArray,
|
import { ButtonComponent } from '../../../../shared/components/ui/button/button.component';
|
||||||
FormControl,
|
import { IconComponent } from '../../../../shared/components/ui/icon/icon.component';
|
||||||
ReactiveFormsModule,
|
|
||||||
Validators,
|
|
||||||
} from '@angular/forms';
|
|
||||||
import { Observable, Subscription } from 'rxjs';
|
|
||||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
|
||||||
|
|
||||||
// Models
|
|
||||||
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';
|
|
||||||
import { CategoryService } from '../../../services/category.service';
|
|
||||||
import { SupplierService } from '../../../services/supplier.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-list',
|
selector: 'app-product-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule],
|
imports: [CommonModule, GenericTableComponent, SearchBarComponent, ButtonComponent, IconComponent],
|
||||||
templateUrl: './product-list.component.html',
|
templateUrl: './product-list.component.html',
|
||||||
|
styleUrl: './product-list.component.css'
|
||||||
})
|
})
|
||||||
export class ProductListComponent implements OnInit, OnDestroy {
|
export class ProductListComponent implements OnChanges {
|
||||||
private productService = inject(ProductService);
|
// --- Inputs & Outputs ---
|
||||||
private categoryService = inject(CategoryService);
|
@Input() products: (AdminProduct & { mainImage?: string; supplierName?: string })[] = [];
|
||||||
private supplierService = inject(SupplierService);
|
@Input() allColumns: ColumnConfig[] = [];
|
||||||
private fb = inject(FormBuilder);
|
@Input() visibleColumns: ColumnConfig[] = [];
|
||||||
|
|
||||||
products$!: Observable<AdminProduct[]>;
|
@Output() addNew = new EventEmitter<void>();
|
||||||
allCategories$!: Observable<Category[]>;
|
@Output() editProduct = new EventEmitter<string>();
|
||||||
allSuppliers$!: Observable<Supplier[]>;
|
@Output() deleteProduct = new EventEmitter<string>();
|
||||||
|
@Output() columnsChange = new EventEmitter<ColumnConfig[]>();
|
||||||
|
@Output() searchChange = new EventEmitter<string>();
|
||||||
|
|
||||||
productForm: FormGroup;
|
filteredProducts: (AdminProduct & { mainImage?: string; supplierName?: string })[] = [];
|
||||||
selectedProductId: string | null = null;
|
isColumnFilterVisible = false;
|
||||||
private nameChangeSubscription?: Subscription;
|
|
||||||
|
|
||||||
// Eigenschaften für das Bild-Management
|
constructor() {}
|
||||||
existingImages: ProductImage[] = [];
|
|
||||||
mainImageFile: File | null = null;
|
|
||||||
additionalImageFiles: File[] = [];
|
|
||||||
|
|
||||||
constructor() {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
this.productForm = this.fb.group({
|
if (changes['products']) {
|
||||||
name: ['', Validators.required],
|
this.filteredProducts = this.products ? [...this.products] : [];
|
||||||
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([]), // FormArray für die IDs der zu löschenden Bilder
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getter für einfachen Zugriff auf FormArrays
|
onSearch(term: string): void {
|
||||||
get categorieIds(): FormArray {
|
this.searchChange.emit(term);
|
||||||
return this.productForm.get('categorieIds') as FormArray;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get imagesToDelete(): FormArray {
|
toggleColumnFilter(): void {
|
||||||
return this.productForm.get('imagesToDelete') as FormArray;
|
this.isColumnFilterVisible = !this.isColumnFilterVisible;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
isColumnVisible(columnKey: string): boolean {
|
||||||
this.loadInitialData();
|
return this.visibleColumns.some(c => c.key === columnKey);
|
||||||
this.subscribeToNameChanges();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
onColumnToggle(column: ColumnConfig, event: Event): void {
|
||||||
this.nameChangeSubscription?.unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadInitialData(): void {
|
|
||||||
this.products$ = this.productService.getAll();
|
|
||||||
this.allCategories$ = this.categoryService.getAll();
|
|
||||||
this.allSuppliers$ = this.supplierService.getAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
selectProduct(product: AdminProduct): void {
|
|
||||||
this.selectedProductId = product.id;
|
|
||||||
this.productForm.patchValue(product);
|
|
||||||
|
|
||||||
this.categorieIds.clear();
|
|
||||||
product.categorieIds?.forEach((id) =>
|
|
||||||
this.categorieIds.push(this.fb.control(id))
|
|
||||||
);
|
|
||||||
|
|
||||||
this.existingImages = product.images || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSelection(): void {
|
|
||||||
this.selectedProductId = null;
|
|
||||||
this.productForm.reset({
|
|
||||||
name: '',
|
|
||||||
slug: '',
|
|
||||||
sku: '',
|
|
||||||
description: '',
|
|
||||||
price: 0,
|
|
||||||
oldPrice: null,
|
|
||||||
purchasePrice: null,
|
|
||||||
stockQuantity: 0,
|
|
||||||
weight: null,
|
|
||||||
isActive: true,
|
|
||||||
isFeatured: false,
|
|
||||||
featuredDisplayOrder: 0,
|
|
||||||
supplierId: null,
|
|
||||||
});
|
|
||||||
this.categorieIds.clear();
|
|
||||||
this.imagesToDelete.clear();
|
|
||||||
this.existingImages = [];
|
|
||||||
this.mainImageFile = null;
|
|
||||||
this.additionalImageFiles = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
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 checkbox = event.target as HTMLInputElement;
|
||||||
const categoryId = checkbox.value;
|
let newVisibleColumns: ColumnConfig[];
|
||||||
|
|
||||||
if (checkbox.checked) {
|
if (checkbox.checked) {
|
||||||
if (!this.categorieIds.value.includes(categoryId)) {
|
newVisibleColumns = [...this.visibleColumns, column];
|
||||||
this.categorieIds.push(new FormControl(categoryId));
|
newVisibleColumns.sort((a, b) =>
|
||||||
}
|
this.allColumns.findIndex(c => c.key === a.key) -
|
||||||
} else {
|
this.allColumns.findIndex(c => c.key === b.key)
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit(): void {
|
|
||||||
if (this.productForm.invalid) return;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
const formValue = this.productForm.value;
|
|
||||||
|
|
||||||
Object.keys(formValue).forEach((key) => {
|
|
||||||
const value = formValue[key];
|
|
||||||
if (key === 'categorieIds' || key === 'imagesToDelete') {
|
|
||||||
// FormArrays müssen speziell behandelt werden
|
|
||||||
(value as string[]).forEach((id) =>
|
|
||||||
formData.append(this.capitalizeFirstLetter(key), id)
|
|
||||||
);
|
|
||||||
} else if (value !== null && value !== undefined && value !== '') {
|
|
||||||
// Leere Strings für optionale number-Felder nicht mitsenden
|
|
||||||
if (
|
|
||||||
['oldPrice', 'purchasePrice', 'weight'].includes(key) &&
|
|
||||||
value === ''
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
formData.append(this.capitalizeFirstLetter(key), value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.mainImageFile) {
|
|
||||||
formData.append('MainImageFile', this.mainImageFile);
|
|
||||||
}
|
|
||||||
this.additionalImageFiles.forEach((file) => {
|
|
||||||
formData.append('AdditionalImageFiles', file);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.selectedProductId) {
|
|
||||||
formData.append('Id', this.selectedProductId);
|
|
||||||
this.productService
|
|
||||||
.update(this.selectedProductId, formData)
|
|
||||||
.subscribe(() => this.reset());
|
|
||||||
} else {
|
} else {
|
||||||
this.productService.create(formData).subscribe(() => this.reset());
|
newVisibleColumns = this.visibleColumns.filter(c => c.key !== column.key);
|
||||||
}
|
}
|
||||||
}
|
this.columnsChange.emit(newVisibleColumns);
|
||||||
|
|
||||||
onDelete(id: string): void {
|
|
||||||
if (confirm('Produkt wirklich löschen?')) {
|
|
||||||
this.productService.delete(id).subscribe(() => this.loadInitialData());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private reset(): void {
|
|
||||||
this.loadInitialData();
|
|
||||||
this.clearSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sucht das Hauptbild aus der Bilderliste eines Produkts und gibt dessen URL zurück.
|
|
||||||
* Gibt eine Platzhalter-URL zurück, wenn kein Hauptbild gefunden wird.
|
|
||||||
* @param images Die Liste der Produktbilder.
|
|
||||||
* @returns Die URL des Hauptbildes oder eine Platzhalter-URL.
|
|
||||||
*/
|
|
||||||
getMainImageUrl(images?: ProductImage[]): string {
|
|
||||||
if (!images || images.length === 0) {
|
|
||||||
return ''; // Platzhalter, wenn gar keine Bilder vorhanden sind
|
|
||||||
}
|
|
||||||
const mainImage = images.find(img => img.isMainImage);
|
|
||||||
return mainImage?.url || images[0].url || ''; // Fallback auf das erste Bild, wenn kein Hauptbild markiert ist
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<div class="page-container">
|
||||||
|
<h1 class="page-title">Produktverwaltung</h1>
|
||||||
|
|
||||||
|
<app-product-edit
|
||||||
|
*ngIf="currentView === 'edit' || currentView === 'create'"
|
||||||
|
[productId]="selectedProductId"
|
||||||
|
(formClose)="onFormClose()">
|
||||||
|
</app-product-edit>
|
||||||
|
|
||||||
|
<app-product-list
|
||||||
|
*ngIf="currentView === 'list'"
|
||||||
|
[products]="(filteredProducts)"
|
||||||
|
[allColumns]="allTableColumns"
|
||||||
|
[visibleColumns]="visibleTableColumns"
|
||||||
|
(addNew)="onAddNew()"
|
||||||
|
(editProduct)="onEditProduct($event)"
|
||||||
|
(deleteProduct)="onDeleteProduct($event)"
|
||||||
|
(columnsChange)="onColumnsChange($event)"
|
||||||
|
(searchChange)="onSearch($event)">
|
||||||
|
</app-product-list>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AdminProduct, ProductImage } from '../../../../core/models/product.model';
|
||||||
|
import { ProductService } from '../../../services/product.service';
|
||||||
|
import { ProductListComponent } from '../product-list/product-list.component';
|
||||||
|
import { ProductEditComponent } from '../product-edit/product-edit.component';
|
||||||
|
import { SnackbarService } from '../../../../shared/services/snackbar.service';
|
||||||
|
import { StorageService } from '../../../../core/services/storage.service';
|
||||||
|
import { ColumnConfig } from '../../../../shared/components/data-display/generic-table/generic-table.component';
|
||||||
|
import { SupplierService } from '../../../services/supplier.service';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-products-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ProductListComponent, ProductEditComponent],
|
||||||
|
providers: [DatePipe],
|
||||||
|
templateUrl: './product-page.component.html',
|
||||||
|
styleUrls: ['./product-page.component.css'],
|
||||||
|
})
|
||||||
|
export class ProductsPageComponent implements OnInit {
|
||||||
|
private productService = inject(ProductService);
|
||||||
|
private supplierService = inject(SupplierService);
|
||||||
|
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 })[] = [];
|
||||||
|
|
||||||
|
currentView: 'list' | 'edit' | 'create' = 'list';
|
||||||
|
selectedProductId: string | null = null;
|
||||||
|
|
||||||
|
// --- VOLLSTÄNDIGE SPALTEN-DEFINITION ---
|
||||||
|
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: 'supplierName', title: 'Lieferant', type: 'text' },
|
||||||
|
{ key: 'isActive', title: 'Aktiv', type: 'status' },
|
||||||
|
{ key: 'isFeatured', title: 'Hervorgehoben', type: 'status' },
|
||||||
|
{ key: 'weight', title: 'Gewicht (kg)', type: 'text', cssClass: 'text-right' },
|
||||||
|
{ key: 'purchasePrice', title: 'EK-Preis', type: 'currency', cssClass: 'text-right' },
|
||||||
|
{ key: 'oldPrice', title: 'Alter Preis', type: 'currency', cssClass: 'text-right' },
|
||||||
|
{ key: 'createdDate', title: 'Erstellt am', type: 'text' },
|
||||||
|
{ key: 'lastModifiedDate', title: 'Geändert am', type: 'text' },
|
||||||
|
{ key: 'actions', title: 'Aktionen', type: 'actions', cssClass: 'text-right' }
|
||||||
|
];
|
||||||
|
visibleTableColumns: ColumnConfig[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.loadTableSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadProducts();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
mainImage: this.getMainImageUrl(p.images),
|
||||||
|
supplierName: supplier?.name || '-',
|
||||||
|
|
||||||
|
// --- HIER IST DIE KORREKTUR ---
|
||||||
|
createdDate: this.datePipe.transform(p.createdDate, 'dd.MM.yyyy HH:mm') || '-',
|
||||||
|
|
||||||
|
// Wenn lastModifiedDate existiert, transformiere es. Wenn das Ergebnis null ist,
|
||||||
|
// gib einen leeren String oder einen Platzhalter zurück. Wenn es nicht existiert, bleibt es undefined.
|
||||||
|
lastModifiedDate: p.lastModifiedDate
|
||||||
|
? (this.datePipe.transform(p.lastModifiedDate, 'dd.MM.yyyy HH:mm') || '-')
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// --- ENDE DER KORREKTUR ---
|
||||||
|
|
||||||
|
this.filteredProducts = [...this.allProducts];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
onSearch(term: any): void {
|
||||||
|
const lowerTerm = term.toLowerCase();
|
||||||
|
this.filteredProducts = this.allProducts.filter(p =>
|
||||||
|
p.name?.toLowerCase().includes(lowerTerm) ||
|
||||||
|
p.sku?.toLowerCase().includes(lowerTerm) ||
|
||||||
|
p.supplierName?.toLowerCase().includes(lowerTerm)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onAddNew(): void { this.currentView = 'create'; this.selectedProductId = null; }
|
||||||
|
onEditProduct(productId: string): void { this.currentView = 'edit'; this.selectedProductId = productId; }
|
||||||
|
onFormClose(): void { this.currentView = 'list'; this.selectedProductId = null; this.loadProducts(); }
|
||||||
|
onDeleteProduct(productId: string): void {
|
||||||
|
if (confirm('Möchten Sie dieses Produkt wirklich endgültig löschen?')) {
|
||||||
|
this.productService.delete(productId).subscribe(() => {
|
||||||
|
this.snackbar.show('Produkt erfolgreich gelöscht.');
|
||||||
|
this.loadProducts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
onColumnsChange(newVisibleColumns: ColumnConfig[]): void {
|
||||||
|
this.visibleTableColumns = newVisibleColumns;
|
||||||
|
const visibleKeys = newVisibleColumns.map(c => c.key);
|
||||||
|
this.storageService.setItem(this.TABLE_SETTINGS_KEY, visibleKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMainImageUrl(images?: ProductImage[]): string {
|
||||||
|
if (!images || images.length === 0) return 'https://via.placeholder.com/50';
|
||||||
|
const mainImage = images.find(img => img.isMainImage);
|
||||||
|
return mainImage?.url || images[0]?.url || 'https://via.placeholder.com/50';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { ProductListComponent } from './product-list/product-list.component';
|
import { ProductListComponent } from './product-list/product-list.component';
|
||||||
|
import { GenericTableComponent } from '../../../shared/components/data-display/generic-table/generic-table.component';
|
||||||
|
import { ProductsPageComponent } from './product-page/product-page.component';
|
||||||
|
|
||||||
export const PRODUCTS_ROUTES: Routes = [
|
export const PRODUCTS_ROUTES: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: ProductListComponent,
|
component: ProductsPageComponent,
|
||||||
|
title: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '1',
|
||||||
|
component: GenericTableComponent,
|
||||||
title: '',
|
title: '',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/* /src/app/shared/components/table/generic-table/generic-table.component.css */
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-table thead th {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 2px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-table tbody tr {
|
||||||
|
transition: background-color var(--transition-speed);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-table tbody tr:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-table tbody tr:hover {
|
||||||
|
background-color: var(--color-body-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-table tbody td {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spezifische Zell-Stile */
|
||||||
|
.user-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-cell img {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verwendet die von dir definierte Klasse für die rechten Aktionen */
|
||||||
|
.actions-cell {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hilfsklasse für rechtsbündigen Text */
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data-cell {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!-- /src/app/shared/components/table/generic-table/generic-table.component.html -->
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="modern-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th *ngFor="let col of columns" [ngClass]="col.cssClass">{{ col.title }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let item of displayedData">
|
||||||
|
<td *ngFor="let col of columns" [ngClass]="col.cssClass">
|
||||||
|
|
||||||
|
<ng-container [ngSwitch]="col.type">
|
||||||
|
|
||||||
|
<ng-container *ngSwitchCase="'text'">
|
||||||
|
<div>
|
||||||
|
<span [class.mono]="col.key === 'id' || col.key === 'orderNumber' || col.key === 'sku'">
|
||||||
|
{{ getProperty(item, col.key) }}
|
||||||
|
</span>
|
||||||
|
<div *ngIf="col.subKey" class="user-email">
|
||||||
|
{{ getProperty(item, col.subKey) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngSwitchCase="'currency'">
|
||||||
|
<span class="amount">{{ getProperty(item, col.key) | currency:'EUR' }}</span>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- VEREINFACHT: Wir übergeben den Wert direkt, die Pille kümmert sich um den Rest. -->
|
||||||
|
<ng-container *ngSwitchCase="'status'">
|
||||||
|
<app-status-pill [status]="getProperty(item, col.key)"></app-status-pill>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngSwitchCase="'image-text'">
|
||||||
|
<div class="user-cell">
|
||||||
|
<img [src]="getProperty(item, col.imageKey!) || 'https://via.placeholder.com/40'"
|
||||||
|
[alt]="'Bild von ' + getProperty(item, col.key)" />
|
||||||
|
<div>
|
||||||
|
<div class="user-name">{{ getProperty(item, col.key) }}</div>
|
||||||
|
<div class="user-email">{{ getProperty(item, col.subKey!) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngSwitchCase="'image'">
|
||||||
|
<img [src]="getProperty(item, col.key) || 'https://via.placeholder.com/50'"
|
||||||
|
alt="Bild"
|
||||||
|
style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;">
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngSwitchCase="'actions'">
|
||||||
|
<div class="actions-cell">
|
||||||
|
<app-button buttonType="icon" tooltip="Details anzeigen" iconName="eye" (click)="view.emit(item)"></app-button>
|
||||||
|
<app-button buttonType="icon" tooltip="Bearbeiten" iconName="edit" (click)="edit.emit(item)"></app-button>
|
||||||
|
<app-button buttonType="icon-danger" tooltip="Löschen" iconName="delete" (click)="delete.emit(item)"></app-button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
</ng-container>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="!displayedData || displayedData.length === 0">
|
||||||
|
<td [attr.colspan]="columns.length" class="no-data-cell">Keine Daten gefunden.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="data.length > itemsPerPage">
|
||||||
|
<app-paginator
|
||||||
|
[currentPage]="currentPage"
|
||||||
|
[totalItems]="data.length"
|
||||||
|
[itemsPerPage]="itemsPerPage"
|
||||||
|
(pageChange)="onPageChange($event)"
|
||||||
|
></app-paginator>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// /src/app/shared/components/table/generic-table/generic-table.component.ts
|
||||||
|
|
||||||
|
import { Component, Input, Output, EventEmitter, SimpleChanges, OnChanges, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule, CurrencyPipe } from '@angular/common';
|
||||||
|
import { StatusPillComponent } from '../../ui/status-pill/status-pill.component';
|
||||||
|
import { ButtonComponent } from '../../ui/button/button.component';
|
||||||
|
import { PaginatorComponent } from '../paginator/paginator.component';
|
||||||
|
|
||||||
|
export type ColumnType = 'text' | 'currency' | 'status' | 'image-text' | 'image' | 'actions';
|
||||||
|
|
||||||
|
export interface ColumnConfig {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
type: ColumnType;
|
||||||
|
imageKey?: string;
|
||||||
|
subKey?: string;
|
||||||
|
cssClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-generic-table',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ CommonModule, CurrencyPipe, StatusPillComponent, ButtonComponent, PaginatorComponent ],
|
||||||
|
templateUrl: './generic-table.component.html',
|
||||||
|
styleUrl: './generic-table.component.css',
|
||||||
|
})
|
||||||
|
export class GenericTableComponent implements OnChanges, OnInit {
|
||||||
|
@Input() data: any[] = [];
|
||||||
|
@Input() columns: ColumnConfig[] = [];
|
||||||
|
@Input() itemsPerPage = 10;
|
||||||
|
|
||||||
|
@Output() view = new EventEmitter<any>();
|
||||||
|
@Output() edit = new EventEmitter<any>();
|
||||||
|
@Output() delete = new EventEmitter<any>();
|
||||||
|
|
||||||
|
public displayedData: any[] = [];
|
||||||
|
public currentPage = 1;
|
||||||
|
|
||||||
|
ngOnInit(): void { this.updatePagination(); }
|
||||||
|
ngOnChanges(changes: SimpleChanges): void { if (changes['data']) { this.currentPage = 1; this.updatePagination(); } }
|
||||||
|
onPageChange(newPage: number): void { this.currentPage = newPage; this.updatePagination(); }
|
||||||
|
|
||||||
|
private updatePagination(): void {
|
||||||
|
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
|
||||||
|
const endIndex = startIndex + this.itemsPerPage;
|
||||||
|
this.displayedData = this.data.slice(startIndex, endIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProperty(item: any, key: string): any {
|
||||||
|
if (!key) return '';
|
||||||
|
return key.split('.').reduce((obj, part) => obj && obj[part], item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,57 +1,66 @@
|
|||||||
|
// /src/app/shared/components/form/form-field/form-field.component.ts
|
||||||
|
|
||||||
import { Component, Input, forwardRef } from '@angular/core';
|
import { Component, Input, forwardRef } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
|
||||||
FormsModule,
|
|
||||||
ControlValueAccessor,
|
|
||||||
NG_VALUE_ACCESSOR,
|
|
||||||
} from '@angular/forms';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-form-field',
|
selector: 'app-form-field',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule, // Wichtig für [(ngModel)] im Template
|
FormsModule,
|
||||||
|
ReactiveFormsModule // <-- WICHTIG: Hinzufügen, um mit AbstractControl zu arbeiten
|
||||||
],
|
],
|
||||||
templateUrl: './form-field.component.html',
|
templateUrl: './form-field.component.html',
|
||||||
styleUrl: './form-field.component.css',
|
styleUrl: './form-field.component.css',
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
// Stellt diese Komponente als "Value Accessor" zur Verfügung
|
|
||||||
provide: NG_VALUE_ACCESSOR,
|
provide: NG_VALUE_ACCESSOR,
|
||||||
useExisting: forwardRef(() => FormFieldComponent),
|
useExisting: forwardRef(() => FormFieldComponent),
|
||||||
multi: true,
|
multi: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
// Die Komponente implementiert die ControlValueAccessor-Schnittstelle
|
export class FormFieldComponent {
|
||||||
export class FormFieldComponent implements ControlValueAccessor {
|
// --- KORREKTUR: Erweitere die erlaubten Typen ---
|
||||||
|
@Input() type: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' = 'text';
|
||||||
@Input() label: string = '';
|
@Input() label: string = '';
|
||||||
@Input() type: 'text' | 'email' | 'password' = 'text';
|
|
||||||
|
|
||||||
controlId = `form-field-${Math.random().toString(36)}`;
|
// Neuer Input, um das FormControl für die Fehleranzeige zu erhalten
|
||||||
|
@Input() control?: AbstractControl;
|
||||||
|
@Input() showErrors = true; // Standardmäßig Fehler anzeigen
|
||||||
|
|
||||||
// --- Eigenschaften für ControlValueAccessor ---
|
controlId = `form-field-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
value: string = '';
|
|
||||||
|
// --- Eigenschaften & Methoden für ControlValueAccessor ---
|
||||||
|
value: string | number = '';
|
||||||
onChange: (value: any) => void = () => {};
|
onChange: (value: any) => void = () => {};
|
||||||
onTouched: () => void = () => {};
|
onTouched: () => void = () => {};
|
||||||
disabled = false;
|
disabled = false;
|
||||||
|
|
||||||
// --- Methoden, die von Angular Forms aufgerufen werden ---
|
|
||||||
|
|
||||||
writeValue(value: any): void {
|
writeValue(value: any): void {
|
||||||
this.value = value;
|
this.value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerOnChange(fn: any): void {
|
registerOnChange(fn: any): void {
|
||||||
this.onChange = fn;
|
this.onChange = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerOnTouched(fn: any): void {
|
registerOnTouched(fn: any): void {
|
||||||
this.onTouched = fn;
|
this.onTouched = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDisabledState?(isDisabled: boolean): void {
|
setDisabledState?(isDisabled: boolean): void {
|
||||||
this.disabled = isDisabled;
|
this.disabled = isDisabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
return 'Ungültige Eingabe.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -43,3 +43,9 @@
|
|||||||
|
|
||||||
.pill-info { color: #1d4ed8; background-color: #eff6ff; border-color: #bfdbfe; }
|
.pill-info { color: #1d4ed8; background-color: #eff6ff; border-color: #bfdbfe; }
|
||||||
:host-context(body.dark-theme) .pill-info { color: #93c5fd; background-color: #1e40af; border-color: #3b82f6; }
|
:host-context(body.dark-theme) .pill-info { color: #93c5fd; background-color: #1e40af; border-color: #3b82f6; }
|
||||||
|
|
||||||
|
.pill-active { color: #15803d; background-color: #ecfdf5; border-color: #bbf7d0; }
|
||||||
|
:host-context(body.dark-theme) .pill-active { color: #a7f3d0; background-color: #166534; border-color: #22c55e; }
|
||||||
|
|
||||||
|
.pill-inactive { color: rgb(168, 168, 168); background-color: #ececec; border-color: #777777; }
|
||||||
|
:host-context(body.dark-theme) .pill-inactive { color: rgb(168, 168, 168); background-color: #ececec; border-color: #777777; }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, Input, OnChanges } from '@angular/core';
|
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||||
import { CommonModule, NgClass } from '@angular/common';
|
import { CommonModule, NgClass } from '@angular/common';
|
||||||
// import { OrderStatus } from '../../../../core/types/order';
|
// import { OrderStatus } from '../../../../core/types/order';
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import { CommonModule, NgClass } from '@angular/common';
|
|||||||
})
|
})
|
||||||
export class StatusPillComponent implements OnChanges {
|
export class StatusPillComponent implements OnChanges {
|
||||||
// Nimmt jetzt den neuen, sprechenden Status entgegen
|
// Nimmt jetzt den neuen, sprechenden Status entgegen
|
||||||
@Input() status: any = 'info';
|
@Input() status: string | boolean = 'info';
|
||||||
|
|
||||||
// Diese Eigenschaften werden vom Template verwendet
|
// Diese Eigenschaften werden vom Template verwendet
|
||||||
public displayText = '';
|
public displayText = '';
|
||||||
@@ -22,13 +22,22 @@ export class StatusPillComponent implements OnChanges {
|
|||||||
['completed', { text: 'Abgeschlossen', css: 'pill-success' }],
|
['completed', { text: 'Abgeschlossen', css: 'pill-success' }],
|
||||||
['processing', { text: 'In Bearbeitung', css: 'pill-warning' }],
|
['processing', { text: 'In Bearbeitung', css: 'pill-warning' }],
|
||||||
['cancelled', { text: 'Storniert', css: 'pill-danger' }],
|
['cancelled', { text: 'Storniert', css: 'pill-danger' }],
|
||||||
['info', { text: 'Info', css: 'pill-info' }]
|
['info', { text: 'Info', css: 'pill-info' }],
|
||||||
|
['active', { text: 'Ja', css: 'pill-active' }],
|
||||||
|
['inactive', { text: 'Nein', css: 'pill-inactive' }]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ngOnChanges(): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
// Wenn sich der Input-Status ändert, aktualisieren wir Text und Klasse
|
if (changes['status']) {
|
||||||
const details = this.statusMap.get(this.status) || this.statusMap.get('info')!;
|
let statusKey = this.status;
|
||||||
this.displayText = details.text;
|
|
||||||
this.cssClass = details.css;
|
if (typeof statusKey === 'boolean') {
|
||||||
|
statusKey = statusKey ? 'active' : 'inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = this.statusMap.get(statusKey as string) || { text: statusKey.toString(), css: 'pill-secondary' };
|
||||||
|
this.displayText = details.text;
|
||||||
|
this.cssClass = details.css;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user