Compare commits

...

10 Commits

Author SHA1 Message Date
Tim
8fb20f70c7 db 2026-03-11 21:59:53 +01:00
Tim
cd2aca8826 margin 2026-03-11 21:59:32 +01:00
Tim
06720d400f Mapping Refactor 2026-03-11 14:30:51 +01:00
Tim
fd9f805c44 name mapping on OrderInfo Adminpage, only one item fetch for whole adminpage 2026-03-11 13:25:04 +01:00
Tim
83b207f4cb remove fetchItems 2026-03-11 12:58:10 +01:00
Tim
7c3ab76c79 start.sh improvement 2026-03-11 12:49:37 +01:00
Tim
483cb4b043 prettier 2026-03-11 12:30:20 +01:00
Tim
e3004bbc72 navbar hover 2026-03-11 00:33:24 +01:00
Tim
8b51809085 Loc 2026-03-10 21:02:37 +01:00
Tim
d3becdef72 catmonthmodel 2026-03-10 20:00:04 +01:00
59 changed files with 5582 additions and 4958 deletions

View File

@@ -25,8 +25,8 @@ public class ControllerPathConfig {
//ArticleController //ArticleController
public static final String ARTICLE_BASE = "/article"; public static final String ARTICLE_BASE = "/article";
public static final String ARTICLE_GET_ALL = ARTICLE_BASE + "/all"; public static final String ARTICLE_GET_ALL = "/all";
public static final String ARTICLE_GET_ALL_WITH_IMAGE = ARTICLE_BASE + "/all/image"; public static final String ARTICLE_GET_ALL_WITH_IMAGE = "/all/image";
//CustomerController //CustomerController
public static final String CUSTOMER_BASE = "/customer"; public static final String CUSTOMER_BASE = "/customer";
@@ -43,12 +43,12 @@ public class ControllerPathConfig {
//OrderController //OrderController
public static final String ORDER_BASE = "/order"; public static final String ORDER_BASE = "/order";
public static final String ORDER_GET_ALL = ORDER_BASE + "/all"; public static final String ORDER_GET_ALL = "/all";
public static final String ORDER_GET_ALL_ADMIN = ORDER_BASE + "/all/all"; public static final String ORDER_GET_ALL_ADMIN = "/all/all";
//ReviewController //ReviewController
public static final String REVIEW_BASE = "/review"; public static final String REVIEW_BASE = "/review";
public static final String REVIEW_GET_ALL = REVIEW_BASE + "/all"; public static final String REVIEW_GET_ALL = "/all";
//StatisticsController //StatisticsController
private static final String STATISTICS_BASE = "/statistics"; private static final String STATISTICS_BASE = "/statistics";

View File

@@ -19,6 +19,7 @@ import static de.htwsaar.webshop.util.LoggerUtil.logRequest;
@RestController @RestController
@Slf4j @Slf4j
@RequestMapping(path = ARTICLE_BASE, produces = "application/json")
public class ArticleController { public class ArticleController {
private final ArticleService articleService; private final ArticleService articleService;
@@ -27,26 +28,26 @@ public class ArticleController {
this.articleService = articleService; this.articleService = articleService;
} }
@RequestMapping(path = ARTICLE_GET_ALL, method = RequestMethod.GET, produces = "application/json") @GetMapping(path = ARTICLE_GET_ALL)
public ResponseEntity<List<ArticleModel>> getAll(HttpServletRequest request) { public ResponseEntity<List<ArticleModel>> getAll(HttpServletRequest request) {
logRequest(request); logRequest(request);
return ResponseEntity.ok(articleService.from(articleService.findAll())); return ResponseEntity.ok(articleService.from(articleService.findAll()));
} }
@RequestMapping(path = ARTICLE_GET_ALL_WITH_IMAGE, method = RequestMethod.GET, produces = "application/json") @GetMapping(path = ARTICLE_GET_ALL_WITH_IMAGE)
public ResponseEntity<List<ArticleWithImageModel>> getAllWithImageData(HttpServletRequest request) { public ResponseEntity<List<ArticleWithImageModel>> getAllWithImageData(HttpServletRequest request) {
logRequest(request); logRequest(request);
return ResponseEntity.ok(articleService.fromWithImage(articleService.findAll())); return ResponseEntity.ok(articleService.fromWithImage(articleService.findAll()));
} }
@RequestMapping(path = ARTICLE_BASE, method = RequestMethod.GET, produces = "application/json") @GetMapping
public ResponseEntity<ArticleModel> getByUUID(HttpServletRequest request, public ResponseEntity<ArticleModel> getByUUID(HttpServletRequest request,
@RequestParam(value = PARAM_UUID) UUID uuid) { @RequestParam(value = PARAM_UUID) UUID uuid) {
logRequest(request); logRequest(request);
return ResponseEntity.ok(articleService.from(articleService.findByUUID(uuid))); return ResponseEntity.ok(articleService.from(articleService.findByUUID(uuid)));
} }
@RequestMapping(path = ARTICLE_BASE, method = RequestMethod.POST, produces = "application/json") @PostMapping
public ResponseEntity<Boolean> add(HttpServletRequest request, public ResponseEntity<Boolean> add(HttpServletRequest request,
@RequestBody Article article) { @RequestBody Article article) {
logRequest(request); logRequest(request);
@@ -60,7 +61,7 @@ public class ArticleController {
return ResponseEntity.ok(a != null); return ResponseEntity.ok(a != null);
} }
@RequestMapping(path = ARTICLE_BASE, method = RequestMethod.PUT, produces = "application/json") @PutMapping
public ResponseEntity<Boolean> update(HttpServletRequest request, public ResponseEntity<Boolean> update(HttpServletRequest request,
@RequestParam(value = PARAM_UUID) UUID uuid, @RequestParam(value = PARAM_UUID) UUID uuid,
@RequestBody Article article) { @RequestBody Article article) {
@@ -80,7 +81,7 @@ public class ArticleController {
return ResponseEntity.ok(true); return ResponseEntity.ok(true);
} }
@RequestMapping(path = ARTICLE_BASE, method = RequestMethod.DELETE, produces = "application/json") @DeleteMapping
public ResponseEntity<Boolean> delete(HttpServletRequest request, public ResponseEntity<Boolean> delete(HttpServletRequest request,
@RequestParam(value = PARAM_UUID) UUID uuid) { @RequestParam(value = PARAM_UUID) UUID uuid) {
logRequest(request); logRequest(request);

View File

@@ -22,6 +22,7 @@ import static de.htwsaar.webshop.util.LoggerUtil.logRequest;
@RestController @RestController
@Slf4j @Slf4j
@RequestMapping(path = ORDER_BASE, produces = "application/json")
public class OrderController { public class OrderController {
private final OrderService orderService; private final OrderService orderService;
@@ -35,7 +36,7 @@ public class OrderController {
this.sessionService = sessionService; this.sessionService = sessionService;
} }
@RequestMapping(path = ORDER_GET_ALL, method = RequestMethod.GET, produces = "application/json") @GetMapping(path = ORDER_GET_ALL)
public ResponseEntity<List<OrderModel>> getAll(HttpServletRequest request, public ResponseEntity<List<OrderModel>> getAll(HttpServletRequest request,
@RequestParam(value = PARAM_CUSTOMER_ID) Long customerId) { @RequestParam(value = PARAM_CUSTOMER_ID) Long customerId) {
logRequest(request); logRequest(request);
@@ -46,7 +47,7 @@ public class OrderController {
return ResponseEntity.ok(orders.stream().map(Order::toModel).toList()); return ResponseEntity.ok(orders.stream().map(Order::toModel).toList());
} }
@RequestMapping(path = ORDER_GET_ALL_ADMIN, method = RequestMethod.GET, produces = "application/json") @GetMapping(path = ORDER_GET_ALL_ADMIN)
public ResponseEntity<List<OrderModel>> getAll(HttpServletRequest request, public ResponseEntity<List<OrderModel>> getAll(HttpServletRequest request,
@RequestParam(value = PARAM_EMAIL) String email, @RequestParam(value = PARAM_EMAIL) String email,
@RequestParam(value = PARAM_SESSION) UUID token) { @RequestParam(value = PARAM_SESSION) UUID token) {
@@ -59,7 +60,7 @@ public class OrderController {
return ResponseEntity.ok(orderService.findAll().stream().map(Order::toModel).toList()); return ResponseEntity.ok(orderService.findAll().stream().map(Order::toModel).toList());
} }
@RequestMapping(path = ORDER_BASE, method = RequestMethod.GET, produces = "application/json") @GetMapping
public ResponseEntity<OrderModel> get(HttpServletRequest request, public ResponseEntity<OrderModel> get(HttpServletRequest request,
@RequestParam(value = PARAM_ID) Long orderId) { @RequestParam(value = PARAM_ID) Long orderId) {
logRequest(request); logRequest(request);
@@ -70,7 +71,7 @@ public class OrderController {
return ResponseEntity.ok(order.toModel()); return ResponseEntity.ok(order.toModel());
} }
@RequestMapping(path = ORDER_BASE, method = RequestMethod.POST, produces = "application/json") @PostMapping
public ResponseEntity<Boolean> add(HttpServletRequest request, public ResponseEntity<Boolean> add(HttpServletRequest request,
@RequestBody OrderModel order) { @RequestBody OrderModel order) {
logRequest(request); logRequest(request);
@@ -85,8 +86,8 @@ public class OrderController {
return ResponseEntity.ok(saved != null); return ResponseEntity.ok(saved != null);
} }
@RequestMapping(path = ORDER_BASE, method = RequestMethod.PATCH, produces = "application/json") @PatchMapping
public ResponseEntity<Boolean> update(HttpServletRequest request, public ResponseEntity<Boolean> patch(HttpServletRequest request,
@RequestParam(value = PARAM_ID) Long orderId, @RequestParam(value = PARAM_ID) Long orderId,
@RequestParam(value = PARAM_STATUS) OrderStatus status) { @RequestParam(value = PARAM_STATUS) OrderStatus status) {
logRequest(request); logRequest(request);
@@ -107,7 +108,7 @@ public class OrderController {
return ResponseEntity.ok(orderService.saveNew(order) != null); return ResponseEntity.ok(orderService.saveNew(order) != null);
} }
@RequestMapping(path = ORDER_BASE, method = RequestMethod.DELETE, produces = "application/json") @DeleteMapping
public ResponseEntity<Boolean> delete(HttpServletRequest request, public ResponseEntity<Boolean> delete(HttpServletRequest request,
@RequestParam(value = PARAM_ID) Long orderId) { @RequestParam(value = PARAM_ID) Long orderId) {
logRequest(request); logRequest(request);

View File

@@ -1,3 +1,4 @@
package de.htwsaar.webshop.controller; package de.htwsaar.webshop.controller;
import de.htwsaar.webshop.model.ReviewModel; import de.htwsaar.webshop.model.ReviewModel;
@@ -5,8 +6,13 @@ import de.htwsaar.webshop.repository.entities.Review;
import de.htwsaar.webshop.service.ArticleService; import de.htwsaar.webshop.service.ArticleService;
import de.htwsaar.webshop.service.ReviewService; import de.htwsaar.webshop.service.ReviewService;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -19,6 +25,7 @@ import static de.htwsaar.webshop.config.ParameterConfig.*;
import static de.htwsaar.webshop.util.LoggerUtil.logRequest; import static de.htwsaar.webshop.util.LoggerUtil.logRequest;
@RestController @RestController
@RequestMapping(value = REVIEW_BASE, produces = "application/json")
@Slf4j @Slf4j
public class ReviewController { public class ReviewController {
@@ -31,70 +38,96 @@ public class ReviewController {
this.articleService = articleService; this.articleService = articleService;
} }
@RequestMapping(path = REVIEW_GET_ALL, method = RequestMethod.GET, produces = "application/json") @GetMapping(path = REVIEW_GET_ALL)
public ResponseEntity<List<ReviewModel>> getAll(HttpServletRequest request, public ResponseEntity<List<ReviewModel>> getAll(HttpServletRequest request,
@RequestParam(value = PARAM_UUID) UUID uuid) { @RequestParam(value = PARAM_UUID) UUID uuid) {
logRequest(request); logRequest(request);
List<ReviewModel> review = reviewService.getAllByUUID(uuid).stream().map(reviewService::toModel).toList(); List<ReviewModel> reviews = reviewService.getAllByUUID(uuid)
if (review.isEmpty()) { .stream()
.map(reviewService::toModel)
.toList();
if (reviews.isEmpty()) {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
return ResponseEntity.ok(review); return ResponseEntity.ok(reviews);
} }
@RequestMapping(path = REVIEW_BASE, method = RequestMethod.GET, produces = "application/json") @GetMapping
public ResponseEntity<Review> get(HttpServletRequest request, public ResponseEntity<ReviewModel> get(HttpServletRequest request,
@RequestParam(value = PARAM_ID) Long reviewId) { @RequestParam(value = PARAM_ID) Long reviewId) {
logRequest(request); logRequest(request);
Review review = reviewService.getReviewById(reviewId); Review review = reviewService.getReviewById(reviewId);
if (review == null) { if (review == null) {
log.debug("Review with id {} not found", reviewId);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
return ResponseEntity.ok(review);
return ResponseEntity.ok(reviewService.toModel(review));
} }
@RequestMapping(path = REVIEW_BASE, method = RequestMethod.POST, produces = "application/json") @PostMapping
public ResponseEntity<Boolean> add(HttpServletRequest request, public ResponseEntity<ReviewModel> add(HttpServletRequest request,
@RequestParam(value = PARAM_UUID) UUID uuid, @RequestParam(value = PARAM_UUID) UUID articleUuid,
@RequestParam(value = PARAM_RATING) int rating, @RequestParam(value = PARAM_RATING)
@RequestBody String review) { @Min(value = 0, message = "Rating must be at least 0")
@Max(value = 10, message = "Rating must be at most 10")
int rating,
@RequestBody @NotBlank(message = "Review text cannot be empty") String reviewText) {
logRequest(request); logRequest(request);
if (uuid == null || articleService.findByUUID(uuid) == null if (articleService.findByUUID(articleUuid) == null) {
|| rating < 0 || rating > 10) { log.warn("Article with UUID {} not found for review creation", articleUuid);
log.warn("[{}] failed Validation, sending bad request", request.getRequestURI()); return ResponseEntity.badRequest().build();
return ResponseEntity.badRequest().body(false);
} }
Review saved = reviewService.save(uuid, rating, review); Review savedReview = reviewService.save(articleUuid, rating, reviewText);
return ResponseEntity.ok(saved != null);
if (savedReview == null) {
log.error("Failed to save review for article UUID {}", articleUuid);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
return ResponseEntity.status(HttpStatus.CREATED).body(reviewService.toModel(savedReview));
} }
@RequestMapping(path = REVIEW_BASE, method = RequestMethod.PUT, produces = "application/json") @PutMapping
public ResponseEntity<Boolean> update(HttpServletRequest request, public ResponseEntity<ReviewModel> update(HttpServletRequest request,
@RequestParam(value = PARAM_ID) Long reviewId, @RequestParam(value = PARAM_ID) Long reviewId,
@RequestBody Review review) { @RequestBody @Valid Review review) {
logRequest(request); logRequest(request);
if (reviewId == null || reviewService.getReviewById(reviewId) == null) {
return ResponseEntity.badRequest().body(false); Review existingReview = reviewService.getReviewById(reviewId);
if (existingReview == null) {
log.warn("Review with id {} not found for update", reviewId);
return ResponseEntity.notFound().build();
} }
review.setId(reviewService.getReviewById(reviewId).getId());
return ResponseEntity.ok(reviewService.save(review) != null); // Ensure the ID matches
review.setId(reviewId);
Review updatedReview = reviewService.save(review);
if (updatedReview == null) {
log.error("Failed to update review with id {}", reviewId);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
return ResponseEntity.ok(reviewService.toModel(updatedReview));
} }
@RequestMapping(path = REVIEW_BASE, method = RequestMethod.DELETE, produces = "application/json") @DeleteMapping
public ResponseEntity<Boolean> delete(HttpServletRequest request, public ResponseEntity<Void> delete(HttpServletRequest request,
@RequestParam(value = PARAM_ID) Long reviewId) { @RequestParam(value = PARAM_ID) Long reviewId) {
logRequest(request); logRequest(request);
if (reviewId == null) {
log.warn("[{}] got invalid imageId", request.getRequestURI()); Review existingReview = reviewService.getReviewById(reviewId);
return ResponseEntity.badRequest().body(false); if (existingReview == null) {
} log.warn("Review with id {} not found for deletion", reviewId);
if (reviewService.getReviewById(reviewId) != null) { return ResponseEntity.notFound().build();
log.warn("[{}] got invalid imageId", request.getRequestURI());
return ResponseEntity.badRequest().body(false);
} }
reviewService.delete(reviewId); reviewService.delete(reviewId);
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
} }

View File

@@ -1,6 +1,6 @@
package de.htwsaar.webshop.controller; package de.htwsaar.webshop.controller;
import de.htwsaar.webshop.model.CatMonthModel; import de.htwsaar.webshop.model.CategoryMonthModel;
import de.htwsaar.webshop.model.OrderStatus; import de.htwsaar.webshop.model.OrderStatus;
import de.htwsaar.webshop.service.SessionService; import de.htwsaar.webshop.service.SessionService;
import de.htwsaar.webshop.service.StatisticsService; import de.htwsaar.webshop.service.StatisticsService;
@@ -12,7 +12,6 @@ import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@@ -34,9 +33,9 @@ public class StatisticsController {
} }
@RequestMapping(value = STATISTICS_VOLUME, method = RequestMethod.GET, produces = "application/json") @RequestMapping(value = STATISTICS_VOLUME, method = RequestMethod.GET, produces = "application/json")
public ResponseEntity<CatMonthModel<Integer>> getMonthlySalesVolume(HttpServletRequest request, public ResponseEntity<CategoryMonthModel<Integer>> getMonthlySalesVolume(HttpServletRequest request,
@RequestParam(value = PARAM_SESSION) UUID session, @RequestParam(value = PARAM_SESSION) UUID session,
@RequestParam(value = PARAM_EMAIL) String email) { @RequestParam(value = PARAM_EMAIL) String email) {
logRequest(request); logRequest(request);
if (!sessionService.isAdmin(session, email)) { if (!sessionService.isAdmin(session, email)) {
log.warn("Invalid session requesting Admin {}", session); log.warn("Invalid session requesting Admin {}", session);
@@ -46,9 +45,9 @@ public class StatisticsController {
} }
@RequestMapping(value = STATISTICS_REVENUE, method = RequestMethod.GET, produces = "application/json") @RequestMapping(value = STATISTICS_REVENUE, method = RequestMethod.GET, produces = "application/json")
public ResponseEntity<CatMonthModel<Integer>> getMonthlyRevenue(HttpServletRequest request, public ResponseEntity<CategoryMonthModel<Integer>> getMonthlyRevenue(HttpServletRequest request,
@RequestParam(value = PARAM_SESSION) UUID token, @RequestParam(value = PARAM_SESSION) UUID token,
@RequestParam(value = PARAM_EMAIL) String email) { @RequestParam(value = PARAM_EMAIL) String email) {
logRequest(request); logRequest(request);
if (!sessionService.isAdmin(token, email)) { if (!sessionService.isAdmin(token, email)) {
log.warn("Invalid session requesting Admin {}", token); log.warn("Invalid session requesting Admin {}", token);

View File

@@ -16,17 +16,10 @@ public class ExpiredSessionDeleteJob {
private final SessionService sessionService; private final SessionService sessionService;
/**
* Constructor of the class
* @param sessionService used in the class
*/
public ExpiredSessionDeleteJob(SessionService sessionService) { public ExpiredSessionDeleteJob(SessionService sessionService) {
this.sessionService = sessionService; this.sessionService = sessionService;
} }
/**
* Method running the job calling the referring service method
*/
@Scheduled(fixedRate = MILLIS_TO_WEEK) @Scheduled(fixedRate = MILLIS_TO_WEEK)
public void runFixedRateTask() { public void runFixedRateTask() {
log.info("Deleting expired sessions..."); log.info("Deleting expired sessions...");

View File

@@ -9,6 +9,6 @@ import java.util.Map;
@Getter @Getter
@Setter @Setter
@AllArgsConstructor @AllArgsConstructor
public class CatMonthModel<T extends Number> { public class CategoryMonthModel<T extends Number> {
Map<String, Map<Long, T>> catMonthMap; Map<String, Map<Long, T>> catMonthMap;
} }

View File

@@ -1,16 +1,14 @@
package de.htwsaar.webshop.service; package de.htwsaar.webshop.service;
import de.htwsaar.webshop.model.CatMonthModel; import de.htwsaar.webshop.model.CategoryMonthModel;
import de.htwsaar.webshop.model.OrderStatus; import de.htwsaar.webshop.model.OrderStatus;
import java.util.Map; import java.util.Map;
import java.util.List;
import java.util.UUID;
public interface StatisticsService { public interface StatisticsService {
CatMonthModel<Integer> getSalesVolume(); CategoryMonthModel<Integer> getSalesVolume();
CatMonthModel<Integer> getSalesRevenue(); CategoryMonthModel<Integer> getSalesRevenue();
Map<OrderStatus, Integer> getOrderStatus(); Map<OrderStatus, Integer> getOrderStatus();

View File

@@ -1,7 +1,7 @@
package de.htwsaar.webshop.service.impl; package de.htwsaar.webshop.service.impl;
import de.htwsaar.webshop.model.ArticleCategory; import de.htwsaar.webshop.model.ArticleCategory;
import de.htwsaar.webshop.model.CatMonthModel; import de.htwsaar.webshop.model.CategoryMonthModel;
import de.htwsaar.webshop.model.OrderStatus; import de.htwsaar.webshop.model.OrderStatus;
import de.htwsaar.webshop.repository.entities.OrderItem; import de.htwsaar.webshop.repository.entities.OrderItem;
import de.htwsaar.webshop.service.ArticleService; import de.htwsaar.webshop.service.ArticleService;
@@ -30,9 +30,9 @@ public class StatisticsServiceImpl implements StatisticsService {
} }
//returns Map<unix milli timestamp, Map<Category, T>> //returns Map<unix milli timestamp, Map<Category, T>>
private <T extends Number> CatMonthModel<T> getMonthCategoryMap(Function<OrderItem, T> mappingFunction, private <T extends Number> CategoryMonthModel<T> getMonthCategoryMap(Function<OrderItem, T> mappingFunction,
BinaryOperator<T> reduceFunction, BinaryOperator<T> reduceFunction,
T defaultValue) { T defaultValue) {
Map<String, Map<Long, T>> map = new TreeMap<>(); Map<String, Map<Long, T>> map = new TreeMap<>();
for (ArticleCategory value : ArticleCategory.values()) { for (ArticleCategory value : ArticleCategory.values()) {
map.put(value.loc, new TreeMap<>()); map.put(value.loc, new TreeMap<>());
@@ -49,16 +49,16 @@ public class StatisticsServiceImpl implements StatisticsService {
}) })
); );
}); });
return new CatMonthModel<>(map); return new CategoryMonthModel<>(map);
} }
@Override @Override
public CatMonthModel<Integer> getSalesVolume() { public CategoryMonthModel<Integer> getSalesVolume() {
return getMonthCategoryMap(OrderItem::getAmount, Integer::sum, 0); return getMonthCategoryMap(OrderItem::getAmount, Integer::sum, 0);
} }
@Override @Override
public CatMonthModel<Integer> getSalesRevenue() { public CategoryMonthModel<Integer> getSalesRevenue() {
return getMonthCategoryMap(item -> item.getArticle().getPrice100(), Integer::sum, 0); return getMonthCategoryMap(item -> item.getArticle().getPrice100(), Integer::sum, 0);
} }

View File

@@ -4880,21 +4880,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -132,8 +132,8 @@
"salesStatistics": "Sales Statistics", "salesStatistics": "Sales Statistics",
"monthlySalesVolume": "Monthly Sales Volume", "monthlySalesVolume": "Monthly Sales Volume",
"monthlySalesRevenue": "Monthly Sales Revenue in €", "monthlySalesRevenue": "Monthly Sales Revenue in €",
"itemVolumeDistribution": "Item Volume Distribution", "itemVolumeDistribution": "Sales Volume Distribution",
"itemRevenueDistribution": "Item Revenue Distribution", "itemRevenueDistribution": "Sales Revenue Distribution",
"stockFulfillment": "Stock fulfillment", "stockFulfillment": "Stock fulfillment",
"orderStatus": "Order Status", "orderStatus": "Order Status",
"ORDERED": "Ordered", "ORDERED": "Ordered",

View File

@@ -1,63 +1,64 @@
/* App.css - CSS-Variablen-basierte Version */ /* App.css - CSS-Variablen-basierte Version */
:root { :root {
--background-color: #fafafa; --background-color: #fafafa;
--text-color: #000000; --text-color: #000000;
--navbar-height: 5.1vh; --navbar-height: 5.1vh;
--page-height: 94vh; --page-height: 94vh;
} }
#root { #root {
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
background-color: var(--background-color) !important; background-color: var(--background-color) !important;
color: var(--text-color) !important; color: var(--text-color) !important;
transition: background-color 300ms cubic-bezier(0.4, 0, 0.2, 1); transition: background-color 300ms cubic-bezier(0.4, 0, 0.2, 1);
} }
html, body { html,
margin: 0; body {
padding: 0; margin: 0;
overflow: hidden; padding: 0;
background-color: var(--background-color) !important; overflow: hidden;
color: var(--text-color) !important; background-color: var(--background-color) !important;
transition: background-color 300ms cubic-bezier(0.4, 0, 0.2, 1); color: var(--text-color) !important;
transition: background-color 300ms cubic-bezier(0.4, 0, 0.2, 1);
} }
.logo { .logo {
height: 6em; height: 6em;
padding: 1.5em; padding: 1.5em;
will-change: filter; will-change: filter;
transition: filter 300ms; transition: filter 300ms;
} }
.logo:hover { .logo:hover {
filter: drop-shadow(0 0 2em #646cffaa); filter: drop-shadow(0 0 2em #646cffaa);
} }
.logo.react:hover { .logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa); filter: drop-shadow(0 0 2em #61dafbaa);
} }
@keyframes logo-spin { @keyframes logo-spin {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
} }
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo { a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear; animation: logo-spin infinite 20s linear;
} }
} }
.card { .card {
padding: 2em; padding: 2em;
border-radius: 8px; border-radius: 8px;
} }
.read-the-docs { .read-the-docs {
opacity: 0.7; opacity: 0.7;
} }

View File

@@ -1,52 +1,51 @@
import {StyledEngineProvider} from '@mui/material/styles'; import { StyledEngineProvider } from "@mui/material/styles";
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {BrowserRouter, Route, Routes} from 'react-router-dom'; import { BrowserRouter, Route, Routes } from "react-router-dom";
import './App.css'; import "./App.css";
import {BasketProvider} from './helper/BasketProvider'; import { BasketProvider } from "./helper/BasketProvider";
import NavBar from './helper/navbar/NavBar'; import NavBar from "./helper/navbar/NavBar";
import Account from './pages/Account'; import Account from "./pages/Account";
import Contact from './pages/Contact'; import Contact from "./pages/Contact";
import Home from './pages/Home'; import Home from "./pages/Home";
import NoPage from './pages/NoPage'; import NoPage from "./pages/NoPage";
import Orders from './pages/Orders'; import Orders from "./pages/Orders";
import Payment from './pages/Payment'; import Payment from "./pages/Payment";
import Product from './pages/Product'; import Product from "./pages/Product";
import {CustomThemeProvider} from './theme/ThemeContext'; import { CustomThemeProvider } from "./theme/ThemeContext";
import FSComponents from './pages/FSComponents.tsx'; import FSComponents from "./pages/FSComponents.tsx";
import AdminPanel from './pages/AdminPanel'; import AdminPanel from "./pages/AdminPanel";
import {AccountProvider} from './helper/AccountProvider.tsx'; import { AccountProvider } from "./helper/AccountProvider.tsx";
import {CookiesProvider} from 'react-cookie'; import { CookiesProvider } from "react-cookie";
export default function App() { export default function App() {
const queryClient = new QueryClient();
const queryClient = new QueryClient(); return (
<QueryClientProvider client={queryClient}>
return ( <StyledEngineProvider injectFirst>
<QueryClientProvider client={queryClient}> <CustomThemeProvider>
<StyledEngineProvider injectFirst> <CookiesProvider>
<CustomThemeProvider> <AccountProvider>
<CookiesProvider> <BasketProvider>
<AccountProvider> <BrowserRouter>
<BasketProvider> <NavBar />
<BrowserRouter> <Routes>
<NavBar/> <Route path="/" element={<Home />} />
<Routes> <Route path="*" element={<NoPage />} />
<Route path="/" element={<Home/>}/> <Route path="/product/:id" element={<Product />} />
<Route path="*" element={<NoPage/>}/> <Route path="/checkout" element={<Payment />} />
<Route path="/product/:id" element={<Product/>}/> <Route path="/components" element={<FSComponents />} />
<Route path="/checkout" element={<Payment/>}/> <Route path="/contact" element={<Contact />} />
<Route path="/components" element={<FSComponents/>}/> <Route path="/account" element={<Account />} />
<Route path="/contact" element={<Contact/>}/> <Route path="/orders" element={<Orders />} />
<Route path='/account' element={<Account/>}/> <Route path="/admin" element={<AdminPanel />} />
<Route path='/orders' element={<Orders/>}/> </Routes>
<Route path='/admin' element={<AdminPanel/>}/> </BrowserRouter>
</Routes> </BasketProvider>
</BrowserRouter> </AccountProvider>
</BasketProvider> </CookiesProvider>
</AccountProvider> </CustomThemeProvider>
</CookiesProvider> </StyledEngineProvider>
</CustomThemeProvider> </QueryClientProvider>
</StyledEngineProvider> );
</QueryClientProvider>
)
} }

View File

@@ -1,58 +1,57 @@
type AccountType = { type AccountType = {
id: number;
customer: {
id: number; id: number;
customer: { name: string;
id: number; surname: string;
name: string; address: string;
surname: string; country: string;
address: string; zip: string;
country: string; };
zip: string; password: string;
}; langI18n: string;
password: string; admin: boolean;
langI18n: string; email: string;
admin: boolean;
email: string;
}; };
export default AccountType; export default AccountType;
export type CustomerType = export type CustomerType = {
{ id: number;
id: number; name: string;
name: string; surname: string;
surname: string; address: string;
address: string; country: string;
country: string; zip: string;
zip: string; };
}
export type SubmitLogin = { export type SubmitLogin = {
email: string; email: string;
password: string; password: string;
}; };
export type SubmitLoginSession = { export type SubmitLoginSession = {
email: string; email: string;
session: string; session: string;
}; };
export type AdminAccountOperation = { export type AdminAccountOperation = {
email: string; email: string;
uuid: string; uuid: string;
accountId: number; accountId: number;
} };
export type User = { export type User = {
password: string; password: string;
email: string; email: string;
customerId: number; customerId: number;
session: string; session: string;
isAdmin: boolean; isAdmin: boolean;
// weitere Felder nach Bedarf // weitere Felder nach Bedarf
}; };
export type AccountContextType = { export type AccountContextType = {
user: User | null; user: User | null;
login: (userData: User) => void; login: (userData: User) => void;
logout: () => void; logout: () => void;
}; };

View File

@@ -1,43 +1,43 @@
export type Item = { export type Item = {
id: number; id: number;
uuid: string; uuid: string;
name: string; name: string;
description: string; description: string;
price100: number; price100: number;
stock: number; stock: number;
stockExpected: number; stockExpected: number;
category: string; category: string;
rating: number; rating: number;
discount100: number; discount100: number;
}; };
type ItemWithImage = { type ItemWithImage = {
id: number; id: number;
uuid: string; uuid: string;
name: string; name: string;
description: string; description: string;
price100: number; price100: number;
stock: number; stock: number;
stockExpected: number; stockExpected: number;
category: string; category: string;
rating: number; rating: number;
discount100: number; discount100: number;
image: string; image: string;
}; };
export type ItemWithFSImage = { export type ItemWithFSImage = {
id: number; id: number;
uuid: string; uuid: string;
name: string; name: string;
description: string; description: string;
price100: number; price100: number;
stock: number; stock: number;
stockExpected: number; stockExpected: number;
category: string; category: string;
rating: number; rating: number;
discount100: number; discount100: number;
image: string; image: string;
farmImage: string; farmImage: string;
}; };
export default ItemWithImage; export default ItemWithImage;

View File

@@ -1,40 +1,39 @@
export enum OrderStatusEnum { export enum OrderStatusEnum {
ORDERED = 'ORDERED', ORDERED = "ORDERED",
IN_PROGRESS = 'IN_PROGRESS', IN_PROGRESS = "IN_PROGRESS",
ISSUES = 'ISSUES', ISSUES = "ISSUES",
DELIVERED = 'DELIVERED', DELIVERED = "DELIVERED",
CANCELLED = 'CANCELLED', CANCELLED = "CANCELLED",
} }
type OrderType = { type OrderType = {
id: number; id: number;
customerId: number; customerId: number;
time: number; time: number;
status: OrderStatusEnum; status: OrderStatusEnum;
orderItems: { id: number; amount: number; article: string }[]; orderItems: { id: number; amount: number; article: string }[];
total: number; total: number;
}; };
export default OrderType; export default OrderType;
export type ShippingDetails = { export type ShippingDetails = {
firstName: string; firstName: string;
lastName: string; lastName: string;
telefon: string; telefon: string;
address: string; address: string;
postalCode: string; postalCode: string;
city: string; city: string;
country: string; country: string;
}; };
export type OrderItem = { export type OrderItem = {
id: string; id: string;
amount: number; amount: number;
article: string; // UUID of the item article: string; // UUID of the item
}; };
export type OrderPatch = { export type OrderPatch = {
id: number; id: number;
status: OrderStatusEnum; status: OrderStatusEnum;
}; };

View File

@@ -1,7 +1,7 @@
type RatingType = { type RatingType = {
rating: number; rating: number;
content: string; content: string;
timestamp: number; timestamp: number;
}; };
export default RatingType; export default RatingType;

View File

@@ -1,7 +1,7 @@
type RatingSubmitType = { type RatingSubmitType = {
articleId: string; articleId: string;
rating: number; rating: number;
content: string; content: string;
}; };
export default RatingSubmitType; export default RatingSubmitType;

View File

@@ -1,19 +1,19 @@
import i18next from "i18next"; import i18next from "i18next";
import {initReactI18next} from "react-i18next"; import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector"; import LanguageDetector from "i18next-browser-languagedetector";
import HttpBackend from "i18next-http-backend"; import HttpBackend from "i18next-http-backend";
i18next i18next
.use(HttpBackend) .use(HttpBackend)
.use(LanguageDetector) .use(LanguageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
fallbackLng: "en", fallbackLng: "en",
debug: false, debug: false,
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
}, },
backend: { backend: {
loadPath: "/locales/{{lng}}/translation.json", loadPath: "/locales/{{lng}}/translation.json",
} },
}); });

View File

@@ -1,42 +1,48 @@
import {createContext, ReactNode, useContext, useEffect, useState} from "react"; import {
import {AccountContextType, User} from "../components/Account"; createContext,
import {useCookies} from 'react-cookie'; ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { AccountContextType, User } from "../components/Account";
import { useCookies } from "react-cookie";
const AccountContext = createContext<AccountContextType | undefined>(undefined); const AccountContext = createContext<AccountContextType | undefined>(undefined);
export const AccountProvider = ({children}: { children: ReactNode }) => { export const AccountProvider = ({ children }: { children: ReactNode }) => {
const [cookies, setCookie] = useCookies(["account"]); const [cookies, setCookie] = useCookies(["account"]);
const initialAccount = const initialAccount =
typeof cookies.account === "object" && !Array.isArray(cookies.account) typeof cookies.account === "object" && !Array.isArray(cookies.account)
? cookies.account ? cookies.account
: null; : null;
const [user, setUser] = useState<User | null>(initialAccount); const [user, setUser] = useState<User | null>(initialAccount);
const login = (userData: User) => { const login = (userData: User) => {
setUser(userData); setUser(userData);
}; };
const logout = () => { const logout = () => {
setUser(null); setUser(null);
}; };
useEffect(() => { useEffect(() => {
setCookie("account", user, {path: "/", maxAge: 3600 * 24 * 7}); setCookie("account", user, { path: "/", maxAge: 3600 * 24 * 7 });
}, [user, setCookie]); }, [user, setCookie]);
return ( return (
<AccountContext.Provider value={{user, login, logout}}> <AccountContext.Provider value={{ user, login, logout }}>
{children} {children}
</AccountContext.Provider> </AccountContext.Provider>
); );
}; };
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export const useAccount = () => { export const useAccount = () => {
const context = useContext(AccountContext); const context = useContext(AccountContext);
if (!context) { if (!context) {
throw new Error("useAccount must be used within an AccountProvider"); throw new Error("useAccount must be used within an AccountProvider");
} }
return context; return context;
}; };

View File

@@ -1,60 +1,64 @@
import React, {createContext, useContext, useEffect, useState} from 'react'; import React, { createContext, useContext, useEffect, useState } from "react";
import Item from '../components/Item'; import Item from "../components/Item";
import {useCookies} from 'react-cookie'; import { useCookies } from "react-cookie";
export interface BasketItem { export interface BasketItem {
item: Item; item: Item;
quantity: number; quantity: number;
} }
interface BasketContextType { interface BasketContextType {
basket: BasketItem[]; basket: BasketItem[];
addToBasket: (item: Item, quantity: number) => void; addToBasket: (item: Item, quantity: number) => void;
clearBasket: () => void; clearBasket: () => void;
} }
const BasketContext = createContext<BasketContextType | undefined>(undefined); const BasketContext = createContext<BasketContextType | undefined>(undefined);
export const BasketProvider: React.FC<{ children: React.ReactNode }> = ({children}) => { export const BasketProvider: React.FC<{ children: React.ReactNode }> = ({
const [cookies, setCookie] = useCookies(["basket"]); children,
const [basket, setBasket] = useState<BasketItem[]>(cookies.basket || []); }) => {
const [cookies, setCookie] = useCookies(["basket"]);
const [basket, setBasket] = useState<BasketItem[]>(cookies.basket || []);
const addToBasket = (item: Item, quantity: number) => { const addToBasket = (item: Item, quantity: number) => {
setBasket((prevBasket) => { setBasket((prevBasket) => {
const existingItem = prevBasket.find((basketItem) => basketItem.item.uuid === item.uuid); const existingItem = prevBasket.find(
if (existingItem) { (basketItem) => basketItem.item.uuid === item.uuid,
// Update quantity if item already exists );
return prevBasket.map((basketItem) => if (existingItem) {
basketItem.item.uuid === item.uuid // Update quantity if item already exists
? {...basketItem, quantity: basketItem.quantity + quantity} return prevBasket.map((basketItem) =>
: basketItem basketItem.item.uuid === item.uuid
); ? { ...basketItem, quantity: basketItem.quantity + quantity }
} : basketItem,
// Add new item to basket );
return [...prevBasket, {item, quantity}]; }
}); // Add new item to basket
}; return [...prevBasket, { item, quantity }];
});
};
const clearBasket = () => { const clearBasket = () => {
setBasket([]); setBasket([]);
}; };
useEffect(() => { useEffect(() => {
setCookie("basket", basket, {path: "/", maxAge: 3600 * 24 * 7}); // 7 Tage setCookie("basket", basket, { path: "/", maxAge: 3600 * 24 * 7 }); // 7 Tage
}, [basket, setCookie]); }, [basket, setCookie]);
return ( return (
<BasketContext.Provider value={{basket, addToBasket, clearBasket}}> <BasketContext.Provider value={{ basket, addToBasket, clearBasket }}>
{children} {children}
</BasketContext.Provider> </BasketContext.Provider>
); );
}; };
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export const useBasket = () => { export const useBasket = () => {
const context = useContext(BasketContext); const context = useContext(BasketContext);
if (!context) { if (!context) {
throw new Error('useBasket must be used within a BasketProvider'); throw new Error("useBasket must be used within a BasketProvider");
} }
return context; return context;
}; };

View File

@@ -1,183 +1,211 @@
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
import {Box, Button, IconButton, Toolbar, useTheme} from "@mui/material"; import { Box, Button, IconButton, Toolbar, useTheme } from "@mui/material";
import {DataGrid, GridColDef, GridRowId, GridRowSelectionModel} from "@mui/x-data-grid"; import {
import {useMutation, useQuery} from "@tanstack/react-query"; DataGrid,
import {useEffect, useState} from "react"; GridColDef,
import {useTranslation} from "react-i18next"; GridRowId,
import AccountType, {AdminAccountOperation, CustomerType} from "../../components/Account"; GridRowSelectionModel,
import {useAccount} from "../AccountProvider"; } from "@mui/x-data-grid";
import {deleteAccountAdmin, fetchAccounts, updateAccountAdmin} from "../query/Queries"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import AccountType, {
AdminAccountOperation,
CustomerType,
} from "../../components/Account";
import { useAccount } from "../AccountProvider";
import {
deleteAccountAdmin,
fetchAccounts,
updateAccountAdmin,
} from "../query/Queries";
import CustomerEditDialog from "./CustomerEditDialog"; import CustomerEditDialog from "./CustomerEditDialog";
export default function AccountsInfo() { export default function AccountsInfo() {
const theme = useTheme(); const theme = useTheme();
const {t} = useTranslation(); const { t } = useTranslation();
const [customerData, setCustomerData] = useState<CustomerType>({ const [customerData, setCustomerData] = useState<CustomerType>({
id: 0, id: 0,
name: "", name: "",
surname: "", surname: "",
address: "", address: "",
zip: "", zip: "",
country: "" country: "",
}); });
async function handleCustomerEdit(account: AccountType) { async function handleCustomerEdit(account: AccountType) {
setCustomerData(account.customer); setCustomerData(account.customer);
setOpen(true); setOpen(true);
}
const [rows, setRows] = useState<AccountType[]>([]);
const [selectedRows, setSelectedRows] = useState<Set<GridRowId>>(new Set());
const { user: loginData } = useAccount();
const { data, refetch } = useQuery({
queryKey: ["fetchAccounts", loginData],
queryFn: () =>
fetchAccounts(
loginData
? loginData
: {
email: "",
password: "",
session: "",
customerId: -1,
isAdmin: false,
},
),
retry: 3,
retryDelay: 1000,
});
const deleteAccount = useMutation({
mutationFn: (user: AdminAccountOperation) => deleteAccountAdmin(user),
});
useEffect(() => {
if (data) {
setRows(data);
} }
}, [data]);
const [rows, setRows] = useState<AccountType[]>([]); const handleSelectionChange = (newSelection: GridRowSelectionModel) => {
const [selectedRows, setSelectedRows] = useState<Set<GridRowId>>(new Set()); setSelectedRows(newSelection.ids);
};
const {user: loginData} = useAccount(); const handleDeleteSelected = async () => {
selectedRows.forEach(async (rowId) => {
const {data, refetch} = useQuery({ let id = rows.find((row) => row.id === rowId)?.id;
queryKey: ["fetchAccounts", loginData], if (id === undefined) id = -1;
queryFn: () => fetchAccounts(loginData ? loginData : { await deleteAccount.mutateAsync({
email: "", email: loginData?.email || "",
password: "", uuid: loginData?.session || "",
session: "", accountId: id,
customerId: -1, });
isAdmin: false
}),
retry: 3,
retryDelay: 1000,
}); });
const deleteAccount = useMutation({ setRows(rows.filter((row) => !selectedRows.has(row.id)));
mutationFn: (user: AdminAccountOperation) => };
deleteAccountAdmin(user),
});
useEffect(() => { const updateAdmin = useMutation({
if (data) { mutationFn: (account: AccountType) =>
setRows(data); updateAccountAdmin(
} account,
}, [data]); loginData
? loginData
: {
email: "",
password: "",
session: "",
customerId: -1,
isAdmin: false,
},
),
});
const handleSelectionChange = (newSelection: GridRowSelectionModel) => { const columns: GridColDef<(typeof rows)[number]>[] = [
setSelectedRows(newSelection.ids); { field: "id", headerName: "ID", width: 60 },
}; {
field: "admin",
headerName: t("admin"),
type: "boolean",
width: 90,
editable: true,
},
{
field: "email",
headerName: t("email"),
width: 150,
editable: false,
},
{
field: "langI18n",
headerName: t("language"),
width: 150,
editable: false,
},
{
//edit billing information button
field: "customer",
headerName: t("address"),
width: 90,
sortable: false,
disableReorder: true,
disableColumnMenu: true,
renderCell: (params) => (
<IconButton onClick={() => handleCustomerEdit(params.row)}>
{" "}
<EditIcon />{" "}
</IconButton>
),
},
];
const handleDeleteSelected = async () => { const [open, setOpen] = useState(false);
selectedRows.forEach(async (rowId) => {
let id = rows.find((row) => row.id === rowId)?.id
if(id === undefined) id = -1;
await deleteAccount.mutateAsync({
email: loginData?.email || '',
uuid: loginData?.session || '',
accountId: id,
});
})
setRows(rows.filter((row) => !selectedRows.has(row.id))); const handleCustomerEditSubmit = async () => {
}; setOpen(false);
await refetch();
};
const updateAdmin = useMutation({ return (
mutationFn: (account: AccountType) => <>
updateAccountAdmin(account, loginData ? loginData : { <Box
email: "", className="page-table"
password: "", sx={{
session: "", backgroundColor: theme.palette.background.paper,
customerId: -1, color: theme.palette.text.secondary,
isAdmin: false }}
}), >
<DataGrid
}); rows={rows}
columns={columns}
const columns: GridColDef<(typeof rows)[number]>[] = [ initialState={{}}
{field: 'id', headerName: 'ID', width: 60}, checkboxSelection
{ disableRowSelectionOnClick
field: 'admin', onRowSelectionModelChange={handleSelectionChange}
headerName: t('admin'), slots={{
type: "boolean", toolbar: () => (
width: 90, <Toolbar>
editable: true <Button
}, variant="contained"
{ color="error"
field: 'email', startIcon={<DeleteIcon />}
headerName: t('email'), onClick={handleDeleteSelected}
width: 150, disabled={selectedRows.size === 0}
editable: false, sx={{
}, marginRight: 1,
{ }}
field: 'langI18n', >
headerName: t('language'), {t("deleteAccount")}
width: 150, </Button>
editable: false, </Toolbar>
}, ),
{ //edit billing information button }}
field: "customer", showToolbar
headerName: t('address'), processRowUpdate={async (updatedRow) => {
width: 90, const originalRow = rows.find((row) => row.id === updatedRow.id);
sortable: false, if (originalRow && originalRow.admin !== updatedRow.admin) {
disableReorder: true, await updateAdmin.mutateAsync(updatedRow);
disableColumnMenu: true, }
renderCell: params => <IconButton onClick={() => handleCustomerEdit(params.row)}> <EditIcon/> </IconButton>, setRows(
} rows.map((row) => (row.id === updatedRow.id ? updatedRow : row)),
]; );
return updatedRow;
const [open, setOpen] = useState(false); }}
/>
const handleCustomerEditSubmit = async () => { </Box>
setOpen(false); <CustomerEditDialog
await refetch(); open={open}
}; onClose={() => setOpen(false)}
onSubmit={handleCustomerEditSubmit}
return ( customerData={customerData}
<> setCustomerData={setCustomerData}
<Box />
className="page-table" </>
sx={{ );
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary
}}
>
<DataGrid
rows={rows}
columns={columns}
initialState={{}}
checkboxSelection
disableRowSelectionOnClick
onRowSelectionModelChange={handleSelectionChange}
slots={{
toolbar: () => (
<Toolbar>
<Button
variant="contained"
color="error"
startIcon={<DeleteIcon/>}
onClick={handleDeleteSelected}
disabled={selectedRows.size === 0}
sx={{
marginRight: 1
}}
>
{t('deleteAccount')}
</Button>
</Toolbar>
)
}}
showToolbar
processRowUpdate={async (updatedRow) => {
const originalRow = rows.find(row => row.id === updatedRow.id);
if (originalRow && originalRow.admin !== updatedRow.admin) {
await updateAdmin.mutateAsync(updatedRow);
}
setRows(rows.map(row => row.id === updatedRow.id ? updatedRow : row));
return updatedRow;
}}
/>
</Box>
<CustomerEditDialog
open={open}
onClose={() => setOpen(false)}
onSubmit={handleCustomerEditSubmit}
customerData={customerData}
setCustomerData={setCustomerData}
/>
</>
);
} }

View File

@@ -1,108 +1,123 @@
import {Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField} from "@mui/material"; import {
import {useMutation} from "@tanstack/react-query"; Button,
import React, {useEffect} from "react"; Dialog,
import {useTranslation} from "react-i18next"; DialogActions,
import {CustomerType} from "../../components/Account"; DialogContent,
import {updateCustomer} from "../query/Queries"; // Importiere die Funktion für die Registrierung DialogTitle,
TextField,
} from "@mui/material";
import { useMutation } from "@tanstack/react-query";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { CustomerType } from "../../components/Account";
import { updateCustomer } from "../query/Queries"; // Importiere die Funktion für die Registrierung
type CustomerDialogProps = { type CustomerDialogProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSubmit: () => void; // Funktion, die aufgerufen wird, wenn der Login erfolgreich ist onSubmit: () => void; // Funktion, die aufgerufen wird, wenn der Login erfolgreich ist
customerData: CustomerType; customerData: CustomerType;
setCustomerData: React.Dispatch<React.SetStateAction<CustomerType>>; setCustomerData: React.Dispatch<React.SetStateAction<CustomerType>>;
}; };
const CustomerEditDialog: React.FC<CustomerDialogProps> = ({ const CustomerEditDialog: React.FC<CustomerDialogProps> = ({
open, open,
onClose, onClose,
customerData, customerData,
setCustomerData, setCustomerData,
onSubmit onSubmit,
}) => { }) => {
const { t } = useTranslation();
const {t} = useTranslation(); useEffect(() => {
if (open) {
useEffect(() => { const active = document.activeElement as HTMLElement | null;
if (open) { if (active && typeof active.blur === "function") {
const active = document.activeElement as HTMLElement | null; active.blur();
if (active && typeof active.blur === "function") { }
active.blur();
}
}
}, [open]);
const changeCustomer = useMutation({
mutationFn: (customer: CustomerType) =>
updateCustomer(customer),
});
const handleSave = async () => {
const customer: CustomerType = {
id: customerData.id,
name: customerData.name,
surname: customerData.surname,
address: customerData.address,
zip: customerData.zip,
country: customerData.country,
};
await changeCustomer.mutateAsync(customer);
onSubmit(); // Rufe die onSubmit-Funktion auf, um den Dialog zu schließen und die Änderungen zu übernehmen
onClose(); // Schließe den Dialog
} }
}, [open]);
return ( const changeCustomer = useMutation({
<Dialog open={open} onClose={onClose} disableEnforceFocus> mutationFn: (customer: CustomerType) => updateCustomer(customer),
<DialogTitle>{t('changeCustomer')}</DialogTitle> });
<DialogContent>
<TextField const handleSave = async () => {
margin="dense" const customer: CustomerType = {
label={t("name")} id: customerData.id,
type="text" name: customerData.name,
fullWidth surname: customerData.surname,
value={customerData.name} address: customerData.address,
onChange={e => setCustomerData(prev => ({...prev, name: e.target.value}))} zip: customerData.zip,
/> country: customerData.country,
<TextField };
margin="dense" await changeCustomer.mutateAsync(customer);
label={t("surname")} onSubmit(); // Rufe die onSubmit-Funktion auf, um den Dialog zu schließen und die Änderungen zu übernehmen
type="text" onClose(); // Schließe den Dialog
fullWidth };
value={customerData.surname}
onChange={e => setCustomerData(prev => ({...prev, surname: e.target.value}))} return (
/> <Dialog open={open} onClose={onClose} disableEnforceFocus>
<TextField <DialogTitle>{t("changeCustomer")}</DialogTitle>
margin="dense" <DialogContent>
label={t("address")} <TextField
type="text" margin="dense"
fullWidth label={t("name")}
value={customerData.address} type="text"
onChange={e => setCustomerData(prev => ({...prev, address: e.target.value}))} fullWidth
/> value={customerData.name}
<TextField onChange={(e) =>
margin="dense" setCustomerData((prev) => ({ ...prev, name: e.target.value }))
label={t("zip")} }
type="text" />
fullWidth <TextField
value={customerData.zip} margin="dense"
onChange={e => setCustomerData(prev => ({...prev, zip: e.target.value}))} label={t("surname")}
/> type="text"
<TextField fullWidth
margin="dense" value={customerData.surname}
label={t("country")} onChange={(e) =>
type="text" setCustomerData((prev) => ({ ...prev, surname: e.target.value }))
fullWidth }
value={customerData.country} />
onChange={e => setCustomerData(prev => ({...prev, country: e.target.value}))} <TextField
/> margin="dense"
</DialogContent> label={t("address")}
<DialogActions> type="text"
<Button variant="contained" color="primary" onClick={handleSave}> fullWidth
{t("save")} value={customerData.address}
</Button> onChange={(e) =>
</DialogActions> setCustomerData((prev) => ({ ...prev, address: e.target.value }))
</Dialog> }
); />
<TextField
margin="dense"
label={t("zip")}
type="text"
fullWidth
value={customerData.zip}
onChange={(e) =>
setCustomerData((prev) => ({ ...prev, zip: e.target.value }))
}
/>
<TextField
margin="dense"
label={t("country")}
type="text"
fullWidth
value={customerData.country}
onChange={(e) =>
setCustomerData((prev) => ({ ...prev, country: e.target.value }))
}
/>
</DialogContent>
<DialogActions>
<Button variant="contained" color="primary" onClick={handleSave}>
{t("save")}
</Button>
</DialogActions>
</Dialog>
);
}; };
export default CustomerEditDialog; export default CustomerEditDialog;

View File

@@ -1,229 +1,243 @@
import React, {useState} from 'react'; import React, { useState } from "react";
import { import {
Alert, Alert,
Box, Box,
Button, Button,
CircularProgress, CircularProgress,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
IconButton, IconButton,
Typography, Typography,
} from '@mui/material'; } from "@mui/material";
import CloudUploadIcon from '@mui/icons-material/CloudUpload'; import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from "@mui/icons-material/Close";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import {Item} from '../../components/Item.tsx'; import { Item } from "../../components/Item.tsx";
interface ItemImageDialogProps { interface ItemImageDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
item: Item; item: Item;
onSuccess?: () => void; onSuccess?: () => void;
isFarmStationImage: boolean; isFarmStationImage: boolean;
} }
export default function ItemImageDialog({open, onClose, item, onSuccess, isFarmStationImage}: ItemImageDialogProps) { export default function ItemImageDialog({
const {t} = useTranslation(); open,
const [selectedFile, setSelectedFile] = useState<File | null>(null); onClose,
const [preview, setPreview] = useState<string | null>(null); item,
const [loading, setLoading] = useState(false); onSuccess,
const [error, setError] = useState<string | null>(null); isFarmStationImage,
const [success, setSuccess] = useState(false); }: ItemImageDialogProps) {
const { t } = useTranslation();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
// Validate file type // Validate file type
if (!file.type.startsWith('image/')) { if (!file.type.startsWith("image/")) {
setError('Please select a valid image file'); setError("Please select a valid image file");
return; return;
} }
// Validate file size (5MB limit) // Validate file size (5MB limit)
if (file.size > 5 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
setError('File size must be less than 5MB'); setError("File size must be less than 5MB");
return; return;
} }
setSelectedFile(file); setSelectedFile(file);
setError(null); setError(null);
// Create preview // Create preview
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
setPreview(e.target?.result as string); setPreview(e.target?.result as string);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
}; };
const convertFileToBase64 = (file: File): Promise<string> => { const convertFileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const result = reader.result as string; const result = reader.result as string;
// Remove the data URL prefix (e.g., "data:image/jpeg;base64,") // Remove the data URL prefix (e.g., "data:image/jpeg;base64,")
const base64 = result.split(',')[1]; const base64 = result.split(",")[1];
resolve(base64); resolve(base64);
}; };
reader.onerror = reject; reader.onerror = reject;
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
}; };
const handleUpload = async () => { const handleUpload = async () => {
if (!selectedFile) { if (!selectedFile) {
setError('Please select an image file'); setError("Please select an image file");
return; return;
} }
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const base64Image = await convertFileToBase64(selectedFile); const base64Image = await convertFileToBase64(selectedFile);
const response = await fetch((isFarmStationImage ? 'http://localhost:8085/farm' : 'http://localhost:8085/image') + '?uuid=' + item.uuid, { const response = await fetch(
method: 'PUT', (isFarmStationImage
headers: { ? "http://localhost:8085/farm"
'Content-Type': 'application/json', : "http://localhost:8085/image") +
}, "?uuid=" +
body: base64Image, item.uuid,
}); {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: base64Image,
},
);
if (!response.ok) { if (!response.ok) {
console.error('Failed to upload image:', await response.text()); console.error("Failed to upload image:", await response.text());
} }
setSuccess(true); setSuccess(true);
onSuccess?.(); onSuccess?.();
// Auto-close dialog after 1.5 seconds // Auto-close dialog after 1.5 seconds
setTimeout(() => { setTimeout(() => {
handleClose(); handleClose();
}, 1500); }, 1500);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to upload image");
} finally {
setLoading(false);
}
};
} catch (err) { const handleClose = () => {
setError(err instanceof Error ? err.message : 'Failed to upload image'); setSelectedFile(null);
} finally { setPreview(null);
setLoading(false); setError(null);
} setSuccess(false);
}; setLoading(false);
onClose();
};
const handleClose = () => { return (
setSelectedFile(null); <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
setPreview(null); <DialogTitle
setError(null); sx={{
setSuccess(false); display: "flex",
setLoading(false); justifyContent: "space-between",
onClose(); alignItems: "center",
}; }}
>
{t("uploadImage")} - {item.name}
<IconButton onClick={handleClose} size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
return ( <DialogContent>
<Dialog <Box sx={{ display: "flex", flexDirection: "column", gap: 2, py: 1 }}>
open={open} {/* Item Info */}
onClose={handleClose} <Box>
maxWidth="sm" <Typography variant="body2" color="textSecondary">
fullWidth {t("item")}: {item.name}
</Typography>
<Typography variant="body2" color="textSecondary">
UUID: {item.uuid}
</Typography>
<Typography variant="body2" color="error">
{t("imageUploadNotice")}
</Typography>
{isFarmStationImage && (
<Typography variant="body2" color="error">
{t("imageUploadNoticeFs")}
</Typography>
)}
</Box>
{/* File Upload */}
<Box>
<input
accept="image/*"
style={{ display: "none" }}
id="image-upload"
type="file"
onChange={handleFileSelect}
/>
<label htmlFor="image-upload">
<Button
variant="outlined"
component="span"
startIcon={<CloudUploadIcon />}
fullWidth
sx={{ mb: 2 }}
>
{selectedFile ? selectedFile.name : t("selectImage")}
</Button>
</label>
</Box>
{/* Image Preview */}
{preview && (
<Box sx={{ textAlign: "center" }}>
<img
src={preview}
alt="Preview"
style={{
maxWidth: "100%",
maxHeight: "300px",
objectFit: "contain",
border: "1px solid #ddd",
borderRadius: "4px",
}}
/>
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
{selectedFile?.name} (
{(selectedFile?.size || 0 / 1024).toFixed(1)} KB)
</Typography>
</Box>
)}
{/* Error Message */}
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Success Message */}
{success && (
<Alert severity="success">{t("imageUploadedSuccessfully")}</Alert>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={loading}>
{t("cancel")}
</Button>
<Button
onClick={handleUpload}
variant="contained"
disabled={!selectedFile || loading}
startIcon={loading ? <CircularProgress size={20} /> : undefined}
> >
<DialogTitle sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}> {loading ? t("uploading") : t("upload")}
{t('uploadImage')} - {item.name} </Button>
<IconButton onClick={handleClose} size="small"> </DialogActions>
<CloseIcon/> </Dialog>
</IconButton> );
</DialogTitle> }
<DialogContent>
<Box sx={{display: 'flex', flexDirection: 'column', gap: 2, py: 1}}>
{/* Item Info */}
<Box>
<Typography variant="body2" color="textSecondary">
{t('item')}: {item.name}
</Typography>
<Typography variant="body2" color="textSecondary">
UUID: {item.uuid}
</Typography>
<Typography variant="body2" color="error">
{t('imageUploadNotice')}
</Typography>
{isFarmStationImage && <Typography variant="body2" color="error">
{t('imageUploadNoticeFs')}
</Typography>}
</Box>
{/* File Upload */}
<Box>
<input
accept="image/*"
style={{display: 'none'}}
id="image-upload"
type="file"
onChange={handleFileSelect}
/>
<label htmlFor="image-upload">
<Button
variant="outlined"
component="span"
startIcon={<CloudUploadIcon/>}
fullWidth
sx={{mb: 2}}
>
{selectedFile ? selectedFile.name : t('selectImage')}
</Button>
</label>
</Box>
{/* Image Preview */}
{preview && (
<Box sx={{textAlign: 'center'}}>
<img
src={preview}
alt="Preview"
style={{
maxWidth: '100%',
maxHeight: '300px',
objectFit: 'contain',
border: '1px solid #ddd',
borderRadius: '4px',
}}
/>
<Typography variant="caption" display="block" sx={{mt: 1}}>
{selectedFile?.name} ({(selectedFile?.size || 0 / 1024).toFixed(1)} KB)
</Typography>
</Box>
)}
{/* Error Message */}
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Success Message */}
{success && (
<Alert severity="success">
{t('imageUploadedSuccessfully')}
</Alert>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={loading}>
{t('cancel')}
</Button>
<Button
onClick={handleUpload}
variant="contained"
disabled={!selectedFile || loading}
startIcon={loading ? <CircularProgress size={20}/> : undefined}
>
{loading ? t('uploading') : t('upload')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -3,256 +3,284 @@ import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
import { Box, Button, IconButton, Toolbar, useTheme } from "@mui/material"; import { Box, Button, IconButton, Toolbar, useTheme } from "@mui/material";
import { Gauge, gaugeClasses } from "@mui/x-charts"; import { Gauge, gaugeClasses } from "@mui/x-charts";
import { DataGrid, GridColDef, GridRowId, GridRowSelectionModel } from "@mui/x-data-grid"; import {
DataGrid,
GridColDef,
GridRowId,
GridRowSelectionModel,
} from "@mui/x-data-grid";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Item from "../../components/Item"; import Item from "../../components/Item";
import { mapValueToColor } from "../../util/ColorUtil.tsx"; import { mapValueToColor } from "../../util/ColorUtil.tsx";
import { useAccount } from "../AccountProvider.tsx"; import {
import { deleteItemQuery, fetchItems, updateItemAdmin } from "../query/Queries.tsx"; deleteItemQuery,
fetchItemList,
updateItemAdmin,
} from "../query/Queries.tsx";
import ItemImageDialog from "./ItemImageDialog.tsx"; import ItemImageDialog from "./ItemImageDialog.tsx";
import NewItemDialog from "./NewItemDialog.tsx"; import NewItemDialog from "./NewItemDialog.tsx";
export default function ItemsInfo() { export default function ItemsInfo({ itemList }: { itemList: Item[] }) {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const [rows, setRows] = useState<Item[]>([]); const [rows, setRows] = useState<Item[]>([]);
const [selectedRows, setSelectedRows] = useState<Set<GridRowId>>(new Set()); const [selectedRows, setSelectedRows] = useState<Set<GridRowId>>(new Set());
const [editImageDialog, setEditImageDialog] = useState(false); const [editImageDialog, setEditImageDialog] = useState(false);
const [newItemDialog, setNewItemDialog] = useState(false); const [newItemDialog, setNewItemDialog] = useState(false);
const [selectedItem, setSelectedItem] = useState<Item | null>(null); const [selectedItem, setSelectedItem] = useState<Item | null>(null);
const [isFarmStationImage, setIsFarmStationImage] = useState(false); const [isFarmStationImage, setIsFarmStationImage] = useState(false);
function handleImageEdit(item: Item) {
setIsFarmStationImage(false);
setSelectedItem(item);
setEditImageDialog(true);
console.log("IconEdit", item);
}
function handleImageEdit(item: Item) { function handleFarmImageEdit(item: Item) {
setIsFarmStationImage(false); setIsFarmStationImage(true);
setSelectedItem(item); setSelectedItem(item);
setEditImageDialog(true); setEditImageDialog(true);
console.log("IconEdit", item); console.log("IconEdit", item);
}
function handleAddItem() {
setNewItemDialog(true);
}
useEffect(() => {
if (itemList) {
setRows(itemList);
} }
}, [itemList]);
function handleFarmImageEdit(item: Item) { const handleSelectionChange = (newSelection: GridRowSelectionModel) => {
setIsFarmStationImage(true); setSelectedRows(newSelection.ids);
setSelectedItem(item); };
setEditImageDialog(true);
console.log("IconEdit", item);
}
const deleteItem = useMutation({
mutationFn: (uuid: string) => deleteItemQuery(uuid),
});
function handleAddItem() { const handleDeleteSelected = async () => {
setNewItemDialog(true); await Promise.all(
} Array.from(selectedRows).map(async (row) => {
await deleteItem.mutateAsync(
const { user: loginData } = useAccount(); rows.find((item) => item.id === row)?.uuid || "",
const { data } = useQuery({
queryKey: ["fetchItems", loginData],
queryFn: () => fetchItems(),
retry: 3,
retryDelay: 1000,
});
useEffect(() => {
if (data) {
setRows(data);
}
}, [data]);
const handleSelectionChange = (newSelection: GridRowSelectionModel) => {
setSelectedRows(newSelection.ids);
};
const deleteItem = useMutation({
mutationFn: (uuid: string) =>
deleteItemQuery(uuid),
});
const handleDeleteSelected = async () => {
await Promise.all(
Array.from(selectedRows).map(async (row) => {
await deleteItem.mutateAsync(rows.find(item => item.id === row)?.uuid || "");
})
); );
}),
setRows(rows.filter((row) => !selectedRows.has(row.id)));
};
const updateItem = useMutation({
mutationFn: (item: Item) =>
updateItemAdmin(item),
});
const handleRowUpdate = async (updatedRow: Item) => {
setRows(rows.map(row => row.id === updatedRow.id ? updatedRow : row));
await updateItem.mutateAsync(updatedRow);
return updatedRow;
}
const columns: GridColDef<(typeof rows)[number]>[] = [
{ field: 'id', headerName: 'ID', width: 60 },
{
field: 'uuid',
headerName: t('uuid'),
type: "string",
width: 120,
editable: false
},
{
field: 'name',
headerName: t('name'),
width: 200,
editable: true,
},
{
field: 'category',
headerName: t('category'),
width: 150,
editable: true,
valueFormatter: (val) => t(val),
},
{
field: 'description',
headerName: t('description'),
width: 150,
editable: true,
},
{
field: 'price100',
headerName: t('price100€'),
width: 100,
editable: true,
type: 'number',
valueFormatter: (val) => (val / 100).toFixed(2),
},
{
field: 'discount100',
headerName: t('discount100'),
width: 120,
editable: true,
type: 'number'
},
{
field: 'stock',
headerName: t('stock'),
width: 100,
editable: true,
type: 'number',
renderCell: params => <Gauge value={Math.min(params.row.stock, params.row.stockExpected)} valueMin={0}
valueMax={params.row.stockExpected} startAngle={-90} endAngle={90} sx={{
[`& .${gaugeClasses.valueArc}`]: {
fill: () => {
return mapValueToColor(0, params.row.stockExpected, params.row.stock)
},
},
}} text={() => `${params.row.stock}`} />
},
{
field: 'rating',
headerName: t('rating'),
width: 100,
editable: false, //the rating is averaged from ratings
type: 'number',
renderCell: params => <Gauge value={Math.min(params.row.rating, 10)} valueMin={0} valueMax={10}
startAngle={-90} endAngle={90} sx={{
[`& .${gaugeClasses.valueArc}`]: {
fill: () => {
return mapValueToColor(0, 10, params.row.rating)
},
},
}} text={() => `${params.row.rating.toFixed(2)}`} />
},
{
field: "actualPrice",
headerName: t('actualPrice'),
width: 90,
editable: false,
valueGetter: (_, row) => (row.price100 / 100 * ((100 - row.discount100) / 100)).toFixed(2)
},
{
field: 'images',
headerName: t('images'),
width: 90,
editable: false,
renderCell: params => <IconButton onClick={() => handleImageEdit(params.row)}> <EditIcon /> </IconButton>,
},
{
field: 'farmImage',
headerName: t('fsImage'),
width: 90,
editable: false,
renderCell: params => <IconButton onClick={() => handleFarmImageEdit(params.row)}> <EditIcon />
</IconButton>,
}
];
return (
<Box
className="page-table"
sx={{
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary
}}
>
<DataGrid
rows={rows}
columns={columns}
initialState={{}}
checkboxSelection
disableRowSelectionOnClick
onRowSelectionModelChange={handleSelectionChange}
slots={{
toolbar: () => (
<Toolbar>
<Button
variant="contained"
color="error"
startIcon={<DeleteIcon />}
onClick={handleDeleteSelected}
disabled={selectedRows.size === 0}
sx={{
marginRight: 1
}}
>
{t('deleteProduct')}
</Button>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon/>}
onClick={handleAddItem}
sx={{
marginRight: 1
}}
>
{t('addProduct')}
</Button>
</Toolbar>
)
}}
showToolbar
processRowUpdate={handleRowUpdate}
/>
{selectedItem && (
<ItemImageDialog
open={editImageDialog}
onClose={() => setEditImageDialog(false)}
item={selectedItem}
onSuccess={() => {
// Refresh data or update UI
console.log('Image uploaded successfully');
}}
isFarmStationImage={isFarmStationImage}
/>
)}
<NewItemDialog
open={newItemDialog}
onClose={() => setNewItemDialog(false)}
/>
</Box>
); );
}
setRows(rows.filter((row) => !selectedRows.has(row.id)));
};
const updateItem = useMutation({
mutationFn: (item: Item) => updateItemAdmin(item),
});
const handleRowUpdate = async (updatedRow: Item) => {
setRows(rows.map((row) => (row.id === updatedRow.id ? updatedRow : row)));
await updateItem.mutateAsync(updatedRow);
return updatedRow;
};
const columns: GridColDef<(typeof rows)[number]>[] = [
{ field: "id", headerName: "ID", width: 60 },
{
field: "uuid",
headerName: t("uuid"),
type: "string",
width: 120,
editable: false,
},
{
field: "name",
headerName: t("name"),
width: 200,
editable: true,
},
{
field: "category",
headerName: t("category"),
width: 150,
editable: true,
valueFormatter: (val) => t(val),
},
{
field: "description",
headerName: t("description"),
width: 150,
editable: true,
},
{
field: "price100",
headerName: t("price100€"),
width: 100,
editable: true,
type: "number",
valueFormatter: (val) => (val / 100).toFixed(2),
},
{
field: "discount100",
headerName: t("discount100"),
width: 120,
editable: true,
type: "number",
},
{
field: "stock",
headerName: t("stock"),
width: 100,
editable: true,
type: "number",
renderCell: (params) => (
<Gauge
value={Math.min(params.row.stock, params.row.stockExpected)}
valueMin={0}
valueMax={params.row.stockExpected}
startAngle={-90}
endAngle={90}
sx={{
[`& .${gaugeClasses.valueArc}`]: {
fill: () => {
return mapValueToColor(
0,
params.row.stockExpected,
params.row.stock,
);
},
},
}}
text={() => `${params.row.stock}`}
/>
),
},
{
field: "rating",
headerName: t("rating"),
width: 100,
editable: false, //the rating is averaged from ratings
type: "number",
renderCell: (params) => (
<Gauge
value={Math.min(params.row.rating, 10)}
valueMin={0}
valueMax={10}
startAngle={-90}
endAngle={90}
sx={{
[`& .${gaugeClasses.valueArc}`]: {
fill: () => {
return mapValueToColor(0, 10, params.row.rating);
},
},
}}
text={() => `${params.row.rating.toFixed(2)}`}
/>
),
},
{
field: "actualPrice",
headerName: t("actualPrice"),
width: 90,
editable: false,
valueGetter: (_, row) =>
((row.price100 / 100) * ((100 - row.discount100) / 100)).toFixed(2),
},
{
field: "images",
headerName: t("images"),
width: 90,
editable: false,
renderCell: (params) => (
<IconButton onClick={() => handleImageEdit(params.row)}>
{" "}
<EditIcon />{" "}
</IconButton>
),
},
{
field: "farmImage",
headerName: t("fsImage"),
width: 90,
editable: false,
renderCell: (params) => (
<IconButton onClick={() => handleFarmImageEdit(params.row)}>
{" "}
<EditIcon />
</IconButton>
),
},
];
return (
<Box
className="page-table"
sx={{
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
}}
>
<DataGrid
rows={rows}
columns={columns}
initialState={{}}
checkboxSelection
disableRowSelectionOnClick
onRowSelectionModelChange={handleSelectionChange}
slots={{
toolbar: () => (
<Toolbar>
<Button
variant="contained"
color="error"
startIcon={<DeleteIcon />}
onClick={handleDeleteSelected}
disabled={selectedRows.size === 0}
sx={{
marginRight: 1,
}}
>
{t("deleteProduct")}
</Button>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={handleAddItem}
sx={{
marginRight: 1,
}}
>
{t("addProduct")}
</Button>
</Toolbar>
),
}}
showToolbar
processRowUpdate={handleRowUpdate}
/>
{selectedItem && (
<ItemImageDialog
open={editImageDialog}
onClose={() => setEditImageDialog(false)}
item={selectedItem}
onSuccess={() => {
// Refresh data or update UI
console.log("Image uploaded successfully");
}}
isFarmStationImage={isFarmStationImage}
/>
)}
<NewItemDialog
open={newItemDialog}
onClose={() => setNewItemDialog(false)}
/>
</Box>
);
}

View File

@@ -1,166 +1,164 @@
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from "@mui/icons-material/Close";
import { import {
Alert, Alert,
Box, Box,
Button, Button,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
IconButton, IconButton,
TextField TextField,
} from '@mui/material'; } from "@mui/material";
import {useMutation} from '@tanstack/react-query'; import { useMutation } from "@tanstack/react-query";
import {useState} from 'react'; import { useState } from "react";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import {Item} from '../../components/Item'; import { Item } from "../../components/Item";
import {submitItem} from '../query/Queries'; import { submitItem } from "../query/Queries";
interface NewItemDialogProps { interface NewItemDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
} }
export default function NewItemDialog({open, onClose}: NewItemDialogProps) { export default function NewItemDialog({ open, onClose }: NewItemDialogProps) {
const {t} = useTranslation(); const { t } = useTranslation();
const [item, setItem] = useState<Item>({ const [item, setItem] = useState<Item>({
id: 0, id: 0,
uuid: "", uuid: "",
name: "", name: "",
description: "", description: "",
price100: 0, price100: 0,
stock: 0, stock: 0,
stockExpected: 1, stockExpected: 1,
category: "", category: "",
rating: -1, rating: -1,
discount100: 0, discount100: 0,
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const handleClose = () => { const handleClose = () => {
setError(null); setError(null);
setSuccess(false); setSuccess(false);
setLoading(false); setLoading(false);
onClose(); onClose();
}; };
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setItem({...item, [e.target.name]: e.target.value}); setItem({ ...item, [e.target.name]: e.target.value });
}; };
const saveItem = useMutation({ const saveItem = useMutation({
mutationFn: (item: Item) => mutationFn: (item: Item) => submitItem(item),
submitItem(item), });
});
const handleSave = async () => { const handleSave = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setSuccess(false); setSuccess(false);
await saveItem.mutateAsync(item) await saveItem.mutateAsync(item);
onClose(); // Close the dialog after saving onClose(); // Close the dialog after saving
setLoading(false); setLoading(false);
}; };
return ( return (
<Dialog <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
open={open} <DialogTitle
onClose={handleClose} sx={{
maxWidth="sm" display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
{t("createNewItem")}
<IconButton onClick={handleClose} size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, py: 1 }}>
{/* Name, Kategorie, Beschreibung, Preis, Rabatt, Bestand, Bestand erwartet */}
<TextField
label={t("name")}
name="name"
value={item.name}
onChange={handleChange}
fullWidth fullWidth
> />
<DialogTitle sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}> <TextField
{t('createNewItem')} label={t("category")}
<IconButton onClick={handleClose} size="small"> name="category"
<CloseIcon/> value={item.category}
</IconButton> onChange={handleChange}
</DialogTitle> fullWidth
/>
<TextField
label={t("description")}
name="description"
value={item.description}
onChange={handleChange}
fullWidth
/>
<TextField
label={t("price100")}
name="price100"
value={item.price100}
onChange={handleChange}
fullWidth
type="number"
/>
<TextField
label={t("discount100")}
name="discount100"
value={item.discount100}
onChange={handleChange}
fullWidth
type="number"
/>
<TextField
label={t("stockExpected")}
name="stockExpected"
value={item.stockExpected}
onChange={handleChange}
fullWidth
type="number"
/>
<TextField
label={t("stock")}
name="stock"
value={item.stock}
onChange={handleChange}
fullWidth
type="number"
/>
<DialogContent> {/* Error Message */}
<Box sx={{display: 'flex', flexDirection: 'column', gap: 2, py: 1}}> {error && (
{/* Name, Kategorie, Beschreibung, Preis, Rabatt, Bestand, Bestand erwartet */} <Alert severity="error" onClose={() => setError(null)}>
<TextField {error}
label={t("name")} </Alert>
name="name" )}
value={item.name}
onChange={handleChange}
fullWidth
/>
<TextField
label={t("category")}
name="category"
value={item.category}
onChange={handleChange}
fullWidth
/>
<TextField
label={t("description")}
name="description"
value={item.description}
onChange={handleChange}
fullWidth
/>
<TextField
label={t("price100")}
name="price100"
value={item.price100}
onChange={handleChange}
fullWidth
type='number'
/>
<TextField
label={t("discount100")}
name="discount100"
value={item.discount100}
onChange={handleChange}
fullWidth
type='number'
/>
<TextField
label={t("stockExpected")}
name="stockExpected"
value={item.stockExpected}
onChange={handleChange}
fullWidth
type='number'
/>
<TextField
label={t("stock")}
name="stock"
value={item.stock}
onChange={handleChange}
fullWidth
type='number'
/>
{/* Error Message */} {/* Success Message */}
{error && ( {success && (
<Alert severity="error" onClose={() => setError(null)}> <Alert severity="success">{t("itemCreatedSuccessfully")}</Alert>
{error} )}
</Alert> </Box>
)} </DialogContent>
{/* Success Message */} <DialogActions>
{success && ( <Button onClick={handleSave} disabled={loading}>
<Alert severity="success"> {t("save")}
{t('itemCreatedSuccessfully')} </Button>
</Alert> <Button onClick={handleClose} disabled={loading}>
)} {t("cancel")}
</Box> </Button>
</DialogContent> </DialogActions>
</Dialog>
<DialogActions> );
<Button onClick={handleSave} disabled={loading}> }
{t('save')}
</Button>
<Button onClick={handleClose} disabled={loading}>
{t('cancel')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -1,231 +1,251 @@
import { import {
Button, Button,
Card, Card,
CardContent, CardContent,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Divider, Divider,
List, List,
ListItemText, ListItemText,
Snackbar, Snackbar,
Stack, Stack,
Typography, Typography,
useTheme useTheme,
} from "@mui/material"; } from "@mui/material";
import {useMutation, useQuery} from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import React, {PropsWithChildren, useState} from "react"; import React, { PropsWithChildren, useState } from "react";
import {DndProvider, useDrag, useDrop} from 'react-dnd'; import { DndProvider, useDrag, useDrop } from "react-dnd";
import {HTML5Backend} from 'react-dnd-html5-backend'; import { HTML5Backend } from "react-dnd-html5-backend";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import OrderType, {OrderPatch, OrderStatusEnum} from "../../components/Order"; import OrderType, { OrderPatch, OrderStatusEnum } from "../../components/Order";
import {useAccount} from "../AccountProvider"; import { useAccount } from "../AccountProvider";
import {fetchOrdersAdmin, orderPatch} from "../query/Queries"; import { fetchOrdersAdmin, orderPatch } from "../query/Queries";
import Item from "../../components/Item";
// The order in which the statuses are displayed // The order in which the statuses are displayed
const statusOrder: OrderStatusEnum[] = [ const statusOrder: OrderStatusEnum[] = [
OrderStatusEnum.CANCELLED, OrderStatusEnum.CANCELLED,
OrderStatusEnum.ISSUES, OrderStatusEnum.ISSUES,
OrderStatusEnum.ORDERED, OrderStatusEnum.ORDERED,
OrderStatusEnum.IN_PROGRESS, OrderStatusEnum.IN_PROGRESS,
OrderStatusEnum.DELIVERED OrderStatusEnum.DELIVERED,
]; ];
const OrderCard: React.FC<{ order: OrderType; onClick: () => void }> = ({
order,
onClick,
}) => {
const { t } = useTranslation();
const OrderCard: React.FC<{ order: OrderType; onClick: () => void }> = ({order, onClick}) => { const [{ isDragging }, drag] = useDrag(() => ({
type: "order",
item: { id: order.id },
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
}));
const {t} = useTranslation(); return (
<div ref={drag} style={{ opacity: isDragging ? 0.5 : 1, marginBottom: 8 }}>
const [{isDragging}, drag] = useDrag(() => ({ <Card
type: 'order', elevation={4}
item: {id: order.id}, sx={{
collect: (monitor) => ({ m: 1,
isDragging: !!monitor.isDragging(), }}
}), >
})); <CardContent onClick={onClick}>
<Typography gutterBottom variant="h5" component="div">
return ( Order: {order.id}
<div ref={drag} style={{opacity: isDragging ? 0.5 : 1, marginBottom: 8}}> </Typography>
<Card elevation={4} sx={{ <Typography>
m: 1 {t("date") + ": " + new Date(order.time).toUTCString()}
}}> </Typography>
<CardContent onClick={onClick}> <Typography>
<Typography gutterBottom variant="h5" component="div"> {t("total") + ": " + (order.total / 100).toFixed(2) + " €"}
Order: {order.id} </Typography>
</Typography> </CardContent>
<Typography> </Card>
{t('date') + ": " + new Date(order.time).toUTCString()} </div>
</Typography> );
<Typography>
{t('total') + ": " + (order.total / 100).toFixed(2) + " €"}
</Typography>
</CardContent>
</Card>
</div>
);
}; };
const Column: React.FC<PropsWithChildren<{ const Column: React.FC<
PropsWithChildren<{
status: OrderStatusEnum; status: OrderStatusEnum;
onDrop: (id: number, status: OrderStatusEnum) => void onDrop: (id: number, status: OrderStatusEnum) => void;
}>> = ({status, onDrop, children}) => { }>
> = ({ status, onDrop, children }) => {
const { t } = useTranslation();
const theme = useTheme();
const {t} = useTranslation(); const [{ isOver }, drop] = useDrop(() => ({
const theme = useTheme(); accept: "order",
drop: (item: { id: number }) => onDrop(item.id, status),
collect: (monitor) => ({
isOver: !!monitor.isOver(),
}),
}));
const [{isOver}, drop] = useDrop(() => ({ return (
accept: 'order', <div
drop: (item: { id: number }) => onDrop(item.id, status), ref={drop}
collect: (monitor) => ({ style={{
isOver: !!monitor.isOver(), flex: 1,
}), backgroundColor: isOver
})); ? theme.palette.background.paper
: "transparent",
return ( minWidth: 300,
<div ref={drop} style={{ maxWidth: 400,
}}
>
<Card
sx={{
minHeight: "100%",
marginTop: 2,
marginLeft: 1,
height: "80vh",
display: "flex",
flexDirection: "column",
}}
elevation={1}
>
<CardContent
sx={{
flex: 1, flex: 1,
backgroundColor: isOver ? theme.palette.background.paper : 'transparent', display: "flex",
minWidth: 300, flexDirection: "column",
maxWidth: 400 overflowY: "auto",
}}> height: "100%",
<Card sx={{ }}
minHeight: '100%', >
marginTop: 2, <Typography variant="h6">{t(status)}</Typography>
marginLeft: 1, <div style={{ flex: 1, overflowY: "auto" }}>{children}</div>
height: '80vh', </CardContent>
display: 'flex', </Card>
flexDirection: 'column' </div>
}} elevation={1}> );
<CardContent sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
height: '100%',
}}>
<Typography variant="h6">{t(status)}</Typography>
<div style={{flex: 1, overflowY: 'auto'}}>
{children}
</div>
</CardContent>
</Card>
</div>
);
}; };
const EditOrder: React.FC<{ open: boolean; order: OrderType | null; onClose: () => void }> = ({ const EditOrder: React.FC<{
open, open: boolean;
order, order: OrderType | null;
onClose articleNameMapping: (uuid: string) => string;
}) => { onClose: () => void;
}> = ({ open, order, articleNameMapping, onClose }) => {
const {t} = useTranslation(); const { t } = useTranslation();
if (order === null) if (order === null) return "";
return ""; return (
return ( <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> <DialogTitle>{`${t(order.status)} #${order?.id}`}</DialogTitle>
<DialogTitle> <DialogContent dividers>
{`${t(order.status)} #${order?.id}`} {order && (
</DialogTitle> <Stack spacing={2}>
<DialogContent dividers> <Typography variant="subtitle1">{`${t("orderDate")}: ${new Date(order.time).toDateString()}`}</Typography>
{order && ( <Divider />
<Stack spacing={2}> <Typography variant="subtitle2">{t("orderedItems")}:</Typography>
<Typography <List dense>
variant="subtitle1">{`${t('orderDate')}: ${new Date(order.time).toDateString()}`}</Typography> {order.orderItems.map((item, idx) => (
<Divider/> <ListItemText
<Typography variant="subtitle2">{t('orderedItems')}:</Typography> key={idx}
<List dense> primary={`${item.amount}x ${articleNameMapping(item.article)}`}
{order.orderItems.map((item, idx) => ( />
<ListItemText ))}
key={idx} </List>
primary={`${item.article} x${item.amount}`} <Divider />
/> <Typography variant="h6">{`${t("sum")}: ${(order.total / 100).toFixed(2)}`}</Typography>
))} </Stack>
</List> )}
<Divider/> </DialogContent>
<Typography variant="h6">{`${t('sum')}: ${(order.total / 100).toFixed(2)}`}</Typography> <DialogActions>
</Stack> <Button onClick={onClose}>{t("close")}</Button>
)} </DialogActions>
</DialogContent> </Dialog>
<DialogActions> );
<Button onClick={onClose}>{t('close')}</Button>
</DialogActions>
</Dialog>
);
}; };
export default function OrdersInfo() { export default function OrdersInfo({ itemList }: { itemList: Item[] }) {
const [editOrder, setEditOrder] = useState<OrderType | null>(null); const [editOrder, setEditOrder] = useState<OrderType | null>(null);
const [openSnackbar, setOpenSnackbar] = useState(false); const [openSnackbar, setOpenSnackbar] = useState(false);
const {user: loginData} = useAccount(); const { user: loginData } = useAccount();
const {data, refetch, isLoading} = useQuery({ const { data, refetch, isLoading } = useQuery<OrderType[]>({
queryKey: ["fetchOrdersAdmin", loginData], queryKey: ["fetchOrdersAdmin", loginData],
queryFn: () => fetchOrdersAdmin(loginData ? loginData : { queryFn: () =>
email: "", fetchOrdersAdmin(
password: "", loginData
session: "", ? loginData
customerId: -1, : {
isAdmin: false email: "",
}), password: "",
retry: 3, session: "",
retryDelay: 1000, customerId: -1,
}); isAdmin: false,
},
),
retry: 3,
retryDelay: 1000,
});
const patchOrderMutation = useMutation({ const patchOrderMutation = useMutation({
mutationFn: (order: OrderPatch) => mutationFn: (order: OrderPatch) =>
orderPatch({id: order.id, status: order.status}), orderPatch({ id: order.id, status: order.status }),
}); });
const handleDrop = async (id: number, status: OrderStatusEnum) => {
const handleDrop = async (id: number, status: OrderStatusEnum) => { const currentOrders = data ?? [];
const obj = currentOrders.find((o) => o.id === id);
const currentOrders = data ?? []; if (!obj) {
const obj = currentOrders.find((o) => o.id === id); setOpenSnackbar(true);
if (!obj) { return;
setOpenSnackbar(true);
return;
}
try {
await patchOrderMutation.mutateAsync({id: obj.id, status: status});
await refetch();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
setOpenSnackbar(true);
}
};
const handleEdit = (order: OrderType) => setEditOrder(order);
if (isLoading || !data) {
return <div>Lade Bestellungen...</div>;
} }
try {
await patchOrderMutation.mutateAsync({ id: obj.id, status: status });
await refetch();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
setOpenSnackbar(true);
}
};
const handleEdit = (order: OrderType) => setEditOrder(order);
return ( if (isLoading || !data) {
<DndProvider backend={HTML5Backend}> return <div>Lade Bestellungen...</div>;
<div style={{display: 'flex', gap: 10, minHeight: '90%'}}> }
{statusOrder.map((status) => (
<Column key={status} status={status} onDrop={handleDrop}> if (!itemList) {
{data return <div>itemlist empty</div>;
.filter((o) => o.status === status) }
.map((o) => (
<OrderCard key={o.id} order={o} onClick={() => handleEdit(o)}/> return (
))} <DndProvider backend={HTML5Backend}>
</Column> <div style={{ display: "flex", gap: 10, minHeight: "90%" }}>
))} {statusOrder.map((status) => (
</div> <Column key={status} status={status} onDrop={handleDrop}>
<EditOrder {data
open={!!editOrder} .filter((o) => o.status === status)
order={editOrder} .map((o) => (
onClose={() => setEditOrder(null)} <OrderCard key={o.id} order={o} onClick={() => handleEdit(o)} />
/> ))}
<Snackbar </Column>
open={openSnackbar} ))}
autoHideDuration={4000} </div>
onClose={() => setOpenSnackbar(false)} <EditOrder
message="Failed changing Orderstatus" open={!!editOrder}
/> order={editOrder}
</DndProvider> articleNameMapping={(uuid: string) => {
); return itemList.find((item) => item.uuid == uuid)?.name || uuid;
}}
onClose={() => setEditOrder(null)}
/>
<Snackbar
open={openSnackbar}
autoHideDuration={4000}
onClose={() => setOpenSnackbar(false)}
message="Failed changing Orderstatus"
/>
</DndProvider>
);
} }

View File

@@ -1,290 +1,339 @@
import {Box, Typography, useTheme} from "@mui/material"; import { Box, Typography, useTheme } from "@mui/material";
import {BarChart} from '@mui/x-charts/BarChart'; import { BarChart } from "@mui/x-charts/BarChart";
import {PieChart} from '@mui/x-charts/PieChart'; import { PieChart } from "@mui/x-charts/PieChart";
import {BarSeriesType} from '@mui/x-charts' import { BarSeriesType } from "@mui/x-charts";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import {useQuery} from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import {fetchOrderStatus, fetchStatisticsRevenue, fetchStatisticsVolume, fetchStockPercent} from "../query/Queries.tsx"; import {
import {useAccount} from "../AccountProvider.tsx"; fetchOrderStatus,
import {getColorFromPercent} from "../../util/ColorUtil.tsx"; fetchStatisticsRevenue,
fetchStatisticsVolume,
fetchStockPercent,
} from "../query/Queries.tsx";
import { useAccount } from "../AccountProvider.tsx";
import { getColorFromPercent } from "../../util/ColorUtil.tsx";
export default function StatisticsInfo() { export default function StatisticsInfo() {
const theme = useTheme(); const theme = useTheme();
const {t} = useTranslation(); const { t } = useTranslation();
const [monthlyVolume, setMonthlyVolume] = useState<BarSeriesType[]>([]); const [monthlyVolume, setMonthlyVolume] = useState<BarSeriesType[]>([]);
const [monthlyVolumeXaxis, setMonthlyVolumeXaxis] = useState([{data: []}]); const [monthlyVolumeXaxis, setMonthlyVolumeXaxis] = useState([{ data: [] }]);
const [totalVolume, setTotalVolume] = useState([]); const [totalVolume, setTotalVolume] = useState([]);
const [monthlyRevenue, setMonthlyRevenue] = useState<BarSeriesType[]>([]); const [monthlyRevenue, setMonthlyRevenue] = useState<BarSeriesType[]>([]);
const [monthlyRevenueXaxis, setMonthlyRevenueXaxis] = useState([{data: []}]); const [monthlyRevenueXaxis, setMonthlyRevenueXaxis] = useState([
const [totalRevenue, setTotalRevenue] = useState([]); { data: [] },
]);
const [totalRevenue, setTotalRevenue] = useState([]);
const [orderStatus, setOrderStatus] = useState([]); const [orderStatus, setOrderStatus] = useState([]);
const [stockPercent, setStockPercent] = useState([]); const [stockPercent, setStockPercent] = useState([]);
const { user: loginData } = useAccount();
const {user: loginData} = useAccount(); const { data: dataVolume } = useQuery({
queryKey: ["fetchStatisticsVolume", loginData],
queryFn: () =>
fetchStatisticsVolume(
loginData
? loginData
: {
email: "",
password: "",
session: "",
customerId: -1,
isAdmin: false,
},
),
retry: 0,
retryDelay: 0,
});
const { data: dataRevenue } = useQuery({
queryKey: ["fetchStatisticsRevenue", loginData],
queryFn: () =>
fetchStatisticsRevenue(
loginData
? loginData
: {
email: "",
password: "",
session: "",
customerId: -1,
isAdmin: false,
},
),
retry: 0,
retryDelay: 0,
});
const {data: dataVolume} = useQuery({ const { data: dataOrderStatus } = useQuery({
queryKey: ["fetchStatisticsVolume", loginData], queryKey: ["fetchOrderStatus", loginData],
queryFn: () => fetchStatisticsVolume(loginData ? loginData : { queryFn: () =>
email: "", fetchOrderStatus(
password: "", loginData
session: "", ? loginData
customerId: -1, : {
isAdmin: false email: "",
}), password: "",
retry: 0, session: "",
retryDelay: 0, customerId: -1,
}); isAdmin: false,
},
),
retry: 0,
retryDelay: 0,
});
const {data: dataRevenue} = useQuery({ const { data: dataStockPercent } = useQuery({
queryKey: ["fetchStatisticsRevenue", loginData], queryKey: ["fetchStockPercent", loginData],
queryFn: () => fetchStatisticsRevenue(loginData ? loginData : { queryFn: () =>
email: "", fetchStockPercent(
password: "", loginData
session: "", ? loginData
customerId: -1, : {
isAdmin: false email: "",
}), password: "",
retry: 0, session: "",
retryDelay: 0, customerId: -1,
}); isAdmin: false,
},
),
retry: 0,
retryDelay: 0,
});
const {data: dataOrderStatus} = useQuery({ useEffect(() => {
queryKey: ["fetchOrderStatus", loginData], if (dataVolume) {
queryFn: () => fetchOrderStatus(loginData ? loginData : { const cmm = [];
email: "", const cmmx = monthlyVolumeXaxis;
password: "", const tv = [];
session: "", let i = 0;
customerId: -1, for (const cat in dataVolume.catMonthMap) {
isAdmin: false for (const timestamp in dataVolume.catMonthMap[cat]) {
}), const date = new Date(parseInt(timestamp));
retry: 0, const formattedDate =
retryDelay: 0, date.getFullYear() +
}); "-" +
String(date.getMonth() + 1).padStart(2, "0");
if (!cmmx[0].data.includes(formattedDate)) {
cmmx[0].data.push(formattedDate);
}
const datapoint = dataVolume.catMonthMap[cat][timestamp];
if (cmm.length == i) {
cmm.push({ id: i, data: [], label: t(cat), type: "bar" });
}
cmm[i].data.push(datapoint);
const {data: dataStockPercent} = useQuery({ if (tv.length == i) {
queryKey: ["fetchStockPercent", loginData], tv.push({ id: i, value: 0, label: t(cat) });
queryFn: () => fetchStockPercent(loginData ? loginData : { }
email: "", tv[i].value += datapoint;
password: "",
session: "",
customerId: -1,
isAdmin: false
}),
retry: 0,
retryDelay: 0,
});
useEffect(() => {
if (dataVolume) {
const cmm = []
const cmmx = monthlyVolumeXaxis
const tv = []
let i = 0
for (const cat in dataVolume.catMonthMap) {
for (const timestamp in dataVolume.catMonthMap[cat]) {
const date = new Date(parseInt(timestamp))
const formattedDate = date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, '0')
if (!cmmx[0].data.includes(formattedDate)) {
cmmx[0].data.push(formattedDate);
}
const datapoint = dataVolume.catMonthMap[cat][timestamp]
if (cmm.length == i) {
cmm.push({id: i, data: [], label: t(cat), type: "bar"})
}
cmm[i].data.push(datapoint)
if (tv.length == i) {
tv.push({id: i, value: 0, label: t(cat)})
}
tv[i].value += datapoint
}
i++;
}
setMonthlyVolume(cmm)
setMonthlyVolumeXaxis(cmmx)
setTotalVolume(tv)
} }
}, [dataVolume]); i++;
}
useEffect(() => { setMonthlyVolume(cmm);
if (dataRevenue) { setMonthlyVolumeXaxis(cmmx);
const cmm = [] setTotalVolume(tv);
const cmmx = monthlyRevenueXaxis }
const tv = [] }, [dataVolume]);
let i = 0
for (const cat in dataRevenue.catMonthMap) {
for (const timestamp in dataRevenue.catMonthMap[cat]) {
const date = new Date(parseInt(timestamp))
const formattedDate = date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, '0')
if (!cmmx[0].data.includes(formattedDate)) {
cmmx[0].data.push(formattedDate);
}
const datapoint = dataRevenue.catMonthMap[cat][timestamp] / 100
if (cmm.length == i) {
cmm.push({id: i, data: [], label: t(cat), type: "bar"})
}
cmm[i].data.push(datapoint)
if (tv.length == i) { useEffect(() => {
tv.push({id: i, value: 0, label: t(cat)}) if (dataRevenue) {
} const cmm = [];
tv[i].value += datapoint const cmmx = monthlyRevenueXaxis;
} const tv = [];
i++; let i = 0;
} for (const cat in dataRevenue.catMonthMap) {
for (const timestamp in dataRevenue.catMonthMap[cat]) {
const date = new Date(parseInt(timestamp));
const formattedDate =
date.getFullYear() +
"-" +
String(date.getMonth() + 1).padStart(2, "0");
if (!cmmx[0].data.includes(formattedDate)) {
cmmx[0].data.push(formattedDate);
}
const datapoint = dataRevenue.catMonthMap[cat][timestamp] / 100;
if (cmm.length == i) {
cmm.push({ id: i, data: [], label: t(cat), type: "bar" });
}
cmm[i].data.push(datapoint);
setMonthlyRevenue(cmm) if (tv.length == i) {
setMonthlyRevenueXaxis(cmmx) tv.push({ id: i, value: 0, label: t(cat) });
setTotalRevenue(tv) }
tv[i].value += datapoint;
} }
}, [dataRevenue, monthlyRevenueXaxis, t]); i++;
}
useEffect(() => { setMonthlyRevenue(cmm);
if (dataOrderStatus) { setMonthlyRevenueXaxis(cmmx);
const orderStatus = [] setTotalRevenue(tv);
for (const status in dataOrderStatus) { }
orderStatus.push({value: dataOrderStatus[status], label: t(status)}) }, [dataRevenue, monthlyRevenueXaxis, t]);
}
setOrderStatus(orderStatus) useEffect(() => {
if (dataOrderStatus) {
const orderStatus = [];
for (const status in dataOrderStatus) {
orderStatus.push({ value: dataOrderStatus[status], label: t(status) });
}
setOrderStatus(orderStatus);
}
}, [dataOrderStatus, t]);
useEffect(() => {
function generateName(percent: string): string {
return ">" + percent + "%";
}
if (dataStockPercent) {
const stockPercent = [];
let i = 0;
for (let x = 0; x < 10; x++) {
stockPercent.push({
value: 0,
label: generateName(String(x * 10)),
color: getColorFromPercent(String(x * 10)),
});
}
for (const cat in dataStockPercent) {
for (const percent in dataStockPercent[cat]) {
let index = stockPercent.findIndex(
(entry) => entry.label == generateName(percent),
);
const datapoint = dataStockPercent[cat][percent];
if (index === -1) {
index =
stockPercent.push({
value: 0,
label: generateName(percent),
color: getColorFromPercent(percent),
}) - 1;
}
stockPercent[index].value += datapoint;
} }
}, [dataOrderStatus, t]); i++;
}
setStockPercent(stockPercent);
}
}, [dataStockPercent]);
useEffect(() => { return (
function generateName(percent: string): string { <Box className="" sx={{ color: theme.palette.text.primary }}>
return ">" + percent + "%"; <Typography mt={4} variant="h4" align="center" gutterBottom>
} {t("salesStatistics")}
</Typography>
if (dataStockPercent) { <Box sx={{ mb: 4 }}>
const stockPercent = [] <Typography variant="h6" align="center" gutterBottom>
let i = 0 {t("monthlySalesVolume")}
for (let x = 0; x < 10; x++) { </Typography>
stockPercent.push({ <BarChart
value: 0, series={monthlyVolume}
label: generateName(String(x * 10)), height={290}
color: getColorFromPercent(String(x * 10)) xAxis={monthlyVolumeXaxis}
}); />
} </Box>
for (const cat in dataStockPercent) { <Box sx={{ mb: 4 }}>
for (const percent in dataStockPercent[cat]) { <Typography variant="h6" align="center" gutterBottom>
let index = stockPercent.findIndex((entry) => entry.label == generateName(percent)) {t("monthlySalesRevenue")}
const datapoint = dataStockPercent[cat][percent] </Typography>
if (index === -1) { <BarChart
index = stockPercent.push({ series={monthlyRevenue}
value: 0, height={290}
label: generateName(percent), xAxis={monthlyRevenueXaxis}
color: getColorFromPercent(percent) />
}) - 1 </Box>
} <Box display={"flex"} mb={9}>
stockPercent[index].value += datapoint <Box className="vw20" sx={{ m: 2 }}>
} <Typography variant="h6" align="center" gutterBottom>
i++ {t("itemVolumeDistribution")}
} </Typography>
setStockPercent(stockPercent) <PieChart
} series={[
}, [dataStockPercent]) {
data: totalVolume,
return ( highlightScope: { fade: "global", highlight: "item" },
<Box className="" sx={{color: theme.palette.text.primary}}> faded: {
<Typography mt={4} variant="h4" align="center" gutterBottom> innerRadius: 30,
{t("salesStatistics")} additionalRadius: -30,
</Typography> color: "gray",
},
<Box sx={{mb: 4}}> },
<Typography variant="h6" align="center" gutterBottom> ]}
{t("monthlySalesVolume")} width={200}
</Typography> height={200}
<BarChart />
series={monthlyVolume}
height={290}
xAxis={monthlyVolumeXaxis}
/>
</Box>
<Box sx={{mb: 4}}>
<Typography variant="h6" align="center" gutterBottom>
{t("monthlySalesRevenue")}
</Typography>
<BarChart
series={monthlyRevenue}
height={290}
xAxis={monthlyRevenueXaxis}
/>
</Box>
<Box display={"flex"} mb={9}>
<Box className="vw20" sx={{m: 2}}>
<Typography variant="h6" align="center" gutterBottom>
{t("itemVolumeDistribution")}
</Typography>
<PieChart
series={[{
data: totalVolume,
highlightScope: {fade: 'global', highlight: 'item'},
faded: {innerRadius: 30, additionalRadius: -30, color: 'gray'},
}]}
width={200}
height={200}
/>
</Box>
<Box className="vw20" sx={{m: 2}}>
<Typography variant="h6" align="center" gutterBottom>
{t("itemRevenueDistribution")}
</Typography>
<PieChart
series={[{
data: totalRevenue,
highlightScope: {fade: 'global', highlight: 'item'},
valueFormatter: (v) => (v ? `${v.value}` : '-'),
}]}
width={200}
height={200}
/>
</Box>
<Box className="vw20" sx={{m: 2}}>
<Typography variant="h6" align="center" gutterBottom>
{t("stockFulfillment")}
</Typography>
<PieChart
series={[{
data: stockPercent,
innerRadius: 10,
outerRadius: 85,
cornerRadius: 5,
paddingAngle: 2,
valueFormatter: (v) => (v ? `${v.value}%` : '-'),
}]}
width={200}
height={200}
hideLegend
/>
</Box>
<Box className="vw20" sx={{m: 2}}>
<Typography variant="h6" align="center" gutterBottom>
{t("orderStatus")}
</Typography>
<PieChart
series={[{
data: orderStatus,
innerRadius: 20,
outerRadius: 70,
cornerRadius: 5,
paddingAngle: 1,
highlightScope: {fade: 'global', highlight: 'item'},
faded: {innerRadius: 30, additionalRadius: -10, color: 'gray'},
}]}
height={200}
width={200}
/>
</Box>
</Box>
</Box> </Box>
);
<Box className="vw20" sx={{ m: 2 }}>
<Typography variant="h6" align="center" gutterBottom>
{t("itemRevenueDistribution")}
</Typography>
<PieChart
series={[
{
data: totalRevenue,
highlightScope: { fade: "global", highlight: "item" },
valueFormatter: (v) => (v ? `${v.value}` : "-"),
},
]}
width={200}
height={200}
/>
</Box>
<Box className="vw20" sx={{ m: 2 }}>
<Typography variant="h6" align="center" gutterBottom>
{t("stockFulfillment")}
</Typography>
<PieChart
series={[
{
data: stockPercent,
innerRadius: 10,
outerRadius: 85,
cornerRadius: 5,
paddingAngle: 2,
valueFormatter: (v) => (v ? `${v.value}%` : "-"),
},
]}
width={200}
height={200}
hideLegend
/>
</Box>
<Box className="vw20" sx={{ m: 2 }}>
<Typography variant="h6" align="center" gutterBottom>
{t("orderStatus")}
</Typography>
<PieChart
series={[
{
data: orderStatus,
innerRadius: 20,
outerRadius: 70,
cornerRadius: 5,
paddingAngle: 1,
highlightScope: { fade: "global", highlight: "item" },
faded: {
innerRadius: 30,
additionalRadius: -10,
color: "gray",
},
},
]}
height={200}
width={200}
/>
</Box>
</Box>
</Box>
);
} }

View File

@@ -1,36 +1,36 @@
.item-description { .item-description {
display: flex; display: flex;
justify-content: space-between; /* Elemente an gegenüberliegenden Seiten platzieren */ justify-content: space-between; /* Elemente an gegenüberliegenden Seiten platzieren */
align-items: center; /* Vertikale Zentrierung */ align-items: center; /* Vertikale Zentrierung */
width: 100%; width: 100%;
} }
.item-card-button { .item-card-button {
color: green; color: green;
} }
.rating-card-body { .rating-card-body {
display: grid; display: grid;
align-items: center; /* Vertikale Zentrierung */ align-items: center; /* Vertikale Zentrierung */
width: 100%; width: 100%;
gap: 30px; gap: 30px;
} }
.rating-button { .rating-button {
max-width: 200px; max-width: 200px;
} }
.rating-card-box { .rating-card-box {
display: grid; display: grid;
align-items: center; /* Vertikale Zentrierung */ align-items: center; /* Vertikale Zentrierung */
gap: 10px; gap: 10px;
width: 100%; width: 100%;
} }
.rating-text-field { .rating-text-field {
background-color: whitesmoke; background-color: whitesmoke;
} }
.vw20 { .vw20 {
width: 20vw; width: 20vw;
} }

View File

@@ -1,75 +1,83 @@
import {FormControl, FormControlLabel, FormLabel, Radio, RadioGroup, Rating, useTheme,} from "@mui/material"; import {
FormControl,
FormControlLabel,
FormLabel,
Radio,
RadioGroup,
Rating,
useTheme,
} from "@mui/material";
import React from "react"; import React from "react";
type FilterItemOption = { type FilterItemOption = {
value: string; value: string;
label: string; label: string;
}; };
type FilterItemProps = { type FilterItemProps = {
filterName: string; filterName: string;
filterItems: FilterItemOption[]; filterItems: FilterItemOption[];
value?: string | null; value?: string | null;
onChange?: (value: string) => void; onChange?: (value: string) => void;
}; };
export default function FilterItem({ export default function FilterItem({
filterName, filterName,
filterItems, filterItems,
value, value,
onChange, onChange,
}: FilterItemProps) { }: FilterItemProps) {
const theme = useTheme(); const theme = useTheme();
if (!value && filterItems.length > 0) { if (!value && filterItems.length > 0) {
value = filterItems[0].value; value = filterItems[0].value;
}
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(event.target.value);
} }
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { return (
if (onChange) { <div style={{ marginBottom: "1.5rem" }}>
onChange(event.target.value); <FormLabel
} component="legend"
}; sx={{
fontWeight: "bold",
color: theme.palette.text.primary,
mb: 1,
}}
>
{filterName}
</FormLabel>
return ( <FormControl>
<div style={{marginBottom: "1.5rem"}}> <RadioGroup value={value} onChange={handleChange}>
<FormLabel {filterItems.map((item, idx) => (
component="legend" <FormControlLabel
sx={{ key={idx}
fontWeight: "bold", value={item.value}
color: theme.palette.text.primary, control={<Radio />}
mb: 1, label={
}} /^[1-5]$/.test(item.value) ? (
> <Rating
{filterName} readOnly
</FormLabel> value={Number(item.value)}
precision={1}
<FormControl> size="small"
<RadioGroup value={value} onChange={handleChange}> />
{filterItems.map((item, idx) => ( ) : (
<FormControlLabel item.label
key={idx} )
value={item.value} }
control={<Radio/>} sx={{
label={ color: theme.palette.text.primary,
/^[1-5]$/.test(item.value) ? ( }}
<Rating />
readOnly ))}
value={Number(item.value)} </RadioGroup>
precision={1} </FormControl>
size="small" </div>
/> );
) : (
item.label
)
}
sx={{
color: theme.palette.text.primary,
}}
/>
))}
</RadioGroup>
</FormControl>
</div>
);
} }

View File

@@ -2,24 +2,24 @@
/* Container um jedes Filter-Widget */ /* Container um jedes Filter-Widget */
.filter-item { .filter-item {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
/* Überschrift (FormLabel) */ /* Überschrift (FormLabel) */
.filter-item__label { .filter-item__label {
font-weight: bold; font-weight: bold;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
/* nutze die CSS-Variable, die GlobalStyles füllen */ /* nutze die CSS-Variable, die GlobalStyles füllen */
color: var(--text-color); color: var(--text-color);
} }
/* Das Material-UI FormControl-Element */ /* Das Material-UI FormControl-Element */
.filter-item__group { .filter-item__group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* Jeder Radio-Button mit Label */ /* Jeder Radio-Button mit Label */
.filter-item__option { .filter-item__option {
color: var(--text-color); color: var(--text-color);
} }

View File

@@ -1,91 +1,150 @@
import {AddShoppingCart} from "@mui/icons-material"; import { AddShoppingCart } from "@mui/icons-material";
import {Box, Card, CardActionArea, CardContent, CardMedia, IconButton, Paper, Rating, Typography} from "@mui/material"; import {
import {useState} from "react"; Box,
import {useTranslation} from 'react-i18next'; Card,
import {useNavigate} from "react-router-dom"; CardActionArea,
CardContent,
CardMedia,
IconButton,
Paper,
Rating,
Typography,
} from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import ItemWithImage from "../../components/Item"; import ItemWithImage from "../../components/Item";
import {useBasket} from "../BasketProvider"; import { useBasket } from "../BasketProvider";
import "../helper.css"; import "../helper.css";
export default function ItemCard({item}: { item: ItemWithImage }) { export default function ItemCard({ item }: { item: ItemWithImage }) {
const { t } = useTranslation();
const navigate = useNavigate();
const { addToBasket } = useBasket();
const {t} = useTranslation(); const handleAddToCart = () => {
const navigate = useNavigate() addToBasket(item, 1);
const {addToBasket} = useBasket(); console.log(`Added ${1} of ${item.name} to basket`);
};
const handleAddToCart = () => { const handleClick = () => {
addToBasket(item, 1); navigate(`/product/${item.id}`, { state: { item } });
console.log(`Added ${1} of ${item.name} to basket`); };
}; const [imageUrl, setImageUrl] = useState<string>(
item.image || "/src/assets/default.jpg",
); // Fallback-Bild
const handleClick = () => { if (
navigate(`/product/${item.id}`, {state: {item}}); imageUrl !== "/src/assets/default.jpg" &&
} !imageUrl.startsWith("data:image/")
const [imageUrl, setImageUrl] = useState<string>(item.image || "/src/assets/default.jpg"); // Fallback-Bild ) {
setImageUrl("data:image/jpeg;base64," + imageUrl);
}
if (imageUrl !== "/src/assets/default.jpg" && !imageUrl.startsWith("data:image/")) { return (
setImageUrl("data:image/jpeg;base64," + imageUrl); <Paper elevation={4}>
} <Card
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
width: "100%",
}}
>
<CardActionArea
onClick={handleClick}
sx={{ height: "100%", width: "100%" }}
component="div"
>
<CardMedia
component="img"
height="140"
src={imageUrl}
alt={item.name}
onError={(event) => {
event.currentTarget.src = "/src/assets/default.jpg"; // Standardbild setzen
}}
sx={{
objectFit: "contain", // Bild wird skaliert, um vollständig sichtbar zu sein
maxWidth: "100%", // Begrenze die maximale Breite auf den Container
maxHeight: "100%", // Begrenze die maximale Höhe auf den Container
}}
/>
<CardContent
sx={{ display: "flex", flexDirection: "column", flexGrow: 1 }}
>
<Typography gutterBottom variant="h5" component="div">
{item.name}
</Typography>
<Box
sx={{
mt: "auto",
display: "flex",
flexGrow: 1,
flexDirection: "column",
}}
>
<Rating
name="half-rating"
readOnly
defaultValue={item.rating / 2}
precision={0.5}
/>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
}}
>
<Typography
variant="body2"
sx={{ color: "text.secondary" }}
className="item-description"
>
{(
(item.price100 / 100) *
(1 - item.discount100 / 100)
).toFixed(2)}{" "}
</Typography>
{item.discount100 == 0 ? (
<></>
) : (
<Typography
variant="body2"
sx={{ color: "red" }}
className="item-description"
>
{-item.discount100}%
</Typography>
)}
<IconButton
aria-label={t("addToCart")}
onClick={(event) => {
event.stopPropagation();
handleAddToCart();
}}
>
<AddShoppingCart />
</IconButton>
</Box>
return ( {item.stock > 10 ? (
<Paper elevation={4}> <Typography variant="body2">{t("inStock")}</Typography>
<Card sx={{height: "100%", width: "100%"}}> ) : item.stock > 0 ? (
<CardActionArea onClick={handleClick} sx={{height: "100%"}} component="div"> <Typography variant="body2" sx={{ color: "orange" }}>
<CardMedia {t("almostSoldOut")}
component="img" </Typography>
height="140" ) : (
src={imageUrl} <Typography variant="body2" sx={{ color: "red" }}>
alt={item.name} {t("outOfStock")}
onError={(event) => { </Typography>
event.currentTarget.src = "/src/assets/default.jpg"; // Standardbild setzen )}
}} </Box>
sx={{ </CardContent>
objectFit: "contain", // Bild wird skaliert, um vollständig sichtbar zu sein </CardActionArea>
maxWidth: "100%", // Begrenze die maximale Breite auf den Container </Card>
maxHeight: "100%", // Begrenze die maximale Höhe auf den Container </Paper>
}} );
/>
<CardContent>
<Typography gutterBottom variant="h5" component="div">
{item.name}
</Typography>
<Rating name="half-rating" readOnly defaultValue={item.rating / 2} precision={0.5}/>
<Box sx={{display: "flex", justifyContent: "space-between", alignItems: "flex-end"}}>
<Typography variant="body2" sx={{color: 'text.secondary'}} className="item-description">
{(item.price100 / 100 * (1 - item.discount100 / 100)).toFixed(2)}
</Typography>
{item.discount100 == 0 ? <></> :
<Typography variant="body2" sx={{color: 'red'}} className="item-description">
{(- item.discount100)}%
</Typography>
}
<IconButton
aria-label={t('addToCart')}
onClick={(event) => {
event.stopPropagation();
handleAddToCart();
}}
>
<AddShoppingCart/>
</IconButton>
</Box>
{item.stock > 10 ? (
<Typography variant="body2">
{t('inStock')}
</Typography>
) : item.stock > 0 ? (
<Typography variant="body2" sx={{color: 'orange'}}>
{t('almostSoldOut')}
</Typography>
) : (
<Typography variant="body2" sx={{color: 'red'}}>
{t('outOfStock')}
</Typography>
)}
</CardContent>
</CardActionArea>
</Card>
</Paper>
)
} }

View File

@@ -1,81 +1,88 @@
import {Box, Slider, Typography, useTheme} from "@mui/material"; import { Box, Slider, Typography, useTheme } from "@mui/material";
import {SyntheticEvent, useEffect, useState} from "react"; import { SyntheticEvent, useEffect, useState } from "react";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
type PriceSliderProps = { type PriceSliderProps = {
min?: number; min?: number;
max?: number; max?: number;
onChange?: (range: [number, number]) => void; onChange?: (range: [number, number]) => void;
}; };
export default function PriceSlider({min = 0, max = 10000, onChange}: PriceSliderProps) { export default function PriceSlider({
const {t} = useTranslation(); min = 0,
const theme = useTheme(); max = 10000,
onChange,
}: PriceSliderProps) {
const { t } = useTranslation();
const theme = useTheme();
const [value, setValue] = useState<[number, number]>([min, max]); const [value, setValue] = useState<[number, number]>([min, max]);
useEffect(() => { useEffect(() => {
setValue([min, max]); setValue([min, max]);
onChange?.([min, max]); onChange?.([min, max]);
}, [min, max]); }, [min, max]);
const handleChange = (_: Event, newValue: number | number[]) => { const handleChange = (_: Event, newValue: number | number[]) => {
if (Array.isArray(newValue)) { if (Array.isArray(newValue)) {
setValue([newValue[0], newValue[1]]); setValue([newValue[0], newValue[1]]);
} }
}; };
const handleCommitted = (_: Event | SyntheticEvent<Element, Event>, newValue: number | number[]) => { const handleCommitted = (
if (Array.isArray(newValue)) { _: Event | SyntheticEvent<Element, Event>,
onChange?.([newValue[0], newValue[1]]); newValue: number | number[],
} ) => {
}; if (Array.isArray(newValue)) {
onChange?.([newValue[0], newValue[1]]);
}
};
const formatValueToEuro = (val: number) => `${(val / 100).toFixed(2)}`; const formatValueToEuro = (val: number) => `${(val / 100).toFixed(2)}`;
return ( return (
<Box sx={{mb: 4}}> <Box sx={{ mb: 4 }}>
<Typography <Typography
variant="h6" variant="h6"
sx={{ sx={{
fontWeight: "bold", fontWeight: "bold",
color: theme.palette.text.primary, color: theme.palette.text.primary,
mb: 1, mb: 1,
}} }}
> >
{t("price")} {t("price")}
</Typography> </Typography>
<Box sx={{px: 1}}> <Box sx={{ px: 1 }}>
<Slider <Slider
value={value} value={value}
onChange={handleChange} onChange={handleChange}
onChangeCommitted={handleCommitted} onChangeCommitted={handleCommitted}
valueLabelDisplay="auto" valueLabelDisplay="auto"
valueLabelFormat={formatValueToEuro} valueLabelFormat={formatValueToEuro}
min={min} min={min}
max={max} max={max}
step={1} step={1}
sx={{ sx={{
color: "#0fd13f", color: "#0fd13f",
'& .MuiSlider-valueLabel': { "& .MuiSlider-valueLabel": {
color: theme.palette.text.primary, color: theme.palette.text.primary,
}, },
}} }}
/> />
<Typography <Typography
variant="body2" variant="body2"
sx={{ sx={{
mt: 1, mt: 1,
color: theme.palette.text.primary, color: theme.palette.text.primary,
fontSize: "0.9rem", fontSize: "0.9rem",
textAlign: "center", textAlign: "center",
}} }}
> >
{formatValueToEuro(value[0])} {formatValueToEuro(value[1])} {formatValueToEuro(value[0])} {formatValueToEuro(value[1])}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
); );
} }

View File

@@ -1,257 +1,307 @@
import {Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Link, TextField} from "@mui/material"; import {
import {useQuery} from "@tanstack/react-query"; Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Link,
TextField,
} from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import i18next from "i18next"; import i18next from "i18next";
import React, {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import AccountType, {User} from "../../components/Account"; import AccountType, { User } from "../../components/Account";
import {useAccount} from "../AccountProvider"; import { useAccount } from "../AccountProvider";
import {fetchAccount, submitLogin, submitRegister} from "../query/Queries"; // Importiere die Funktion für die Registrierung import { fetchAccount, submitLogin, submitRegister } from "../query/Queries"; // Importiere die Funktion für die Registrierung
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
type LoginDialogProps = { type LoginDialogProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSubmit: () => void; // Funktion, die aufgerufen wird, wenn der Login erfolgreich ist onSubmit: () => void; // Funktion, die aufgerufen wird, wenn der Login erfolgreich ist
loginData: { email: string; password: string }; loginData: { email: string; password: string };
setLoginData: React.Dispatch<React.SetStateAction<{ email: string; password: string, customerId: number }>>; setLoginData: React.Dispatch<
React.SetStateAction<{
email: string;
password: string;
customerId: number;
}>
>;
}; };
const LoginDialog: React.FC<LoginDialogProps> = ({open, onClose, loginData, setLoginData, onSubmit}) => { const LoginDialog: React.FC<LoginDialogProps> = ({
open,
onClose,
loginData,
setLoginData,
onSubmit,
}) => {
const { t } = useTranslation();
const { login } = useAccount();
const [showRegister, setShowRegister] = useState(false);
const [registerData, setRegisterData] = useState<AccountType>({
email: "",
password: "",
id: 0,
customer: {
id: 0,
name: "",
surname: "",
address: "",
country: "",
zip: "",
},
langI18n: i18next.language,
admin: false,
});
const [showErrorRegister, setShowErrorRegister] = useState(false); // Neuer Zustand für die Anzeige der Fehlermeldung
const [showErrorLogin, setShowErrorLogin] = useState(false); // Neuer Zustand für die Anzeige der Login-Fehlermeldung
const {t} = useTranslation(); useEffect(() => {
const {login} = useAccount(); if (open) {
const [showRegister, setShowRegister] = useState(false); const active = document.activeElement as HTMLElement | null;
const [registerData, setRegisterData] = useState<AccountType>({ if (active && typeof active.blur === "function") {
email: "", active.blur();
password: "", }
id: 0, }
customer: {id: 0, name: "", surname: "", address: "", country: "", zip: ""}, }, [open]);
langI18n: i18next.language,
admin: false
});
const [showErrorRegister, setShowErrorRegister] = useState(false); // Neuer Zustand für die Anzeige der Fehlermeldung
const [showErrorLogin, setShowErrorLogin] = useState(false); // Neuer Zustand für die Anzeige der Login-Fehlermeldung
useEffect(() => { // useQuery für Login
if (open) { const {
const active = document.activeElement as HTMLElement | null; refetch: refetchLogin,
if (active && typeof active.blur === "function") { isLoading: isLoadingLogin,
active.blur(); error: errorLogin,
} } = useQuery({
} queryKey: ["submitLogin", loginData],
}, [open]); queryFn: () => submitLogin(loginData),
retry: 0,
retryDelay: 1000,
enabled: false,
});
const { refetch: refetchAccount } = useQuery({
queryKey: ["fetchAccount", loginData],
queryFn: () => fetchAccount(loginData),
retry: 0,
retryDelay: 1000,
enabled: false,
});
// useQuery für Login // useQuery für Registrierung
const {refetch: refetchLogin, isLoading: isLoadingLogin, error: errorLogin} = useQuery({ const {
queryKey: ["submitLogin", loginData], refetch: refetchRegister,
queryFn: () => submitLogin(loginData), isLoading: isLoadingRegister,
retry: 0, error: errorRegister,
retryDelay: 1000, } = useQuery({
enabled: false, queryKey: ["submitRegister", registerData],
}); queryFn: () => submitRegister(registerData),
retry: 0,
retryDelay: 1000,
enabled: false,
});
const {refetch: refetchAccount} = useQuery({ const handleClose = () => {
queryKey: ["fetchAccount", loginData], setShowErrorLogin(false); // Fehlermeldung zurücksetzen
queryFn: () => fetchAccount(loginData), setShowErrorRegister(false); // Fehlermeldung zurücksetzen
retry: 0, onClose();
retryDelay: 1000, };
enabled: false,
});
// useQuery für Registrierung const handleLogin = async () => {
const {refetch: refetchRegister, isLoading: isLoadingRegister, error: errorRegister} = useQuery({ try {
queryKey: ["submitRegister", registerData], setShowErrorLogin(false); // Fehlermeldung zurücksetzen
queryFn: () => submitRegister(registerData), setShowErrorRegister(false); // Fehlermeldung zurücksetzen
retry: 0, const response = await refetchLogin(); // Anfrage auslösen
retryDelay: 1000, if (response.status === "success") {
enabled: false, const session = response.data.uuid; // Session-Daten aus der Antwort extrahieren
}); const customerData = (await refetchAccount()).data;
const user: User = {
email: customerData.email,
password: customerData.password,
customerId: customerData.customer.id, // Setze die customerId aus den Account-Daten
session: session, // Setze die Session aus der Login-Antwort
isAdmin: customerData.admin,
};
login(user);
setShowRegister(false); // Zurück zum Login wechseln
onSubmit(); // Dialog schließen
} else {
setShowErrorLogin(true); // Fehlermeldung anzeigen
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
setShowErrorLogin(true); // Fehlermeldung anzeigen
}
};
const handleClose = () => { const handleRegister = async () => {
setShowErrorLogin(false); // Fehlermeldung zurücksetzen try {
setShowErrorRegister(false); // Fehlermeldung zurücksetzen setShowErrorLogin(false); // Fehlermeldung zurücksetzen
onClose(); setShowErrorRegister(false); // Fehlermeldung zurücksetzen
}; await refetchRegister(); // Beispiel für den Refetch-Aufruf
// Erfolgslogik hier
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
setShowErrorRegister(true); // Fehlermeldung anzeigen
}
};
const handleLogin = async () => { return (
try { <Dialog open={open} onClose={handleClose} disableEnforceFocus>
setShowErrorLogin(false); // Fehlermeldung zurücksetzen <form
setShowErrorRegister(false); // Fehlermeldung zurücksetzen onSubmit={(e) => {
const response = await refetchLogin(); // Anfrage auslösen e.preventDefault();
if (response.status === "success") { if (showRegister) {
const session = response.data.uuid; // Session-Daten aus der Antwort extrahieren handleLogin();
const customerData = (await refetchAccount()).data; } else {
const user: User = { handleRegister();
email: customerData.email, }
password: customerData.password, }}
customerId: customerData.customer.id, // Setze die customerId aus den Account-Daten noValidate
session: session, // Setze die Session aus der Login-Antwort >
isAdmin: customerData.admin <DialogTitle>{showRegister ? t("register") : t("login")}</DialogTitle>
}; <DialogContent>
login(user); <TextField
setShowRegister(false); // Zurück zum Login wechseln margin="dense"
onSubmit(); // Dialog schließen label={t("email")}
} else { type="email"
setShowErrorLogin(true); // Fehlermeldung anzeigen fullWidth
} value={showRegister ? registerData.email : loginData.email}
// eslint-disable-next-line @typescript-eslint/no-unused-vars onChange={(e) => {
} catch (error) { setLoginData((prev) => ({ ...prev, email: e.target.value }));
setShowErrorLogin(true); // Fehlermeldung anzeigen setRegisterData((prev) => ({ ...prev, email: e.target.value }));
} }}
}; />
<TextField
const handleRegister = async () => { margin="dense"
try { label={t("password")}
setShowErrorLogin(false); // Fehlermeldung zurücksetzen type="password"
setShowErrorRegister(false); // Fehlermeldung zurücksetzen fullWidth
await refetchRegister(); // Beispiel für den Refetch-Aufruf value={showRegister ? registerData.password : loginData.password}
// Erfolgslogik hier onChange={(e) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars setLoginData((prev) => ({ ...prev, password: e.target.value }));
} catch (error) { setRegisterData((prev) => ({
setShowErrorRegister(true); // Fehlermeldung anzeigen ...prev,
} password: e.target.value,
}; }));
}}
return ( />
<Dialog open={open} onClose={handleClose} disableEnforceFocus> {showRegister && (
<form onSubmit={e => { <>
e.preventDefault(); <TextField
if (showRegister) { margin="dense"
handleLogin(); label={t("firstName")}
} else { type="text"
handleRegister(); fullWidth
value={registerData.customer.name}
onChange={(e) =>
setRegisterData((prev) => ({
...prev,
customer: { ...prev.customer, name: e.target.value },
}))
} }
}} noValidate> />
<DialogTitle>{showRegister ? t("register") : t("login")}</DialogTitle> <TextField
<DialogContent> margin="dense"
<TextField label={t("lastName")}
margin="dense" type="text"
label={t("email")} fullWidth
type="email" value={registerData.customer.surname}
fullWidth onChange={(e) =>
value={showRegister ? registerData.email : loginData.email} setRegisterData((prev) => ({
onChange={e => { ...prev,
setLoginData(prev => ({...prev, email: e.target.value})); customer: { ...prev.customer, surname: e.target.value },
setRegisterData(prev => ({...prev, email: e.target.value})) }))
}} }
/> />
<TextField <TextField
margin="dense" margin="dense"
label={t("password")} label={t("address")}
type="password" type="text"
fullWidth fullWidth
value={showRegister ? registerData.password : loginData.password} value={registerData.customer.address}
onChange={e => { onChange={(e) =>
setLoginData(prev => ({...prev, password: e.target.value})) setRegisterData((prev) => ({
setRegisterData(prev => ({...prev, password: e.target.value})) ...prev,
}} customer: { ...prev.customer, address: e.target.value },
/> }))
{showRegister && }
<> />
<TextField <TextField
margin="dense" margin="dense"
label={t("firstName")} label={t("country")}
type="text" type="text"
fullWidth fullWidth
value={registerData.customer.name} value={registerData.customer.country}
onChange={e => setRegisterData(prev => ({ onChange={(e) =>
...prev, setRegisterData((prev) => ({
customer: {...prev.customer, name: e.target.value}, ...prev,
}))} customer: { ...prev.customer, country: e.target.value },
/> }))
<TextField }
margin="dense" />
label={t("lastName")} <TextField
type="text" margin="dense"
fullWidth label={t("zip")}
value={registerData.customer.surname} type="text"
onChange={e => setRegisterData(prev => ({ fullWidth
...prev, value={registerData.customer.zip}
customer: {...prev.customer, surname: e.target.value}, onChange={(e) =>
}))} setRegisterData((prev) => ({
/> ...prev,
<TextField customer: { ...prev.customer, zip: e.target.value },
margin="dense" }))
label={t("address")} }
type="text" />
fullWidth </>
value={registerData.customer.address} )}
onChange={e => setRegisterData(prev => ({ </DialogContent>
...prev, <DialogActions>
customer: {...prev.customer, address: e.target.value}, <Button onClick={handleClose}>{t("cancel")}</Button>
}))} {showRegister ? (
/> <Button onClick={handleRegister} disabled={isLoadingRegister}>
<TextField {isLoadingRegister ? t("loading") : t("register")}
margin="dense" </Button>
label={t("country")} ) : (
type="text" <Button onClick={handleLogin} disabled={isLoadingLogin}>
fullWidth {isLoadingLogin ? t("loading") : t("login")}
value={registerData.customer.country} </Button>
onChange={e => setRegisterData(prev => ({ )}
...prev, </DialogActions>
customer: {...prev.customer, country: e.target.value}, {showErrorLogin && errorLogin && (
}))} <Box color="error.main">{t("loginFailed")}</Box>
/> )}
<TextField {showErrorRegister && errorRegister !== null && (
margin="dense" <Box color="error.main">{t("registerFailed")}</Box>
label={t("zip")} )}
type="text" {showRegister ? (
fullWidth <Box sx={{ width: "100%", textAlign: "center", pb: 2 }}>
value={registerData.customer.zip} <Link
onChange={e => setRegisterData(prev => ({ component="button"
...prev, variant="body2"
customer: {...prev.customer, zip: e.target.value}, onClick={() => setShowRegister(false)}
}))} color="primary"
/> underline="hover"
</> >
} {t("backToLogin")}
</DialogContent> </Link>
<DialogActions> </Box>
<Button onClick={handleClose}>{t("cancel")}</Button> ) : (
{showRegister ? ( <Box sx={{ width: "100%", textAlign: "center", pb: 2 }}>
<Button onClick={handleRegister} disabled={isLoadingRegister}> <Link
{isLoadingRegister ? t("loading") : t("register")} component="button"
</Button> variant="body2"
) : ( onClick={() => setShowRegister(true)}
<Button onClick={handleLogin} disabled={isLoadingLogin}> color="primary"
{isLoadingLogin ? t("loading") : t("login")} underline="hover"
</Button> >
)} {t("noAccountRegister")}
</DialogActions> </Link>
{showErrorLogin && errorLogin && ( </Box>
<Box color="error.main">{t("loginFailed")}</Box> )}
)} </form>
{showErrorRegister && errorRegister !== null && ( </Dialog>
<Box color="error.main">{t("registerFailed")}</Box> );
)}
{showRegister ? (
<Box sx={{width: '100%', textAlign: 'center', pb: 2}}>
<Link
component="button"
variant="body2"
onClick={() => setShowRegister(false)}
color="primary"
underline="hover"
>
{t("backToLogin")}
</Link>
</Box>
) : (
<Box sx={{width: '100%', textAlign: 'center', pb: 2}}>
<Link
component="button"
variant="body2"
onClick={() => setShowRegister(true)}
color="primary"
underline="hover"
>
{t("noAccountRegister")}
</Link>
</Box>
)}
</form>
</Dialog>
);
}; };
export default LoginDialog; export default LoginDialog;

View File

@@ -1,80 +1,80 @@
/* Navbar styles */ /* Navbar styles */
.navbar { .navbar {
/*color in tsx*/ /*color in tsx*/
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
position: fixed; position: fixed;
top: 0; top: 0;
width: 100%; width: 100%;
overflow-x: hidden; overflow-x: hidden;
z-index: 10000; z-index: 10000;
height: var(--navbar-height); height: var(--navbar-height);
min-height: 64px; min-height: 64px;
} }
/* Logo styles */ /* Logo styles */
.navbar-logo { .navbar-logo {
text-decoration: none; text-decoration: none;
margin-right: 1rem; margin-right: 1rem;
height: 3rem; height: 3rem;
} }
/* Menu styles */ /* Menu styles */
.navbar-menu { .navbar-menu {
display: flex; display: flex;
align-items: center; align-items: center;
margin-left: auto; margin-left: auto;
} }
/* Search styles */ /* Search styles */
.search { .search {
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
background-color: rgba(255, 255, 255, 0.15); background-color: rgba(255, 255, 255, 0.15);
} }
.search:hover { .search:hover {
background-color: rgba(255, 255, 255, 0.25); background-color: rgba(255, 255, 255, 0.25);
} }
.search-icon-wrapper { .search-icon-wrapper {
padding: 8px; padding: 8px;
height: 100%; height: 100%;
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.search-input { .search-input {
color: inherit; color: inherit;
width: 100%; width: 100%;
padding: 8px 8px 8px 40px; padding: 8px 8px 8px 40px;
font-size: 1rem; font-size: 1rem;
} }
/* User avatar styles */ /* User avatar styles */
.navbar-user { .navbar-user {
margin-left: 16px; margin-left: 16px;
} }
/* Typography styles */ /* Typography styles */
.navbar-typography { .navbar-typography {
font-family: 'monospace'; font-family: "monospace";
font-weight: 700; font-weight: 700;
letter-spacing: .3rem; letter-spacing: 0.3rem;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
/* Button styles */ /* Button styles */
.navbar-button { .navbar-button {
margin: 2; margin: 2;
color: white; color: white;
display: block; display: block;
} }
.navbar-offset { .navbar-offset {
height: var(--navbar-height); height: var(--navbar-height);
} }

View File

@@ -1,331 +1,348 @@
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from "@mui/icons-material/Menu";
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from "@mui/icons-material/Search";
import {Autocomplete, Badge, TextField} from '@mui/material'; import { Autocomplete, Badge, TextField } from "@mui/material";
import AppBar from '@mui/material/AppBar'; import AppBar from "@mui/material/AppBar";
import Avatar from '@mui/material/Avatar'; import Avatar from "@mui/material/Avatar";
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import Button from '@mui/material/Button'; import Button from "@mui/material/Button";
import IconButton from '@mui/material/IconButton'; import IconButton from "@mui/material/IconButton";
import Menu from '@mui/material/Menu'; import Menu from "@mui/material/Menu";
import MenuItem from '@mui/material/MenuItem'; import MenuItem from "@mui/material/MenuItem";
import Toolbar from '@mui/material/Toolbar'; import Toolbar from "@mui/material/Toolbar";
import Tooltip from '@mui/material/Tooltip'; import Tooltip from "@mui/material/Tooltip";
import Typography from '@mui/material/Typography'; import Typography from "@mui/material/Typography";
import {useQuery} from '@tanstack/react-query'; import { useQuery } from "@tanstack/react-query";
import * as React from 'react'; import * as React from "react";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import {useNavigate} from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import Item from '../../components/Item'; import Item from "../../components/Item";
import ThemeToggle from '../../theme/ThemeToggle'; import ThemeToggle from "../../theme/ThemeToggle";
import {useAccount} from '../AccountProvider'; import { useAccount } from "../AccountProvider";
import {fetchItemList} from '../query/Queries'; import { fetchItemList } from "../query/Queries";
import LoginDialog from './LoginDialog'; import LoginDialog from "./LoginDialog";
import './NavBar.css'; import "./NavBar.css";
import {useBasket} from '../BasketProvider'; import { useBasket } from "../BasketProvider";
import logo from '../../assets/logo/Blume-logo.png'; import logo from "../../assets/logo/Blume-logo.png";
export default function NavBar() { export default function NavBar() {
const {t} = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [anchorElNav, setAnchorElNav] = React.useState<null | HTMLElement>(null); const [anchorElNav, setAnchorElNav] = React.useState<null | HTMLElement>(
const [anchorElUser, setAnchorElUser] = React.useState<null | HTMLElement>(null); null,
const [avatarName, setAvatarName] = React.useState<string>(''); // Für Avatar-Tooltip );
const [anchorElUser, setAnchorElUser] = React.useState<null | HTMLElement>(
null,
);
const [avatarName, setAvatarName] = React.useState<string>(""); // Für Avatar-Tooltip
const {user, logout} = useAccount(); const { user, logout } = useAccount();
const {basket} = useBasket(); const { basket } = useBasket();
const totalQuantity = basket?.reduce((sum, item) => sum + item.quantity, 0) ?? 0; const totalQuantity =
basket?.reduce((sum, item) => sum + item.quantity, 0) ?? 0;
const [loginOpen, setLoginOpen] = React.useState(false);
const [loginData, setLoginData] = React.useState({
password: "",
email: "",
customerId: 0,
});
const [loginOpen, setLoginOpen] = React.useState(false); const [itemNames, setItemNames] = React.useState<string[]>([]); // Für Autocomplete
const [loginData, setLoginData] = React.useState({password: '', email: '', customerId: 0});
const pageKeys = ["components", "checkout", "contact", "admin"];
const [itemNames, setItemNames] = React.useState<string[]>([]); // Für Autocomplete const filteredPages = pageKeys
.filter((key) => {
if (key === "admin") {
return user?.isAdmin === true; // nur Admins sehen Admin-Seite
}
return true; // alle anderen Seiten immer anzeigen
})
.map((key) => ({ key, label: t(key) }));
const pageKeys = ['components', 'checkout', 'contact', 'admin']; const settings = user
? [
{
key: "email",
label: `${t("loggedInAs")}: ${user.email}`,
disabled: true, // wir nutzen dieses Flag gleich zur Erkennung
},
{ key: "account", label: t("account") },
{ key: "orders", label: t("orders") },
{ key: "logout", label: t("logout") },
]
: [{ key: "login", label: t("login") }];
const filteredPages = pageKeys const handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => {
.filter(key => { setAnchorElNav(event.currentTarget);
if (key === "admin") { };
return user?.isAdmin === true; // nur Admins sehen Admin-Seite
}
return true; // alle anderen Seiten immer anzeigen
})
.map(key => ({key, label: t(key)}));
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElUser(event.currentTarget);
};
const settings = user const handleCloseNavMenu = (link: string) => {
? [ setAnchorElNav(null);
{ navigate(`/${link.toLowerCase()}`);
key: 'email', };
label: `${t('loggedInAs')}: ${user.email}`,
disabled: true // wir nutzen dieses Flag gleich zur Erkennung
},
{key: 'account', label: t('account')},
{key: 'orders', label: t('orders')},
{key: 'logout', label: t('logout')}
]
: [
{key: 'login', label: t('login')}
];
const handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => { const handleCloseUserMenu = (link: string) => {
setAnchorElNav(event.currentTarget); setAnchorElUser(null);
}; if (link === "login") {
setLoginOpen(true);
} else if (link === "logout") {
logout();
if (
location.pathname.startsWith("/account") ||
location.pathname.startsWith("/orders")
) {
navigate("/");
}
} else {
navigate(`/${link.toLowerCase()}`);
}
};
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => { const handleLoginSubmit = () => {
setAnchorElUser(event.currentTarget); setLoginOpen(false);
}; };
const handleCloseNavMenu = (link: string) => { // useQuery, um die Item-Namen zu laden
setAnchorElNav(null); const { data: items = [] } = useQuery<Item[]>({
navigate(`/${link.toLowerCase()}`); queryKey: ["fetchItemList"],
}; queryFn: fetchItemList,
});
const handleCloseUserMenu = (link: string) => { React.useEffect(() => {
setAnchorElUser(null); // Extrahiere die Namen der Items für Autocomplete
if (link === 'login') { setItemNames(items.map((item) => item.name));
setLoginOpen(true); }, [items]);
} else if (link === 'logout') {
logout();
if (
location.pathname.startsWith('/account') ||
location.pathname.startsWith('/orders')
) {
navigate('/');
}
} else {
navigate(`/${link.toLowerCase()}`)
}
};
const handleLoginSubmit = () => { React.useEffect(() => {
setLoginOpen(false); // Setze den Avatar-Namen, wenn der Benutzer angemeldet ist
}; if (user !== undefined && user !== null) {
setAvatarName(user.email.toUpperCase());
}
if (!user) {
setAvatarName("");
}
}, [user]);
const handleSearch = (_: React.SyntheticEvent, value: string | null) => {
if (!value) {
// Wenn der Suchwert leer ist, navigiere zur Homepage ohne Suchparameter
navigate("/");
} else {
// Navigiere zur Homepage mit dem Suchparameter
navigate(`/?search=${encodeURIComponent(value)}`);
}
};
// useQuery, um die Item-Namen zu laden return (
const {data: items = []} = useQuery<Item[]>({ <>
queryKey: ["fetchItemList"], <AppBar className="navbar" color="primary" elevation={4}>
queryFn: fetchItemList, <Toolbar
}); disableGutters
sx={{
React.useEffect(() => { display: "flex",
// Extrahiere die Namen der Items für Autocomplete justifyContent: "space-between",
setItemNames(items.map((item) => item.name)); alignItems: "center", // <--- HIER hinzugefügt
}, [items]); px: 3,
minHeight: { xs: 56, sm: 64 }, // optional: Standardhöhe für bessere Zentrierung
React.useEffect(() => { }}
// Setze den Avatar-Namen, wenn der Benutzer angemeldet ist >
if (user !== undefined && user !== null) { <Box sx={{ display: "flex", alignItems: "center", minWidth: "0px" }}>
setAvatarName(user.email.toUpperCase()); <img
} src={logo}
if (!user) { alt="Logo"
setAvatarName(''); className="navbar-logo"
} style={{
}, [user]); display: "block",
height: 40,
const handleSearch = (_: React.SyntheticEvent, value: string | null) => { marginRight: 12,
objectFit: "contain",
if (!value) { }} // optional: Höhe anpassen
// Wenn der Suchwert leer ist, navigiere zur Homepage ohne Suchparameter onClick={() => navigate("/")}
navigate("/");
} else {
// Navigiere zur Homepage mit dem Suchparameter
navigate(`/?search=${encodeURIComponent(value)}`);
}
};
return (
<>
<AppBar className="navbar" color="primary" elevation={4}>
<Toolbar
disableGutters
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center', // <--- HIER hinzugefügt
px: 3,
minHeight: { xs: 56, sm: 64 }, // optional: Standardhöhe für bessere Zentrierung
}}
>
<Box sx={{display: "flex", alignItems: "center", minWidth: "0px"}}>
<img
src={logo}
alt="Logo"
className="navbar-logo"
style={{ display: "block", height: 40, marginRight: 12, objectFit: "contain" }} // optional: Höhe anpassen
onClick={() => navigate('/')}
/>
<Typography
variant="h6"
noWrap
component="a"
onClick={() => navigate('/')}
sx={{
fontFamily: "monospace",
fontWeight: 700,
letterSpacing: ".3rem",
color: "white",
textDecoration: "none",
display: "flex",
alignItems: "center", // <--- HIER hinzugefügt
height: "100%", // optional
}}
>
Digitaler Produktionsshop
</Typography>
</Box>
<Box sx={{
flexGrow: 1,
display: "flex",
justifyContent: "center",
alignItems: "center", // <--- HIER hinzugefügt
px: 3,
zIndex: 100000
}}>
<Autocomplete
sx={{
flexGrow: 1,
minWidth: "150px",
maxWidth: "600px",
display: "flex",
alignItems: "center" // <--- HIER hinzugefügt
}}
freeSolo
options={itemNames}
onInputChange={handleSearch}
renderInput={(params) => (
<TextField
{...params}
placeholder={t("search") + "..."}
InputProps={{
...params.InputProps,
startAdornment: (
<SearchIcon sx={{color: "white", mr: 1}}/>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: 'white',
borderWidth: '1px',
},
'&:hover fieldset': {
borderColor: 'white',
},
'&.Mui-focused fieldset': {
borderColor: 'white',
},
},
input: {
color: 'white',
},
}}
/>
)}
/>
</Box>
<Box sx={{display: "flex", alignItems: "center", gap: 2, marginLeft: 'auto'}}>
<Box sx={{display: {xs: "none", md: "flex"}, gap: 2}}>
{filteredPages.map(({key, label}) => {
if (key === 'checkout') {
return (
<Button
key={key}
onClick={() => handleCloseNavMenu(key)}
sx={{color: "white", fontWeight: 500}}
>
<Badge
badgeContent={totalQuantity}
color="error"
overlap="rectangular"
showZero={false}
>
{label}
</Badge>
</Button>
);
}
return (
<Button
key={key}
onClick={() => handleCloseNavMenu(key)}
sx={{color: "white", fontWeight: 500}}
>
{label}
</Button>
);
})}
</Box>
<Box sx={{display: {xs: "flex", md: "none"}}}>
<IconButton
size="large"
aria-label={t('menu')}
onClick={handleOpenNavMenu}
color="inherit"
>
<MenuIcon/>
</IconButton>
<Menu
anchorEl={anchorElNav}
anchorOrigin={{vertical: "top", horizontal: "right"}}
transformOrigin={{vertical: "top", horizontal: "right"}}
open={Boolean(anchorElNav)}
onClose={() => setAnchorElNav(null)}
>
{filteredPages.map(({key, label}) => (
<MenuItem key={key} onClick={() => handleCloseNavMenu(key)}>
<Typography>{label}</Typography>
</MenuItem>
))}
</Menu>
</Box>
<ThemeToggle/>
<Tooltip title={t('openSettings')} placement='bottom-end'>
<IconButton onClick={handleOpenUserMenu} sx={{p: 0}}>
<Avatar alt={avatarName} src="/static/images/avatar/2.jpg"/>
</IconButton>
</Tooltip>
<Menu
sx={{mt: "15px"}}
id="menu-appbar-user"
anchorEl={anchorElUser}
open={Boolean(anchorElUser)}
onClose={() => setAnchorElUser(null)}
>
{settings.map(({key, label, disabled}) => (
<MenuItem
key={key}
onClick={() => {
if (!disabled) handleCloseUserMenu(key);
}}
disabled={disabled}
>
<Typography sx={{textAlign: "center"}}>{label}</Typography>
</MenuItem>
))}
</Menu>
</Box>
</Toolbar>
</AppBar>
<LoginDialog
open={loginOpen}
onClose={() => setLoginOpen(false)}
onSubmit={handleLoginSubmit}
loginData={loginData}
setLoginData={setLoginData}
/> />
<div className="navbar-offset"/> <Typography
</> variant="h6"
); noWrap
component="a"
onClick={() => navigate("/")}
sx={{
fontFamily: "monospace",
fontWeight: 700,
letterSpacing: ".3rem",
color: "white",
textDecoration: "none",
display: "flex",
alignItems: "center", // <--- HIER hinzugefügt
height: "100%", // optional
":hover": {
color: "#fff1d8ff",
},
}}
>
Digitaler Produktionsshop
</Typography>
</Box>
<Box
sx={{
flexGrow: 1,
display: "flex",
justifyContent: "center",
alignItems: "center", // <--- HIER hinzugefügt
px: 3,
zIndex: 100000,
}}
>
<Autocomplete
sx={{
flexGrow: 1,
minWidth: "150px",
maxWidth: "600px",
display: "flex",
alignItems: "center", // <--- HIER hinzugefügt
}}
freeSolo
options={itemNames}
onInputChange={handleSearch}
renderInput={(params) => (
<TextField
{...params}
placeholder={t("search") + "..."}
InputProps={{
...params.InputProps,
startAdornment: (
<SearchIcon sx={{ color: "white", mr: 1 }} />
),
}}
sx={{
"& .MuiOutlinedInput-root": {
"& fieldset": {
borderColor: "white",
borderWidth: "1px",
},
"&:hover fieldset": {
borderColor: "white",
},
"&.Mui-focused fieldset": {
borderColor: "white",
},
},
input: {
color: "white",
},
}}
/>
)}
/>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
marginLeft: "auto",
}}
>
<Box sx={{ display: { xs: "none", md: "flex" }, gap: 2 }}>
{filteredPages.map(({ key, label }) => {
if (key === "checkout") {
return (
<Button
key={key}
onClick={() => handleCloseNavMenu(key)}
sx={{ color: "white", fontWeight: 500 }}
>
<Badge
badgeContent={totalQuantity}
color="error"
overlap="rectangular"
showZero={false}
>
{label}
</Badge>
</Button>
);
}
return (
<Button
key={key}
onClick={() => handleCloseNavMenu(key)}
sx={{ color: "white", fontWeight: 500 }}
>
{label}
</Button>
);
})}
</Box>
<Box sx={{ display: { xs: "flex", md: "none" } }}>
<IconButton
size="large"
aria-label={t("menu")}
onClick={handleOpenNavMenu}
color="inherit"
>
<MenuIcon />
</IconButton>
<Menu
anchorEl={anchorElNav}
anchorOrigin={{ vertical: "top", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
open={Boolean(anchorElNav)}
onClose={() => setAnchorElNav(null)}
>
{filteredPages.map(({ key, label }) => (
<MenuItem key={key} onClick={() => handleCloseNavMenu(key)}>
<Typography>{label}</Typography>
</MenuItem>
))}
</Menu>
</Box>
<ThemeToggle />
<Tooltip title={t("openSettings")} placement="bottom-end">
<IconButton onClick={handleOpenUserMenu} sx={{ p: 0 }}>
<Avatar alt={avatarName} src="/static/images/avatar/2.jpg" />
</IconButton>
</Tooltip>
<Menu
sx={{ mt: "15px" }}
id="menu-appbar-user"
anchorEl={anchorElUser}
open={Boolean(anchorElUser)}
onClose={() => setAnchorElUser(null)}
>
{settings.map(({ key, label, disabled }) => (
<MenuItem
key={key}
onClick={() => {
if (!disabled) handleCloseUserMenu(key);
}}
disabled={disabled}
>
<Typography sx={{ textAlign: "center" }}>{label}</Typography>
</MenuItem>
))}
</Menu>
</Box>
</Toolbar>
</AppBar>
<LoginDialog
open={loginOpen}
onClose={() => setLoginOpen(false)}
onSubmit={handleLoginSubmit}
loginData={loginData}
setLoginData={setLoginData}
/>
<div className="navbar-offset" />
</>
);
} }

View File

@@ -1,209 +1,216 @@
import {Close, LocalShipping, ShoppingCart} from "@mui/icons-material"; import { Close, LocalShipping, ShoppingCart } from "@mui/icons-material";
import { import {
Alert, Alert,
Box, Box,
Button, Button,
Card, Card,
Divider, Divider,
Grid, Grid,
IconButton, IconButton,
Rating, Rating,
Snackbar, Snackbar,
SnackbarCloseReason, SnackbarCloseReason,
Stack, Stack,
TextField, TextField,
Typography Typography,
} from "@mui/material"; } from "@mui/material";
import React, {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import Item from "../../components/Item"; import Item from "../../components/Item";
import {useBasket} from "../BasketProvider"; import { useBasket } from "../BasketProvider";
export default function ProductInfo({item}: { item: Item }) { export default function ProductInfo({ item }: { item: Item }) {
const { t } = useTranslation();
const [quantity, setQuantity] = useState<number>(1);
const [open, setOpen] = useState<boolean>(false);
const [imageDimensions, setImageDimensions] = useState({
width: 0,
height: 0,
});
const {t} = useTranslation(); const { addToBasket } = useBasket();
const [quantity, setQuantity] = useState<number>(1);
const [open, setOpen] = useState<boolean>(false);
const [imageDimensions, setImageDimensions] = useState({width: 0, height: 0});
const handleClose = (
_: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason,
) => {
if (reason === "clickaway") {
return;
}
const {addToBasket} = useBasket(); setOpen(false);
};
const handleClose = ( const action = (
_: React.SyntheticEvent | Event, <React.Fragment>
reason?: SnackbarCloseReason, <IconButton
) => { size="small"
if (reason === 'clickaway') { aria-label={t("close")}
return; color="inherit"
onClick={handleClose}
>
<Close fontSize="small" />
</IconButton>
</React.Fragment>
);
const handleAddToCart = () => {
addToBasket(item, quantity);
setOpen(true);
console.log(`Added {quantity} of €{item.name} to basket`);
};
const discountedPrice = item.price100 * (1 - item.discount100 / 100);
const handleImageLoad = (event: React.SyntheticEvent<HTMLImageElement>) => {
const { naturalWidth, naturalHeight } = event.currentTarget;
setImageDimensions({ width: naturalWidth, height: naturalHeight });
};
const [imageUrl, setImageUrl] = useState<string>("/src/assets/default.jpg"); // Fallback-Bild
useEffect(() => {
const fetchImage = async () => {
try {
const response = await fetch(
`http://localhost:8085/image?uuid=${item.uuid}`,
);
let data = await response.text();
if (data.length == 0) {
console.error("Got emtpy picture for article ", item.uuid);
} }
if (!data.startsWith("data:image/")) {
setOpen(false); data = "data:image/jpeg;base64," + data;
}
setImageUrl(data);
} catch (error) {
console.error("Fehler beim Laden des Bildes:", error);
}
}; };
const action = ( fetchImage();
<React.Fragment> }, [item.uuid]);
<IconButton
size="small"
aria-label={t('close')}
color="inherit"
onClick={handleClose}
>
<Close fontSize="small"/>
</IconButton>
</React.Fragment>
);
return (
<Grid container spacing={4}>
{/* Left Column - Image */}
const handleAddToCart = () => { <Card
addToBasket(item, quantity); elevation={2}
setOpen(true); sx={{ width: "100%", maxWidth: 400, display: "inherit" }}
console.log(`Added {quantity} of €{item.name} to basket`); >
}; <Box
component="img"
src={imageUrl}
alt={item.name}
onLoad={handleImageLoad} // Event-Handler zum Ermitteln der Bildgröße
onError={(event) => {
event.currentTarget.src = "/src/assets/default.jpg"; // Standardbild setzen
}}
sx={{
maxWidth:
imageDimensions.width > imageDimensions.height ? "100%" : "auto",
maxHeight:
imageDimensions.height >= imageDimensions.width ? 400 : "auto",
width: "auto",
height: "auto",
objectFit: "contain",
margin: "auto",
}}
/>
</Card>
const discountedPrice = item.price100 * (1 - item.discount100 / 100); {/* Right Column - Product Details */}
const handleImageLoad = (event: React.SyntheticEvent<HTMLImageElement>) => { <Stack spacing={3}>
const {naturalWidth, naturalHeight} = event.currentTarget; <Typography variant="h4" component="h1">
setImageDimensions({width: naturalWidth, height: naturalHeight}); {item.name}
}; </Typography>
const [imageUrl, setImageUrl] = useState<string>("/src/assets/default.jpg"); // Fallback-Bild <Box display="flex" alignItems="center" gap={1}>
<Rating value={item.rating / 2} precision={0.5} readOnly />
<Typography variant="body2" color="text.secondary">
{item.rating > 0 ? `(${item.rating / 2} / 5)` : t("noRatingsYet")}
</Typography>
</Box>
useEffect(() => { <Stack direction="row" alignItems="center" spacing={2}>
const fetchImage = async () => { {item.discount100 > 0 ? (
try { <>
const response = await fetch(`http://localhost:8085/image?uuid=${item.uuid}`); <Typography variant="h4" color="green">
let data = await response.text(); {(discountedPrice / 100).toFixed(2)}
if (data.length == 0) { </Typography>
console.error("Got emtpy picture for article ", item.uuid); <Typography
} variant="h6"
if (!data.startsWith("data:image/")) { color="text.secondary"
data = "data:image/jpeg;base64," + data sx={{ textDecoration: "line-through" }}
} >
setImageUrl(data); {(item.price100 / 100).toFixed(2)}
} catch (error) { </Typography>
console.error("Fehler beim Laden des Bildes:", error); <Typography variant="h6" color="error">
} -{item.discount100} %
}; </Typography>
</>
) : (
<Typography variant="h4" color="green">
{(item.price100 / 100).toFixed(2)}
</Typography>
)}
</Stack>
fetchImage(); <Divider />
}, [item.uuid]);
return ( <Box>
<Grid container spacing={4}> {item.stock > 10 ? (
{/* Left Column - Image */} <Alert severity="success" variant="outlined">
{t("inStock")} ({item.stock} {t("available")})
</Alert>
) : item.stock > 0 ? (
<Alert severity="warning" variant="outlined">
{t("almostSoldOut")} ({item.stock} {t("available")})
</Alert>
) : (
<Alert severity="error" variant="filled">
{t("outOfStock")}
</Alert>
)}
</Box>
<Card elevation={2} sx={{width: '100%', maxWidth: 400, display: 'inherit'}}> <Stack direction="row" spacing={2} alignItems="center">
<Box <TextField
component="img" type="number"
src={imageUrl} label={t("quantity")}
alt={item.name} value={quantity}
onLoad={handleImageLoad} // Event-Handler zum Ermitteln der Bildgröße onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value)))}
onError={(event) => { InputProps={{ inputProps: { min: 1, max: item.stock } }}
event.currentTarget.src = "/src/assets/default.jpg"; // Standardbild setzen sx={{ width: 100 }}
}} />
sx={{ <Button
maxWidth: imageDimensions.width > imageDimensions.height ? "100%" : "auto", variant="contained"
maxHeight: imageDimensions.height >= imageDimensions.width ? 400 : "auto", size="large"
width: "auto", startIcon={<ShoppingCart />}
height: "auto", onClick={handleAddToCart}
objectFit: "contain", disabled={item.stock <= 0}
}} fullWidth
/> >
</Card> {t("addToCart")}
</Button>
</Stack>
<Box sx={{ mt: 2 }}>
{/* Right Column - Product Details */} <Typography variant="body2" color="text.secondary">
<LocalShipping sx={{ mr: 1, verticalAlign: "middle" }} />
<Stack spacing={3}> {t("freeShipping")}
<Typography variant="h4" component="h1"> </Typography>
{item.name} </Box>
</Typography> </Stack>
<Snackbar
<Box display="flex" alignItems="center" gap={1}> open={open}
<Rating value={item.rating / 2} precision={0.5} readOnly/> autoHideDuration={3000}
<Typography variant="body2" color="text.secondary"> onClose={handleClose}
{item.rating > 0 ? `(${item.rating / 2} / 5)` : t('noRatingsYet')} message={t("addedToCart")}
action={action}
</Typography> />
</Box> </Grid>
);
<Stack direction="row" alignItems="center" spacing={2}> }
{item.discount100 > 0 ? (
<>
<Typography variant="h4" color="green">
{(discountedPrice / 100).toFixed(2)}
</Typography>
<Typography
variant="h6"
color="text.secondary"
sx={{textDecoration: 'line-through'}}
>
{(item.price100 / 100).toFixed(2)}
</Typography>
<Typography variant="h6" color="error">
-{item.discount100} %
</Typography>
</>
) : (
<Typography variant="h4" color="green">
{(item.price100 / 100).toFixed(2)}
</Typography>
)}
</Stack>
<Divider/>
<Box>
{item.stock > 10 ? (
<Alert severity="success" variant='outlined'>
{t('inStock')} ({item.stock} {t('available')})
</Alert>
) : item.stock > 0 ? (
<Alert severity="warning"
variant='outlined'>{t('almostSoldOut')} ({item.stock} {t('available')})</Alert>
) : (
<Alert severity="error" variant='filled'>{t('outOfStock')}</Alert>
)}
</Box>
<Stack direction="row" spacing={2} alignItems="center">
<TextField
type="number"
label={t('quantity')}
value={quantity}
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value)))}
InputProps={{inputProps: {min: 1, max: item.stock}}}
sx={{width: 100}}
/>
<Button
variant="contained"
size="large"
startIcon={<ShoppingCart/>}
onClick={handleAddToCart}
disabled={item.stock <= 0}
fullWidth
>
{t('addToCart')}
</Button>
</Stack>
<Box sx={{mt: 2}}>
<Typography variant="body2" color="text.secondary">
<LocalShipping sx={{mr: 1, verticalAlign: 'middle'}}/>
{t('freeShipping')}
</Typography>
</Box>
</Stack>
<Snackbar
open={open}
autoHideDuration={3000}
onClose={handleClose}
message={t('addedToCart')}
action={action}
/>
</Grid>
);
}

View File

@@ -1,55 +1,63 @@
import {Card, CardActionArea, CardContent, Paper, Rating, Typography, useTheme} from "@mui/material"; import {
Card,
CardActionArea,
CardContent,
Paper,
Rating,
Typography,
useTheme,
} from "@mui/material";
import RatingType from "../../components/Rating"; import RatingType from "../../components/Rating";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
export default function RatingCard(ratingType: RatingType) { export default function RatingCard(ratingType: RatingType) {
const {t} = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); // Zugriff auf Light/Dark-Mode const theme = useTheme(); // Zugriff auf Light/Dark-Mode
const handleClick = () => { const handleClick = () => {};
};
return ( return (
<Paper <Paper
elevation={4} elevation={4}
sx={{ sx={{
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary, color: theme.palette.text.primary,
mb: 3 mb: 3,
}} }}
> >
<Card <Card
sx={{ sx={{
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
}} }}
>
<CardActionArea onClick={handleClick}>
<CardContent>
<Typography
gutterBottom
variant="h6"
component="div"
sx={{ color: theme.palette.text.primary }}
> >
<CardActionArea onClick={handleClick}> {t("ratingFrom")}{" "}
<CardContent> {new Date(ratingType.timestamp).toLocaleDateString("de-DE")}
<Typography </Typography>
gutterBottom
variant="h6"
component="div"
sx={{color: theme.palette.text.primary}}
>
{t('ratingFrom')} {new Date(ratingType.timestamp).toLocaleDateString('de-DE')}
</Typography>
<Rating <Rating
name="half-rating" name="half-rating"
readOnly readOnly
defaultValue={ratingType.rating / 2} defaultValue={ratingType.rating / 2}
precision={0.5} precision={0.5}
/> />
<Typography <Typography
variant="body2" variant="body2"
sx={{color: theme.palette.text.secondary, mt: 1}} sx={{ color: theme.palette.text.secondary, mt: 1 }}
> >
{ratingType.content} {ratingType.content}
</Typography> </Typography>
</CardContent> </CardContent>
</CardActionArea> </CardActionArea>
</Card> </Card>
</Paper> </Paper>
); );
} }

