Compare commits
10 Commits
1585129e1f
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec6e6bdd7a | ||
|
|
05c2b6b5c9 | ||
|
|
dfe631edf6 | ||
|
|
ac42f8b1b9 | ||
|
|
c10e6b4faa | ||
|
|
2491b0142d | ||
|
|
7511596b11 | ||
|
|
8df2420aa0 | ||
|
|
9173f9b625 | ||
|
|
b1b1c3173b |
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",
|
||||||
|
|||||||
1070
src/_styles_OLD.txt
1070
src/_styles_OLD.txt
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,9 @@ export interface CreateAddress {
|
|||||||
postalCode: string;
|
postalCode: string;
|
||||||
country: string;
|
country: string;
|
||||||
type: AddressType;
|
type: AddressType;
|
||||||
|
// FEHLTEN:
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateAddress extends CreateAddress {
|
export interface UpdateAddress extends CreateAddress {
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export interface OrderDetail {
|
|||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
shippingAddress: Address;
|
shippingAddress: Address;
|
||||||
billingAddress: Address;
|
billingAddress: Address;
|
||||||
|
shippingAddressId?: string;
|
||||||
|
billingAddressId?: string;
|
||||||
|
paymentMethodId?: string;
|
||||||
paymentMethod?: string;
|
paymentMethod?: string;
|
||||||
shippingTrackingNumber?: string;
|
shippingTrackingNumber?: string;
|
||||||
shippedDate?: string;
|
shippedDate?: string;
|
||||||
|
|||||||
@@ -41,4 +41,5 @@ export interface AdminProduct {
|
|||||||
images?: ProductImage[];
|
images?: ProductImage[];
|
||||||
isFeatured: boolean;
|
isFeatured: boolean;
|
||||||
featuredDisplayOrder: number;
|
featuredDisplayOrder: number;
|
||||||
|
rowVersion?: string;
|
||||||
}
|
}
|
||||||
9
src/app/core/models/shared.models.ts
Normal file
9
src/app/core/models/shared.models.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Das Backend sendet dies bei Validierungsfehlern (400 Bad Request)
|
||||||
|
export interface ValidationProblemDetails {
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
status?: number;
|
||||||
|
detail?: string;
|
||||||
|
instance?: string;
|
||||||
|
errors?: { [key: string]: 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;
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,9 @@ export interface User {
|
|||||||
lastActive?: string;
|
lastActive?: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
defaultShippingAddressId?: string;
|
||||||
|
defaultBillingAddressId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateUserRolesRequest {
|
export interface UpdateUserRolesRequest {
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/* /src/app/shared/components/form/multi-select-dropdown/multi-select-dropdown.component.css */
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-display {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 2.5rem 0.5rem 1rem;
|
||||||
|
min-height: 54px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--transition-speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-display:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-text {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 1rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--color-text-light);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-display:focus ~ .form-label,
|
||||||
|
.form-label.has-value {
|
||||||
|
top: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-display::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 1rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 0.8em;
|
||||||
|
height: 0.5em;
|
||||||
|
background-color: var(--color-text-light);
|
||||||
|
clip-path: polygon(100% 0%, 0 0%, 50% 100%);
|
||||||
|
transition: transform 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-wrapper.is-open .select-display::after {
|
||||||
|
transform: translateY(-50%) rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-list {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
box-shadow: var(--box-shadow-md);
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 0.5rem;
|
||||||
|
animation: fadeInDown 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-checkbox-group {
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-checkbox-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-checkbox-group label:hover {
|
||||||
|
background-color: var(--color-body-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from { opacity: 0; transform: translateY(-10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!-- /src/app/shared/components/form/multi-select-dropdown/multi-select-dropdown.component.html -->
|
||||||
|
|
||||||
|
<div class="custom-select-wrapper" [class.is-open]="isOpen">
|
||||||
|
<!-- Der klickbare Bereich, der die Pills anzeigt -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="form-input select-display"
|
||||||
|
(click)="toggleDropdown($event)">
|
||||||
|
|
||||||
|
<div class="selected-pills">
|
||||||
|
<app-status-pill
|
||||||
|
*ngFor="let value of selectedValues"
|
||||||
|
[text]="getLabelForValue(value)"
|
||||||
|
status="info"
|
||||||
|
[removable]="true"
|
||||||
|
(remove)="onPillRemove(value, $event)">
|
||||||
|
</app-status-pill>
|
||||||
|
<span *ngIf="selectedValues.length === 0" class="placeholder-text">{{ placeholder }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Das schwebende Label -->
|
||||||
|
<label class="form-label" [class.has-value]="selectedValues.length > 0">
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Die aufklappbare Liste mit den Checkboxen -->
|
||||||
|
<div *ngIf="isOpen" class="options-list">
|
||||||
|
<div class="category-checkbox-group">
|
||||||
|
<label *ngFor="let option of options">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[value]="option.value"
|
||||||
|
[checked]="isSelected(option.value)"
|
||||||
|
(change)="onOptionToggle(option.value)" />
|
||||||
|
{{ option.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// /src/app/shared/components/form/product-category-dropdown/product-category-dropdown.component.ts
|
||||||
|
|
||||||
|
import { Component, Input, forwardRef, HostListener, ElementRef, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
|
import { StatusPillComponent } from '../../../../shared/components/ui/status-pill/status-pill.component';
|
||||||
|
|
||||||
|
// Eine wiederverwendbare Schnittstelle für die Optionen
|
||||||
|
export interface SelectOption {
|
||||||
|
value: any;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-product-category-dropdown',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, StatusPillComponent],
|
||||||
|
templateUrl: './product-category-dropdown.component.html',
|
||||||
|
styleUrls: ['./product-category-dropdown.component.css'],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => ProductCategoryDropdownComponent),
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ProductCategoryDropdownComponent implements ControlValueAccessor {
|
||||||
|
@Input() options: SelectOption[] = [];
|
||||||
|
@Input() label = '';
|
||||||
|
@Input() placeholder = 'Bitte wählen...';
|
||||||
|
|
||||||
|
public selectedValues: any[] = [];
|
||||||
|
public isOpen = false;
|
||||||
|
public isDisabled = false;
|
||||||
|
|
||||||
|
// Callbacks, die von Angular überschrieben werden
|
||||||
|
private onChange: (value: any[]) => void = () => {};
|
||||||
|
private onTouched: () => void = () => {};
|
||||||
|
|
||||||
|
// --- ControlValueAccessor Implementierung ---
|
||||||
|
|
||||||
|
// Wird von Angular aufgerufen, um Werte ins Formular zu schreiben (z.B. beim Laden)
|
||||||
|
writeValue(value: any[]): void {
|
||||||
|
if (value && Array.isArray(value)) {
|
||||||
|
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 {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.isDisabled) return;
|
||||||
|
this.isOpen = !this.isOpen;
|
||||||
|
if (!this.isOpen) {
|
||||||
|
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 {
|
||||||
|
if (this.isSelected(value)) {
|
||||||
|
// Entfernen
|
||||||
|
this.selectedValues = this.selectedValues.filter(v => v !== value);
|
||||||
|
} else {
|
||||||
|
// Hinzufügen
|
||||||
|
this.selectedValues = [...this.selectedValues, value];
|
||||||
|
}
|
||||||
|
// Angular mitteilen, dass sich der Wert geändert hat
|
||||||
|
this.onChange(this.selectedValues);
|
||||||
|
this.onTouched();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPillRemove(value: any, event: any): void {
|
||||||
|
event.stopPropagation(); // Verhindert, dass das Dropdown aufgeht/zugeht
|
||||||
|
this.selectedValues = this.selectedValues.filter(v => v !== value);
|
||||||
|
this.onChange(this.selectedValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3[card-header] {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 300px;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!-- /src/app/features/admin/components/products/product-create/product-create.component.html -->
|
||||||
|
<div *ngIf="isLoading; else formContent" class="loading-container">
|
||||||
|
<p>Lade Formulardaten...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #formContent>
|
||||||
|
<div class="page-header">
|
||||||
|
<h3 card-header>Neues Produkt erstellen</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-product-form
|
||||||
|
[productForm]="productForm"
|
||||||
|
[allCategories]="(allCategories$ | async) || []"
|
||||||
|
[supplierOptions]="(supplierOptions$ | async) || []"
|
||||||
|
[allImages]="allImagesForForm"
|
||||||
|
[isLoading]="isLoading"
|
||||||
|
submitButtonText="Produkt erstellen"
|
||||||
|
(formSubmit)="onSubmit()"
|
||||||
|
(formCancel)="cancel()"
|
||||||
|
(filesSelected)="onFilesSelected($event)"
|
||||||
|
(setMainImage)="onSetMainImage($event)"
|
||||||
|
(deleteImage)="onDeleteImage($event)">
|
||||||
|
</app-product-form>
|
||||||
|
</ng-template>
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// /src/app/features/admin/components/products/product-create/product-create.component.ts
|
||||||
|
|
||||||
|
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import {
|
||||||
|
FormBuilder,
|
||||||
|
FormGroup,
|
||||||
|
FormArray,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
Validators,
|
||||||
|
FormControl,
|
||||||
|
} from '@angular/forms';
|
||||||
|
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
|
||||||
|
import { Observable, Subscription } from 'rxjs';
|
||||||
|
import {
|
||||||
|
debounceTime,
|
||||||
|
distinctUntilChanged,
|
||||||
|
map,
|
||||||
|
startWith,
|
||||||
|
} from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Models, Services und UI-Komponenten
|
||||||
|
import { Category } from '../../../../core/models/category.model';
|
||||||
|
import { ProductService } from '../../../services/product.service';
|
||||||
|
import { CategoryService } from '../../../services/category.service';
|
||||||
|
import { SupplierService } from '../../../services/supplier.service';
|
||||||
|
import { SnackbarService } from '../../../../shared/services/snackbar.service';
|
||||||
|
import {
|
||||||
|
ImagePreview,
|
||||||
|
ProductFormComponent,
|
||||||
|
} from '../product-form/product-form.component';
|
||||||
|
import { SelectOption } from '../../../../shared/components/form/form-select/form-select.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-product-create',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
|
||||||
|
ProductFormComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './product-create.component.html',
|
||||||
|
styleUrls: ['./product-create.component.css'],
|
||||||
|
})
|
||||||
|
export class ProductCreateComponent implements OnInit, OnDestroy {
|
||||||
|
// --- Injektionen ---
|
||||||
|
private sanitizer = inject(DomSanitizer);
|
||||||
|
private router = inject(Router);
|
||||||
|
private productService = inject(ProductService);
|
||||||
|
private categoryService = inject(CategoryService);
|
||||||
|
private supplierService = inject(SupplierService);
|
||||||
|
private fb = inject(FormBuilder);
|
||||||
|
private snackbarService = inject(SnackbarService);
|
||||||
|
|
||||||
|
// --- Komponenten-Status ---
|
||||||
|
isLoading = true;
|
||||||
|
productForm: FormGroup;
|
||||||
|
allCategories$!: Observable<Category[]>;
|
||||||
|
supplierOptions$!: Observable<SelectOption[]>;
|
||||||
|
private nameChangeSubscription?: Subscription;
|
||||||
|
|
||||||
|
// --- Bild-Management State ---
|
||||||
|
newImageFiles: File[] = [];
|
||||||
|
mainImageIdentifier: string | null = null;
|
||||||
|
allImagesForForm: ImagePreview[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.productForm = this.fb.group({
|
||||||
|
name: ['', Validators.required],
|
||||||
|
slug: ['', Validators.required],
|
||||||
|
sku: ['', Validators.required],
|
||||||
|
price: [0, [Validators.required, Validators.min(0)]],
|
||||||
|
stockQuantity: [0, [Validators.required, Validators.min(0)]],
|
||||||
|
description: [''],
|
||||||
|
oldPrice: [null, [Validators.min(0)]],
|
||||||
|
purchasePrice: [null, [Validators.min(0)]],
|
||||||
|
weight: [null, [Validators.min(0)]],
|
||||||
|
isActive: [true],
|
||||||
|
isFeatured: [false],
|
||||||
|
featuredDisplayOrder: [0],
|
||||||
|
supplierId: [null],
|
||||||
|
categorieIds: new FormControl([]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lifecycle Hooks ---
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadDropdownData();
|
||||||
|
this.subscribeToNameChanges();
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.nameChangeSubscription?.unsubscribe();
|
||||||
|
// BEST PRACTICE: Temporäre Bild-URLs aus dem Speicher entfernen, um Memory Leaks zu vermeiden
|
||||||
|
this.allImagesForForm.forEach((image) =>
|
||||||
|
URL.revokeObjectURL(image.url as string)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Öffentliche Methoden & Event-Handler ---
|
||||||
|
loadDropdownData(): void {
|
||||||
|
this.allCategories$ = this.categoryService.getAll();
|
||||||
|
this.supplierOptions$ = this.supplierService.getAll().pipe(
|
||||||
|
map((suppliers) =>
|
||||||
|
suppliers.map((s) => ({ value: s.id, label: s.name || 'Unbenannt' }))
|
||||||
|
),
|
||||||
|
startWith([])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
if (this.productForm.invalid) {
|
||||||
|
this.productForm.markAllAsTouched();
|
||||||
|
this.snackbarService.show('Bitte füllen Sie alle Pflichtfelder aus.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEU: Stellt sicher, dass Slug/SKU vor dem Senden generiert werden, falls sie leer sind
|
||||||
|
this.prepareSubmissionData();
|
||||||
|
const formData = this.createFormData();
|
||||||
|
|
||||||
|
this.productService.create(formData).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackbarService.show('Produkt erfolgreich erstellt');
|
||||||
|
this.router.navigate(['/shop/products']);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.snackbarService.show('Ein Fehler ist aufgetreten.');
|
||||||
|
console.error(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
this.router.navigate(['/shop/products']);
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilesSelected(files: File[]): void {
|
||||||
|
this.newImageFiles.push(...files);
|
||||||
|
if (!this.mainImageIdentifier && this.newImageFiles.length > 0) {
|
||||||
|
this.mainImageIdentifier = this.newImageFiles[0].name;
|
||||||
|
}
|
||||||
|
this.rebuildAllImagesForForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
onSetMainImage(identifier: string): void {
|
||||||
|
this.mainImageIdentifier = identifier;
|
||||||
|
this.rebuildAllImagesForForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleteImage(identifier: string): void {
|
||||||
|
this.newImageFiles = this.newImageFiles.filter(
|
||||||
|
(file) => file.name !== identifier
|
||||||
|
);
|
||||||
|
if (this.mainImageIdentifier === identifier) {
|
||||||
|
this.mainImageIdentifier =
|
||||||
|
this.newImageFiles.length > 0 ? this.newImageFiles[0].name : null;
|
||||||
|
}
|
||||||
|
this.rebuildAllImagesForForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private Helfermethoden ---
|
||||||
|
private rebuildAllImagesForForm(): void {
|
||||||
|
// Alte URLs freigeben, um Memory Leaks zu verhindern
|
||||||
|
this.allImagesForForm.forEach((image) =>
|
||||||
|
URL.revokeObjectURL(image.url as string)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.allImagesForForm = this.newImageFiles.map((file) => ({
|
||||||
|
identifier: file.name,
|
||||||
|
url: this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(file)),
|
||||||
|
isMainImage: file.name === this.mainImageIdentifier,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareSubmissionData(): void {
|
||||||
|
const name = this.productForm.get('name')?.value;
|
||||||
|
const slugControl = this.productForm.get('slug');
|
||||||
|
const skuControl = this.productForm.get('sku');
|
||||||
|
|
||||||
|
if (name && slugControl && !slugControl.value) {
|
||||||
|
slugControl.setValue(this.generateSlug(name), { emitEvent: false });
|
||||||
|
}
|
||||||
|
if (name && skuControl && !skuControl.value) {
|
||||||
|
skuControl.setValue(this.generateSkuValue(name), { emitEvent: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createFormData(): FormData {
|
||||||
|
const formData = new FormData();
|
||||||
|
const formValue = this.productForm.getRawValue();
|
||||||
|
|
||||||
|
Object.keys(formValue).forEach((key) => {
|
||||||
|
const value = formValue[key];
|
||||||
|
if (key === 'categorieIds') {
|
||||||
|
(value as string[]).forEach((id) =>
|
||||||
|
formData.append('CategorieIds', id)
|
||||||
|
);
|
||||||
|
} else if (value !== null && value !== undefined && value !== '') {
|
||||||
|
formData.append(this.capitalizeFirstLetter(key), value.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mainImageFile = this.newImageFiles.find(
|
||||||
|
(f) => f.name === this.mainImageIdentifier
|
||||||
|
);
|
||||||
|
if (mainImageFile) {
|
||||||
|
formData.append('MainImageFile', mainImageFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// KORREKTUR: Die Logik für 'MainImageId' wurde entfernt, da sie hier nicht relevant ist.
|
||||||
|
|
||||||
|
this.newImageFiles
|
||||||
|
.filter((f) => f.name !== this.mainImageIdentifier)
|
||||||
|
.forEach((file) => formData.append('AdditionalImageFiles', file));
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private subscribeToNameChanges(): void {
|
||||||
|
this.nameChangeSubscription = this.productForm
|
||||||
|
.get('name')
|
||||||
|
?.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
|
||||||
|
.subscribe((name: any) => {
|
||||||
|
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) => ({ ä: '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +1,21 @@
|
|||||||
:host { display: block; }
|
.page-header {
|
||||||
|
|
||||||
.form-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] { margin: 0; }
|
h3[card-header] {
|
||||||
|
margin: 0;
|
||||||
.form-section { margin-bottom: 1.5rem; }
|
font-size: 1.5rem;
|
||||||
.section-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 1.25rem; color: var(--color-text); }
|
font-weight: 600;
|
||||||
|
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; }
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
.input-with-button { display: flex; gap: 0.5rem; }
|
justify-content: center;
|
||||||
.input-with-button input { flex-grow: 1; }
|
align-items: center;
|
||||||
|
min-height: 300px;
|
||||||
.category-checkbox-group {
|
color: var(--text-color-secondary);
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
@@ -1,108 +1,25 @@
|
|||||||
<!-- /src/app/features/admin/components/products/product-edit/product-edit.component.html -->
|
<!-- /src/app/features/admin/components/products/product-edit/product-edit.component.html -->
|
||||||
|
|
||||||
<app-card>
|
<div *ngIf="isLoading; else formContent" class="loading-container">
|
||||||
<div *ngIf="isLoading; else formContent" class="loading-container">
|
<p>Lade Produktdaten...</p>
|
||||||
<p>Lade Produktdaten...</p>
|
</div>
|
||||||
|
|
||||||
|
<ng-template #formContent>
|
||||||
|
<div class="page-header">
|
||||||
|
<h3 card-header>Produkt bearbeiten</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<app-product-form
|
||||||
<ng-template #formContent>
|
[productForm]="productForm"
|
||||||
<form [formGroup]="productForm" (ngSubmit)="onSubmit()" novalidate>
|
[allCategories]="(allCategories$ | async) || []"
|
||||||
<div class="form-header">
|
[supplierOptions]="(supplierOptions$ | async) || []"
|
||||||
<h3 card-header>{{ isEditMode ? "Produkt bearbeiten" : "Neues Produkt erstellen" }}</h3>
|
[allImages]="allImagesForForm"
|
||||||
</div>
|
[isLoading]="isLoading"
|
||||||
|
submitButtonText="Änderungen speichern"
|
||||||
<div class="edit-layout">
|
(formSubmit)="onSubmit()"
|
||||||
<!-- LINKE SPALTE -->
|
(formCancel)="cancel()"
|
||||||
<div class="main-content">
|
(filesSelected)="onFilesSelected($event)"
|
||||||
<app-card>
|
(setMainImage)="onSetMainImage($event)"
|
||||||
<h4 card-header>Allgemein</h4>
|
(deleteImage)="onDeleteImage($event)"
|
||||||
<div class="form-section">
|
>
|
||||||
<app-form-field label="Name"><input type="text" formControlName="name"></app-form-field>
|
</app-product-form>
|
||||||
<app-form-field label="Slug"><input type="text" formControlName="slug"></app-form-field>
|
</ng-template>
|
||||||
<app-form-textarea label="Beschreibung" [rows]="8" formControlName="description"></app-form-textarea>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
|
|
||||||
<app-card>
|
|
||||||
<h4 card-header>Produktbilder</h4>
|
|
||||||
<!-- ... (Bild-Management bleibt gleich) ... -->
|
|
||||||
</app-card>
|
|
||||||
|
|
||||||
<app-card>
|
|
||||||
<h4 card-header>Preisgestaltung</h4>
|
|
||||||
<div class="form-grid price-grid">
|
|
||||||
<!-- KORREKTUR: Wrapper entfernt, formControlName auf die Komponente -->
|
|
||||||
<app-form-field label="Preis (€)" type="number" formControlName="price"></app-form-field>
|
|
||||||
<app-form-field label="Alter Preis (€)" type="number" formControlName="oldPrice"></app-form-field>
|
|
||||||
<app-form-field label="Einkaufspreis (€)" type="number" formControlName="purchasePrice"></app-form-field>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RECHTE SPALTE -->
|
|
||||||
<div class="sidebar-content">
|
|
||||||
<app-card>
|
|
||||||
<h4 card-header>Status</h4>
|
|
||||||
<div class="form-section">
|
|
||||||
<app-slide-toggle formControlName="isActive">Aktiv (im Shop sichtbar)</app-slide-toggle>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
|
|
||||||
<app-card>
|
|
||||||
<h4 card-header>Organisation</h4>
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">SKU (Artikelnummer)</label>
|
|
||||||
<div class="input-with-button">
|
|
||||||
<input type="text" class="form-input" formControlName="sku" />
|
|
||||||
<app-button buttonType="icon" (click)="generateSku()" iconName="placeholder"></app-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<app-form-field label="Lagerbestand" type="number" formControlName="stockQuantity"></app-form-field>
|
|
||||||
<app-form-field label="Gewicht (kg)" type="number" formControlName="weight"></app-form-field>
|
|
||||||
<app-form-select label="Lieferant" [options]="(supplierOptions$ | async) || []" formControlName="supplierId"></app-form-select>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
|
|
||||||
<app-card>
|
|
||||||
<h4 card-header>Kategorien</h4>
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="multi-select-container">
|
|
||||||
<div class="selected-pills">
|
|
||||||
<span *ngFor="let catId of categorieIds.value" class="pill">
|
|
||||||
{{ getCategoryName(catId) }}
|
|
||||||
<app-icon iconName="x" (click)="removeCategoryById(catId)"></app-icon>
|
|
||||||
</span>
|
|
||||||
<span *ngIf="categorieIds.length === 0" class="placeholder">Keine ausgewählt</span>
|
|
||||||
</div>
|
|
||||||
<div class="category-checkbox-group">
|
|
||||||
<label *ngFor="let category of allCategories$ | async">
|
|
||||||
<input type="checkbox" [value]="category.id" [checked]="isCategorySelected(category.id)" (change)="onCategoryChange($event)">
|
|
||||||
{{ category.name }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
|
|
||||||
<app-card>
|
|
||||||
<h4 card-header>Hervorheben</h4>
|
|
||||||
<div class="form-section">
|
|
||||||
<app-slide-toggle formControlName="isFeatured">Auf Startseite anzeigen</app-slide-toggle>
|
|
||||||
<app-form-field *ngIf="productForm.get('isFeatured')?.value" label="Anzeigereihenfolge">
|
|
||||||
<input type="number" formControlName="featuredDisplayOrder">
|
|
||||||
</app-form-field>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<app-button buttonType="stroked" (click)="cancel()">Abbrechen</app-button>
|
|
||||||
<app-button submitType="submit" buttonType="primary" [disabled]="productForm.invalid || isLoading">
|
|
||||||
{{ isEditMode ? "Änderungen speichern" : "Produkt erstellen" }}
|
|
||||||
</app-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</ng-template>
|
|
||||||
</app-card>
|
|
||||||
|
|||||||
@@ -1,228 +1,300 @@
|
|||||||
// /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 { FormBuilder, FormGroup, FormArray, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { Observable, Subscription } from 'rxjs';
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
import { debounceTime, distinctUntilChanged, map, startWith, tap } from 'rxjs/operators';
|
import { Observable, Subscription, of } from 'rxjs';
|
||||||
|
import { switchMap, finalize, map } from 'rxjs/operators';
|
||||||
|
|
||||||
// Models
|
|
||||||
import { AdminProduct, ProductImage } from '../../../../core/models/product.model';
|
import { AdminProduct, ProductImage } from '../../../../core/models/product.model';
|
||||||
import { Category } from '../../../../core/models/category.model';
|
import { Category } from '../../../../core/models/category.model';
|
||||||
import { Supplier } from '../../../../core/models/supplier.model';
|
|
||||||
|
|
||||||
// Services
|
|
||||||
import { ProductService } from '../../../services/product.service';
|
import { ProductService } from '../../../services/product.service';
|
||||||
import { CategoryService } from '../../../services/category.service';
|
import { CategoryService } from '../../../services/category.service';
|
||||||
import { SupplierService } from '../../../services/supplier.service';
|
import { SupplierService } from '../../../services/supplier.service';
|
||||||
import { SnackbarService } from '../../../../shared/services/snackbar.service';
|
import { SnackbarService } from '../../../../shared/services/snackbar.service';
|
||||||
|
import { ImagePreview, ProductFormComponent } from '../product-form/product-form.component';
|
||||||
// Wiederverwendbare UI- & Form-Komponenten
|
import { SelectOption } from '../../../../shared/components/form/form-select/form-select.component';
|
||||||
import { CardComponent } from '../../../../shared/components/ui/card/card.component';
|
import { Supplier } from '../../../../core/models/supplier.model';
|
||||||
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({
|
@Component({
|
||||||
selector: 'app-product-edit',
|
selector: 'app-product-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ CommonModule, ReactiveFormsModule, CardComponent, ButtonComponent, IconComponent, FormFieldComponent, FormSelectComponent, FormTextareaComponent, SlideToggleComponent ],
|
imports: [CommonModule, ReactiveFormsModule, ProductFormComponent],
|
||||||
templateUrl: './product-edit.component.html',
|
templateUrl: './product-edit.component.html',
|
||||||
styleUrl: './product-edit.component.css'
|
styleUrls: ['./product-edit.component.css'],
|
||||||
})
|
})
|
||||||
export class ProductEditComponent implements OnInit, OnDestroy {
|
export class ProductEditComponent implements OnInit, OnDestroy {
|
||||||
|
// --- 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);
|
||||||
|
|
||||||
productId: string | null = null;
|
// --- Component State ---
|
||||||
isEditMode = false;
|
public productForm!: FormGroup;
|
||||||
isLoading = true;
|
public isLoading = true;
|
||||||
productForm: FormGroup;
|
private productId!: string;
|
||||||
|
private subscriptions = new Subscription();
|
||||||
|
|
||||||
allCategories$!: Observable<Category[]>;
|
// WICHTIG: Die RowVersion speichern wir hier, nicht im Formular
|
||||||
supplierOptions$!: Observable<SelectOption[]>;
|
private loadedRowVersion: string | null = null;
|
||||||
allCategories: Category[] = [];
|
|
||||||
|
|
||||||
private nameChangeSubscription?: Subscription;
|
public allCategories$!: Observable<Category[]>;
|
||||||
existingImages: ProductImage[] = [];
|
public supplierOptions$!: Observable<SelectOption[]>;
|
||||||
mainImageFile: File | null = null;
|
|
||||||
additionalImageFiles: File[] = [];
|
// --- Image Management ---
|
||||||
|
public allImagesForForm: ImagePreview[] = [];
|
||||||
|
private newImageFiles = new Map<string, File>();
|
||||||
|
private imagesToDelete: string[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.initForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initForm(): void {
|
||||||
|
// RowVersion nehmen wir hier raus, um Manipulationen zu verhindern
|
||||||
this.productForm = this.fb.group({
|
this.productForm = this.fb.group({
|
||||||
name: ['', Validators.required], slug: ['', Validators.required], sku: ['', Validators.required],
|
name: ['', Validators.required],
|
||||||
description: [''], price: [0, [Validators.required, Validators.min(0)]],
|
description: ['', Validators.required],
|
||||||
oldPrice: [null, [Validators.min(0)]], purchasePrice: [null, [Validators.min(0)]],
|
sku: ['', Validators.required],
|
||||||
stockQuantity: [0, [Validators.required, Validators.min(0)]], weight: [null, [Validators.min(0)]],
|
price: [0, [Validators.required, Validators.min(0)]],
|
||||||
isActive: [true], isFeatured: [false], featuredDisplayOrder: [0],
|
oldPrice: [null],
|
||||||
supplierId: [null], categorieIds: this.fb.array([]), imagesToDelete: this.fb.array([])
|
isActive: [true],
|
||||||
|
stockQuantity: [0, [Validators.required, Validators.min(0)]],
|
||||||
|
slug: ['', Validators.required],
|
||||||
|
weight: [0],
|
||||||
|
supplierId: [null],
|
||||||
|
purchasePrice: [null],
|
||||||
|
isFeatured: [false],
|
||||||
|
featuredDisplayOrder: [0],
|
||||||
|
categorieIds: [[]]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get categorieIds(): FormArray { return this.productForm.get('categorieIds') as FormArray; }
|
|
||||||
get imagesToDelete(): FormArray { return this.productForm.get('imagesToDelete') as FormArray; }
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.productId = this.route.snapshot.paramMap.get('id');
|
this.loadInitialData();
|
||||||
this.isEditMode = !!this.productId;
|
}
|
||||||
|
|
||||||
this.loadDropdownData();
|
private loadInitialData(): void {
|
||||||
this.subscribeToNameChanges();
|
this.isLoading = true;
|
||||||
|
this.allCategories$ = this.categoryService.getAll();
|
||||||
|
|
||||||
if (this.isEditMode && this.productId) {
|
this.supplierOptions$ = this.supplierService.getAll().pipe(
|
||||||
this.isLoading = true;
|
map((suppliers: Supplier[]) =>
|
||||||
this.productService.getById(this.productId).subscribe(product => {
|
suppliers
|
||||||
|
.filter(s => !!s.name)
|
||||||
|
.map(s => ({ value: s.id, label: s.name as string }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const productSub = this.route.paramMap.pipe(
|
||||||
|
switchMap(params => {
|
||||||
|
const id = params.get('id');
|
||||||
|
if (id) {
|
||||||
|
this.productId = id;
|
||||||
|
return this.productService.getById(id);
|
||||||
|
}
|
||||||
|
this.snackbarService.show('Keine Produkt-ID gefunden.');
|
||||||
|
this.router.navigate(['/shop/products']);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
).subscribe({
|
||||||
|
next: product => {
|
||||||
if (product) {
|
if (product) {
|
||||||
this.populateForm(product);
|
this.patchForm(product);
|
||||||
|
if (product.images) {
|
||||||
|
this.initializeImages(product.images);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.isLoading = false;
|
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 || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeImages(images: ProductImage[]): void {
|
||||||
|
this.allImagesForForm = images
|
||||||
|
.filter(img => !!img.url)
|
||||||
|
.map(img => ({
|
||||||
|
identifier: img.id,
|
||||||
|
url: img.url as string,
|
||||||
|
isMainImage: img.isMainImage,
|
||||||
|
})).sort((a, b) => (a.isMainImage ? -1 : 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Image Event Handlers ---
|
||||||
|
|
||||||
|
onFilesSelected(files: File[]): void {
|
||||||
|
for (const file of files) {
|
||||||
|
const tempId = `new_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const url = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(file));
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
this.isLoading = false;
|
this.imagesToDelete.push(identifier);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
if (deletedImage.isMainImage && this.allImagesForForm.length > 0) {
|
||||||
this.nameChangeSubscription?.unsubscribe();
|
this.allImagesForForm[0].isMainImage = true;
|
||||||
}
|
|
||||||
|
|
||||||
loadDropdownData(): void {
|
|
||||||
this.allCategories$ = this.categoryService.getAll().pipe(
|
|
||||||
tap(categories => this.allCategories = categories)
|
|
||||||
);
|
|
||||||
this.supplierOptions$ = this.supplierService.getAll().pipe(
|
|
||||||
map(suppliers => suppliers.map(s => ({ value: s.id, label: s.name || 'Unbenannt' }))),
|
|
||||||
startWith([])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
populateForm(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
getCategoryName(categoryId: string): string {
|
|
||||||
return this.allCategories.find(c => c.id === categoryId)?.name || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
removeCategoryById(categoryId: string): void {
|
|
||||||
const index = this.categorieIds.controls.findIndex(x => x.value === categoryId);
|
|
||||||
if (index !== -1) {
|
|
||||||
this.categorieIds.removeAt(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
generateSku(): void {
|
|
||||||
const name = this.productForm.get('name')?.value || 'PROD';
|
|
||||||
const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, '');
|
|
||||||
const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase();
|
|
||||||
const sku = `${prefix}-${randomPart}`;
|
|
||||||
this.productForm.get('sku')?.setValue(sku);
|
|
||||||
this.snackbarService.show('Neue SKU generiert!');
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
this.isLoading = true;
|
||||||
const formValue = this.productForm.value;
|
const formData = this.createUpdateFormData();
|
||||||
|
|
||||||
Object.keys(formValue).forEach((key) => {
|
this.productService.update(this.productId, formData).pipe(
|
||||||
const value = formValue[key];
|
finalize(() => this.isLoading = false)
|
||||||
if (key === 'categorieIds' || key === 'imagesToDelete') {
|
).subscribe({
|
||||||
(value as string[]).forEach((id) => formData.append(this.capitalizeFirstLetter(key), id));
|
|
||||||
} else if (value !== null && value !== undefined && value !== '') {
|
|
||||||
if (['oldPrice', 'purchasePrice', 'weight'].includes(key) && value === '') return;
|
|
||||||
formData.append(this.capitalizeFirstLetter(key), value.toString());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.mainImageFile) formData.append('MainImageFile', this.mainImageFile);
|
|
||||||
this.additionalImageFiles.forEach((file) => formData.append('AdditionalImageFiles', file));
|
|
||||||
|
|
||||||
const operation: Observable<any> = this.isEditMode
|
|
||||||
? this.productService.update(this.productId!, formData)
|
|
||||||
: this.productService.create(formData);
|
|
||||||
|
|
||||||
operation.subscribe({
|
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackbarService.show(this.isEditMode ? 'Produkt aktualisiert' : 'Produkt erstellt');
|
this.snackbarService.show('Produkt erfolgreich aktualisiert!');
|
||||||
this.router.navigate(['/admin/products']);
|
this.router.navigate(['/shop/products']);
|
||||||
},
|
},
|
||||||
error: (err: any) => {
|
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(['/admin/products']);
|
this.router.navigate(['/shop/products']);
|
||||||
}
|
}
|
||||||
|
|
||||||
private subscribeToNameChanges(): void {
|
ngOnDestroy(): void {
|
||||||
this.nameChangeSubscription = this.productForm.get('name')?.valueChanges.pipe(debounceTime(300), distinctUntilChanged())
|
this.subscriptions.unsubscribe();
|
||||||
.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,115 @@
|
|||||||
|
/* /src/app/features/admin/components/products/product-form/product-form.component.css */
|
||||||
|
|
||||||
|
:host {
|
||||||
|
--form-spacing-vertical: 1.5rem;
|
||||||
|
--form-spacing-horizontal: 1.5rem;
|
||||||
|
--grid-gap: 1.5rem;
|
||||||
|
--border-radius: 8px;
|
||||||
|
--text-color-secondary: #64748b;
|
||||||
|
--background-color-light: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-layout { display: grid; grid-template-columns: 2fr 1fr; gap: var(--grid-gap); }
|
||||||
|
.main-content, .sidebar-content { display: flex; flex-direction: column; gap: var(--grid-gap); }
|
||||||
|
app-card { display: block; width: 100%; }
|
||||||
|
.form-section { padding: var(--form-spacing-horizontal); display: flex; flex-direction: column; gap: var(--form-spacing-vertical); }
|
||||||
|
h4[card-header] { margin-bottom: 0; }
|
||||||
|
.form-field { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.form-label { font-weight: 500; color: #334155; }
|
||||||
|
.required-indicator { color: var(--color-danger); margin-left: 4px; }
|
||||||
|
.form-hint { font-size: 0.875rem; color: var(--text-color-secondary); margin-top: -0.75rem; }
|
||||||
|
.input-with-button { display: flex; flex-direction: row; gap: 0.5rem; }
|
||||||
|
.sku-input {width: 100%;}
|
||||||
|
.input-with-button .form-input { flex-grow: 1; }
|
||||||
|
.price-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: var(--grid-gap); }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
BILDER-MANAGEMENT STYLING (FINAL & KORRIGIERT)
|
||||||
|
========================================================================== */
|
||||||
|
.image-upload-section {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-title { font-size: 1rem; font-weight: 600; color: var(--text-color-secondary); margin-bottom: 0.25rem; }
|
||||||
|
|
||||||
|
.image-gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-container {
|
||||||
|
position: relative; /* Wichtig für die Positionierung des Buttons */
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
overflow: visible; /* Erlaubt dem Button, leicht überzulappen */
|
||||||
|
border: 3px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-container:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-container.is-main {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--border-radius); /* Abgerundete Ecken für das Bild selbst */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styling für den Löschen-Button als Overlay */
|
||||||
|
.delete-overlay-button {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background-color: var(--color-danger);
|
||||||
|
color: white;
|
||||||
|
border: 2px solid var(--color-body-bg-lighter, white);
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-container:hover .delete-overlay-button {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-overlay-button app-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Kategorien- & Formular-Aktionen
|
||||||
|
========================================================================== */
|
||||||
|
.multi-select-container { border: 1px solid var(--color-border); border-radius: var(--border-radius); }
|
||||||
|
.selected-pills { display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.75rem; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.pill { display: flex; align-items: center; gap: 0.5rem; background-color: var(--background-color-light); border: 1px solid var(--color-border); padding: 0.25rem 0.75rem; border-radius: 16px; font-size: 0.875rem; }
|
||||||
|
.pill app-icon { cursor: pointer; width: 16px; height: 16px; }
|
||||||
|
.pill app-icon:hover { color: var(--color-danger); }
|
||||||
|
.placeholder { color: var(--text-color-secondary); }
|
||||||
|
.category-checkbox-group { max-height: 200px; overflow-y: auto; padding: 0.75rem; }
|
||||||
|
.category-checkbox-group label { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0; }
|
||||||
|
.form-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: var(--grid-gap); padding-top: var(--grid-gap); border-top: none; }
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.edit-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<!-- /src/app/features/admin/components/products/product-form/product-form.component.html -->
|
||||||
|
|
||||||
|
<form [formGroup]="productForm" (ngSubmit)="onSubmit()" novalidate>
|
||||||
|
<div class="edit-layout">
|
||||||
|
<div class="main-content">
|
||||||
|
<!-- Card: Allgemeine Produktinformationen -->
|
||||||
|
|
||||||
|
<app-form-group title="Allgemein" description="Hier kannst du Allgemeine PRodukt Informationen ändern....">
|
||||||
|
|
||||||
|
<app-form-field label="Name" type="text" formControlName="name" [control]="productForm.get('name')"></app-form-field>
|
||||||
|
<app-form-field label="Slug" type="text" formControlName="slug" [control]="productForm.get('slug')"></app-form-field>
|
||||||
|
<app-form-textarea label="Beschreibung" [rows]="8" formControlName="description" [control]="productForm.get('description')"></app-form-textarea>
|
||||||
|
|
||||||
|
</app-form-group>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Card: Bild-Management (Komplett überarbeitet) -->
|
||||||
|
<app-form-group title="Bild-Management" description="Wählen Sie Bilder für Ihr Produkt aus. Klicken Sie auf ein Bild um es es Hauptbild zu nutzen.">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input id="file-upload" type="file" accept="image/*" multiple (change)="onFilesSelected($event)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div *ngIf="hasImages">
|
||||||
|
|
||||||
|
<div class="image-gallery-grid">
|
||||||
|
<!-- Einheitliche Schleife mit neuem Löschen-Button -->
|
||||||
|
<div
|
||||||
|
*ngFor="let image of allImages"
|
||||||
|
class="image-preview-container"
|
||||||
|
(click)="setAsMainImage(image.identifier)"
|
||||||
|
[ngClass]="{ 'is-main': image.isMainImage }">
|
||||||
|
|
||||||
|
<img [src]="image.url" [alt]="'Bildvorschau'" class="image-preview" />
|
||||||
|
|
||||||
|
<!-- Löschen-Button als Overlay für JEDES Bild -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="delete-overlay-button"
|
||||||
|
(click)="requestImageDeletion(image.identifier, $event)"
|
||||||
|
aria-label="Bild entfernen">
|
||||||
|
<app-icon iconName="x"></app-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="gallery-hint">Klicken Sie auf ein Bild, um es als Hauptbild festzulegen.</p>
|
||||||
|
</div>
|
||||||
|
</app-form-group>
|
||||||
|
|
||||||
|
<!-- Card: Preisgestaltung -->
|
||||||
|
<app-form-group title="Preisgestaltung" description="Produktpreise und Einkaufspreise festlegen.">
|
||||||
|
|
||||||
|
<div class="form-grid price-grid">
|
||||||
|
<app-form-field label="Preis (€)" type="number" formControlName="price" [control]="productForm.get('price')"></app-form-field>
|
||||||
|
<app-form-field label="Alter Preis (€)" type="number" formControlName="oldPrice" [control]="productForm.get('oldPrice')"></app-form-field>
|
||||||
|
<app-form-field label="Einkaufspreis (€)" type="number" formControlName="purchasePrice" [control]="productForm.get('purchasePrice')"></app-form-field>
|
||||||
|
</div>
|
||||||
|
</app-form-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RECHTE SPALTE -->
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<app-form-group title="Status" description="Ein Deaktiviertes Produkt ist im Shop nicht sichtbar. Hervorgehobene Produkte werden bevorzugt angezeigt.">
|
||||||
|
|
||||||
|
|
||||||
|
<app-slide-toggle label="Aktiv (im Shop sichtbar)" labelPosition="right" formControlName="isActive"></app-slide-toggle>
|
||||||
|
|
||||||
|
|
||||||
|
<app-slide-toggle label="Hervorheben" labelPosition="right" formControlName="isFeatured"></app-slide-toggle>
|
||||||
|
<app-form-field *ngIf="productForm.get('isFeatured')?.value" label="Priorität" type="number" formControlName="featuredDisplayOrder" [control]="productForm.get('featuredDisplayOrder')"></app-form-field>
|
||||||
|
|
||||||
|
|
||||||
|
</app-form-group>
|
||||||
|
|
||||||
|
<app-form-group title="Organisation" description="">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="input-with-button">
|
||||||
|
<app-form-field class="sku-input" label="SKU (Artikelnummer)" type="text" formControlName="sku" [control]="productForm.get('sku')"></app-form-field>
|
||||||
|
<app-button buttonType="icon" (click)="generateSku()" iconName="placeholder"></app-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-form-field label="Lagerbestand" type="number" formControlName="stockQuantity" [control]="productForm.get('stockQuantity')"></app-form-field>
|
||||||
|
<app-form-field label="Gewicht (kg)" type="number" formControlName="weight" [control]="productForm.get('weight')"></app-form-field>
|
||||||
|
<app-form-select label="Lieferant" [options]="supplierOptions" formControlName="supplierId" [control]="productForm.get('supplierId')"></app-form-select>
|
||||||
|
|
||||||
|
|
||||||
|
<app-product-category-dropdown
|
||||||
|
label="Kategorien"
|
||||||
|
|
||||||
|
[options]="categoryOptions"
|
||||||
|
formControlName="categorieIds"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
</app-product-category-dropdown>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</app-form-group>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FORMULAR-AKTIONEN -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<app-button buttonType="stroked" (click)="cancel()">Abbrechen</app-button>
|
||||||
|
<app-button submitType="submit" buttonType="primary" [disabled]="!productForm.valid || isLoading">{{ submitButtonText }}</app-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// /src/app/features/admin/components/products/product-form/product-form.component.ts (CORRECTED)
|
||||||
|
|
||||||
|
import { Component, Input, Output, EventEmitter, inject, SimpleChanges } from '@angular/core';
|
||||||
|
import { CommonModule, NgClass } from '@angular/common';
|
||||||
|
import { FormGroup, ReactiveFormsModule } from '@angular/forms'; // FormArray and FormControl no longer needed here
|
||||||
|
import { SafeUrl } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
// Models & UI Components
|
||||||
|
import { Category } from '../../../../core/models/category.model';
|
||||||
|
import { ButtonComponent } from '../../../../shared/components/ui/button/button.component';
|
||||||
|
import { IconComponent } from '../../../../shared/components/ui/icon/icon.component';
|
||||||
|
import { FormFieldComponent } from '../../../../shared/components/form/form-field/form-field.component';
|
||||||
|
import {
|
||||||
|
FormSelectComponent,
|
||||||
|
SelectOption,
|
||||||
|
} from '../../../../shared/components/form/form-select/form-select.component';
|
||||||
|
import { FormTextareaComponent } from '../../../../shared/components/form/form-textarea/form-textarea.component';
|
||||||
|
import { SlideToggleComponent } from '../../../../shared/components/form/slide-toggle/slide-toggle.component';
|
||||||
|
import { SnackbarService } from '../../../../shared/services/snackbar.service';
|
||||||
|
import { FormGroupComponent } from '../../../../shared/components/form/form-group/form-group.component';
|
||||||
|
import { ProductCategoryDropdownComponent } from '../product-category-dropdown/product-category-dropdown.component';
|
||||||
|
|
||||||
|
export interface ImagePreview {
|
||||||
|
identifier: string;
|
||||||
|
url: string | SafeUrl;
|
||||||
|
isMainImage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-product-form',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule, NgClass, ReactiveFormsModule, ButtonComponent, IconComponent,
|
||||||
|
FormFieldComponent, FormSelectComponent, FormTextareaComponent,
|
||||||
|
SlideToggleComponent, FormGroupComponent, ProductCategoryDropdownComponent
|
||||||
|
],
|
||||||
|
templateUrl: './product-form.component.html',
|
||||||
|
styleUrls: ['./product-form.component.css'],
|
||||||
|
})
|
||||||
|
export class ProductFormComponent {
|
||||||
|
// --- Inputs ---
|
||||||
|
@Input() productForm!: FormGroup;
|
||||||
|
@Input() allCategories: Category[] = [];
|
||||||
|
@Input() supplierOptions: SelectOption[] = [];
|
||||||
|
@Input() isLoading = false;
|
||||||
|
@Input() submitButtonText = 'Speichern';
|
||||||
|
@Input() allImages: ImagePreview[] = [];
|
||||||
|
|
||||||
|
// --- Outputs ---
|
||||||
|
@Output() formSubmit = new EventEmitter<void>();
|
||||||
|
@Output() formCancel = new EventEmitter<void>();
|
||||||
|
@Output() filesSelected = new EventEmitter<File[]>();
|
||||||
|
@Output() setMainImage = new EventEmitter<string>();
|
||||||
|
@Output() deleteImage = new EventEmitter<string>();
|
||||||
|
|
||||||
|
private snackbarService = inject(SnackbarService);
|
||||||
|
public categoryOptions: SelectOption[] = [];
|
||||||
|
|
||||||
|
// --- GETTER ---
|
||||||
|
get hasImages(): boolean {
|
||||||
|
return this.allImages && this.allImages.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LIFECYCLE HOOKS ---
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes['allCategories']) {
|
||||||
|
this.categoryOptions = this.allCategories
|
||||||
|
.filter(cat => !!cat.name)
|
||||||
|
.map(cat => ({
|
||||||
|
value: cat.id,
|
||||||
|
label: cat.name!
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- EVENT-HANDLERS ---
|
||||||
|
onSubmit(): void { this.formSubmit.emit(); }
|
||||||
|
cancel(): void { this.formCancel.emit(); }
|
||||||
|
|
||||||
|
onFilesSelected(event: Event): void {
|
||||||
|
const files = (event.target as HTMLInputElement).files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
this.filesSelected.emit(Array.from(files));
|
||||||
|
}
|
||||||
|
(event.target as HTMLInputElement).value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
setAsMainImage(identifier: string): void {
|
||||||
|
this.setMainImage.emit(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestImageDeletion(identifier: string, event: MouseEvent): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.deleteImage.emit(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
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!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALL OBSOLETE CATEGORY HELPER FUNCTIONS HAVE BEEN REMOVED
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -3,37 +3,22 @@
|
|||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||||
import {
|
import { AdminProduct, ProductImage } from '../../../../core/models/product.model';
|
||||||
AdminProduct,
|
|
||||||
ProductImage,
|
|
||||||
} from '../../../../core/models/product.model';
|
|
||||||
import { ProductService } from '../../../services/product.service';
|
import { ProductService } from '../../../services/product.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 { StorageService } from '../../../../core/services/storage.service';
|
import { StorageService } from '../../../../core/services/storage.service';
|
||||||
import {
|
import { GenericTableComponent, ColumnConfig } from '../../../../shared/components/data-display/generic-table/generic-table.component';
|
||||||
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: [
|
imports: [CommonModule, GenericTableComponent, SearchBarComponent, ButtonComponent],
|
||||||
CommonModule,
|
|
||||||
CurrencyPipe,
|
|
||||||
DatePipe,
|
|
||||||
GenericTableComponent,
|
|
||||||
SearchBarComponent,
|
|
||||||
ButtonComponent,
|
|
||||||
IconComponent,
|
|
||||||
],
|
|
||||||
providers: [DatePipe],
|
providers: [DatePipe],
|
||||||
templateUrl: './product-list.component.html',
|
templateUrl: './product-list.component.html',
|
||||||
styleUrl: './product-list.component.css',
|
styleUrl: './product-list.component.css'
|
||||||
})
|
})
|
||||||
export class ProductListComponent implements OnInit {
|
export class ProductListComponent implements OnInit {
|
||||||
private productService = inject(ProductService);
|
private productService = inject(ProductService);
|
||||||
@@ -45,65 +30,36 @@ export class ProductListComponent implements OnInit {
|
|||||||
|
|
||||||
private readonly TABLE_SETTINGS_KEY = 'product-table-columns';
|
private readonly TABLE_SETTINGS_KEY = 'product-table-columns';
|
||||||
|
|
||||||
allProducts: (AdminProduct & {
|
allProducts: (AdminProduct & { mainImage?: string; supplierName?: string })[] = [];
|
||||||
mainImage?: string;
|
filteredProducts: (AdminProduct & { mainImage?: string; supplierName?: string })[] = [];
|
||||||
supplierName?: string;
|
|
||||||
})[] = [];
|
|
||||||
filteredProducts: (AdminProduct & {
|
|
||||||
mainImage?: string;
|
|
||||||
supplierName?: string;
|
|
||||||
})[] = [];
|
|
||||||
isColumnFilterVisible = false;
|
isColumnFilterVisible = false;
|
||||||
|
|
||||||
readonly allTableColumns: ColumnConfig[] = [
|
readonly allTableColumns: ColumnConfig[] = [
|
||||||
{ key: 'mainImage', title: 'Bild', type: 'image' },
|
{ key: 'mainImage', title: 'Bild', type: 'image' },
|
||||||
{ key: 'name', title: 'Name', type: 'text', subKey: 'sku' },
|
{ key: 'name', title: 'Name', type: 'text', subKey: 'sku' },
|
||||||
{ key: 'price', title: 'Preis', type: 'currency', cssClass: 'text-right' },
|
{ key: 'price', title: 'Preis', type: 'currency', cssClass: 'text-right' },
|
||||||
{
|
{ key: 'stockQuantity', title: 'Lager', type: 'text', cssClass: 'text-right' },
|
||||||
key: 'stockQuantity',
|
|
||||||
title: 'Lager',
|
|
||||||
type: 'text',
|
|
||||||
cssClass: 'text-right',
|
|
||||||
},
|
|
||||||
{ key: 'supplierName', title: 'Lieferant', type: 'text' },
|
{ key: 'supplierName', title: 'Lieferant', type: 'text' },
|
||||||
{ key: 'isActive', title: 'Aktiv', type: 'status' },
|
{ key: 'isActive', title: 'Aktiv', type: 'status' },
|
||||||
{ key: 'id', title: 'ID', type: 'text' },
|
{ key: 'id', title: 'ID', type: 'text' },
|
||||||
{ key: 'description', title: 'Beschreibung', type: 'text' },
|
{ key: 'description', title: 'Beschreibung', type: 'text' },
|
||||||
{
|
{ key: 'oldPrice', title: 'Alter Preis', type: 'currency', cssClass: 'text-right' },
|
||||||
key: 'oldPrice',
|
{ key: 'purchasePrice', title: 'Einkaufspreis', type: 'currency', cssClass: 'text-right' },
|
||||||
title: 'Alter Preis',
|
|
||||||
type: 'currency',
|
|
||||||
cssClass: 'text-right',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'purchasePrice',
|
|
||||||
title: 'Einkaufspreis',
|
|
||||||
type: 'currency',
|
|
||||||
cssClass: 'text-right',
|
|
||||||
},
|
|
||||||
{ key: 'isInStock', title: 'Auf Lager', type: 'status' },
|
{ key: 'isInStock', title: 'Auf Lager', type: 'status' },
|
||||||
{ key: 'weight', title: 'Gewicht', type: 'number', cssClass: 'text-right' },
|
{ key: 'weight', title: 'Gewicht', type: 'text', cssClass: 'text-right' },
|
||||||
{ key: 'slug', title: 'Slug', type: 'text' },
|
{ key: 'slug', title: 'Slug', type: 'text' },
|
||||||
{ key: 'createdDate', title: 'Erstellt am', type: 'date' },
|
{ key: 'createdDate', title: 'Erstellt am', type: 'text' },
|
||||||
{ key: 'lastModifiedDate', title: 'Zuletzt geändert', type: 'date' },
|
{ key: 'lastModifiedDate', title: 'Zuletzt geändert', type: 'text' },
|
||||||
{ key: 'supplierId', title: 'Lieferanten-ID', type: 'text' },
|
{ key: 'supplierId', title: 'Lieferanten-ID', type: 'text' },
|
||||||
{ key: 'categorieIds', title: 'Kategorie-IDs', type: 'text' },
|
{ key: 'categorieIds', title: 'Kategorie-IDs', type: 'text' },
|
||||||
{ key: 'isFeatured', title: 'Hervorgehoben', type: 'status' },
|
{ key: 'isFeatured', title: 'Hervorgehoben', type: 'status' },
|
||||||
{
|
{ key: 'featuredDisplayOrder', title: 'Anzeigereihenfolge (hervorgehoben)', type: 'number', cssClass: 'text-right' },
|
||||||
key: 'featuredDisplayOrder',
|
{ key: 'actions', title: 'Aktionen', type: 'actions', cssClass: 'text-right' }
|
||||||
title: 'Anzeigereihenfolge (hervorgehoben)',
|
];
|
||||||
type: 'number',
|
|
||||||
cssClass: 'text-right',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
title: 'Aktionen',
|
|
||||||
type: 'actions',
|
|
||||||
cssClass: 'text-right',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
visibleTableColumns: ColumnConfig[] = [];
|
visibleTableColumns: ColumnConfig[] = [];
|
||||||
|
|
||||||
|
public readonly fallbackImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNjY2NjY2MiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cmVjdCB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHg9IjMiIHk9IjMiIHJ4PSIyIiByeT0iMiIvPjxjaXJjbGUgY3g9IjkiIGN5PSI5IiByPSIyIi8+PHBhdGggZD0ibTIxIDE1LTUtNWwtNSA1bC0yLTJsLTUgNSIvPjwvc3ZnPg==';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadTableSettings();
|
this.loadTableSettings();
|
||||||
}
|
}
|
||||||
@@ -113,16 +69,15 @@ export class ProductListComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadProducts(): void {
|
loadProducts(): void {
|
||||||
this.productService.getAll().subscribe((products) => {
|
this.productService.getAll().subscribe(products => {
|
||||||
this.supplierService.getAll().subscribe((suppliers) => {
|
this.supplierService.getAll().subscribe(suppliers => {
|
||||||
this.allProducts = products.map((p) => {
|
this.allProducts = products.map(p => {
|
||||||
const supplier = suppliers.find((s) => s.id === p.supplierId);
|
const supplier = suppliers.find(s => s.id === p.supplierId);
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
mainImage: this.getMainImageUrl(p.images),
|
mainImage: this.getMainImageUrl(p.images),
|
||||||
supplierName: supplier?.name || '-',
|
supplierName: supplier?.name || '-',
|
||||||
createdDate:
|
createdDate: this.datePipe.transform(p.createdDate, 'dd.MM.yyyy HH:mm') || '-',
|
||||||
this.datePipe.transform(p.createdDate, 'dd.MM.yyyy HH:mm') || '-',
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
this.onSearch('');
|
this.onSearch('');
|
||||||
@@ -132,19 +87,18 @@ export class ProductListComponent implements OnInit {
|
|||||||
|
|
||||||
onSearch(term: string): void {
|
onSearch(term: string): void {
|
||||||
const lowerTerm = term.toLowerCase();
|
const lowerTerm = term.toLowerCase();
|
||||||
this.filteredProducts = this.allProducts.filter(
|
this.filteredProducts = this.allProducts.filter(p =>
|
||||||
(p) =>
|
p.name?.toLowerCase().includes(lowerTerm) ||
|
||||||
p.name?.toLowerCase().includes(lowerTerm) ||
|
p.sku?.toLowerCase().includes(lowerTerm)
|
||||||
p.sku?.toLowerCase().includes(lowerTerm)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onAddNew(): void {
|
onAddNew(): void {
|
||||||
this.router.navigate(['/shop/products/new']);
|
this.router.navigate(['/shop/products/create']);
|
||||||
}
|
}
|
||||||
|
|
||||||
onEditProduct(productId: string): void {
|
onEditProduct(productId: string): void {
|
||||||
this.router.navigate(['/shop/products', productId]);
|
this.router.navigate(['/shop/products/edit', productId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
onDeleteProduct(productId: string): void {
|
onDeleteProduct(productId: string): void {
|
||||||
@@ -157,26 +111,14 @@ export class ProductListComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private loadTableSettings(): void {
|
private loadTableSettings(): void {
|
||||||
const savedKeys = this.storageService.getItem<string[]>(
|
const savedKeys = this.storageService.getItem<string[]>(this.TABLE_SETTINGS_KEY);
|
||||||
this.TABLE_SETTINGS_KEY
|
const defaultKeys = ['mainImage', 'name', 'price', 'stockQuantity', 'isActive', 'actions'];
|
||||||
);
|
const keysToUse = (savedKeys && savedKeys.length > 0) ? savedKeys : defaultKeys;
|
||||||
const defaultKeys = [
|
this.visibleTableColumns = this.allTableColumns.filter(c => keysToUse.includes(c.key));
|
||||||
'mainImage',
|
|
||||||
'name',
|
|
||||||
'price',
|
|
||||||
'stockQuantity',
|
|
||||||
'isActive',
|
|
||||||
'actions',
|
|
||||||
];
|
|
||||||
const keysToUse =
|
|
||||||
savedKeys && savedKeys.length > 0 ? savedKeys : defaultKeys;
|
|
||||||
this.visibleTableColumns = this.allTableColumns.filter((c) =>
|
|
||||||
keysToUse.includes(c.key)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveTableSettings(): void {
|
private saveTableSettings(): void {
|
||||||
const visibleKeys = this.visibleTableColumns.map((c) => c.key);
|
const visibleKeys = this.visibleTableColumns.map(c => c.key);
|
||||||
this.storageService.setItem(this.TABLE_SETTINGS_KEY, visibleKeys);
|
this.storageService.setItem(this.TABLE_SETTINGS_KEY, visibleKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,29 +127,26 @@ export class ProductListComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isColumnVisible(columnKey: string): boolean {
|
isColumnVisible(columnKey: string): boolean {
|
||||||
return this.visibleTableColumns.some((c) => c.key === columnKey);
|
return this.visibleTableColumns.some(c => c.key === columnKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
onColumnToggle(column: ColumnConfig, event: Event): void {
|
onColumnToggle(column: ColumnConfig, event: Event): void {
|
||||||
const checkbox = event.target as HTMLInputElement;
|
const checkbox = event.target as HTMLInputElement;
|
||||||
if (checkbox.checked) {
|
if (checkbox.checked) {
|
||||||
this.visibleTableColumns.push(column);
|
this.visibleTableColumns.push(column);
|
||||||
this.visibleTableColumns.sort(
|
this.visibleTableColumns.sort((a, b) =>
|
||||||
(a, b) =>
|
this.allTableColumns.findIndex(c => c.key === a.key) -
|
||||||
this.allTableColumns.findIndex((c) => c.key === a.key) -
|
this.allTableColumns.findIndex(c => c.key === b.key)
|
||||||
this.allTableColumns.findIndex((c) => c.key === b.key)
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.visibleTableColumns = this.visibleTableColumns.filter(
|
this.visibleTableColumns = this.visibleTableColumns.filter(c => c.key !== column.key);
|
||||||
(c) => c.key !== column.key
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
this.saveTableSettings();
|
this.saveTableSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
<p>product-new works!</p>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-product-new',
|
|
||||||
imports: [],
|
|
||||||
templateUrl: './product-new.component.html',
|
|
||||||
styleUrl: './product-new.component.css'
|
|
||||||
})
|
|
||||||
export class ProductNewComponent {
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -3,21 +3,22 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { ProductListComponent } from './product-list/product-list.component';
|
import { ProductListComponent } from './product-list/product-list.component';
|
||||||
import { ProductEditComponent } from './product-edit/product-edit.component';
|
import { ProductEditComponent } from './product-edit/product-edit.component';
|
||||||
|
import { ProductCreateComponent } from './product-create/product-create.component';
|
||||||
|
|
||||||
export const PRODUCTS_ROUTES: Routes = [
|
export const PRODUCTS_ROUTES: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: ProductListComponent,
|
component: ProductListComponent,
|
||||||
title: 'Produktübersicht'
|
title: 'Produktübersicht',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'new',
|
path: 'create',
|
||||||
component: ProductEditComponent,
|
component: ProductCreateComponent,
|
||||||
title: 'Neues Produkt erstellen'
|
title: 'Product | Create',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':id',
|
path: 'edit/:id',
|
||||||
component: ProductEditComponent,
|
component: ProductEditComponent,
|
||||||
title: 'Produkt bearbeiten'
|
title: 'Product | Edit',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -22,27 +22,51 @@
|
|||||||
<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
|
||||||
type="number"
|
type="number"
|
||||||
formControlName="minDeliveryDays"
|
formControlName="minDeliveryDays"
|
||||||
placeholder="z.B. 1"
|
placeholder="z.B. 1"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>Maximale Liefertage:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
formControlName="maxDeliveryDays"
|
||||||
|
placeholder="z.B. 3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- +++ NEUE GEWICHTS FELDER +++ -->
|
||||||
<label>Maximale Liefertage:</label>
|
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
||||||
<input
|
<div>
|
||||||
type="number"
|
<label>Gewicht von (kg):</label>
|
||||||
formControlName="maxDeliveryDays"
|
<input
|
||||||
placeholder="z.B. 3"
|
type="number"
|
||||||
/>
|
formControlName="minWeight"
|
||||||
|
placeholder="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>Gewicht bis (kg):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
formControlName="maxWeight"
|
||||||
|
placeholder="10"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- +++ ENDE NEU +++ -->
|
<!-- +++ ENDE NEU +++ -->
|
||||||
|
|
||||||
<div>
|
<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>
|
||||||
<button (click)="selectMethod(method)">Bearbeiten</button>
|
Lieferzeit: {{ method.minDeliveryDays }}-{{ method.maxDeliveryDays }} Tage |
|
||||||
<button (click)="onDelete(method.id)">Löschen</button>
|
<!-- 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)="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>
|
||||||
|
|||||||
@@ -46,12 +46,16 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%; /* Vertikal zentrieren (Schritt 1) */
|
top: 50%; /* Vertikal zentrieren (Schritt 1) */
|
||||||
left: 1rem; /* Linken Abstand wie beim Input-Padding halten */
|
left: 1rem; /* Linken Abstand wie beim Input-Padding halten */
|
||||||
transform: translateY(-50%); /* Vertikal zentrieren (Schritt 2 - Feinjustierung) */
|
transform: translateY(
|
||||||
|
-50%
|
||||||
|
); /* Vertikal zentrieren (Schritt 2 - Feinjustierung) */
|
||||||
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
/* Aussehen & Typografie */
|
/* Aussehen & Typografie */
|
||||||
color: var(--color-text-light);
|
color: var(--color-text-light);
|
||||||
background-color: var(--color-surface); /* WICHTIG: Überdeckt die Input-Linie beim Schweben */
|
background-color: var(
|
||||||
|
--color-surface
|
||||||
|
); /* WICHTIG: Überdeckt die Input-Linie beim Schweben */
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
|
|
||||||
/* Verhalten */
|
/* Verhalten */
|
||||||
@@ -75,7 +79,9 @@
|
|||||||
.form-input:focus ~ .form-label,
|
.form-input:focus ~ .form-label,
|
||||||
.form-input:not(:placeholder-shown) ~ .form-label {
|
.form-input:not(:placeholder-shown) ~ .form-label {
|
||||||
top: 0;
|
top: 0;
|
||||||
transform: translateY(-50%); /* Bleibt für die Zentrierung auf der Border-Linie wichtig */
|
transform: translateY(
|
||||||
|
-50%
|
||||||
|
); /* Bleibt für die Zentrierung auf der Border-Linie wichtig */
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
@@ -90,9 +96,9 @@
|
|||||||
.form-input:-webkit-autofill:hover,
|
.form-input:-webkit-autofill:hover,
|
||||||
.form-input:-webkit-autofill:focus,
|
.form-input:-webkit-autofill:focus,
|
||||||
.form-input:-webkit-autofill:active {
|
.form-input:-webkit-autofill:active {
|
||||||
/* OPTIONAL: Überschreibt den unschönen gelben/blauen Autofill-Hintergrund */
|
/* OPTIONAL: Überschreibt den unschönen gelben/blauen Autofill-Hintergrund */
|
||||||
-webkit-box-shadow: 0 0 0 30px var(--color-surface) inset !important;
|
-webkit-box-shadow: 0 0 0 30px var(--color-surface) inset !important;
|
||||||
-webkit-text-fill-color: var(--color-text) !important;
|
-webkit-text-fill-color: var(--color-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:-webkit-autofill ~ .form-label {
|
.form-input:-webkit-autofill ~ .form-label {
|
||||||
@@ -101,3 +107,17 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.required-indicator {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styling für die Fehlermeldung */
|
||||||
|
.error-message {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
<div class="form-field">
|
<!-- /src/app/shared/components/form/form-field/form-field.component.html -->
|
||||||
<input
|
|
||||||
[type]="type"
|
|
||||||
class="form-input"
|
|
||||||
[id]="controlId"
|
|
||||||
placeholder=" "
|
|
||||||
[disabled]="disabled"
|
|
||||||
[(ngModel)]="value"
|
|
||||||
(ngModelChange)="onChange($event)"
|
|
||||||
(blur)="onTouched()">
|
|
||||||
|
|
||||||
<label [for]="controlId" class="form-label">{{ label }}</label>
|
<div class="form-field-wrapper">
|
||||||
|
<div class="form-field">
|
||||||
|
<input
|
||||||
|
[type]="type"
|
||||||
|
class="form-input"
|
||||||
|
[id]="controlId"
|
||||||
|
placeholder=" "
|
||||||
|
[disabled]="disabled"
|
||||||
|
[(ngModel)]="value"
|
||||||
|
(ngModelChange)="onChange($event)"
|
||||||
|
(blur)="onTouched()">
|
||||||
|
|
||||||
|
<label [for]="controlId" class="form-label">
|
||||||
|
{{ label }}
|
||||||
|
<!-- Der Indikator wird jetzt nur bei Bedarf angezeigt -->
|
||||||
|
<span *ngIf="isRequired" class="required-indicator">*</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Anzeige für Validierungsfehler -->
|
||||||
|
<div *ngIf="showErrors && errorMessage" class="error-message">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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 { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
|
import { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, Validators } from '@angular/forms'; // Validators importieren
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-form-field',
|
selector: 'app-form-field',
|
||||||
@@ -10,10 +10,10 @@ import { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule }
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule // <-- WICHTIG: Hinzufügen, um mit AbstractControl zu arbeiten
|
ReactiveFormsModule
|
||||||
],
|
],
|
||||||
templateUrl: './form-field.component.html',
|
templateUrl: './form-field.component.html',
|
||||||
styleUrl: './form-field.component.css',
|
styleUrls: ['./form-field.component.css'],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: NG_VALUE_ACCESSOR,
|
provide: NG_VALUE_ACCESSOR,
|
||||||
@@ -23,44 +23,42 @@ import { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule }
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FormFieldComponent {
|
export class FormFieldComponent {
|
||||||
// --- KORREKTUR: Erweitere die erlaubten Typen ---
|
|
||||||
@Input() type: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' = 'text';
|
@Input() type: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' = 'text';
|
||||||
@Input() label: string = '';
|
@Input() label: string = '';
|
||||||
|
@Input() control?: AbstractControl | null;
|
||||||
// Neuer Input, um das FormControl für die Fehleranzeige zu erhalten
|
@Input() showErrors = true;
|
||||||
@Input() control?: AbstractControl;
|
|
||||||
@Input() showErrors = true; // Standardmäßig Fehler anzeigen
|
|
||||||
|
|
||||||
controlId = `form-field-${Math.random().toString(36).substring(2, 9)}`;
|
controlId = `form-field-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
|
||||||
// --- Eigenschaften & Methoden für ControlValueAccessor ---
|
|
||||||
value: string | number = '';
|
value: string | number = '';
|
||||||
onChange: (value: any) => void = () => {};
|
onChange: (value: any) => void = () => {};
|
||||||
onTouched: () => void = () => {};
|
onTouched: () => void = () => {};
|
||||||
disabled = false;
|
disabled = false;
|
||||||
|
|
||||||
writeValue(value: any): void {
|
// NEU: Getter, der automatisch prüft, ob das Feld ein Pflichtfeld ist.
|
||||||
this.value = value;
|
get isRequired(): boolean {
|
||||||
}
|
if (!this.control) {
|
||||||
registerOnChange(fn: any): void {
|
return false;
|
||||||
this.onChange = fn;
|
}
|
||||||
}
|
// hasValidator prüft, ob ein bestimmter Validator auf dem Control gesetzt ist.
|
||||||
registerOnTouched(fn: any): void {
|
return this.control.hasValidator(Validators.required);
|
||||||
this.onTouched = fn;
|
|
||||||
}
|
|
||||||
setDisabledState?(isDisabled: boolean): void {
|
|
||||||
this.disabled = isDisabled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hilfsfunktion für das Template, um Fehler zu finden
|
|
||||||
get errorMessage(): string | null {
|
get errorMessage(): string | null {
|
||||||
if (!this.control || !this.control.errors || (!this.control.touched && !this.control.dirty)) {
|
if (!this.control || !this.control.errors || (!this.control.touched && !this.control.dirty)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (this.control.hasError('required')) return 'Dieses Feld ist erforderlich.';
|
const errors = this.control.errors;
|
||||||
if (this.control.hasError('email')) return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
|
if (errors['required']) return 'Dieses Feld ist erforderlich.';
|
||||||
if (this.control.hasError('min')) return `Der Wert muss mindestens ${this.control.errors['min'].min} sein.`;
|
if (errors['email']) return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
|
||||||
// ... weitere Fehlermeldungen hier
|
if (errors['min']) return `Der Wert muss mindestens ${errors['min'].min} sein.`;
|
||||||
|
if (errors['max']) return `Der Wert darf maximal ${errors['max'].max} sein.`;
|
||||||
|
if (errors['minlength']) return `Mindestens ${errors['minlength'].requiredLength} Zeichen erforderlich.`;
|
||||||
|
|
||||||
return 'Ungültige Eingabe.';
|
return 'Ungültige Eingabe.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeValue(value: any): void { this.value = value; }
|
||||||
|
registerOnChange(fn: any): void { this.onChange = fn; }
|
||||||
|
registerOnTouched(fn: any): void { this.onTouched = fn; }
|
||||||
|
setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; }
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--color-text-light);
|
color: var(--color-text-light);
|
||||||
margin-top: -0.75rem; /* Rücken wir näher an den Titel */
|
margin-top: -0.75rem; /* Rücken wir näher an den Titel */
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group-content {
|
.form-group-content {
|
||||||
|
|||||||
@@ -9,12 +9,6 @@
|
|||||||
{{ description }}
|
{{ description }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!--
|
|
||||||
HIER IST DIE MAGIE:
|
|
||||||
<ng-content> ist ein Platzhalter. Alles, was Sie in Demo2Component
|
|
||||||
zwischen <app-form-group> und </app-form-group> schreiben,
|
|
||||||
wird genau an dieser Stelle eingefügt.
|
|
||||||
-->
|
|
||||||
<div class="form-group-content">
|
<div class="form-group-content">
|
||||||
<ng-content></ng-content>
|
<ng-content></ng-content>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component, Input, forwardRef, HostListener, ElementRef } from '@angular/core';
|
import { Component, Input, forwardRef, HostListener, ElementRef } from '@angular/core';
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormsModule } from '@angular/forms';
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormsModule, AbstractControl } from '@angular/forms';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { animate, style, transition, trigger } from '@angular/animations';
|
import { animate, style, transition, trigger } from '@angular/animations';
|
||||||
|
|
||||||
@@ -39,10 +39,12 @@ export interface SelectOption {
|
|||||||
export class FormSelectComponent implements ControlValueAccessor {
|
export class FormSelectComponent implements ControlValueAccessor {
|
||||||
@Input() label: string = '';
|
@Input() label: string = '';
|
||||||
@Input() options: SelectOption[] = [];
|
@Input() options: SelectOption[] = [];
|
||||||
|
@Input() control: AbstractControl | null = null;
|
||||||
|
|
||||||
// NEU: Zustand für das Dropdown-Menü und das angezeigte Label
|
|
||||||
isOpen = false;
|
isOpen = false;
|
||||||
selectedLabel: string | null = null;
|
|
||||||
|
// -- ENTFERNEN SIE DIESE ZEILE --
|
||||||
|
// selectedLabel: string | null = null;
|
||||||
|
|
||||||
controlId = `form-select-${Math.random().toString(36).substring(2)}`;
|
controlId = `form-select-${Math.random().toString(36).substring(2)}`;
|
||||||
value: any = null;
|
value: any = null;
|
||||||
@@ -50,46 +52,47 @@ export class FormSelectComponent implements ControlValueAccessor {
|
|||||||
onTouched: () => void = () => {};
|
onTouched: () => void = () => {};
|
||||||
disabled = false;
|
disabled = false;
|
||||||
|
|
||||||
|
// ++ FÜGEN SIE DIESEN GETTER HINZU ++
|
||||||
|
// Dieser Getter wird immer dann neu berechnet, wenn Angular die Ansicht prüft.
|
||||||
|
// Er hat immer Zugriff auf die aktuellsten `options` und den aktuellsten `value`.
|
||||||
|
get selectedLabel(): string | null {
|
||||||
|
const selectedOption = this.options.find(opt => opt.value === this.value);
|
||||||
|
return selectedOption ? selectedOption.label : null;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(private elementRef: ElementRef) {}
|
constructor(private elementRef: ElementRef) {}
|
||||||
|
|
||||||
// NEU: Schließt das Dropdown, wenn außerhalb des Elements geklickt wird
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
onDocumentClick(event: MouseEvent): void {
|
onDocumentClick(event: MouseEvent): void {
|
||||||
// Diese Logik ist jetzt sicher, weil Klicks innerhalb der Komponente sie nie erreichen
|
|
||||||
if (!this.elementRef.nativeElement.contains(event.target)) {
|
if (!this.elementRef.nativeElement.contains(event.target)) {
|
||||||
this.isOpen = false;
|
this.isOpen = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ÜBERARBEITET: writeValue aktualisiert jetzt auch das sichtbare Label
|
// KORRIGIERT: writeValue setzt jetzt nur noch den Wert.
|
||||||
writeValue(value: any): void {
|
writeValue(value: any): void {
|
||||||
this.value = value;
|
this.value = value;
|
||||||
const selectedOption = this.options.find(opt => opt.value === value);
|
// Die Zeile, die `selectedLabel` gesetzt hat, wird nicht mehr benötigt.
|
||||||
this.selectedLabel = selectedOption ? selectedOption.label : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
registerOnChange(fn: any): void { this.onChange = fn; }
|
registerOnChange(fn: any): void { this.onChange = fn; }
|
||||||
registerOnTouched(fn: any): void { this.onTouched = fn; }
|
registerOnTouched(fn: any): void { this.onTouched = fn; }
|
||||||
setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; }
|
setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; }
|
||||||
|
|
||||||
// NEU: Methode zum Öffnen/Schließen des Menüs
|
|
||||||
// ÜBERARBEITET: Nimmt das Event entgegen und stoppt es
|
|
||||||
toggleDropdown(event: MouseEvent): void {
|
toggleDropdown(event: MouseEvent): void {
|
||||||
event.stopPropagation(); // <-- WICHTIGSTE KORREKTUR
|
event.stopPropagation();
|
||||||
if (!this.disabled) {
|
if (!this.disabled) {
|
||||||
this.isOpen = !this.isOpen;
|
this.isOpen = !this.isOpen;
|
||||||
if (!this.isOpen) {
|
if (!this.isOpen) { this.onTouched(); }
|
||||||
this.onTouched();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ÜBERARBEITET: Nimmt das Event entgegen und stoppt es
|
// KORRIGIERT: selectOption setzt jetzt nur noch den Wert.
|
||||||
selectOption(option: SelectOption, event: MouseEvent): void {
|
selectOption(option: SelectOption, event: MouseEvent): void {
|
||||||
event.stopPropagation(); // <-- WICHTIGE KORREKTUR
|
event.stopPropagation();
|
||||||
if (!this.disabled) {
|
if (!this.disabled) {
|
||||||
this.value = option.value;
|
this.value = option.value;
|
||||||
this.selectedLabel = option.label;
|
// Die Zeile, die `selectedLabel` gesetzt hat, wird nicht mehr benötigt.
|
||||||
this.onChange(this.value);
|
this.onChange(this.value);
|
||||||
this.onTouched();
|
this.onTouched();
|
||||||
this.isOpen = false;
|
this.isOpen = false;
|
||||||
|
|||||||
@@ -77,3 +77,17 @@ border-radius: 4px;
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.required-indicator {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styling für die Fehlermeldung */
|
||||||
|
.error-message {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
<div class="form-field">
|
<!-- /src/app/shared/components/form/form-textarea/form-textarea.component.html -->
|
||||||
<textarea
|
|
||||||
class="form-input"
|
|
||||||
[id]="controlId"
|
|
||||||
placeholder=" "
|
|
||||||
[rows]="rows"
|
|
||||||
[(ngModel)]="value"
|
|
||||||
(ngModelChange)="onChange($event)"
|
|
||||||
(blur)="onTouched()"
|
|
||||||
[disabled]="disabled"></textarea>
|
|
||||||
|
|
||||||
<label [for]="controlId" class="form-label">{{ label }}</label>
|
<div class="form-field-wrapper">
|
||||||
|
<div class="form-field">
|
||||||
|
<textarea
|
||||||
|
class="form-input"
|
||||||
|
[id]="controlId"
|
||||||
|
[rows]="rows"
|
||||||
|
placeholder=" "
|
||||||
|
[disabled]="disabled"
|
||||||
|
[(ngModel)]="value"
|
||||||
|
(ngModelChange)="onChange($event)"
|
||||||
|
(blur)="onTouched()"></textarea>
|
||||||
|
|
||||||
|
<label [for]="controlId" class="form-label">
|
||||||
|
{{ label }}
|
||||||
|
<span *ngIf="isRequired" class="required-indicator">*</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="showErrors && errorMessage" class="error-message">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,38 +1,54 @@
|
|||||||
|
// /src/app/shared/components/form/form-textarea/form-textarea.component.ts
|
||||||
|
|
||||||
import { Component, Input, forwardRef } from '@angular/core';
|
import { Component, Input, forwardRef } from '@angular/core';
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormsModule } from '@angular/forms';
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-form-textarea',
|
selector: 'app-form-textarea',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [ CommonModule, FormsModule, ReactiveFormsModule ],
|
||||||
CommonModule,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
FormsModule // Wichtig für [(ngModel)]
|
|
||||||
],
|
|
||||||
templateUrl: './form-textarea.component.html',
|
templateUrl: './form-textarea.component.html',
|
||||||
styleUrl: './form-textarea.component.css',
|
styleUrls: ['./form-textarea.component.css'],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: NG_VALUE_ACCESSOR,
|
provide: NG_VALUE_ACCESSOR,
|
||||||
useExisting: forwardRef(() => FormTextareaComponent),
|
useExisting: forwardRef(() => FormTextareaComponent),
|
||||||
multi: true
|
multi: true,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
})
|
})
|
||||||
export class FormTextareaComponent implements ControlValueAccessor {
|
export class FormTextareaComponent {
|
||||||
@Input() label: string = '';
|
@Input() label: string = '';
|
||||||
@Input() rows = 3; // Standardanzahl der Zeilen
|
@Input() rows: number = 4;
|
||||||
|
|
||||||
// Eindeutige ID für die Verknüpfung
|
// NEU: Hinzufügen des 'control' Inputs, genau wie in form-field
|
||||||
controlId = `form-textarea-${Math.random().toString(36).substring(2)}`;
|
@Input() control?: AbstractControl | null;
|
||||||
|
|
||||||
// --- Logik für ControlValueAccessor ---
|
@Input() showErrors = true;
|
||||||
|
|
||||||
|
controlId = `form-textarea-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
value: string = '';
|
value: string = '';
|
||||||
onChange: (value: any) => void = () => {};
|
onChange: (value: any) => void = () => {};
|
||||||
onTouched: () => void = () => {};
|
onTouched: () => void = () => {};
|
||||||
disabled = false;
|
disabled = false;
|
||||||
|
|
||||||
|
get isRequired(): boolean {
|
||||||
|
if (!this.control) return false;
|
||||||
|
return this.control.hasValidator(Validators.required);
|
||||||
|
}
|
||||||
|
|
||||||
|
get errorMessage(): string | null {
|
||||||
|
if (!this.control || !this.control.errors || (!this.control.touched && !this.control.dirty)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const errors = this.control.errors;
|
||||||
|
if (errors['required']) return 'Dieses Feld ist erforderlich.';
|
||||||
|
if (errors['minlength']) return `Mindestens ${errors['minlength'].requiredLength} Zeichen erforderlich.`;
|
||||||
|
|
||||||
|
return 'Ungültige Eingabe.';
|
||||||
|
}
|
||||||
|
|
||||||
writeValue(value: any): void { this.value = value; }
|
writeValue(value: any): void { this.value = value; }
|
||||||
registerOnChange(fn: any): void { this.onChange = fn; }
|
registerOnChange(fn: any): void { this.onChange = fn; }
|
||||||
registerOnTouched(fn: any): void { this.onTouched = fn; }
|
registerOnTouched(fn: any): void { this.onTouched = fn; }
|
||||||
|
|||||||
@@ -1,128 +1,128 @@
|
|||||||
<aside class="sidebar" [class.collapsed]="isCollapsed">
|
<aside class="sidebar" [class.collapsed]="isCollapsed">
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
|
<!-- Toggle bleibt wie er ist, da er keine Route ist -->
|
||||||
<div class="nav-item" (click)="toggleSidebar()">
|
<div class="nav-item" (click)="toggleSidebar()">
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
||||||
|
|
||||||
<span>Toggle</span>
|
<span>Toggle</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Products -->
|
||||||
<div
|
<div
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
[class.active]="activeRoute === 'home'"
|
routerLink="/shop/products"
|
||||||
(click)="setActive('home')"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
||||||
|
<span>products</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Home -->
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
routerLink="/shop/home"
|
||||||
|
routerLinkActive="active"
|
||||||
|
>
|
||||||
|
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
||||||
<span>Home</span>
|
<span>Home</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories -->
|
||||||
<div
|
<div
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
[class.active]="activeRoute === 'categories'"
|
routerLink="/shop/categories"
|
||||||
(click)="setActive('categories')"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
||||||
|
|
||||||
<span>categories</span>
|
<span>categories</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Discounts -->
|
||||||
<div
|
<div
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
[class.active]="activeRoute === 'discounts'"
|
routerLink="/shop/discounts"
|
||||||
(click)="setActive('discounts')"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
||||||
|
|
||||||
<span>discounts</span>
|
<span>discounts</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Orders -->
|
||||||
<div
|
<div
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
[class.active]="activeRoute === 'orders'"
|
routerLink="/shop/orders"
|
||||||
(click)="setActive('orders')"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
||||||
|
|
||||||
<span>orders</span>
|
<span>orders</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment Methods -->
|
||||||
<div
|
<div
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
[class.active]="activeRoute === 'payment-methods'"
|
routerLink="/shop/payment-methods"
|
||||||
(click)="setActive('payment-methods')"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
||||||
|
|
||||||
<span>payment-methods</span>
|
<span>payment-methods</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Reviews -->
|
||||||
<div
|
<div
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
[class.active]="activeRoute === 'products'"
|
routerLink="/shop/reviews"
|
||||||
(click)="setActive('products')"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
||||||
|
|
||||||
<span>products</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="nav-item"
|
|
||||||
[class.active]="activeRoute === 'reviews'"
|
|
||||||
(click)="setActive('reviews')"
|
|
||||||
>
|
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
|
||||||
|
|
||||||
<span>reviews</span>
|
<span>reviews</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div
|
|
||||||
class="nav-item"
|
|
||||||
[class.active]="activeRoute === 'settings'"
|
|
||||||
(click)="setActive('settings')"
|
|
||||||
>
|
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
|
||||||
|
|
||||||
<span>settings</span>
|
<!-- Shipping Methods -->
|
||||||
</div> -->
|
|
||||||
<div
|
<div
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
[class.active]="activeRoute === 'shipping-methods'"
|
routerLink="/shop/shipping-methods"
|
||||||
(click)="setActive('shipping-methods')"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
||||||
|
|
||||||
<span>shipping-methods</span>
|
<span>shipping-methods</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Shop Info -->
|
||||||
<div
|
<div
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
[class.active]="activeRoute === 'shop-info'"
|
routerLink="/shop/shop-info"
|
||||||
(click)="setActive('shop-info')"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
||||||
|
|
||||||
<span>shop-info</span>
|
<span>shop-info</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Supplier List -->
|
||||||
<div
|
<div
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
[class.active]="activeRoute === 'supplier-list'"
|
routerLink="/shop/supplier-list"
|
||||||
(click)="setActive('supplier-list')"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
||||||
|
|
||||||
<span>supplier-list</span>
|
<span>supplier-list</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Users -->
|
||||||
<div
|
<div
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
[class.active]="activeRoute === 'users'"
|
routerLink="/shop/users"
|
||||||
(click)="setActive('users')"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
||||||
|
|
||||||
<span>users</span>
|
<span>users</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Analytics -->
|
||||||
<div
|
<div
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
[class.active]="activeRoute === 'analytics'"
|
routerLink="/shop/analytics"
|
||||||
(click)="setActive('analytics')"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
|
||||||
|
|
||||||
<span>analytics</span>
|
<span>analytics</span>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,51 +1,40 @@
|
|||||||
// /src/app/core/components/default-layout/sidebar/sidebar.component.ts
|
|
||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterLink, RouterLinkActive } from '@angular/router'; // RouterLink und RouterLinkActive importieren
|
// WICHTIG: RouterLink und RouterLinkActive importieren
|
||||||
|
import { RouterLink, RouterLinkActive, Router } from '@angular/router';
|
||||||
import { IconComponent } from '../../ui/icon/icon.component';
|
import { IconComponent } from '../../ui/icon/icon.component';
|
||||||
import { StorageService } from '../../../../core/services/storage.service';
|
import { StorageService } from '../../../../core/services/storage.service';
|
||||||
import { Router } from '@angular/router';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-sidebar',
|
selector: 'app-sidebar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, IconComponent], // RouterLink und RouterLinkActive hier hinzufügen
|
// WICHTIG: Hier im Array hinzufügen
|
||||||
|
imports: [CommonModule, IconComponent, RouterLink, RouterLinkActive],
|
||||||
templateUrl: './sidebar.component.html',
|
templateUrl: './sidebar.component.html',
|
||||||
styleUrl: './sidebar.component.css',
|
styleUrl: './sidebar.component.css',
|
||||||
})
|
})
|
||||||
export class SidebarComponent implements OnInit {
|
export class SidebarComponent implements OnInit {
|
||||||
// --- Abhängigkeiten mit moderner inject()-Syntax ---
|
|
||||||
private storageService = inject(StorageService);
|
private storageService = inject(StorageService);
|
||||||
|
|
||||||
private readonly sidebarCollapsedKey = 'app-sidebar-collapsed';
|
private readonly sidebarCollapsedKey = 'app-sidebar-collapsed';
|
||||||
|
|
||||||
public isCollapsed = false;
|
public isCollapsed = false;
|
||||||
|
|
||||||
activeRoute = 'dashboard';
|
|
||||||
constructor(private router: Router) {}
|
constructor(private router: Router) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadCollapsedState();
|
this.loadCollapsedState();
|
||||||
}
|
}
|
||||||
|
|
||||||
setActive(route: string): void {
|
|
||||||
this.activeRoute = route;
|
|
||||||
this.router.navigateByUrl('/shop/' + route);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSidebar(): void {
|
toggleSidebar(): void {
|
||||||
this.isCollapsed = !this.isCollapsed;
|
this.isCollapsed = !this.isCollapsed;
|
||||||
this.saveCollapsedState();
|
this.saveCollapsedState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadCollapsedState(): void {
|
private loadCollapsedState(): void {
|
||||||
// Der Service kümmert sich um die Browser-Prüfung und gibt boolean oder null zurück
|
this.isCollapsed = this.storageService.getItem<boolean>(this.sidebarCollapsedKey) ?? false;
|
||||||
this.isCollapsed =
|
|
||||||
this.storageService.getItem<boolean>(this.sidebarCollapsedKey) ?? false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveCollapsedState(): void {
|
private saveCollapsedState(): void {
|
||||||
// Der Service kümmert sich um die Serialisierung des booleans
|
|
||||||
this.storageService.setItem(this.sidebarCollapsedKey, this.isCollapsed);
|
this.storageService.setItem(this.sidebarCollapsedKey, this.isCollapsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -49,3 +49,5 @@
|
|||||||
|
|
||||||
.pill-inactive { color: rgb(168, 168, 168); background-color: #ececec; border-color: #777777; }
|
.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; }
|
:host-context(body.dark-theme) .pill-inactive { color: rgb(168, 168, 168); background-color: #ececec; border-color: #777777; }
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<!-- /src/app/shared/components/ui/status-pill/status-pill.component.html -->
|
||||||
|
|
||||||
<div class="status-pill" [ngClass]="cssClass">
|
<div class="status-pill" [ngClass]="cssClass">
|
||||||
{{ displayText }}
|
<span>{{ displayText }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,24 +1,35 @@
|
|||||||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
// /src/app/shared/components/ui/status-pill/status-pill.component.ts
|
||||||
|
|
||||||
|
import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
|
||||||
import { CommonModule, NgClass } from '@angular/common';
|
import { CommonModule, NgClass } from '@angular/common';
|
||||||
// import { OrderStatus } from '../../../../core/types/order';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-status-pill',
|
selector: 'app-status-pill',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgClass],
|
imports: [CommonModule, NgClass], // IconComponent hinzufügen
|
||||||
templateUrl: './status-pill.component.html',
|
templateUrl: './status-pill.component.html',
|
||||||
styleUrl: './status-pill.component.css'
|
styleUrls: ['./status-pill.component.css']
|
||||||
})
|
})
|
||||||
export class StatusPillComponent implements OnChanges {
|
export class StatusPillComponent implements OnChanges {
|
||||||
// Nimmt jetzt den neuen, sprechenden Status entgegen
|
// --- INPUTS ---
|
||||||
|
// Nimmt den Status für die Farbe entgegen
|
||||||
@Input() status: string | boolean = 'info';
|
@Input() status: string | boolean = 'info';
|
||||||
|
|
||||||
// Diese Eigenschaften werden vom Template verwendet
|
// NEU: Nimmt einen expliziten Text entgegen. Hat Vorrang vor dem Status-Text.
|
||||||
|
@Input() text?: string;
|
||||||
|
|
||||||
|
// NEU: Steuert, ob der "Entfernen"-Button angezeigt wird
|
||||||
|
@Input() removable = false;
|
||||||
|
|
||||||
|
// --- OUTPUT ---
|
||||||
|
// NEU: Wird ausgelöst, wenn der Entfernen-Button geklickt wird
|
||||||
|
@Output() remove = new EventEmitter<void>();
|
||||||
|
|
||||||
|
// --- Interne Eigenschaften für das Template ---
|
||||||
public displayText = '';
|
public displayText = '';
|
||||||
public cssClass = '';
|
public cssClass = '';
|
||||||
|
|
||||||
// Eine Map, die Statusnamen auf Text und CSS-Klasse abbildet
|
private statusMap = new Map<any, { text: string, css: string }>([
|
||||||
private statusMap = new Map<any, { text: string, css: string }>([
|
|
||||||
['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' }],
|
||||||
@@ -27,17 +38,25 @@ export class StatusPillComponent implements OnChanges {
|
|||||||
['inactive', { text: 'Nein', css: 'pill-inactive' }]
|
['inactive', { text: 'Nein', css: 'pill-inactive' }]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
if (changes['status']) {
|
if (changes['status'] || changes['text']) {
|
||||||
let statusKey = this.status;
|
let statusKey = this.status;
|
||||||
|
|
||||||
|
// Konvertiere boolean in einen String-Key
|
||||||
if (typeof statusKey === 'boolean') {
|
if (typeof statusKey === 'boolean') {
|
||||||
statusKey = statusKey ? 'active' : 'inactive';
|
statusKey = statusKey ? 'active' : 'inactive';
|
||||||
}
|
}
|
||||||
|
|
||||||
const details = this.statusMap.get(statusKey as string) || { text: statusKey.toString(), css: 'pill-secondary' };
|
const details = this.statusMap.get(statusKey as string) || { text: 'Info', css: 'pill-secondary' };
|
||||||
this.displayText = details.text;
|
|
||||||
|
// NEUE LOGIK: Wenn ein expliziter Text übergeben wird, hat dieser Vorrang
|
||||||
|
this.displayText = this.text ?? details.text;
|
||||||
this.cssClass = details.css;
|
this.cssClass = details.css;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Methode, die das Event auslöst
|
||||||
|
onRemove(): void {
|
||||||
|
this.remove.emit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user