Compare commits
4 Commits
2491b0142d
...
FEature_Sh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c2b6b5c9 | ||
|
|
dfe631edf6 | ||
|
|
ac42f8b1b9 | ||
|
|
c10e6b4faa |
5736
imageupdatefehler.txt
Normal file
5736
imageupdatefehler.txt
Normal file
File diff suppressed because it is too large
Load Diff
38
package-lock.json
generated
38
package-lock.json
generated
@@ -746,9 +746,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular/ssr": {
|
"node_modules/@angular/ssr": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.19",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-19.2.19.tgz",
|
||||||
"integrity": "sha512-9ABOYrHrCYnOiihkeHvN+QIaIN+1Js4QfT4cXAtZs/f+yFaURGre7yvyK1KncMBKwxXzjcqvu1QquFMU/m/JLw==",
|
"integrity": "sha512-7HqC3K99DdzDakB/4mkqGqY6REQNMxskU1VVkH9D7SthZSuxhWIMVBojVhBDd+JOUYiyQlwEGMBevbrgbtfKlQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
@@ -4262,9 +4262,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@npmcli/package-json/node_modules/glob": {
|
"node_modules/@npmcli/package-json/node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6658,9 +6658,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cacache/node_modules/glob": {
|
"node_modules/cacache/node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -9796,9 +9796,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -11437,9 +11437,9 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/node-forge": {
|
"node_modules/node-forge": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz",
|
||||||
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
|
"integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -14075,10 +14075,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.1",
|
"version": "7.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz",
|
||||||
"integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==",
|
"integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==",
|
||||||
"license": "ISC",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@isaacs/fs-minipass": "^4.0.0",
|
"@isaacs/fs-minipass": "^4.0.0",
|
||||||
"chownr": "^3.0.0",
|
"chownr": "^3.0.0",
|
||||||
|
|||||||
@@ -41,4 +41,5 @@ export interface AdminProduct {
|
|||||||
images?: ProductImage[];
|
images?: ProductImage[];
|
||||||
isFeatured: boolean;
|
isFeatured: boolean;
|
||||||
featuredDisplayOrder: number;
|
featuredDisplayOrder: number;
|
||||||
|
rowVersion?: string;
|
||||||
}
|
}
|
||||||
@@ -6,4 +6,7 @@ export interface ShippingMethod {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
minDeliveryDays: number;
|
minDeliveryDays: number;
|
||||||
maxDeliveryDays: number;
|
maxDeliveryDays: number;
|
||||||
|
// NEU: Gewichtsgrenzen
|
||||||
|
minWeight: number;
|
||||||
|
maxWeight: number;
|
||||||
}
|
}
|
||||||
@@ -26,56 +26,80 @@ export interface SelectOption {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ProductCategoryDropdownComponent implements ControlValueAccessor {
|
export class ProductCategoryDropdownComponent implements ControlValueAccessor {
|
||||||
@Input() label = '';
|
|
||||||
@Input() options: SelectOption[] = [];
|
@Input() options: SelectOption[] = [];
|
||||||
|
@Input() label = '';
|
||||||
@Input() placeholder = 'Bitte wählen...';
|
@Input() placeholder = 'Bitte wählen...';
|
||||||
|
|
||||||
public isOpen = false;
|
|
||||||
public selectedValues: any[] = [];
|
public selectedValues: any[] = [];
|
||||||
private elementRef = inject(ElementRef);
|
public isOpen = false;
|
||||||
|
public isDisabled = false;
|
||||||
|
|
||||||
onChange: (value: any[]) => void = () => {};
|
// Callbacks, die von Angular überschrieben werden
|
||||||
onTouched: () => void = () => {};
|
private onChange: (value: any[]) => void = () => {};
|
||||||
|
private onTouched: () => void = () => {};
|
||||||
|
|
||||||
writeValue(values: any[]): void { this.selectedValues = Array.isArray(values) ? values : []; }
|
// --- ControlValueAccessor Implementierung ---
|
||||||
registerOnChange(fn: any): void { this.onChange = fn; }
|
|
||||||
registerOnTouched(fn: any): void { this.onTouched = fn; }
|
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
// Wird von Angular aufgerufen, um Werte ins Formular zu schreiben (z.B. beim Laden)
|
||||||
onDocumentClick(event: Event): void {
|
writeValue(value: any[]): void {
|
||||||
if (this.isOpen && !this.elementRef.nativeElement.contains(event.target)) {
|
if (value && Array.isArray(value)) {
|
||||||
this.closeDropdown();
|
this.selectedValues = value;
|
||||||
|
} else {
|
||||||
|
this.selectedValues = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registriert die Funktion, die aufgerufen wird, wenn sich der Wert ändert
|
||||||
|
registerOnChange(fn: any): void {
|
||||||
|
this.onChange = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registriert die Funktion, die aufgerufen wird, wenn das Feld "berührt" wurde
|
||||||
|
registerOnTouched(fn: any): void {
|
||||||
|
this.onTouched = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Wenn das Feld deaktiviert wird
|
||||||
|
setDisabledState(isDisabled: boolean): void {
|
||||||
|
this.isDisabled = isDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UI Logik ---
|
||||||
|
|
||||||
toggleDropdown(event: Event): void {
|
toggleDropdown(event: Event): void {
|
||||||
event.stopPropagation();
|
event.preventDefault();
|
||||||
|
if (this.isDisabled) return;
|
||||||
this.isOpen = !this.isOpen;
|
this.isOpen = !this.isOpen;
|
||||||
if (!this.isOpen) { this.onTouched(); }
|
if (!this.isOpen) {
|
||||||
}
|
|
||||||
|
|
||||||
closeDropdown(): void {
|
|
||||||
if (this.isOpen) {
|
|
||||||
this.isOpen = false;
|
|
||||||
this.onTouched();
|
this.onTouched();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSelected(value: any): boolean {
|
||||||
|
return this.selectedValues.includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLabelForValue(value: any): string {
|
||||||
|
const option = this.options.find(o => o.value === value);
|
||||||
|
return option ? option.label : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
onOptionToggle(value: any): void {
|
onOptionToggle(value: any): void {
|
||||||
const index = this.selectedValues.indexOf(value);
|
if (this.isSelected(value)) {
|
||||||
if (index > -1) {
|
// Entfernen
|
||||||
this.selectedValues.splice(index, 1);
|
this.selectedValues = this.selectedValues.filter(v => v !== value);
|
||||||
} else {
|
} else {
|
||||||
this.selectedValues.push(value);
|
// Hinzufügen
|
||||||
|
this.selectedValues = [...this.selectedValues, value];
|
||||||
}
|
}
|
||||||
this.onChange([...this.selectedValues]); // Wichtig: Neue Array-Instanz senden
|
// Angular mitteilen, dass sich der Wert geändert hat
|
||||||
|
this.onChange(this.selectedValues);
|
||||||
|
this.onTouched();
|
||||||
}
|
}
|
||||||
|
|
||||||
onPillRemove(value: any, event: any): void { // Typ korrigiert
|
onPillRemove(value: any, event: any): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation(); // Verhindert, dass das Dropdown aufgeht/zugeht
|
||||||
this.onOptionToggle(value);
|
this.selectedValues = this.selectedValues.filter(v => v !== value);
|
||||||
|
this.onChange(this.selectedValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
isSelected(value: any): boolean { return this.selectedValues.includes(value); }
|
|
||||||
getLabelForValue(value: any): string { return this.options.find(opt => opt.value === value)?.label || ''; }
|
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
.form-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h3[card-header] {
|
h3[card-header] {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template #formContent>
|
<ng-template #formContent>
|
||||||
<div class="form-header">
|
<div class="page-header">
|
||||||
<h3 card-header>Neues Produkt erstellen</h3>
|
<h3 card-header>Neues Produkt erstellen</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
.form-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h3[card-header] {
|
h3[card-header] {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template #formContent>
|
<ng-template #formContent>
|
||||||
<div class="form-header">
|
<div class="page-header">
|
||||||
<h3 card-header>Produkt bearbeiten</h3>
|
<h3 card-header>Produkt bearbeiten</h3>
|
||||||
</div>
|
</div>
|
||||||
<app-product-form
|
<app-product-form
|
||||||
|
|||||||
@@ -1,42 +1,20 @@
|
|||||||
// /src/app/features/admin/components/products/product-edit/product-edit.component.ts
|
|
||||||
|
|
||||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import {
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
FormBuilder,
|
|
||||||
FormGroup,
|
|
||||||
FormArray,
|
|
||||||
FormControl,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
Validators,
|
|
||||||
} from '@angular/forms';
|
|
||||||
import { DomSanitizer } from '@angular/platform-browser';
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
import { Observable, Subscription } from 'rxjs';
|
import { Observable, Subscription, of } from 'rxjs';
|
||||||
import {
|
import { switchMap, finalize, map } from 'rxjs/operators';
|
||||||
map,
|
|
||||||
startWith,
|
|
||||||
debounceTime,
|
|
||||||
distinctUntilChanged,
|
|
||||||
finalize,
|
|
||||||
} from 'rxjs/operators';
|
|
||||||
|
|
||||||
// Models, Services und UI-Komponenten
|
import { AdminProduct, ProductImage } from '../../../../core/models/product.model';
|
||||||
import {
|
|
||||||
AdminProduct,
|
|
||||||
ProductImage,
|
|
||||||
} from '../../../../core/models/product.model';
|
|
||||||
import { Category } from '../../../../core/models/category.model';
|
import { Category } from '../../../../core/models/category.model';
|
||||||
import { ProductService } from '../../../services/product.service';
|
import { ProductService } from '../../../services/product.service';
|
||||||
import { CategoryService } from '../../../services/category.service';
|
import { CategoryService } from '../../../services/category.service';
|
||||||
import { SupplierService } from '../../../services/supplier.service';
|
import { SupplierService } from '../../../services/supplier.service';
|
||||||
import { SnackbarService } from '../../../../shared/services/snackbar.service';
|
import { SnackbarService } from '../../../../shared/services/snackbar.service';
|
||||||
import { CardComponent } from '../../../../shared/components/ui/card/card.component';
|
import { ImagePreview, ProductFormComponent } from '../product-form/product-form.component';
|
||||||
import {
|
|
||||||
ImagePreview,
|
|
||||||
ProductFormComponent,
|
|
||||||
} from '../product-form/product-form.component';
|
|
||||||
import { SelectOption } from '../../../../shared/components/form/form-select/form-select.component';
|
import { SelectOption } from '../../../../shared/components/form/form-select/form-select.component';
|
||||||
|
import { Supplier } from '../../../../core/models/supplier.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-edit',
|
selector: 'app-product-edit',
|
||||||
@@ -46,304 +24,277 @@ import { SelectOption } from '../../../../shared/components/form/form-select/for
|
|||||||
styleUrls: ['./product-edit.component.css'],
|
styleUrls: ['./product-edit.component.css'],
|
||||||
})
|
})
|
||||||
export class ProductEditComponent implements OnInit, OnDestroy {
|
export class ProductEditComponent implements OnInit, OnDestroy {
|
||||||
// --- Injektionen ---
|
// --- Injected Services ---
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private fb = inject(FormBuilder);
|
||||||
private productService = inject(ProductService);
|
private productService = inject(ProductService);
|
||||||
private categoryService = inject(CategoryService);
|
private categoryService = inject(CategoryService);
|
||||||
private supplierService = inject(SupplierService);
|
private supplierService = inject(SupplierService);
|
||||||
private fb = inject(FormBuilder);
|
|
||||||
private snackbarService = inject(SnackbarService);
|
private snackbarService = inject(SnackbarService);
|
||||||
private sanitizer = inject(DomSanitizer);
|
private sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
// --- Komponenten-Status ---
|
// --- Component State ---
|
||||||
productId!: string;
|
public productForm!: FormGroup;
|
||||||
isLoading = true;
|
public isLoading = true;
|
||||||
productForm: FormGroup;
|
private productId!: string;
|
||||||
private nameChangeSubscription?: Subscription;
|
private subscriptions = new Subscription();
|
||||||
allCategories$!: Observable<Category[]>;
|
|
||||||
supplierOptions$!: Observable<SelectOption[]>;
|
|
||||||
|
|
||||||
// --- NEUER STATE FÜR BILD-MANAGEMENT ---
|
// WICHTIG: Die RowVersion speichern wir hier, nicht im Formular
|
||||||
existingImages: ProductImage[] = [];
|
private loadedRowVersion: string | null = null;
|
||||||
newImageFiles: File[] = [];
|
|
||||||
mainImageIdentifier: string | null = null; // Hält die ID oder den Dateinamen des Hauptbildes
|
public allCategories$!: Observable<Category[]>;
|
||||||
allImagesForForm: ImagePreview[] = []; // Die kombinierte Liste für die Child-Komponente
|
public supplierOptions$!: Observable<SelectOption[]>;
|
||||||
|
|
||||||
|
// --- Image Management ---
|
||||||
|
public allImagesForForm: ImagePreview[] = [];
|
||||||
|
private newImageFiles = new Map<string, File>();
|
||||||
|
private imagesToDelete: string[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.productForm = this.fb.group({
|
this.initForm();
|
||||||
id: ['', Validators.required],
|
|
||||||
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: new FormControl([]),
|
|
||||||
imagesToDelete: this.fb.array([]),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get imagesToDelete(): FormArray {
|
private initForm(): void {
|
||||||
return this.productForm.get('imagesToDelete') as FormArray;
|
// RowVersion nehmen wir hier raus, um Manipulationen zu verhindern
|
||||||
|
this.productForm = this.fb.group({
|
||||||
|
name: ['', Validators.required],
|
||||||
|
description: ['', Validators.required],
|
||||||
|
sku: ['', Validators.required],
|
||||||
|
price: [0, [Validators.required, Validators.min(0)]],
|
||||||
|
oldPrice: [null],
|
||||||
|
isActive: [true],
|
||||||
|
stockQuantity: [0, [Validators.required, Validators.min(0)]],
|
||||||
|
slug: ['', Validators.required],
|
||||||
|
weight: [0],
|
||||||
|
supplierId: [null],
|
||||||
|
purchasePrice: [null],
|
||||||
|
isFeatured: [false],
|
||||||
|
featuredDisplayOrder: [0],
|
||||||
|
categorieIds: [[]]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const id = this.route.snapshot.paramMap.get('id');
|
this.loadInitialData();
|
||||||
if (!id) {
|
|
||||||
this.snackbarService.show('Produkt-ID fehlt.');
|
|
||||||
this.router.navigate(['/shop/products']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.productId = id;
|
|
||||||
this.loadDropdownData();
|
|
||||||
this.loadProductData();
|
|
||||||
this.subscribeToNameChanges();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
private loadInitialData(): void {
|
||||||
this.nameChangeSubscription?.unsubscribe();
|
|
||||||
this.allImagesForForm.forEach((image) => {
|
|
||||||
if (typeof image.url === 'object') {
|
|
||||||
// Nur Object-URLs von neuen Bildern freigeben
|
|
||||||
URL.revokeObjectURL(image.url as string);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadDropdownData(): void {
|
|
||||||
this.allCategories$ = this.categoryService.getAll();
|
|
||||||
this.supplierOptions$ = this.supplierService.getAll().pipe(
|
|
||||||
map((suppliers) =>
|
|
||||||
suppliers.map((s) => ({ value: s.id, label: s.name || 'Unbenannt' }))
|
|
||||||
),
|
|
||||||
startWith([])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadProductData(): void {
|
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.productService
|
this.allCategories$ = this.categoryService.getAll();
|
||||||
.getById(this.productId)
|
|
||||||
.pipe(
|
this.supplierOptions$ = this.supplierService.getAll().pipe(
|
||||||
finalize(() => {
|
map((suppliers: Supplier[]) =>
|
||||||
this.isLoading = false;
|
suppliers
|
||||||
})
|
.filter(s => !!s.name)
|
||||||
|
.map(s => ({ value: s.id, label: s.name as string }))
|
||||||
)
|
)
|
||||||
.subscribe({
|
);
|
||||||
next: (product) => {
|
|
||||||
if (product) {
|
const productSub = this.route.paramMap.pipe(
|
||||||
this.populateForm(product);
|
switchMap(params => {
|
||||||
} else {
|
const id = params.get('id');
|
||||||
this.snackbarService.show('Produkt nicht gefunden.');
|
if (id) {
|
||||||
this.router.navigate(['/shop/products']);
|
this.productId = id;
|
||||||
|
return this.productService.getById(id);
|
||||||
}
|
}
|
||||||
},
|
this.snackbarService.show('Keine Produkt-ID gefunden.');
|
||||||
error: (err) => {
|
|
||||||
this.snackbarService.show('Fehler beim Laden des Produkts.');
|
|
||||||
console.error('Fehler beim Laden der Produktdaten:', err);
|
|
||||||
this.router.navigate(['/shop/products']);
|
this.router.navigate(['/shop/products']);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
).subscribe({
|
||||||
|
next: product => {
|
||||||
|
if (product) {
|
||||||
|
this.patchForm(product);
|
||||||
|
if (product.images) {
|
||||||
|
this.initializeImages(product.images);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.isLoading = false;
|
||||||
},
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.snackbarService.show('Fehler beim Laden der Produktdaten.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.subscriptions.add(productSub);
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchForm(product: AdminProduct): void {
|
||||||
|
// 1. RowVersion sicher extrahieren und trimmen
|
||||||
|
const rawVersion = product.rowVersion || (product as any).RowVersion;
|
||||||
|
this.loadedRowVersion = rawVersion ? String(rawVersion).trim() : null;
|
||||||
|
|
||||||
|
console.log('Geladene RowVersion:', this.loadedRowVersion);
|
||||||
|
|
||||||
|
// 2. Formularwerte setzen
|
||||||
|
this.productForm.patchValue({
|
||||||
|
name: product.name,
|
||||||
|
description: product.description,
|
||||||
|
sku: product.sku,
|
||||||
|
price: product.price,
|
||||||
|
oldPrice: product.oldPrice,
|
||||||
|
isActive: product.isActive,
|
||||||
|
stockQuantity: product.stockQuantity,
|
||||||
|
slug: product.slug,
|
||||||
|
weight: product.weight,
|
||||||
|
supplierId: product.supplierId,
|
||||||
|
purchasePrice: product.purchasePrice,
|
||||||
|
isFeatured: product.isFeatured,
|
||||||
|
featuredDisplayOrder: product.featuredDisplayOrder,
|
||||||
|
categorieIds: product.categorieIds || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// AFTER (CORRECT)
|
private initializeImages(images: ProductImage[]): void {
|
||||||
populateForm(product: AdminProduct): void {
|
this.allImagesForForm = images
|
||||||
// This single line handles ALL form controls, including setting
|
.filter(img => !!img.url)
|
||||||
// the 'categorieIds' FormControl to the array from the product object.
|
.map(img => ({
|
||||||
this.productForm.patchValue(product);
|
identifier: img.id,
|
||||||
|
url: img.url as string,
|
||||||
|
isMainImage: img.isMainImage,
|
||||||
|
})).sort((a, b) => (a.isMainImage ? -1 : 1));
|
||||||
|
}
|
||||||
|
|
||||||
// The obsolete FormArray logic has been removed.
|
// --- Image Event Handlers ---
|
||||||
|
|
||||||
this.existingImages = product.images || [];
|
onFilesSelected(files: File[]): void {
|
||||||
const mainImage = this.existingImages.find((img) => img.isMainImage);
|
for (const file of files) {
|
||||||
this.mainImageIdentifier =
|
const tempId = `new_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
mainImage?.id ||
|
const url = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(file));
|
||||||
(this.existingImages.length > 0 ? this.existingImages[0].id : null);
|
|
||||||
|
|
||||||
this.rebuildAllImagesForForm();
|
this.newImageFiles.set(tempId, file);
|
||||||
}
|
|
||||||
|
this.allImagesForForm.push({
|
||||||
|
identifier: tempId,
|
||||||
|
url: url,
|
||||||
|
isMainImage: this.allImagesForForm.length === 1, // Wenn es das erste Bild ist, direkt Main
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSetMainImage(identifier: string): void {
|
||||||
|
this.allImagesForForm.forEach(img => {
|
||||||
|
img.isMainImage = img.identifier === identifier;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleteImage(identifier: string): void {
|
||||||
|
const imageIndex = this.allImagesForForm.findIndex(img => img.identifier === identifier);
|
||||||
|
if (imageIndex === -1) return;
|
||||||
|
|
||||||
|
const deletedImage = this.allImagesForForm[imageIndex];
|
||||||
|
this.allImagesForForm.splice(imageIndex, 1);
|
||||||
|
|
||||||
|
if (this.newImageFiles.has(identifier)) {
|
||||||
|
this.newImageFiles.delete(identifier);
|
||||||
|
} else {
|
||||||
|
this.imagesToDelete.push(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedImage.isMainImage && this.allImagesForForm.length > 0) {
|
||||||
|
this.allImagesForForm[0].isMainImage = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.productForm.invalid) {
|
if (this.productForm.invalid) {
|
||||||
this.productForm.markAllAsTouched();
|
this.productForm.markAllAsTouched();
|
||||||
this.snackbarService.show('Bitte füllen Sie alle Pflichtfelder aus.');
|
this.snackbarService.show('Bitte füllen Sie alle erforderlichen Felder aus.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.prepareSubmissionData();
|
|
||||||
const formData = this.createFormData();
|
this.isLoading = true;
|
||||||
this.productService.update(this.productId, formData).subscribe({
|
const formData = this.createUpdateFormData();
|
||||||
|
|
||||||
|
this.productService.update(this.productId, formData).pipe(
|
||||||
|
finalize(() => this.isLoading = false)
|
||||||
|
).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackbarService.show('Produkt erfolgreich aktualisiert');
|
this.snackbarService.show('Produkt erfolgreich aktualisiert!');
|
||||||
this.router.navigate(['/shop/products']);
|
this.router.navigate(['/shop/products']);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.snackbarService.show('Ein Fehler ist aufgetreten.');
|
console.error('Update failed', err);
|
||||||
console.error(err);
|
// Spezifische Behandlung für 409
|
||||||
},
|
if (err.status === 409) {
|
||||||
|
this.snackbarService.show('Konflikt beim Speichern. Bitte laden Sie die Seite neu.');
|
||||||
|
} else {
|
||||||
|
this.snackbarService.show('Fehler beim Aktualisieren des Produkts.');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createUpdateFormData(): FormData {
|
||||||
|
const formData = new FormData();
|
||||||
|
const val = this.productForm.getRawValue();
|
||||||
|
|
||||||
|
// 1. ZUERST ID und RowVersion anhängen (Wichtig für Backend-Parser)
|
||||||
|
formData.append('Id', this.productId);
|
||||||
|
|
||||||
|
// if (this.loadedRowVersion) {
|
||||||
|
// formData.append('RowVersion', this.loadedRowVersion);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 2. Einfache Felder
|
||||||
|
formData.append('Name', val.name);
|
||||||
|
formData.append('Description', val.description);
|
||||||
|
formData.append('SKU', val.sku);
|
||||||
|
formData.append('Slug', val.slug);
|
||||||
|
formData.append('IsActive', String(val.isActive));
|
||||||
|
formData.append('IsFeatured', String(val.isFeatured));
|
||||||
|
formData.append('FeaturedDisplayOrder', String(val.featuredDisplayOrder || 0));
|
||||||
|
|
||||||
|
// 3. Zahlen sicher als String mit Punkt formatieren
|
||||||
|
// Das verhindert Fehler, wenn der Browser "12,50" senden würde
|
||||||
|
const formatNumber = (num: any) => (num === null || num === undefined || num === '') ? '' : String(Number(num));
|
||||||
|
|
||||||
|
formData.append('Price', formatNumber(val.price));
|
||||||
|
formData.append('StockQuantity', formatNumber(val.stockQuantity));
|
||||||
|
|
||||||
|
if (val.oldPrice) formData.append('OldPrice', formatNumber(val.oldPrice));
|
||||||
|
if (val.weight) formData.append('Weight', formatNumber(val.weight));
|
||||||
|
if (val.purchasePrice) formData.append('PurchasePrice', formatNumber(val.purchasePrice));
|
||||||
|
if (val.supplierId) formData.append('SupplierId', val.supplierId);
|
||||||
|
|
||||||
|
// 4. Arrays / Listen
|
||||||
|
if (Array.isArray(val.categorieIds)) {
|
||||||
|
val.categorieIds.forEach((id: string) => formData.append('CategorieIds', id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.imagesToDelete.length > 0) {
|
||||||
|
this.imagesToDelete.forEach(id => formData.append('ImagesToDelete', id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Dateien - Ganz am Ende anhängen
|
||||||
|
const mainImagePreview = this.allImagesForForm.find(img => img.isMainImage);
|
||||||
|
const mainImageTempId = mainImagePreview ? mainImagePreview.identifier : null;
|
||||||
|
|
||||||
|
this.newImageFiles.forEach((file, tempId) => {
|
||||||
|
// Dateinamen bereinigen, um Parsing-Probleme zu vermeiden
|
||||||
|
const safeFileName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||||
|
|
||||||
|
if (tempId === mainImageTempId) {
|
||||||
|
formData.append('MainImageFile', file, safeFileName);
|
||||||
|
} else {
|
||||||
|
formData.append('AdditionalImageFiles', file, safeFileName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mainImagePreview && !this.newImageFiles.has(mainImagePreview.identifier)) {
|
||||||
|
console.log('Setze bestehendes Bild als Main:', mainImagePreview.identifier);
|
||||||
|
formData.append('SetMainImageId', mainImagePreview.identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.router.navigate(['/shop/products']);
|
this.router.navigate(['/shop/products']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NEUE EVENT-HANDLER FÜR BILDER ---
|
ngOnDestroy(): void {
|
||||||
onFilesSelected(files: File[]): void {
|
this.subscriptions.unsubscribe();
|
||||||
this.newImageFiles.push(...files);
|
|
||||||
if (!this.mainImageIdentifier && this.newImageFiles.length > 0) {
|
|
||||||
this.mainImageIdentifier = this.newImageFiles[0].name;
|
|
||||||
}
|
|
||||||
this.rebuildAllImagesForForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
onSetMainImage(identifier: string): void {
|
|
||||||
this.mainImageIdentifier = identifier;
|
|
||||||
this.rebuildAllImagesForForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
onDeleteImage(identifier: string): void {
|
|
||||||
const isExisting = this.existingImages.some((img) => img.id === identifier);
|
|
||||||
if (isExisting) {
|
|
||||||
this.imagesToDelete.push(new FormControl(identifier));
|
|
||||||
this.existingImages = this.existingImages.filter(
|
|
||||||
(img) => img.id !== identifier
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.newImageFiles = this.newImageFiles.filter(
|
|
||||||
(file) => file.name !== identifier
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.mainImageIdentifier === identifier) {
|
|
||||||
const firstExisting =
|
|
||||||
this.existingImages.length > 0 ? this.existingImages[0].id : null;
|
|
||||||
const firstNew =
|
|
||||||
this.newImageFiles.length > 0 ? this.newImageFiles[0].name : null;
|
|
||||||
this.mainImageIdentifier = firstExisting || firstNew;
|
|
||||||
}
|
|
||||||
this.rebuildAllImagesForForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Private Helfermethoden ---
|
|
||||||
private rebuildAllImagesForForm(): void {
|
|
||||||
this.allImagesForForm.forEach((image) => {
|
|
||||||
if (typeof image.url === 'object')
|
|
||||||
URL.revokeObjectURL(image.url as string);
|
|
||||||
});
|
|
||||||
|
|
||||||
const combined: ImagePreview[] = [];
|
|
||||||
this.existingImages
|
|
||||||
.filter((img) => !!img.url) // Stellt sicher, dass nur Bilder mit URL verwendet werden
|
|
||||||
.forEach((img) => {
|
|
||||||
combined.push({
|
|
||||||
identifier: img.id,
|
|
||||||
url: img.url!,
|
|
||||||
isMainImage: img.id === this.mainImageIdentifier,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.newImageFiles.forEach((file) => {
|
|
||||||
combined.push({
|
|
||||||
identifier: file.name,
|
|
||||||
url: this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(file)),
|
|
||||||
isMainImage: file.name === this.mainImageIdentifier,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.allImagesForForm = combined;
|
|
||||||
}
|
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const mainImageFile = this.newImageFiles.find(
|
|
||||||
(f) => f.name === this.mainImageIdentifier
|
|
||||||
);
|
|
||||||
if (mainImageFile) {
|
|
||||||
formData.append('MainImageFile', mainImageFile);
|
|
||||||
} else if (this.mainImageIdentifier) {
|
|
||||||
formData.append('MainImageId', this.mainImageIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.newImageFiles
|
|
||||||
.filter((f) => f.name !== this.mainImageIdentifier)
|
|
||||||
.forEach((file) => formData.append('AdditionalImageFiles', file));
|
|
||||||
|
|
||||||
return formData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[char] || '')
|
|
||||||
)
|
|
||||||
.replace(/[^a-z0-9-]/g, '')
|
|
||||||
.replace(/-+/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateSkuValue(name: string): string {
|
|
||||||
const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, '');
|
|
||||||
const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase();
|
|
||||||
return `${prefix}-${randomPart}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private capitalizeFirstLetter(string: string): string {
|
|
||||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,14 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
app-search-bar {
|
app-search-bar {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
@@ -20,7 +28,9 @@ app-search-bar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.column-filter-container {
|
.column-filter-container {
|
||||||
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-filter-dropdown {
|
.column-filter-dropdown {
|
||||||
|
|||||||
@@ -1,20 +1,34 @@
|
|||||||
<!-- /src/app/features/admin/components/products/product-list/product-list.component.html -->
|
<!-- /src/app/features/admin/components/products/product-list/product-list.component.html -->
|
||||||
|
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<div class="header">
|
<div>
|
||||||
<h1 class="page-title">Produktübersicht</h1>
|
<h3 class="page-header">Produktübersicht</h3>
|
||||||
<app-button buttonType="primary" (click)="onAddNew()">
|
|
||||||
<app-icon iconName="plus"></app-icon> Neues Produkt
|
|
||||||
</app-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-header">
|
<div class="table-header">
|
||||||
<app-search-bar placeholder="Produkte nach Name oder SKU suchen..." (search)="onSearch($event)"></app-search-bar>
|
<app-search-bar
|
||||||
|
placeholder="Produkte nach Name oder SKU suchen..."
|
||||||
|
(search)="onSearch($event)"
|
||||||
|
></app-search-bar>
|
||||||
|
|
||||||
<div class="column-filter-container">
|
<div class="column-filter-container">
|
||||||
<app-button buttonType="stroked" (click)="toggleColumnFilter()" iconName="filter">Spalten</app-button>
|
<app-button buttonType="primary" iconName="plus" (click)="onAddNew()">
|
||||||
|
Neues Produkt
|
||||||
|
</app-button>
|
||||||
|
|
||||||
|
<app-button
|
||||||
|
buttonType="stroked"
|
||||||
|
(click)="toggleColumnFilter()"
|
||||||
|
iconName="filter"
|
||||||
|
>Spalten</app-button
|
||||||
|
>
|
||||||
<div class="column-filter-dropdown" *ngIf="isColumnFilterVisible">
|
<div class="column-filter-dropdown" *ngIf="isColumnFilterVisible">
|
||||||
<label *ngFor="let col of allTableColumns">
|
<label *ngFor="let col of allTableColumns">
|
||||||
<input type="checkbox" [checked]="isColumnVisible(col.key)" (change)="onColumnToggle(col, $event)">
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="isColumnVisible(col.key)"
|
||||||
|
(change)="onColumnToggle(col, $event)"
|
||||||
|
/>
|
||||||
{{ col.title }}
|
{{ col.title }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,6 +39,7 @@
|
|||||||
[data]="filteredProducts"
|
[data]="filteredProducts"
|
||||||
[columns]="visibleTableColumns"
|
[columns]="visibleTableColumns"
|
||||||
(edit)="onEditProduct($event.id)"
|
(edit)="onEditProduct($event.id)"
|
||||||
(delete)="onDeleteProduct($event.id)">
|
(delete)="onDeleteProduct($event.id)"
|
||||||
|
>
|
||||||
</app-generic-table>
|
</app-generic-table>
|
||||||
</div>
|
</div>
|
||||||
@@ -11,12 +11,11 @@ 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 { SearchBarComponent } from '../../../../shared/components/layout/search-bar/search-bar.component';
|
||||||
import { ButtonComponent } from '../../../../shared/components/ui/button/button.component';
|
import { ButtonComponent } from '../../../../shared/components/ui/button/button.component';
|
||||||
import { IconComponent } from '../../../../shared/components/ui/icon/icon.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-list',
|
selector: 'app-product-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, CurrencyPipe, GenericTableComponent, SearchBarComponent, ButtonComponent, IconComponent],
|
imports: [CommonModule, GenericTableComponent, SearchBarComponent, ButtonComponent],
|
||||||
providers: [DatePipe],
|
providers: [DatePipe],
|
||||||
templateUrl: './product-list.component.html',
|
templateUrl: './product-list.component.html',
|
||||||
styleUrl: './product-list.component.css'
|
styleUrl: './product-list.component.css'
|
||||||
@@ -59,6 +58,8 @@ export class ProductListComponent implements OnInit {
|
|||||||
];
|
];
|
||||||
visibleTableColumns: ColumnConfig[] = [];
|
visibleTableColumns: ColumnConfig[] = [];
|
||||||
|
|
||||||
|
public readonly fallbackImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNjY2NjY2MiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cmVjdCB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHg9IjMiIHk9IjMiIHJ4PSIyIiByeT0iMiIvPjxjaXJjbGUgY3g9IjkiIGN5PSI5IiByPSIyIi8+PHBhdGggZD0ibTIxIDE1LTUtNWwtNSA1bC0yLTJsLTUgNSIvPjwvc3ZnPg==';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadTableSettings();
|
this.loadTableSettings();
|
||||||
}
|
}
|
||||||
@@ -144,8 +145,8 @@ export class ProductListComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getMainImageUrl(images?: ProductImage[]): string {
|
getMainImageUrl(images?: ProductImage[]): string {
|
||||||
if (!images || images.length === 0) return 'https://via.placeholder.com/50';
|
if (!images || images.length === 0) return this.fallbackImage;
|
||||||
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';
|
return mainImage?.url || images[0]?.url || this.fallbackImage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<input type="number" formControlName="cost" placeholder="Kosten" />
|
<input type="number" formControlName="cost" placeholder="Kosten" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- +++ NEUE FELDER HINZUGEFÜGT +++ -->
|
<div style="display: flex; gap: 10px;">
|
||||||
<div>
|
<div>
|
||||||
<label>Minimale Liefertage:</label>
|
<label>Minimale Liefertage:</label>
|
||||||
<input
|
<input
|
||||||
@@ -40,9 +40,33 @@
|
|||||||
placeholder="z.B. 3"
|
placeholder="z.B. 3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- +++ ENDE NEU +++ -->
|
</div>
|
||||||
|
|
||||||
|
<!-- +++ NEUE GEWICHTS FELDER +++ -->
|
||||||
|
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
||||||
|
<div>
|
||||||
|
<label>Gewicht von (kg):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
formControlName="minWeight"
|
||||||
|
placeholder="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<label>Gewicht bis (kg):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
formControlName="maxWeight"
|
||||||
|
placeholder="10"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- +++ ENDE NEU +++ -->
|
||||||
|
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
<label><input type="checkbox" formControlName="isActive" /> Aktiv</label>
|
<label><input type="checkbox" formControlName="isActive" /> Aktiv</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,16 +79,26 @@
|
|||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<h2>Bestehende Methoden</h2>
|
<h2>Bestehende Methoden</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li *ngFor="let method of methods$ | async">
|
<li *ngFor="let method of methods$ | async" style="margin-bottom: 10px; border-bottom: 1px solid #ccc; padding-bottom: 5px;">
|
||||||
{{ method.name }}
|
<strong>{{ method.name }}</strong> ({{ method.cost | currency : "EUR" }}) <br/>
|
||||||
({{ method.cost | currency : "EUR" }}) - Lieferzeit:
|
|
||||||
{{ method.minDeliveryDays }}-{{ method.maxDeliveryDays }} Tage - Aktiv:
|
<!-- Anzeige der Details -->
|
||||||
{{ method.isActive }}
|
<small>
|
||||||
|
Lieferzeit: {{ method.minDeliveryDays }}-{{ method.maxDeliveryDays }} Tage |
|
||||||
|
<!-- NEU: Gewichtsanzeige -->
|
||||||
|
Gewicht: {{ method.minWeight }}kg - {{ method.maxWeight }}kg |
|
||||||
|
Aktiv: {{ method.isActive ? 'Ja' : 'Nein' }}
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<div style="margin-top: 5px;">
|
||||||
<button (click)="selectMethod(method)">Bearbeiten</button>
|
<button (click)="selectMethod(method)">Bearbeiten</button>
|
||||||
<button (click)="onDelete(method.id)">Löschen</button>
|
<button (click)="onDelete(method.id)">Löschen</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,7 +26,10 @@ export class ShippingMethodListComponent implements OnInit {
|
|||||||
cost: [0, [Validators.required, Validators.min(0)]],
|
cost: [0, [Validators.required, Validators.min(0)]],
|
||||||
isActive: [true],
|
isActive: [true],
|
||||||
minDeliveryDays: [1, [Validators.required, Validators.min(0)]],
|
minDeliveryDays: [1, [Validators.required, Validators.min(0)]],
|
||||||
maxDeliveryDays: [3, [Validators.required, Validators.min(0)]]
|
maxDeliveryDays: [3, [Validators.required, Validators.min(0)]],
|
||||||
|
// NEU: Validierung für Gewicht
|
||||||
|
minWeight: [0, [Validators.required, Validators.min(0)]],
|
||||||
|
maxWeight: [10, [Validators.required, Validators.min(0)]]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,15 +49,16 @@ export class ShippingMethodListComponent implements OnInit {
|
|||||||
cost: 0,
|
cost: 0,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
minDeliveryDays: 1,
|
minDeliveryDays: 1,
|
||||||
maxDeliveryDays: 3
|
maxDeliveryDays: 3,
|
||||||
|
// NEU: Reset Werte
|
||||||
|
minWeight: 0,
|
||||||
|
maxWeight: 10
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- KORREKTUR: onSubmit sendet jetzt direkt das Formularwert-Objekt als JSON ---
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.methodForm.invalid) return;
|
if (this.methodForm.invalid) return;
|
||||||
|
|
||||||
// Das Formular-Objekt hat bereits die richtige Struktur, die das Backend erwartet.
|
|
||||||
const dataToSend: ShippingMethod = {
|
const dataToSend: ShippingMethod = {
|
||||||
id: this.selectedMethodId || '00000000-0000-0000-0000-000000000000',
|
id: this.selectedMethodId || '00000000-0000-0000-0000-000000000000',
|
||||||
...this.methodForm.value
|
...this.methodForm.value
|
||||||
@@ -66,7 +70,6 @@ export class ShippingMethodListComponent implements OnInit {
|
|||||||
this.shippingMethodService.create(dataToSend).subscribe(() => this.reset());
|
this.shippingMethodService.create(dataToSend).subscribe(() => this.reset());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// --- ENDE KORREKTUR ---
|
|
||||||
|
|
||||||
onDelete(id: string): void {
|
onDelete(id: string): void {
|
||||||
if (confirm('Versandmethode wirklich löschen?')) {
|
if (confirm('Versandmethode wirklich löschen?')) {
|
||||||
|
|||||||
@@ -35,8 +35,8 @@
|
|||||||
|
|
||||||
<ng-container *ngSwitchCase="'image-text'">
|
<ng-container *ngSwitchCase="'image-text'">
|
||||||
<div class="user-cell">
|
<div class="user-cell">
|
||||||
<img [src]="getProperty(item, col.imageKey!) || 'https://via.placeholder.com/40'"
|
<img [src]="getProperty(item, col.imageKey!) || fallbackImage"
|
||||||
[alt]="'Bild von ' + getProperty(item, col.key)" />
|
alt="{{ item.name }}" />
|
||||||
<div>
|
<div>
|
||||||
<div class="user-name">{{ getProperty(item, col.key) }}</div>
|
<div class="user-name">{{ getProperty(item, col.key) }}</div>
|
||||||
<div class="user-email">{{ getProperty(item, col.subKey!) }}</div>
|
<div class="user-email">{{ getProperty(item, col.subKey!) }}</div>
|
||||||
@@ -45,8 +45,8 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container *ngSwitchCase="'image'">
|
<ng-container *ngSwitchCase="'image'">
|
||||||
<img [src]="getProperty(item, col.key) || 'https://via.placeholder.com/50'"
|
<img [src]="getProperty(item, col.key) || fallbackImage"
|
||||||
alt="Bild"
|
alt="{{ item.name }}"
|
||||||
style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;">
|
style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;">
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class GenericTableComponent implements OnChanges, OnInit {
|
|||||||
|
|
||||||
public displayedData: any[] = [];
|
public displayedData: any[] = [];
|
||||||
public currentPage = 1;
|
public currentPage = 1;
|
||||||
|
public readonly fallbackImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNjY2NjY2MiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cmVjdCB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHg9IjMiIHk9IjMiIHJ4PSIyIiByeT0iMiIvPjxjaXJjbGUgY3g9IjkiIGN5PSI5IiByPSIyIi8+PHBhdGggZD0ibTIxIDE1LTUtNWwtNSA1bC0yLTJsLTUgNSIvPjwvc3ZnPg==';
|
||||||
|
|
||||||
ngOnInit(): void { this.updatePagination(); }
|
ngOnInit(): void { this.updatePagination(); }
|
||||||
ngOnChanges(changes: SimpleChanges): void { if (changes['data']) { this.currentPage = 1; this.updatePagination(); } }
|
ngOnChanges(changes: SimpleChanges): void { if (changes['data']) { this.currentPage = 1; this.updatePagination(); } }
|
||||||
@@ -47,6 +48,7 @@ export class GenericTableComponent implements OnChanges, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getProperty(item: any, key: string): any {
|
getProperty(item: any, key: string): any {
|
||||||
|
|
||||||
if (!key) return '';
|
if (!key) return '';
|
||||||
return key.split('.').reduce((obj, part) => obj && obj[part], item);
|
return key.split('.').reduce((obj, part) => obj && obj[part], item);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
iconName="chevron_backward"
|
iconName="chevron_backward"
|
||||||
(click)="goToPrevious()"
|
(click)="goToPrevious()"
|
||||||
[disabled]="currentPage === 1"
|
[disabled]="currentPage === 1"
|
||||||
tooltip="Vorherige Seite">
|
>
|
||||||
|
|
||||||
</app-button>
|
</app-button>
|
||||||
<app-button
|
<app-button
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
iconName="chevron_forward"
|
iconName="chevron_forward"
|
||||||
(click)="goToNext()"
|
(click)="goToNext()"
|
||||||
[disabled]="currentPage === totalPages"
|
[disabled]="currentPage === totalPages"
|
||||||
tooltip="Nächste Seite">
|
>
|
||||||
|
|
||||||
</app-button>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -135,11 +135,20 @@
|
|||||||
transform: translateX(-50%) translateY(-12px);
|
transform: translateX(-50%) translateY(-12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.btn.is-loading {
|
.btn.is-loading {
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-content {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.btn-content span {
|
||||||
|
display: flex;
|
||||||
|
height: auto;
|
||||||
|
align-content: center;
|
||||||
|
flex-wrap: wrap-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-content.is-hidden {
|
.btn-content.is-hidden {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user