View File

@@ -1,166 +1,170 @@
import { import {
Box, Box,
Button, Button,
Divider, Divider,
IconButton, IconButton,
Rating, Rating,
Snackbar, Snackbar,
SnackbarCloseReason, SnackbarCloseReason,
TextField, TextField,
Typography, Typography,
useTheme useTheme,
} from "@mui/material"; } from "@mui/material";
import {Close} from "@mui/icons-material"; import { Close } from "@mui/icons-material";
import {useQuery} from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import React, {useMemo, useState} from "react"; import React, { useMemo, useState } from "react";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import RatingType from "../../components/Rating"; import RatingType from "../../components/Rating";
import {fetchRatingList, submitRating} from "../query/Queries"; import { fetchRatingList, submitRating } from "../query/Queries";
import RatingCard from "./RatingCard"; import RatingCard from "./RatingCard";
import RatingSubmitType from "../../components/RatingSubmit"; import RatingSubmitType from "../../components/RatingSubmit";
export default function Ratings({itemId}: { itemId: string }) { export default function Ratings({ itemId }: { itemId: string }) {
const {t} = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const [open, setOpen] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false);
const [ratingText, setRatingText] = useState<string>(""); const [ratingText, setRatingText] = useState<string>("");
const [ratingValue, setRatingValue] = useState<number | null>(2.5); const [ratingValue, setRatingValue] = useState<number | null>(2.5);
const ratingData: RatingSubmitType = { const ratingData: RatingSubmitType = {
rating: ratingValue || 0, rating: ratingValue || 0,
content: ratingText || "", content: ratingText || "",
articleId: itemId, articleId: itemId,
}; };
const {refetch} = useQuery({ const { refetch } = useQuery({
queryKey: ["submitRating", ratingData], queryKey: ["submitRating", ratingData],
queryFn: () => submitRating(ratingData), queryFn: () => submitRating(ratingData),
retry: 3, retry: 3,
retryDelay: 1000, retryDelay: 1000,
enabled: false, enabled: false,
}); });
const handleRatingSubmit = () => { const handleRatingSubmit = () => {
setOpen(true); setOpen(true);
void refetch(); // bewusst ausgelöst, kein await notwendig void refetch(); // bewusst ausgelöst, kein await notwendig
}; };
const handleClose = ( const handleClose = (
_: React.SyntheticEvent | Event, _: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason reason?: SnackbarCloseReason,
) => { ) => {
if (reason === "clickaway") return; if (reason === "clickaway") return;
setOpen(false); setOpen(false);
}; };
const action = ( const action = (
<React.Fragment> <React.Fragment>
<IconButton <IconButton
size="small" size="small"
aria-label={t("close")} aria-label={t("close")}
color="inherit" color="inherit"
onClick={handleClose} onClick={handleClose}
> >
<Close fontSize="small"/> <Close fontSize="small" />
</IconButton> </IconButton>
</React.Fragment> </React.Fragment>
); );
const {data = []} = useQuery<RatingType[]>({ const { data = [] } = useQuery<RatingType[]>({
queryKey: ["fetchRatingList", itemId], queryKey: ["fetchRatingList", itemId],
queryFn: () => fetchRatingList(itemId), queryFn: () => fetchRatingList(itemId),
retry: 3, retry: 3,
retryDelay: 1000, retryDelay: 1000,
}); });
const ratings: RatingType[] = useMemo(() => data || [], [data]); const ratings: RatingType[] = useMemo(() => data || [], [data]);
const getRatings = () => { const getRatings = () => {
if (ratings.length === 0) { if (ratings.length === 0) {
return ( return (
<Typography variant="body1" sx={{color: theme.palette.text.secondary}}> <Typography
{t("noRatingsYet")} variant="body1"
</Typography> sx={{ color: theme.palette.text.secondary }}
); >
} {t("noRatingsYet")}
</Typography>
);
}
return ratings.map((ratingType: RatingType) => ( return ratings.map((ratingType: RatingType) => (
<RatingCard key={ratingType.timestamp} {...ratingType} /> <RatingCard key={ratingType.timestamp} {...ratingType} />
)); ));
}; };
return ( return (
<> <>
<Divider sx={{backgroundColor: theme.palette.divider, my: 3}}/> <Divider sx={{ backgroundColor: theme.palette.divider, my: 3 }} />
<Box sx={{mb: 4}}> <Box sx={{ mb: 4 }}>
<Typography variant="h5" sx={{color: theme.palette.text.primary, mb: 2}}> <Typography
{t("rateThisProduct")}: variant="h5"
</Typography> sx={{ color: theme.palette.text.primary, mb: 2 }}
>
{t("rateThisProduct")}:
</Typography>
<Rating <Rating
name="half-rating" name="half-rating"
value={ratingValue} value={ratingValue}
onChange={(_, value) => setRatingValue(value)} onChange={(_, value) => setRatingValue(value)}
precision={0.5} precision={0.5}
/> />
<TextField <TextField
label={t("review")} label={t("review")}
multiline multiline
minRows={4} minRows={4}
maxRows={16} maxRows={16}
fullWidth fullWidth
sx={{ sx={{
mt: 2, mt: 2,
mb: 2, mb: 2,
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary, color: theme.palette.text.primary,
'& .MuiInputBase-input': { "& .MuiInputBase-input": {
color: theme.palette.text.primary, color: theme.palette.text.primary,
}, },
'& label': { "& label": {
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
}, },
'& .MuiOutlinedInput-root': { "& .MuiOutlinedInput-root": {
'& fieldset': { "& fieldset": {
borderColor: theme.palette.divider, borderColor: theme.palette.divider,
}, },
'&:hover fieldset': { "&:hover fieldset": {
borderColor: theme.palette.text.primary, borderColor: theme.palette.text.primary,
}, },
'&.Mui-focused fieldset': { "&.Mui-focused fieldset": {
borderColor: theme.palette.primary.main, borderColor: theme.palette.primary.main,
}, },
}, },
}} }}
value={ratingText} value={ratingText}
onChange={(e) => setRatingText(e.target.value)} onChange={(e) => setRatingText(e.target.value)}
/> />
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
onClick={handleRatingSubmit} onClick={handleRatingSubmit}
> >
{t("submit")} {t("submit")}
</Button> </Button>
</Box> </Box>
<Divider sx={{backgroundColor: theme.palette.divider, my: 3}}/> <Divider sx={{ backgroundColor: theme.palette.divider, my: 3 }} />
<Box> <Box>{getRatings()}</Box>
{getRatings()}
</Box>
<Snackbar <Snackbar
open={open} open={open}
autoHideDuration={3000} autoHideDuration={3000}
onClose={handleClose} onClose={handleClose}
message={t("thanksForRating")} message={t("thanksForRating")}
action={action} action={action}
/> />
</> </>
); );
} }

