From 949770a741fe7a7d3d25008dbcaa3d35e8c341af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Fri, 27 Feb 2026 15:07:32 +0100 Subject: [PATCH] feat(back-end and front-end): back-office --- .../controller/AdminOrderController.java | 156 -------- .../{ => admin}/AdminAuthController.java | 36 +- .../admin/AdminOperationsController.java | 142 +++++++ .../admin/AdminOrderController.java | 301 +++++++++++++++ .../dto/AdminContactRequestDto.java | 88 +++++ .../dto/AdminFilamentStockDto.java | 78 ++++ .../dto/AdminOrderStatusUpdateRequest.java | 13 + .../dto/AdminQuoteSessionDto.java | 61 +++ .../com/printcalculator/dto/OrderDto.java | 24 ++ .../security/AdminLoginThrottleService.java | 91 +++++ .../resources/application-local.properties | 2 +- .../controller/AdminAuthSecurityTest.java | 45 ++- .../AdminLoginThrottleServiceTest.java | 17 + .../src/app/features/admin/admin.routes.ts | 29 +- .../admin-contact-requests.component.html | 40 ++ .../admin-contact-requests.component.scss | 59 +++ .../pages/admin-contact-requests.component.ts | 37 ++ .../pages/admin-dashboard.component.html | 200 +++++++--- .../pages/admin-dashboard.component.scss | 361 ++++++++++++++++-- .../admin/pages/admin-dashboard.component.ts | 217 +++++++++-- .../pages/admin-filament-stock.component.html | 43 +++ .../pages/admin-filament-stock.component.scss | 77 ++++ .../pages/admin-filament-stock.component.ts | 41 ++ .../admin/pages/admin-login.component.html | 52 +-- .../admin/pages/admin-login.component.scss | 49 ++- .../admin/pages/admin-login.component.ts | 79 +++- .../pages/admin-orders-past.component.html | 39 ++ .../pages/admin-orders-past.component.scss | 59 +++ .../pages/admin-orders-past.component.ts | 39 ++ .../admin/pages/admin-sessions.component.html | 40 ++ .../admin/pages/admin-sessions.component.scss | 59 +++ .../admin/pages/admin-sessions.component.ts | 37 ++ .../admin/pages/admin-shell.component.html | 24 ++ .../admin/pages/admin-shell.component.scss | 120 ++++++ .../admin/pages/admin-shell.component.ts | 40 ++ .../admin/services/admin-auth.service.ts | 13 +- .../services/admin-operations.service.ts | 56 +++ .../admin/services/admin-orders.service.ts | 39 +- 38 files changed, 2558 insertions(+), 345 deletions(-) delete mode 100644 backend/src/main/java/com/printcalculator/controller/AdminOrderController.java rename backend/src/main/java/com/printcalculator/controller/{ => admin}/AdminAuthController.java (54%) create mode 100644 backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java create mode 100644 backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminContactRequestDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminFilamentStockDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminOrderStatusUpdateRequest.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java create mode 100644 backend/src/main/java/com/printcalculator/security/AdminLoginThrottleService.java create mode 100644 backend/src/test/java/com/printcalculator/security/AdminLoginThrottleServiceTest.java create mode 100644 frontend/src/app/features/admin/pages/admin-contact-requests.component.html create mode 100644 frontend/src/app/features/admin/pages/admin-contact-requests.component.scss create mode 100644 frontend/src/app/features/admin/pages/admin-contact-requests.component.ts create mode 100644 frontend/src/app/features/admin/pages/admin-filament-stock.component.html create mode 100644 frontend/src/app/features/admin/pages/admin-filament-stock.component.scss create mode 100644 frontend/src/app/features/admin/pages/admin-filament-stock.component.ts create mode 100644 frontend/src/app/features/admin/pages/admin-orders-past.component.html create mode 100644 frontend/src/app/features/admin/pages/admin-orders-past.component.scss create mode 100644 frontend/src/app/features/admin/pages/admin-orders-past.component.ts create mode 100644 frontend/src/app/features/admin/pages/admin-sessions.component.html create mode 100644 frontend/src/app/features/admin/pages/admin-sessions.component.scss create mode 100644 frontend/src/app/features/admin/pages/admin-sessions.component.ts create mode 100644 frontend/src/app/features/admin/pages/admin-shell.component.html create mode 100644 frontend/src/app/features/admin/pages/admin-shell.component.scss create mode 100644 frontend/src/app/features/admin/pages/admin-shell.component.ts create mode 100644 frontend/src/app/features/admin/services/admin-operations.service.ts diff --git a/backend/src/main/java/com/printcalculator/controller/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/AdminOrderController.java deleted file mode 100644 index f4ba54f..0000000 --- a/backend/src/main/java/com/printcalculator/controller/AdminOrderController.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.printcalculator.controller; - -import com.printcalculator.dto.AddressDto; -import com.printcalculator.dto.OrderDto; -import com.printcalculator.dto.OrderItemDto; -import com.printcalculator.entity.Order; -import com.printcalculator.entity.OrderItem; -import com.printcalculator.repository.OrderItemRepository; -import com.printcalculator.repository.OrderRepository; -import com.printcalculator.repository.PaymentRepository; -import com.printcalculator.service.PaymentService; -import org.springframework.http.ResponseEntity; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; - -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import static org.springframework.http.HttpStatus.NOT_FOUND; - -@RestController -@RequestMapping("/api/admin/orders") -public class AdminOrderController { - - private final OrderRepository orderRepo; - private final OrderItemRepository orderItemRepo; - private final PaymentRepository paymentRepo; - private final PaymentService paymentService; - - public AdminOrderController( - OrderRepository orderRepo, - OrderItemRepository orderItemRepo, - PaymentRepository paymentRepo, - PaymentService paymentService - ) { - this.orderRepo = orderRepo; - this.orderItemRepo = orderItemRepo; - this.paymentRepo = paymentRepo; - this.paymentService = paymentService; - } - - @GetMapping - public ResponseEntity> listOrders() { - List response = orderRepo.findAllByOrderByCreatedAtDesc() - .stream() - .map(this::toOrderDto) - .toList(); - return ResponseEntity.ok(response); - } - - @GetMapping("/{orderId}") - public ResponseEntity getOrder(@PathVariable UUID orderId) { - return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); - } - - @PostMapping("/{orderId}/payments/confirm") - @Transactional - public ResponseEntity confirmPayment( - @PathVariable UUID orderId, - @RequestBody(required = false) Map payload - ) { - getOrderOrThrow(orderId); - String method = payload != null ? payload.get("method") : null; - paymentService.confirmPayment(orderId, method); - return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); - } - - private Order getOrderOrThrow(UUID orderId) { - return orderRepo.findById(orderId) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found")); - } - - private OrderDto toOrderDto(Order order) { - List items = orderItemRepo.findByOrder_Id(order.getId()); - OrderDto dto = new OrderDto(); - dto.setId(order.getId()); - dto.setOrderNumber(getDisplayOrderNumber(order)); - dto.setStatus(order.getStatus()); - - paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> { - dto.setPaymentStatus(p.getStatus()); - dto.setPaymentMethod(p.getMethod()); - }); - - dto.setCustomerEmail(order.getCustomerEmail()); - dto.setCustomerPhone(order.getCustomerPhone()); - dto.setPreferredLanguage(order.getPreferredLanguage()); - dto.setBillingCustomerType(order.getBillingCustomerType()); - dto.setCurrency(order.getCurrency()); - dto.setSetupCostChf(order.getSetupCostChf()); - dto.setShippingCostChf(order.getShippingCostChf()); - dto.setDiscountChf(order.getDiscountChf()); - dto.setSubtotalChf(order.getSubtotalChf()); - dto.setTotalChf(order.getTotalChf()); - dto.setCreatedAt(order.getCreatedAt()); - dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); - - AddressDto billing = new AddressDto(); - billing.setFirstName(order.getBillingFirstName()); - billing.setLastName(order.getBillingLastName()); - billing.setCompanyName(order.getBillingCompanyName()); - billing.setContactPerson(order.getBillingContactPerson()); - billing.setAddressLine1(order.getBillingAddressLine1()); - billing.setAddressLine2(order.getBillingAddressLine2()); - billing.setZip(order.getBillingZip()); - billing.setCity(order.getBillingCity()); - billing.setCountryCode(order.getBillingCountryCode()); - dto.setBillingAddress(billing); - - if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) { - AddressDto shipping = new AddressDto(); - shipping.setFirstName(order.getShippingFirstName()); - shipping.setLastName(order.getShippingLastName()); - shipping.setCompanyName(order.getShippingCompanyName()); - shipping.setContactPerson(order.getShippingContactPerson()); - shipping.setAddressLine1(order.getShippingAddressLine1()); - shipping.setAddressLine2(order.getShippingAddressLine2()); - shipping.setZip(order.getShippingZip()); - shipping.setCity(order.getShippingCity()); - shipping.setCountryCode(order.getShippingCountryCode()); - dto.setShippingAddress(shipping); - } - - List itemDtos = items.stream().map(i -> { - OrderItemDto idto = new OrderItemDto(); - idto.setId(i.getId()); - idto.setOriginalFilename(i.getOriginalFilename()); - idto.setMaterialCode(i.getMaterialCode()); - idto.setColorCode(i.getColorCode()); - idto.setQuantity(i.getQuantity()); - idto.setPrintTimeSeconds(i.getPrintTimeSeconds()); - idto.setMaterialGrams(i.getMaterialGrams()); - idto.setUnitPriceChf(i.getUnitPriceChf()); - idto.setLineTotalChf(i.getLineTotalChf()); - return idto; - }).toList(); - dto.setItems(itemDtos); - - return dto; - } - - private String getDisplayOrderNumber(Order order) { - String orderNumber = order.getOrderNumber(); - if (orderNumber != null && !orderNumber.isBlank()) { - return orderNumber; - } - return order.getId() != null ? order.getId().toString() : "unknown"; - } -} diff --git a/backend/src/main/java/com/printcalculator/controller/AdminAuthController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminAuthController.java similarity index 54% rename from backend/src/main/java/com/printcalculator/controller/AdminAuthController.java rename to backend/src/main/java/com/printcalculator/controller/admin/AdminAuthController.java index a489f56..7c7a4d2 100644 --- a/backend/src/main/java/com/printcalculator/controller/AdminAuthController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminAuthController.java @@ -1,7 +1,9 @@ -package com.printcalculator.controller; +package com.printcalculator.controller.admin; import com.printcalculator.dto.AdminLoginRequest; +import com.printcalculator.security.AdminLoginThrottleService; import com.printcalculator.security.AdminSessionService; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import org.springframework.http.HttpHeaders; @@ -13,26 +15,52 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; +import java.util.OptionalLong; @RestController @RequestMapping("/api/admin/auth") public class AdminAuthController { private final AdminSessionService adminSessionService; + private final AdminLoginThrottleService adminLoginThrottleService; - public AdminAuthController(AdminSessionService adminSessionService) { + public AdminAuthController( + AdminSessionService adminSessionService, + AdminLoginThrottleService adminLoginThrottleService + ) { this.adminSessionService = adminSessionService; + this.adminLoginThrottleService = adminLoginThrottleService; } @PostMapping("/login") public ResponseEntity> login( @Valid @RequestBody AdminLoginRequest request, + HttpServletRequest httpRequest, HttpServletResponse response ) { - if (!adminSessionService.isPasswordValid(request.getPassword())) { - return ResponseEntity.status(401).body(Map.of("authenticated", false)); + String clientKey = adminLoginThrottleService.resolveClientKey(httpRequest); + OptionalLong remainingLock = adminLoginThrottleService.getRemainingLockSeconds(clientKey); + if (remainingLock.isPresent()) { + long retryAfter = remainingLock.getAsLong(); + return ResponseEntity.status(429) + .header(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfter)) + .body(Map.of( + "authenticated", false, + "retryAfterSeconds", retryAfter + )); } + if (!adminSessionService.isPasswordValid(request.getPassword())) { + long retryAfter = adminLoginThrottleService.registerFailure(clientKey); + return ResponseEntity.status(401) + .header(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfter)) + .body(Map.of( + "authenticated", false, + "retryAfterSeconds", retryAfter + )); + } + + adminLoginThrottleService.reset(clientKey); String token = adminSessionService.createSessionToken(); response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLoginCookie(token).toString()); diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java new file mode 100644 index 0000000..4d55467 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java @@ -0,0 +1,142 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AdminContactRequestDto; +import com.printcalculator.dto.AdminFilamentStockDto; +import com.printcalculator.dto.AdminQuoteSessionDto; +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.entity.FilamentVariantStockKg; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.CustomQuoteRequestRepository; +import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.FilamentVariantStockKgRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/admin") +@Transactional(readOnly = true) +public class AdminOperationsController { + + private final FilamentVariantStockKgRepository filamentStockRepo; + private final FilamentVariantRepository filamentVariantRepo; + private final CustomQuoteRequestRepository customQuoteRequestRepo; + private final QuoteSessionRepository quoteSessionRepo; + + public AdminOperationsController( + FilamentVariantStockKgRepository filamentStockRepo, + FilamentVariantRepository filamentVariantRepo, + CustomQuoteRequestRepository customQuoteRequestRepo, + QuoteSessionRepository quoteSessionRepo + ) { + this.filamentStockRepo = filamentStockRepo; + this.filamentVariantRepo = filamentVariantRepo; + this.customQuoteRequestRepo = customQuoteRequestRepo; + this.quoteSessionRepo = quoteSessionRepo; + } + + @GetMapping("/filament-stock") + public ResponseEntity> getFilamentStock() { + List stocks = filamentStockRepo.findAll(Sort.by(Sort.Direction.ASC, "stockKg")); + Set variantIds = stocks.stream() + .map(FilamentVariantStockKg::getFilamentVariantId) + .collect(Collectors.toSet()); + + Map variantsById; + if (variantIds.isEmpty()) { + variantsById = Collections.emptyMap(); + } else { + variantsById = filamentVariantRepo.findAllById(variantIds).stream() + .collect(Collectors.toMap(FilamentVariant::getId, variant -> variant)); + } + + List response = stocks.stream().map(stock -> { + FilamentVariant variant = variantsById.get(stock.getFilamentVariantId()); + AdminFilamentStockDto dto = new AdminFilamentStockDto(); + dto.setFilamentVariantId(stock.getFilamentVariantId()); + dto.setStockSpools(stock.getStockSpools()); + dto.setSpoolNetKg(stock.getSpoolNetKg()); + dto.setStockKg(stock.getStockKg()); + + if (variant != null) { + dto.setMaterialCode( + variant.getFilamentMaterialType() != null + ? variant.getFilamentMaterialType().getMaterialCode() + : "UNKNOWN" + ); + dto.setVariantDisplayName(variant.getVariantDisplayName()); + dto.setColorName(variant.getColorName()); + dto.setActive(variant.getIsActive()); + } else { + dto.setMaterialCode("UNKNOWN"); + dto.setVariantDisplayName("Variant " + stock.getFilamentVariantId()); + dto.setColorName("-"); + dto.setActive(false); + } + + return dto; + }).toList(); + + return ResponseEntity.ok(response); + } + + @GetMapping("/contact-requests") + public ResponseEntity> getContactRequests() { + List response = customQuoteRequestRepo.findAll( + Sort.by(Sort.Direction.DESC, "createdAt") + ) + .stream() + .map(this::toContactRequestDto) + .toList(); + + return ResponseEntity.ok(response); + } + + @GetMapping("/sessions") + public ResponseEntity> getQuoteSessions() { + List response = quoteSessionRepo.findAll( + Sort.by(Sort.Direction.DESC, "createdAt") + ) + .stream() + .map(this::toQuoteSessionDto) + .toList(); + + return ResponseEntity.ok(response); + } + + private AdminContactRequestDto toContactRequestDto(CustomQuoteRequest request) { + AdminContactRequestDto dto = new AdminContactRequestDto(); + dto.setId(request.getId()); + dto.setRequestType(request.getRequestType()); + dto.setCustomerType(request.getCustomerType()); + dto.setEmail(request.getEmail()); + dto.setPhone(request.getPhone()); + dto.setName(request.getName()); + dto.setCompanyName(request.getCompanyName()); + dto.setStatus(request.getStatus()); + dto.setCreatedAt(request.getCreatedAt()); + return dto; + } + + private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) { + AdminQuoteSessionDto dto = new AdminQuoteSessionDto(); + dto.setId(session.getId()); + dto.setStatus(session.getStatus()); + dto.setMaterialCode(session.getMaterialCode()); + dto.setCreatedAt(session.getCreatedAt()); + dto.setExpiresAt(session.getExpiresAt()); + dto.setConvertedOrderId(session.getConvertedOrderId()); + return dto; + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java new file mode 100644 index 0000000..b8d3c46 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java @@ -0,0 +1,301 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AddressDto; +import com.printcalculator.dto.AdminOrderStatusUpdateRequest; +import com.printcalculator.dto.OrderDto; +import com.printcalculator.dto.OrderItemDto; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.entity.Payment; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.service.InvoicePdfRenderingService; +import com.printcalculator.service.PaymentService; +import com.printcalculator.service.QrBillService; +import com.printcalculator.service.StorageService; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@RestController +@RequestMapping("/api/admin/orders") +@Transactional(readOnly = true) +public class AdminOrderController { + private static final List ALLOWED_ORDER_STATUSES = List.of( + "PENDING_PAYMENT", + "PAID", + "IN_PRODUCTION", + "SHIPPED", + "COMPLETED", + "CANCELLED" + ); + + private final OrderRepository orderRepo; + private final OrderItemRepository orderItemRepo; + private final PaymentRepository paymentRepo; + private final PaymentService paymentService; + private final StorageService storageService; + private final InvoicePdfRenderingService invoiceService; + private final QrBillService qrBillService; + + public AdminOrderController( + OrderRepository orderRepo, + OrderItemRepository orderItemRepo, + PaymentRepository paymentRepo, + PaymentService paymentService, + StorageService storageService, + InvoicePdfRenderingService invoiceService, + QrBillService qrBillService + ) { + this.orderRepo = orderRepo; + this.orderItemRepo = orderItemRepo; + this.paymentRepo = paymentRepo; + this.paymentService = paymentService; + this.storageService = storageService; + this.invoiceService = invoiceService; + this.qrBillService = qrBillService; + } + + @GetMapping + public ResponseEntity> listOrders() { + List response = orderRepo.findAllByOrderByCreatedAtDesc() + .stream() + .map(this::toOrderDto) + .toList(); + return ResponseEntity.ok(response); + } + + @GetMapping("/{orderId}") + public ResponseEntity getOrder(@PathVariable UUID orderId) { + return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); + } + + @PostMapping("/{orderId}/payments/confirm") + @Transactional + public ResponseEntity confirmPayment( + @PathVariable UUID orderId, + @RequestBody(required = false) Map payload + ) { + getOrderOrThrow(orderId); + String method = payload != null ? payload.get("method") : null; + paymentService.confirmPayment(orderId, method); + return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); + } + + @PostMapping("/{orderId}/status") + @Transactional + public ResponseEntity updateOrderStatus( + @PathVariable UUID orderId, + @RequestBody AdminOrderStatusUpdateRequest payload + ) { + if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) { + throw new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "Status is required"); + } + + Order order = getOrderOrThrow(orderId); + String normalizedStatus = payload.getStatus().trim().toUpperCase(Locale.ROOT); + if (!ALLOWED_ORDER_STATUSES.contains(normalizedStatus)) { + throw new ResponseStatusException( + BAD_REQUEST, + "Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES) + ); + } + order.setStatus(normalizedStatus); + orderRepo.save(order); + + return ResponseEntity.ok(toOrderDto(order)); + } + + @GetMapping("/{orderId}/items/{orderItemId}/file") + public ResponseEntity downloadOrderItemFile( + @PathVariable UUID orderId, + @PathVariable UUID orderItemId + ) { + OrderItem item = orderItemRepo.findById(orderItemId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order item not found")); + + if (!item.getOrder().getId().equals(orderId)) { + throw new ResponseStatusException(NOT_FOUND, "Order item not found for order"); + } + + String relativePath = item.getStoredRelativePath(); + if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) { + throw new ResponseStatusException(NOT_FOUND, "File not available"); + } + + try { + Resource resource = storageService.loadAsResource(Paths.get(relativePath)); + MediaType contentType = MediaType.APPLICATION_OCTET_STREAM; + if (item.getMimeType() != null && !item.getMimeType().isBlank()) { + try { + contentType = MediaType.parseMediaType(item.getMimeType()); + } catch (Exception ignored) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + } + + String filename = item.getOriginalFilename() != null && !item.getOriginalFilename().isBlank() + ? item.getOriginalFilename() + : "order-item-" + orderItemId; + + return ResponseEntity.ok() + .contentType(contentType) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(filename, StandardCharsets.UTF_8) + .build() + .toString()) + .body(resource); + } catch (Exception e) { + throw new ResponseStatusException(NOT_FOUND, "File not available"); + } + } + + @GetMapping("/{orderId}/documents/confirmation") + public ResponseEntity downloadOrderConfirmation(@PathVariable UUID orderId) { + return generateDocument(getOrderOrThrow(orderId), true); + } + + @GetMapping("/{orderId}/documents/invoice") + public ResponseEntity downloadOrderInvoice(@PathVariable UUID orderId) { + return generateDocument(getOrderOrThrow(orderId), false); + } + + private Order getOrderOrThrow(UUID orderId) { + return orderRepo.findById(orderId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found")); + } + + private OrderDto toOrderDto(Order order) { + List items = orderItemRepo.findByOrder_Id(order.getId()); + OrderDto dto = new OrderDto(); + dto.setId(order.getId()); + dto.setOrderNumber(getDisplayOrderNumber(order)); + dto.setStatus(order.getStatus()); + + paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> { + dto.setPaymentStatus(p.getStatus()); + dto.setPaymentMethod(p.getMethod()); + }); + + dto.setCustomerEmail(order.getCustomerEmail()); + dto.setCustomerPhone(order.getCustomerPhone()); + dto.setPreferredLanguage(order.getPreferredLanguage()); + dto.setBillingCustomerType(order.getBillingCustomerType()); + dto.setCurrency(order.getCurrency()); + dto.setSetupCostChf(order.getSetupCostChf()); + dto.setShippingCostChf(order.getShippingCostChf()); + dto.setDiscountChf(order.getDiscountChf()); + dto.setSubtotalChf(order.getSubtotalChf()); + dto.setTotalChf(order.getTotalChf()); + dto.setCreatedAt(order.getCreatedAt()); + dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); + QuoteSession sourceSession = order.getSourceQuoteSession(); + if (sourceSession != null) { + dto.setPrintMaterialCode(sourceSession.getMaterialCode()); + dto.setPrintNozzleDiameterMm(sourceSession.getNozzleDiameterMm()); + dto.setPrintLayerHeightMm(sourceSession.getLayerHeightMm()); + dto.setPrintInfillPattern(sourceSession.getInfillPattern()); + dto.setPrintInfillPercent(sourceSession.getInfillPercent()); + dto.setPrintSupportsEnabled(sourceSession.getSupportsEnabled()); + } + + AddressDto billing = new AddressDto(); + billing.setFirstName(order.getBillingFirstName()); + billing.setLastName(order.getBillingLastName()); + billing.setCompanyName(order.getBillingCompanyName()); + billing.setContactPerson(order.getBillingContactPerson()); + billing.setAddressLine1(order.getBillingAddressLine1()); + billing.setAddressLine2(order.getBillingAddressLine2()); + billing.setZip(order.getBillingZip()); + billing.setCity(order.getBillingCity()); + billing.setCountryCode(order.getBillingCountryCode()); + dto.setBillingAddress(billing); + + if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) { + AddressDto shipping = new AddressDto(); + shipping.setFirstName(order.getShippingFirstName()); + shipping.setLastName(order.getShippingLastName()); + shipping.setCompanyName(order.getShippingCompanyName()); + shipping.setContactPerson(order.getShippingContactPerson()); + shipping.setAddressLine1(order.getShippingAddressLine1()); + shipping.setAddressLine2(order.getShippingAddressLine2()); + shipping.setZip(order.getShippingZip()); + shipping.setCity(order.getShippingCity()); + shipping.setCountryCode(order.getShippingCountryCode()); + dto.setShippingAddress(shipping); + } + + List itemDtos = items.stream().map(i -> { + OrderItemDto idto = new OrderItemDto(); + idto.setId(i.getId()); + idto.setOriginalFilename(i.getOriginalFilename()); + idto.setMaterialCode(i.getMaterialCode()); + idto.setColorCode(i.getColorCode()); + idto.setQuantity(i.getQuantity()); + idto.setPrintTimeSeconds(i.getPrintTimeSeconds()); + idto.setMaterialGrams(i.getMaterialGrams()); + idto.setUnitPriceChf(i.getUnitPriceChf()); + idto.setLineTotalChf(i.getLineTotalChf()); + return idto; + }).toList(); + dto.setItems(itemDtos); + + return dto; + } + + private String getDisplayOrderNumber(Order order) { + String orderNumber = order.getOrderNumber(); + if (orderNumber != null && !orderNumber.isBlank()) { + return orderNumber; + } + return order.getId() != null ? order.getId().toString() : "unknown"; + } + + private ResponseEntity generateDocument(Order order, boolean isConfirmation) { + String displayOrderNumber = getDisplayOrderNumber(order); + if (isConfirmation) { + String relativePath = "orders/" + order.getId() + "/documents/confirmation-" + displayOrderNumber + ".pdf"; + try { + byte[] existingPdf = storageService.loadAsResource(Paths.get(relativePath)).getInputStream().readAllBytes(); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(existingPdf); + } catch (Exception ignored) { + // fallback to generated confirmation document + } + } + + List items = orderItemRepo.findByOrder_Id(order.getId()); + Payment payment = paymentRepo.findByOrder_Id(order.getId()).orElse(null); + byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment); + + String prefix = isConfirmation ? "confirmation-" : "invoice-"; + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + prefix + displayOrderNumber + ".pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(pdf); + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminContactRequestDto.java b/backend/src/main/java/com/printcalculator/dto/AdminContactRequestDto.java new file mode 100644 index 0000000..a9d4c37 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminContactRequestDto.java @@ -0,0 +1,88 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminContactRequestDto { + private UUID id; + private String requestType; + private String customerType; + private String email; + private String phone; + private String name; + private String companyName; + private String status; + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getRequestType() { + return requestType; + } + + public void setRequestType(String requestType) { + this.requestType = requestType; + } + + public String getCustomerType() { + return customerType; + } + + public void setCustomerType(String customerType) { + this.customerType = customerType; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCompanyName() { + return companyName; + } + + public void setCompanyName(String companyName) { + this.companyName = companyName; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminFilamentStockDto.java b/backend/src/main/java/com/printcalculator/dto/AdminFilamentStockDto.java new file mode 100644 index 0000000..5848ca2 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminFilamentStockDto.java @@ -0,0 +1,78 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; + +public class AdminFilamentStockDto { + private Long filamentVariantId; + private String materialCode; + private String variantDisplayName; + private String colorName; + private BigDecimal stockSpools; + private BigDecimal spoolNetKg; + private BigDecimal stockKg; + private Boolean active; + + public Long getFilamentVariantId() { + return filamentVariantId; + } + + public void setFilamentVariantId(Long filamentVariantId) { + this.filamentVariantId = filamentVariantId; + } + + public String getMaterialCode() { + return materialCode; + } + + public void setMaterialCode(String materialCode) { + this.materialCode = materialCode; + } + + public String getVariantDisplayName() { + return variantDisplayName; + } + + public void setVariantDisplayName(String variantDisplayName) { + this.variantDisplayName = variantDisplayName; + } + + public String getColorName() { + return colorName; + } + + public void setColorName(String colorName) { + this.colorName = colorName; + } + + public BigDecimal getStockSpools() { + return stockSpools; + } + + public void setStockSpools(BigDecimal stockSpools) { + this.stockSpools = stockSpools; + } + + public BigDecimal getSpoolNetKg() { + return spoolNetKg; + } + + public void setSpoolNetKg(BigDecimal spoolNetKg) { + this.spoolNetKg = spoolNetKg; + } + + public BigDecimal getStockKg() { + return stockKg; + } + + public void setStockKg(BigDecimal stockKg) { + this.stockKg = stockKg; + } + + public Boolean getActive() { + return active; + } + + public void setActive(Boolean active) { + this.active = active; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminOrderStatusUpdateRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminOrderStatusUpdateRequest.java new file mode 100644 index 0000000..7a18fe2 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminOrderStatusUpdateRequest.java @@ -0,0 +1,13 @@ +package com.printcalculator.dto; + +public class AdminOrderStatusUpdateRequest { + private String status; + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java b/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java new file mode 100644 index 0000000..47b0be5 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java @@ -0,0 +1,61 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminQuoteSessionDto { + private UUID id; + private String status; + private String materialCode; + private OffsetDateTime createdAt; + private OffsetDateTime expiresAt; + private UUID convertedOrderId; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getMaterialCode() { + return materialCode; + } + + public void setMaterialCode(String materialCode) { + this.materialCode = materialCode; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(OffsetDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public UUID getConvertedOrderId() { + return convertedOrderId; + } + + public void setConvertedOrderId(UUID convertedOrderId) { + this.convertedOrderId = convertedOrderId; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/OrderDto.java b/backend/src/main/java/com/printcalculator/dto/OrderDto.java index 63dd666..63e9f16 100644 --- a/backend/src/main/java/com/printcalculator/dto/OrderDto.java +++ b/backend/src/main/java/com/printcalculator/dto/OrderDto.java @@ -25,6 +25,12 @@ public class OrderDto { private BigDecimal subtotalChf; private BigDecimal totalChf; private OffsetDateTime createdAt; + private String printMaterialCode; + private BigDecimal printNozzleDiameterMm; + private BigDecimal printLayerHeightMm; + private String printInfillPattern; + private Integer printInfillPercent; + private Boolean printSupportsEnabled; private List items; // Getters and Setters @@ -85,6 +91,24 @@ public class OrderDto { public OffsetDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + public String getPrintMaterialCode() { return printMaterialCode; } + public void setPrintMaterialCode(String printMaterialCode) { this.printMaterialCode = printMaterialCode; } + + public BigDecimal getPrintNozzleDiameterMm() { return printNozzleDiameterMm; } + public void setPrintNozzleDiameterMm(BigDecimal printNozzleDiameterMm) { this.printNozzleDiameterMm = printNozzleDiameterMm; } + + public BigDecimal getPrintLayerHeightMm() { return printLayerHeightMm; } + public void setPrintLayerHeightMm(BigDecimal printLayerHeightMm) { this.printLayerHeightMm = printLayerHeightMm; } + + public String getPrintInfillPattern() { return printInfillPattern; } + public void setPrintInfillPattern(String printInfillPattern) { this.printInfillPattern = printInfillPattern; } + + public Integer getPrintInfillPercent() { return printInfillPercent; } + public void setPrintInfillPercent(Integer printInfillPercent) { this.printInfillPercent = printInfillPercent; } + + public Boolean getPrintSupportsEnabled() { return printSupportsEnabled; } + public void setPrintSupportsEnabled(Boolean printSupportsEnabled) { this.printSupportsEnabled = printSupportsEnabled; } + public List getItems() { return items; } public void setItems(List items) { this.items = items; } } diff --git a/backend/src/main/java/com/printcalculator/security/AdminLoginThrottleService.java b/backend/src/main/java/com/printcalculator/security/AdminLoginThrottleService.java new file mode 100644 index 0000000..de2b5ad --- /dev/null +++ b/backend/src/main/java/com/printcalculator/security/AdminLoginThrottleService.java @@ -0,0 +1,91 @@ +package com.printcalculator.security; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.OptionalLong; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class AdminLoginThrottleService { + + private static final long BASE_DELAY_SECONDS = 2L; + private static final long MAX_DELAY_SECONDS = 3600L; + + private final ConcurrentHashMap attemptsByClient = new ConcurrentHashMap<>(); + + public OptionalLong getRemainingLockSeconds(String clientKey) { + LoginAttemptState state = attemptsByClient.get(clientKey); + if (state == null) { + return OptionalLong.empty(); + } + + long now = Instant.now().getEpochSecond(); + long remaining = state.blockedUntilEpochSeconds - now; + if (remaining <= 0) { + attemptsByClient.remove(clientKey, state); + return OptionalLong.empty(); + } + + return OptionalLong.of(remaining); + } + + public long registerFailure(String clientKey) { + long now = Instant.now().getEpochSecond(); + LoginAttemptState state = attemptsByClient.compute(clientKey, (key, current) -> { + int nextFailures = current == null ? 1 : current.failures + 1; + long delay = calculateDelaySeconds(nextFailures); + return new LoginAttemptState(nextFailures, now + delay); + }); + + return calculateDelaySeconds(state.failures); + } + + public void reset(String clientKey) { + attemptsByClient.remove(clientKey); + } + + public String resolveClientKey(HttpServletRequest request) { + String forwardedFor = request.getHeader("X-Forwarded-For"); + if (forwardedFor != null && !forwardedFor.isBlank()) { + String[] parts = forwardedFor.split(","); + if (parts.length > 0 && !parts[0].trim().isEmpty()) { + return parts[0].trim(); + } + } + + String realIp = request.getHeader("X-Real-IP"); + if (realIp != null && !realIp.isBlank()) { + return realIp.trim(); + } + + String remoteAddress = request.getRemoteAddr(); + if (remoteAddress != null && !remoteAddress.isBlank()) { + return remoteAddress.trim(); + } + + return "unknown"; + } + + private long calculateDelaySeconds(int failures) { + long delay = BASE_DELAY_SECONDS; + for (int i = 1; i < failures; i++) { + if (delay >= MAX_DELAY_SECONDS) { + return MAX_DELAY_SECONDS; + } + delay *= 2; + } + return Math.min(delay, MAX_DELAY_SECONDS); + } + + private static class LoginAttemptState { + private final int failures; + private final long blockedUntilEpochSeconds; + + private LoginAttemptState(int failures, long blockedUntilEpochSeconds) { + this.failures = failures; + this.blockedUntilEpochSeconds = blockedUntilEpochSeconds; + } + } +} diff --git a/backend/src/main/resources/application-local.properties b/backend/src/main/resources/application-local.properties index 568593c..84e4ff5 100644 --- a/backend/src/main/resources/application-local.properties +++ b/backend/src/main/resources/application-local.properties @@ -2,6 +2,6 @@ app.mail.enabled=false app.mail.admin.enabled=false # Admin back-office local test credentials -admin.password=local-admin-password +admin.password=ciaociao admin.session.secret=local-admin-session-secret-0123456789abcdef0123456789 admin.session.ttl-minutes=480 diff --git a/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java index 5eeb84b..6b82749 100644 --- a/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java +++ b/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java @@ -1,6 +1,8 @@ package com.printcalculator.controller; import com.printcalculator.config.SecurityConfig; +import com.printcalculator.controller.admin.AdminAuthController; +import com.printcalculator.security.AdminLoginThrottleService; import com.printcalculator.security.AdminSessionAuthenticationFilter; import com.printcalculator.security.AdminSessionService; import jakarta.servlet.http.Cookie; @@ -22,7 +24,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(controllers = AdminAuthController.class) -@Import({SecurityConfig.class, AdminSessionAuthenticationFilter.class, AdminSessionService.class}) +@Import({ + SecurityConfig.class, + AdminSessionAuthenticationFilter.class, + AdminSessionService.class, + AdminLoginThrottleService.class +}) @TestPropertySource(properties = { "admin.password=test-admin-password", "admin.session.secret=0123456789abcdef0123456789abcdef", @@ -36,6 +43,10 @@ class AdminAuthSecurityTest { @Test void loginOk_ShouldReturnCookie() throws Exception { MvcResult result = mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.1"); + return req; + }) .contentType(MediaType.APPLICATION_JSON) .content("{\"password\":\"test-admin-password\"}")) .andExpect(status().isOk()) @@ -53,9 +64,37 @@ class AdminAuthSecurityTest { @Test void loginKo_ShouldReturnUnauthorized() throws Exception { mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.2"); + return req; + }) .contentType(MediaType.APPLICATION_JSON) .content("{\"password\":\"wrong-password\"}")) .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.authenticated").value(false)) + .andExpect(jsonPath("$.retryAfterSeconds").value(2)); + } + + @Test + void loginKoSecondAttemptDuringLock_ShouldReturnTooManyRequests() throws Exception { + mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.3"); + return req; + }) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"wrong-password\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.retryAfterSeconds").value(2)); + + mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.3"); + return req; + }) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"wrong-password\"}")) + .andExpect(status().isTooManyRequests()) .andExpect(jsonPath("$.authenticated").value(false)); } @@ -68,6 +107,10 @@ class AdminAuthSecurityTest { @Test void adminAccessWithValidCookie_ShouldReturn200() throws Exception { MvcResult login = mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.4"); + return req; + }) .contentType(MediaType.APPLICATION_JSON) .content("{\"password\":\"test-admin-password\"}")) .andExpect(status().isOk()) diff --git a/backend/src/test/java/com/printcalculator/security/AdminLoginThrottleServiceTest.java b/backend/src/test/java/com/printcalculator/security/AdminLoginThrottleServiceTest.java new file mode 100644 index 0000000..e7447e2 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/security/AdminLoginThrottleServiceTest.java @@ -0,0 +1,17 @@ +package com.printcalculator.security; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class AdminLoginThrottleServiceTest { + + private final AdminLoginThrottleService service = new AdminLoginThrottleService(); + + @Test + void registerFailure_ShouldDoubleDelay() { + assertEquals(2L, service.registerFailure("127.0.0.1")); + assertEquals(4L, service.registerFailure("127.0.0.1")); + assertEquals(8L, service.registerFailure("127.0.0.1")); + } +} diff --git a/frontend/src/app/features/admin/admin.routes.ts b/frontend/src/app/features/admin/admin.routes.ts index d33a624..13632c3 100644 --- a/frontend/src/app/features/admin/admin.routes.ts +++ b/frontend/src/app/features/admin/admin.routes.ts @@ -9,6 +9,33 @@ export const ADMIN_ROUTES: Routes = [ { path: '', canActivate: [adminAuthGuard], - loadComponent: () => import('./pages/admin-dashboard.component').then(m => m.AdminDashboardComponent) + loadComponent: () => import('./pages/admin-shell.component').then(m => m.AdminShellComponent), + children: [ + { + path: '', + pathMatch: 'full', + redirectTo: 'orders' + }, + { + path: 'orders', + loadComponent: () => import('./pages/admin-dashboard.component').then(m => m.AdminDashboardComponent) + }, + { + path: 'orders-past', + loadComponent: () => import('./pages/admin-orders-past.component').then(m => m.AdminOrdersPastComponent) + }, + { + path: 'filament-stock', + loadComponent: () => import('./pages/admin-filament-stock.component').then(m => m.AdminFilamentStockComponent) + }, + { + path: 'contact-requests', + loadComponent: () => import('./pages/admin-contact-requests.component').then(m => m.AdminContactRequestsComponent) + }, + { + path: 'sessions', + loadComponent: () => import('./pages/admin-sessions.component').then(m => m.AdminSessionsComponent) + } + ] } ]; diff --git a/frontend/src/app/features/admin/pages/admin-contact-requests.component.html b/frontend/src/app/features/admin/pages/admin-contact-requests.component.html new file mode 100644 index 0000000..33efec7 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.html @@ -0,0 +1,40 @@ +
+
+
+

Richieste di contatto

+

Richieste preventivo personalizzato ricevute dal sito.

+
+ +
+ +

{{ errorMessage }}

+ +
+ + + + + + + + + + + + + + + + + + + + + +
DataNome / AziendaEmailTipo richiestaTipo clienteStato
{{ request.createdAt | date:'short' }}{{ request.name || request.companyName || '-' }}{{ request.email }}{{ request.requestType }}{{ request.customerType }}{{ request.status }}
+
+
+ + +

Caricamento richieste...

+
diff --git a/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss b/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss new file mode 100644 index 0000000..a293e62 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss @@ -0,0 +1,59 @@ +.section-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + margin-bottom: var(--space-5); +} + +h2 { + margin: 0; +} + +p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); +} + +button { + border: 0; + border-radius: var(--radius-md); + background: var(--color-brand); + color: var(--color-neutral-900); + padding: var(--space-2) var(--space-4); + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; +} + +button:hover:not(:disabled) { + background: var(--color-brand-hover); +} + +.table-wrap { + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: var(--space-3); + border-bottom: 1px solid var(--color-border); +} + +.error { + color: var(--color-danger-500); +} diff --git a/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts b/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts new file mode 100644 index 0000000..17613b6 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts @@ -0,0 +1,37 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; +import { AdminContactRequest, AdminOperationsService } from '../services/admin-operations.service'; + +@Component({ + selector: 'app-admin-contact-requests', + standalone: true, + imports: [CommonModule], + templateUrl: './admin-contact-requests.component.html', + styleUrl: './admin-contact-requests.component.scss' +}) +export class AdminContactRequestsComponent implements OnInit { + private readonly adminOperationsService = inject(AdminOperationsService); + + requests: AdminContactRequest[] = []; + loading = false; + errorMessage: string | null = null; + + ngOnInit(): void { + this.loadRequests(); + } + + loadRequests(): void { + this.loading = true; + this.errorMessage = null; + this.adminOperationsService.getContactRequests().subscribe({ + next: (requests) => { + this.requests = requests; + this.loading = false; + }, + error: () => { + this.loading = false; + this.errorMessage = 'Impossibile caricare le richieste di contatto.'; + } + }); + } +} diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html index 9a89943..4c8fe93 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.html +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -1,67 +1,167 @@
-

Back-office ordini

-

Gestione pagamenti e dettaglio ordini

+

Ordini

+

Seleziona un ordine a sinistra e gestiscilo nel dettaglio a destra.

-

{{ errorMessage }}

-
- - - - - - - - - - - - - - - - - - - - - -
OrdineEmailStatoPagamentoTotaleAzioni
{{ order.orderNumber }}{{ order.customerEmail }}{{ order.status }}{{ order.paymentStatus || 'PENDING' }}{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }} - - -
-
- -
-

Dettaglio ordine {{ selectedOrder.orderNumber }}

-

Caricamento dettaglio...

-

Cliente: {{ selectedOrder.customerEmail }}

-

Pagamento: {{ selectedOrder.paymentStatus || 'PENDING' }}

- -
-
-

File: {{ item.originalFilename }}

-

Qta: {{ item.quantity }}

-

Prezzo riga: {{ item.lineTotalChf | currency:'CHF':'symbol':'1.2-2' }}

+
+
+

Lista ordini

+
+ +
-
-
+
+ + + + + + + + + + + + + + + + + + + + +
OrdineEmailPagamentoTotale
{{ order.orderNumber }}{{ order.customerEmail }}{{ order.paymentStatus || 'PENDING' }}{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}
Nessun ordine trovato per il filtro inserito.
+
+
+ +
+
+

Dettaglio ordine {{ selectedOrder.orderNumber }}

+

UUID: {{ selectedOrder.id }}

+

Caricamento dettaglio...

+
+ +
+
Cliente{{ selectedOrder.customerEmail }}
+
Stato pagamento{{ selectedOrder.paymentStatus || 'PENDING' }}
+
Stato ordine{{ selectedOrder.status }}
+
Totale{{ selectedOrder.totalChf | currency:'CHF':'symbol':'1.2-2' }}
+
+ +
+
+ + + +
+ +
+ + + +
+
+ +
+ + + +
+ +
+
+
+

{{ item.originalFilename }}

+

+ Qta: {{ item.quantity }} | + Colore: + + {{ item.colorCode || '-' }} + | + Riga: {{ item.lineTotalChf | currency:'CHF':'symbol':'1.2-2' }} +

+
+ +
+
+
+ +
+

Nessun ordine selezionato

+

Seleziona un ordine dalla lista per vedere i dettagli.

+
+

Caricamento ordini...

+ + diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.scss b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss index f5eb982..8cdf210 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.scss +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss @@ -1,43 +1,61 @@ .admin-dashboard { - padding: 1rem; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-5); + box-shadow: var(--shadow-sm); } .dashboard-header { display: flex; justify-content: space-between; align-items: flex-start; - gap: 1rem; - margin-bottom: 1rem; + gap: var(--space-4); + margin-bottom: var(--space-4); } .dashboard-header h1 { margin: 0; - font-size: 1.6rem; + font-size: 1.45rem; } .dashboard-header p { - margin: 0.35rem 0 0; - color: #4b5a70; + margin: var(--space-2) 0 0; + color: var(--color-text-muted); } .header-actions { display: flex; - gap: 0.5rem; + gap: var(--space-2); +} + +.workspace { + display: grid; + grid-template-columns: minmax(400px, 0.95fr) minmax(560px, 1.45fr); + gap: var(--space-4); + align-items: start; } button { border: 0; - border-radius: 10px; - background: #0f3f6f; - color: #fff; - padding: 0.55rem 0.8rem; + border-radius: var(--radius-md); + background: var(--color-brand); + color: var(--color-neutral-900); + padding: var(--space-2) var(--space-4); font-weight: 600; cursor: pointer; + transition: background-color 0.2s ease; + line-height: 1.2; +} + +button:hover:not(:disabled) { + background: var(--color-brand-hover); } button.ghost { - background: #eef2f8; - color: #163a5f; + background: var(--color-bg-card); + color: var(--color-text); + border: 1px solid var(--color-border); } button:disabled { @@ -45,11 +63,38 @@ button:disabled { cursor: default; } +.list-panel h2 { + font-size: 1.05rem; + margin-bottom: var(--space-2); +} + +.list-toolbar { + display: grid; + gap: var(--space-1); + margin-bottom: var(--space-3); +} + +.list-toolbar label { + font-size: 0.78rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.list-toolbar input { + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + background: var(--color-bg-card); + font: inherit; +} + .table-wrap { overflow: auto; - border: 1px solid #d8e0ec; - border-radius: 12px; - background: #fff; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + max-height: 72vh; } table { @@ -58,62 +103,296 @@ table { } thead { - background: #f3f6fa; + background: var(--color-neutral-100); } th, td { text-align: left; - padding: 0.75rem; - border-bottom: 1px solid #e5ebf4; + padding: var(--space-3); + border-bottom: 1px solid var(--color-border); vertical-align: top; + font-size: 0.93rem; } -td.actions { +tbody tr { + cursor: pointer; + transition: background-color 0.15s ease; +} + +tbody tr:hover { + background: #fff9d9; +} + +tbody tr.selected { + background: #fff5b8; +} + +tbody tr.no-results { + cursor: default; +} + +tbody tr.no-results:hover { + background: transparent; +} + +.detail-panel { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); + min-height: 520px; +} + +.detail-panel.empty { + display: grid; + align-content: center; + justify-items: center; + text-align: center; + color: var(--color-text-muted); +} + +.order-uuid { + font-size: 0.84rem; + color: var(--color-text-muted); +} + +.order-uuid code { + font-size: 0.82rem; + color: var(--color-text); + background: var(--color-neutral-100); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 2px 6px; +} + +.detail-header h2 { + margin: 0 0 var(--space-2); +} + +.meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.meta-grid > div { + background: var(--color-neutral-100); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); + display: grid; + gap: 2px; +} + +.meta-grid strong { + font-size: 0.78rem; + color: var(--color-text-muted); +} + +.actions-block { display: flex; - gap: 0.5rem; - min-width: 210px; + flex-wrap: wrap; + gap: var(--space-3); + align-items: flex-end; + margin-bottom: var(--space-3); } -tr.selected { - background: #f4f9ff; +.status-editor { + display: grid; + gap: var(--space-2); } -.details { - margin-top: 1rem; - background: #fff; - border: 1px solid #d8e0ec; - border-radius: 12px; - padding: 1rem; +.status-editor label { + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text-muted); } -.details h2 { - margin-top: 0; +.status-editor select { + min-width: 220px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + background: var(--color-bg-card); + font: inherit; +} + +.doc-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-3); } .items { display: grid; - gap: 0.75rem; - grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--space-3); } .item { - border: 1px solid #e5ebf4; - border-radius: 10px; - padding: 0.65rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--color-bg-card); + box-shadow: var(--shadow-sm); + display: grid; + gap: var(--space-3); } -.item p { - margin: 0.2rem 0; +.item-main { + min-width: 0; +} + +.file-name { + margin: 0; + font-size: 0.92rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-meta { + margin: var(--space-1) 0 0; + font-size: 0.84rem; + color: var(--color-text-muted); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.item button { + justify-self: start; +} + +.color-swatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; + border: 1px solid var(--color-border); } .error { - color: #b4232c; - margin-bottom: 0.9rem; + color: var(--color-danger-500); + margin-bottom: var(--space-3); +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(16, 24, 32, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 4000; + padding: var(--space-4); +} + +.modal-card { + width: min(860px, 100%); + max-height: 88vh; + overflow: auto; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + padding: var(--space-4); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-3); +} + +.modal-header h3 { + margin: 0; +} + +.close-btn { + white-space: nowrap; +} + +.modal-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); + margin-bottom: var(--space-4); +} + +.modal-grid > div { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-neutral-100); + padding: var(--space-3); + display: grid; + gap: 2px; +} + +.modal-grid strong { + font-size: 0.78rem; + color: var(--color-text-muted); +} + +h4 { + margin: 0 0 var(--space-2); +} + +.file-color-list { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; +} + +.file-color-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-card); +} + +.file-color-row:last-child { + border-bottom: 0; +} + +.filename { + font-size: 0.9rem; +} + +.file-color { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--color-text-muted); + font-size: 0.88rem; +} + +@media (max-width: 1060px) { + .workspace { + grid-template-columns: 1fr; + } } @media (max-width: 820px) { .dashboard-header { flex-direction: column; } + + .meta-grid, + .modal-grid { + grid-template-columns: 1fr; + } + + .item { + align-items: flex-start; + } } diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts index a73ea5a..2f32b4f 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts @@ -1,30 +1,39 @@ import { CommonModule } from '@angular/common'; import { Component, inject, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { AdminAuthService } from '../services/admin-auth.service'; +import { FormsModule } from '@angular/forms'; import { AdminOrder, AdminOrdersService } from '../services/admin-orders.service'; -const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']); - @Component({ selector: 'app-admin-dashboard', standalone: true, - imports: [CommonModule], + imports: [CommonModule, FormsModule], templateUrl: './admin-dashboard.component.html', styleUrl: './admin-dashboard.component.scss' }) export class AdminDashboardComponent implements OnInit { private readonly adminOrdersService = inject(AdminOrdersService); - private readonly adminAuthService = inject(AdminAuthService); - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); orders: AdminOrder[] = []; + filteredOrders: AdminOrder[] = []; selectedOrder: AdminOrder | null = null; + selectedStatus = ''; + selectedPaymentMethod = 'OTHER'; + orderSearchTerm = ''; + showPrintDetails = false; loading = false; detailLoading = false; - confirmingOrderId: string | null = null; + confirmingPayment = false; + updatingStatus = false; errorMessage: string | null = null; + readonly orderStatusOptions = [ + 'PENDING_PAYMENT', + 'PAID', + 'IN_PRODUCTION', + 'SHIPPED', + 'COMPLETED', + 'CANCELLED' + ]; + readonly paymentMethodOptions = ['TWINT', 'BANK_TRANSFER', 'CARD', 'CASH', 'OTHER']; ngOnInit(): void { this.loadOrders(); @@ -36,6 +45,22 @@ export class AdminDashboardComponent implements OnInit { this.adminOrdersService.listOrders().subscribe({ next: (orders) => { this.orders = orders; + this.refreshFilteredOrders(); + + if (!this.selectedOrder && this.filteredOrders.length > 0) { + this.openDetails(this.filteredOrders[0].id); + } else if (this.selectedOrder) { + const exists = orders.find(order => order.id === this.selectedOrder?.id); + const selectedIsVisible = this.filteredOrders.some(order => order.id === this.selectedOrder?.id); + if (exists && selectedIsVisible) { + this.openDetails(exists.id); + } else if (this.filteredOrders.length > 0) { + this.openDetails(this.filteredOrders[0].id); + } else { + this.selectedOrder = null; + this.selectedStatus = ''; + } + } this.loading = false; }, error: () => { @@ -45,11 +70,28 @@ export class AdminDashboardComponent implements OnInit { }); } + onSearchChange(value: string): void { + this.orderSearchTerm = value; + this.refreshFilteredOrders(); + + if (this.filteredOrders.length === 0) { + this.selectedOrder = null; + this.selectedStatus = ''; + return; + } + + if (!this.selectedOrder || !this.filteredOrders.some(order => order.id === this.selectedOrder?.id)) { + this.openDetails(this.filteredOrders[0].id); + } + } + openDetails(orderId: string): void { this.detailLoading = true; this.adminOrdersService.getOrder(orderId).subscribe({ next: (order) => { this.selectedOrder = order; + this.selectedStatus = order.status; + this.selectedPaymentMethod = order.paymentMethod || 'OTHER'; this.detailLoading = false; }, error: () => { @@ -59,41 +101,156 @@ export class AdminDashboardComponent implements OnInit { }); } - confirmPayment(orderId: string): void { - this.confirmingOrderId = orderId; - this.adminOrdersService.confirmPayment(orderId).subscribe({ + confirmPayment(): void { + if (!this.selectedOrder || this.confirmingPayment) { + return; + } + + this.confirmingPayment = true; + this.adminOrdersService.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod).subscribe({ next: (updatedOrder) => { - this.confirmingOrderId = null; - this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order); - if (this.selectedOrder?.id === updatedOrder.id) { - this.selectedOrder = updatedOrder; - } + this.confirmingPayment = false; + this.applyOrderUpdate(updatedOrder); }, error: () => { - this.confirmingOrderId = null; + this.confirmingPayment = false; this.errorMessage = 'Conferma pagamento non riuscita.'; } }); } - logout(): void { - this.adminAuthService.logout().subscribe({ - next: () => { - void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']); + updateStatus(): void { + if (!this.selectedOrder || this.updatingStatus || !this.selectedStatus.trim()) { + return; + } + + this.updatingStatus = true; + this.adminOrdersService.updateOrderStatus(this.selectedOrder.id, { + status: this.selectedStatus.trim() + }).subscribe({ + next: (updatedOrder) => { + this.updatingStatus = false; + this.applyOrderUpdate(updatedOrder); }, error: () => { - void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']); + this.updatingStatus = false; + this.errorMessage = 'Aggiornamento stato ordine non riuscito.'; } }); } - private resolveLang(): string { - for (const level of this.route.pathFromRoot) { - const lang = level.snapshot.paramMap.get('lang'); - if (lang && SUPPORTED_LANGS.has(lang)) { - return lang; - } + downloadItemFile(itemId: string, filename: string): void { + if (!this.selectedOrder) { + return; } - return 'it'; + + this.adminOrdersService.downloadOrderItemFile(this.selectedOrder.id, itemId).subscribe({ + next: (blob) => { + this.downloadBlob(blob, filename || `order-item-${itemId}`); + }, + error: () => { + this.errorMessage = 'Download file non riuscito.'; + } + }); + } + + downloadConfirmation(): void { + if (!this.selectedOrder) { + return; + } + + this.adminOrdersService.downloadOrderConfirmation(this.selectedOrder.id).subscribe({ + next: (blob) => { + this.downloadBlob(blob, `conferma-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`); + }, + error: () => { + this.errorMessage = 'Download conferma ordine non riuscito.'; + } + }); + } + + downloadInvoice(): void { + if (!this.selectedOrder) { + return; + } + + this.adminOrdersService.downloadOrderInvoice(this.selectedOrder.id).subscribe({ + next: (blob) => { + this.downloadBlob(blob, `fattura-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`); + }, + error: () => { + this.errorMessage = 'Download fattura non riuscito.'; + } + }); + } + + onStatusChange(event: Event): void { + const value = (event.target as HTMLSelectElement | null)?.value ?? ''; + this.selectedStatus = value; + } + + onPaymentMethodChange(event: Event): void { + const value = (event.target as HTMLSelectElement | null)?.value ?? 'OTHER'; + this.selectedPaymentMethod = value; + } + + openPrintDetails(): void { + this.showPrintDetails = true; + } + + closePrintDetails(): void { + this.showPrintDetails = false; + } + + getQualityLabel(layerHeight?: number): string { + if (!layerHeight || Number.isNaN(layerHeight)) { + return '-'; + } + if (layerHeight <= 0.12) { + return 'Alta'; + } + if (layerHeight <= 0.2) { + return 'Standard'; + } + return 'Bozza'; + } + + isHexColor(value?: string): boolean { + return typeof value === 'string' && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value); + } + + isSelected(orderId: string): boolean { + return this.selectedOrder?.id === orderId; + } + + private applyOrderUpdate(updatedOrder: AdminOrder): void { + this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order); + this.refreshFilteredOrders(); + this.selectedOrder = updatedOrder; + this.selectedStatus = updatedOrder.status; + this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod; + } + + private refreshFilteredOrders(): void { + const term = this.orderSearchTerm.trim().toLowerCase(); + if (!term) { + this.filteredOrders = [...this.orders]; + return; + } + + this.filteredOrders = this.orders.filter((order) => { + const fullUuid = order.id.toLowerCase(); + const shortUuid = (order.orderNumber || '').toLowerCase(); + return fullUuid.includes(term) || shortUuid.includes(term); + }); + } + + private downloadBlob(blob: Blob, filename: string): void { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + window.URL.revokeObjectURL(url); } } diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.html b/frontend/src/app/features/admin/pages/admin-filament-stock.component.html new file mode 100644 index 0000000..3b2c72c --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.html @@ -0,0 +1,43 @@ +
+
+
+

Stock filamenti

+

Monitoraggio quantità disponibili per variante.

+
+ +
+ +

{{ errorMessage }}

+ +
+ + + + + + + + + + + + + + + + + + + + + +
MaterialeVarianteColoreSpoolKg totaliStato
{{ row.materialCode }}{{ row.variantDisplayName }}{{ row.colorName }}{{ row.stockSpools | number:'1.0-3' }}{{ row.stockKg | number:'1.0-3' }} kg + Basso + OK +
+
+
+ + +

Caricamento stock...

+
diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss b/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss new file mode 100644 index 0000000..37b7847 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss @@ -0,0 +1,77 @@ +.section-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + margin-bottom: var(--space-5); +} + +h2 { + margin: 0; +} + +p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); +} + +button { + border: 0; + border-radius: var(--radius-md); + background: var(--color-brand); + color: var(--color-neutral-900); + padding: var(--space-2) var(--space-4); + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; +} + +button:hover:not(:disabled) { + background: var(--color-brand-hover); +} + +.table-wrap { + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: var(--space-3); + border-bottom: 1px solid var(--color-border); +} + +.badge { + display: inline-block; + border-radius: 999px; + padding: 0.15rem 0.5rem; + font-size: 0.78rem; + font-weight: 700; +} + +.badge.low { + background: #ffebee; + color: var(--color-danger-500); +} + +.badge.ok { + background: #e6f5ea; + color: #157347; +} + +.error { + color: var(--color-danger-500); +} diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts b/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts new file mode 100644 index 0000000..73d8c80 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts @@ -0,0 +1,41 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; +import { AdminFilamentStockRow, AdminOperationsService } from '../services/admin-operations.service'; + +@Component({ + selector: 'app-admin-filament-stock', + standalone: true, + imports: [CommonModule], + templateUrl: './admin-filament-stock.component.html', + styleUrl: './admin-filament-stock.component.scss' +}) +export class AdminFilamentStockComponent implements OnInit { + private readonly adminOperationsService = inject(AdminOperationsService); + + rows: AdminFilamentStockRow[] = []; + loading = false; + errorMessage: string | null = null; + + ngOnInit(): void { + this.loadStock(); + } + + loadStock(): void { + this.loading = true; + this.errorMessage = null; + this.adminOperationsService.getFilamentStock().subscribe({ + next: (rows) => { + this.rows = rows; + this.loading = false; + }, + error: () => { + this.loading = false; + this.errorMessage = 'Impossibile caricare lo stock filamenti.'; + } + }); + } + + isLowStock(row: AdminFilamentStockRow): boolean { + return Number(row.stockKg) < 1; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-login.component.html b/frontend/src/app/features/admin/pages/admin-login.component.html index 77674e1..4fb6e1c 100644 --- a/frontend/src/app/features/admin/pages/admin-login.component.html +++ b/frontend/src/app/features/admin/pages/admin-login.component.html @@ -1,27 +1,31 @@ - + diff --git a/frontend/src/app/features/admin/pages/admin-login.component.scss b/frontend/src/app/features/admin/pages/admin-login.component.scss index 22e8546..8be1bfd 100644 --- a/frontend/src/app/features/admin/pages/admin-login.component.scss +++ b/frontend/src/app/features/admin/pages/admin-login.component.scss @@ -3,33 +3,33 @@ justify-content: center; align-items: center; min-height: 70vh; - padding: 2rem 1rem; + padding: var(--space-8) 0; } .admin-login-card { width: 100%; max-width: 420px; - background: #fff; - border: 1px solid #d6dde8; - border-radius: 14px; - padding: 1.5rem; - box-shadow: 0 20px 45px rgba(15, 23, 42, 0.08); + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); } h1 { margin: 0; - font-size: 1.6rem; + font-size: 1.5rem; } p { - margin: 0.5rem 0 1.25rem; - color: #46546a; + margin: var(--space-2) 0 var(--space-5); + color: var(--color-text-muted); } form { display: flex; flex-direction: column; - gap: 0.65rem; + gap: var(--space-3); } label { @@ -37,20 +37,25 @@ label { } input { - border: 1px solid #c3cedd; - border-radius: 10px; - padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); font-size: 1rem; } button { border: 0; - border-radius: 10px; - background: #0f3f6f; - color: #fff; - padding: 0.75rem 0.9rem; + border-radius: var(--radius-md); + background: var(--color-brand); + color: var(--color-neutral-900); + padding: var(--space-3) var(--space-4); font-weight: 600; cursor: pointer; + transition: background-color 0.2s ease; +} + +button:hover:not(:disabled) { + background: var(--color-brand-hover); } button:disabled { @@ -59,6 +64,12 @@ button:disabled { } .error { - margin-top: 1rem; - color: #b0182a; + margin-top: var(--space-4); + margin-bottom: var(--space-2); + color: var(--color-danger-500); +} + +.hint { + margin: 0; + color: var(--color-text-muted); } diff --git a/frontend/src/app/features/admin/pages/admin-login.component.ts b/frontend/src/app/features/admin/pages/admin-login.component.ts index ac070a1..10b0a3a 100644 --- a/frontend/src/app/features/admin/pages/admin-login.component.ts +++ b/frontend/src/app/features/admin/pages/admin-login.component.ts @@ -1,8 +1,9 @@ import { CommonModule } from '@angular/common'; -import { Component, inject } from '@angular/core'; +import { Component, inject, OnDestroy } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { AdminAuthService } from '../services/admin-auth.service'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AdminAuthResponse, AdminAuthService } from '../services/admin-auth.service'; const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']); @@ -13,7 +14,7 @@ const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']); templateUrl: './admin-login.component.html', styleUrl: './admin-login.component.scss' }) -export class AdminLoginComponent { +export class AdminLoginComponent implements OnDestroy { private readonly authService = inject(AdminAuthService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); @@ -21,9 +22,11 @@ export class AdminLoginComponent { password = ''; loading = false; errorMessage: string | null = null; + lockSecondsRemaining = 0; + private lockTimer: ReturnType | null = null; submit(): void { - if (!this.password.trim() || this.loading) { + if (!this.password.trim() || this.loading || this.lockSecondsRemaining > 0) { return; } @@ -31,24 +34,25 @@ export class AdminLoginComponent { this.errorMessage = null; this.authService.login(this.password).subscribe({ - next: (isAuthenticated) => { + next: (response: AdminAuthResponse) => { this.loading = false; - if (!isAuthenticated) { - this.errorMessage = 'Password non valida.'; + if (!response?.authenticated) { + this.handleLoginFailure(response?.retryAfterSeconds); return; } + this.clearLock(); const redirect = this.route.snapshot.queryParamMap.get('redirect'); if (redirect && redirect.startsWith('/')) { void this.router.navigateByUrl(redirect); return; } - void this.router.navigate(['/', this.resolveLang(), 'admin']); + void this.router.navigate(['/', this.resolveLang(), 'admin', 'orders']); }, - error: () => { + error: (error: HttpErrorResponse) => { this.loading = false; - this.errorMessage = 'Password non valida.'; + this.handleLoginFailure(this.extractRetryAfterSeconds(error)); } }); } @@ -62,4 +66,59 @@ export class AdminLoginComponent { } return 'it'; } + + private handleLoginFailure(retryAfterSeconds: number | undefined): void { + const timeout = this.normalizeTimeout(retryAfterSeconds); + this.errorMessage = 'Password non valida.'; + this.startLock(timeout); + } + + private extractRetryAfterSeconds(error: HttpErrorResponse): number { + const fromBody = Number(error?.error?.retryAfterSeconds); + if (Number.isFinite(fromBody) && fromBody > 0) { + return Math.floor(fromBody); + } + + const fromHeader = Number(error?.headers?.get('Retry-After')); + if (Number.isFinite(fromHeader) && fromHeader > 0) { + return Math.floor(fromHeader); + } + + return 2; + } + + private normalizeTimeout(value: number | undefined): number { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.floor(parsed); + } + return 2; + } + + private startLock(seconds: number): void { + this.lockSecondsRemaining = Math.max(this.lockSecondsRemaining, seconds); + this.stopTimer(); + this.lockTimer = setInterval(() => { + this.lockSecondsRemaining = Math.max(0, this.lockSecondsRemaining - 1); + if (this.lockSecondsRemaining === 0) { + this.stopTimer(); + } + }, 1000); + } + + private clearLock(): void { + this.lockSecondsRemaining = 0; + this.stopTimer(); + } + + private stopTimer(): void { + if (this.lockTimer !== null) { + clearInterval(this.lockTimer); + this.lockTimer = null; + } + } + + ngOnDestroy(): void { + this.stopTimer(); + } } diff --git a/frontend/src/app/features/admin/pages/admin-orders-past.component.html b/frontend/src/app/features/admin/pages/admin-orders-past.component.html new file mode 100644 index 0000000..ff9c777 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-orders-past.component.html @@ -0,0 +1,39 @@ +
+
+
+

Ordini pagati

+
+ +
+ +

{{ errorMessage }}

+ +
+ + + + + + + + + + + + + + + + + + + + + +
OrdineDataEmailStato ordineStato pagamentoTotale
{{ order.orderNumber }}{{ order.createdAt | date:'short' }}{{ order.customerEmail }}{{ order.status }}{{ order.paymentStatus || 'PENDING' }}{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}
+
+
+ + +

Caricamento ordini passati...

+
diff --git a/frontend/src/app/features/admin/pages/admin-orders-past.component.scss b/frontend/src/app/features/admin/pages/admin-orders-past.component.scss new file mode 100644 index 0000000..a293e62 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-orders-past.component.scss @@ -0,0 +1,59 @@ +.section-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + margin-bottom: var(--space-5); +} + +h2 { + margin: 0; +} + +p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); +} + +button { + border: 0; + border-radius: var(--radius-md); + background: var(--color-brand); + color: var(--color-neutral-900); + padding: var(--space-2) var(--space-4); + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; +} + +button:hover:not(:disabled) { + background: var(--color-brand-hover); +} + +.table-wrap { + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: var(--space-3); + border-bottom: 1px solid var(--color-border); +} + +.error { + color: var(--color-danger-500); +} diff --git a/frontend/src/app/features/admin/pages/admin-orders-past.component.ts b/frontend/src/app/features/admin/pages/admin-orders-past.component.ts new file mode 100644 index 0000000..0c02fbf --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-orders-past.component.ts @@ -0,0 +1,39 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; +import { AdminOrder, AdminOrdersService } from '../services/admin-orders.service'; + +@Component({ + selector: 'app-admin-orders-past', + standalone: true, + imports: [CommonModule], + templateUrl: './admin-orders-past.component.html', + styleUrl: './admin-orders-past.component.scss' +}) +export class AdminOrdersPastComponent implements OnInit { + private readonly adminOrdersService = inject(AdminOrdersService); + + orders: AdminOrder[] = []; + loading = false; + errorMessage: string | null = null; + + ngOnInit(): void { + this.loadOrders(); + } + + loadOrders(): void { + this.loading = true; + this.errorMessage = null; + this.adminOrdersService.listOrders().subscribe({ + next: (orders) => { + this.orders = orders.filter((order) => + order.paymentStatus === 'COMPLETED' || order.status !== 'PENDING_PAYMENT' + ); + this.loading = false; + }, + error: () => { + this.loading = false; + this.errorMessage = 'Impossibile caricare gli ordini passati.'; + } + }); + } +} diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.html b/frontend/src/app/features/admin/pages/admin-sessions.component.html new file mode 100644 index 0000000..31e1e0d --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.html @@ -0,0 +1,40 @@ +
+
+
+

Sessioni quote

+

Sessioni create dal configuratore con stato e conversione ordine.

+
+ +
+ +

{{ errorMessage }}

+ +
+ + + + + + + + + + + + + + + + + + + + + +
SessioneData creazioneScadenzaMaterialeStatoOrdine convertito
{{ session.id }}{{ session.createdAt | date:'short' }}{{ session.expiresAt | date:'short' }}{{ session.materialCode }}{{ session.status }}{{ session.convertedOrderId || '-' }}
+
+
+ + +

Caricamento sessioni...

+
diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.scss b/frontend/src/app/features/admin/pages/admin-sessions.component.scss new file mode 100644 index 0000000..a293e62 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.scss @@ -0,0 +1,59 @@ +.section-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + margin-bottom: var(--space-5); +} + +h2 { + margin: 0; +} + +p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); +} + +button { + border: 0; + border-radius: var(--radius-md); + background: var(--color-brand); + color: var(--color-neutral-900); + padding: var(--space-2) var(--space-4); + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; +} + +button:hover:not(:disabled) { + background: var(--color-brand-hover); +} + +.table-wrap { + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: var(--space-3); + border-bottom: 1px solid var(--color-border); +} + +.error { + color: var(--color-danger-500); +} diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.ts b/frontend/src/app/features/admin/pages/admin-sessions.component.ts new file mode 100644 index 0000000..c47b761 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.ts @@ -0,0 +1,37 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; +import { AdminOperationsService, AdminQuoteSession } from '../services/admin-operations.service'; + +@Component({ + selector: 'app-admin-sessions', + standalone: true, + imports: [CommonModule], + templateUrl: './admin-sessions.component.html', + styleUrl: './admin-sessions.component.scss' +}) +export class AdminSessionsComponent implements OnInit { + private readonly adminOperationsService = inject(AdminOperationsService); + + sessions: AdminQuoteSession[] = []; + loading = false; + errorMessage: string | null = null; + + ngOnInit(): void { + this.loadSessions(); + } + + loadSessions(): void { + this.loading = true; + this.errorMessage = null; + this.adminOperationsService.getSessions().subscribe({ + next: (sessions) => { + this.sessions = sessions; + this.loading = false; + }, + error: () => { + this.loading = false; + this.errorMessage = 'Impossibile caricare le sessioni.'; + } + }); + } +} diff --git a/frontend/src/app/features/admin/pages/admin-shell.component.html b/frontend/src/app/features/admin/pages/admin-shell.component.html new file mode 100644 index 0000000..7f3d0e2 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-shell.component.html @@ -0,0 +1,24 @@ +
+
+ + +
+ +
+
+
diff --git a/frontend/src/app/features/admin/pages/admin-shell.component.scss b/frontend/src/app/features/admin/pages/admin-shell.component.scss new file mode 100644 index 0000000..9e28d2a --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-shell.component.scss @@ -0,0 +1,120 @@ +.admin-container { + margin-top: var(--space-8); + max-width: min(1720px, 96vw); + padding: 0 var(--space-6); +} + +.admin-shell { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + min-height: calc(100vh - 220px); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + overflow: hidden; + background: var(--color-bg-card); + box-shadow: var(--shadow-sm); +} + +.sidebar { + background: var(--color-neutral-100); + color: var(--color-text); + padding: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-4); + border-right: 1px solid var(--color-border); +} + +.brand h1 { + margin: 0; + font-size: 1.15rem; +} + +.brand p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.menu { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.menu a { + text-decoration: none; + color: var(--color-text-muted); + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + font-weight: 600; + border: 1px solid var(--color-border); + background: var(--color-bg-card); + transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease; +} + +.menu a:hover { + border-color: var(--color-brand); + color: var(--color-text); +} + +.menu a.active { + background: #fff5b8; + color: var(--color-text); + border-color: var(--color-brand); +} + +.logout { + margin-top: auto; + border: 1px solid var(--color-border); + color: var(--color-text); + background: var(--color-bg-card); + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + font-weight: 600; + cursor: pointer; + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +.logout:hover { + border-color: var(--color-brand); + background: #fff8cc; +} + +.content { + background: var(--color-bg); + padding: var(--space-6); + min-width: 0; +} + +@media (max-width: 960px) { + .admin-container { + margin-top: var(--space-6); + padding: 0 var(--space-4); + } + + .admin-shell { + grid-template-columns: 1fr; + min-height: unset; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid var(--color-border); + padding: var(--space-4); + } + + .menu { + flex-direction: row; + flex-wrap: wrap; + } + + .logout { + margin-top: var(--space-2); + align-self: flex-start; + } + + .content { + padding: var(--space-4); + } +} diff --git a/frontend/src/app/features/admin/pages/admin-shell.component.ts b/frontend/src/app/features/admin/pages/admin-shell.component.ts new file mode 100644 index 0000000..7617b0c --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-shell.component.ts @@ -0,0 +1,40 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { AdminAuthService } from '../services/admin-auth.service'; + +const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']); + +@Component({ + selector: 'app-admin-shell', + standalone: true, + imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive], + templateUrl: './admin-shell.component.html', + styleUrl: './admin-shell.component.scss' +}) +export class AdminShellComponent { + private readonly adminAuthService = inject(AdminAuthService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + logout(): void { + this.adminAuthService.logout().subscribe({ + next: () => { + void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']); + }, + error: () => { + void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']); + } + }); + } + + private resolveLang(): string { + for (const level of this.route.pathFromRoot) { + const lang = level.snapshot.paramMap.get('lang'); + if (lang && SUPPORTED_LANGS.has(lang)) { + return lang; + } + } + return 'it'; + } +} diff --git a/frontend/src/app/features/admin/services/admin-auth.service.ts b/frontend/src/app/features/admin/services/admin-auth.service.ts index 30779f8..0eb7491 100644 --- a/frontend/src/app/features/admin/services/admin-auth.service.ts +++ b/frontend/src/app/features/admin/services/admin-auth.service.ts @@ -1,10 +1,13 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { map, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; import { environment } from '../../../../environments/environment'; -interface AdminAuthResponse { +export interface AdminAuthResponse { authenticated: boolean; + retryAfterSeconds?: number; + expiresInMinutes?: number; } @Injectable({ @@ -14,10 +17,8 @@ export class AdminAuthService { private readonly http = inject(HttpClient); private readonly baseUrl = `${environment.apiUrl}/api/admin/auth`; - login(password: string): Observable { - return this.http.post(`${this.baseUrl}/login`, { password }, { withCredentials: true }).pipe( - map((response) => Boolean(response?.authenticated)) - ); + login(password: string): Observable { + return this.http.post(`${this.baseUrl}/login`, { password }, { withCredentials: true }); } logout(): Observable { diff --git a/frontend/src/app/features/admin/services/admin-operations.service.ts b/frontend/src/app/features/admin/services/admin-operations.service.ts new file mode 100644 index 0000000..c18b684 --- /dev/null +++ b/frontend/src/app/features/admin/services/admin-operations.service.ts @@ -0,0 +1,56 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; + +export interface AdminFilamentStockRow { + filamentVariantId: number; + materialCode: string; + variantDisplayName: string; + colorName: string; + stockSpools: number; + spoolNetKg: number; + stockKg: number; + active: boolean; +} + +export interface AdminContactRequest { + id: string; + requestType: string; + customerType: string; + email: string; + phone?: string; + name?: string; + companyName?: string; + status: string; + createdAt: string; +} + +export interface AdminQuoteSession { + id: string; + status: string; + materialCode: string; + createdAt: string; + expiresAt: string; + convertedOrderId?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class AdminOperationsService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/api/admin`; + + getFilamentStock(): Observable { + return this.http.get(`${this.baseUrl}/filament-stock`, { withCredentials: true }); + } + + getContactRequests(): Observable { + return this.http.get(`${this.baseUrl}/contact-requests`, { withCredentials: true }); + } + + getSessions(): Observable { + return this.http.get(`${this.baseUrl}/sessions`, { withCredentials: true }); + } +} diff --git a/frontend/src/app/features/admin/services/admin-orders.service.ts b/frontend/src/app/features/admin/services/admin-orders.service.ts index e9d163d..392f10f 100644 --- a/frontend/src/app/features/admin/services/admin-orders.service.ts +++ b/frontend/src/app/features/admin/services/admin-orders.service.ts @@ -24,9 +24,19 @@ export interface AdminOrder { customerEmail: string; totalChf: number; createdAt: string; + printMaterialCode?: string; + printNozzleDiameterMm?: number; + printLayerHeightMm?: number; + printInfillPattern?: string; + printInfillPercent?: number; + printSupportsEnabled?: boolean; items: AdminOrderItem[]; } +export interface AdminUpdateOrderStatusPayload { + status: string; +} + @Injectable({ providedIn: 'root' }) @@ -42,7 +52,32 @@ export class AdminOrdersService { return this.http.get(`${this.baseUrl}/${orderId}`, { withCredentials: true }); } - confirmPayment(orderId: string): Observable { - return this.http.post(`${this.baseUrl}/${orderId}/payments/confirm`, {}, { withCredentials: true }); + confirmPayment(orderId: string, method: string): Observable { + return this.http.post(`${this.baseUrl}/${orderId}/payments/confirm`, { method }, { withCredentials: true }); + } + + updateOrderStatus(orderId: string, payload: AdminUpdateOrderStatusPayload): Observable { + return this.http.post(`${this.baseUrl}/${orderId}/status`, payload, { withCredentials: true }); + } + + downloadOrderItemFile(orderId: string, orderItemId: string): Observable { + return this.http.get(`${this.baseUrl}/${orderId}/items/${orderItemId}/file`, { + withCredentials: true, + responseType: 'blob' + }); + } + + downloadOrderConfirmation(orderId: string): Observable { + return this.http.get(`${this.baseUrl}/${orderId}/documents/confirmation`, { + withCredentials: true, + responseType: 'blob' + }); + } + + downloadOrderInvoice(orderId: string): Observable { + return this.http.get(`${this.baseUrl}/${orderId}/documents/invoice`, { + withCredentials: true, + responseType: 'blob' + }); } }