Compare commits

15 Commits

Author SHA1 Message Date
Tizian.Breuch
ec6e6bdd7a models 2025-12-04 13:43:45 +01:00
Webtree-design
05c2b6b5c9 regestry 2025-12-04 11:33:46 +01:00
Tizian.Breuch
dfe631edf6 shipping methods 2025-11-28 11:18:42 +01:00
Tizian.Breuch
ac42f8b1b9 main image set 2025-11-26 08:26:28 +01:00
Tizian.Breuch
c10e6b4faa images works 2025-11-25 11:24:44 +01:00
Tizian.Breuch
2491b0142d good image bearbeiten muss noch 2025-11-06 16:52:34 +01:00
Tizian.Breuch
7511596b11 good i guess 2025-11-06 16:23:39 +01:00
Tizian.Breuch
8df2420aa0 good 2025-10-29 18:54:23 +01:00
Tizian.Breuch
9173f9b625 good 2025-10-29 18:34:59 +01:00
Tizian.Breuch
b1b1c3173b ok 2025-10-29 14:04:14 +01:00
Tizian.Breuch
1585129e1f pipeline nur auf build bracnch triggern 2025-10-29 11:51:13 +01:00
Tizian.Breuch
05bedfedfb table and new product 2025-10-29 11:50:48 +01:00
Tizian.Breuch
4549149e48 products 2025-10-29 11:41:15 +01:00
Tizian.Breuch
fd68b47414 ok 2025-10-24 14:35:07 +02:00
Tizian.Breuch
1ec7ac6ccc localstorage service u. auslagerung 2025-10-24 14:11:54 +02:00
54 changed files with 8041 additions and 1882 deletions

View File

@@ -3,7 +3,7 @@ name: Build and Push Docker Image
on:
push:
branches:
- master
- build
jobs:
build-and-push:

5736
imageupdatefehler.txt Normal file

File diff suppressed because it is too large Load Diff

38
package-lock.json generated
View File

@@ -746,9 +746,9 @@
}
},
"node_modules/@angular/ssr": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-19.2.17.tgz",
"integrity": "sha512-9ABOYrHrCYnOiihkeHvN+QIaIN+1Js4QfT4cXAtZs/f+yFaURGre7yvyK1KncMBKwxXzjcqvu1QquFMU/m/JLw==",
"version": "19.2.19",
"resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-19.2.19.tgz",
"integrity": "sha512-7HqC3K99DdzDakB/4mkqGqY6REQNMxskU1VVkH9D7SthZSuxhWIMVBojVhBDd+JOUYiyQlwEGMBevbrgbtfKlQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -4262,9 +4262,9 @@
}
},
"node_modules/@npmcli/package-json/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -6658,9 +6658,9 @@
}
},
"node_modules/cacache/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -9796,9 +9796,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11437,9 +11437,9 @@
"optional": true
},
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz",
"integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==",
"dev": true,
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
@@ -14075,10 +14075,10 @@
}
},
"node_modules/tar": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz",
"integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==",
"license": "ISC",
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz",
"integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",

1
public/icons/filter.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#666666"><path d="M440-160q-17 0-28.5-11.5T400-200v-240L168-736q-15-20-4.5-42t36.5-22h560q26 0 36.5 22t-4.5 42L560-440v240q0 17-11.5 28.5T520-160h-80Zm40-308 198-252H282l198 252Zm0 0Z"/></svg>

After

Width:  |  Height:  |  Size: 290 B

1
public/icons/menu.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#666666"><path d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/></svg>

After

Width:  |  Height:  |  Size: 193 B

1
public/icons/plus.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#666666"><path d="M440-440H200v-80h240v-240h80v240h240v80H520v240h-80v-240Z"/></svg>

After

Width:  |  Height:  |  Size: 182 B

View File

@@ -1,53 +1,53 @@
import { Component, OnInit, Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, CommonModule } from '@angular/common'; // isPlatformBrowser importieren
// /src/app/core/components/cookie-consent/cookie-consent.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StorageService } from '../../services/storage.service'; // <-- NEUER IMPORT
// Ein Enum für die verschiedenen Zustände ist typsicherer als reine Strings
type ConsentStatus = 'accepted' | 'declined';
@Component({
selector: 'app-cookie-consent',
standalone: true, // <-- standalone: true hinzufügen, falls es fehlt
imports: [CommonModule],
templateUrl: './cookie-consent.component.html',
styleUrl: './cookie-consent.component.css'
})
export class CookieConsentComponent implements OnInit {
// --- Abhängigkeiten mit moderner inject()-Syntax ---
private storageService = inject(StorageService);
isVisible = false;
private readonly consentKey = 'cookie_consent_status';
private isBrowser: boolean;
// Wir injizieren PLATFORM_ID, um die aktuelle Plattform (Browser/Server) zu ermitteln.
constructor(@Inject(PLATFORM_ID) private platformId: Object) {
// Speichere das Ergebnis in einer Eigenschaft, um es wiederverwenden zu können.
this.isBrowser = isPlatformBrowser(this.platformId);
}
// Der Konstruktor wird viel sauberer oder kann ganz entfallen
constructor() {}
ngOnInit(): void {
// Wir führen die Prüfung nur aus, wenn der Code im Browser läuft.
if (this.isBrowser) {
// Der Service kümmert sich um die Browser-Prüfung.
// Wir können ihn einfach immer aufrufen.
this.checkConsent();
}
}
private checkConsent(): void {
const consent = localStorage.getItem(this.consentKey);
const consent = this.storageService.getItem<ConsentStatus>(this.consentKey);
// Das Banner wird nur angezeigt, wenn noch gar nichts gespeichert wurde (consent ist null)
if (!consent) {
this.isVisible = true;
}
}
accept(): void {
// Wir stellen sicher, dass wir localStorage nur im Browser schreiben.
if (this.isBrowser) {
localStorage.setItem(this.consentKey, 'accepted');
// Der Service kümmert sich um die Browser-Prüfung und Serialisierung.
this.storageService.setItem(this.consentKey, 'accepted');
this.isVisible = false;
console.log('Cookies wurden akzeptiert.');
}
}
decline(): void {
// Wir stellen sicher, dass wir localStorage nur im Browser schreiben.
if (this.isBrowser) {
localStorage.setItem(this.consentKey, 'declined');
this.storageService.setItem(this.consentKey, 'declined');
this.isVisible = false;
console.log('Cookies wurden abgelehnt.');
}
}
}

View File

@@ -19,6 +19,9 @@ export interface CreateAddress {
postalCode: string;
country: string;
type: AddressType;
// FEHLTEN:
firstName: string;
lastName: string;
}
export interface UpdateAddress extends CreateAddress {

View File

@@ -34,6 +34,9 @@ export interface OrderDetail {
totalAmount: number;
shippingAddress: Address;
billingAddress: Address;
shippingAddressId?: string;
billingAddressId?: string;
paymentMethodId?: string;
paymentMethod?: string;
shippingTrackingNumber?: string;
shippedDate?: string;

View File

@@ -41,4 +41,5 @@ export interface AdminProduct {
images?: ProductImage[];
isFeatured: boolean;
featuredDisplayOrder: number;
rowVersion?: string;
}

View 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[] };
}

View File

@@ -6,4 +6,7 @@ export interface ShippingMethod {
isActive: boolean;
minDeliveryDays: number;
maxDeliveryDays: number;
// NEU: Gewichtsgrenzen
minWeight: number;
maxWeight: number;
}

View File