View File

@@ -1,323 +1,406 @@
// api/queries.js // api/queries.js
import AccountType, {AdminAccountOperation, CustomerType, SubmitLogin, User} from "../../components/Account"; import AccountType, {
import OrderType, {OrderPatch} from "../../components/Order"; AdminAccountOperation,
CustomerType,
SubmitLogin,
User,
} from "../../components/Account";
import OrderType, { OrderPatch } from "../../components/Order";
import RatingSubmitType from "../../components/RatingSubmit"; import RatingSubmitType from "../../components/RatingSubmit";
import {Item, ItemWithFSImage} from "../../components/Item"; import { Item, ItemWithFSImage } from "../../components/Item";
export const fetchItemList = async () => { export const fetchItemList = async () => {
const response = await fetch('http://localhost:8085/article/all'); const response = await fetch("http://localhost:8085/article/all");
if (!response.ok) { if (!response.ok) {
throw new Error('Fehler beim Laden der Items'); throw new Error("Fehler beim Laden der Items");
} }
const data = await response.json(); const data = await response.json();
return data; return data;
}; };
export const fetchItemListWithImage = async () => { export const fetchItemListWithImage = async () => {
const response = await fetch('http://localhost:8085/article/all/image'); const response = await fetch("http://localhost:8085/article/all/image");
if (!response.ok) { if (!response.ok) {
throw new Error('Fehler beim Laden der Items'); throw new Error("Fehler beim Laden der Items");
} }
const data = await response.json(); const data = await response.json();
return data; return data;
}; };
export const submitRating = async (ratingData: RatingSubmitType) => { export const submitRating = async (ratingData: RatingSubmitType) => {
const response = await fetch('http://localhost:8085/review?uuid=' + ratingData.articleId + '&rating=' + ratingData.rating * 2, { const response = await fetch(
method: 'POST', "http://localhost:8085/review?uuid=" +
headers: { ratingData.articleId +
'Content-Type': 'text/plain', "&rating=" +
}, ratingData.rating * 2,
body: ratingData.content, {
}); method: "POST",
headers: {
"Content-Type": "text/plain",
},
body: ratingData.content,
},
);
if (!response.ok) { if (!response.ok) {
throw new Error('Fehler beim Senden der Bewertung'); throw new Error("Fehler beim Senden der Bewertung");
} }
const data = await response.json(); const data = await response.json();
return data; return data;
} };
export const fetchRatingList = async (itemId: string) => { export const fetchRatingList = async (itemId: string) => {
const response = await fetch('http://localhost:8085/review/all?uuid=' + itemId); const response = await fetch(
if (!response.ok) { "http://localhost:8085/review/all?uuid=" + itemId,
throw new Error('Fehler beim Laden der Items'); );
} if (!response.ok) {
const data = await response.json(); throw new Error("Fehler beim Laden der Items");
return data; }
const data = await response.json();
return data;
}; };
export const submitOrder = async (data: OrderType) => { export const submitOrder = async (data: OrderType) => {
const response = await fetch('http://localhost:8085/order', { const response = await fetch("http://localhost:8085/order", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Fehler beim Senden der Bestellung'); throw new Error("Fehler beim Senden der Bestellung");
} }
return await response.json(); return await response.json();
} };
export const submitAccount = async (data: AccountType) => { export const submitAccount = async (data: AccountType) => {
const response = await fetch('http://localhost:8085/account', { const response = await fetch("http://localhost:8085/account", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Fehler beim Senden des Accounts'); throw new Error("Fehler beim Senden des Accounts");
} }
return await response.json(); return await response.json();
} };
export const submitCustomer = async (data: CustomerType) => { export const submitCustomer = async (data: CustomerType) => {
const response = await fetch('http://localhost:8085/customer', { const response = await fetch("http://localhost:8085/customer", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Fehler beim Senden des Accounts'); throw new Error("Fehler beim Senden des Accounts");
} }
return await response.json(); return await response.json();
} };
export const submitLogin = async (loginData: SubmitLogin) => { export const submitLogin = async (loginData: SubmitLogin) => {
const response = await fetch("http://localhost:8085/session?email=" + loginData.email + "&password=" + loginData.password, { const response = await fetch(
method: "POST", "http://localhost:8085/session?email=" +
headers: { loginData.email +
"Content-Type": "application/json", "&password=" +
}, loginData.password,
body: JSON.stringify(loginData), {
}); method: "POST",
if (!response.ok) { headers: {
throw new Error("Login failed"); "Content-Type": "application/json",
} },
return response.json(); body: JSON.stringify(loginData),
},
);
if (!response.ok) {
throw new Error("Login failed");
}
return response.json();
}; };
export const fetchAccount = async (loginData: SubmitLogin) => { export const fetchAccount = async (loginData: SubmitLogin) => {
const response = await fetch("http://localhost:8085/account?email=" + loginData.email + "&password=" + loginData.password); const response = await fetch(
if (!response.ok) { "http://localhost:8085/account?email=" +
throw new Error("Login failed"); loginData.email +
} "&password=" +
return response.json(); loginData.password,
);
if (!response.ok) {
throw new Error("Login failed");
}
return response.json();
}; };
export const submitRegister = async (registerData: AccountType) => { export const submitRegister = async (registerData: AccountType) => {
const response = await fetch("http://localhost:8085/account", { const response = await fetch("http://localhost:8085/account", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(registerData), body: JSON.stringify(registerData),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error("Login failed"); throw new Error("Login failed");
} }
return response.json(); return response.json();
}; };
export const fetchCustomer = async (userId: number) => { export const fetchCustomer = async (userId: number) => {
const response = await fetch('http://localhost:8085/customer?id=' + userId); const response = await fetch("http://localhost:8085/customer?id=" + userId);
if (!response.ok) { if (!response.ok) {
throw new Error('Fehler beim Laden des Customers'); throw new Error("Fehler beim Laden des Customers");
} }
const data = await response.json(); const data = await response.json();
return data; return data;
}; };
export const deleteAccount = async (user: User) => { export const deleteAccount = async (user: User) => {
const response = await fetch('http://localhost:8085/account?email=' + user.email + '&password=' + user.password, { const response = await fetch(
method: 'DELETE', "http://localhost:8085/account?email=" +
}); user.email +
if (!response.ok) { "&password=" +
throw new Error('Fehler beim Löschen des Accounts'); user.password,
} {
return await response.json(); method: "DELETE",
},
);
if (!response.ok) {
throw new Error("Fehler beim Löschen des Accounts");
}
return await response.json();
}; };
export const deleteAccountAdmin = async (operation: AdminAccountOperation) => { export const deleteAccountAdmin = async (operation: AdminAccountOperation) => {
const response = await fetch('http://localhost:8085/account/admin?email=' + operation.email + '&uuid=' + operation.uuid + '&id=' + operation.accountId, { const response = await fetch(
method: 'DELETE', "http://localhost:8085/account/admin?email=" +
}); operation.email +
if (!response.ok) { "&uuid=" +
throw new Error('Fehler beim Löschen des Accounts'); operation.uuid +
} "&id=" +
return await response.json(); operation.accountId,
{
method: "DELETE",
},
);
if (!response.ok) {
throw new Error("Fehler beim Löschen des Accounts");
}
return await response.json();
}; };
export const fetchOrders = async (customerId: number) => { export const fetchOrders = async (customerId: number) => {
const response = await fetch('http://localhost:8085/order/all?customerId=' + customerId); const response = await fetch(
if (!response.ok) { "http://localhost:8085/order/all?customerId=" + customerId,
throw new Error('Fehler beim Laden des Customers'); );
} if (!response.ok) {
const data = await response.json(); throw new Error("Fehler beim Laden des Customers");
return data; }
const data = await response.json();
return data;
}; };
export const fetchAccounts = async (loginData: User) => { export const fetchAccounts = async (loginData: User) => {
const response = await fetch("http://localhost:8085/account/all?email=" + loginData.email + "&session=" + loginData.session); const response = await fetch(
if (!response.ok) { "http://localhost:8085/account/all?email=" +
throw new Error("Login failed"); loginData.email +
} "&session=" +
return response.json(); loginData.session,
}; );
if (!response.ok) {
export const fetchItems = async () => { //TODO: remove and use above throw new Error("Login failed");
const response = await fetch("http://localhost:8085/article/all"); }
if (!response.ok) { return response.json();
throw new Error("fetching items failed");
}
return response.json();
}; };
export const fetchStatisticsVolume = async (loginData: User) => { export const fetchStatisticsVolume = async (loginData: User) => {
const response = await fetch("http://localhost:8085/statistics/volume?email=" + loginData.email + "&session=" + loginData.session); const response = await fetch(
if (!response.ok) { "http://localhost:8085/statistics/volume?email=" +
throw new Error("fetching satistics Volume failed"); loginData.email +
} "&session=" +
return response.json(); loginData.session,
);
if (!response.ok) {
throw new Error("fetching satistics Volume failed");
}
return response.json();
}; };
export const fetchStatisticsRevenue = async (loginData: User) => { export const fetchStatisticsRevenue = async (loginData: User) => {
const response = await fetch("http://localhost:8085/statistics/revenue?email=" + loginData.email + "&session=" + loginData.session); const response = await fetch(
if (!response.ok) { "http://localhost:8085/statistics/revenue?email=" +
throw new Error("fetching satistics Revenue failed"); loginData.email +
} "&session=" +
return response.json(); loginData.session,
);
if (!response.ok) {
throw new Error("fetching satistics Revenue failed");
}
return response.json();
}; };
export const editAccount = async (customer: CustomerType) => { export const editAccount = async (customer: CustomerType) => {
const response = await fetch('http://localhost:8085/customer?id=' + customer.id, { const response = await fetch(
method: 'PUT', "http://localhost:8085/customer?id=" + customer.id,
headers: { {
"Content-Type": "application/json", method: "PUT",
}, headers: {
body: JSON.stringify(customer), "Content-Type": "application/json",
}); },
if (!response.ok) { body: JSON.stringify(customer),
throw new Error('Fehler beim Löschen des Accounts'); },
} );
return await response.json(); if (!response.ok) {
throw new Error("Fehler beim Löschen des Accounts");
}
return await response.json();
}; };
export const orderPatch = async (order: OrderPatch) => { export const orderPatch = async (order: OrderPatch) => {
const response = await fetch("http://localhost:8085/order?id=" + order.id + "&status=" + order.status, { const response = await fetch(
method: "PATCH", "http://localhost:8085/order?id=" + order.id + "&status=" + order.status,
} {
); method: "PATCH",
if (!response.ok) { },
throw new Error("Order patch failed"); );
} if (!response.ok) {
return response.json(); throw new Error("Order patch failed");
}
return response.json();
}; };
export const fetchOrderStatus = async (loginData: User) => { export const fetchOrderStatus = async (loginData: User) => {
const response = await fetch("http://localhost:8085/statistics/orderstatus?email=" + loginData.email + "&session=" + loginData.session); const response = await fetch(
if (!response.ok) { "http://localhost:8085/statistics/orderstatus?email=" +
throw new Error("fetching order status failed"); loginData.email +
} "&session=" +
return response.json(); loginData.session,
);
if (!response.ok) {
throw new Error("fetching order status failed");
}
return response.json();
}; };
export const fetchStockPercent = async (loginData: User) => { export const fetchStockPercent = async (loginData: User) => {
const response = await fetch("http://localhost:8085/statistics/stockpercent?email=" + loginData.email + "&session=" + loginData.session); const response = await fetch(
if (!response.ok) { "http://localhost:8085/statistics/stockpercent?email=" +
throw new Error("fetching stock% failed"); loginData.email +
} "&session=" +
return response.json(); loginData.session,
);
if (!response.ok) {
throw new Error("fetching stock% failed");
}
return response.json();
}; };
export const fetchOrdersAdmin = async (loginData: User) => { export const fetchOrdersAdmin = async (loginData: User) => {
const response = await fetch("http://localhost:8085/order/all/all?email=" + loginData.email + "&session=" + loginData.session); const response = await fetch(
if (!response.ok) { "http://localhost:8085/order/all/all?email=" +
throw new Error("fetching admin orders failed"); loginData.email +
} "&session=" +
return response.json(); loginData.session,
);
if (!response.ok) {
throw new Error("fetching admin orders failed");
}
return response.json();
}; };
export const updateCustomer = async (customer: CustomerType) => { export const updateCustomer = async (customer: CustomerType) => {
const response = await fetch('http://localhost:8085/customer?id=' + customer.id, { const response = await fetch(
method: 'PUT', "http://localhost:8085/customer?id=" + customer.id,
headers: { {
"Content-Type": "application/json", method: "PUT",
}, headers: {
body: JSON.stringify(customer), "Content-Type": "application/json",
}); },
if (!response.ok) { body: JSON.stringify(customer),
throw new Error('Fehler beim Ändern des Customers'); },
} );
return await response.json(); if (!response.ok) {
} throw new Error("Fehler beim Ändern des Customers");
}
return await response.json();
};
export const fetchFarmingStationItemList = async (): Promise<ItemWithFSImage[]> => { export const fetchFarmingStationItemList = async (): Promise<
const response = await fetch('http://localhost:8085/farm/articles'); ItemWithFSImage[]
if (!response.ok) > => {
throw new Error('Failed to fetch items'); const response = await fetch("http://localhost:8085/farm/articles");
return response.json(); if (!response.ok) throw new Error("Failed to fetch items");
} return response.json();
};
export const submitItem = async (item: Item) => { export const submitItem = async (item: Item) => {
const response = await fetch("http://localhost:8085/article", { const response = await fetch("http://localhost:8085/article", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(item), body: JSON.stringify(item),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error("Login failed"); throw new Error("Login failed");
} }
return response.json(); return response.json();
}; };
export const deleteItemQuery = async (uuid: string) => { export const deleteItemQuery = async (uuid: string) => {
const response = await fetch("http://localhost:8085/article?uuid=" + uuid, { const response = await fetch("http://localhost:8085/article?uuid=" + uuid, {
method: "DELETE" method: "DELETE",
}); });
if (!response.ok) { if (!response.ok) {
throw new Error("Login failed"); throw new Error("Login failed");
} }
return response.json(); return response.json();
}; };
export const updateAccountAdmin = async (account: AccountType, user: User) => { export const updateAccountAdmin = async (account: AccountType, user: User) => {
const response = await fetch('http://localhost:8085/account/admin?email=' + user.email + "&uuid=" + user.session + "&id=" + account.id + "&admin=" + account.admin, { const response = await fetch(
method: 'POST', "http://localhost:8085/account/admin?email=" +
}); user.email +
if (!response.ok) { "&uuid=" +
throw new Error('Fehler beim Ändern des Customers'); user.session +
} "&id=" +
return await response.json(); account.id +
} "&admin=" +
account.admin,
{
method: "POST",
},
);
if (!response.ok) {
throw new Error("Fehler beim Ändern des Customers");
}
return await response.json();
};
export const updateItemAdmin = async (item: Item) => { export const updateItemAdmin = async (item: Item) => {
const response = await fetch('http://localhost:8085/article?uuid=' + item.uuid, { const response = await fetch(
method: 'PUT', "http://localhost:8085/article?uuid=" + item.uuid,
headers: { {
"Content-Type": "application/json", method: "PUT",
}, headers: {
body: JSON.stringify(item), "Content-Type": "application/json",
}); },
if (!response.ok) { body: JSON.stringify(item),
throw new Error('Fehler beim Ändern des Items'); },
} );
return await response.json(); if (!response.ok) {
} throw new Error("Fehler beim Ändern des Items");
}
return await response.json();
};

View File

@@ -1,73 +1,73 @@
:root { :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light dark; color-scheme: light dark;
color: rgba(255, 255, 255, 0.87); color: rgba(255, 255, 255, 0.87);
background-color: #242424; background-color: #242424;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
a { a {
font-weight: 500; font-weight: 500;
color: #646cff; color: #646cff;
text-decoration: inherit; text-decoration: inherit;
} }
a:hover { a:hover {
color: #535bf2; color: #535bf2;
} }
body { body {
margin: 0; margin: 0;
display: flex; display: flex;
place-items: center; place-items: center;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
} }
h1 { h1 {
font-size: 3.2em; font-size: 3.2em;
line-height: 1.1; line-height: 1.1;
} }
button { button {
border-radius: 8px; border-radius: 8px;
border: 1px solid transparent; border: 1px solid transparent;
padding: 0.6em 1.2em; padding: 0.6em 1.2em;
font-size: 1em; font-size: 1em;
font-weight: 500; font-weight: 500;
font-family: inherit; font-family: inherit;
background-color: #1a1a1a; background-color: #1a1a1a;
cursor: pointer; cursor: pointer;
transition: border-color 0.25s; transition: border-color 0.25s;
} }
button:hover { button:hover {
border-color: #646cff; border-color: #646cff;
} }
button:focus, button:focus,
button:focus-visible { button:focus-visible {
outline: 4px auto -webkit-focus-ring-color; outline: 4px auto -webkit-focus-ring-color;
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
color: #213547; color: #213547;
background-color: #ffffff; background-color: #ffffff;
} }
a:hover { a:hover {
color: #747bff; color: #747bff;
} }
button { button {
background-color: #f9f9f9; background-color: #f9f9f9;
} }
} }

View File

@@ -1,15 +1,15 @@
import './components/i18n/i18n'; import "./components/i18n/i18n";
import {StrictMode} from 'react'; import { StrictMode } from "react";
import {createRoot} from 'react-dom/client'; import { createRoot } from "react-dom/client";
import './index.css'; import "./index.css";
import App from './App.tsx'; import App from "./App.tsx";
const rootElement = document.getElementById('root'); const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found"); if (!rootElement) throw new Error("Root element not found");
const root = createRoot(rootElement); const root = createRoot(rootElement);
root.render( root.render(
<StrictMode> <StrictMode>
<App/> <App />
</StrictMode> </StrictMode>,
); );

View File

@@ -1,239 +1,252 @@
import { import {
Box, Box,
Button, Button,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Divider, Divider,
Paper, Paper,
Stack, Stack,
TextField, TextField,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import {useQuery} from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import {useNavigate} from "react-router-dom"; import { useNavigate } from "react-router-dom";
import {CustomerType, User} from "../components/Account"; import { CustomerType, User } from "../components/Account";
import {useAccount} from "../helper/AccountProvider"; import { useAccount } from "../helper/AccountProvider";
import {deleteAccount, editAccount, fetchCustomer} from "../helper/query/Queries"; import {
deleteAccount,
editAccount,
fetchCustomer,
} from "../helper/query/Queries";
import "./pages.css"; import "./pages.css";
export default function Account() { export default function Account() {
const {t} = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const {user: userData, logout} = useAccount(); const { user: userData, logout } = useAccount();
const [user, setUser] = useState<CustomerType>({ const [user, setUser] = useState<CustomerType>({
name: "", name: "",
surname: "", surname: "",
address: "", address: "",
country: "", country: "",
zip: "", zip: "",
id: userData?.customerId || 0, id: userData?.customerId || 0,
}); });
const [userDataState, setUserDataState] = useState<User>(userData || { const [userDataState, setUserDataState] = useState<User>(
password: "", userData || {
email: "", password: "",
customerId: 0, email: "",
session: "", customerId: 0,
isAdmin: false, session: "",
}); isAdmin: false,
},
);
useEffect(() => { useEffect(() => {
if (userData?.customerId) { if (userData?.customerId) {
setUser((prev) => ({ setUser((prev) => ({
...prev, ...prev,
id: userData.customerId, id: userData.customerId,
})); }));
} }
}, [userData]); }, [userData]);
const [edit, setEdit] = useState(false); const [edit, setEdit] = useState(false);
const [form, setForm] = useState(user); const [form, setForm] = useState(user);
// Neu: Passwort-Dialog-Status und Passwort-Input // Neu: Passwort-Dialog-Status und Passwort-Input
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [passwordInput, setPasswordInput] = useState(""); const [passwordInput, setPasswordInput] = useState("");
const {data} = useQuery<CustomerType>({ const { data } = useQuery<CustomerType>({
queryKey: ["fetchCustomer", userData?.customerId], queryKey: ["fetchCustomer", userData?.customerId],
queryFn: () => fetchCustomer(userData?.customerId || 0), queryFn: () => fetchCustomer(userData?.customerId || 0),
retry: 1, retry: 1,
retryDelay: 1000, retryDelay: 1000,
}); });
const {refetch: deleteRefetch} = useQuery({ const { refetch: deleteRefetch } = useQuery({
queryKey: ["deleteAccount", userDataState], queryKey: ["deleteAccount", userDataState],
queryFn: () => deleteAccount(userDataState!), queryFn: () => deleteAccount(userDataState!),
enabled: false, enabled: false,
}); });
const {refetch: editRefetch} = useQuery({ const { refetch: editRefetch } = useQuery({
queryKey: ["editAccount", form], queryKey: ["editAccount", form],
queryFn: () => editAccount(form), queryFn: () => editAccount(form),
enabled: false, enabled: false,
}); });
useEffect(() => { useEffect(() => {
if (data) { if (data) {
setUser(data); setUser(data);
setForm(data); setForm(data);
} }
}, [data]); }, [data]);
const handleEdit = () => setEdit(true); const handleEdit = () => setEdit(true);
const handleCancel = () => { const handleCancel = () => {
setForm(user); setForm(user);
setEdit(false); setEdit(false);
}; };
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({...form, [e.target.name]: e.target.value}); setForm({ ...form, [e.target.name]: e.target.value });
}; };
const handleSave = async () => { const handleSave = async () => {
setUser(form); setUser(form);
setEdit(false); setEdit(false);
await editRefetch(); await editRefetch();
}; };
// Neu: Passwort-Dialog öffnen // Neu: Passwort-Dialog öffnen
const handleDeleteClick = () => { const handleDeleteClick = () => {
setPasswordInput(""); setPasswordInput("");
setPasswordDialogOpen(true); setPasswordDialogOpen(true);
}; };
// Neu: Passwort-Dialog schließen // Neu: Passwort-Dialog schließen
const handlePasswordDialogClose = () => { const handlePasswordDialogClose = () => {
setPasswordDialogOpen(false); setPasswordDialogOpen(false);
}; };
// Neu: Passwort-Eingabe bestätigen // Neu: Passwort-Eingabe bestätigen
const handlePasswordConfirm = async () => { const handlePasswordConfirm = async () => {
if (!passwordInput) { if (!passwordInput) {
alert(t("pleaseEnterPassword")); alert(t("pleaseEnterPassword"));
return; return;
} }
// Passwort in Form aktualisieren (hier z.B. als field "password", anpassen falls anders) // Passwort in Form aktualisieren (hier z.B. als field "password", anpassen falls anders)
setUserDataState({...userDataState, password: passwordInput}); setUserDataState({ ...userDataState, password: passwordInput });
// Erst User-Daten mit Passwort aktualisieren // Erst User-Daten mit Passwort aktualisieren
try { try {
await editRefetch(); // Achtung: editRefetch verwendet immer noch alten form, daher call direkt mit updatedForm: await editRefetch(); // Achtung: editRefetch verwendet immer noch alten form, daher call direkt mit updatedForm:
// Danach Account löschen // Danach Account löschen
await deleteRefetch(); await deleteRefetch();
logout(); logout();
navigate("/"); navigate("/");
} catch (error) { } catch (error) {
console.error("Fehler beim Löschen des Accounts:", error); console.error("Fehler beim Löschen des Accounts:", error);
alert(t("deleteAccountFailed")); alert(t("deleteAccountFailed"));
} finally { } finally {
setPasswordDialogOpen(false); setPasswordDialogOpen(false);
} }
}; };
return ( return (
<Box <Box
className="page-background page-background-center" className="page-background page-background-center"
sx={{minHeight: "100vh", justifyContent: "flex-start", pt: 4}} sx={{ minHeight: "100vh", justifyContent: "flex-start", pt: 4 }}
> >
<Paper elevation={3} sx={{p: 4, maxWidth: 500, width: "100%", mx: "auto"}}> <Paper
<Typography variant="h4" gutterBottom> elevation={3}
{t("myAccount")} sx={{ p: 4, maxWidth: 500, width: "100%", mx: "auto" }}
</Typography> >
<Divider sx={{mb: 3}}/> <Typography variant="h4" gutterBottom>
<Stack spacing={2}> {t("myAccount")}
<TextField </Typography>
label={t("name")} <Divider sx={{ mb: 3 }} />
name="name" <Stack spacing={2}>
value={edit ? form.name : user.name} <TextField
onChange={handleChange} label={t("name")}
disabled={!edit} name="name"
fullWidth value={edit ? form.name : user.name}
/> onChange={handleChange}
<TextField disabled={!edit}
label={t("surname")} fullWidth
name="surname" />
value={edit ? form.surname : user.surname} <TextField
onChange={handleChange} label={t("surname")}
disabled={!edit} name="surname"
fullWidth value={edit ? form.surname : user.surname}
/> onChange={handleChange}
<TextField disabled={!edit}
label={t("address")} fullWidth
name="address" />
value={edit ? form.address : user.address} <TextField
onChange={handleChange} label={t("address")}
disabled={!edit} name="address"
fullWidth value={edit ? form.address : user.address}
/> onChange={handleChange}
<TextField disabled={!edit}
label={t("country")} fullWidth
name="country" />
value={edit ? form.country : user.country} <TextField
onChange={handleChange} label={t("country")}
disabled={!edit} name="country"
fullWidth value={edit ? form.country : user.country}
/> onChange={handleChange}
<TextField disabled={!edit}
label={t("zip")} fullWidth
name="zip" />
value={edit ? form.zip : user.zip} <TextField
onChange={handleChange} label={t("zip")}
disabled={!edit} name="zip"
fullWidth value={edit ? form.zip : user.zip}
/> onChange={handleChange}
</Stack> disabled={!edit}
<Box sx={{display: "flex", gap: 2, mt: 4}}> fullWidth
{edit ? ( />
<> </Stack>
<Button variant="contained" color="primary" onClick={handleSave}> <Box sx={{ display: "flex", gap: 2, mt: 4 }}>
{t("save")} {edit ? (
</Button> <>
<Button variant="outlined" color="secondary" onClick={handleCancel}> <Button variant="contained" color="primary" onClick={handleSave}>
{t("cancel")} {t("save")}
</Button> </Button>
</> <Button
) : ( variant="outlined"
<Button variant="contained" color="primary" onClick={handleEdit}> color="secondary"
{t("edit")} onClick={handleCancel}
</Button> >
)} {t("cancel")}
<Button </Button>
variant="outlined" </>
color="error" ) : (
onClick={handleDeleteClick} // Neu: Passwort-Dialog öffnen <Button variant="contained" color="primary" onClick={handleEdit}>
sx={{marginLeft: "auto"}} {t("edit")}
> </Button>
{t("deleteAccount")} )}
</Button> <Button
</Box> variant="outlined"
</Paper> color="error"
onClick={handleDeleteClick} // Neu: Passwort-Dialog öffnen
{/* Passwort-Dialog */} sx={{ marginLeft: "auto" }}
<Dialog open={passwordDialogOpen} onClose={handlePasswordDialogClose}> >
<DialogTitle>{t("confirmDeleteAccount")}</DialogTitle> {t("deleteAccount")}
<DialogContent> </Button>
<Typography>{t("enterPasswordToConfirmDeletion")}</Typography>
<TextField
autoFocus
margin="dense"
label={t("password")}
type="password"
fullWidth
variant="standard"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={handlePasswordDialogClose}>{t("cancel")}</Button>
<Button color="error" onClick={handlePasswordConfirm}>
{t("delete")}
</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); </Paper>
{/* Passwort-Dialog */}
<Dialog open={passwordDialogOpen} onClose={handlePasswordDialogClose}>
<DialogTitle>{t("confirmDeleteAccount")}</DialogTitle>
<DialogContent>
<Typography>{t("enterPasswordToConfirmDeletion")}</Typography>
<TextField
autoFocus
margin="dense"
label={t("password")}
type="password"
fullWidth
variant="standard"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={handlePasswordDialogClose}>{t("cancel")}</Button>
<Button color="error" onClick={handlePasswordConfirm}>
{t("delete")}
</Button>
</DialogActions>
</Dialog>
</Box>
);
} }

View File

@@ -1,81 +1,111 @@
import {AccountCircle, Category, QueryStats, ReceiptLong,} from "@mui/icons-material"; import {
import {Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText, useTheme,} from "@mui/material"; AccountCircle,
import {useState} from "react"; Category,
import {useTranslation} from "react-i18next"; QueryStats,
ReceiptLong,
} from "@mui/icons-material";
import {
Box,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
useTheme,
} from "@mui/material";
import { fetchItemList } from "../helper/query/Queries.tsx";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAccount } from "../helper/AccountProvider.tsx";
import AccountsInfo from "../helper/adminpanel/AccountsInfo"; import AccountsInfo from "../helper/adminpanel/AccountsInfo";
import ItemInfo from "../helper/adminpanel/ItemsInfo"; import ItemInfo from "../helper/adminpanel/ItemsInfo";
import OrdersInfo from "../helper/adminpanel/OrdersInfo"; import OrdersInfo from "../helper/adminpanel/OrdersInfo";
import StatisticsInfo from "../helper/adminpanel/StatisticsInfo"; import StatisticsInfo from "../helper/adminpanel/StatisticsInfo";
import Item from "../components/Item";
export default function AdminPanel() { export default function AdminPanel() {
const {t} = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const [infoStatus, setInfoStatus] = useState<string>("statistics"); const [infoStatus, setInfoStatus] = useState<string>("statistics");
const handleInfoStatus = (path: string) => { const handleInfoStatus = (path: string) => {
setInfoStatus(path); setInfoStatus(path);
}; };
const renderContent = () => { const { user: loginData } = useAccount();
switch (infoStatus) {
case "statistics":
return <StatisticsInfo/>;
case "orders":
return <OrdersInfo/>;
case "accounts":
return <AccountsInfo/>;
case "items":
return <ItemInfo/>;
default:
return <StatisticsInfo/>;
}
};
const menuItems = [ const { data: itemList, isLoading } = useQuery<Item[]>({
{key: "statistics", icon: <QueryStats/>, label: t("statistics")}, queryKey: ["fetchItemList", loginData],
{key: "orders", icon: <ReceiptLong/>, label: t("orders")}, queryFn: () => fetchItemList(),
{key: "accounts", icon: <AccountCircle/>, label: t("accounts")}, retry: 3,
{key: "items", icon: <Category/>, label: t("items")}, retryDelay: 1000,
]; });
return ( const renderContent = (itemList: Item[]) => {
<Box className="page-container" sx={{color: theme.palette.text.primary}}> switch (infoStatus) {
<div case "statistics":
className="home-page-background" return <StatisticsInfo />;
style={{ case "orders":
color: theme.palette.text.primary, return <OrdersInfo itemList={itemList} />;
backgroundColor: theme.palette.background.paper, case "accounts":
}}> return <AccountsInfo />;
{/* Sidebar */} case "items":
<Box className="sidebar"> return <ItemInfo itemList={itemList} />;
<nav aria-label={t("mainAdminMenu")}> default:
<List> return <StatisticsInfo />;
{menuItems.map((item) => ( }
<ListItem key={item.key} disablePadding> };
<ListItemButton
selected={infoStatus === item.key}
onClick={() => handleInfoStatus(item.key)}
sx={{
"&.Mui-selected": {
bgcolor: theme.palette.action.selected,
color: theme.palette.primary.main,
},
"&:hover": {
bgcolor: theme.palette.action.hover,
},
}}>
<ListItemIcon sx={{color: "inherit"}}>{item.icon}</ListItemIcon>
<ListItemText primary={item.label}/>
</ListItemButton>
</ListItem>
))}
</List>
</nav>
</Box>
{/* Content */} const menuItems = [
<Box className="page-background">{renderContent()}</Box> { key: "statistics", icon: <QueryStats />, label: t("statistics") },
</div> { key: "orders", icon: <ReceiptLong />, label: t("orders") },
{ key: "accounts", icon: <AccountCircle />, label: t("accounts") },
{ key: "items", icon: <Category />, label: t("items") },
];
return (
<Box className="page-container" sx={{ color: theme.palette.text.primary }}>
<div
className="home-page-background"
style={{
color: theme.palette.text.primary,
backgroundColor: theme.palette.background.paper,
}}
>
{/* Sidebar */}
<Box className="sidebar">
<nav aria-label={t("mainAdminMenu")}>
<List>
{menuItems.map((item) => (
<ListItem key={item.key} disablePadding>
<ListItemButton
selected={infoStatus === item.key}
onClick={() => handleInfoStatus(item.key)}
sx={{
"&.Mui-selected": {
bgcolor: theme.palette.action.selected,
color: theme.palette.primary.main,
},
"&:hover": {
bgcolor: theme.palette.action.hover,
},
}}
>
<ListItemIcon sx={{ color: "inherit" }}>
{item.icon}
</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
</ListItem>
))}
</List>
</nav>
</Box> </Box>
);
{/* Content */}
<Box className="page-background">{renderContent(itemList)}</Box>
</div>
</Box>
);
} }

View File

@@ -1,91 +1,130 @@
import {Box, Divider, Typography} from "@mui/material"; import { Box, Divider, Typography } from "@mui/material";
import "./pages.css"; import "./pages.css";
export default function Impressum() { export default function Impressum() {
return ( return (
<Box className="impressum-container"> <Box className="impressum-container">
<Typography variant="h4" sx={{color: 'text.primary', mb: 2}}> <Typography variant="h4" sx={{ color: "text.primary", mb: 2 }}>
Impressum Impressum
</Typography> </Typography>
<Typography variant="body1" sx={{color: 'text.primary', mb: 4}}> <Typography variant="body1" sx={{ color: "text.primary", mb: 4 }}>
Hochschule für Technik und Wirtschaft<br/> Hochschule für Technik und Wirtschaft
des Saarlandes<br/> <br />
Goebenstraße 40<br/> des Saarlandes
66117 Saarbrücken<br/><br/> <br />
Telefon: (0681) 58 67 - 0<br/> Goebenstraße 40
Telefax: (0681) 58 67 - 122<br/> <br />
E-Mail: info@htwsaar.de<br/><br/> 66117 Saarbrücken
Aufsichtsbehörde:<br/> <br />
Ministerium der Finanzen und für Wissenschaft des Saarlandes <br />
</Typography> Telefon: (0681) 58 67 - 0<br />
Telefax: (0681) 58 67 - 122
<br />
E-Mail: info@htwsaar.de
<br />
<br />
Aufsichtsbehörde:
<br />
Ministerium der Finanzen und für Wissenschaft des Saarlandes
</Typography>
<Divider className="contact-divider"/> <Divider className="contact-divider" />
<Typography variant="h5" sx={{color: 'text.primary', mt: 4, mb: 2}}> <Typography variant="h5" sx={{ color: "text.primary", mt: 4, mb: 2 }}>
Datenschutzerklärung Datenschutzerklärung
</Typography> </Typography>
<Typography variant="body1" sx={{color: 'text.primary', mb: 2}}> <Typography variant="body1" sx={{ color: "text.primary", mb: 2 }}>
Personenbezogene Daten (nachfolgend zumeist nur Daten genannt) ... Personenbezogene Daten (nachfolgend zumeist nur Daten genannt) ...
</Typography> </Typography>
<Typography variant="body1" sx={{color: 'text.primary', mb: 2}}> <Typography variant="body1" sx={{ color: "text.primary", mb: 2 }}>
Gemäß Art. 4 Ziffer 1. der Verordnung (EU) 2016/679, also der Datenschutz-Grundverordnung ... Gemäß Art. 4 Ziffer 1. der Verordnung (EU) 2016/679, also der
</Typography> Datenschutz-Grundverordnung ...
</Typography>
<Typography variant="body1" sx={{color: 'text.primary', mb: 2}}> <Typography variant="body1" sx={{ color: "text.primary", mb: 2 }}>
Unsere Datenschutzerklärung ist wie folgt gegliedert:<br/> Unsere Datenschutzerklärung ist wie folgt gegliedert:
I. Informationen über uns als Verantwortliche<br/> <br />
II. Rechte der Nutzer und Betroffenen<br/> I. Informationen über uns als Verantwortliche
III. Informationen zur Datenverarbeitung <br />
</Typography> II. Rechte der Nutzer und Betroffenen
<br />
III. Informationen zur Datenverarbeitung
</Typography>
<Typography variant="h6" sx={{color: 'text.primary', mt: 4, mb: 1}}> <Typography variant="h6" sx={{ color: "text.primary", mt: 4, mb: 1 }}>
I. Informationen über uns als Verantwortliche I. Informationen über uns als Verantwortliche
</Typography> </Typography>
<Typography variant="body1" sx={{color: 'text.primary', mb: 2}}> <Typography variant="body1" sx={{ color: "text.primary", mb: 2 }}>
Verantwortlicher Anbieter dieses Internetauftritts ... Verantwortlicher Anbieter dieses Internetauftritts ...
</Typography> </Typography>
<Typography variant="h6" sx={{color: 'text.primary', mt: 4, mb: 1}}> <Typography variant="h6" sx={{ color: "text.primary", mt: 4, mb: 1 }}>
II. Rechte der Nutzer und Betroffenen II. Rechte der Nutzer und Betroffenen
</Typography> </Typography>
<Typography variant="body1" sx={{color: 'text.primary', mb: 2}}> <Typography variant="body1" sx={{ color: "text.primary", mb: 2 }}>
Mit Blick auf die nachfolgend noch näher beschriebene Datenverarbeitung haben die Nutzer und Betroffenen Mit Blick auf die nachfolgend noch näher beschriebene Datenverarbeitung
... haben die Nutzer und Betroffenen ...
</Typography> </Typography>
<Box component="ul" sx={{listStyle: 'none', p: 0, m: 0}}> <Box component="ul" sx={{ listStyle: "none", p: 0, m: 0 }}>
<li><Typography variant="body2" sx={{color: 'text.primary', mb: 0.5}}> Auskunft über die verarbeiteten <li>
Daten (Art. 15 DSGVO)</Typography></li> <Typography variant="body2" sx={{ color: "text.primary", mb: 0.5 }}>
<li><Typography variant="body2" sx={{color: 'text.primary', mb: 0.5}}> Berichtigung unrichtiger Daten {" "}
(Art. 16 DSGVO)</Typography></li> Auskunft über die verarbeiteten Daten (Art. 15 DSGVO)
<li><Typography variant="body2" sx={{color: 'text.primary', mb: 0.5}}> Löschung der Daten (Art. 17 </Typography>
DSGVO)</Typography></li> </li>
<li><Typography variant="body2" sx={{color: 'text.primary', mb: 0.5}}> Einschränkung der Verarbeitung <li>
(Art. 18 DSGVO)</Typography></li> <Typography variant="body2" sx={{ color: "text.primary", mb: 0.5 }}>
<li><Typography variant="body2" sx={{color: 'text.primary', mb: 0.5}}> Datenübertragbarkeit (Art. 20 {" "}
DSGVO)</Typography></li> Berichtigung unrichtiger Daten (Art. 16 DSGVO)
</Box> </Typography>
</li>
<li>
<Typography variant="body2" sx={{ color: "text.primary", mb: 0.5 }}>
{" "}
Löschung der Daten (Art. 17 DSGVO)
</Typography>
</li>
<li>
<Typography variant="body2" sx={{ color: "text.primary", mb: 0.5 }}>
{" "}
Einschränkung der Verarbeitung (Art. 18 DSGVO)
</Typography>
</li>
<li>
<Typography variant="body2" sx={{ color: "text.primary", mb: 0.5 }}>
{" "}
Datenübertragbarkeit (Art. 20 DSGVO)
</Typography>
</li>
</Box>
<Typography variant="h6" sx={{color: 'text.primary', mt: 4, mb: 1}}> <Typography variant="h6" sx={{ color: "text.primary", mt: 4, mb: 1 }}>
III. Informationen zur Datenverarbeitung III. Informationen zur Datenverarbeitung
</Typography> </Typography>
<Typography variant="body1" sx={{color: 'text.primary', mb: 2}}> <Typography variant="body1" sx={{ color: "text.primary", mb: 2 }}>
Ihre bei Nutzung unseres Internetauftritts verarbeiteten Daten ... Ihre bei Nutzung unseres Internetauftritts verarbeiteten Daten ...
</Typography> </Typography>
{/* Du kannst einfach alle weiteren Absätze so fortsetzen copy & paste, {/* Du kannst einfach alle weiteren Absätze so fortsetzen copy & paste,
jeweils in: <Typography variant="body1" sx={{ color: 'text.primary' }}>…</Typography> */} jeweils in: <Typography variant="body1" sx={{ color: 'text.primary' }}>…</Typography> */}
<Typography variant="body2" sx={{color: 'text.primary', mt: 4}}> <Typography variant="body2" sx={{ color: "text.primary", mt: 4 }}>
Mehr Infos unter: <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" Mehr Infos unter:{" "}
rel="noopener noreferrer">CloudFlare Datenschutzerklärung</a> <a
</Typography> href="https://www.cloudflare.com/privacypolicy/"
</Box> target="_blank"
); rel="noopener noreferrer"
>
CloudFlare Datenschutzerklärung
</a>
</Typography>
</Box>
);
} }

View File

@@ -1,106 +1,142 @@
import {Box, Button, Typography, useTheme} from "@mui/material"; import { Box, Button, Typography, useTheme } from "@mui/material";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import {useState} from "react"; import { useState } from "react";
import {useQuery} from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import {useBasket} from "../helper/BasketProvider"; import { useBasket } from "../helper/BasketProvider";
import ItemCard from "../helper/homepage/ItemCard"; import ItemCard from "../helper/homepage/ItemCard";
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart'; import AddShoppingCartIcon from "@mui/icons-material/AddShoppingCart";
import farmingStation from '../assets/fscomponents/fs_components_0.png'; import farmingStation from "../assets/fscomponents/fs_components_0.png";
import {ItemWithFSImage} from "../components/Item"; import { ItemWithFSImage } from "../components/Item";
import {fetchFarmingStationItemList} from "../helper/query/Queries"; import { fetchFarmingStationItemList } from "../helper/query/Queries";
"../components/Item"; ("../components/Item");
export default function FSComponents() { export default function FSComponents() {
const {t} = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const {addToBasket} = useBasket(); const { addToBasket } = useBasket();
const [hoverIndex, setHoverIndex] = useState<number | null>(null); const [hoverIndex, setHoverIndex] = useState<number | null>(null);
// Sehr sehr dummer Weg das zu machen, aber wird später noch refactored // Sehr sehr dummer Weg das zu machen, aber wird später noch refactored
const wantedIds = ["60", "67", "68", "69", "70", "71", "72", "73", "74", "75"]; const wantedIds = [
"60",
"67",
"68",
"69",
"70",
"71",
"72",
"73",
"74",
"75",
];
// Daten mit react-query laden // Daten mit react-query laden
const {data = [], isLoading, error} = useQuery<ItemWithFSImage[]>({ const {
queryKey: ['fetchFarmingStationItemList'], data = [],
queryFn: fetchFarmingStationItemList, isLoading,
retry: 3, error,
retryDelay: 1000, } = useQuery<ItemWithFSImage[]>({
queryKey: ["fetchFarmingStationItemList"],
queryFn: fetchFarmingStationItemList,
retry: 3,
retryDelay: 1000,
});
// Button-Funktion: alle gefilterten Items in den Warenkorb packen
const handleAddAllToCart = () => {
data.forEach((item) => {
addToBasket(item, 1);
}); });
};
// Button-Funktion: alle gefilterten Items in den Warenkorb packen if (isLoading) return <Typography>{t("loading")}</Typography>;
const handleAddAllToCart = () => { if (error)
data.forEach(item => { return <Typography color="error">{t("errorLoadingItems")}</Typography>;
addToBasket(item, 1);
});
};
if (isLoading) return <Typography>{t('loading')}</Typography>; return (
if (error) return <Typography color="error">{t('errorLoadingItems')}</Typography>; <Box
sx={{
width: "100%",
display: "flex",
maxWidth: 1600,
mx: "auto",
pt: 2,
height: "100vh",
}}
>
{/* Bild links */}
<Box
sx={{
width: "auto",
height: "90vh",
position: "sticky",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 2,
overflow: "hidden",
}}
>
<Box
component="img"
src={
hoverIndex !== null ? data[hoverIndex].farmImage : farmingStation
}
alt={t("componentsFarmingStation")}
sx={{
width: "100%",
height: "auto",
maxHeight: "80vh",
objectFit: "contain",
borderRadius: 2,
border: `4px solid ${theme.palette.primary.main}`,
marginBottom: 2,
}}
/>
<Button
variant="contained"
fullWidth
startIcon={<AddShoppingCartIcon />}
onClick={handleAddAllToCart}
>
{t("addAllToCart")}
</Button>
</Box>
return ( {/* Items rechts */}
<Box sx={{width: '100%', display: 'flex', maxWidth: 1600, mx: 'auto', pt: 2, height: '100vh'}}> <Box
{/* Bild links */} sx={{
<Box sx={{ width: "60%",
width: 'auto', height: "90vh",
height: '90vh', overflowY: "auto",
position: 'sticky', padding: 2,
display: 'flex', }}
flexDirection: 'column', >
alignItems: 'center', <Box mb={2}>
justifyContent: 'center', <Typography
padding: 2, variant="h4"
overflow: 'hidden', align="center"
}}> gutterBottom
<Box sx={{ color: "text.primary" }}
component="img" >
src={hoverIndex !== null ? data[hoverIndex].farmImage : farmingStation} {t("componentsFarmingStation")}
alt={t('componentsFarmingStation')} </Typography>
sx={{
width: '100%',
height: 'auto',
maxHeight: '80vh',
objectFit: 'contain',
borderRadius: 2,
border: `4px solid ${theme.palette.primary.main}`,
marginBottom: 2,
}}
/>
<Button
variant="contained"
fullWidth
startIcon={<AddShoppingCartIcon/>}
onClick={handleAddAllToCart}
>
{t('addAllToCart')}
</Button>
</Box>
{/* Items rechts */}
<Box sx={{
width: '60%',
height: '90vh',
overflowY: 'auto',
padding: 2,
}}>
<Box mb={2}>
<Typography variant="h4" align="center" gutterBottom sx={{color: 'text.primary'}}>
{t('componentsFarmingStation')}
</Typography>
</Box>
<Box className="cardgrid">
{data.map((item, index) => (
<div
key={item.id}
onMouseEnter={() => setHoverIndex(index)}
onMouseLeave={() => setHoverIndex(null)}
>
<ItemCard item={item}/>
</div>
))}
</Box>
</Box>
</Box> </Box>
); <Box className="cardgrid">
{data.map((item, index) => (
<div
key={item.id}
onMouseEnter={() => setHoverIndex(index)}
onMouseLeave={() => setHoverIndex(null)}
>
<ItemCard item={item} />
</div>
))}
</Box>
</Box>
</Box>
);
} }

View File

@@ -1,201 +1,206 @@
import {Alert, Box, useTheme} from "@mui/material"; import { Alert, Box, useTheme } from "@mui/material";
import {useQuery} from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import {useEffect, useMemo, useRef, useState} from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import {useLocation, useNavigate} from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import ItemWithImage from "../components/Item"; import ItemWithImage from "../components/Item";
import FilterItem from "../helper/homepage/FilterItem"; import FilterItem from "../helper/homepage/FilterItem";
import ItemCard from "../helper/homepage/ItemCard"; import ItemCard from "../helper/homepage/ItemCard";
import PriceSlider from "../helper/homepage/PriceSlider"; import PriceSlider from "../helper/homepage/PriceSlider";
import {fetchItemListWithImage} from '../helper/query/Queries'; import { fetchItemListWithImage } from "../helper/query/Queries";
import "./pages.css"; // Import der CSS-Datei import "./pages.css"; // Import der CSS-Datei
export default function Home() { export default function Home() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const theme = useTheme();
const [searchQuery, setSearchQuery] = useState<string | null>(null);
const {t} = useTranslation(); const categoriesFilter = useMemo(
const navigate = useNavigate(); () => [
const location = useLocation(); { value: "", label: t("allCategories") },
const theme = useTheme(); { value: "Seeds", label: t("seeds") },
const [searchQuery, setSearchQuery] = useState<string | null>(null); { value: "GardenSupplies", label: t("gardenSupplies") },
{ value: "TechnicalComponents", label: t("technicalComponents") },
{ value: "Other", label: t("other") },
],
[t],
);
const categoriesFilter = useMemo(() => [ const ratingFilter = [
{value: "", label: t("allCategories")}, { value: "", label: t("allRatings") },
{value: "Seeds", label: t("seeds")}, ...[5, 4, 3, 2, 1].map((value) => ({
{value: "GardenSupplies", label: t("gardenSupplies")}, value: value.toString(),
{value: "TechnicalComponents", label: t("technicalComponents")}, label: value.toString(),
{value: "Other", label: t("other")} })),
], [t]); ];
const ratingFilter = [ const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
{value: "", label: t('allRatings')}, const [selectedRating, setSelectedRating] = useState<string | null>(null);
...[5, 4, 3, 2, 1].map(value => ({
value: value.toString(),
label: value.toString()
}))
];
const [selectedCategory, setSelectedCategory] = useState<string | null>(null); const { data = [], isLoading } = useQuery<ItemWithImage[]>({
const [selectedRating, setSelectedRating] = useState<string | null>(null); queryKey: ["fetchItemListWithImage"],
queryFn: fetchItemListWithImage,
retry: 3, // Versucht es 3-mal erneut
retryDelay: 1000, // Wartezeit zwischen den Versuchen (in ms)
});
const {data = [], isLoading} = useQuery<ItemWithImage[]>({ const items: ItemWithImage[] = useMemo(() => data || [], [data]);
queryKey: ['fetchItemListWithImage'],
queryFn: fetchItemListWithImage,
retry: 3, // Versucht es 3-mal erneut
retryDelay: 1000, // Wartezeit zwischen den Versuchen (in ms)
});
const items: ItemWithImage[] = useMemo(() => data || [], [data]); const discountedPrices = items.map(
(item) => item.price100 * (1 - item.discount100 / 100),
);
const minPrice =
discountedPrices.length > 0 ? Math.min(...discountedPrices) : 0;
const maxPrice =
discountedPrices.length > 0 ? Math.max(...discountedPrices) : 1000;
const [priceRange, setPriceRange] = useState<[number, number]>([
minPrice,
maxPrice,
]);
const discountedPrices = items.map( // Filter aus URL übernehmen
(item) => item.price100 * (1 - item.discount100 / 100) useEffect(() => {
); const params = new URLSearchParams(location.search);
const minPrice = discountedPrices.length > 0 ? Math.min(...discountedPrices) : 0; const category = params.get("category");
const maxPrice = discountedPrices.length > 0 ? Math.max(...discountedPrices) : 1000; if (category && categoriesFilter.some((f) => f.value === category)) {
const [priceRange, setPriceRange] = useState<[number, number]>([ setSelectedCategory(category);
minPrice, } else {
maxPrice, setSelectedCategory(null);
]); }
}, [location.search, categoriesFilter]);
// Filter aus URL übernehmen // Filterfunktion bleibt gleich
useEffect(() => { const filteredItems: ItemWithImage[] = useMemo(() => {
const params = new URLSearchParams(location.search); return items
const category = params.get("category"); .filter((item) => {
if (category && categoriesFilter.some((f) => f.value === category)) { const discountedPrice = item.price100 * (1 - item.discount100 / 100);
setSelectedCategory(category); return (
} else { discountedPrice >= priceRange[0] && discountedPrice <= priceRange[1]
setSelectedCategory(null); );
} })
}, [location.search, categoriesFilter]); .filter((item) => {
if (!selectedCategory) return true;
return item.category.toLowerCase() === selectedCategory.toLowerCase();
})
.filter((item) => {
if (!selectedRating) return true;
const rating = item.rating;
return rating >= Number(selectedRating) * 2;
})
.filter((item) => {
if (!searchQuery) return true;
return item.name.toLowerCase().includes(searchQuery.toLowerCase());
});
}, [items, priceRange, selectedCategory, selectedRating, searchQuery]);
// Filterfunktion bleibt gleich // Lese die Suchanfrage aus der URL
const filteredItems: ItemWithImage[] = useMemo(() => { useEffect(() => {
return items const params = new URLSearchParams(location.search);
.filter((item) => { const query = params.get("search");
const discountedPrice = item.price100 * (1 - item.discount100 / 100); setSearchQuery(query);
return discountedPrice >= priceRange[0] && discountedPrice <= priceRange[1]; }, [location.search]);
})
.filter((item) => {
if (!selectedCategory) return true;
return item.category.toLowerCase() === selectedCategory.toLowerCase();
})
.filter((item) => {
if (!selectedRating) return true;
const rating = item.rating;
return rating >= (Number(selectedRating) * 2);
})
.filter((item) => {
if (!searchQuery) return true;
return (item.name.toLowerCase().includes(searchQuery.toLowerCase())
);
});
}, [items, priceRange, selectedCategory, selectedRating, searchQuery]);
// Container Ref
const containerRef = useRef<HTMLDivElement>(null);
// Lese die Suchanfrage aus der URL const prevItemsLength = useRef(items.length);
useEffect(() => {
const params = new URLSearchParams(location.search);
const query = params.get("search");
setSearchQuery(query);
}, [location.search]);
useEffect(() => {
if (items.length >= prevItemsLength.current) {
prevItemsLength.current = items.length;
return;
}
// Container Ref setTimeout(() => {
const containerRef = useRef<HTMLDivElement>(null); containerRef.current?.scrollTo(0, 0);
}, 50);
const prevItemsLength = useRef(items.length); prevItemsLength.current = items.length;
}, [items]);
useEffect(() => { // Kategorie-Änderung
if (items.length >= prevItemsLength.current) { const handleCategoryChange = (category: string) => {
prevItemsLength.current = items.length; if (category === "") {
return; setSelectedCategory(null);
} navigate(`/`);
} else {
setSelectedCategory(category);
navigate(`/?category=${encodeURIComponent(category)}`);
}
};
setTimeout(() => { // Rating-Änderung (bleibt gleich)
containerRef.current?.scrollTo(0, 0); const handleRatingChange = (rating: string) => {
}, 50); if (rating === "") {
setSelectedRating(null);
} else {
setSelectedRating(rating);
}
};
prevItemsLength.current = items.length; return (
}, [items]); <div
className="home-page-background"
// Kategorie-Änderung style={{ backgroundColor: theme.palette.homepage }}
const handleCategoryChange = (category: string) => { >
if (category === "") { <div className="sidebar sidebar-filter">
setSelectedCategory(null); <FilterItem
navigate(`/`); filterName={t("category")}
} else { filterItems={categoriesFilter}
setSelectedCategory(category); value={selectedCategory}
navigate(`/?category=${encodeURIComponent(category)}`); onChange={handleCategoryChange}
} />
}; <PriceSlider
min={minPrice}
// Rating-Änderung (bleibt gleich) max={maxPrice}
const handleRatingChange = (rating: string) => { onChange={(range) => {
if (rating === "") { setPriceRange(range);
setSelectedRating(null); }}
} else { />
setSelectedRating(rating); <FilterItem
} filterName={t("ratingFrom")}
}; filterItems={ratingFilter}
value={selectedRating}
return ( onChange={handleRatingChange}
<div />
className="home-page-background" </div>
style={{backgroundColor: theme.palette.homepage}} <div
> className="home-page-background"
<div className="sidebar sidebar-filter"> style={{ backgroundColor: theme.palette.homepage }}
<FilterItem >
filterName={t("category")} {isLoading && t("loading")}
filterItems={categoriesFilter} {!isLoading && (
value={selectedCategory} <main
onChange={handleCategoryChange} className="page-background page-background-center"
/> ref={containerRef}
<PriceSlider >
min={minPrice} <Box className="cardgrid">
max={maxPrice} {filteredItems.length === 0 ? (
onChange={(range) => { <Alert
setPriceRange(range); variant="filled"
}} severity="error"
/> sx={{
<FilterItem bgcolor: theme.palette.error.main,
filterName={t("rating")} color: theme.palette.getContrastText(
filterItems={ratingFilter} theme.palette.error.main,
value={selectedRating} ),
onChange={handleRatingChange} width: "100%",
/> justifyContent: "center",
</div> }}
<div
className="home-page-background"
style={{backgroundColor: theme.palette.homepage}}
>
{isLoading && t('loading')}
{!isLoading && <main
className="page-background page-background-center"
ref={containerRef}
> >
<Box className="cardgrid"> {t("noItemsFound")}
{filteredItems.length === 0 ? ( </Alert>
<Alert ) : (
variant="filled" filteredItems.map((item) => (
severity="error" <ItemCard key={item.id} item={item} />
sx={{ ))
bgcolor: theme.palette.error.main, )}
color: theme.palette.getContrastText( </Box>
theme.palette.error.main </main>
), )}
width: "100%", </div>
justifyContent: "center", </div>
}} );
>
{t("noItemsFound")}
</Alert>
) : (
filteredItems.map(item => (
<ItemCard key={item.id} item={item}/>
))
)}
</Box>
</main>}
</div>
</div>
);
} }

