diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index f83cf59..8c1373b 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -1,146 +1,62 @@ package com.printcalculator.controller; -import com.printcalculator.dto.*; -import com.printcalculator.entity.*; -import com.printcalculator.repository.*; -import com.printcalculator.service.payment.InvoicePdfRenderingService; -import com.printcalculator.service.OrderService; -import com.printcalculator.service.payment.PaymentService; -import com.printcalculator.service.payment.QrBillService; -import com.printcalculator.service.storage.StorageService; -import com.printcalculator.service.payment.TwintPaymentService; +import com.printcalculator.dto.CreateOrderRequest; +import com.printcalculator.dto.OrderDto; +import com.printcalculator.service.order.OrderControllerService; +import jakarta.validation.Valid; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.util.StringUtils; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.*; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import jakarta.validation.Valid; import java.io.IOException; - -import java.nio.file.InvalidPathException; -import java.nio.file.Path; - -import java.util.List; -import java.util.UUID; import java.util.Map; -import java.util.HashMap; -import java.util.Base64; -import java.util.Set; -import java.util.stream.Collectors; -import java.net.URI; -import java.util.Locale; -import java.util.regex.Pattern; +import java.util.UUID; @RestController @RequestMapping("/api/orders") public class OrderController { - private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); - private static final Set PERSONAL_DATA_REDACTED_STATUSES = Set.of( - "IN_PRODUCTION", - "SHIPPED", - "COMPLETED" - ); - private final OrderService orderService; - private final OrderRepository orderRepo; - private final OrderItemRepository orderItemRepo; - private final QuoteSessionRepository quoteSessionRepo; - private final QuoteLineItemRepository quoteLineItemRepo; - private final CustomerRepository customerRepo; - private final StorageService storageService; - private final InvoicePdfRenderingService invoiceService; - private final QrBillService qrBillService; - private final TwintPaymentService twintPaymentService; - private final PaymentService paymentService; - private final PaymentRepository paymentRepo; + private final OrderControllerService orderControllerService; - - public OrderController(OrderService orderService, - OrderRepository orderRepo, - OrderItemRepository orderItemRepo, - QuoteSessionRepository quoteSessionRepo, - QuoteLineItemRepository quoteLineItemRepo, - CustomerRepository customerRepo, - StorageService storageService, - InvoicePdfRenderingService invoiceService, - QrBillService qrBillService, - TwintPaymentService twintPaymentService, - PaymentService paymentService, - PaymentRepository paymentRepo) { - this.orderService = orderService; - this.orderRepo = orderRepo; - this.orderItemRepo = orderItemRepo; - this.quoteSessionRepo = quoteSessionRepo; - this.quoteLineItemRepo = quoteLineItemRepo; - this.customerRepo = customerRepo; - this.storageService = storageService; - this.invoiceService = invoiceService; - this.qrBillService = qrBillService; - this.twintPaymentService = twintPaymentService; - this.paymentService = paymentService; - this.paymentRepo = paymentRepo; + public OrderController(OrderControllerService orderControllerService) { + this.orderControllerService = orderControllerService; } - - // 1. Create Order from Quote @PostMapping("/from-quote/{quoteSessionId}") @Transactional public ResponseEntity createOrderFromQuote( @PathVariable UUID quoteSessionId, - @Valid @RequestBody com.printcalculator.dto.CreateOrderRequest request + @Valid @RequestBody CreateOrderRequest request ) { - Order order = orderService.createOrderFromQuote(quoteSessionId, request); - List items = orderItemRepo.findByOrder_Id(order.getId()); - return ResponseEntity.ok(convertToDto(order, items)); + return ResponseEntity.ok(orderControllerService.createOrderFromQuote(quoteSessionId, request)); } - + @PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Transactional public ResponseEntity uploadOrderItemFile( - @PathVariable UUID orderId, - @PathVariable UUID orderItemId, - @RequestParam("file") MultipartFile file + @PathVariable UUID orderId, + @PathVariable UUID orderItemId, + @RequestParam("file") MultipartFile file ) throws IOException { - - OrderItem item = orderItemRepo.findById(orderItemId) - .orElseThrow(() -> new RuntimeException("OrderItem not found")); - - if (!item.getOrder().getId().equals(orderId)) { + boolean uploaded = orderControllerService.uploadOrderItemFile(orderId, orderItemId, file); + if (!uploaded) { return ResponseEntity.badRequest().build(); } - - String relativePath = item.getStoredRelativePath(); - Path destinationRelativePath; - if (relativePath == null || relativePath.equals("PENDING")) { - String ext = getExtension(file.getOriginalFilename()); - String storedFilename = UUID.randomUUID() + "." + ext; - destinationRelativePath = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString(), storedFilename); - item.setStoredRelativePath(destinationRelativePath.toString()); - item.setStoredFilename(storedFilename); - } else { - destinationRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId); - if (destinationRelativePath == null) { - return ResponseEntity.badRequest().build(); - } - } - - storageService.store(file, destinationRelativePath); - item.setFileSizeBytes(file.getSize()); - item.setMimeType(file.getContentType()); - orderItemRepo.save(item); - return ResponseEntity.ok().build(); } @GetMapping("/{orderId}") public ResponseEntity getOrder(@PathVariable UUID orderId) { - return orderRepo.findById(orderId) - .map(o -> { - List items = orderItemRepo.findByOrder_Id(o.getId()); - return ResponseEntity.ok(convertToDto(o, items)); - }) + return orderControllerService.getOrder(orderId) + .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } @@ -150,89 +66,29 @@ public class OrderController { @PathVariable UUID orderId, @RequestBody Map payload ) { - String method = payload.get("method"); - paymentService.reportPayment(orderId, method); - return getOrder(orderId); + return orderControllerService.reportPayment(orderId, payload.get("method")) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); } @GetMapping("/{orderId}/confirmation") public ResponseEntity getConfirmation(@PathVariable UUID orderId) { - return generateDocument(orderId, true); + return orderControllerService.getConfirmation(orderId); } @GetMapping("/{orderId}/invoice") public ResponseEntity getInvoice(@PathVariable UUID orderId) { - // Paid invoices are sent by email after back-office payment confirmation. - // The public endpoint must not expose a "paid" invoice download. return ResponseEntity.notFound().build(); } - private ResponseEntity generateDocument(UUID orderId, boolean isConfirmation) { - Order order = orderRepo.findById(orderId) - .orElseThrow(() -> new RuntimeException("Order not found")); - - if (isConfirmation) { - Path relativePath = buildConfirmationPdfRelativePath(order); - try { - byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes(); - return ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=\"confirmation-" + getDisplayOrderNumber(order) + ".pdf\"") - .contentType(MediaType.APPLICATION_PDF) - .body(existingPdf); - } catch (Exception ignored) { - // Fallback to on-the-fly generation if the stored file is missing or unreadable. - } - } - - List items = orderItemRepo.findByOrder_Id(orderId); - Payment payment = paymentRepo.findByOrder_Id(orderId).orElse(null); - - byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment); - String typePrefix = isConfirmation ? "confirmation-" : "invoice-"; - String truncatedUuid = order.getId().toString().substring(0, 8); - return ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=\"" + typePrefix + truncatedUuid + ".pdf\"") - .contentType(MediaType.APPLICATION_PDF) - .body(pdf); - } - - private Path buildConfirmationPdfRelativePath(Order order) { - return Path.of( - "orders", - order.getId().toString(), - "documents", - "confirmation-" + getDisplayOrderNumber(order) + ".pdf" - ); - } - @GetMapping("/{orderId}/twint") public ResponseEntity> getTwintPayment(@PathVariable UUID orderId) { - Order order = orderRepo.findById(orderId).orElse(null); - if (order == null) { - return ResponseEntity.notFound().build(); - } - - byte[] qrPng = twintPaymentService.generateQrPng(order, 360); - String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng); - - Map data = new HashMap<>(); - data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl(order)); - data.put("openUrl", "/api/orders/" + orderId + "/twint/open"); - data.put("qrImageUrl", "/api/orders/" + orderId + "/twint/qr"); - data.put("qrImageDataUri", qrDataUri); - return ResponseEntity.ok(data); + return orderControllerService.getTwintPayment(orderId); } @GetMapping("/{orderId}/twint/open") public ResponseEntity openTwintPayment(@PathVariable UUID orderId) { - Order order = orderRepo.findById(orderId).orElse(null); - if (order == null) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.status(302) - .location(URI.create(twintPaymentService.getTwintPaymentUrl(order))) - .build(); + return orderControllerService.openTwintPayment(orderId); } @GetMapping("/{orderId}/twint/qr") @@ -240,150 +96,6 @@ public class OrderController { @PathVariable UUID orderId, @RequestParam(defaultValue = "320") int size ) { - Order order = orderRepo.findById(orderId).orElse(null); - if (order == null) { - return ResponseEntity.notFound().build(); - } - - int normalizedSize = Math.max(200, Math.min(size, 600)); - byte[] png = twintPaymentService.generateQrPng(order, normalizedSize); - - return ResponseEntity.ok() - .contentType(MediaType.IMAGE_PNG) - .body(png); + return orderControllerService.getTwintQr(orderId, size); } - - private String getExtension(String filename) { - if (filename == null) return "stl"; - String cleaned = StringUtils.cleanPath(filename); - if (cleaned.contains("..")) { - return "stl"; - } - int i = cleaned.lastIndexOf('.'); - if (i > 0 && i < cleaned.length() - 1) { - String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT); - if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) { - return ext; - } - } - return "stl"; - } - - private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) { - try { - Path candidate = Path.of(storedRelativePath).normalize(); - if (candidate.isAbsolute()) { - return null; - } - - Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString()); - if (!candidate.startsWith(expectedPrefix)) { - return null; - } - - return candidate; - } catch (InvalidPathException e) { - return null; - } - } - - private OrderDto convertToDto(Order order, List items) { - 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()); - }); - - boolean redactPersonalData = shouldRedactPersonalData(order.getStatus()); - if (!redactPersonalData) { - dto.setCustomerEmail(order.getCustomerEmail()); - dto.setCustomerPhone(order.getCustomerPhone()); - dto.setBillingCustomerType(order.getBillingCustomerType()); - } - dto.setPreferredLanguage(order.getPreferredLanguage()); - dto.setCurrency(order.getCurrency()); - dto.setSetupCostChf(order.getSetupCostChf()); - dto.setShippingCostChf(order.getShippingCostChf()); - dto.setDiscountChf(order.getDiscountChf()); - dto.setSubtotalChf(order.getSubtotalChf()); - dto.setIsCadOrder(order.getIsCadOrder()); - dto.setSourceRequestId(order.getSourceRequestId()); - dto.setCadHours(order.getCadHours()); - dto.setCadHourlyRateChf(order.getCadHourlyRateChf()); - dto.setCadTotalChf(order.getCadTotalChf()); - dto.setTotalChf(order.getTotalChf()); - dto.setCreatedAt(order.getCreatedAt()); - dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); - - if (!redactPersonalData) { - 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 (!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.setQuality(i.getQuality()); - idto.setNozzleDiameterMm(i.getNozzleDiameterMm()); - idto.setLayerHeightMm(i.getLayerHeightMm()); - idto.setInfillPercent(i.getInfillPercent()); - idto.setInfillPattern(i.getInfillPattern()); - idto.setSupportsEnabled(i.getSupportsEnabled()); - idto.setQuantity(i.getQuantity()); - idto.setPrintTimeSeconds(i.getPrintTimeSeconds()); - idto.setMaterialGrams(i.getMaterialGrams()); - idto.setUnitPriceChf(i.getUnitPriceChf()); - idto.setLineTotalChf(i.getLineTotalChf()); - return idto; - }).collect(Collectors.toList()); - dto.setItems(itemDtos); - - return dto; - } - - private boolean shouldRedactPersonalData(String status) { - if (status == null || status.isBlank()) { - return false; - } - return PERSONAL_DATA_REDACTED_STATUSES.contains(status.trim().toUpperCase(Locale.ROOT)); - } - - 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/admin/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java index 9508b5f..797310e 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java @@ -1,25 +1,9 @@ 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.*; -import com.printcalculator.event.OrderShippedEvent; -import com.printcalculator.repository.OrderItemRepository; -import com.printcalculator.repository.OrderRepository; -import com.printcalculator.repository.PaymentRepository; -import com.printcalculator.repository.QuoteLineItemRepository; -import com.printcalculator.service.payment.InvoicePdfRenderingService; -import com.printcalculator.service.payment.PaymentService; -import com.printcalculator.service.payment.QrBillService; -import com.printcalculator.service.storage.StorageService; -import org.springframework.context.ApplicationEventPublisher; +import com.printcalculator.service.order.AdminOrderControllerService; import org.springframework.core.io.Resource; -import org.springframework.core.io.UrlResource; -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; @@ -28,80 +12,30 @@ 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.Files; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Comparator; 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 Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); - 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 QuoteLineItemRepository quoteLineItemRepo; - private final PaymentService paymentService; - private final StorageService storageService; - private final InvoicePdfRenderingService invoiceService; - private final QrBillService qrBillService; - private final ApplicationEventPublisher eventPublisher; + private final AdminOrderControllerService adminOrderControllerService; - public AdminOrderController( - OrderRepository orderRepo, - OrderItemRepository orderItemRepo, - PaymentRepository paymentRepo, - QuoteLineItemRepository quoteLineItemRepo, - PaymentService paymentService, - StorageService storageService, - InvoicePdfRenderingService invoiceService, - QrBillService qrBillService, - ApplicationEventPublisher eventPublisher - ) { - this.orderRepo = orderRepo; - this.orderItemRepo = orderItemRepo; - this.paymentRepo = paymentRepo; - this.quoteLineItemRepo = quoteLineItemRepo; - this.paymentService = paymentService; - this.storageService = storageService; - this.invoiceService = invoiceService; - this.qrBillService = qrBillService; - this.eventPublisher = eventPublisher; + public AdminOrderController(AdminOrderControllerService adminOrderControllerService) { + this.adminOrderControllerService = adminOrderControllerService; } @GetMapping public ResponseEntity> listOrders() { - List response = orderRepo.findAllByOrderByCreatedAtDesc() - .stream() - .map(this::toOrderDto) - .toList(); - return ResponseEntity.ok(response); + return ResponseEntity.ok(adminOrderControllerService.listOrders()); } @GetMapping("/{orderId}") public ResponseEntity getOrder(@PathVariable UUID orderId) { - return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); + return ResponseEntity.ok(adminOrderControllerService.getOrder(orderId)); } @PostMapping("/{orderId}/payments/confirm") @@ -110,13 +44,7 @@ public class AdminOrderController { @PathVariable UUID orderId, @RequestBody(required = false) Map payload ) { - getOrderOrThrow(orderId); - String method = payload != null ? payload.get("method") : null; - if (method == null || method.isBlank()) { - throw new ResponseStatusException(BAD_REQUEST, "Payment method is required"); - } - paymentService.updatePaymentMethod(orderId, method); - return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); + return ResponseEntity.ok(adminOrderControllerService.updatePaymentMethod(orderId, payload)); } @PostMapping("/{orderId}/status") @@ -125,28 +53,7 @@ public class AdminOrderController { @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) - ); - } - String previousStatus = order.getStatus(); - order.setStatus(normalizedStatus); - Order savedOrder = orderRepo.save(order); - - // Notify customer only on transition to SHIPPED. - if (!"SHIPPED".equals(previousStatus) && "SHIPPED".equals(normalizedStatus)) { - eventPublisher.publishEvent(new OrderShippedEvent(this, savedOrder)); - } - - return ResponseEntity.ok(toOrderDto(savedOrder)); + return ResponseEntity.ok(adminOrderControllerService.updateOrderStatus(orderId, payload)); } @GetMapping("/{orderId}/items/{orderItemId}/file") @@ -154,290 +61,16 @@ public class AdminOrderController { @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"); - } - Path safeRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId); - if (safeRelativePath == null) { - throw new ResponseStatusException(NOT_FOUND, "File not available"); - } - - try { - Resource resource = storageService.loadAsResource(safeRelativePath); - 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"); - } + return adminOrderControllerService.downloadOrderItemFile(orderId, orderItemId); } @GetMapping("/{orderId}/documents/confirmation") public ResponseEntity downloadOrderConfirmation(@PathVariable UUID orderId) { - return generateDocument(getOrderOrThrow(orderId), true); + return adminOrderControllerService.downloadOrderConfirmation(orderId); } @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.setIsCadOrder(order.getIsCadOrder()); - dto.setSourceRequestId(order.getSourceRequestId()); - dto.setCadHours(order.getCadHours()); - dto.setCadHourlyRateChf(order.getCadHourlyRateChf()); - dto.setCadTotalChf(order.getCadTotalChf()); - 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) { - Path relativePath = buildConfirmationPdfRelativePath(order.getId(), displayOrderNumber); - try { - byte[] existingPdf = storageService.loadAsResource(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); - } - - private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) { - try { - Path candidate = Path.of(storedRelativePath).normalize(); - if (candidate.isAbsolute()) { - return null; - } - Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString()); - if (!candidate.startsWith(expectedPrefix)) { - return null; - } - return candidate; - } catch (InvalidPathException e) { - return null; - } - } - - private Resource loadOrderItemResourceWithRecovery(OrderItem item, Path safeRelativePath) { - try { - return storageService.loadAsResource(safeRelativePath); - } catch (Exception primaryFailure) { - Path sourceQuotePath = resolveFallbackQuoteItemPath(item); - if (sourceQuotePath == null) { - throw new ResponseStatusException(NOT_FOUND, "File not available"); - } - try { - storageService.store(sourceQuotePath, safeRelativePath); - return storageService.loadAsResource(safeRelativePath); - } catch (Exception copyFailure) { - try { - Resource quoteResource = new UrlResource(sourceQuotePath.toUri()); - if (quoteResource.exists() || quoteResource.isReadable()) { - return quoteResource; - } - } catch (Exception ignored) { - // fall through to 404 - } - throw new ResponseStatusException(NOT_FOUND, "File not available"); - } - } - } - - private Path resolveFallbackQuoteItemPath(OrderItem orderItem) { - Order order = orderItem.getOrder(); - QuoteSession sourceSession = order != null ? order.getSourceQuoteSession() : null; - UUID sourceSessionId = sourceSession != null ? sourceSession.getId() : null; - if (sourceSessionId == null) { - return null; - } - - String targetFilename = normalizeFilename(orderItem.getOriginalFilename()); - if (targetFilename == null) { - return null; - } - - return quoteLineItemRepo.findByQuoteSessionId(sourceSessionId).stream() - .filter(q -> targetFilename.equals(normalizeFilename(q.getOriginalFilename()))) - .sorted(Comparator.comparingInt((QuoteLineItem q) -> scoreQuoteMatch(orderItem, q)).reversed()) - .map(q -> resolveStoredQuotePath(q.getStoredPath(), sourceSessionId)) - .filter(path -> path != null && Files.exists(path)) - .findFirst() - .orElse(null); - } - - private int scoreQuoteMatch(OrderItem orderItem, QuoteLineItem quoteItem) { - int score = 0; - if (orderItem.getQuantity() != null && orderItem.getQuantity().equals(quoteItem.getQuantity())) { - score += 4; - } - if (orderItem.getPrintTimeSeconds() != null && orderItem.getPrintTimeSeconds().equals(quoteItem.getPrintTimeSeconds())) { - score += 3; - } - if (orderItem.getMaterialCode() != null - && quoteItem.getMaterialCode() != null - && orderItem.getMaterialCode().equalsIgnoreCase(quoteItem.getMaterialCode())) { - score += 3; - } - if (orderItem.getMaterialGrams() != null - && quoteItem.getMaterialGrams() != null - && orderItem.getMaterialGrams().compareTo(quoteItem.getMaterialGrams()) == 0) { - score += 2; - } - return score; - } - - private String normalizeFilename(String filename) { - if (filename == null || filename.isBlank()) { - return null; - } - return filename.trim(); - } - - private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) { - if (storedPath == null || storedPath.isBlank()) { - return null; - } - try { - Path raw = Path.of(storedPath).normalize(); - Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize(); - Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize(); - if (!resolved.startsWith(expectedSessionRoot)) { - return null; - } - return resolved; - } catch (InvalidPathException e) { - return null; - } - } - - private Path buildConfirmationPdfRelativePath(UUID orderId, String orderNumber) { - return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf"); + return adminOrderControllerService.downloadOrderInvoice(orderId); } } diff --git a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java new file mode 100644 index 0000000..22327e0 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java @@ -0,0 +1,423 @@ +package com.printcalculator.service.order; + +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.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.event.OrderShippedEvent; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +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; + +@Service +@Transactional(readOnly = true) +public class AdminOrderControllerService { + private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); + 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 QuoteLineItemRepository quoteLineItemRepo; + private final PaymentService paymentService; + private final StorageService storageService; + private final InvoicePdfRenderingService invoiceService; + private final QrBillService qrBillService; + private final ApplicationEventPublisher eventPublisher; + + public AdminOrderControllerService(OrderRepository orderRepo, + OrderItemRepository orderItemRepo, + PaymentRepository paymentRepo, + QuoteLineItemRepository quoteLineItemRepo, + PaymentService paymentService, + StorageService storageService, + InvoicePdfRenderingService invoiceService, + QrBillService qrBillService, + ApplicationEventPublisher eventPublisher) { + this.orderRepo = orderRepo; + this.orderItemRepo = orderItemRepo; + this.paymentRepo = paymentRepo; + this.quoteLineItemRepo = quoteLineItemRepo; + this.paymentService = paymentService; + this.storageService = storageService; + this.invoiceService = invoiceService; + this.qrBillService = qrBillService; + this.eventPublisher = eventPublisher; + } + + public List listOrders() { + return orderRepo.findAllByOrderByCreatedAtDesc() + .stream() + .map(this::toOrderDto) + .toList(); + } + + public OrderDto getOrder(UUID orderId) { + return toOrderDto(getOrderOrThrow(orderId)); + } + + @Transactional + public OrderDto updatePaymentMethod(UUID orderId, Map payload) { + getOrderOrThrow(orderId); + String method = payload != null ? payload.get("method") : null; + if (method == null || method.isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Payment method is required"); + } + paymentService.updatePaymentMethod(orderId, method); + return toOrderDto(getOrderOrThrow(orderId)); + } + + @Transactional + public OrderDto updateOrderStatus(UUID orderId, AdminOrderStatusUpdateRequest payload) { + if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) { + throw new ResponseStatusException(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) + ); + } + String previousStatus = order.getStatus(); + order.setStatus(normalizedStatus); + Order savedOrder = orderRepo.save(order); + + if (!"SHIPPED".equals(previousStatus) && "SHIPPED".equals(normalizedStatus)) { + eventPublisher.publishEvent(new OrderShippedEvent(this, savedOrder)); + } + + return toOrderDto(savedOrder); + } + + public ResponseEntity downloadOrderItemFile(UUID orderId, 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"); + } + Path safeRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId); + if (safeRelativePath == null) { + throw new ResponseStatusException(NOT_FOUND, "File not available"); + } + + try { + Resource resource = loadOrderItemResourceWithRecovery(item, safeRelativePath); + 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 (ResponseStatusException e) { + throw e; + } catch (Exception e) { + throw new ResponseStatusException(NOT_FOUND, "File not available"); + } + } + + public ResponseEntity downloadOrderConfirmation(UUID orderId) { + return generateDocument(getOrderOrThrow(orderId), true); + } + + public ResponseEntity downloadOrderInvoice(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(payment -> { + dto.setPaymentStatus(payment.getStatus()); + dto.setPaymentMethod(payment.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.setIsCadOrder(order.getIsCadOrder()); + dto.setSourceRequestId(order.getSourceRequestId()); + dto.setCadHours(order.getCadHours()); + dto.setCadHourlyRateChf(order.getCadHourlyRateChf()); + dto.setCadTotalChf(order.getCadTotalChf()); + 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(item -> { + OrderItemDto itemDto = new OrderItemDto(); + itemDto.setId(item.getId()); + itemDto.setOriginalFilename(item.getOriginalFilename()); + itemDto.setMaterialCode(item.getMaterialCode()); + itemDto.setColorCode(item.getColorCode()); + itemDto.setQuantity(item.getQuantity()); + itemDto.setPrintTimeSeconds(item.getPrintTimeSeconds()); + itemDto.setMaterialGrams(item.getMaterialGrams()); + itemDto.setUnitPriceChf(item.getUnitPriceChf()); + itemDto.setLineTotalChf(item.getLineTotalChf()); + return itemDto; + }).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) { + Path relativePath = buildConfirmationPdfRelativePath(order.getId(), displayOrderNumber); + try { + byte[] existingPdf = storageService.loadAsResource(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); + } + + private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) { + try { + Path candidate = Path.of(storedRelativePath).normalize(); + if (candidate.isAbsolute()) { + return null; + } + Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString()); + if (!candidate.startsWith(expectedPrefix)) { + return null; + } + return candidate; + } catch (InvalidPathException e) { + return null; + } + } + + private Resource loadOrderItemResourceWithRecovery(OrderItem item, Path safeRelativePath) { + try { + return storageService.loadAsResource(safeRelativePath); + } catch (Exception primaryFailure) { + Path sourceQuotePath = resolveFallbackQuoteItemPath(item); + if (sourceQuotePath == null) { + throw new ResponseStatusException(NOT_FOUND, "File not available"); + } + try { + storageService.store(sourceQuotePath, safeRelativePath); + return storageService.loadAsResource(safeRelativePath); + } catch (Exception copyFailure) { + try { + Resource quoteResource = new UrlResource(sourceQuotePath.toUri()); + if (quoteResource.exists() || quoteResource.isReadable()) { + return quoteResource; + } + } catch (Exception ignored) { + // fall through to 404 + } + throw new ResponseStatusException(NOT_FOUND, "File not available"); + } + } + } + + private Path resolveFallbackQuoteItemPath(OrderItem orderItem) { + Order order = orderItem.getOrder(); + QuoteSession sourceSession = order != null ? order.getSourceQuoteSession() : null; + UUID sourceSessionId = sourceSession != null ? sourceSession.getId() : null; + if (sourceSessionId == null) { + return null; + } + + String targetFilename = normalizeFilename(orderItem.getOriginalFilename()); + if (targetFilename == null) { + return null; + } + + return quoteLineItemRepo.findByQuoteSessionId(sourceSessionId).stream() + .filter(quoteItem -> targetFilename.equals(normalizeFilename(quoteItem.getOriginalFilename()))) + .sorted(Comparator.comparingInt((QuoteLineItem quoteItem) -> scoreQuoteMatch(orderItem, quoteItem)).reversed()) + .map(quoteItem -> resolveStoredQuotePath(quoteItem.getStoredPath(), sourceSessionId)) + .filter(path -> path != null && Files.exists(path)) + .findFirst() + .orElse(null); + } + + private int scoreQuoteMatch(OrderItem orderItem, QuoteLineItem quoteItem) { + int score = 0; + if (orderItem.getQuantity() != null && orderItem.getQuantity().equals(quoteItem.getQuantity())) { + score += 4; + } + if (orderItem.getPrintTimeSeconds() != null && orderItem.getPrintTimeSeconds().equals(quoteItem.getPrintTimeSeconds())) { + score += 3; + } + if (orderItem.getMaterialCode() != null + && quoteItem.getMaterialCode() != null + && orderItem.getMaterialCode().equalsIgnoreCase(quoteItem.getMaterialCode())) { + score += 3; + } + if (orderItem.getMaterialGrams() != null + && quoteItem.getMaterialGrams() != null + && orderItem.getMaterialGrams().compareTo(quoteItem.getMaterialGrams()) == 0) { + score += 2; + } + return score; + } + + private String normalizeFilename(String filename) { + if (filename == null || filename.isBlank()) { + return null; + } + return filename.trim(); + } + + private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) { + if (storedPath == null || storedPath.isBlank()) { + return null; + } + try { + Path raw = Path.of(storedPath).normalize(); + Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize(); + Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize(); + if (!resolved.startsWith(expectedSessionRoot)) { + return null; + } + return resolved; + } catch (InvalidPathException e) { + return null; + } + } + + private Path buildConfirmationPdfRelativePath(UUID orderId, String orderNumber) { + return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf"); + } +} diff --git a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java new file mode 100644 index 0000000..03d6163 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java @@ -0,0 +1,352 @@ +package com.printcalculator.service.order; + +import com.printcalculator.dto.AddressDto; +import com.printcalculator.dto.CreateOrderRequest; +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.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.service.OrderService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.payment.TwintPaymentService; +import com.printcalculator.service.storage.StorageService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class OrderControllerService { + private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); + private static final Set PERSONAL_DATA_REDACTED_STATUSES = Set.of( + "IN_PRODUCTION", + "SHIPPED", + "COMPLETED" + ); + + private final OrderService orderService; + private final OrderRepository orderRepo; + private final OrderItemRepository orderItemRepo; + private final StorageService storageService; + private final InvoicePdfRenderingService invoiceService; + private final QrBillService qrBillService; + private final TwintPaymentService twintPaymentService; + private final PaymentService paymentService; + private final PaymentRepository paymentRepo; + + public OrderControllerService(OrderService orderService, + OrderRepository orderRepo, + OrderItemRepository orderItemRepo, + StorageService storageService, + InvoicePdfRenderingService invoiceService, + QrBillService qrBillService, + TwintPaymentService twintPaymentService, + PaymentService paymentService, + PaymentRepository paymentRepo) { + this.orderService = orderService; + this.orderRepo = orderRepo; + this.orderItemRepo = orderItemRepo; + this.storageService = storageService; + this.invoiceService = invoiceService; + this.qrBillService = qrBillService; + this.twintPaymentService = twintPaymentService; + this.paymentService = paymentService; + this.paymentRepo = paymentRepo; + } + + @Transactional + public OrderDto createOrderFromQuote(UUID quoteSessionId, CreateOrderRequest request) { + Order order = orderService.createOrderFromQuote(quoteSessionId, request); + List items = orderItemRepo.findByOrder_Id(order.getId()); + return convertToDto(order, items); + } + + @Transactional + public boolean uploadOrderItemFile(UUID orderId, UUID orderItemId, MultipartFile file) throws IOException { + OrderItem item = orderItemRepo.findById(orderItemId) + .orElseThrow(() -> new RuntimeException("OrderItem not found")); + + if (!item.getOrder().getId().equals(orderId)) { + return false; + } + + String relativePath = item.getStoredRelativePath(); + Path destinationRelativePath; + if (relativePath == null || relativePath.equals("PENDING")) { + String ext = getExtension(file.getOriginalFilename()); + String storedFilename = UUID.randomUUID() + "." + ext; + destinationRelativePath = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString(), storedFilename); + item.setStoredRelativePath(destinationRelativePath.toString()); + item.setStoredFilename(storedFilename); + } else { + destinationRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId); + if (destinationRelativePath == null) { + return false; + } + } + + storageService.store(file, destinationRelativePath); + item.setFileSizeBytes(file.getSize()); + item.setMimeType(file.getContentType()); + orderItemRepo.save(item); + + return true; + } + + public Optional getOrder(UUID orderId) { + return orderRepo.findById(orderId) + .map(order -> { + List items = orderItemRepo.findByOrder_Id(order.getId()); + return convertToDto(order, items); + }); + } + + @Transactional + public Optional reportPayment(UUID orderId, String method) { + paymentService.reportPayment(orderId, method); + return getOrder(orderId); + } + + public ResponseEntity getConfirmation(UUID orderId) { + return generateDocument(orderId, true); + } + + public ResponseEntity> getTwintPayment(UUID orderId) { + Order order = orderRepo.findById(orderId).orElse(null); + if (order == null) { + return ResponseEntity.notFound().build(); + } + + byte[] qrPng = twintPaymentService.generateQrPng(order, 360); + String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng); + + Map data = new HashMap<>(); + data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl(order)); + data.put("openUrl", "/api/orders/" + orderId + "/twint/open"); + data.put("qrImageUrl", "/api/orders/" + orderId + "/twint/qr"); + data.put("qrImageDataUri", qrDataUri); + return ResponseEntity.ok(data); + } + + public ResponseEntity openTwintPayment(UUID orderId) { + Order order = orderRepo.findById(orderId).orElse(null); + if (order == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.status(302) + .location(URI.create(twintPaymentService.getTwintPaymentUrl(order))) + .build(); + } + + public ResponseEntity getTwintQr(UUID orderId, int size) { + Order order = orderRepo.findById(orderId).orElse(null); + if (order == null) { + return ResponseEntity.notFound().build(); + } + + int normalizedSize = Math.max(200, Math.min(size, 600)); + byte[] png = twintPaymentService.generateQrPng(order, normalizedSize); + + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_PNG) + .body(png); + } + + private ResponseEntity generateDocument(UUID orderId, boolean isConfirmation) { + Order order = orderRepo.findById(orderId) + .orElseThrow(() -> new RuntimeException("Order not found")); + + if (isConfirmation) { + Path relativePath = buildConfirmationPdfRelativePath(order); + try { + byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes(); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"confirmation-" + getDisplayOrderNumber(order) + ".pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(existingPdf); + } catch (Exception ignored) { + // Fallback to on-the-fly generation if the stored file is missing or unreadable. + } + } + + List items = orderItemRepo.findByOrder_Id(orderId); + Payment payment = paymentRepo.findByOrder_Id(orderId).orElse(null); + + byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment); + String typePrefix = isConfirmation ? "confirmation-" : "invoice-"; + String truncatedUuid = order.getId().toString().substring(0, 8); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"" + typePrefix + truncatedUuid + ".pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(pdf); + } + + private Path buildConfirmationPdfRelativePath(Order order) { + return Path.of( + "orders", + order.getId().toString(), + "documents", + "confirmation-" + getDisplayOrderNumber(order) + ".pdf" + ); + } + + private String getExtension(String filename) { + if (filename == null) { + return "stl"; + } + String cleaned = StringUtils.cleanPath(filename); + if (cleaned.contains("..")) { + return "stl"; + } + int i = cleaned.lastIndexOf('.'); + if (i > 0 && i < cleaned.length() - 1) { + String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT); + if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) { + return ext; + } + } + return "stl"; + } + + private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) { + try { + Path candidate = Path.of(storedRelativePath).normalize(); + if (candidate.isAbsolute()) { + return null; + } + + Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString()); + if (!candidate.startsWith(expectedPrefix)) { + return null; + } + + return candidate; + } catch (InvalidPathException e) { + return null; + } + } + + private OrderDto convertToDto(Order order, List items) { + OrderDto dto = new OrderDto(); + dto.setId(order.getId()); + dto.setOrderNumber(getDisplayOrderNumber(order)); + dto.setStatus(order.getStatus()); + + paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> { + dto.setPaymentStatus(payment.getStatus()); + dto.setPaymentMethod(payment.getMethod()); + }); + + boolean redactPersonalData = shouldRedactPersonalData(order.getStatus()); + if (!redactPersonalData) { + dto.setCustomerEmail(order.getCustomerEmail()); + dto.setCustomerPhone(order.getCustomerPhone()); + dto.setBillingCustomerType(order.getBillingCustomerType()); + } + dto.setPreferredLanguage(order.getPreferredLanguage()); + dto.setCurrency(order.getCurrency()); + dto.setSetupCostChf(order.getSetupCostChf()); + dto.setShippingCostChf(order.getShippingCostChf()); + dto.setDiscountChf(order.getDiscountChf()); + dto.setSubtotalChf(order.getSubtotalChf()); + dto.setIsCadOrder(order.getIsCadOrder()); + dto.setSourceRequestId(order.getSourceRequestId()); + dto.setCadHours(order.getCadHours()); + dto.setCadHourlyRateChf(order.getCadHourlyRateChf()); + dto.setCadTotalChf(order.getCadTotalChf()); + dto.setTotalChf(order.getTotalChf()); + dto.setCreatedAt(order.getCreatedAt()); + dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); + + if (!redactPersonalData) { + 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(item -> { + OrderItemDto itemDto = new OrderItemDto(); + itemDto.setId(item.getId()); + itemDto.setOriginalFilename(item.getOriginalFilename()); + itemDto.setMaterialCode(item.getMaterialCode()); + itemDto.setColorCode(item.getColorCode()); + itemDto.setQuality(item.getQuality()); + itemDto.setNozzleDiameterMm(item.getNozzleDiameterMm()); + itemDto.setLayerHeightMm(item.getLayerHeightMm()); + itemDto.setInfillPercent(item.getInfillPercent()); + itemDto.setInfillPattern(item.getInfillPattern()); + itemDto.setSupportsEnabled(item.getSupportsEnabled()); + itemDto.setQuantity(item.getQuantity()); + itemDto.setPrintTimeSeconds(item.getPrintTimeSeconds()); + itemDto.setMaterialGrams(item.getMaterialGrams()); + itemDto.setUnitPriceChf(item.getUnitPriceChf()); + itemDto.setLineTotalChf(item.getLineTotalChf()); + return itemDto; + }).collect(Collectors.toList()); + dto.setItems(itemDtos); + + return dto; + } + + private boolean shouldRedactPersonalData(String status) { + if (status == null || status.isBlank()) { + return false; + } + return PERSONAL_DATA_REDACTED_STATUSES.contains(status.trim().toUpperCase(Locale.ROOT)); + } + + 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/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java b/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java index 5849c12..a15b5af 100644 --- a/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java +++ b/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java @@ -2,14 +2,12 @@ package com.printcalculator.controller; import com.printcalculator.dto.OrderDto; import com.printcalculator.entity.Order; -import com.printcalculator.repository.CustomerRepository; import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.PaymentRepository; -import com.printcalculator.repository.QuoteLineItemRepository; -import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.service.payment.InvoicePdfRenderingService; import com.printcalculator.service.OrderService; +import com.printcalculator.service.order.OrderControllerService; import com.printcalculator.service.payment.PaymentService; import com.printcalculator.service.payment.QrBillService; import com.printcalculator.service.storage.StorageService; @@ -41,12 +39,6 @@ class OrderControllerPrivacyTest { @Mock private OrderItemRepository orderItemRepo; @Mock - private QuoteSessionRepository quoteSessionRepo; - @Mock - private QuoteLineItemRepository quoteLineItemRepo; - @Mock - private CustomerRepository customerRepo; - @Mock private StorageService storageService; @Mock private InvoicePdfRenderingService invoiceService; @@ -63,13 +55,10 @@ class OrderControllerPrivacyTest { @BeforeEach void setUp() { - controller = new OrderController( + OrderControllerService orderControllerService = new OrderControllerService( orderService, orderRepo, orderItemRepo, - quoteSessionRepo, - quoteLineItemRepo, - customerRepo, storageService, invoiceService, qrBillService, @@ -77,6 +66,7 @@ class OrderControllerPrivacyTest { paymentService, paymentRepo ); + controller = new OrderController(orderControllerService); } @Test diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java index 804da16..e20e4e3 100644 --- a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java @@ -6,6 +6,8 @@ import com.printcalculator.entity.Order; import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.service.order.AdminOrderControllerService; import com.printcalculator.service.payment.InvoicePdfRenderingService; import com.printcalculator.service.payment.PaymentService; import com.printcalculator.service.payment.QrBillService; @@ -41,6 +43,8 @@ class AdminOrderControllerStatusValidationTest { @Mock private PaymentRepository paymentRepository; @Mock + private QuoteLineItemRepository quoteLineItemRepository; + @Mock private PaymentService paymentService; @Mock private StorageService storageService; @@ -55,16 +59,18 @@ class AdminOrderControllerStatusValidationTest { @BeforeEach void setUp() { - controller = new AdminOrderController( + AdminOrderControllerService adminOrderControllerService = new AdminOrderControllerService( orderRepository, orderItemRepository, paymentRepository, + quoteLineItemRepository, paymentService, storageService, invoicePdfRenderingService, qrBillService, eventPublisher ); + controller = new AdminOrderController(adminOrderControllerService); } @Test diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html index 8fccbf1..5c3b16f 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html @@ -63,11 +63,11 @@ {{ item.unitTime / 3600 | number: "1.1-1" }}h | {{ item.unitWeight | number: "1.0-0" }}g | - materiale: {{ item.material || "N/D" }} - @if (getItemDifferenceLabel(item.fileName)) { + {{ item.material || "N/D" }} + @if (getItemDifferenceLabel(item.fileName, item.material)) { | - {{ getItemDifferenceLabel(item.fileName) }} + {{ getItemDifferenceLabel(item.fileName, item.material) }} } @@ -110,7 +110,7 @@
- + {{ "QUOTE.CONSULT" | translate }}
diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss index 8218937..2ed738c 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss @@ -20,10 +20,11 @@ display: flex; justify-content: space-between; align-items: center; - padding: var(--space-3); - background: var(--color-neutral-50); + padding: var(--space-3) var(--space-4); + background: var(--color-bg-card); border-radius: var(--radius-md); border: 1px solid var(--color-border); + box-shadow: 0 2px 6px rgba(10, 20, 30, 0.04); } .item-info { @@ -54,6 +55,19 @@ color: var(--color-text-muted); } +.material-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid #d9d4bd; + background: #fbf7e9; + color: #6d5b1d; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.2px; +} + .item-controls { display: flex; align-items: center; @@ -149,6 +163,7 @@ .actions-right { display: flex; align-items: center; + gap: var(--space-2); } .actions-right { diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts index 2aad331..3e1c30c 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts @@ -189,14 +189,30 @@ export class QuoteResultComponent implements OnDestroy { this.quantityTimers.clear(); } - getItemDifferenceLabel(fileName: string): string { + getItemDifferenceLabel(fileName: string, materialCode?: string): string { const differences = this.itemSettingsDiffByFileName()[fileName]?.differences || []; if (differences.length === 0) return ''; - const materialOnly = differences.find( + const normalizedMaterial = String(materialCode || '') + .trim() + .toLowerCase(); + + const filtered = differences.filter((entry) => { + const normalized = String(entry || '') + .trim() + .toLowerCase(); + const isMaterialOnly = !normalized.includes(':'); + return !(isMaterialOnly && normalized === normalizedMaterial); + }); + + if (filtered.length === 0) { + return ''; + } + + const materialOnly = filtered.find( (entry) => !entry.includes(':') && entry.trim().length > 0, ); - return materialOnly || differences.join(' | '); + return materialOnly || filtered.join(' | '); } } diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html index 6fce66e..843fb2e 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html @@ -13,11 +13,9 @@ > } -
} - @if (items().length === 0) { } - @if (items().length > 0) {
@for (item of items(); track item.file.name; let i = $index) { @@ -83,7 +80,6 @@ }
-
.

- + @if (mode() === "advanced") { +
+ +
- @if (sameSettingsForAll()) { -
-

Impostazioni globali

+ @if (sameSettingsForAll()) { +
+

Impostazioni globali

-
- - - @if (mode() === "easy") { +
- } @else { + - } -
+
- @if (mode() === "advanced") {
- } @else { - @if (getSelectedItem(); as selectedItem) { -
-

- Impostazioni file: {{ selectedItem.file.name }} -

- -
- - - @if (mode() === "easy") { - - } @else { - - }
- @if (mode() === "easy") { - - } @else { - - } -
+ } @else { + @if (getSelectedItem(); as selectedItem) { +
+

+ Impostazioni file: {{ selectedItem.file.name }} +

- @if (items().length > 1) { -
- - -
- } +
+ - @if (mode() === "advanced") { -
- + +
- -
+
+ -
- + +
- +
+ + + +
} -
} } } @@ -349,7 +258,6 @@ @if (items().length === 0 && form.get("itemsTouched")?.value) {
{{ "CALC.ERR_FILE_REQUIRED" | translate }}
} -
- @if (loading() && uploadProgress() < 100) {
diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss index f3350cd..fd36719 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss @@ -2,8 +2,8 @@ margin-bottom: var(--space-6); } .upload-privacy-note { - margin-top: var(--space-6); - margin-bottom: 0; + margin-top: var(--space-4); + margin-bottom: var(--space-1); font-size: 0.8rem; color: var(--color-text-muted); text-align: left; @@ -35,48 +35,50 @@ /* Grid Layout for Files */ .items-grid { display: grid; - grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */ - gap: var(--space-2); /* Tighten gap for mobile */ + grid-template-columns: 1fr; + gap: var(--space-3); margin-top: var(--space-4); margin-bottom: var(--space-4); @media (min-width: 640px) { + grid-template-columns: 1fr 1fr; gap: var(--space-3); } } .file-card { - padding: var(--space-2); /* Reduced from space-3 */ - background: var(--color-neutral-100); + padding: var(--space-3); + background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: var(--radius-md); transition: all 0.2s; cursor: pointer; display: flex; flex-direction: column; - gap: 4px; /* Reduced gap */ - position: relative; /* For absolute positioning of remove btn */ - min-width: 0; /* Allow flex item to shrink below content size if needed */ + gap: var(--space-2); + position: relative; + min-width: 0; &:hover { border-color: var(--color-neutral-300); + box-shadow: 0 4px 10px rgba(10, 20, 30, 0.07); } &.active { border-color: var(--color-brand); - background: rgba(250, 207, 10, 0.05); + background: rgba(250, 207, 10, 0.08); box-shadow: 0 0 0 1px var(--color-brand); } } .card-header { overflow: hidden; - padding-right: 25px; /* Adjusted */ - margin-bottom: 2px; + padding-right: 28px; + margin-bottom: 0; } .file-name { - font-weight: 500; - font-size: 0.8rem; /* Smaller font */ + font-weight: 600; + font-size: 0.92rem; color: var(--color-text); display: block; white-space: nowrap; @@ -92,47 +94,46 @@ .card-controls { display: flex; - align-items: flex-end; /* Align bottom of input and color circle */ - gap: 16px; /* Space between Qty and Color */ + align-items: flex-end; + gap: var(--space-4); width: 100%; } .qty-group, .color-group { display: flex; - flex-direction: column; /* Stack label and input */ + flex-direction: column; align-items: flex-start; - gap: 0px; + gap: 2px; label { - font-size: 0.6rem; + font-size: 0.72rem; color: var(--color-text-muted); text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.3px; font-weight: 600; - margin-bottom: 2px; + margin-bottom: 0; } } .color-group { - align-items: flex-start; /* Align label left */ - /* margin-right removed */ + align-items: flex-start; - /* Override margin in selector for this context */ ::ng-deep .color-selector-container { margin-left: 0; } } .qty-input { - width: 36px; /* Slightly smaller */ - padding: 1px 2px; + width: 54px; + padding: 4px 6px; border: 1px solid var(--color-border); border-radius: var(--radius-sm); text-align: center; - font-size: 0.85rem; + font-size: 0.95rem; + font-weight: 600; background: white; - height: 24px; /* Explicit height to match color circle somewhat */ + height: 34px; &:focus { outline: none; border-color: var(--color-brand); @@ -141,10 +142,10 @@ .btn-remove { position: absolute; - top: 4px; - right: 4px; - width: 18px; - height: 18px; + top: 6px; + right: 6px; + width: 20px; + height: 20px; border-radius: 4px; border: none; background: transparent; @@ -155,7 +156,7 @@ align-items: center; justify-content: center; transition: all 0.2s; - font-size: 0.8rem; + font-size: 0.9rem; &:hover { background: var(--color-danger-100); @@ -170,7 +171,7 @@ .btn-add-more { width: 100%; - padding: var(--space-3); + padding: 0.75rem var(--space-3); background: var(--color-neutral-800); color: white; border: none; @@ -193,6 +194,50 @@ } } +.sync-settings { + margin-top: var(--space-4); + margin-bottom: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-neutral-50); + padding: var(--space-3); +} + +.sync-settings-toggle { + display: flex; + align-items: flex-start; + gap: var(--space-3); + cursor: pointer; + + input[type="checkbox"] { + width: 20px; + height: 20px; + margin-top: 2px; + accent-color: var(--color-brand); + flex-shrink: 0; + } +} + +.sync-settings-copy { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sync-settings-title { + font-size: 0.95rem; + font-weight: 700; + color: var(--color-text); + line-height: 1.2; +} + +.sync-settings-subtitle { + font-size: 0.8rem; + font-weight: 500; + color: var(--color-text-muted); + line-height: 1.35; +} + .checkbox-row { display: flex; align-items: center; diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index d608ce6..1fdd614 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -22,6 +22,7 @@ import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component'; import { QuoteRequest, + QuoteRequestItem, QuoteEstimatorService, OptionsResponse, SimpleOption, @@ -34,33 +35,33 @@ interface FormItem { file: File; previewFile?: File; quantity: number; - material?: string; - quality?: string; - color: string; - filamentVariantId?: number; - supportEnabled?: boolean; - infillDensity?: number; - infillPattern?: string; - layerHeight?: number; - nozzleDiameter?: number; - printSettings: ItemPrintSettings; -} - -interface ItemPrintSettings { material: string; quality: string; - nozzleDiameter: number; - layerHeight: number; + color: string; + filamentVariantId?: number; + supportEnabled: boolean; infillDensity: number; infillPattern: string; - supportEnabled: boolean; + layerHeight: number; + nozzleDiameter: number; } interface ItemSettingsDiffInfo { differences: string[]; } -type ItemPrintSettingsUpdate = Partial; +type ItemPrintSettingsUpdate = Partial< + Pick< + FormItem, + | 'material' + | 'quality' + | 'nozzleDiameter' + | 'layerHeight' + | 'infillDensity' + | 'infillPattern' + | 'supportEnabled' + > +>; @Component({ selector: 'app-upload-form', @@ -83,6 +84,7 @@ export class UploadFormComponent implements OnInit { lockedSettings = input(false); loading = input(false); uploadProgress = input(0); + submitRequest = output(); itemQuantityChange = output<{ index: number; @@ -109,37 +111,148 @@ export class UploadFormComponent implements OnInit { items = signal([]); selectedFile = signal(null); + sameSettingsForAll = signal(true); - // Dynamic Options materials = signal([]); qualities = signal([]); nozzleDiameters = signal([]); infillPatterns = signal([]); layerHeights = signal([]); + currentMaterialVariants = signal([]); - // Store full material options to lookup variants/colors if needed later private fullMaterialOptions: MaterialOption[] = []; private allLayerHeights: SimpleOption[] = []; private layerHeightsByNozzle: Record = {}; private isPatchingSettings = false; - sameSettingsForAll = signal(true); - - // Computed variants for valid material - currentMaterialVariants = signal([]); - - private updateVariants() { - const matCode = this.form.get('material')?.value; - if (matCode && this.fullMaterialOptions.length > 0) { - const found = this.fullMaterialOptions.find((m) => m.code === matCode); - this.currentMaterialVariants.set(found ? found.variants : []); - this.syncSelectedItemVariantSelection(); - } else { - this.currentMaterialVariants.set([]); - } - } acceptedFormats = '.stl,.3mf,.step,.stp'; + constructor() { + this.form = this.fb.group({ + itemsTouched: [false], + syncAllItems: [true], + material: ['', Validators.required], + quality: ['standard', Validators.required], + notes: [''], + infillDensity: [15, [Validators.min(0), Validators.max(100)]], + layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]], + nozzleDiameter: [0.4, Validators.required], + infillPattern: ['grid', Validators.required], + supportEnabled: [false], + }); + + this.form.get('material')?.valueChanges.subscribe((value) => { + this.updateVariants(String(value || '')); + }); + + this.form.get('quality')?.valueChanges.subscribe((quality) => { + if (this.isPatchingSettings || this.mode() !== 'easy') { + return; + } + this.applyEasyPresetFromQuality(String(quality || 'standard')); + }); + + this.form.get('nozzleDiameter')?.valueChanges.subscribe((nozzle) => { + if (this.isPatchingSettings) { + return; + } + this.updateLayerHeightOptionsForNozzle(nozzle, true); + }); + + this.form.valueChanges.subscribe(() => { + if (this.isPatchingSettings) { + return; + } + + if (this.sameSettingsForAll()) { + this.applyGlobalSettingsToAllItems(); + } else { + this.syncSelectedItemSettingsFromForm(); + } + + this.emitPrintSettingsChange(); + this.emitItemSettingsDiffChange(); + }); + + effect(() => { + this.applySettingsLock(this.lockedSettings()); + }); + + effect(() => { + if (this.mode() !== 'easy' || this.sameSettingsForAll()) { + return; + } + + this.sameSettingsForAll.set(true); + this.form.get('syncAllItems')?.setValue(true, { emitEvent: false }); + this.applyGlobalSettingsToAllItems(); + this.emitPrintSettingsChange(); + this.emitItemSettingsDiffChange(); + }); + } + + ngOnInit() { + this.estimator.getOptions().subscribe({ + next: (options: OptionsResponse) => { + this.fullMaterialOptions = options.materials || []; + + this.materials.set( + (options.materials || []).map((m) => ({ label: m.label, value: m.code })), + ); + this.qualities.set( + (options.qualities || []).map((q) => ({ label: q.label, value: q.id })), + ); + this.infillPatterns.set( + (options.infillPatterns || []).map((p) => ({ label: p.label, value: p.id })), + ); + this.nozzleDiameters.set( + (options.nozzleDiameters || []).map((n) => ({ label: n.label, value: n.value })), + ); + + this.allLayerHeights = (options.layerHeights || []).map((l) => ({ + label: l.label, + value: l.value, + })); + + this.layerHeightsByNozzle = {}; + (options.layerHeightsByNozzle || []).forEach((entry) => { + this.layerHeightsByNozzle[this.toNozzleKey(entry.nozzleDiameter)] = ( + entry.layerHeights || [] + ).map((layer) => ({ + label: layer.label, + value: layer.value, + })); + }); + + this.setDefaults(); + }, + error: (err) => { + console.error('Failed to load options', err); + this.materials.set([ + { + label: this.translate.instant('CALC.FALLBACK_MATERIAL'), + value: 'PLA', + }, + ]); + this.qualities.set([ + { + label: this.translate.instant('CALC.FALLBACK_QUALITY_STANDARD'), + value: 'standard', + }, + ]); + this.infillPatterns.set([{ label: 'Grid', value: 'grid' }]); + this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]); + + this.allLayerHeights = [{ label: '0.20 mm', value: 0.2 }]; + this.layerHeightsByNozzle = { + [this.toNozzleKey(0.4)]: this.allLayerHeights, + }; + + this.setDefaults(); + }, + }); + } + isStlFile(file: File | null): boolean { if (!file) return false; const name = file.name.toLowerCase(); @@ -154,8 +267,7 @@ export class UploadFormComponent implements OnInit { const selected = this.selectedFile(); if (!selected) return null; const item = this.items().find((i) => i.file === selected); - if (!item) return null; - return item.previewFile ?? item.file; + return item ? item.previewFile || item.file : null; } getSelectedItemIndex(): number { @@ -167,290 +279,107 @@ export class UploadFormComponent implements OnInit { getSelectedItem(): FormItem | null { const index = this.getSelectedItemIndex(); if (index < 0) return null; - return this.items()[index] ?? null; + return this.items()[index] || null; } getVariantsForMaterial(materialCode: string | null | undefined): VariantOption[] { - if (!materialCode) return []; - const found = this.fullMaterialOptions.find((m) => m.code === materialCode); - return found?.variants ?? []; - } + const normalized = String(materialCode || '').trim().toUpperCase(); + if (!normalized) return []; - constructor() { - this.form = this.fb.group({ - itemsTouched: [false], // Hack to track touched state for custom items list - syncAllItems: [true], - material: ['', Validators.required], - quality: ['', Validators.required], - items: [[]], // Track items in form for validation if needed - notes: [''], - // Advanced fields - infillDensity: [15, [Validators.min(0), Validators.max(100)]], - layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]], - nozzleDiameter: [0.4, Validators.required], - infillPattern: ['grid'], - supportEnabled: [false], - }); - - // Listen to material changes to update variants and propagate when "all files equal" is active. - this.form.get('material')?.valueChanges.subscribe((materialCode) => { - this.updateVariants(); - if (this.sameSettingsForAll() && !this.isPatchingSettings) { - this.applyGlobalMaterialToAll(String(materialCode || 'PLA')); - } - }); - - this.form.get('quality')?.valueChanges.subscribe((quality) => { - if (this.mode() !== 'easy' || this.isPatchingSettings) return; - this.applyAdvancedPresetFromQuality(quality); - if (this.sameSettingsForAll()) { - this.applyGlobalFieldToAll('quality', String(quality || 'standard')); - } - }); - - this.form.get('nozzleDiameter')?.valueChanges.subscribe((value) => { - if (!this.sameSettingsForAll() || this.isPatchingSettings) return; - this.applyGlobalFieldToAll( - 'nozzleDiameter', - Number.isFinite(Number(value)) ? Number(value) : 0.4, - ); - }); - this.form.get('layerHeight')?.valueChanges.subscribe((value) => { - if (!this.sameSettingsForAll() || this.isPatchingSettings) return; - this.applyGlobalFieldToAll( - 'layerHeight', - Number.isFinite(Number(value)) ? Number(value) : 0.2, - ); - }); - this.form.get('infillDensity')?.valueChanges.subscribe((value) => { - if (!this.sameSettingsForAll() || this.isPatchingSettings) return; - this.applyGlobalFieldToAll( - 'infillDensity', - Number.isFinite(Number(value)) ? Number(value) : 15, - ); - }); - this.form.get('infillPattern')?.valueChanges.subscribe((value) => { - if (!this.sameSettingsForAll() || this.isPatchingSettings) return; - this.applyGlobalFieldToAll('infillPattern', String(value || 'grid')); - }); - this.form.get('supportEnabled')?.valueChanges.subscribe((value) => { - if (!this.sameSettingsForAll() || this.isPatchingSettings) return; - this.applyGlobalFieldToAll('supportEnabled', !!value); - }); - this.form.get('nozzleDiameter')?.valueChanges.subscribe((nozzle) => { - if (this.isPatchingSettings) return; - this.updateLayerHeightOptionsForNozzle(nozzle, true); - }); - this.form.valueChanges.subscribe(() => { - if (this.isPatchingSettings) return; - this.syncSelectedItemSettingsFromForm(); - this.emitPrintSettingsChange(); - this.emitItemSettingsDiffChange(); - }); - - effect(() => { - this.applySettingsLock(this.lockedSettings()); - }); - } - - private applyAdvancedPresetFromQuality(quality: string | null | undefined) { - const normalized = (quality || 'standard').toLowerCase(); - - const presets: Record< - string, - { - nozzleDiameter: number; - layerHeight: number; - infillDensity: number; - infillPattern: string; - } - > = { - standard: { - nozzleDiameter: 0.4, - layerHeight: 0.2, - infillDensity: 15, - infillPattern: 'grid', - }, - extra_fine: { - nozzleDiameter: 0.4, - layerHeight: 0.12, - infillDensity: 20, - infillPattern: 'grid', - }, - high: { - nozzleDiameter: 0.4, - layerHeight: 0.12, - infillDensity: 20, - infillPattern: 'grid', - }, // Legacy alias - draft: { - nozzleDiameter: 0.4, - layerHeight: 0.24, - infillDensity: 12, - infillPattern: 'grid', - }, - }; - - const preset = presets[normalized] || presets['standard']; - this.form.patchValue(preset, { emitEvent: false }); - this.updateLayerHeightOptionsForNozzle(preset.nozzleDiameter, true); - } - - ngOnInit() { - this.estimator.getOptions().subscribe({ - next: (options: OptionsResponse) => { - this.fullMaterialOptions = options.materials; - this.updateVariants(); // Trigger initial update - - this.materials.set( - options.materials.map((m) => ({ label: m.label, value: m.code })), - ); - this.qualities.set( - options.qualities.map((q) => ({ label: q.label, value: q.id })), - ); - this.infillPatterns.set( - options.infillPatterns.map((p) => ({ label: p.label, value: p.id })), - ); - this.allLayerHeights = options.layerHeights.map((l) => ({ - label: l.label, - value: l.value, - })); - this.layerHeightsByNozzle = {}; - (options.layerHeightsByNozzle || []).forEach((entry) => { - this.layerHeightsByNozzle[this.toNozzleKey(entry.nozzleDiameter)] = - entry.layerHeights.map((layer) => ({ - label: layer.label, - value: layer.value, - })); - }); - this.layerHeights.set(this.allLayerHeights); - this.nozzleDiameters.set( - options.nozzleDiameters.map((n) => ({ - label: n.label, - value: n.value, - })), - ); - - this.setDefaults(); - }, - error: (err) => { - console.error('Failed to load options', err); - // Fallback for debugging/offline dev - this.materials.set([ - { - label: this.translate.instant('CALC.FALLBACK_MATERIAL'), - value: 'PLA', - }, - ]); - this.qualities.set([ - { - label: this.translate.instant('CALC.FALLBACK_QUALITY_STANDARD'), - value: 'standard', - }, - ]); - this.allLayerHeights = [{ label: '0.20 mm', value: 0.2 }]; - this.layerHeightsByNozzle = { - [this.toNozzleKey(0.4)]: this.allLayerHeights, - }; - this.layerHeights.set(this.allLayerHeights); - this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]); - this.setDefaults(); - }, - }); - } - - private setDefaults() { - // Set Defaults if available - if (this.materials().length > 0 && !this.form.get('material')?.value) { - const exactPla = this.materials().find( - (m) => typeof m.value === 'string' && m.value.toUpperCase() === 'PLA', - ); - const anyPla = this.materials().find( - (m) => - typeof m.value === 'string' && - m.value.toUpperCase().startsWith('PLA'), - ); - const preferredMaterial = exactPla ?? anyPla ?? this.materials()[0]; - this.form.get('material')?.setValue(preferredMaterial.value); - } - if (this.qualities().length > 0 && !this.form.get('quality')?.value) { - // Try to find 'standard' or use first - const std = this.qualities().find((q) => q.value === 'standard'); - this.form - .get('quality') - ?.setValue(std ? std.value : this.qualities()[0].value); - } - if ( - this.nozzleDiameters().length > 0 && - !this.form.get('nozzleDiameter')?.value - ) { - this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4 - } - - this.updateLayerHeightOptionsForNozzle( - this.form.get('nozzleDiameter')?.value, - true, + const found = this.fullMaterialOptions.find( + (m) => String(m.code || '').trim().toUpperCase() === normalized, ); + return found?.variants || []; + } - if ( - this.infillPatterns().length > 0 && - !this.form.get('infillPattern')?.value - ) { - this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value); + getLayerHeightOptionsForNozzle(nozzleRaw: unknown): SimpleOption[] { + const key = this.toNozzleKey(nozzleRaw); + const perNozzle = this.layerHeightsByNozzle[key]; + if (perNozzle && perNozzle.length > 0) { + return perNozzle; } - - this.emitPrintSettingsChange(); + return this.allLayerHeights.length > 0 + ? this.allLayerHeights + : [{ label: '0.20 mm', value: 0.2 }]; } onFilesDropped(newFiles: File[]) { - const MAX_SIZE = 200 * 1024 * 1024; // 200MB + const MAX_SIZE = 200 * 1024 * 1024; const validItems: FormItem[] = []; let hasError = false; + const defaults = this.getCurrentGlobalItemDefaults(); for (const file of newFiles) { if (file.size > MAX_SIZE) { hasError = true; - } else { - const defaultSelection = this.getDefaultVariantSelection(defaults.material); - validItems.push({ - file, - previewFile: this.isStlFile(file) ? file : undefined, - quantity: 1, - material: defaults.material, - quality: defaults.quality, - color: defaultSelection.colorName, - filamentVariantId: defaultSelection.filamentVariantId, - supportEnabled: defaults.supportEnabled, - infillDensity: defaults.infillDensity, - infillPattern: defaults.infillPattern, - layerHeight: defaults.layerHeight, - nozzleDiameter: defaults.nozzleDiameter, - printSettings: this.getCurrentItemPrintSettings(), - }); + continue; } + + const selection = this.getDefaultVariantSelection(defaults.material); + validItems.push({ + file, + previewFile: this.isStlFile(file) ? file : undefined, + quantity: 1, + material: defaults.material, + quality: defaults.quality, + color: selection.colorName, + filamentVariantId: selection.filamentVariantId, + supportEnabled: defaults.supportEnabled, + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + layerHeight: defaults.layerHeight, + nozzleDiameter: defaults.nozzleDiameter, + }); } if (hasError) { alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE')); } - if (validItems.length > 0) { - this.items.update((current) => [...current, ...validItems]); - this.form.get('itemsTouched')?.setValue(true); - // Auto select last added - this.selectFile(validItems[validItems.length - 1].file); - this.emitItemSettingsDiffChange(); + if (validItems.length === 0) { + return; } + + this.items.update((current) => [...current, ...validItems]); + this.form.get('itemsTouched')?.setValue(true); + + if (this.sameSettingsForAll()) { + this.applyGlobalSettingsToAllItems(); + } + + this.selectFile(validItems[validItems.length - 1].file); + this.emitItemSettingsDiffChange(); } onAdditionalFilesSelected(event: Event) { const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - this.onFilesDropped(Array.from(input.files)); - // Reset input so same files can be selected again if needed - input.value = ''; + if (!input.files || input.files.length === 0) { + return; } + + this.onFilesDropped(Array.from(input.files)); + input.value = ''; + } + + updateItemQuantity(index: number, event: Event) { + const input = event.target as HTMLInputElement; + const parsed = parseInt(input.value, 10); + const quantity = Number.isFinite(parsed) ? parsed : 1; + + const currentItem = this.items()[index]; + if (!currentItem) { + return; + } + + const normalizedQty = this.normalizeQuantity(quantity); + this.updateItemQuantityByIndex(index, quantity); + + this.itemQuantityChange.emit({ + index, + fileName: currentItem.file.name, + quantity: normalizedQty, + }); } updateItemQuantityByIndex(index: number, quantity: number) { @@ -459,75 +388,71 @@ export class UploadFormComponent implements OnInit { this.items.update((current) => { if (index >= current.length) return current; - const applyToAll = this.sameSettingsForAll(); - return current.map((item, idx) => { - if (!applyToAll && idx !== index) return item; - return { ...item, quantity: normalizedQty }; - }); + + if (this.sameSettingsForAll()) { + return current.map((item) => ({ ...item, quantity: normalizedQty })); + } + + return current.map((item, idx) => + idx === index ? { ...item, quantity: normalizedQty } : item, + ); }); } updateItemQuantityByName(fileName: string, quantity: number) { const targetName = this.normalizeFileName(fileName); const normalizedQty = this.normalizeQuantity(quantity); - const applyToAll = this.sameSettingsForAll(); this.items.update((current) => { let matched = false; + return current.map((item) => { - if (applyToAll) { + if (this.sameSettingsForAll()) { return { ...item, quantity: normalizedQty }; } + if (!matched && this.normalizeFileName(item.file.name) === targetName) { matched = true; return { ...item, quantity: normalizedQty }; } + return item; }); }); } selectFile(file: File) { - if (this.selectedFile() === file) { - // toggle off? no, keep active - } else { + if (this.selectedFile() !== file) { this.selectedFile.set(file); } this.loadSelectedItemSettingsIntoForm(); } - // Helper to get color of currently selected file getSelectedFileColor(): string { - const file = this.selectedFile(); - if (!file) return '#facf0a'; // Default - - const item = this.items().find((i) => i.file === file); - if (item) { - const vars = this.getVariantsForMaterial(item.material); - if (vars && vars.length > 0) { - const found = item.filamentVariantId - ? vars.find((v) => v.id === item.filamentVariantId) - : vars.find((v) => v.colorName === item.color); - if (found) return found.hexColor; - } - return getColorHex(item.color); + const selected = this.selectedFile(); + if (!selected) { + return '#facf0a'; } - return '#facf0a'; - } - updateItemQuantity(index: number, event: Event) { - const input = event.target as HTMLInputElement; - const parsed = parseInt(input.value, 10); - const quantity = Number.isFinite(parsed) ? parsed : 1; - const currentItem = this.items()[index]; - if (!currentItem) return; - const normalizedQty = this.normalizeQuantity(quantity); - this.updateItemQuantityByIndex(index, quantity); - this.itemQuantityChange.emit({ - index, - fileName: currentItem.file.name, - quantity: normalizedQty, - }); + const item = this.items().find((i) => i.file === selected); + if (!item) { + return '#facf0a'; + } + + const variants = this.getVariantsForMaterial(item.material); + if (variants.length > 0) { + const byId = + item.filamentVariantId != null + ? variants.find((v) => v.id === item.filamentVariantId) + : null; + const byColor = variants.find((v) => v.colorName === item.color); + const selectedVariant = byId || byColor; + if (selectedVariant) { + return selectedVariant.hexColor; + } + } + + return getColorHex(item.color); } updateItemColor( @@ -537,464 +462,734 @@ export class UploadFormComponent implements OnInit { const colorName = typeof newSelection === 'string' ? newSelection : newSelection.colorName; const filamentVariantId = - typeof newSelection === 'string' - ? undefined - : newSelection.filamentVariantId; - this.items.update((current) => { - const updated = [...current]; - const applyToAll = this.sameSettingsForAll(); - return updated.map((item, idx) => { - if (!applyToAll && idx !== index) return item; - return { - ...item, - color: colorName, - filamentVariantId, - }; - }); - }); - } - - updateItemMaterial(index: number, materialCode: string) { - if (!Number.isInteger(index) || index < 0) return; - const variants = this.getVariantsForMaterial(materialCode); - const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; + typeof newSelection === 'string' ? undefined : newSelection.filamentVariantId; this.items.update((current) => { - if (index >= current.length) return current; - const applyToAll = this.sameSettingsForAll(); - return current.map((item, idx) => { - if (!applyToAll && idx !== index) return item; - const next = { ...item, material: materialCode }; - if (fallback) { - next.color = fallback.colorName; - next.filamentVariantId = fallback.id; - } else { - next.filamentVariantId = undefined; - } - return next; - }); + if (index < 0 || index >= current.length) { + return current; + } + + return current.map((item, idx) => + idx === index + ? { + ...item, + color: colorName, + filamentVariantId, + } + : item, + ); }); } - updateSelectedItemNumberField( - field: - | 'nozzleDiameter' - | 'layerHeight' - | 'infillDensity' - | 'quantity', - value: number, - ) { - const index = this.getSelectedItemIndex(); - if (index < 0) return; - const normalized = - field === 'quantity' - ? this.normalizeQuantity(value) - : Number.isFinite(value) - ? value - : undefined; - - this.items.update((current) => { - if (index >= current.length) return current; - const applyToAll = this.sameSettingsForAll(); - return current.map((item, idx) => { - if (!applyToAll && idx !== index) return item; - return { - ...item, - [field]: normalized, - }; - }); - }); - } - - updateSelectedItemStringField( - field: 'quality' | 'infillPattern', - value: string, - ) { - const index = this.getSelectedItemIndex(); - if (index < 0) return; - this.items.update((current) => { - if (index >= current.length) return current; - const applyToAll = this.sameSettingsForAll(); - return current.map((item, idx) => { - if (!applyToAll && idx !== index) return item; - return { - ...item, - [field]: value, - }; - }); - }); - } - - updateSelectedItemSupport(value: boolean) { - const index = this.getSelectedItemIndex(); - if (index < 0) return; - this.items.update((current) => { - if (index >= current.length) return current; - const applyToAll = this.sameSettingsForAll(); - return current.map((item, idx) => { - if (!applyToAll && idx !== index) return item; - return { - ...item, - supportEnabled: value, - }; - }); - }); - } - - onSameSettingsToggle(enabled: boolean) { - this.sameSettingsForAll.set(enabled); - if (!enabled) { - // Keep per-file values aligned with what the user sees in global controls - // right before switching to single-file mode. - this.syncAllItemsWithGlobalForm(); - return; - } - - const selected = this.getSelectedItem() ?? this.items()[0]; - if (!selected) return; - - const normalizedQuality = this.normalizeQualityValue( - selected.quality ?? this.form.get('quality')?.value, - ); - - this.isPatchingSettings = true; - this.form.patchValue( - { - material: selected.material || this.form.get('material')?.value || 'PLA', - quality: normalizedQuality, - nozzleDiameter: - selected.nozzleDiameter ?? this.form.get('nozzleDiameter')?.value ?? 0.4, - layerHeight: - selected.layerHeight ?? this.form.get('layerHeight')?.value ?? 0.2, - infillDensity: - selected.infillDensity ?? this.form.get('infillDensity')?.value ?? 15, - infillPattern: - selected.infillPattern || this.form.get('infillPattern')?.value || 'grid', - supportEnabled: - selected.supportEnabled ?? - this.form.get('supportEnabled')?.value ?? - false, - }, - { emitEvent: false }, - ); - this.isPatchingSettings = false; - - const sharedPatch: Partial = { - quantity: selected.quantity, - material: selected.material, - quality: normalizedQuality, - color: selected.color, - filamentVariantId: selected.filamentVariantId, - supportEnabled: selected.supportEnabled, - infillDensity: selected.infillDensity, - infillPattern: selected.infillPattern, - layerHeight: selected.layerHeight, - nozzleDiameter: selected.nozzleDiameter, - }; - - this.items.update((current) => - current.map((item) => ({ - ...item, - ...sharedPatch, - })), - ); - } - - private applyGlobalMaterialToAll(materialCode: string): void { - const normalizedMaterial = materialCode || 'PLA'; - const variants = this.getVariantsForMaterial(normalizedMaterial); - const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; - this.items.update((current) => - current.map((item) => ({ - ...item, - material: normalizedMaterial, - color: fallback ? fallback.colorName : item.color, - filamentVariantId: fallback ? fallback.id : item.filamentVariantId, - })), - ); - } - - private applyGlobalFieldToAll( - field: - | 'quality' - | 'nozzleDiameter' - | 'layerHeight' - | 'infillDensity' - | 'infillPattern' - | 'supportEnabled', - value: string | number | boolean, - ): void { - this.items.update((current) => - current.map((item) => ({ - ...item, - [field]: value, - })), - ); - } - - patchItemSettingsByIndex(index: number, patch: Partial) { - if (!Number.isInteger(index) || index < 0) return; - const normalizedPatch: Partial = { ...patch }; - if (normalizedPatch.quality !== undefined && normalizedPatch.quality !== null) { - normalizedPatch.quality = this.normalizeQualityValue(normalizedPatch.quality); - } - this.items.update((current) => { - if (index >= current.length) return current; - const updated = [...current]; - updated[index] = { ...updated[index], ...normalizedPatch }; - return updated; - }); - this.emitItemSettingsDiffChange(); - } - - setItemPrintSettingsByIndex(index: number, update: ItemPrintSettingsUpdate) { - if (!Number.isInteger(index) || index < 0) return; - - let selectedItemUpdated = false; - this.items.update((current) => { - if (index >= current.length) return current; - const updated = [...current]; - const target = updated[index]; - if (!target) return current; - - const merged: ItemPrintSettings = { - ...target.printSettings, - ...update, - }; - - updated[index] = { - ...target, - printSettings: merged, - }; - selectedItemUpdated = target.file === this.selectedFile(); - return updated; - }); - - if (selectedItemUpdated) { - this.loadSelectedItemSettingsIntoForm(); - this.emitPrintSettingsChange(); - } - this.emitItemSettingsDiffChange(); - } - removeItem(index: number) { let nextSelected: File | null = null; + this.items.update((current) => { const updated = [...current]; const removed = updated.splice(index, 1)[0]; - if (this.selectedFile() === removed.file) { - nextSelected = updated.length > 0 ? updated[Math.max(0, index - 1)].file : null; + if (!removed) { + return current; } + + if (this.selectedFile() === removed.file) { + nextSelected = + updated.length > 0 ? updated[Math.max(0, index - 1)].file : null; + } + return updated; }); + if (nextSelected) { this.selectFile(nextSelected); } else if (this.items().length === 0) { this.selectedFile.set(null); } + this.emitItemSettingsDiffChange(); } - setFiles(files: File[]) { - const validItems: FormItem[] = []; - const defaults = this.getCurrentGlobalItemDefaults(); - const defaultSelection = this.getDefaultVariantSelection(defaults.material); - for (const file of files) { - validItems.push({ - file, - previewFile: this.isStlFile(file) ? file : undefined, - quantity: 1, - material: defaults.material, - quality: defaults.quality, - color: defaultSelection.colorName, - filamentVariantId: defaultSelection.filamentVariantId, - supportEnabled: defaults.supportEnabled, - infillDensity: defaults.infillDensity, - infillPattern: defaults.infillPattern, - layerHeight: defaults.layerHeight, - nozzleDiameter: defaults.nozzleDiameter, - }); + onSameSettingsToggle(enabled: boolean) { + this.sameSettingsForAll.set(enabled); + this.form.get('syncAllItems')?.setValue(enabled, { emitEvent: false }); + + if (enabled) { + this.applyGlobalSettingsToAllItems(); + } else { + this.loadSelectedItemSettingsIntoForm(); } - if (validItems.length > 0) { - this.items.set(validItems); - this.form.get('itemsTouched')?.setValue(true); - // Auto select last added - this.selectFile(validItems[validItems.length - 1].file); - this.emitItemSettingsDiffChange(); - } - } - - setPreviewFileByIndex(index: number, previewFile: File) { - if (!Number.isInteger(index) || index < 0) return; - this.items.update((current) => { - if (index >= current.length) return current; - const updated = [...current]; - updated[index] = { ...updated[index], previewFile }; - return updated; - }); - } - - private getCurrentGlobalItemDefaults(): Omit & { - material: string; - quality: string; - } { - return { - material: this.form.get('material')?.value || 'PLA', - quality: this.normalizeQualityValue(this.form.get('quality')?.value), - supportEnabled: !!this.form.get('supportEnabled')?.value, - infillDensity: Number(this.form.get('infillDensity')?.value ?? 15), - infillPattern: this.form.get('infillPattern')?.value || 'grid', - layerHeight: Number(this.form.get('layerHeight')?.value ?? 0.2), - nozzleDiameter: Number(this.form.get('nozzleDiameter')?.value ?? 0.4), - }; - } - - private getDefaultVariantSelection(materialCode?: string): { - colorName: string; - filamentVariantId?: number; - } { - const vars = materialCode - ? this.getVariantsForMaterial(materialCode) - : this.currentMaterialVariants(); - if (vars && vars.length > 0) { - const preferred = vars.find((v) => !v.isOutOfStock) || vars[0]; - return { - colorName: preferred.colorName, - filamentVariantId: preferred.id, - }; - } - return { colorName: 'Black' }; - } - - getVariantsForItem(item: FormItem): VariantOption[] { - return this.getVariantsForMaterialCode(item.printSettings.material); - } - - private getVariantsForMaterialCode(materialCodeRaw: string): VariantOption[] { - const materialCode = String(materialCodeRaw || '').toUpperCase(); - if (!materialCode) { - return []; - } - const material = this.fullMaterialOptions.find( - (option) => String(option.code || '').toUpperCase() === materialCode, - ); - return material?.variants || []; - } - - private syncSelectedItemVariantSelection(): void { - const vars = this.currentMaterialVariants(); - if (!vars || vars.length === 0) { - return; - } - - const selected = this.selectedFile(); - if (!selected) { - return; - } - - const fallback = vars.find((v) => !v.isOutOfStock) || vars[0]; - this.items.update((current) => - current.map((item) => { - if (item.file !== selected) { - return item; - } - const byId = - item.filamentVariantId != null - ? vars.find((v) => v.id === item.filamentVariantId) - : null; - const byColor = vars.find((v) => v.colorName === item.color); - const selectedVariant = byId || byColor || fallback; - return { - ...item, - color: selectedVariant.colorName, - filamentVariantId: selectedVariant.id, - }; - }), - ); + this.emitPrintSettingsChange(); + this.emitItemSettingsDiffChange(); } patchSettings(settings: any) { if (!settings) return; - // settings object matches keys in our form? - // Session has: materialCode, etc. derived from QuoteSession entity properties - // We need to map them if names differ. const patch: any = {}; if (settings.materialCode) patch.material = settings.materialCode; - // Heuristic for Quality if not explicitly stored as "draft/standard/high" - // But we stored it in session creation? - // QuoteSession entity does NOT store "quality" string directly, only layerHeight/infill. - // So we might need to deduce it or just set Custom/Advanced. - // But for Easy mode, we want to show "Standard" etc. - - // Actually, let's look at what we have in QuoteSession. - // layerHeightMm, infillPercent, etc. - // If we are in Easy mode, we might just set the "quality" dropdown to match approx? - // Or if we stored "quality" in notes or separate field? We didn't. - - // Let's try to reverse map or defaults. - if (settings.layerHeightMm) { - if (settings.layerHeightMm >= 0.24) patch.quality = 'draft'; - else if (settings.layerHeightMm <= 0.12) patch.quality = 'extra_fine'; - else patch.quality = 'standard'; - - patch.layerHeight = settings.layerHeightMm; + const layer = Number(settings.layerHeightMm); + if (Number.isFinite(layer)) { + patch.layerHeight = layer; + patch.quality = + layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard'; } - if (settings.nozzleDiameterMm) - patch.nozzleDiameter = settings.nozzleDiameterMm; - if (settings.infillPercent) patch.infillDensity = settings.infillPercent; + const nozzle = Number(settings.nozzleDiameterMm); + if (Number.isFinite(nozzle)) patch.nozzleDiameter = nozzle; + + const infill = Number(settings.infillPercent); + if (Number.isFinite(infill)) patch.infillDensity = infill; + if (settings.infillPattern) patch.infillPattern = settings.infillPattern; if (settings.supportsEnabled !== undefined) - patch.supportEnabled = settings.supportsEnabled; + patch.supportEnabled = Boolean(settings.supportsEnabled); if (settings.notes) patch.notes = settings.notes; this.isPatchingSettings = true; this.form.patchValue(patch, { emitEvent: false }); this.isPatchingSettings = false; + + this.updateVariants(String(this.form.get('material')?.value || '')); this.updateLayerHeightOptionsForNozzle( this.form.get('nozzleDiameter')?.value, true, ); + + if (this.sameSettingsForAll()) { + this.applyGlobalSettingsToAllItems(); + } else { + this.syncSelectedItemSettingsFromForm(); + } + this.emitPrintSettingsChange(); + this.emitItemSettingsDiffChange(); + } + + setFiles(files: File[]) { + const defaults = this.getCurrentGlobalItemDefaults(); + const selection = this.getDefaultVariantSelection(defaults.material); + + const validItems: FormItem[] = files.map((file) => ({ + file, + previewFile: this.isStlFile(file) ? file : undefined, + quantity: 1, + material: defaults.material, + quality: defaults.quality, + color: selection.colorName, + filamentVariantId: selection.filamentVariantId, + supportEnabled: defaults.supportEnabled, + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + layerHeight: defaults.layerHeight, + nozzleDiameter: defaults.nozzleDiameter, + })); + + this.items.set(validItems); + + if (validItems.length > 0) { + this.form.get('itemsTouched')?.setValue(true); + this.selectFile(validItems[validItems.length - 1].file); + } else { + this.selectedFile.set(null); + } + + this.emitItemSettingsDiffChange(); + } + + setPreviewFileByIndex(index: number, previewFile: File) { + if (!Number.isInteger(index) || index < 0) return; + + this.items.update((current) => { + if (index >= current.length) return current; + return current.map((item, idx) => + idx === index ? { ...item, previewFile } : item, + ); + }); + } + + setItemPrintSettingsByIndex(index: number, update: ItemPrintSettingsUpdate) { + if (!Number.isInteger(index) || index < 0) return; + + this.items.update((current) => { + if (index >= current.length) return current; + + return current.map((item, idx) => { + if (idx !== index) { + return item; + } + + let next: FormItem = { + ...item, + ...update, + }; + + if (update.quality !== undefined) { + next.quality = this.normalizeQualityValue(update.quality); + } + + if (update.material !== undefined) { + const variants = this.getVariantsForMaterial(update.material); + const byId = + next.filamentVariantId != null + ? variants.find((v) => v.id === next.filamentVariantId) + : null; + const byColor = variants.find((v) => v.colorName === next.color); + const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; + const variant = byId || byColor || fallback; + if (variant) { + next = { + ...next, + color: variant.colorName, + filamentVariantId: variant.id, + }; + } + } + + return next; + }); + }); + + this.refreshSameSettingsFlag(); + + if (!this.sameSettingsForAll() && this.getSelectedItemIndex() === index) { + this.loadSelectedItemSettingsIntoForm(); + this.emitPrintSettingsChange(); + } + + this.emitItemSettingsDiffChange(); + } + + getCurrentRequestDraft(): QuoteRequest { + const defaults = this.getCurrentGlobalItemDefaults(); + + const items: QuoteRequestItem[] = this.items().map((item) => + this.toRequestItem(item, defaults), + ); + + return { + items, + material: defaults.material, + quality: defaults.quality, + notes: this.form.get('notes')?.value || '', + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + supportEnabled: defaults.supportEnabled, + layerHeight: defaults.layerHeight, + nozzleDiameter: defaults.nozzleDiameter, + mode: this.mode(), + }; } onSubmit() { - console.log('UploadFormComponent: onSubmit triggered'); - console.log('Form Valid:', this.form.valid, 'Items:', this.items().length); - - if (this.form.valid && this.items().length > 0) { - const items = this.items(); - const firstItemMaterial = items[0]?.material; - console.log( - 'UploadFormComponent: Emitting submitRequest', - this.form.value, - ); - this.submitRequest.emit({ - ...this.form.getRawValue(), - items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite - mode: this.mode(), - }); - } else { - console.warn('UploadFormComponent: Form Invalid or No Items'); - console.log('Form Errors:', this.form.errors); - Object.keys(this.form.controls).forEach((key) => { - const control = this.form.get(key); - if (control?.invalid) { - console.log( - 'Invalid Control:', - key, - control.errors, - 'Value:', - control.value, - ); - } - }); + if (!this.form.valid || this.items().length === 0) { this.form.markAllAsTouched(); this.form.get('itemsTouched')?.setValue(true); + return; } + + this.submitRequest.emit(this.getCurrentRequestDraft()); + } + + private setDefaults() { + if (this.materials().length > 0 && !this.form.get('material')?.value) { + const exactPla = this.materials().find( + (m) => typeof m.value === 'string' && m.value.toUpperCase() === 'PLA', + ); + const fallback = exactPla || this.materials()[0]; + this.form.get('material')?.setValue(fallback.value, { emitEvent: false }); + } + + if (this.qualities().length > 0 && !this.form.get('quality')?.value) { + const standard = this.qualities().find((q) => q.value === 'standard'); + this.form + .get('quality') + ?.setValue(standard ? standard.value : this.qualities()[0].value, { + emitEvent: false, + }); + } + + if (this.nozzleDiameters().length > 0 && !this.form.get('nozzleDiameter')?.value) { + this.form.get('nozzleDiameter')?.setValue(0.4, { emitEvent: false }); + } + + if (this.infillPatterns().length > 0 && !this.form.get('infillPattern')?.value) { + this.form + .get('infillPattern') + ?.setValue(this.infillPatterns()[0].value, { emitEvent: false }); + } + + this.updateVariants(String(this.form.get('material')?.value || '')); + this.updateLayerHeightOptionsForNozzle( + this.form.get('nozzleDiameter')?.value, + true, + ); + + if (this.mode() === 'easy') { + this.applyEasyPresetFromQuality(String(this.form.get('quality')?.value || 'standard')); + } + + this.emitPrintSettingsChange(); + } + + private applyEasyPresetFromQuality(qualityRaw: string) { + const preset = this.easyModePresetForQuality(qualityRaw); + + this.isPatchingSettings = true; + this.form.patchValue( + { + quality: preset.quality, + nozzleDiameter: preset.nozzleDiameter, + layerHeight: preset.layerHeight, + infillDensity: preset.infillDensity, + infillPattern: preset.infillPattern, + }, + { emitEvent: false }, + ); + this.isPatchingSettings = false; + + this.updateLayerHeightOptionsForNozzle(preset.nozzleDiameter, true); + } + + private easyModePresetForQuality(qualityRaw: string): { + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + } { + const quality = this.normalizeQualityValue(qualityRaw); + + if (quality === 'draft') { + return { + quality: 'draft', + nozzleDiameter: 0.4, + layerHeight: 0.28, + infillDensity: 15, + infillPattern: 'grid', + }; + } + + if (quality === 'extra_fine') { + return { + quality: 'extra_fine', + nozzleDiameter: 0.4, + layerHeight: 0.12, + infillDensity: 20, + infillPattern: 'gyroid', + }; + } + + return { + quality: 'standard', + nozzleDiameter: 0.4, + layerHeight: 0.2, + infillDensity: 15, + infillPattern: 'grid', + }; + } + + private getCurrentGlobalItemDefaults(): { + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + } { + const material = String(this.form.get('material')?.value || 'PLA'); + const quality = this.normalizeQualityValue(this.form.get('quality')?.value); + + if (this.mode() === 'easy') { + const preset = this.easyModePresetForQuality(quality); + return { + material, + quality: preset.quality, + nozzleDiameter: preset.nozzleDiameter, + layerHeight: preset.layerHeight, + infillDensity: preset.infillDensity, + infillPattern: preset.infillPattern, + supportEnabled: Boolean(this.form.get('supportEnabled')?.value), + }; + } + + return { + material, + quality, + nozzleDiameter: this.normalizeNumber(this.form.get('nozzleDiameter')?.value, 0.4), + layerHeight: this.normalizeNumber(this.form.get('layerHeight')?.value, 0.2), + infillDensity: this.normalizeNumber(this.form.get('infillDensity')?.value, 20), + infillPattern: String(this.form.get('infillPattern')?.value || 'grid'), + supportEnabled: Boolean(this.form.get('supportEnabled')?.value), + }; + } + + private toRequestItem( + item: FormItem, + defaults: ReturnType, + ): QuoteRequestItem { + const quality = this.normalizeQualityValue(item.quality || defaults.quality); + + if (this.mode() === 'easy') { + const preset = this.easyModePresetForQuality(quality); + return { + file: item.file, + quantity: this.normalizeQuantity(item.quantity), + material: item.material || defaults.material, + quality: preset.quality, + color: item.color, + filamentVariantId: item.filamentVariantId, + supportEnabled: item.supportEnabled ?? defaults.supportEnabled, + infillDensity: preset.infillDensity, + infillPattern: preset.infillPattern, + layerHeight: preset.layerHeight, + nozzleDiameter: preset.nozzleDiameter, + }; + } + + return { + file: item.file, + quantity: this.normalizeQuantity(item.quantity), + material: item.material || defaults.material, + quality, + color: item.color, + filamentVariantId: item.filamentVariantId, + supportEnabled: item.supportEnabled, + infillDensity: this.normalizeNumber(item.infillDensity, defaults.infillDensity), + infillPattern: item.infillPattern || defaults.infillPattern, + layerHeight: this.normalizeNumber(item.layerHeight, defaults.layerHeight), + nozzleDiameter: this.normalizeNumber(item.nozzleDiameter, defaults.nozzleDiameter), + }; + } + + private applyGlobalSettingsToAllItems() { + const defaults = this.getCurrentGlobalItemDefaults(); + const variants = this.getVariantsForMaterial(defaults.material); + const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; + + this.items.update((current) => + current.map((item) => { + const byId = + item.filamentVariantId != null + ? variants.find((v) => v.id === item.filamentVariantId) + : null; + const byColor = variants.find((v) => v.colorName === item.color); + const selectedVariant = byId || byColor || fallback; + + return { + ...item, + material: defaults.material, + quality: defaults.quality, + nozzleDiameter: defaults.nozzleDiameter, + layerHeight: defaults.layerHeight, + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + supportEnabled: defaults.supportEnabled, + color: selectedVariant ? selectedVariant.colorName : item.color, + filamentVariantId: selectedVariant + ? selectedVariant.id + : item.filamentVariantId, + }; + }), + ); + } + + private syncSelectedItemSettingsFromForm() { + if (this.sameSettingsForAll()) { + return; + } + + const index = this.getSelectedItemIndex(); + if (index < 0) { + return; + } + + const defaults = this.getCurrentGlobalItemDefaults(); + + this.items.update((current) => { + if (index >= current.length) return current; + + return current.map((item, idx) => { + if (idx !== index) { + return item; + } + + const variants = this.getVariantsForMaterial(defaults.material); + const byId = + item.filamentVariantId != null + ? variants.find((v) => v.id === item.filamentVariantId) + : null; + const byColor = variants.find((v) => v.colorName === item.color); + const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; + const selectedVariant = byId || byColor || fallback; + + return { + ...item, + material: defaults.material, + quality: defaults.quality, + nozzleDiameter: defaults.nozzleDiameter, + layerHeight: defaults.layerHeight, + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + supportEnabled: defaults.supportEnabled, + color: selectedVariant ? selectedVariant.colorName : item.color, + filamentVariantId: selectedVariant + ? selectedVariant.id + : item.filamentVariantId, + }; + }); + }); + } + + private loadSelectedItemSettingsIntoForm() { + if (this.sameSettingsForAll()) { + return; + } + + const selected = this.getSelectedItem(); + if (!selected) { + return; + } + + this.isPatchingSettings = true; + this.form.patchValue( + { + material: selected.material, + quality: this.normalizeQualityValue(selected.quality), + nozzleDiameter: selected.nozzleDiameter, + layerHeight: selected.layerHeight, + infillDensity: selected.infillDensity, + infillPattern: selected.infillPattern, + supportEnabled: selected.supportEnabled, + }, + { emitEvent: false }, + ); + this.isPatchingSettings = false; + + this.updateVariants(selected.material); + this.updateLayerHeightOptionsForNozzle(selected.nozzleDiameter, true); + } + + private updateVariants(materialCode: string) { + const variants = this.getVariantsForMaterial(materialCode); + this.currentMaterialVariants.set(variants); + + if (this.sameSettingsForAll() || !this.selectedFile()) { + return; + } + + if (variants.length === 0) { + return; + } + + const selectedIndex = this.getSelectedItemIndex(); + if (selectedIndex < 0) { + return; + } + + this.items.update((current) => { + if (selectedIndex >= current.length) { + return current; + } + + const selectedItem = current[selectedIndex]; + const byId = + selectedItem.filamentVariantId != null + ? variants.find((v) => v.id === selectedItem.filamentVariantId) + : null; + const byColor = variants.find((v) => v.colorName === selectedItem.color); + const fallback = variants.find((v) => !v.isOutOfStock) || variants[0]; + const selectedVariant = byId || byColor || fallback; + + if (!selectedVariant) { + return current; + } + + return current.map((item, idx) => + idx === selectedIndex + ? { + ...item, + color: selectedVariant.colorName, + filamentVariantId: selectedVariant.id, + } + : item, + ); + }); + } + + private updateLayerHeightOptionsForNozzle( + nozzleRaw: unknown, + clampCurrentLayer: boolean, + ) { + const options = this.getLayerHeightOptionsForNozzle(nozzleRaw); + this.layerHeights.set(options); + + if (!clampCurrentLayer || options.length === 0) { + return; + } + + const currentLayer = this.normalizeNumber(this.form.get('layerHeight')?.value, options[0].value as number); + const allowed = options.some( + (option) => + Math.abs(this.normalizeNumber(option.value, currentLayer) - currentLayer) < + 0.0001, + ); + + if (allowed) { + return; + } + + this.isPatchingSettings = true; + this.form.patchValue( + { + layerHeight: Number(options[0].value), + }, + { emitEvent: false }, + ); + this.isPatchingSettings = false; + } + + private emitPrintSettingsChange() { + const defaults = this.getCurrentGlobalItemDefaults(); + this.printSettingsChange.emit({ + mode: this.mode(), + material: defaults.material, + quality: defaults.quality, + nozzleDiameter: defaults.nozzleDiameter, + layerHeight: defaults.layerHeight, + infillDensity: defaults.infillDensity, + infillPattern: defaults.infillPattern, + supportEnabled: defaults.supportEnabled, + }); + } + + private emitItemSettingsDiffChange() { + if (this.sameSettingsForAll()) { + this.itemSettingsDiffChange.emit({}); + return; + } + + const baseline = this.getCurrentGlobalItemDefaults(); + const diffByFileName: Record = {}; + + this.items().forEach((item) => { + const differences: string[] = []; + + if (this.normalizeText(item.material) !== this.normalizeText(baseline.material)) { + differences.push(item.material.toUpperCase()); + } + + if (this.mode() === 'easy') { + if ( + this.normalizeText(item.quality) !== this.normalizeText(baseline.quality) + ) { + differences.push(`quality:${item.quality}`); + } + } else { + if ( + Math.abs( + this.normalizeNumber(item.nozzleDiameter, baseline.nozzleDiameter) - + baseline.nozzleDiameter, + ) > 0.0001 + ) { + differences.push(`nozzle:${item.nozzleDiameter}`); + } + + if ( + Math.abs( + this.normalizeNumber(item.layerHeight, baseline.layerHeight) - + baseline.layerHeight, + ) > 0.0001 + ) { + differences.push(`layer:${item.layerHeight}`); + } + + if ( + Math.abs( + this.normalizeNumber(item.infillDensity, baseline.infillDensity) - + baseline.infillDensity, + ) > 0.0001 + ) { + differences.push(`infill:${item.infillDensity}%`); + } + + if ( + this.normalizeText(item.infillPattern) !== + this.normalizeText(baseline.infillPattern) + ) { + differences.push(`pattern:${item.infillPattern}`); + } + + if (Boolean(item.supportEnabled) !== Boolean(baseline.supportEnabled)) { + differences.push( + `support:${Boolean(item.supportEnabled) ? 'on' : 'off'}`, + ); + } + } + + if (differences.length > 0) { + diffByFileName[item.file.name] = { differences }; + } + }); + + this.itemSettingsDiffChange.emit(diffByFileName); + } + + private getDefaultVariantSelection(materialCode: string): { + colorName: string; + filamentVariantId?: number; + } { + const variants = this.getVariantsForMaterial(materialCode); + if (variants.length === 0) { + return { colorName: 'Black' }; + } + + const preferred = variants.find((v) => !v.isOutOfStock) || variants[0]; + return { + colorName: preferred.colorName, + filamentVariantId: preferred.id, + }; + } + + private refreshSameSettingsFlag() { + const current = this.items(); + if (current.length <= 1) { + return; + } + + const first = current[0]; + const allEqual = current.every((item) => + this.sameItemSettings(first, item), + ); + + if (!allEqual) { + this.sameSettingsForAll.set(false); + this.form.get('syncAllItems')?.setValue(false, { emitEvent: false }); + } + } + + private sameItemSettings(a: FormItem, b: FormItem): boolean { + return ( + this.normalizeText(a.material) === this.normalizeText(b.material) && + this.normalizeText(a.quality) === this.normalizeText(b.quality) && + Math.abs(this.normalizeNumber(a.nozzleDiameter, 0.4) - this.normalizeNumber(b.nozzleDiameter, 0.4)) < + 0.0001 && + Math.abs(this.normalizeNumber(a.layerHeight, 0.2) - this.normalizeNumber(b.layerHeight, 0.2)) < + 0.0001 && + Math.abs(this.normalizeNumber(a.infillDensity, 20) - this.normalizeNumber(b.infillDensity, 20)) < + 0.0001 && + this.normalizeText(a.infillPattern) === this.normalizeText(b.infillPattern) && + Boolean(a.supportEnabled) === Boolean(b.supportEnabled) + ); + } + + private normalizeQualityValue(value: any): string { + const normalized = String(value || 'standard').trim().toLowerCase(); + if (normalized === 'high' || normalized === 'high_definition') { + return 'extra_fine'; + } + return normalized || 'standard'; } private normalizeQuantity(quantity: number): number { @@ -1004,10 +1199,29 @@ export class UploadFormComponent implements OnInit { return Math.floor(quantity); } + private normalizeNumber(value: any, fallback: number): number { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; + } + + private normalizeText(value: any): string { + return String(value || '') + .trim() + .toLowerCase(); + } + private normalizeFileName(fileName: string): string { return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? ''; } + private toNozzleKey(value: unknown): string { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return '0.40'; + } + return numeric.toFixed(2); + } + private applySettingsLock(locked: boolean): void { const controlsToLock = [ 'syncAllItems', @@ -1023,6 +1237,7 @@ export class UploadFormComponent implements OnInit { controlsToLock.forEach((name) => { const control = this.form.get(name); if (!control) return; + if (locked) { control.disable({ emitEvent: false }); } else {