@@ -8,6 +8,9 @@ export interface User {
lastActive?: string;
firstName?: string;
lastName?: string;
phoneNumber?: string;
defaultShippingAddressId?: string;
defaultBillingAddressId?: string;
}
export interface UpdateUserRolesRequest {

View File

@@ -1,17 +1,18 @@
// /src/app/core/services/auth.service.ts
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { isPlatformBrowser } from '@angular/common';
import { Router } from '@angular/router';
import { Observable, BehaviorSubject, of } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
import { jwtDecode } from 'jwt-decode';
// Eigene Imports
import { LoginRequest, AuthResponse, RegisterRequest } from '../models/auth.models';
import { API_URL } from '../tokens/api-url.token';
import { StorageService } from './storage.service';
// Ein Hilfs-Interface, um die Struktur des dekodierten Tokens typsicher zu machen.
// Hilfs-Interface für die dekodierten Token-Daten.
interface DecodedToken {
exp: number; // Expiration time als UNIX-Timestamp in Sekunden
// Hier könnten weitere Claims wie 'email', 'sub', 'role' etc. stehen
@@ -21,24 +22,22 @@ interface DecodedToken {
providedIn: 'root'
})
export class AuthService {
// --- Injizierte Abhängigkeiten mit moderner Syntax ---
// --- Injizierte Abhängigkeiten ---
private http = inject(HttpClient);
private router = inject(Router);
private platformId = inject(PLATFORM_ID);
private storageService = inject(StorageService); // <-- NEU
private apiUrl = inject(API_URL);
private readonly endpoint = '/Auth';
private readonly TOKEN_KEY = 'auth-token';
private readonly ROLES_KEY = 'auth-user-roles';
private loggedInStatus = new BehaviorSubject<boolean>(this.isBrowser() && !!this.getToken());
private loggedInStatus = new BehaviorSubject<boolean>(!!this.getToken());
public isLoggedIn$ = this.loggedInStatus.asObservable();
private tokenExpirationTimer: any;
constructor() {
// Beim Initialisieren des Services prüfen, ob bereits ein Token vorhanden ist
// und ggf. den Logout-Timer dafür starten (z.B. nach einem Neuladen der Seite).
this.initTokenCheck();
}
@@ -47,7 +46,7 @@ export class AuthService {
tap(response => {
if (response?.isAuthSuccessful) {
this.setSession(response);
this.startTokenExpirationTimer(); // Timer nach erfolgreichem Login starten
this.startTokenExpirationTimer();
}
}),
catchError(() => {
@@ -62,7 +61,7 @@ export class AuthService {
tap(response => {
if (response?.isAuthSuccessful) {
this.setSession(response);
this.startTokenExpirationTimer(); // Timer nach erfolgreichem Login starten
this.startTokenExpirationTimer();
}
}),
catchError(() => {
@@ -80,7 +79,6 @@ export class AuthService {
logout(): void {
this.clearSession();
// Den proaktiven Timer stoppen, da der Logout manuell erfolgt.
if (this.tokenExpirationTimer) {
clearTimeout(this.tokenExpirationTimer);
}
@@ -88,13 +86,11 @@ export class AuthService {
}
getToken(): string | null {
return this.isBrowser() ? localStorage.getItem(this.TOKEN_KEY) : null;
return this.storageService.getItem<string>(this.TOKEN_KEY);
}
getUserRoles(): string[] {
if (!this.isBrowser()) return [];
const roles = localStorage.getItem(this.ROLES_KEY);
return roles ? JSON.parse(roles) : [];
return this.storageService.getItem<string[]>(this.ROLES_KEY) || [];
}
hasRole(requiredRole: string): boolean {
@@ -102,25 +98,19 @@ export class AuthService {
}
private setSession(authResponse: AuthResponse): void {
if (this.isBrowser() && authResponse?.token && authResponse?.roles) {
localStorage.setItem(this.TOKEN_KEY, authResponse.token);
localStorage.setItem(this.ROLES_KEY, JSON.stringify(authResponse.roles));
if (authResponse?.token && authResponse?.roles) {
this.storageService.setItem(this.TOKEN_KEY, authResponse.token);
this.storageService.setItem(this.ROLES_KEY, authResponse.roles);
this.loggedInStatus.next(true);
}
}
private clearSession(): void {
if (this.isBrowser()) {
localStorage.removeItem(this.TOKEN_KEY);
localStorage.removeItem(this.ROLES_KEY);
}
this.storageService.removeItem(this.TOKEN_KEY);
this.storageService.removeItem(this.ROLES_KEY);
this.loggedInStatus.next(false);
}
private isBrowser(): boolean {
return isPlatformBrowser(this.platformId);
}
private startTokenExpirationTimer(): void {
if (this.tokenExpirationTimer) {
clearTimeout(this.tokenExpirationTimer);
@@ -133,18 +123,21 @@ export class AuthService {
try {
const decodedToken = jwtDecode<DecodedToken>(token);
const expirationDate = new Date(decodedToken.exp * 1000); // JWT 'exp' ist in Sekunden, Date() braucht Millisekunden
const expirationDate = new Date(decodedToken.exp * 1000);
const timeoutDuration = expirationDate.getTime() - new Date().getTime();
if (timeoutDuration > 0) {
// Puffer, um Clock-Skew-Probleme zu vermeiden
const clockSkewBuffer = 5000; // 5 Sekunden
if (timeoutDuration > clockSkewBuffer) {
this.tokenExpirationTimer = setTimeout(() => {
console.warn('Sitzung proaktiv beendet, da das Token abgelaufen ist.');
this.logout();
// Hier könnte man eine Snackbar-Nachricht anzeigen
}, timeoutDuration);
} else {
// Das gespeicherte Token ist bereits abgelaufen
this.clearSession();
if (this.getToken()) {
this.logout();
}
}
} catch (error) {
console.error('Fehler beim Dekodieren des Tokens. Session wird bereinigt.', error);
@@ -153,8 +146,7 @@ export class AuthService {
}
private initTokenCheck(): void {
if (this.isBrowser()) {
// Der StorageService ist bereits SSR-sicher, wir brauchen hier keine extra Prüfung.
this.startTokenExpirationTimer();
}
}
}

View File

@@ -0,0 +1,87 @@
// /src/app/core/services/storage.service.ts
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
// Definiert die verfügbaren Speichertypen
export type StorageType = 'localStorage' | 'sessionStorage';
@Injectable({
providedIn: 'root'
})
export class StorageService {
private platformId = inject(PLATFORM_ID);
/**
* Speichert einen Wert im angegebenen Web Storage.
* Serialisiert Objekte automatisch zu JSON.
* @param key Der Schlüssel, unter dem der Wert gespeichert wird.
* @param value Der zu speichernde Wert.
* @param storageType Der zu verwendende Speicher ('localStorage' oder 'sessionStorage'). Standard ist 'localStorage'.
*/
setItem<T>(key: string, value: T, storageType: StorageType = 'localStorage'): void {
if (isPlatformBrowser(this.platformId)) {
try {
const storage = this.getStorage(storageType);
const serializedValue = JSON.stringify(value);
storage.setItem(key, serializedValue);
} catch (e) {
console.error(`Error saving to ${storageType} with key "${key}"`, e);
}
}
}
/**
* Ruft einen Wert aus dem angegebenen Web Storage ab.
* Deserialisiert JSON-Strings automatisch in Objekte.
* @param key Der Schlüssel des abzurufenden Wertes.
* @param storageType Der zu verwendende Speicher ('localStorage' oder 'sessionStorage'). Standard ist 'localStorage'.
* @returns Der abgerufene Wert (typsicher) oder null, wenn der Schlüssel nicht existiert oder ein Fehler auftritt.
*/
getItem<T>(key: string, storageType: StorageType = 'localStorage'): T | null {
if (isPlatformBrowser(this.platformId)) {
try {
const storage = this.getStorage(storageType);
const serializedValue = storage.getItem(key);
if (serializedValue === null) {
return null;
}
return JSON.parse(serializedValue) as T;
} catch (e) {
console.error(`Error reading from ${storageType} with key "${key}"`, e);
return null;
}
}
return null;
}
/**
* Entfernt einen Wert aus dem angegebenen Web Storage.
* @param key Der Schlüssel des zu entfernenden Wertes.
* @param storageType Der zu verwendende Speicher ('localStorage' oder 'sessionStorage'). Standard ist 'localStorage'.
*/
removeItem(key: string, storageType: StorageType = 'localStorage'): void {
if (isPlatformBrowser(this.platformId)) {
const storage = this.getStorage(storageType);
storage.removeItem(key);
}
}
/**
* Löscht alle Einträge im angegebenen Web Storage.
* @param storageType Der zu leerende Speicher ('localStorage' oder 'sessionStorage'). Standard ist 'localStorage'.
*/
clear(storageType: StorageType = 'localStorage'): void {
if (isPlatformBrowser(this.platformId)) {
const storage = this.getStorage(storageType);
storage.clear();
}
}
/**
* Private Hilfsfunktion, um das korrekte Storage-Objekt zurückzugeben.
*/
private getStorage(storageType: StorageType): Storage {
return storageType === 'localStorage' ? window.localStorage : window.sessionStorage;
}
}

View File

@@ -66,6 +66,30 @@ export class DashboardPageComponent {
amount: '€87.00',
status: 'info', // NEU: Sprechender Status
},
{
id: 'a2d4b',
user: { name: 'Max Mustermann', email: 'max@test.de', avatarUrl: 'https://i.pravatar.cc/150?u=max' },
amount: '€129.99',
status: 'completed', // NEU: Sprechender Status
},
{
id: 'f8e9c',
user: { name: 'Erika Musterfrau', email: 'erika@test.de', avatarUrl: 'https://i.pravatar.cc/150?u=erika' },
amount: '€49.50',
status: 'processing', // NEU: Sprechender Status
},
{
id: 'h1g3j',
user: { name: 'John Doe', email: 'john.d@test.com', avatarUrl: 'https://i.pravatar.cc/150?u=john' },
amount: '€87.00',
status: 'cancelled', // NEU: Sprechender Status
},
{
id: 'h1g3j',
user: { name: 'John Doe', email: 'john.d@test.com', avatarUrl: 'https://i.pravatar.cc/150?u=john' },
amount: '€87.00',
status: 'info', // NEU: Sprechender Status
},
];
handleDeleteOrder(orderId: string): void {

View File

@@ -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); }
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,25 @@
<!-- /src/app/features/admin/components/products/product-edit/product-edit.component.html -->
<div *ngIf="isLoading; else formContent" class="loading-container">
<p>Lade Produktdaten...</p>
</div>
<ng-template #formContent>
<div class="page-header">
<h3 card-header>Produkt bearbeiten</h3>
</div>
<app-product-form
[productForm]="productForm"
[allCategories]="(allCategories$ | async) || []"
[supplierOptions]="(supplierOptions$ | async) || []"
[allImages]="allImagesForForm"
[isLoading]="isLoading"
submitButtonText="Änderungen speichern"
(formSubmit)="onSubmit()"
(formCancel)="cancel()"
(filesSelected)="onFilesSelected($event)"
(setMainImage)="onSetMainImage($event)"
(deleteImage)="onDeleteImage($event)"
>
</app-product-form>
</ng-template>

View File

@@ -0,0 +1,300 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { DomSanitizer } from '@angular/platform-browser';
import { Observable, Subscription, of } from 'rxjs';
import { switchMap, finalize, map } from 'rxjs/operators';
import { AdminProduct, ProductImage } from '../../../../core/models/product.model';
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';
import { Supplier } from '../../../../core/models/supplier.model';
@Component({
selector: 'app-product-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, ProductFormComponent],
templateUrl: './product-edit.component.html',
styleUrls: ['./product-edit.component.css'],
})
export class ProductEditComponent implements OnInit, OnDestroy {
// --- Injected Services ---
private route = inject(ActivatedRoute);
private router = inject(Router);
private fb = inject(FormBuilder);
private productService = inject(ProductService);
private categoryService = inject(CategoryService);
private supplierService = inject(SupplierService);
private snackbarService = inject(SnackbarService);
private sanitizer = inject(DomSanitizer);
// --- Component State ---
public productForm!: FormGroup;
public isLoading = true;
private productId!: string;
private subscriptions = new Subscription();
// WICHTIG: Die RowVersion speichern wir hier, nicht im Formular
private loadedRowVersion: string | null = null;
public allCategories$!: Observable<Category[]>;
public supplierOptions$!: Observable<SelectOption[]>;
// --- Image Management ---
public allImagesForForm: ImagePreview[] = [];
private newImageFiles = new Map<string, File>();
private imagesToDelete: string[] = [];
constructor() {
this.initForm();
}
private initForm(): void {
// RowVersion nehmen wir hier raus, um Manipulationen zu verhindern
this.productForm = this.fb.group({
name: ['', Validators.required],
description: ['', Validators.required],
sku: ['', Validators.required],
price: [0, [Validators.required, Validators.min(0)]],
oldPrice: [null],
isActive: [true],
stockQuantity: [0, [Validators.required, Validators.min(0)]],
slug: ['', Validators.required],
weight: [0],
supplierId: [null],
purchasePrice: [null],
isFeatured: [false],
featuredDisplayOrder: [0],
categorieIds: [[]]
});
}
ngOnInit(): void {
this.loadInitialData();
}
private loadInitialData(): void {
this.isLoading = true;
this.allCategories$ = this.categoryService.getAll();
this.supplierOptions$ = this.supplierService.getAll().pipe(
map((suppliers: Supplier[]) =>
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) {
this.patchForm(product);
if (product.images) {
this.initializeImages(product.images);
}
}
this.isLoading = false;
},
error: () => {
this.isLoading = false;
this.snackbarService.show('Fehler beim Laden der Produktdaten.');
}
});
this.subscriptions.add(productSub);
}
private patchForm(product: AdminProduct): void {
// 1. RowVersion sicher extrahieren und trimmen
const rawVersion = product.rowVersion || (product as any).RowVersion;
this.loadedRowVersion = rawVersion ? String(rawVersion).trim() : null;
console.log('Geladene RowVersion:', this.loadedRowVersion);
// 2. Formularwerte setzen
this.productForm.patchValue({
name: product.name,
description: product.description,
sku: product.sku,
price: product.price,
oldPrice: product.oldPrice,
isActive: product.isActive,
stockQuantity: product.stockQuantity,
slug: product.slug,
weight: product.weight,
supplierId: product.supplierId,
purchasePrice: product.purchasePrice,
isFeatured: product.isFeatured,
featuredDisplayOrder: product.featuredDisplayOrder,
categorieIds: product.categorieIds || [],
});
}
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 {
this.imagesToDelete.push(identifier);
}
if (deletedImage.isMainImage && this.allImagesForForm.length > 0) {
this.allImagesForForm[0].isMainImage = true;
}
}
onSubmit(): void {
if (this.productForm.invalid) {
this.productForm.markAllAsTouched();
this.snackbarService.show('Bitte füllen Sie alle erforderlichen Felder aus.');
return;
}
this.isLoading = true;
const formData = this.createUpdateFormData();
this.productService.update(this.productId, formData).pipe(
finalize(() => this.isLoading = false)
).subscribe({
next: () => {
this.snackbarService.show('Produkt erfolgreich aktualisiert!');
this.router.navigate(['/shop/products']);
},
error: (err) => {
console.error('Update failed', 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 {
this.router.navigate(['/shop/products']);
}
ngOnDestroy(): void {
this.subscriptions.unsubscribe();
}
}

View File

@@ -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; }
}

View File

@@ -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>

View File

@@ -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
}

View File

@@ -0,0 +1,65 @@
/* /src/app/features/admin/components/products/product-list/product-list.component.css */
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
}
.page-header {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text);
margin-bottom: 1rem;
}
app-search-bar {
flex-grow: 1;
max-width: 400px;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.column-filter-container {
display: flex;
position: relative;
gap: 1.5rem;
}
.column-filter-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
box-shadow: var(--box-shadow-lg);
padding: 0.75rem;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 180px;
}
.column-filter-dropdown label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.25rem;
border-radius: var(--border-radius-sm);
white-space: nowrap;
}
.column-filter-dropdown label:hover {
background-color: var(--color-body-bg-hover);
}

View File

@@ -1,227 +1,45 @@
<div>
<h1>Produkte verwalten</h1>
<!-- /src/app/features/admin/components/products/product-list/product-list.component.html -->
<!-- Das Formular bleibt unverändert und ist bereits korrekt -->
<form [formGroup]="productForm" (ngSubmit)="onSubmit()">
<!-- ... (Dein komplettes Formular hier) ... -->
<h3>
{{ selectedProductId ? "Produkt bearbeiten" : "Neues Produkt erstellen" }}
</h3>
<h4>Basis-Informationen</h4>
<div><label>Name:</label><input type="text" formControlName="name" /></div>
<div class="page-container">
<div>
<label>Slug (automatisch generiert):</label
><input type="text" formControlName="slug" />
<h3 class="page-header">Produktübersicht</h3>
</div>
<div>
<label>SKU (Artikelnummer):</label>
<div style="display: flex; align-items: center; gap: 10px">
<div class="table-header">
<app-search-bar
placeholder="Produkte nach Name oder SKU suchen..."
(search)="onSearch($event)"
></app-search-bar>
<div class="column-filter-container">
<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">
<label *ngFor="let col of allTableColumns">
<input
id="sku"
type="text"
formControlName="sku"
style="flex-grow: 1"
/><button type="button" (click)="generateSku()">Generieren</button>
</div>
</div>
<div>
<label>Beschreibung:</label
><textarea formControlName="description" rows="5"></textarea>
</div>
<hr />
<h4>Preis & Lager</h4>
<div>
<label>Preis (€):</label><input type="number" formControlName="price" />
</div>
<div>
<label>Alter Preis (€) (optional):</label
><input type="number" formControlName="oldPrice" />
</div>
<div>
<label>Einkaufspreis (€) (optional):</label
><input type="number" formControlName="purchasePrice" />
</div>
<div>
<label>Lagerbestand:</label
><input type="number" formControlName="stockQuantity" />
</div>
<div>
<label>Gewicht (in kg) (optional):</label
><input type="number" formControlName="weight" />
</div>
<hr />
<h4>Zuweisungen</h4>
<div>
<label>Lieferant:</label
><select formControlName="supplierId">
<option [ngValue]="null">-- Kein Lieferant --</option>
<option
*ngFor="let supplier of allSuppliers$ | async"
[value]="supplier.id"
>
{{ supplier.name }}
</option>
</select>
</div>
<div>
<label>Kategorien:</label>
<div
class="category-checkbox-group"
style="
height: 100px;
overflow-y: auto;
border: 1px solid #ccc;
padding: 5px;
margin-top: 5px;
"
>
<div *ngFor="let category of allCategories$ | async">
<label
><input
type="checkbox"
[value]="category.id"
[checked]="isCategorySelected(category.id)"
(change)="onCategoryChange($event)"
/>{{ category.name }}</label
>
</div>
</div>
</div>
<hr />
<h4>Produktbilder</h4>
<div *ngIf="selectedProductId && existingImages.length > 0">
<p>Bestehende Bilder:</p>
<div
style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 15px"
>
<div *ngFor="let img of existingImages" style="position: relative">
<img
[src]="img.url"
[alt]="productForm.get('name')?.value"
style="
width: 100px;
height: 100px;
object-fit: cover;
border: 2px solid;
"
[style.borderColor]="img.isMainImage ? 'green' : 'gray'"
/><button
(click)="deleteExistingImage(img.id, $event)"
style="
position: absolute;
top: -5px;
right: -5px;
background: red;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
border: none;
cursor: pointer;
line-height: 20px;
text-align: center;
padding: 0;
"
>
X
</button>
</div>
</div>
</div>
<div>
<label for="main-image">Hauptbild (ersetzt bestehendes Hauptbild)</label
><input
id="main-image"
type="file"
(change)="onMainFileChange($event)"
accept="image/*"
[checked]="isColumnVisible(col.key)"
(change)="onColumnToggle(col, $event)"
/>
{{ col.title }}
</label>
</div>
<div>
<label for="additional-images">Zusätzliche Bilder hinzufügen</label
><input
id="additional-images"
type="file"
(change)="onAdditionalFilesChange($event)"
accept="image/*"
multiple
/>
</div>
<hr />
<h4>Status & Sichtbarkeit</h4>
<div>
<label
><input type="checkbox" formControlName="isActive" /> Aktiv (im Shop
sichtbar)</label
>
</div>
<div>
<label
><input type="checkbox" formControlName="isFeatured" /> Hervorgehoben
(z.B. auf Startseite)</label
>
</div>
<div>
<label>Anzeigereihenfolge (Hervorgehoben):</label
><input type="number" formControlName="featuredDisplayOrder" />
</div>
<br /><br />
<button type="submit" [disabled]="productForm.invalid">
{{ selectedProductId ? "Aktualisieren" : "Erstellen" }}
</button>
<button type="button" *ngIf="selectedProductId" (click)="clearSelection()">
Abbrechen
</button>
</form>
<hr />
<h2>Bestehende Produkte</h2>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr >
<th style="padding: 8px; border: 1px solid #ddd;">Bild</th>
<th style="padding: 8px; border: 1px solid #ddd;">Name</th>
<th style="padding: 8px; border: 1px solid #ddd;">SKU</th>
<th style="padding: 8px; border: 1px solid #ddd;">Preis</th>
<th style="padding: 8px; border: 1px solid #ddd;">Lagerbestand</th>
<th style="padding: 8px; border: 1px solid #ddd;">Aktiv</th>
<th style="padding: 8px; border: 1px solid #ddd;">Aktionen</th>
</tr>
</thead>
<tbody>
<ng-container *ngIf="products$ | async as products; else loading">
<tr *ngIf="products.length === 0">
<td colspan="7" style="text-align: center; padding: 16px;">Keine Produkte gefunden.</td>
</tr>
<tr *ngFor="let product of products">
<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">
<!-- +++ HIER IST DIE KORREKTUR +++ -->
<img
[src]="getMainImageUrl(product.images)"
[alt]="product.name"
style="width: 50px; height: 50px; object-fit: cover;">
</td>
<td style="padding: 8px; border: 1px solid #ddd;">
<strong>{{ product.name }}</strong><br>
<small style="color: #777;">Slug: {{ product.slug }}</small>
</td>
<td style="padding: 8px; border: 1px solid #ddd;">{{ product.sku }}</td>
<td style="padding: 8px; border: 1px solid #ddd;">{{ product.price | currency:'EUR' }}</td>
<td style="padding: 8px; border: 1px solid #ddd;">{{ product.stockQuantity }}</td>
<td style="padding: 8px; border: 1px solid #ddd; text-align: center;" [style.color]="product.isActive ? 'green' : 'red'">
{{ product.isActive ? 'Ja' : 'Nein' }}
</td>
<td style="padding: 8px; border: 1px solid #ddd; width: 150px; text-align: center;">
<button (click)="selectProduct(product)">Bearbeiten</button>
<button (click)="onDelete(product.id)" style="margin-left: 5px; background-color: #dc3545; color: white;">Löschen</button>
</td>
</tr>
</ng-container>
<ng-template #loading>
<tr>
<td colspan="7" style="text-align: center; padding: 16px;">Lade Produkte...</td>
</tr>
</ng-template>
</tbody>
</table>
<app-generic-table
[data]="filteredProducts"
[columns]="visibleTableColumns"
(edit)="onEditProduct($event.id)"
(delete)="onDeleteProduct($event.id)"
>
</app-generic-table>
</div>

View File

@@ -1,282 +1,152 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
FormBuilder,
FormGroup,
FormArray,
FormControl,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { Observable, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
// /src/app/features/admin/components/products/product-list/product-list.component.ts
// Models
import {
AdminProduct,
ProductImage,
} from '../../../../core/models/product.model';
import { Category } from '../../../../core/models/category.model';
import { Supplier } from '../../../../core/models/supplier.model';
// Services
import { Component, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { AdminProduct, ProductImage } from '../../../../core/models/product.model';
import { 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 { StorageService } from '../../../../core/services/storage.service';
import { GenericTableComponent, ColumnConfig } from '../../../../shared/components/data-display/generic-table/generic-table.component';
import { SearchBarComponent } from '../../../../shared/components/layout/search-bar/search-bar.component';
import { ButtonComponent } from '../../../../shared/components/ui/button/button.component';
@Component({
selector: 'app-product-list',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
imports: [CommonModule, GenericTableComponent, SearchBarComponent, ButtonComponent],
providers: [DatePipe],
templateUrl: './product-list.component.html',
styleUrl: './product-list.component.css'
})
export class ProductListComponent implements OnInit, OnDestroy {
export class ProductListComponent implements OnInit {
private productService = inject(ProductService);
private categoryService = inject(CategoryService);
private supplierService = inject(SupplierService);
private fb = inject(FormBuilder);
private router = inject(Router);
private snackbar = inject(SnackbarService);
private storageService = inject(StorageService);
private datePipe = inject(DatePipe);
products$!: Observable<AdminProduct[]>;
allCategories$!: Observable<Category[]>;
allSuppliers$!: Observable<Supplier[]>;
private readonly TABLE_SETTINGS_KEY = 'product-table-columns';
productForm: FormGroup;
selectedProductId: string | null = null;
private nameChangeSubscription?: Subscription;
allProducts: (AdminProduct & { mainImage?: string; supplierName?: string })[] = [];
filteredProducts: (AdminProduct & { mainImage?: string; supplierName?: string })[] = [];
isColumnFilterVisible = false;
// Eigenschaften für das Bild-Management
existingImages: ProductImage[] = [];
mainImageFile: File | null = null;
additionalImageFiles: File[] = [];
readonly allTableColumns: ColumnConfig[] = [
{ key: 'mainImage', title: 'Bild', type: 'image' },
{ key: 'name', title: 'Name', type: 'text', subKey: 'sku' },
{ key: 'price', title: 'Preis', type: 'currency', cssClass: 'text-right' },
{ key: 'stockQuantity', title: 'Lager', type: 'text', cssClass: 'text-right' },
{ key: 'supplierName', title: 'Lieferant', type: 'text' },
{ key: 'isActive', title: 'Aktiv', type: 'status' },
{ key: 'id', title: 'ID', type: 'text' },
{ key: 'description', title: 'Beschreibung', type: 'text' },
{ key: 'oldPrice', title: 'Alter Preis', type: 'currency', cssClass: 'text-right' },
{ key: 'purchasePrice', title: 'Einkaufspreis', type: 'currency', cssClass: 'text-right' },
{ key: 'isInStock', title: 'Auf Lager', type: 'status' },
{ key: 'weight', title: 'Gewicht', type: 'text', cssClass: 'text-right' },
{ key: 'slug', title: 'Slug', type: 'text' },
{ key: 'createdDate', title: 'Erstellt am', type: 'text' },
{ key: 'lastModifiedDate', title: 'Zuletzt geändert', type: 'text' },
{ key: 'supplierId', title: 'Lieferanten-ID', type: 'text' },
{ key: 'categorieIds', title: 'Kategorie-IDs', type: 'text' },
{ key: 'isFeatured', title: 'Hervorgehoben', type: 'status' },
{ key: 'featuredDisplayOrder', title: 'Anzeigereihenfolge (hervorgehoben)', type: 'number', cssClass: 'text-right' },
{ key: 'actions', title: 'Aktionen', type: 'actions', cssClass: 'text-right' }
];
visibleTableColumns: ColumnConfig[] = [];
public readonly fallbackImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNjY2NjY2MiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cmVjdCB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHg9IjMiIHk9IjMiIHJ4PSIyIiByeT0iMiIvPjxjaXJjbGUgY3g9IjkiIGN5PSI5IiByPSIyIi8+PHBhdGggZD0ibTIxIDE1LTUtNWwtNSA1bC0yLTJsLTUgNSIvPjwvc3ZnPg==';
constructor() {
this.productForm = this.fb.group({
name: ['', Validators.required],
slug: ['', Validators.required],
sku: ['', Validators.required],
description: [''],
price: [0, [Validators.required, Validators.min(0)]],
oldPrice: [null, [Validators.min(0)]],
purchasePrice: [null, [Validators.min(0)]],
stockQuantity: [0, [Validators.required, Validators.min(0)]],
weight: [null, [Validators.min(0)]],
isActive: [true],
isFeatured: [false],
featuredDisplayOrder: [0],
supplierId: [null],
categorieIds: this.fb.array([]),
imagesToDelete: this.fb.array([]), // FormArray für die IDs der zu löschenden Bilder
});
}
// Getter für einfachen Zugriff auf FormArrays
get categorieIds(): FormArray {
return this.productForm.get('categorieIds') as FormArray;
}
get imagesToDelete(): FormArray {
return this.productForm.get('imagesToDelete') as FormArray;
this.loadTableSettings();
}
ngOnInit(): void {
this.loadInitialData();
this.subscribeToNameChanges();
this.loadProducts();
}
ngOnDestroy(): void {
this.nameChangeSubscription?.unsubscribe();
}
loadInitialData(): void {
this.products$ = this.productService.getAll();
this.allCategories$ = this.categoryService.getAll();
this.allSuppliers$ = this.supplierService.getAll();
}
selectProduct(product: AdminProduct): void {
this.selectedProductId = product.id;
this.productForm.patchValue(product);
this.categorieIds.clear();
product.categorieIds?.forEach((id) =>
this.categorieIds.push(this.fb.control(id))
);
this.existingImages = product.images || [];
}
clearSelection(): void {
this.selectedProductId = null;
this.productForm.reset({
name: '',
slug: '',
sku: '',
description: '',
price: 0,
oldPrice: null,
purchasePrice: null,
stockQuantity: 0,
weight: null,
isActive: true,
isFeatured: false,
featuredDisplayOrder: 0,
supplierId: null,
loadProducts(): void {
this.productService.getAll().subscribe(products => {
this.supplierService.getAll().subscribe(suppliers => {
this.allProducts = products.map(p => {
const supplier = suppliers.find(s => s.id === p.supplierId);
return {
...p,
mainImage: this.getMainImageUrl(p.images),
supplierName: supplier?.name || '-',
createdDate: this.datePipe.transform(p.createdDate, 'dd.MM.yyyy HH:mm') || '-',
};
});
this.onSearch('');
});
});
this.categorieIds.clear();
this.imagesToDelete.clear();
this.existingImages = [];
this.mainImageFile = null;
this.additionalImageFiles = [];
}
onMainFileChange(event: Event): void {
const file = (event.target as HTMLInputElement).files?.[0];
if (file) this.mainImageFile = file;
}
onAdditionalFilesChange(event: Event): void {
const files = (event.target as HTMLInputElement).files;
if (files) this.additionalImageFiles = Array.from(files);
}
deleteExistingImage(imageId: string, event: Event): void {
event.preventDefault();
this.imagesToDelete.push(this.fb.control(imageId));
this.existingImages = this.existingImages.filter(
(img) => img.id !== imageId
onSearch(term: string): void {
const lowerTerm = term.toLowerCase();
this.filteredProducts = this.allProducts.filter(p =>
p.name?.toLowerCase().includes(lowerTerm) ||
p.sku?.toLowerCase().includes(lowerTerm)
);
}
onCategoryChange(event: Event): void {
onAddNew(): void {
this.router.navigate(['/shop/products/create']);
}
onEditProduct(productId: string): void {
this.router.navigate(['/shop/products/edit', productId]);
}
onDeleteProduct(productId: string): void {
if (confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
this.productService.delete(productId).subscribe(() => {
this.snackbar.show('Produkt erfolgreich gelöscht.');
this.loadProducts();
});
}
}
private loadTableSettings(): void {
const savedKeys = this.storageService.getItem<string[]>(this.TABLE_SETTINGS_KEY);
const defaultKeys = ['mainImage', 'name', 'price', 'stockQuantity', 'isActive', 'actions'];
const keysToUse = (savedKeys && savedKeys.length > 0) ? savedKeys : defaultKeys;
this.visibleTableColumns = this.allTableColumns.filter(c => keysToUse.includes(c.key));
}
private saveTableSettings(): void {
const visibleKeys = this.visibleTableColumns.map(c => c.key);
this.storageService.setItem(this.TABLE_SETTINGS_KEY, visibleKeys);
}
toggleColumnFilter(): void {
this.isColumnFilterVisible = !this.isColumnFilterVisible;
}
isColumnVisible(columnKey: string): boolean {
return this.visibleTableColumns.some(c => c.key === columnKey);
}
onColumnToggle(column: ColumnConfig, 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
this.visibleTableColumns.push(column);
this.visibleTableColumns.sort((a, b) =>
this.allTableColumns.findIndex(c => c.key === a.key) -
this.allTableColumns.findIndex(c => c.key === b.key)
);
if (index !== -1) {
this.categorieIds.removeAt(index);
}
}
}
isCategorySelected(categoryId: string): boolean {
return this.categorieIds.value.includes(categoryId);
}
generateSku(): void {
const name = this.productForm.get('name')?.value || 'PROD';
const prefix = name.substring(0, 4).toUpperCase().replace(/\s+/g, '');
const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase();
const sku = `${prefix}-${randomPart}`;
this.productForm.get('sku')?.setValue(sku);
}
onSubmit(): void {
if (this.productForm.invalid) return;
const formData = new FormData();
const formValue = this.productForm.value;
Object.keys(formValue).forEach((key) => {
const value = formValue[key];
if (key === 'categorieIds' || key === 'imagesToDelete') {
// FormArrays müssen speziell behandelt werden
(value as string[]).forEach((id) =>
formData.append(this.capitalizeFirstLetter(key), id)
);
} else if (value !== null && value !== undefined && value !== '') {
// Leere Strings für optionale number-Felder nicht mitsenden
if (
['oldPrice', 'purchasePrice', 'weight'].includes(key) &&
value === ''
)
return;
formData.append(this.capitalizeFirstLetter(key), value);
}
});
if (this.mainImageFile) {
formData.append('MainImageFile', this.mainImageFile);
}
this.additionalImageFiles.forEach((file) => {
formData.append('AdditionalImageFiles', file);
});
if (this.selectedProductId) {
formData.append('Id', this.selectedProductId);
this.productService
.update(this.selectedProductId, formData)
.subscribe(() => this.reset());
} else {
this.productService.create(formData).subscribe(() => this.reset());
this.visibleTableColumns = this.visibleTableColumns.filter(c => c.key !== column.key);
}
this.saveTableSettings();
}
onDelete(id: string): void {
if (confirm('Produkt wirklich löschen?')) {
this.productService.delete(id).subscribe(() => this.loadInitialData());
}
}
private reset(): void {
this.loadInitialData();
this.clearSelection();
}
private subscribeToNameChanges(): void {
this.nameChangeSubscription = this.productForm
.get('name')
?.valueChanges.pipe(debounceTime(300), distinctUntilChanged())
.subscribe((name) => {
if (name && !this.productForm.get('slug')?.dirty) {
const slug = this.generateSlug(name);
this.productForm.get('slug')?.setValue(slug);
}
});
}
private generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[äöüß]/g, (char) => {
switch (char) {
case 'ä':
return 'ae';
case 'ö':
return 'oe';
case 'ü':
return 'ue';
case 'ß':
return 'ss';
default:
return '';
}
})
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-');
}
private capitalizeFirstLetter(string: string): string {
return string.charAt(0).toUpperCase() + string.slice(1);
}
/**
* Sucht das Hauptbild aus der Bilderliste eines Produkts und gibt dessen URL zurück.
* Gibt eine Platzhalter-URL zurück, wenn kein Hauptbild gefunden wird.
* @param images Die Liste der Produktbilder.
* @returns Die URL des Hauptbildes oder eine Platzhalter-URL.
*/
getMainImageUrl(images?: ProductImage[]): string {
if (!images || images.length === 0) {
return ''; // Platzhalter, wenn gar keine Bilder vorhanden sind
}
if (!images || images.length === 0) return this.fallbackImage;
const mainImage = images.find(img => img.isMainImage);
return mainImage?.url || images[0].url || ''; // Fallback auf das erste Bild, wenn kein Hauptbild markiert ist
return mainImage?.url || images[0]?.url || this.fallbackImage;
}
}

View File

@@ -1,10 +1,24 @@
// /src/app/features/admin/components/products/products.routes.ts
import { Routes } from '@angular/router';
import { ProductListComponent } from './product-list/product-list.component';
import { ProductEditComponent } from './product-edit/product-edit.component';
import { ProductCreateComponent } from './product-create/product-create.component';
export const PRODUCTS_ROUTES: Routes = [
{
path: '',
component: ProductListComponent,
title: '',
title: 'Produktübersicht',
},
{
path: 'create',
component: ProductCreateComponent,
title: 'Product | Create',
},
{
path: 'edit/:id',
component: ProductEditComponent,
title: 'Product | Edit',
},
];

View File

@@ -22,7 +22,7 @@
<input type="number" formControlName="cost" placeholder="Kosten" />
</div>
<!-- +++ NEUE FELDER HINZUGEFÜGT +++ -->
<div style="display: flex; gap: 10px;">
<div>
<label>Minimale Liefertage:</label>
<input
@@ -40,9 +40,33 @@
placeholder="z.B. 3"
/>
</div>
<!-- +++ ENDE NEU +++ -->
</div>
<!-- +++ NEUE GEWICHTS FELDER +++ -->
<div style="display: flex; gap: 10px; margin-top: 10px;">
<div>
<label>Gewicht von (kg):</label>
<input
type="number"
formControlName="minWeight"
placeholder="0"
step="0.01"
/>
</div>
<div>
<label>Gewicht bis (kg):</label>
<input
type="number"
formControlName="maxWeight"
placeholder="10"
step="0.01"
/>
</div>
</div>
<!-- +++ ENDE NEU +++ -->
<div style="margin-top: 10px;">
<label><input type="checkbox" formControlName="isActive" /> Aktiv</label>
</div>
@@ -55,16 +79,26 @@
Abbrechen
</button>
</form>
<hr />
<h2>Bestehende Methoden</h2>
<ul>
<li *ngFor="let method of methods$ | async">
{{ method.name }}
({{ method.cost | currency : "EUR" }}) - Lieferzeit:
{{ method.minDeliveryDays }}-{{ method.maxDeliveryDays }} Tage - Aktiv:
{{ method.isActive }}
<li *ngFor="let method of methods$ | async" style="margin-bottom: 10px; border-bottom: 1px solid #ccc; padding-bottom: 5px;">
<strong>{{ method.name }}</strong> ({{ method.cost | currency : "EUR" }}) <br/>
<!-- Anzeige der Details -->
<small>
Lieferzeit: {{ method.minDeliveryDays }}-{{ method.maxDeliveryDays }} Tage |
<!-- NEU: Gewichtsanzeige -->
Gewicht: {{ method.minWeight }}kg - {{ method.maxWeight }}kg |
Aktiv: {{ method.isActive ? 'Ja' : 'Nein' }}
</small>
<div style="margin-top: 5px;">
<button (click)="selectMethod(method)">Bearbeiten</button>
<button (click)="onDelete(method.id)">Löschen</button>
</div>
</li>
</ul>
</div>

View File

@@ -26,7 +26,10 @@ export class ShippingMethodListComponent implements OnInit {
cost: [0, [Validators.required, Validators.min(0)]],
isActive: [true],
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,
isActive: true,
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 {
if (this.methodForm.invalid) return;
// Das Formular-Objekt hat bereits die richtige Struktur, die das Backend erwartet.
const dataToSend: ShippingMethod = {
id: this.selectedMethodId || '00000000-0000-0000-0000-000000000000',
...this.methodForm.value
@@ -66,7 +70,6 @@ export class ShippingMethodListComponent implements OnInit {
this.shippingMethodService.create(dataToSend).subscribe(() => this.reset());
}
}
// --- ENDE KORREKTUR ---
onDelete(id: string): void {
if (confirm('Versandmethode wirklich löschen?')) {

View File

@@ -0,0 +1,99 @@
/* /src/app/shared/components/table/generic-table/generic-table.component.css */
:host {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.table-container {
overflow-x: auto;
flex-grow: 1;
min-width: 0;
}
.modern-table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
}
.modern-table thead th {
padding: 0.75rem 1.5rem;
color: var(--color-text-light);
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
text-align: left;
border-bottom: 2px solid var(--color-border);
}
.modern-table tbody tr {
transition: background-color var(--transition-speed);
border-bottom: 1px solid var(--color-border);
}
.modern-table tbody tr:last-of-type {
border-bottom: none;
}
.modern-table tbody tr:hover {
background-color: var(--color-body-bg-hover);
}
.modern-table tbody td {
padding: 1rem 1.5rem;
vertical-align: middle;
}
/* Spezifische Zell-Stile */
.user-cell {
display: flex;
align-items: center;
gap: 1rem;
}
.user-cell img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.user-name {
font-weight: 600;
color: var(--color-text);
}
.user-email {
font-size: 0.9rem;
color: var(--color-text-light);
}
.amount {
font-weight: 600;
}
.mono {
font-family: "Courier New", Courier, monospace;
}
/* Verwendet die von dir definierte Klasse für die rechten Aktionen */
.actions-cell {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
/* Hilfsklasse für rechtsbündigen Text */
.text-right {
text-align: right;
}
.no-data-cell {
text-align: center;
padding: 2rem;
color: var(--color-text-light);
}

View File

@@ -0,0 +1,77 @@
<!-- /src/app/shared/components/table/generic-table/generic-table.component.html -->
<div class="table-container">
<table class="modern-table">
<thead>
<tr>
<th *ngFor="let col of columns" [ngClass]="col.cssClass">{{ col.title }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of displayedData">
<td *ngFor="let col of columns" [ngClass]="col.cssClass">
<ng-container [ngSwitch]="col.type">
<ng-container *ngSwitchCase="'text'">
<div>
<span [class.mono]="col.key === 'id' || col.key === 'orderNumber' || col.key === 'sku'">
{{ getProperty(item, col.key) }}
</span>
<div *ngIf="col.subKey" class="user-email">
{{ getProperty(item, col.subKey) }}
</div>
</div>
</ng-container>
<ng-container *ngSwitchCase="'currency'">
<span class="amount">{{ getProperty(item, col.key) | currency:'EUR' }}</span>
</ng-container>
<!-- VEREINFACHT: Wir übergeben den Wert direkt, die Pille kümmert sich um den Rest. -->
<ng-container *ngSwitchCase="'status'">
<app-status-pill [status]="getProperty(item, col.key)"></app-status-pill>
</ng-container>
<ng-container *ngSwitchCase="'image-text'">
<div class="user-cell">
<img [src]="getProperty(item, col.imageKey!) || fallbackImage"
alt="{{ item.name }}" />
<div>
<div class="user-name">{{ getProperty(item, col.key) }}</div>
<div class="user-email">{{ getProperty(item, col.subKey!) }}</div>
</div>
</div>
</ng-container>
<ng-container *ngSwitchCase="'image'">
<img [src]="getProperty(item, col.key) || fallbackImage"
alt="{{ item.name }}"
style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;">
</ng-container>
<ng-container *ngSwitchCase="'actions'">
<div class="actions-cell">
<app-button buttonType="icon" tooltip="Details anzeigen" iconName="eye" (click)="view.emit(item)"></app-button>
<app-button buttonType="icon" tooltip="Bearbeiten" iconName="edit" (click)="edit.emit(item)"></app-button>
<app-button buttonType="icon-danger" tooltip="Löschen" iconName="delete" (click)="delete.emit(item)"></app-button>
</div>
</ng-container>
</ng-container>
</td>
</tr>
<tr *ngIf="!displayedData || displayedData.length === 0">
<td [attr.colspan]="columns.length" class="no-data-cell">Keine Daten gefunden.</td>
</tr>
</tbody>
</table>
</div>
<div *ngIf="data.length > itemsPerPage">
<app-paginator
[currentPage]="currentPage"
[totalItems]="data.length"
[itemsPerPage]="itemsPerPage"
(pageChange)="onPageChange($event)"
></app-paginator>
</div>

View File

@@ -0,0 +1,55 @@
// /src/app/shared/components/table/generic-table/generic-table.component.ts
import { Component, Input, Output, EventEmitter, SimpleChanges, OnChanges, OnInit } from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { StatusPillComponent } from '../../ui/status-pill/status-pill.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { PaginatorComponent } from '../paginator/paginator.component';
export type ColumnType = 'text' | 'currency' | 'status' | 'image-text' | 'image' | 'actions' | 'date' | 'number';
export interface ColumnConfig {
key: string;
title: string;
type: ColumnType;
imageKey?: string;
subKey?: string;
cssClass?: string;
}
@Component({
selector: 'app-generic-table',
standalone: true,
imports: [ CommonModule, CurrencyPipe, StatusPillComponent, ButtonComponent, PaginatorComponent ],
templateUrl: './generic-table.component.html',
styleUrl: './generic-table.component.css',
})
export class GenericTableComponent implements OnChanges, OnInit {
@Input() data: any[] = [];
@Input() columns: ColumnConfig[] = [];
@Input() itemsPerPage = 10;
@Output() view = new EventEmitter<any>();
@Output() edit = new EventEmitter<any>();
@Output() delete = new EventEmitter<any>();
public displayedData: any[] = [];
public currentPage = 1;
public readonly fallbackImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNjY2NjY2MiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cmVjdCB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHg9IjMiIHk9IjMiIHJ4PSIyIiByeT0iMiIvPjxjaXJjbGUgY3g9IjkiIGN5PSI5IiByPSIyIi8+PHBhdGggZD0ibTIxIDE1LTUtNWwtNSA1bC0yLTJsLTUgNSIvPjwvc3ZnPg==';
ngOnInit(): void { this.updatePagination(); }
ngOnChanges(changes: SimpleChanges): void { if (changes['data']) { this.currentPage = 1; this.updatePagination(); } }
onPageChange(newPage: number): void { this.currentPage = newPage; this.updatePagination(); }
private updatePagination(): void {
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
const endIndex = startIndex + this.itemsPerPage;
this.displayedData = this.data.slice(startIndex, endIndex);
}
getProperty(item: any, key: string): any {
if (!key) return '';
return key.split('.').reduce((obj, part) => obj && obj[part], item);
}
}

View File

@@ -8,7 +8,7 @@
iconName="chevron_backward"
(click)="goToPrevious()"
[disabled]="currentPage === 1"
tooltip="Vorherige Seite">
>
</app-button>
<app-button
@@ -16,7 +16,7 @@
iconName="chevron_forward"
(click)="goToNext()"
[disabled]="currentPage === totalPages"
tooltip="Nächste Seite">
>
</app-button>
</div>

View File

@@ -46,12 +46,16 @@
position: absolute;
top: 50%; /* Vertikal zentrieren (Schritt 1) */
left: 1rem; /* Linken Abstand wie beim Input-Padding halten */
transform: translateY(-50%); /* Vertikal zentrieren (Schritt 2 - Feinjustierung) */
transform: translateY(
-50%
); /* Vertikal zentrieren (Schritt 2 - Feinjustierung) */
border-radius: 4px;
/* Aussehen & Typografie */
color: var(--color-text-light);
background-color: var(--color-surface); /* WICHTIG: Überdeckt die Input-Linie beim Schweben */
background-color: var(
--color-surface
); /* WICHTIG: Überdeckt die Input-Linie beim Schweben */
padding: 0 0.25rem;
/* Verhalten */
@@ -75,7 +79,9 @@
.form-input:focus ~ .form-label,
.form-input:not(:placeholder-shown) ~ .form-label {
top: 0;
transform: translateY(-50%); /* Bleibt für die Zentrierung auf der Border-Linie wichtig */
transform: translateY(
-50%
); /* Bleibt für die Zentrierung auf der Border-Linie wichtig */
font-size: 0.8rem;
color: var(--color-primary);
}
@@ -101,3 +107,17 @@
font-size: 0.8rem;
color: var(--color-primary);
}
.required-indicator {
color: var(--color-danger);
font-weight: bold;
margin-left: 2px;
}
/* Styling für die Fehlermeldung */
.error-message {
color: var(--color-danger);
font-size: 0.875rem;
margin-top: 0.25rem;
padding-left: 0.25rem;
}

View File

@@ -1,4 +1,7 @@
<div class="form-field">
<!-- /src/app/shared/components/form/form-field/form-field.component.html -->
<div class="form-field-wrapper">
<div class="form-field">
<input
[type]="type"
class="form-input"
@@ -9,5 +12,15 @@
(ngModelChange)="onChange($event)"
(blur)="onTouched()">
<label [for]="controlId" class="form-label">{{ label }}</label>
<label [for]="controlId" class="form-label">
{{ label }}
<!-- Der Indikator wird jetzt nur bei Bedarf angezeigt -->
<span *ngIf="isRequired" class="required-indicator">*</span>
</label>
</div>
<!-- Anzeige für Validierungsfehler -->
<div *ngIf="showErrors && errorMessage" class="error-message">
{{ errorMessage }}
</div>
</div>

View File

@@ -1,57 +1,64 @@
// /src/app/shared/components/form/form-field/form-field.component.ts
import { Component, Input, forwardRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
FormsModule,
ControlValueAccessor,
NG_VALUE_ACCESSOR,
} from '@angular/forms';
import { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, Validators } from '@angular/forms'; // Validators importieren
@Component({
selector: 'app-form-field',
standalone: true,
imports: [
CommonModule,
FormsModule, // Wichtig für [(ngModel)] im Template
FormsModule,
ReactiveFormsModule
],
templateUrl: './form-field.component.html',
styleUrl: './form-field.component.css',
styleUrls: ['./form-field.component.css'],
providers: [
{
// Stellt diese Komponente als "Value Accessor" zur Verfügung
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => FormFieldComponent),
multi: true,
},
],
})
// Die Komponente implementiert die ControlValueAccessor-Schnittstelle
export class FormFieldComponent implements ControlValueAccessor {
export class FormFieldComponent {
@Input() type: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' = 'text';
@Input() label: string = '';
@Input() type: 'text' | 'email' | 'password' = 'text';
@Input() control?: AbstractControl | null;
@Input() showErrors = true;
controlId = `form-field-${Math.random().toString(36)}`;
// --- Eigenschaften für ControlValueAccessor ---
value: string = '';
controlId = `form-field-${Math.random().toString(36).substring(2, 9)}`;
value: string | number = '';
onChange: (value: any) => void = () => {};
onTouched: () => void = () => {};
disabled = false;
// --- Methoden, die von Angular Forms aufgerufen werden ---
writeValue(value: any): void {
this.value = value;
// NEU: Getter, der automatisch prüft, ob das Feld ein Pflichtfeld ist.
get isRequired(): boolean {
if (!this.control) {
return false;
}
// hasValidator prüft, ob ein bestimmter Validator auf dem Control gesetzt ist.
return this.control.hasValidator(Validators.required);
}
registerOnChange(fn: any): void {
this.onChange = fn;
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['email']) return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
if (errors['min']) return `Der Wert muss mindestens ${errors['min'].min} sein.`;
if (errors['max']) return `Der Wert darf maximal ${errors['max'].max} sein.`;
if (errors['minlength']) return `Mindestens ${errors['minlength'].requiredLength} Zeichen erforderlich.`;
return 'Ungültige Eingabe.';
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled;
}
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; }
}

View File

@@ -23,7 +23,7 @@
font-size: 0.9rem;
color: var(--color-text-light);
margin-top: -0.75rem; /* Rücken wir näher an den Titel */
margin-bottom: 1.5rem;
margin-bottom: 1rem;
}
.form-group-content {

View File

@@ -9,12 +9,6 @@
{{ description }}
</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">
<ng-content></ng-content>
</div>

View File

@@ -1,5 +1,5 @@
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 { animate, style, transition, trigger } from '@angular/animations';
@@ -39,10 +39,12 @@ export interface SelectOption {
export class FormSelectComponent implements ControlValueAccessor {
@Input() label: string = '';
@Input() options: SelectOption[] = [];
@Input() control: AbstractControl | null = null;
// NEU: Zustand für das Dropdown-Menü und das angezeigte Label
isOpen = false;
selectedLabel: string | null = null;
// -- ENTFERNEN SIE DIESE ZEILE --
// selectedLabel: string | null = null;
controlId = `form-select-${Math.random().toString(36).substring(2)}`;
value: any = null;
@@ -50,46 +52,47 @@ export class FormSelectComponent implements ControlValueAccessor {
onTouched: () => void = () => {};
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) {}
// NEU: Schließt das Dropdown, wenn außerhalb des Elements geklickt wird
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
// Diese Logik ist jetzt sicher, weil Klicks innerhalb der Komponente sie nie erreichen
if (!this.elementRef.nativeElement.contains(event.target)) {
this.isOpen = false;
}
}
// ÜBERARBEITET: writeValue aktualisiert jetzt auch das sichtbare Label
// KORRIGIERT: writeValue setzt jetzt nur noch den Wert.
writeValue(value: any): void {
this.value = value;
const selectedOption = this.options.find(opt => opt.value === value);
this.selectedLabel = selectedOption ? selectedOption.label : null;
// Die Zeile, die `selectedLabel` gesetzt hat, wird nicht mehr benötigt.
}
registerOnChange(fn: any): void { this.onChange = fn; }
registerOnTouched(fn: any): void { this.onTouched = fn; }
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 {
event.stopPropagation(); // <-- WICHTIGSTE KORREKTUR
event.stopPropagation();
if (!this.disabled) {
this.isOpen = !this.isOpen;
if (!this.isOpen) {
this.onTouched();
}
if (!this.isOpen) { this.onTouched(); }
}
}
// ÜBERARBEITET: Nimmt das Event entgegen und stoppt es
// KORRIGIERT: selectOption setzt jetzt nur noch den Wert.
selectOption(option: SelectOption, event: MouseEvent): void {
event.stopPropagation(); // <-- WICHTIGE KORREKTUR
event.stopPropagation();
if (!this.disabled) {
this.value = option.value;
this.selectedLabel = option.label;
// Die Zeile, die `selectedLabel` gesetzt hat, wird nicht mehr benötigt.
this.onChange(this.value);
this.onTouched();
this.isOpen = false;

View File

@@ -77,3 +77,17 @@ border-radius: 4px;
font-size: 0.8rem;
color: var(--color-primary);
}
.required-indicator {
color: var(--color-danger);
font-weight: bold;
margin-left: 2px;
}
/* Styling für die Fehlermeldung */
.error-message {
color: var(--color-danger);
font-size: 0.875rem;
margin-top: 0.25rem;
padding-left: 0.25rem;
}

View File

@@ -1,13 +1,24 @@
<div class="form-field">
<!-- /src/app/shared/components/form/form-textarea/form-textarea.component.html -->
<div class="form-field-wrapper">
<div class="form-field">
<textarea
class="form-input"
[id]="controlId"
placeholder=" "
[rows]="rows"
placeholder=" "
[disabled]="disabled"
[(ngModel)]="value"
(ngModelChange)="onChange($event)"
(blur)="onTouched()"
[disabled]="disabled"></textarea>
(blur)="onTouched()"></textarea>
<label [for]="controlId" class="form-label">{{ label }}</label>
<label [for]="controlId" class="form-label">
{{ label }}
<span *ngIf="isRequired" class="required-indicator">*</span>
</label>
</div>
<div *ngIf="showErrors && errorMessage" class="error-message">
{{ errorMessage }}
</div>
</div>

View File

@@ -1,38 +1,54 @@
// /src/app/shared/components/form/form-textarea/form-textarea.component.ts
import { Component, Input, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { AbstractControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-form-textarea',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
FormsModule // Wichtig für [(ngModel)]
],
imports: [ CommonModule, FormsModule, ReactiveFormsModule ],
templateUrl: './form-textarea.component.html',
styleUrl: './form-textarea.component.css',
styleUrls: ['./form-textarea.component.css'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => FormTextareaComponent),
multi: true
}
]
multi: true,
},
],
})
export class FormTextareaComponent implements ControlValueAccessor {
export class FormTextareaComponent {
@Input() label: string = '';
@Input() rows = 3; // Standardanzahl der Zeilen
@Input() rows: number = 4;
// Eindeutige ID für die Verknüpfung
controlId = `form-textarea-${Math.random().toString(36).substring(2)}`;
// NEU: Hinzufügen des 'control' Inputs, genau wie in form-field
@Input() control?: AbstractControl | null;
// --- Logik für ControlValueAccessor ---
@Input() showErrors = true;
controlId = `form-textarea-${Math.random().toString(36).substring(2, 9)}`;
value: string = '';
onChange: (value: any) => void = () => {};
onTouched: () => void = () => {};
disabled = false;
get isRequired(): boolean {
if (!this.control) return false;
return this.control.hasValidator(Validators.required);
}
get errorMessage(): string | null {
if (!this.control || !this.control.errors || (!this.control.touched && !this.control.dirty)) {
return null;
}
const errors = this.control.errors;
if (errors['required']) return 'Dieses Feld ist erforderlich.';
if (errors['minlength']) return `Mindestens ${errors['minlength'].requiredLength} Zeichen erforderlich.`;
return 'Ungültige Eingabe.';
}
writeValue(value: any): void { this.value = value; }
registerOnChange(fn: any): void { this.onChange = fn; }
registerOnTouched(fn: any): void { this.onTouched = fn; }

View File

@@ -1,128 +1,128 @@
<aside class="sidebar" [class.collapsed]="isCollapsed">
<nav class="sidebar-nav">
<!-- Toggle bleibt wie er ist, da er keine Route ist -->
<div class="nav-item" (click)="toggleSidebar()">
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
<span>Toggle</span>
</div>
<!-- Products -->
<div
class="nav-item"
[class.active]="activeRoute === 'home'"
(click)="setActive('home')"
routerLink="/shop/products"
routerLinkActive="active"
>
<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>
</div>
<!-- Categories -->
<div
class="nav-item"
[class.active]="activeRoute === 'categories'"
(click)="setActive('categories')"
routerLink="/shop/categories"
routerLinkActive="active"
>
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
<span>categories</span>
</div>
<!-- Discounts -->
<div
class="nav-item"
[class.active]="activeRoute === 'discounts'"
(click)="setActive('discounts')"
routerLink="/shop/discounts"
routerLinkActive="active"
>
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
<span>discounts</span>
</div>
<!-- Orders -->
<div
class="nav-item"
[class.active]="activeRoute === 'orders'"
(click)="setActive('orders')"
routerLink="/shop/orders"
routerLinkActive="active"
>
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
<span>orders</span>
</div>
<!-- Payment Methods -->
<div
class="nav-item"
[class.active]="activeRoute === 'payment-methods'"
(click)="setActive('payment-methods')"
routerLink="/shop/payment-methods"
routerLinkActive="active"
>
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
<span>payment-methods</span>
</div>
<!-- Reviews -->
<div
class="nav-item"
[class.active]="activeRoute === 'products'"
(click)="setActive('products')"
routerLink="/shop/reviews"
routerLinkActive="active"
>
<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>
</div>
<!-- <div
class="nav-item"
[class.active]="activeRoute === 'settings'"
(click)="setActive('settings')"
>
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
<span>settings</span>
</div> -->
<!-- Shipping Methods -->
<div
class="nav-item"
[class.active]="activeRoute === 'shipping-methods'"
(click)="setActive('shipping-methods')"
routerLink="/shop/shipping-methods"
routerLinkActive="active"
>
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
<span>shipping-methods</span>
</div>
<!-- Shop Info -->
<div
class="nav-item"
[class.active]="activeRoute === 'shop-info'"
(click)="setActive('shop-info')"
routerLink="/shop/shop-info"
routerLinkActive="active"
>
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
<span>shop-info</span>
</div>
<!-- Supplier List -->
<div
class="nav-item"
[class.active]="activeRoute === 'supplier-list'"
(click)="setActive('supplier-list')"
routerLink="/shop/supplier-list"
routerLinkActive="active"
>
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
<span>supplier-list</span>
</div>
<!-- Users -->
<div
class="nav-item"
[class.active]="activeRoute === 'users'"
(click)="setActive('users')"
routerLink="/shop/users"
routerLinkActive="active"
>
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
<span>users</span>
</div>
<!-- Analytics -->
<div
class="nav-item"
[class.active]="activeRoute === 'analytics'"
(click)="setActive('analytics')"
routerLink="/shop/analytics"
routerLinkActive="active"
>
<app-icon [iconName]="'placeholder'" [svgColor]="'#8e44ad'"></app-icon>
<span>analytics</span>
</div>
</nav>

View File

@@ -1,76 +1,40 @@
import { Component, Inject, PLATFORM_ID, OnInit } from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
// WICHTIG: RouterLink und RouterLinkActive importieren
import { RouterLink, RouterLinkActive, Router } from '@angular/router';
import { IconComponent } from '../../ui/icon/icon.component';
import { Router } from '@angular/router';
import { StorageService } from '../../../../core/services/storage.service';
@Component({
selector: 'app-sidebar',
standalone: true,
imports: [CommonModule, IconComponent],
// WICHTIG: Hier im Array hinzufügen
imports: [CommonModule, IconComponent, RouterLink, RouterLinkActive],
templateUrl: './sidebar.component.html',
styleUrl: './sidebar.component.css',
})
export class SidebarComponent implements OnInit {
// 1. OnInit implementieren
// Key für localStorage, genau wie beim Dark Mode
private readonly sidebarCollapsedKey = 'app-sidebar-collapsed-setting';
private storageService = inject(StorageService);
private readonly sidebarCollapsedKey = 'app-sidebar-collapsed';
// Dummy-Eigenschaft für die aktive Route
activeRoute = 'dashboard';
// Der Standardwert ist 'false' (aufgeklappt), wird aber sofort überschrieben
public isCollapsed = false;
// 2. PLATFORM_ID injizieren, um localStorage sicher zu verwenden
constructor(
@Inject(PLATFORM_ID) private platformId: Object,
private router: Router
) {}
constructor(private router: Router) {}
// 3. Beim Start der Komponente den gespeicherten Zustand laden
ngOnInit(): void {
this.loadCollapsedState();
}
// Dummy-Methode
setActive(route: string): void {
this.activeRoute = route;
this.router.navigateByUrl('/shop/' + route);
}
// 4. Die Umschalt-Methode aktualisieren, damit sie den neuen Zustand speichert
toggleSidebar(): void {
// Zuerst den Zustand ändern
this.isCollapsed = !this.isCollapsed;
// Dann den neuen Zustand speichern
this.saveCollapsedState();
}
// 5. Methode zum Laden des Zustands (kopiert vom Dark-Mode-Muster)
private loadCollapsedState(): void {
if (isPlatformBrowser(this.platformId)) {
try {
const storedValue = localStorage.getItem(this.sidebarCollapsedKey);
// Setze den Zustand der Komponente basierend auf dem gespeicherten Wert
this.isCollapsed = storedValue === 'true';
} catch (e) {
console.error('Could not access localStorage for sidebar state:', e);
}
}
this.isCollapsed = this.storageService.getItem<boolean>(this.sidebarCollapsedKey) ?? false;
}
// 6. Methode zum Speichern des Zustands (kopiert vom Dark-Mode-Muster)
private saveCollapsedState(): void {
if (isPlatformBrowser(this.platformId)) {
try {
localStorage.setItem(
this.sidebarCollapsedKey,
String(this.isCollapsed)
);
} catch (e) {
console.error('Could not write to localStorage for sidebar state:', e);
}
}
this.storageService.setItem(this.sidebarCollapsedKey, this.isCollapsed);
}
}

View File

@@ -135,11 +135,20 @@
transform: translateX(-50%) translateY(-12px);
}
.btn.is-loading {
cursor: wait;
}
.btn-content {
display: flex;
}
.btn-content span {
display: flex;
height: auto;
align-content: center;
flex-wrap: wrap-reverse;
}
.btn-content.is-hidden {
visibility: hidden;
opacity: 0;

View File

@@ -43,3 +43,11 @@
.pill-info { color: #1d4ed8; background-color: #eff6ff; border-color: #bfdbfe; }
:host-context(body.dark-theme) .pill-info { color: #93c5fd; background-color: #1e40af; border-color: #3b82f6; }
.pill-active { color: #15803d; background-color: #ecfdf5; border-color: #bbf7d0; }
:host-context(body.dark-theme) .pill-active { color: #a7f3d0; background-color: #166534; border-color: #22c55e; }
.pill-inactive { color: rgb(168, 168, 168); background-color: #ececec; border-color: #777777; }
:host-context(body.dark-theme) .pill-inactive { color: rgb(168, 168, 168); background-color: #ececec; border-color: #777777; }

View File

@@ -1,3 +1,5 @@
<!-- /src/app/shared/components/ui/status-pill/status-pill.component.html -->
<div class="status-pill" [ngClass]="cssClass">
{{ displayText }}
<span>{{ displayText }}</span>
</div>

View File

@@ -1,34 +1,62 @@
import { Component, Input, OnChanges } 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 { OrderStatus } from '../../../../core/types/order';
@Component({
selector: 'app-status-pill',
standalone: true,
imports: [CommonModule, NgClass],
imports: [CommonModule, NgClass], // IconComponent hinzufügen
templateUrl: './status-pill.component.html',
styleUrl: './status-pill.component.css'
styleUrls: ['./status-pill.component.css']
})
export class StatusPillComponent implements OnChanges {
// Nimmt jetzt den neuen, sprechenden Status entgegen
@Input() status: any = 'info';
// --- INPUTS ---
// Nimmt den Status für die Farbe entgegen
@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 cssClass = '';
// Eine Map, die Statusnamen auf Text und CSS-Klasse abbildet
private statusMap = new Map<any, { text: string, css: string }>([
['completed', { text: 'Abgeschlossen', css: 'pill-success' }],
['processing', { text: 'In Bearbeitung', css: 'pill-warning' }],
['cancelled', { text: 'Storniert', css: 'pill-danger' }],
['info', { text: 'Info', css: 'pill-info' }]
['info', { text: 'Info', css: 'pill-info' }],
['active', { text: 'Ja', css: 'pill-active' }],
['inactive', { text: 'Nein', css: 'pill-inactive' }]
]);
ngOnChanges(): void {
// Wenn sich der Input-Status ändert, aktualisieren wir Text und Klasse
const details = this.statusMap.get(this.status) || this.statusMap.get('info')!;
this.displayText = details.text;
ngOnChanges(changes: SimpleChanges): void {
if (changes['status'] || changes['text']) {
let statusKey = this.status;
// Konvertiere boolean in einen String-Key
if (typeof statusKey === 'boolean') {
statusKey = statusKey ? 'active' : 'inactive';
}
const details = this.statusMap.get(statusKey as string) || { text: 'Info', css: 'pill-secondary' };
// NEUE LOGIK: Wenn ein expliziter Text übergeben wird, hat dieser Vorrang
this.displayText = this.text ?? details.text;
this.cssClass = details.css;
}
}
// Methode, die das Event auslöst
onRemove(): void {
this.remove.emit();
}
}

File diff suppressed because it is too large Load Diff