View File

@@ -1,41 +1,40 @@
import {Box, Button, Typography, useTheme} from "@mui/material"; import { Box, Button, Typography, useTheme } from "@mui/material";
import {useNavigate} from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./pages.css"; import "./pages.css";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
export default function NoPage() { export default function NoPage() {
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
const theme = useTheme(); const handleGoHome = () => {
const {t} = useTranslation(); navigate("/");
const navigate = useNavigate(); };
const handleGoHome = () => { return (
navigate("/"); <Box
}; className="no-page-container"
sx={{ color: theme.palette.text.primary }}
return ( >
<Box <Typography variant="h1" className="no-page-title">
className="no-page-container" 404
sx={{color: theme.palette.text.primary}} </Typography>
> <Typography variant="h5" className="no-page-subtitle">
<Typography variant="h1" className="no-page-title"> {t("pageDoesNotExist")}
404 </Typography>
</Typography> <Typography variant="body1" className="no-page-description">
<Typography variant="h5" className="no-page-subtitle"> {t("wrongTurn")}
{t('pageDoesNotExist')} </Typography>
</Typography> <Button
<Typography variant="body1" className="no-page-description"> variant="contained"
{t('wrongTurn')} color="primary"
</Typography> size="large"
<Button className="no-page-button"
variant="contained" onClick={handleGoHome}
color="primary" >
size="large" {t("backToHome")}
className="no-page-button" </Button>
onClick={handleGoHome} </Box>
> );
{t('backToHome')} }
</Button>
</Box>
);
}

View File

@@ -1,149 +1,185 @@
import { import {
Box, Box,
Button, Button,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Divider, Divider,
List, List,
ListItemButton, ListItemButton,
ListItemText, ListItemText,
Paper, Paper,
Stack, Stack,
Tab, Tab,
Tabs, Tabs,
Typography Typography,
} from "@mui/material"; } from "@mui/material";
import {useQuery} from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import OrderType, {OrderStatusEnum} from "../components/Order"; import OrderType, { OrderStatusEnum } from "../components/Order";
import "./pages.css"; import "./pages.css";
import {useAccount} from "../helper/AccountProvider"; import { useAccount } from "../helper/AccountProvider";
import {fetchOrders, orderPatch} from "../helper/query/Queries"; import { fetchOrders, orderPatch } from "../helper/query/Queries";
export default function Orders() { export default function Orders() {
const { user } = useAccount();
const [orders, setOrders] = useState<OrderType[]>([]);
const {user} = useAccount(); const { data: accountOrders, refetch } = useQuery<OrderType[]>({
const [orders, setOrders] = useState<OrderType[]>([]) queryKey: ["fetchOrders", user?.customerId], // Hier sollte die tatsächliche Kunden-ID verwendet werden
queryFn: () => (user ? fetchOrders(user.customerId) : Promise.resolve([])), // Simulierte API-Antwort
enabled: !!user, // Nur ausführen, wenn user existiert
retry: 3, // Versucht es 3-mal erneut
retryDelay: 1000, // Wartezeit zwischen den Versuchen (in ms)
});
const {data: accountOrders, refetch} = useQuery<OrderType[]>({ useEffect(() => {
queryKey: ['fetchOrders', user?.customerId], // Hier sollte die tatsächliche Kunden-ID verwendet werden setOrders(accountOrders ?? []);
queryFn: () => user ? fetchOrders(user.customerId) : Promise.resolve([]), // Simulierte API-Antwort console.log("Orders fetched:", accountOrders);
enabled: !!user, // Nur ausführen, wenn user existiert }, [accountOrders]);
retry: 3, // Versucht es 3-mal erneut
retryDelay: 1000, // Wartezeit zwischen den Versuchen (in ms)
});
useEffect(() => { const { t } = useTranslation();
setOrders(accountOrders ?? []);
console.log("Orders fetched:", accountOrders);
}, [accountOrders]);
const {t} = useTranslation(); const [tab, setTab] = useState(0);
const [selectedOrder, setSelectedOrder] = useState<OrderType | null>(null);
const [tab, setTab] = useState(0); const activeOrders = orders.filter(
const [selectedOrder, setSelectedOrder] = useState<OrderType | null>(null); (o) =>
o.status === OrderStatusEnum.ISSUES ||
o.status === OrderStatusEnum.IN_PROGRESS ||
o.status === OrderStatusEnum.ORDERED,
);
const inactiveOrders = orders.filter(
(o) =>
o.status === OrderStatusEnum.CANCELLED ||
o.status === OrderStatusEnum.DELIVERED,
);
const activeOrders = orders.filter(o => o.status === OrderStatusEnum.ISSUES || o.status === OrderStatusEnum.IN_PROGRESS || o.status === OrderStatusEnum.ORDERED); const handleTabChange = (_: React.SyntheticEvent, newValue: number) =>
const inactiveOrders = orders.filter(o => o.status === OrderStatusEnum.CANCELLED || o.status === OrderStatusEnum.DELIVERED); setTab(newValue);
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => setTab(newValue); const { refetch: cancleOrder } = useQuery({
queryKey: [
"orderPatch",
{ id: selectedOrder?.id || -1, status: OrderStatusEnum.CANCELLED },
],
queryFn: () =>
orderPatch({
id: selectedOrder?.id || -1,
status: OrderStatusEnum.CANCELLED,
}),
retry: 0,
retryDelay: 1000,
enabled: false,
});
const handleCancelOrder = async () => {
await cancleOrder();
setSelectedOrder(null);
refetch();
};
const {refetch: cancleOrder} = useQuery({ return (
queryKey: ["orderPatch", {id: selectedOrder?.id || -1, status: OrderStatusEnum.CANCELLED}], <Box
queryFn: () => orderPatch({id: selectedOrder?.id || -1, status: OrderStatusEnum.CANCELLED}), className="page-background page-background-center"
retry: 0, sx={{ minHeight: "100vh", pt: 4 }}
retryDelay: 1000, >
enabled: false, <Paper
}); elevation={3}
sx={{ p: 4, maxWidth: 700, width: "100%", mx: "auto" }}
>
<Typography variant="h4" gutterBottom>
{t("myOrders")}
</Typography>
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 3 }}>
<Tab label={t("active")} />
<Tab label={t("previous")} />
</Tabs>
<Divider sx={{ mb: 2 }} />
{tab === 0 ? (
activeOrders.length > 0 ? (
<List>
{activeOrders.map((order) => (
<ListItemButton
key={order.id}
onClick={() => setSelectedOrder(order)}
>
<ListItemText
primary={`${t("order")} #${order.id}${order.status}${new Date(order.time).toUTCString()}`}
secondary={`${t("sum")}: ${(order.total / 100).toFixed(2)} € • ${order.orderItems.length} ${t("items")}`}
/>
</ListItemButton>
))}
</List>
) : (
<Typography color="text.secondary">
{t("noActiveOrders")}
</Typography>
)
) : inactiveOrders.length > 0 ? (
<List>
{inactiveOrders.map((order) => (
<ListItemButton
key={order.id}
onClick={() => setSelectedOrder(order)}
>
<ListItemText
primary={`${t("order")} #${order.id}${order.status}${new Date(order.time).toUTCString()}`}
secondary={`${t("sum")}: ${(order.total / 100).toFixed(2)} € • ${order.orderItems.length} ${t("items")}`}
/>
</ListItemButton>
))}
</List>
) : (
<Typography color="text.secondary">
{t("noPreviousOrders")}
</Typography>
)}
const handleCancelOrder = async () => { <Dialog
await cancleOrder(); open={!!selectedOrder}
setSelectedOrder(null); onClose={() => setSelectedOrder(null)}
refetch(); maxWidth="sm"
}; fullWidth
>
return ( <DialogTitle>
<Box className="page-background page-background-center" sx={{minHeight: "100vh", pt: 4}}> {`${selectedOrder?.status === OrderStatusEnum.IN_PROGRESS ? t("activeOrder") : t("previousOrder")} #${selectedOrder?.id}`}
<Paper elevation={3} sx={{p: 4, maxWidth: 700, width: "100%", mx: "auto"}}> </DialogTitle>
<Typography variant="h4" gutterBottom> <DialogContent dividers>
{t('myOrders')} {selectedOrder && (
<Stack spacing={2}>
<Typography variant="subtitle1">{`${t("orderDate")}: ${new Date(selectedOrder.time).toUTCString()}`}</Typography>
<Divider />
<Typography variant="subtitle2">
{t("orderedItems")}:
</Typography> </Typography>
<Tabs value={tab} onChange={handleTabChange} sx={{mb: 3}}> <List dense>
<Tab label={t('active')}/> {selectedOrder.orderItems.map((item, idx) => (
<Tab label={t('previous')}/> <ListItemText
</Tabs> key={idx}
<Divider sx={{mb: 2}}/> primary={`${item.article} x${item.amount}`}
{tab === 0 ? ( />
activeOrders.length > 0 ? ( ))}
<List> </List>
{activeOrders.map(order => ( <Divider />
<ListItemButton key={order.id} onClick={() => setSelectedOrder(order)}> <Typography variant="h6">{`${t("sum")}: ${(selectedOrder.total / 100).toFixed(2)}`}</Typography>
<ListItemText </Stack>
primary={`${t('order')} #${order.id}${order.status}${new Date(order.time).toUTCString()}`} )}
secondary={`${t('sum')}: ${(order.total / 100).toFixed(2)} € • ${order.orderItems.length} ${t('items')}`} </DialogContent>
/> <DialogActions>
</ListItemButton> {(selectedOrder?.status === OrderStatusEnum.ISSUES ||
))} selectedOrder?.status === OrderStatusEnum.IN_PROGRESS ||
</List> selectedOrder?.status === OrderStatusEnum.ORDERED) && (
) : ( <Button color="error" onClick={() => handleCancelOrder()}>
<Typography color="text.secondary">{t('noActiveOrders')}</Typography> {t("cancelOrder")}
) </Button>
) : ( )}
inactiveOrders.length > 0 ? ( <Button onClick={() => setSelectedOrder(null)}>{t("close")}</Button>
<List> </DialogActions>
{inactiveOrders.map(order => ( </Dialog>
<ListItemButton key={order.id} onClick={() => setSelectedOrder(order)}> </Paper>
<ListItemText </Box>
primary={`${t('order')} #${order.id}${order.status}${new Date(order.time).toUTCString()}`} );
secondary={`${t('sum')}: ${(order.total / 100).toFixed(2)} € • ${order.orderItems.length} ${t('items')}`}
/>
</ListItemButton>
))}
</List>
) : (
<Typography color="text.secondary">{t('noPreviousOrders')}</Typography>
)
)}
<Dialog open={!!selectedOrder} onClose={() => setSelectedOrder(null)} maxWidth="sm" fullWidth>
<DialogTitle>
{`${selectedOrder?.status === OrderStatusEnum.IN_PROGRESS ? t('activeOrder') : t('previousOrder')} #${selectedOrder?.id}`}
</DialogTitle>
<DialogContent dividers>
{selectedOrder && (
<Stack spacing={2}>
<Typography
variant="subtitle1">{`${t('orderDate')}: ${new Date(selectedOrder.time).toUTCString()}`}</Typography>
<Divider/>
<Typography variant="subtitle2">{t('orderedItems')}:</Typography>
<List dense>
{selectedOrder.orderItems.map((item, idx) => (
<ListItemText
key={idx}
primary={`${item.article} x${item.amount}`}
/>
))}
</List>
<Divider/>
<Typography
variant="h6">{`${t('sum')}: ${(selectedOrder.total / 100).toFixed(2)}`}</Typography>
</Stack>
)}
</DialogContent>
<DialogActions>
{(selectedOrder?.status === OrderStatusEnum.ISSUES || selectedOrder?.status === OrderStatusEnum.IN_PROGRESS || selectedOrder?.status === OrderStatusEnum.ORDERED) && (
<Button color="error" onClick={() => handleCancelOrder()}>
{t('cancelOrder')}
</Button>
)}
<Button onClick={() => setSelectedOrder(null)}>{t('close')}</Button>
</DialogActions>
</Dialog>
</Paper>
</Box>
);
} }

View File

@@ -1,373 +1,403 @@
import { import {
Alert, Alert,
Box, Box,
Button, Button,
Container, Container,
Divider, Divider,
Grid, Grid,
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
Paper, Paper,
Step, Step,
StepLabel, StepLabel,
Stepper, Stepper,
TextField, TextField,
Typography Typography,
} from '@mui/material'; } from "@mui/material";
import {useMutation, useQuery} from '@tanstack/react-query'; import { useMutation, useQuery } from "@tanstack/react-query";
import {TFunction} from 'i18next'; import { TFunction } from "i18next";
import React, {useEffect, useState} from 'react'; import React, { useEffect, useState } from "react";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import {useNavigate} from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import {CustomerType} from '../components/Account'; import { CustomerType } from "../components/Account";
import Item from '../components/Item'; import Item from "../components/Item";
import OrderType, {OrderStatusEnum} from '../components/Order'; import OrderType, { OrderStatusEnum } from "../components/Order";
import {useAccount} from '../helper/AccountProvider'; import { useAccount } from "../helper/AccountProvider";
import {BasketItem, useBasket} from '../helper/BasketProvider'; import { BasketItem, useBasket } from "../helper/BasketProvider";
import {fetchCustomer, submitCustomer, submitOrder} from '../helper/query/Queries'; import {
fetchCustomer,
submitCustomer,
submitOrder,
} from "../helper/query/Queries";
function getDiscountedPrice(item: Item): number { function getDiscountedPrice(item: Item): number {
return (item.price100 / 100 * (100 - item.discount100) / 100); return ((item.price100 / 100) * (100 - item.discount100)) / 100;
} }
function generateBasket(t: TFunction<"translation", undefined>, basket: BasketItem[]) { function generateBasket(
return basket.length === 0 ? ( t: TFunction<"translation", undefined>,
<Typography color="error" sx={{my: 2}}> basket: BasketItem[],
{t('basketEmpty')} ) {
</Typography> return basket.length === 0 ? (
) : ( <Typography color="error" sx={{ my: 2 }}>
<List> {t("basketEmpty")}
{basket.map((item) => ( </Typography>
<ListItem key={item.item.uuid}> ) : (
<ListItemText <List>
primary={`${item.quantity}x ${item.item.name}`} {basket.map((item) => (
secondary={`Item ID: ${item.item.uuid}`} <ListItem key={item.item.uuid}>
/> <ListItemText
primary={`${item.quantity}x ${item.item.name}`}
secondary={`Item ID: ${item.item.uuid}`}
/>
<div className="rightBound"> <div className="rightBound">
{`${(item.quantity * getDiscountedPrice(item.item)).toFixed(2)}`}<br/> {`${(item.quantity * getDiscountedPrice(item.item)).toFixed(2)}`}
{item.item.discount100 > 0 ? <a className='rightBound red'>{-item.item.discount100}%</a> : ""} <br />
</div> {item.item.discount100 > 0 ? (
</ListItem> <a className="rightBound red">{-item.item.discount100}%</a>
))} ) : (
</List> ""
) )}
</div>
</ListItem>
))}
</List>
);
} }
function generateTotal(t: TFunction<"translation", undefined>, basket: BasketItem[]) { function generateTotal(
return basket.length === 0 ? "" : t: TFunction<"translation", undefined>,
<div className='rightBound'> basket: BasketItem[],
{t('total') + ": " + basket.map((item) => item.quantity * getDiscountedPrice(item.item)) ) {
.reduce((prev: number, cur: number) => prev + cur, 0).toFixed(2) + ``} return basket.length === 0 ? (
</div> ""
) : (
<div className="rightBound">
{t("total") +
": " +
basket
.map((item) => item.quantity * getDiscountedPrice(item.item))
.reduce((prev: number, cur: number) => prev + cur, 0)
.toFixed(2) +
``}
</div>
);
} }
export default function Payment() { export default function Payment() {
const { t } = useTranslation();
const { basket, clearBasket } = useBasket();
const navigator = useNavigate();
const [activeStep, setActiveStep] = useState(0);
const [shippingDetails, setShippingDetails] = useState<CustomerType>({
id: 0, // This will be set by the backend or user data
name: "",
surname: "",
address: "",
zip: "",
country: "Deutschland",
});
const [orderNumber, setOrderNumber] = useState<string | null>(null);
const steps = [
t("reviewCart"),
t("shippingDetails"),
t("payment"),
t("orderSummary"),
];
const { user } = useAccount();
const submitOrderData: OrderType = {
id: 0, // This will be set by the backend
customerId: user ? user.customerId : 0, // Use user ID if logged in, otherwise 0
time: Date.now(),
status: OrderStatusEnum.ORDERED, // Initial status when order is placed
orderItems: basket.map((item) => ({
id: item.item.id,
amount: item.quantity,
article: item.item.uuid, // Assuming UUID is the identifier for the item
})),
total: basket.reduce(
(total, item) => total + item.quantity * getDiscountedPrice(item.item),
0,
),
};
const {t} = useTranslation(); const { refetch: refetchCustomer } = useQuery({
const {basket, clearBasket} = useBasket(); queryKey: ["submitCustomer", shippingDetails],
const navigator = useNavigate(); queryFn: () => submitCustomer(shippingDetails),
const [activeStep, setActiveStep] = useState(0); retry: 0,
const [shippingDetails, setShippingDetails] = useState<CustomerType>({ retryDelay: 1000,
id: 0, // This will be set by the backend or user data enabled: false,
name: '', });
surname: '',
address: '',
zip: '',
country: 'Deutschland',
});
const [orderNumber, setOrderNumber] = useState<string | null>(null);
const steps = [t('reviewCart'), t('shippingDetails'), t('payment'), t('orderSummary')];
const {user} = useAccount();
const submitOrderData: OrderType = { const showAlert = () => {
id: 0, // This will be set by the backend return <Alert>//TODO:</Alert>;
customerId: user ? user.customerId : 0, // Use user ID if logged in, otherwise 0 };
time: Date.now(),
status: OrderStatusEnum.ORDERED, // Initial status when order is placed
orderItems: basket.map(item => ({
id: item.item.id,
amount: item.quantity,
article: item.item.uuid, // Assuming UUID is the identifier for the item
})),
total: basket.reduce((total, item) => total + (item.quantity * getDiscountedPrice(item.item)), 0),
};
const {refetch: refetchCustomer} = useQuery({ const { refetch: customerData } = useQuery<CustomerType>({
queryKey: ["submitCustomer", shippingDetails], queryKey: ["fetchCustomer", user?.customerId],
queryFn: () => submitCustomer(shippingDetails), queryFn: () => fetchCustomer(user?.customerId || 0), // Funktion zum Abrufen der Kundendaten
retry: 0, enabled: false,
retryDelay: 1000, });
enabled: false,
});
const showAlert = () => { useEffect(() => {
return <Alert> const fetchShippingDetails = async () => {
//TODO: if (user) {
</Alert> try {
}; const userShippingDetails = (await customerData()).data;
setShippingDetails(userShippingDetails || shippingDetails);
const {refetch: customerData} = useQuery<CustomerType>({ } catch (error) {
queryKey: ['fetchCustomer', user?.customerId], console.error("Fehler beim Laden der Kundendaten:", error);
queryFn: () => fetchCustomer(user?.customerId || 0), // Funktion zum Abrufen der Kundendaten
enabled: false
});
useEffect(() => {
const fetchShippingDetails = async () => {
if (user) {
try {
const userShippingDetails = (await customerData()).data;
setShippingDetails(userShippingDetails || shippingDetails);
} catch (error) {
console.error("Fehler beim Laden der Kundendaten:", error);
}
}
};
fetchShippingDetails();
}, [user, customerData]);
// Verwende useMutation statt useQuery für submitOrder
const {mutateAsync: submitOrderMutation} = useMutation({
mutationFn: (orderData: OrderType) => submitOrder(orderData),
});
const handleNext = async () => {
let next: boolean = true;
if (activeStep === steps.length - 2) {
// Simulate order placement and generate order number
const generatedOrderNumber = `ORD-${Math.floor(Math.random() * 1000000)}`;
setOrderNumber(generatedOrderNumber);
let customerId = user ? user.customerId : 0;
if (!customerId) {
const customerResponse = await refetchCustomer();
customerId = customerResponse.data.id;
}
// Erzeuge die Orderdaten mit der richtigen customerId
const orderData: OrderType = {
...submitOrderData,
customerId,
};
try {
await submitOrderMutation(orderData);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
next = false;
}
}
if (next) {
setActiveStep((prevStep) => prevStep + 1);
} else {
showAlert();
} }
}
}; };
const handleBack = () => { fetchShippingDetails();
setActiveStep((prevStep) => prevStep - 1); }, [user, customerData]);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { // Verwende useMutation statt useQuery für submitOrder
const {name, value} = e.target; const { mutateAsync: submitOrderMutation } = useMutation({
setShippingDetails((prevDetails) => ({ mutationFn: (orderData: OrderType) => submitOrder(orderData),
...prevDetails, });
[name]: value,
}));
};
const handleClearBasket = () => {
clearBasket();
};
// Hilfsfunktion prüfen, ob alle Pflichtfelder ausgefüllt sind const handleNext = async () => {
const isShippingDetailsValid = () => { let next: boolean = true;
return (
shippingDetails.name.trim() !== '' &&
shippingDetails.surname.trim() !== '' &&
shippingDetails.address.trim() !== '' &&
shippingDetails.zip.trim() !== '' &&
shippingDetails.country.trim() !== ''
);
};
const renderStepContent = (step: number) => { if (activeStep === steps.length - 2) {
switch (step) { // Simulate order placement and generate order number
case 0: const generatedOrderNumber = `ORD-${Math.floor(Math.random() * 1000000)}`;
return ( setOrderNumber(generatedOrderNumber);
<Box>
<Typography variant="h6" gutterBottom>
{t('reviewCart')}
</Typography>
{generateBasket(t, basket)}
<Divider sx={{my: 2}}/>
<Button variant="outlined" color="error" onClick={handleClearBasket}
disabled={basket.length === 0}>
{t('clearCart')}
</Button>
{generateTotal(t, basket)}
</Box>
);
case 1:
return (
<Box>
<Typography variant="h6" gutterBottom>
{t('shippingDetails')}
</Typography>
<Grid container spacing={2}>
<TextField let customerId = user ? user.customerId : 0;
fullWidth if (!customerId) {
label={t('firstName')} const customerResponse = await refetchCustomer();
name="name" customerId = customerResponse.data.id;
value={shippingDetails.name} }
onChange={handleInputChange}
required
/>
<TextField // Erzeuge die Orderdaten mit der richtigen customerId
fullWidth const orderData: OrderType = {
label={t('lastName')} ...submitOrderData,
name="surname" customerId,
value={shippingDetails.surname} };
onChange={handleInputChange}
required
/>
<TextField try {
fullWidth await submitOrderMutation(orderData);
label={t('address')} // eslint-disable-next-line @typescript-eslint/no-unused-vars
name="address" } catch (e) {
value={shippingDetails.address} next = false;
onChange={handleInputChange} }
required }
/> if (next) {
setActiveStep((prevStep) => prevStep + 1);
} else {
showAlert();
}
};
<TextField const handleBack = () => {
fullWidth setActiveStep((prevStep) => prevStep - 1);
label={t('postalCode')} };
name="zip"
value={shippingDetails.zip}
onChange={handleInputChange}
required
/>
<TextField const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
fullWidth const { name, value } = e.target;
label={t('country')} setShippingDetails((prevDetails) => ({
name="country" ...prevDetails,
value={shippingDetails.country} [name]: value,
onChange={handleInputChange} }));
required };
/> const handleClearBasket = () => {
</Grid> clearBasket();
</Box> };
);
case 2:
return (
<Box>
<Typography variant="h6" gutterBottom>
{t('payment')}
</Typography>
<Typography variant="body1">
{t('paymentNotAvailable')}
</Typography>
</Box>
);
case 3:
return (
<Box>
<Typography variant="h6" gutterBottom>
{t('orderSummary')}
</Typography>
<Typography variant="body1" gutterBottom>
{t('thanksForOrder')}
</Typography>
<Typography variant="body2" gutterBottom>
{t('yourOrderNumber')}: <strong>{orderNumber}</strong>
</Typography>
<Divider sx={{my: 2}}/>
<Typography variant="h6">{t('shippingDetails')}:</Typography>
<Typography variant="body2">
{shippingDetails.name} {shippingDetails.surname}<br/>
{shippingDetails.address}<br/>
{shippingDetails.zip} {shippingDetails.country}<br/>
</Typography>
<Divider sx={{my: 2}}/>
<Typography variant="h6">{t('orderedItems')}:</Typography>
{generateBasket(t, basket)}
<Divider sx={{my: 2}}/>
{generateTotal(t, basket)}
<br/>
</Box>
);
default:
return <div>Unknown step</div>;
}
};
// Hilfsfunktion prüfen, ob alle Pflichtfelder ausgefüllt sind
const isShippingDetailsValid = () => {
return ( return (
<div className="page-background"> shippingDetails.name.trim() !== "" &&
<Container shippingDetails.surname.trim() !== "" &&
maxWidth="md" shippingDetails.address.trim() !== "" &&
sx={{ shippingDetails.zip.trim() !== "" &&
py: 4, shippingDetails.country.trim() !== ""
maxHeight: '90vh',
overflowY: 'auto'
}}
>
<Paper elevation={3} sx={{p: 4}}>
<Typography variant="h4" align="center" gutterBottom>
{t('completeYourOrder')}
</Typography>
<Stepper activeStep={activeStep} alternativeLabel>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
<Box sx={{mt: 4}}>{renderStepContent(activeStep)}</Box>
<Box sx={{display: 'flex', justifyContent: 'space-between', mt: 4}}>
<Button
disabled={activeStep === 0}
onClick={handleBack}
variant="outlined"
>
{t('back')}
</Button>
{activeStep === steps.length - 1 ? (
<Button
variant="contained"
color="primary"
onClick={() => {
handleClearBasket();
navigator('/');
}}
>
{t('finish')}
</Button>
) : (
<Button
variant="contained"
color="primary"
onClick={handleNext}
disabled={
basket.length === 0 ||
(activeStep === 1 && !isShippingDetailsValid())
}
>
{activeStep === steps.length - 2 ? t('placeOrder') : t('next')}
</Button>
)}
</Box>
</Paper>
</Container>
</div>
); );
} };
const renderStepContent = (step: number) => {
switch (step) {
case 0:
return (
<Box>
<Typography variant="h6" gutterBottom>
{t("reviewCart")}
</Typography>
{generateBasket(t, basket)}
<Divider sx={{ my: 2 }} />
<Button
variant="outlined"
color="error"
onClick={handleClearBasket}
disabled={basket.length === 0}
>
{t("clearCart")}
</Button>
{generateTotal(t, basket)}
</Box>
);
case 1:
return (
<Box>
<Typography variant="h6" gutterBottom>
{t("shippingDetails")}
</Typography>
<Grid container spacing={2}>
<TextField
fullWidth
label={t("firstName")}
name="name"
value={shippingDetails.name}
onChange={handleInputChange}
required
/>
<TextField
fullWidth
label={t("lastName")}
name="surname"
value={shippingDetails.surname}
onChange={handleInputChange}
required
/>
<TextField
fullWidth
label={t("address")}
name="address"
value={shippingDetails.address}
onChange={handleInputChange}
required
/>
<TextField
fullWidth
label={t("postalCode")}
name="zip"
value={shippingDetails.zip}
onChange={handleInputChange}
required
/>
<TextField
fullWidth
label={t("country")}
name="country"
value={shippingDetails.country}
onChange={handleInputChange}
required
/>
</Grid>
</Box>
);
case 2:
return (
<Box>
<Typography variant="h6" gutterBottom>
{t("payment")}
</Typography>
<Typography variant="body1">{t("paymentNotAvailable")}</Typography>
</Box>
);
case 3:
return (
<Box>
<Typography variant="h6" gutterBottom>
{t("orderSummary")}
</Typography>
<Typography variant="body1" gutterBottom>
{t("thanksForOrder")}
</Typography>
<Typography variant="body2" gutterBottom>
{t("yourOrderNumber")}: <strong>{orderNumber}</strong>
</Typography>
<Divider sx={{ my: 2 }} />
<Typography variant="h6">{t("shippingDetails")}:</Typography>
<Typography variant="body2">
{shippingDetails.name} {shippingDetails.surname}
<br />
{shippingDetails.address}
<br />
{shippingDetails.zip} {shippingDetails.country}
<br />
</Typography>
<Divider sx={{ my: 2 }} />
<Typography variant="h6">{t("orderedItems")}:</Typography>
{generateBasket(t, basket)}
<Divider sx={{ my: 2 }} />
{generateTotal(t, basket)}
<br />
</Box>
);
default:
return <div>Unknown step</div>;
}
};
return (
<div className="page-background">
<Container
maxWidth="md"
sx={{
py: 4,
maxHeight: "90vh",
overflowY: "auto",
}}
>
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h4" align="center" gutterBottom>
{t("completeYourOrder")}
</Typography>
<Stepper activeStep={activeStep} alternativeLabel>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
<Box sx={{ mt: 4 }}>{renderStepContent(activeStep)}</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 4 }}>
<Button
disabled={activeStep === 0}
onClick={handleBack}
variant="outlined"
>
{t("back")}
</Button>
{activeStep === steps.length - 1 ? (
<Button
variant="contained"
color="primary"
onClick={() => {
handleClearBasket();
navigator("/");
}}
>
{t("finish")}
</Button>
) : (
<Button
variant="contained"
color="primary"
onClick={handleNext}
disabled={
basket.length === 0 ||
(activeStep === 1 && !isShippingDetailsValid())
}
>
{activeStep === steps.length - 2 ? t("placeOrder") : t("next")}
</Button>
)}
</Box>
</Paper>
</Container>
</div>
);
}

View File

@@ -1,92 +1,88 @@
import {Box, Button, Container, Divider, Typography} from '@mui/material'; import { Box, Button, Container, Divider, Typography } from "@mui/material";
import {useLocation, useNavigate} from 'react-router-dom'; import { useLocation, useNavigate } from "react-router-dom";
import Item from '../components/Item'; import Item from "../components/Item";
import ProductInfo from '../helper/productpage/ProductInfo'; import ProductInfo from "../helper/productpage/ProductInfo";
import Ratings from '../helper/productpage/Ratings'; import Ratings from "../helper/productpage/Ratings";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import {useTheme} from '@mui/material/styles'; import { useTheme } from "@mui/material/styles";
export default function Product() { export default function Product() {
const {t} = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const item = location.state?.item as Item; const item = location.state?.item as Item;
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); // Zugriff auf das aktive Theme const theme = useTheme(); // Zugriff auf das aktive Theme
const handleGoHome = () => { const handleGoHome = () => {
navigate("/"); navigate("/");
}; };
// Wenn kein Produkt vorhanden ist
if (!item) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
textAlign: 'center',
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
gap: '2rem',
overflow: 'auto',
px: 2,
}}
>
<Typography variant="h1">
{t('productNotFound')}
</Typography>
<Typography variant="h5">
{t('productDoesNotExist')}
</Typography>
<Button
variant="contained"
color="primary"
size="large"
onClick={handleGoHome}
>
{t('backToHome')}
</Button>
</Box>
);
}
// Wenn kein Produkt vorhanden ist
if (!item) {
return ( return (
<Box <Box
sx={{ sx={{
backgroundColor: theme.palette.background.default, display: "flex",
color: theme.palette.text.primary, flexDirection: "column",
height: '100vh', alignItems: "center",
overflow: 'auto', justifyContent: "center",
pt: 4, height: "100vh",
pb: 10, textAlign: "center",
}} backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
gap: "2rem",
overflow: "auto",
px: 2,
}}
>
<Typography variant="h1">{t("productNotFound")}</Typography>
<Typography variant="h5">{t("productDoesNotExist")}</Typography>
<Button
variant="contained"
color="primary"
size="large"
onClick={handleGoHome}
> >
<Container maxWidth="lg"> {t("backToHome")}
<ProductInfo item={item}/> </Button>
</Box>
<Box sx={{my: 4}}>
<Divider sx={{backgroundColor: theme.palette.text.secondary}}/>
</Box>
<Typography
variant="body2"
sx={{color: theme.palette.text.secondary, mb: 1}}
>
{t('articleNumber')}: {item.uuid}
</Typography>
<Typography
variant="body1"
sx={{color: theme.palette.text.primary, mb: 3}}
>
{item.description}
</Typography>
<Ratings itemId={item.uuid}/>
</Container>
</Box>
); );
}; }
return (
<Box
sx={{
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
height: "100vh",
overflow: "auto",
pt: 4,
pb: 10,
}}
>
<Container maxWidth="lg">
<ProductInfo item={item} />
<Box sx={{ my: 4 }}>
<Divider sx={{ backgroundColor: theme.palette.text.secondary }} />
</Box>
<Typography
variant="body2"
sx={{ color: theme.palette.text.secondary, mb: 1 }}
>
{t("articleNumber")}: {item.uuid}
</Typography>
<Typography
variant="body1"
sx={{ color: theme.palette.text.primary, mb: 3 }}
>
{item.description}
</Typography>
<Ratings itemId={item.uuid} />
</Container>
</Box>
);
}

View File

@@ -1,170 +1,170 @@
.no-page-container { .no-page-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100vh; height: 100vh;
text-align: center; text-align: center;
gap: 2%; gap: 2%;
} }
.no-page-title { .no-page-title {
font-size: 8rem; font-size: 8rem;
font-weight: bold; font-weight: bold;
margin-bottom: 16px; margin-bottom: 16px;
} }
.no-page-subtitle { .no-page-subtitle {
font-size: 1.5rem; font-size: 1.5rem;
margin-bottom: 8px; margin-bottom: 8px;
} }
.no-page-description { .no-page-description {
font-size: 1rem; font-size: 1rem;
margin-bottom: 24px; margin-bottom: 24px;
} }
.no-page-button { .no-page-button {
font-size: 1rem; font-size: 1rem;
padding: 12px 24px; padding: 12px 24px;
background-color: #0fd13f; background-color: #0fd13f;
color: white; color: white;
border-radius: 8px; border-radius: 8px;
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
} }
.no-page-button:hover { .no-page-button:hover {
background-color: #0cc634; background-color: #0cc634;
} }
.cardgrid { .cardgrid {
display: grid; display: grid;
gap: 24px; gap: 24px;
width: 90%; width: 90%;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
padding: 16px 16px 64px 16px; padding: 16px 16px 64px 16px;
margin: 0 auto; margin: 0 auto;
box-sizing: border-box; box-sizing: border-box;
} }
.page-background { .page-background {
background-color: var(--background-color); background-color: var(--background-color);
min-height: var(--page-height); min-height: var(--page-height);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: auto; overflow: auto;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
color: var(--text-color); color: var(--text-color);
} }
.toppad { .toppad {
padding: 20px 0; padding: 20px 0;
} }
.page-background-center { .page-background-center {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.page-background.page-background--no-space-between { .page-background.page-background--no-space-between {
justify-content: flex-start !important; justify-content: flex-start !important;
} }
.page-table { .page-table {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
height: var(--page-height); height: var(--page-height);
} }
.impressum-container { .impressum-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
justify-content: flex-start; justify-content: flex-start;
padding: 20px; padding: 20px;
text-align: left; text-align: left;
background-color: var(--background-color); background-color: var(--background-color);
color: var(--text-color); color: var(--text-color);
height: var(--page-height); height: var(--page-height);
margin: 0; margin: 0;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
overflow: auto; overflow: auto;
} }
.impressum-title { .impressum-title {
font-size: 2.5rem; font-size: 2.5rem;
font-weight: bold; font-weight: bold;
margin-bottom: 16px; margin-bottom: 16px;
color: var(--text-color); color: var(--text-color);
} }
.impressum-content { .impressum-content {
font-size: 1rem; font-size: 1rem;
line-height: 1.6; line-height: 1.6;
color: var(--text-color); color: var(--text-color);
} }
.contact-divider { .contact-divider {
background-color: var(--text-color); background-color: var(--text-color);
height: 2px; height: 2px;
} }
.contact-divider-box { .contact-divider-box {
width: 100%; width: 100%;
margin: 25px 0; margin: 25px 0;
} }
.product-page-background { .product-page-background {
background-color: var(--background-color); background-color: var(--background-color);
height: 100%; height: 100%;
min-height: 600px; min-height: 600px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 20px 0; padding: 20px 0;
box-sizing: border-box; box-sizing: border-box;
color: var(--text-color); color: var(--text-color);
} }
.home-page-background { .home-page-background {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
width: 100%; width: 100%;
scroll-behavior: smooth; scroll-behavior: smooth;
height: 100vh; height: 100vh;
box-sizing: border-box; box-sizing: border-box;
color: var(--text-color); color: var(--text-color);
} }
.sidebar { .sidebar {
width: fit-content; width: fit-content;
display: grid; display: grid;
place-self: start; place-self: start;
white-space: nowrap; white-space: nowrap;
margin-top: 2vh; margin-top: 2vh;
} }
.sidebar-filter { .sidebar-filter {
margin: 30px margin: 30px;
} }
.no-results { .no-results {
text-align: center; text-align: center;
font-size: 1rem; font-size: 1rem;
min-width: 600px; min-width: 600px;
background-color: var(--background-color); background-color: var(--background-color);
color: var(--text-color); color: var(--text-color);
} }
.rightBound { .rightBound {
float: right; float: right;
} }
.red { .red {
color: #F00; color: #f00;
} }

View File

@@ -1,185 +1,204 @@
'use client'; "use client";
import React, {createContext, ReactNode, useContext, useEffect, useState} from 'react'; import React, {
import {createTheme, ThemeProvider} from '@mui/material/styles'; createContext,
import {CssBaseline, GlobalStyles} from '@mui/material'; ReactNode,
import useMediaQuery from '@mui/material/useMediaQuery'; useContext,
useEffect,
useState,
} from "react";
import { createTheme, ThemeProvider } from "@mui/material/styles";
import { CssBaseline, GlobalStyles } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
type ThemeMode = 'light' | 'dark'; type ThemeMode = "light" | "dark";
interface ThemeContextType { interface ThemeContextType {
mode: ThemeMode; mode: ThemeMode;
toggleMode: () => void; toggleMode: () => void;
} }
const ThemeContext = createContext<ThemeContextType>({ const ThemeContext = createContext<ThemeContextType>({
mode: 'light', mode: "light",
toggleMode: () => { toggleMode: () => {},
},
}); });
export const useThemeMode = () => useContext(ThemeContext); export const useThemeMode = () => useContext(ThemeContext);
interface CustomThemeProviderProps { interface CustomThemeProviderProps {
children: ReactNode; children: ReactNode;
} }
export const CustomThemeProvider: React.FC<CustomThemeProviderProps> = ({children}) => { export const CustomThemeProvider: React.FC<CustomThemeProviderProps> = ({
// SSR-sichere System-Präferenz-Erkennung children,
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)', {noSsr: true}); }) => {
// SSR-sichere System-Präferenz-Erkennung
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)", {
noSsr: true,
});
// SSR-sichere Initialisierung // SSR-sichere Initialisierung
const [mode, setMode] = useState<ThemeMode>('light'); const [mode, setMode] = useState<ThemeMode>("light");
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Nach dem ersten Render ausführen (SSR-sicher) // Nach dem ersten Render ausführen (SSR-sicher)
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
const savedMode = localStorage.getItem('themeMode') as ThemeMode; const savedMode = localStorage.getItem("themeMode") as ThemeMode;
if (savedMode === 'light' || savedMode === 'dark') { if (savedMode === "light" || savedMode === "dark") {
setMode(savedMode); setMode(savedMode);
} else { } else {
setMode(prefersDarkMode ? 'dark' : 'light'); setMode(prefersDarkMode ? "dark" : "light");
} }
}
}, [prefersDarkMode]);
// Mode in localStorage speichern
useEffect(() => {
if (mounted && typeof window !== 'undefined') {
localStorage.setItem('themeMode', mode);
}
}, [mode, mounted]);
// Browser-only DOM-Manipulation
useEffect(() => {
if (mounted && typeof window !== 'undefined') {
const backgroundColor = mode === 'dark' ? '#121212' : '#fafafa';
// Warten bis DOM geladen ist
const updateBackground = () => {
document.documentElement.style.setProperty('--background-color', backgroundColor);
document.documentElement.style.backgroundColor = backgroundColor;
document.body.style.backgroundColor = backgroundColor;
const root = document.getElementById('root');
if (root) {
root.style.backgroundColor = backgroundColor;
}
};
// Sofort ausführen
updateBackground();
// Nach einem kurzen Delay nochmal (für hartnäckige Cases)
const timeoutId = setTimeout(updateBackground, 100);
return () => clearTimeout(timeoutId);
}
}, [mode, mounted]);
const toggleMode = () => {
setMode(prevMode => prevMode === 'light' ? 'dark' : 'light');
};
// Theme basierend auf Mode erstellen
const theme = React.useMemo(() =>
createTheme({
palette: {
mode,
primary: {
main: '#0fd13f', // Grüne NavBar
light: mode === 'dark' ? '#4caf50' : '#42a5f5',
dark: mode === 'dark' ? '#388e3c' : '#1565c0',
contrastText: '#fff',
},
secondary: {
main: mode === 'dark' ? '#bb86fc' : '#9c27b0',
light: mode === 'dark' ? '#d7aefb' : '#ba68c8',
dark: mode === 'dark' ? '#985eff' : '#7b1fa2',
contrastText: '#fff',
},
background: {
default: mode === 'dark' ? '#121212' : '#fafafa',
paper: mode === 'dark' ? '#1e1e1e' : '#ffffff',
},
text: {
primary: mode === 'dark' ? '#ffffff' : '#000000',
secondary: mode === 'dark' ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.6)',
},
homepage: mode === 'dark' ? '#1e1e1e' : '#f4f4f4',
},
components: {
MuiCssBaseline: {
styleOverrides: {
html: {
backgroundColor: `${mode === 'dark' ? '#121212' : '#fafafa'} !important`,
transition: 'background-color 300ms cubic-bezier(0.4, 0, 0.2, 1)',
},
body: {
backgroundColor: `${mode === 'dark' ? '#121212' : '#fafafa'} !important`,
transition: 'background-color 300ms cubic-bezier(0.4, 0, 0.2, 1)',
margin: 0,
},
},
},
MuiAppBar: {
styleOverrides: {
colorPrimary: {
backgroundColor: mode === 'dark' ? '#388e3c' : '#0fd13f',
color: '#ffffff',
},
},
},
},
}),
[mode]
);
// Aggressive GlobalStyles mit CSS-Variablen
const globalStyles = mounted ? (
<GlobalStyles
styles={{
':root': {
'--background-color': mode === 'dark' ? '#121212' : '#fafafa',
'--text-color': mode === 'dark' ? '#ffffff' : '#000000',
},
'*': {
boxSizing: 'border-box',
},
'html, body, #root': {
backgroundColor: `var(--background-color) !important`,
color: `var(--text-color) !important`,
transition: 'background-color 300ms cubic-bezier(0.4, 0, 0.2, 1), color 300ms cubic-bezier(0.4, 0, 0.2, 1)',
margin: 0,
padding: 0,
minHeight: '100vh',
},
'div, section, main, article': {
backgroundColor: 'transparent',
},
}}
/>
) : null;
// SSR-Fallback während mounted = false
if (!mounted) {
return (
<ThemeProvider theme={createTheme({palette: {mode: 'light'}})}>
<CssBaseline/>
{children}
</ThemeProvider>
);
} }
}, [prefersDarkMode]);
// Mode in localStorage speichern
useEffect(() => {
if (mounted && typeof window !== "undefined") {
localStorage.setItem("themeMode", mode);
}
}, [mode, mounted]);
// Browser-only DOM-Manipulation
useEffect(() => {
if (mounted && typeof window !== "undefined") {
const backgroundColor = mode === "dark" ? "#121212" : "#fafafa";
// Warten bis DOM geladen ist
const updateBackground = () => {
document.documentElement.style.setProperty(
"--background-color",
backgroundColor,
);
document.documentElement.style.backgroundColor = backgroundColor;
document.body.style.backgroundColor = backgroundColor;
const root = document.getElementById("root");
if (root) {
root.style.backgroundColor = backgroundColor;
}
};
// Sofort ausführen
updateBackground();
// Nach einem kurzen Delay nochmal (für hartnäckige Cases)
const timeoutId = setTimeout(updateBackground, 100);
return () => clearTimeout(timeoutId);
}
}, [mode, mounted]);
const toggleMode = () => {
setMode((prevMode) => (prevMode === "light" ? "dark" : "light"));
};
// Theme basierend auf Mode erstellen
const theme = React.useMemo(
() =>
createTheme({
palette: {
mode,
primary: {
main: "#0fd13f", // Grüne NavBar
light: mode === "dark" ? "#4caf50" : "#42a5f5",
dark: mode === "dark" ? "#388e3c" : "#1565c0",
contrastText: "#fff",
},
secondary: {
main: mode === "dark" ? "#bb86fc" : "#9c27b0",
light: mode === "dark" ? "#d7aefb" : "#ba68c8",
dark: mode === "dark" ? "#985eff" : "#7b1fa2",
contrastText: "#fff",
},
background: {
default: mode === "dark" ? "#121212" : "#fafafa",
paper: mode === "dark" ? "#1e1e1e" : "#ffffff",
},
text: {
primary: mode === "dark" ? "#ffffff" : "#000000",
secondary:
mode === "dark"
? "rgba(255, 255, 255, 0.7)"
: "rgba(0, 0, 0, 0.6)",
},
homepage: mode === "dark" ? "#1e1e1e" : "#f4f4f4",
},
components: {
MuiCssBaseline: {
styleOverrides: {
html: {
backgroundColor: `${mode === "dark" ? "#121212" : "#fafafa"} !important`,
transition:
"background-color 300ms cubic-bezier(0.4, 0, 0.2, 1)",
},
body: {
backgroundColor: `${mode === "dark" ? "#121212" : "#fafafa"} !important`,
transition:
"background-color 300ms cubic-bezier(0.4, 0, 0.2, 1)",
margin: 0,
},
},
},
MuiAppBar: {
styleOverrides: {
colorPrimary: {
backgroundColor: mode === "dark" ? "#388e3c" : "#0fd13f",
color: "#ffffff",
},
},
},
},
}),
[mode],
);
// Aggressive GlobalStyles mit CSS-Variablen
const globalStyles = mounted ? (
<GlobalStyles
styles={{
":root": {
"--background-color": mode === "dark" ? "#121212" : "#fafafa",
"--text-color": mode === "dark" ? "#ffffff" : "#000000",
},
"*": {
boxSizing: "border-box",
},
"html, body, #root": {
backgroundColor: `var(--background-color) !important`,
color: `var(--text-color) !important`,
transition:
"background-color 300ms cubic-bezier(0.4, 0, 0.2, 1), color 300ms cubic-bezier(0.4, 0, 0.2, 1)",
margin: 0,
padding: 0,
minHeight: "100vh",
},
"div, section, main, article": {
backgroundColor: "transparent",
},
}}
/>
) : null;
// SSR-Fallback während mounted = false
if (!mounted) {
return ( return (
<ThemeContext.Provider value={{mode, toggleMode}}> <ThemeProvider theme={createTheme({ palette: { mode: "light" } })}>
<ThemeProvider theme={theme}> <CssBaseline />
<CssBaseline enableColorScheme/> {children}
{globalStyles} </ThemeProvider>
{children}
</ThemeProvider>
</ThemeContext.Provider>
); );
}
return (
<ThemeContext.Provider value={{ mode, toggleMode }}>
<ThemeProvider theme={theme}>
<CssBaseline enableColorScheme />
{globalStyles}
{children}
</ThemeProvider>
</ThemeContext.Provider>
);
}; };

View File

@@ -1,31 +1,30 @@
// theme/ThemeToggle.tsx // theme/ThemeToggle.tsx
import React from 'react'; import React from "react";
import {IconButton, Tooltip} from '@mui/material'; import { IconButton, Tooltip } from "@mui/material";
import {Brightness4, Brightness7} from '@mui/icons-material'; import { Brightness4, Brightness7 } from "@mui/icons-material";
import {useThemeMode} from './ThemeContext'; import { useThemeMode } from "./ThemeContext";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
const ThemeToggle: React.FC = () => { const ThemeToggle: React.FC = () => {
const {mode, toggleMode} = useThemeMode(); const { mode, toggleMode } = useThemeMode();
const {t} = useTranslation(); const { t } = useTranslation();
return ( return (
<Tooltip title={mode === 'dark' ? t('lightMode') : t('darkMode')}> <Tooltip title={mode === "dark" ? t("lightMode") : t("darkMode")}>
<IconButton
<IconButton onClick={toggleMode}
onClick={toggleMode} color="inherit"
color="inherit" sx={{
sx={{ transition: "transform 0.2s",
transition: 'transform 0.2s', "&:hover": {
'&:hover': { transform: "scale(1.1)",
transform: 'scale(1.1)', },
}, }}
}} >
> {mode === "dark" ? <Brightness7 /> : <Brightness4 />}
{mode === 'dark' ? <Brightness7/> : <Brightness4/>} </IconButton>
</IconButton> </Tooltip>
</Tooltip> );
);
}; };
export default ThemeToggle; export default ThemeToggle;

View File

@@ -1,13 +1,13 @@
import '@mui/material/styles'; import "@mui/material/styles";
declare module '@mui/material/styles' { declare module "@mui/material/styles" {
interface Palette { interface Palette {
tertiary: Palette['primary']; tertiary: Palette["primary"];
homepage: string; homepage: string;
} }
interface PaletteOptions { interface PaletteOptions {
tertiary?: PaletteOptions['primary']; tertiary?: PaletteOptions["primary"];
homepage?: string; homepage?: string;
} }
} }

View File

@@ -1,206 +1,206 @@
import {createTheme} from '@mui/material/styles'; import { createTheme } from "@mui/material/styles";
import './theme-augmentation.d.ts'; // Falls vorhanden import "./theme-augmentation.d.ts"; // Falls vorhanden
export const darkmode = createTheme({ export const darkmode = createTheme({
palette: { palette: {
primary: { primary: {
main: '#0fd13f', // Grüne Standardfarbe main: "#0fd13f", // Grüne Standardfarbe
light: '#42a5f5', light: "#42a5f5",
dark: '#15650', dark: "#15650",
contrastText: '#fff', contrastText: "#fff",
},
secondary: {
main: '#9c27b0',
light: '#ba68c8',
dark: '#7b1fa2',
contrastText: '#fff',
},
error: {
main: '#f44336',
light: '#e57373',
dark: '#d32f2f',
contrastText: '#fff',
},
warning: {
main: '#ed6c02',
light: '#ff9800',
dark: '#e65100',
contrastText: '#fff',
},
info: {
main: '#0288d1',
light: '#03a9f4',
dark: '#01579b',
contrastText: '#fff',
},
success: {
main: '#2e7d32',
light: '#4caf50',
dark: '#1b5e20',
contrastText: '#fff',
},
background: {
default: '#fafafa',
paper: '#ffffff',
},
text: {
primary: '#000000',
secondary: 'rgba(0, 0, 0, 0.6)',
},
}, },
// Sanfte Übergänge secondary: {
transitions: { main: "#9c27b0",
duration: { light: "#ba68c8",
shortest: 150, dark: "#7b1fa2",
shorter: 200, contrastText: "#fff",
short: 250,
standard: 300,
complex: 375,
enteringScreen: 225,
leavingScreen: 195,
},
easing: {
easeInOut: 'cubic-bezier(0.4, 0, 0.2, 1)',
easeOut: 'cubic-bezier(0.0, 0, 0.2, 1)',
easeIn: 'cubic-bezier(0.4, 0, 1, 1)',
sharp: 'cubic-bezier(0.4, 0, 0.6, 1)',
},
}, },
// Verbesserte Komponenten-Overrides error: {
components: { main: "#f44336",
MuiCssBaseline: { light: "#e57373",
styleOverrides: { dark: "#d32f2f",
body: { contrastText: "#fff",
backgroundColor: '#fafafa',
transition: 'background-color 300ms cubic-bezier(0.4, 0, 0.2, 1)',
},
html: {
backgroundColor: '#fafafa',
transition: 'background-color 300ms cubic-bezier(0.4, 0, 0.2, 1)',
},
},
},
MuiAppBar: {
styleOverrides: {
colorPrimary: {
backgroundColor: '#0fd13f', // Grüne NavBar
color: '#ffffff',
},
},
},
MuiButton: {
styleOverrides: {
root: {
borderRadius: 8,
textTransform: 'none', // Entfernt automatische Großschreibung
fontWeight: 600,
},
contained: {
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
'&:hover': {
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 12,
boxShadow: '0 2px 12px rgba(0,0,0,0.08)',
transition: 'box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
boxShadow: '0 4px 20px rgba(0,0,0,0.12)',
},
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: 8,
},
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 16,
},
},
},
MuiPaper: {
styleOverrides: {
root: {
borderRadius: 8,
},
elevation1: {
boxShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
},
elevation2: {
boxShadow: '0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23)',
},
},
},
}, },
// Benutzerdefinierte Typografie warning: {
typography: { main: "#ed6c02",
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', light: "#ff9800",
h1: { dark: "#e65100",
fontWeight: 700, contrastText: "#fff",
fontSize: '2.5rem',
lineHeight: 1.2,
},
h2: {
fontWeight: 600,
fontSize: '2rem',
lineHeight: 1.3,
},
h3: {
fontWeight: 600,
fontSize: '1.75rem',
lineHeight: 1.3,
},
h4: {
fontWeight: 600,
fontSize: '1.5rem',
lineHeight: 1.4,
},
h5: {
fontWeight: 600,
fontSize: '1.25rem',
lineHeight: 1.4,
},
h6: {
fontWeight: 600,
fontSize: '1rem',
lineHeight: 1.4,
},
body1: {
fontSize: '1rem',
lineHeight: 1.5,
},
body2: {
fontSize: '0.875rem',
lineHeight: 1.4,
},
button: {
fontWeight: 600,
textTransform: 'none',
},
}, },
// Benutzerdefinierte Breakpoints info: {
breakpoints: { main: "#0288d1",
values: { light: "#03a9f4",
xs: 0, dark: "#01579b",
sm: 600, contrastText: "#fff",
md: 960,
lg: 1280,
xl: 1920,
},
}, },
// Angepasste Spacing-Funktion success: {
spacing: 8, // 8px als Basis-Einheit main: "#2e7d32",
light: "#4caf50",
dark: "#1b5e20",
contrastText: "#fff",
},
background: {
default: "#fafafa",
paper: "#ffffff",
},
text: {
primary: "#000000",
secondary: "rgba(0, 0, 0, 0.6)",
},
},
// Sanfte Übergänge
transitions: {
duration: {
shortest: 150,
shorter: 200,
short: 250,
standard: 300,
complex: 375,
enteringScreen: 225,
leavingScreen: 195,
},
easing: {
easeInOut: "cubic-bezier(0.4, 0, 0.2, 1)",
easeOut: "cubic-bezier(0.0, 0, 0.2, 1)",
easeIn: "cubic-bezier(0.4, 0, 1, 1)",
sharp: "cubic-bezier(0.4, 0, 0.6, 1)",
},
},
// Verbesserte Komponenten-Overrides
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
backgroundColor: "#fafafa",
transition: "background-color 300ms cubic-bezier(0.4, 0, 0.2, 1)",
},
html: {
backgroundColor: "#fafafa",
transition: "background-color 300ms cubic-bezier(0.4, 0, 0.2, 1)",
},
},
},
MuiAppBar: {
styleOverrides: {
colorPrimary: {
backgroundColor: "#0fd13f", // Grüne NavBar
color: "#ffffff",
},
},
},
MuiButton: {
styleOverrides: {
root: {
borderRadius: 8,
textTransform: "none", // Entfernt automatische Großschreibung
fontWeight: 600,
},
contained: {
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
"&:hover": {
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 12,
boxShadow: "0 2px 12px rgba(0,0,0,0.08)",
transition: "box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1)",
"&:hover": {
boxShadow: "0 4px 20px rgba(0,0,0,0.12)",
},
},
},
},
MuiTextField: {
styleOverrides: {
root: {
"& .MuiOutlinedInput-root": {
borderRadius: 8,
},
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 16,
},
},
},
MuiPaper: {
styleOverrides: {
root: {
borderRadius: 8,
},
elevation1: {
boxShadow: "0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)",
},
elevation2: {
boxShadow: "0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23)",
},
},
},
},
// Benutzerdefinierte Typografie
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
h1: {
fontWeight: 700,
fontSize: "2.5rem",
lineHeight: 1.2,
},
h2: {
fontWeight: 600,
fontSize: "2rem",
lineHeight: 1.3,
},
h3: {
fontWeight: 600,
fontSize: "1.75rem",
lineHeight: 1.3,
},
h4: {
fontWeight: 600,
fontSize: "1.5rem",
lineHeight: 1.4,
},
h5: {
fontWeight: 600,
fontSize: "1.25rem",
lineHeight: 1.4,
},
h6: {
fontWeight: 600,
fontSize: "1rem",
lineHeight: 1.4,
},
body1: {
fontSize: "1rem",
lineHeight: 1.5,
},
body2: {
fontSize: "0.875rem",
lineHeight: 1.4,
},
button: {
fontWeight: 600,
textTransform: "none",
},
},
// Benutzerdefinierte Breakpoints
breakpoints: {
values: {
xs: 0,
sm: 600,
md: 960,
lg: 1280,
xl: 1920,
},
},
// Angepasste Spacing-Funktion
spacing: 8, // 8px als Basis-Einheit
}); });

View File

@@ -1,58 +1,66 @@
export function mapValueToColor(minVal: number, maxVal: number, actualVal: number): string { export function mapValueToColor(
const clamped = Math.min(Math.max(actualVal, minVal), maxVal); minVal: number,
maxVal: number,
actualVal: number,
): string {
const clamped = Math.min(Math.max(actualVal, minVal), maxVal);
// Calculate interpolation ratio (0-1) // Calculate interpolation ratio (0-1)
const ratio = maxVal !== minVal const ratio = maxVal !== minVal ? (clamped - minVal) / (maxVal - minVal) : 0;
? (clamped - minVal) / (maxVal - minVal) return hsvDegToHex(120 * ratio); //120° is green, 0° is red
: 0;
return hsvDegToHex(120 * ratio);//120° is green, 0° is red
} }
export function getColorFromPercent(percent: string): string { export function getColorFromPercent(percent: string): string {
let perc = Number(percent) / 100 let perc = Number(percent) / 100;
if (perc > 1) { if (perc > 1) {
perc = 2.5; perc = 2.5;
} }
return hsvDegToHex(120 * perc); return hsvDegToHex(120 * perc);
} }
export function hsvDegToHex(h: number): string { export function hsvDegToHex(h: number): string {
h = Math.max(0, Math.min(360, h)); h = Math.max(0, Math.min(360, h));
const c = 1; const c = 1;
const x = (1 - Math.abs(((h / 60) % 2) - 1)); const x = 1 - Math.abs(((h / 60) % 2) - 1);
let r, g, b; let r, g, b;
if (h < 60) { if (h < 60) {
r = c; r = c;
g = x; g = x;
b = 0; b = 0;
} else if (h < 120) { } else if (h < 120) {
r = x; r = x;
g = c; g = c;
b = 0; b = 0;
} else if (h < 180) { } else if (h < 180) {
r = 0; r = 0;
g = c; g = c;
b = x; b = x;
} else if (h < 240) { } else if (h < 240) {
r = 0; r = 0;
g = x; g = x;
b = c; b = c;
} else if (h < 300) { } else if (h < 300) {
r = x; r = x;
g = 0; g = 0;
b = c; b = c;
} else { } else {
r = c; r = c;
g = 0; g = 0;
b = x; b = x;
} }
// scale to 0-255 // scale to 0-255
const rHex = Math.round(r * 255).toString(16).padStart(2, '0'); const rHex = Math.round(r * 255)
const gHex = Math.round(g * 255).toString(16).padStart(2, '0'); .toString(16)
const bHex = Math.round(b * 255).toString(16).padStart(2, '0'); .padStart(2, "0");
const gHex = Math.round(g * 255)
.toString(16)
.padStart(2, "0");
const bHex = Math.round(b * 255)
.toString(16)
.padStart(2, "0");
return `#${rHex}${gHex}${bHex}`; return `#${rHex}${gHex}${bHex}`;
} }

View File

@@ -1,16 +1,16 @@
#!/bin/sh #!/bin/sh
LANG="C"
cleanup() { cleanup() {
trap - INT EXIT TERM
echo "[DPS] Cleaning up..." echo "[DPS] Cleaning up..."
pkill $backend_pid # pkill because subshells pgrep -P "$backend_pid" | xargs kill -TERM > /dev/null 2>&1
pkill $frontend_pid pgrep -P "$frontend_pid" | xargs kill -TERM > /dev/null 2>&1
echo "[DPS] Cleaned up..." echo "[DPS] Cleaned up..."
exit exit
} }
# Trap INT (Ctrl+C) trap cleanup INT EXIT TERM
trap cleanup INT
echo "[DPS] trapped" echo "[DPS] trapped"
cd ./00-backend cd ./00-backend
@@ -25,4 +25,5 @@ echo "[DPS] Frontend started with PID $frontend_pid"
echo "[DPS] Ctrl+C to stop" echo "[DPS] Ctrl+C to stop"
# Wait for cleanup # Wait for cleanup
wait wait $backend_pid
wait $frontend_pid