diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java
index 003ade8..47d7d40 100644
--- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java
+++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java
@@ -2,519 +2,46 @@ package com.printcalculator.controller;
import com.printcalculator.dto.QuoteRequestDto;
import com.printcalculator.entity.CustomQuoteRequest;
-import com.printcalculator.entity.CustomQuoteRequestAttachment;
-import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
-import com.printcalculator.repository.CustomQuoteRequestRepository;
-import com.printcalculator.service.ClamAVService;
-import com.printcalculator.service.email.EmailNotificationService;
+import com.printcalculator.service.request.CustomQuoteRequestControllerService;
import jakarta.validation.Valid;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
-import org.springframework.util.StringUtils;
-import org.springframework.web.bind.annotation.*;
-import org.springframework.web.server.ResponseStatusException;
+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.RequestMapping;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
-import java.io.InputStream;
-import java.nio.file.Files;
-import java.nio.file.InvalidPathException;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.nio.file.StandardCopyOption;
-import java.time.OffsetDateTime;
-import java.time.Year;
-import java.time.format.DateTimeFormatter;
-import java.time.format.FormatStyle;
-import java.util.HashMap;
import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
import java.util.UUID;
-import java.util.regex.Pattern;
@RestController
@RequestMapping("/api/custom-quote-requests")
public class CustomQuoteRequestController {
- private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestController.class);
- private final CustomQuoteRequestRepository requestRepo;
- private final CustomQuoteRequestAttachmentRepository attachmentRepo;
- private final ClamAVService clamAVService;
- private final EmailNotificationService emailNotificationService;
+ private final CustomQuoteRequestControllerService customQuoteRequestControllerService;
- @Value("${app.mail.contact-request.admin.enabled:true}")
- private boolean contactRequestAdminMailEnabled;
-
- @Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}")
- private String contactRequestAdminMailAddress;
-
- @Value("${app.mail.contact-request.customer.enabled:true}")
- private boolean contactRequestCustomerMailEnabled;
-
- // TODO: Inject Storage Service
- private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize();
- private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
- private static final Set FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of(
- "zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst"
- );
- private static final Set FORBIDDEN_COMPRESSED_MIME_TYPES = Set.of(
- "application/zip",
- "application/x-zip-compressed",
- "application/x-rar-compressed",
- "application/vnd.rar",
- "application/x-7z-compressed",
- "application/gzip",
- "application/x-gzip",
- "application/x-tar",
- "application/x-bzip2",
- "application/x-xz",
- "application/zstd",
- "application/x-zstd"
- );
-
- public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
- CustomQuoteRequestAttachmentRepository attachmentRepo,
- ClamAVService clamAVService,
- EmailNotificationService emailNotificationService) {
- this.requestRepo = requestRepo;
- this.attachmentRepo = attachmentRepo;
- this.clamAVService = clamAVService;
- this.emailNotificationService = emailNotificationService;
+ public CustomQuoteRequestController(CustomQuoteRequestControllerService customQuoteRequestControllerService) {
+ this.customQuoteRequestControllerService = customQuoteRequestControllerService;
}
- // 1. Create Custom Quote Request
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional
public ResponseEntity createCustomQuoteRequest(
@Valid @RequestPart("request") QuoteRequestDto requestDto,
@RequestPart(value = "files", required = false) List files
) throws IOException {
- if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) {
- throw new ResponseStatusException(
- HttpStatus.BAD_REQUEST,
- "Accettazione Termini e Privacy obbligatoria."
- );
- }
- String language = normalizeLanguage(requestDto.getLanguage());
-
- // 1. Create Request
- CustomQuoteRequest request = new CustomQuoteRequest();
- request.setRequestType(requestDto.getRequestType());
- request.setCustomerType(requestDto.getCustomerType());
- request.setEmail(requestDto.getEmail());
- request.setPhone(requestDto.getPhone());
- request.setName(requestDto.getName());
- request.setCompanyName(requestDto.getCompanyName());
- request.setContactPerson(requestDto.getContactPerson());
- request.setMessage(requestDto.getMessage());
- request.setStatus("PENDING");
- request.setCreatedAt(OffsetDateTime.now());
- request.setUpdatedAt(OffsetDateTime.now());
-
- request = requestRepo.save(request);
-
- // 2. Handle Attachments
- int attachmentsCount = 0;
- if (files != null && !files.isEmpty()) {
- if (files.size() > 15) {
- throw new IOException("Too many files. Max 15 allowed.");
- }
-
- for (MultipartFile file : files) {
- if (file.isEmpty()) continue;
-
- if (isCompressedFile(file)) {
- throw new ResponseStatusException(
- HttpStatus.BAD_REQUEST,
- "Compressed files are not allowed."
- );
- }
-
- // Scan for virus
- clamAVService.scan(file.getInputStream());
-
- CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment();
- attachment.setRequest(request);
- attachment.setOriginalFilename(file.getOriginalFilename());
- attachment.setMimeType(file.getContentType());
- attachment.setFileSizeBytes(file.getSize());
- attachment.setCreatedAt(OffsetDateTime.now());
-
- // Generate path
- UUID fileUuid = UUID.randomUUID();
- String storedFilename = fileUuid + ".upload";
-
- // Note: We don't have attachment ID yet.
- // We'll save attachment first to get ID.
- attachment.setStoredFilename(storedFilename);
- attachment.setStoredRelativePath("PENDING");
-
- attachment = attachmentRepo.save(attachment);
-
- Path relativePath = Path.of(
- "quote-requests",
- request.getId().toString(),
- "attachments",
- attachment.getId().toString(),
- storedFilename
- );
- attachment.setStoredRelativePath(relativePath.toString());
- attachmentRepo.save(attachment);
-
- // Save file to disk
- Path absolutePath = resolveWithinStorageRoot(relativePath);
- Files.createDirectories(absolutePath.getParent());
- try (InputStream inputStream = file.getInputStream()) {
- Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING);
- }
- attachmentsCount++;
- }
- }
-
- sendAdminContactRequestNotification(request, attachmentsCount);
- sendCustomerContactRequestConfirmation(request, attachmentsCount, language);
-
- return ResponseEntity.ok(request);
+ return ResponseEntity.ok(customQuoteRequestControllerService.createCustomQuoteRequest(requestDto, files));
}
-
- // 2. Get Request
+
@GetMapping("/{id}")
public ResponseEntity getCustomQuoteRequest(@PathVariable UUID id) {
- return requestRepo.findById(id)
+ return customQuoteRequestControllerService.getCustomQuoteRequest(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
-
- // Helper
- private String getExtension(String filename) {
- if (filename == null) return "dat";
- String cleaned = StringUtils.cleanPath(filename);
- if (cleaned.contains("..")) {
- return "dat";
- }
- 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 "dat";
- }
-
- private boolean isCompressedFile(MultipartFile file) {
- String ext = getExtension(file.getOriginalFilename());
- if (FORBIDDEN_COMPRESSED_EXTENSIONS.contains(ext)) {
- return true;
- }
- String mime = file.getContentType();
- return mime != null && FORBIDDEN_COMPRESSED_MIME_TYPES.contains(mime.toLowerCase());
- }
-
- private Path resolveWithinStorageRoot(Path relativePath) {
- try {
- Path normalizedRelative = relativePath.normalize();
- if (normalizedRelative.isAbsolute()) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
- }
- Path absolutePath = STORAGE_ROOT.resolve(normalizedRelative).normalize();
- if (!absolutePath.startsWith(STORAGE_ROOT)) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
- }
- return absolutePath;
- } catch (InvalidPathException e) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
- }
- }
-
- private void sendAdminContactRequestNotification(CustomQuoteRequest request, int attachmentsCount) {
- if (!contactRequestAdminMailEnabled) {
- return;
- }
- if (contactRequestAdminMailAddress == null || contactRequestAdminMailAddress.isBlank()) {
- logger.warn("Contact request admin notification enabled but no admin address configured.");
- return;
- }
-
- Map templateData = new HashMap<>();
- templateData.put("requestId", request.getId());
- templateData.put("createdAt", request.getCreatedAt());
- templateData.put("requestType", safeValue(request.getRequestType()));
- templateData.put("customerType", safeValue(request.getCustomerType()));
- templateData.put("name", safeValue(request.getName()));
- templateData.put("companyName", safeValue(request.getCompanyName()));
- templateData.put("contactPerson", safeValue(request.getContactPerson()));
- templateData.put("email", safeValue(request.getEmail()));
- templateData.put("phone", safeValue(request.getPhone()));
- templateData.put("message", safeValue(request.getMessage()));
- templateData.put("attachmentsCount", attachmentsCount);
- templateData.put("currentYear", Year.now().getValue());
-
- emailNotificationService.sendEmail(
- contactRequestAdminMailAddress,
- "Nuova richiesta di contatto #" + request.getId(),
- "contact-request-admin",
- templateData
- );
- }
-
- private void sendCustomerContactRequestConfirmation(CustomQuoteRequest request, int attachmentsCount, String language) {
- if (!contactRequestCustomerMailEnabled) {
- return;
- }
- if (request.getEmail() == null || request.getEmail().isBlank()) {
- logger.warn("Contact request confirmation skipped: missing customer email for request {}", request.getId());
- return;
- }
-
- Map templateData = new HashMap<>();
- templateData.put("requestId", request.getId());
- templateData.put(
- "createdAt",
- request.getCreatedAt().format(
- DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(localeForLanguage(language))
- )
- );
- templateData.put("recipientName", resolveRecipientName(request, language));
- templateData.put("requestType", localizeRequestType(request.getRequestType(), language));
- templateData.put("customerType", localizeCustomerType(request.getCustomerType(), language));
- templateData.put("name", safeValue(request.getName()));
- templateData.put("companyName", safeValue(request.getCompanyName()));
- templateData.put("contactPerson", safeValue(request.getContactPerson()));
- templateData.put("email", safeValue(request.getEmail()));
- templateData.put("phone", safeValue(request.getPhone()));
- templateData.put("message", safeValue(request.getMessage()));
- templateData.put("attachmentsCount", attachmentsCount);
- templateData.put("currentYear", Year.now().getValue());
- String subject = applyCustomerContactRequestTexts(templateData, language, request.getId());
-
- emailNotificationService.sendEmail(
- request.getEmail(),
- subject,
- "contact-request-customer",
- templateData
- );
- }
-
- private String applyCustomerContactRequestTexts(
- Map templateData,
- String language,
- UUID requestId
- ) {
- return switch (language) {
- case "en" -> {
- templateData.put("emailTitle", "Contact request received");
- templateData.put("headlineText", "We received your contact request");
- templateData.put("greetingText", "Hi " + templateData.get("recipientName") + ",");
- templateData.put("introText", "Thank you for contacting us. Our team will reply as soon as possible.");
- templateData.put("requestIdHintText", "Please keep this request ID for future order references:");
- templateData.put("detailsTitleText", "Request details");
- templateData.put("labelRequestId", "Request ID");
- templateData.put("labelDate", "Date");
- templateData.put("labelRequestType", "Request type");
- templateData.put("labelCustomerType", "Customer type");
- templateData.put("labelName", "Name");
- templateData.put("labelCompany", "Company");
- templateData.put("labelContactPerson", "Contact person");
- templateData.put("labelEmail", "Email");
- templateData.put("labelPhone", "Phone");
- templateData.put("labelMessage", "Message");
- templateData.put("labelAttachments", "Attachments");
- templateData.put("supportText", "If you need help, reply to this email.");
- templateData.put("footerText", "Automated request-receipt confirmation from 3D-Fab.");
- yield "We received your contact request #" + requestId + " - 3D-Fab";
- }
- case "de" -> {
- templateData.put("emailTitle", "Kontaktanfrage erhalten");
- templateData.put("headlineText", "Wir haben Ihre Kontaktanfrage erhalten");
- templateData.put("greetingText", "Hallo " + templateData.get("recipientName") + ",");
- templateData.put("introText", "Vielen Dank fuer Ihre Anfrage. Unser Team antwortet Ihnen so schnell wie moeglich.");
- templateData.put("requestIdHintText", "Bitte speichern Sie diese Anfrage-ID fuer zukuenftige Bestellreferenzen:");
- templateData.put("detailsTitleText", "Anfragedetails");
- templateData.put("labelRequestId", "Anfrage-ID");
- templateData.put("labelDate", "Datum");
- templateData.put("labelRequestType", "Anfragetyp");
- templateData.put("labelCustomerType", "Kundentyp");
- templateData.put("labelName", "Name");
- templateData.put("labelCompany", "Firma");
- templateData.put("labelContactPerson", "Kontaktperson");
- templateData.put("labelEmail", "E-Mail");
- templateData.put("labelPhone", "Telefon");
- templateData.put("labelMessage", "Nachricht");
- templateData.put("labelAttachments", "Anhaenge");
- templateData.put("supportText", "Wenn Sie Hilfe brauchen, antworten Sie auf diese E-Mail.");
- templateData.put("footerText", "Automatische Bestaetigung des Anfrageeingangs von 3D-Fab.");
- yield "Wir haben Ihre Kontaktanfrage erhalten #" + requestId + " - 3D-Fab";
- }
- case "fr" -> {
- templateData.put("emailTitle", "Demande de contact recue");
- templateData.put("headlineText", "Nous avons recu votre demande de contact");
- templateData.put("greetingText", "Bonjour " + templateData.get("recipientName") + ",");
- templateData.put("introText", "Merci pour votre message. Notre equipe vous repondra des que possible.");
- templateData.put("requestIdHintText", "Veuillez conserver cet ID de demande pour vos futures references de commande :");
- templateData.put("detailsTitleText", "Details de la demande");
- templateData.put("labelRequestId", "ID de demande");
- templateData.put("labelDate", "Date");
- templateData.put("labelRequestType", "Type de demande");
- templateData.put("labelCustomerType", "Type de client");
- templateData.put("labelName", "Nom");
- templateData.put("labelCompany", "Entreprise");
- templateData.put("labelContactPerson", "Contact");
- templateData.put("labelEmail", "Email");
- templateData.put("labelPhone", "Telephone");
- templateData.put("labelMessage", "Message");
- templateData.put("labelAttachments", "Pieces jointes");
- templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email.");
- templateData.put("footerText", "Confirmation automatique de reception de demande par 3D-Fab.");
- yield "Nous avons recu votre demande de contact #" + requestId + " - 3D-Fab";
- }
- default -> {
- templateData.put("emailTitle", "Richiesta di contatto ricevuta");
- templateData.put("headlineText", "Abbiamo ricevuto la tua richiesta di contatto");
- templateData.put("greetingText", "Ciao " + templateData.get("recipientName") + ",");
- templateData.put("introText", "Grazie per averci contattato. Il nostro team ti rispondera' il prima possibile.");
- templateData.put("requestIdHintText", "Conserva questo ID richiesta per i futuri riferimenti d'ordine:");
- templateData.put("detailsTitleText", "Dettagli richiesta");
- templateData.put("labelRequestId", "ID richiesta");
- templateData.put("labelDate", "Data");
- templateData.put("labelRequestType", "Tipo richiesta");
- templateData.put("labelCustomerType", "Tipo cliente");
- templateData.put("labelName", "Nome");
- templateData.put("labelCompany", "Azienda");
- templateData.put("labelContactPerson", "Contatto");
- templateData.put("labelEmail", "Email");
- templateData.put("labelPhone", "Telefono");
- templateData.put("labelMessage", "Messaggio");
- templateData.put("labelAttachments", "Allegati");
- templateData.put("supportText", "Se hai bisogno, rispondi direttamente a questa email.");
- templateData.put("footerText", "Conferma automatica di ricezione richiesta da 3D-Fab.");
- yield "Abbiamo ricevuto la tua richiesta di contatto #" + requestId + " - 3D-Fab";
- }
- };
- }
-
- private String localizeRequestType(String requestType, String language) {
- if (requestType == null || requestType.isBlank()) {
- return "-";
- }
-
- String normalized = requestType.trim().toLowerCase(Locale.ROOT);
- return switch (language) {
- case "en" -> switch (normalized) {
- case "custom", "print_service" -> "Custom part request";
- case "series" -> "Series production request";
- case "consult", "design_service" -> "Consultation request";
- case "question" -> "General question";
- default -> requestType;
- };
- case "de" -> switch (normalized) {
- case "custom", "print_service" -> "Anfrage fuer Einzelteil";
- case "series" -> "Anfrage fuer Serienproduktion";
- case "consult", "design_service" -> "Beratungsanfrage";
- case "question" -> "Allgemeine Frage";
- default -> requestType;
- };
- case "fr" -> switch (normalized) {
- case "custom", "print_service" -> "Demande de piece personnalisee";
- case "series" -> "Demande de production en serie";
- case "consult", "design_service" -> "Demande de conseil";
- case "question" -> "Question generale";
- default -> requestType;
- };
- default -> switch (normalized) {
- case "custom", "print_service" -> "Richiesta pezzo personalizzato";
- case "series" -> "Richiesta produzione in serie";
- case "consult", "design_service" -> "Richiesta consulenza";
- case "question" -> "Domanda generale";
- default -> requestType;
- };
- };
- }
-
- private String localizeCustomerType(String customerType, String language) {
- if (customerType == null || customerType.isBlank()) {
- return "-";
- }
- String normalized = customerType.trim().toLowerCase(Locale.ROOT);
- return switch (language) {
- case "en" -> switch (normalized) {
- case "private" -> "Private";
- case "business" -> "Business";
- default -> customerType;
- };
- case "de" -> switch (normalized) {
- case "private" -> "Privat";
- case "business" -> "Unternehmen";
- default -> customerType;
- };
- case "fr" -> switch (normalized) {
- case "private" -> "Prive";
- case "business" -> "Entreprise";
- default -> customerType;
- };
- default -> switch (normalized) {
- case "private" -> "Privato";
- case "business" -> "Azienda";
- default -> customerType;
- };
- };
- }
-
- private Locale localeForLanguage(String language) {
- return switch (language) {
- case "en" -> Locale.ENGLISH;
- case "de" -> Locale.GERMAN;
- case "fr" -> Locale.FRENCH;
- default -> Locale.ITALIAN;
- };
- }
-
- private String normalizeLanguage(String language) {
- if (language == null || language.isBlank()) {
- return "it";
- }
- String normalized = language.toLowerCase(Locale.ROOT).trim();
- if (normalized.startsWith("en")) {
- return "en";
- }
- if (normalized.startsWith("de")) {
- return "de";
- }
- if (normalized.startsWith("fr")) {
- return "fr";
- }
- return "it";
- }
-
- private String resolveRecipientName(CustomQuoteRequest request, String language) {
- if (request.getName() != null && !request.getName().isBlank()) {
- return request.getName().trim();
- }
- if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) {
- return request.getContactPerson().trim();
- }
- if (request.getCompanyName() != null && !request.getCompanyName().isBlank()) {
- return request.getCompanyName().trim();
- }
- return switch (language) {
- case "en" -> "customer";
- case "de" -> "Kunde";
- case "fr" -> "client";
- default -> "cliente";
- };
- }
-
- private String safeValue(String value) {
- if (value == null || value.isBlank()) {
- return "-";
- }
- return value;
- }
}
diff --git a/backend/src/main/java/com/printcalculator/controller/OptionsController.java b/backend/src/main/java/com/printcalculator/controller/OptionsController.java
index e187422..28a1abb 100644
--- a/backend/src/main/java/com/printcalculator/controller/OptionsController.java
+++ b/backend/src/main/java/com/printcalculator/controller/OptionsController.java
@@ -3,18 +3,19 @@ package com.printcalculator.controller;
import com.printcalculator.dto.OptionsResponse;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
-import com.printcalculator.entity.LayerHeightOption;
import com.printcalculator.entity.MaterialOrcaProfileMap;
import com.printcalculator.entity.NozzleOption;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.PrinterMachineProfile;
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
-import com.printcalculator.repository.LayerHeightOptionRepository;
import com.printcalculator.repository.MaterialOrcaProfileMapRepository;
import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PrinterMachineRepository;
+import com.printcalculator.repository.PrinterMachineProfileRepository;
+import com.printcalculator.service.NozzleLayerHeightPolicyService;
import com.printcalculator.service.OrcaProfileResolver;
+import com.printcalculator.service.ProfileManager;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
@@ -23,7 +24,11 @@ import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.Comparator;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
+import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Collectors;
@@ -32,26 +37,32 @@ public class OptionsController {
private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
- private final LayerHeightOptionRepository layerHeightRepo;
private final NozzleOptionRepository nozzleRepo;
private final PrinterMachineRepository printerMachineRepo;
+ private final PrinterMachineProfileRepository printerMachineProfileRepo;
private final MaterialOrcaProfileMapRepository materialOrcaMapRepo;
private final OrcaProfileResolver orcaProfileResolver;
+ private final ProfileManager profileManager;
+ private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
public OptionsController(FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
- LayerHeightOptionRepository layerHeightRepo,
NozzleOptionRepository nozzleRepo,
PrinterMachineRepository printerMachineRepo,
+ PrinterMachineProfileRepository printerMachineProfileRepo,
MaterialOrcaProfileMapRepository materialOrcaMapRepo,
- OrcaProfileResolver orcaProfileResolver) {
+ OrcaProfileResolver orcaProfileResolver,
+ ProfileManager profileManager,
+ NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) {
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
- this.layerHeightRepo = layerHeightRepo;
this.nozzleRepo = nozzleRepo;
this.printerMachineRepo = printerMachineRepo;
+ this.printerMachineProfileRepo = printerMachineProfileRepo;
this.materialOrcaMapRepo = materialOrcaMapRepo;
this.orcaProfileResolver = orcaProfileResolver;
+ this.profileManager = profileManager;
+ this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
}
@GetMapping("/api/calculator/options")
@@ -116,17 +127,27 @@ public class OptionsController {
new OptionsResponse.InfillPatternOption("cubic", "Cubic")
);
- List layers = layerHeightRepo.findAll().stream()
- .filter(l -> Boolean.TRUE.equals(l.getIsActive()))
- .sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm))
- .map(l -> new OptionsResponse.LayerHeightOptionDTO(
- l.getLayerHeightMm().doubleValue(),
- String.format("%.2f mm", l.getLayerHeightMm())
- ))
- .toList();
+ PrinterMachine targetMachine = resolveMachine(printerMachineId);
+
+ Set supportedMachineNozzles = targetMachine != null
+ ? printerMachineProfileRepo.findByPrinterMachineAndIsActiveTrue(targetMachine).stream()
+ .map(PrinterMachineProfile::getNozzleDiameterMm)
+ .filter(v -> v != null)
+ .map(nozzleLayerHeightPolicyService::normalizeNozzle)
+ .collect(Collectors.toCollection(LinkedHashSet::new))
+ : Set.of();
+
+ boolean restrictNozzlesByMachineProfile = !supportedMachineNozzles.isEmpty();
List nozzles = nozzleRepo.findAll().stream()
.filter(n -> Boolean.TRUE.equals(n.getIsActive()))
+ .filter(n -> {
+ if (!restrictNozzlesByMachineProfile) {
+ return true;
+ }
+ BigDecimal normalized = nozzleLayerHeightPolicyService.normalizeNozzle(n.getNozzleDiameterMm());
+ return normalized != null && supportedMachineNozzles.contains(normalized);
+ })
.sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm))
.map(n -> new OptionsResponse.NozzleOptionDTO(
n.getNozzleDiameterMm().doubleValue(),
@@ -137,24 +158,88 @@ public class OptionsController {
))
.toList();
- return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles));
+ Map> rulesByNozzle = nozzleLayerHeightPolicyService.getActiveRulesByNozzle();
+ Set visibleNozzlesFromOptions = nozzles.stream()
+ .map(OptionsResponse.NozzleOptionDTO::value)
+ .map(BigDecimal::valueOf)
+ .map(nozzleLayerHeightPolicyService::normalizeNozzle)
+ .filter(v -> v != null)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+
+ Map> effectiveRulesByNozzle = new LinkedHashMap<>();
+ for (BigDecimal nozzle : visibleNozzlesFromOptions) {
+ List policyLayers = rulesByNozzle.getOrDefault(nozzle, List.of());
+ List compatibleProcessLayers = resolveCompatibleProcessLayers(targetMachine, nozzle);
+ List effective = mergePolicyAndProcessLayers(policyLayers, compatibleProcessLayers);
+ if (!effective.isEmpty()) {
+ effectiveRulesByNozzle.put(nozzle, effective);
+ }
+ }
+ if (effectiveRulesByNozzle.isEmpty()) {
+ for (BigDecimal nozzle : visibleNozzlesFromOptions) {
+ List policyLayers = rulesByNozzle.getOrDefault(nozzle, List.of());
+ if (!policyLayers.isEmpty()) {
+ effectiveRulesByNozzle.put(nozzle, policyLayers);
+ }
+ }
+ }
+
+ Set visibleNozzles = new LinkedHashSet<>(effectiveRulesByNozzle.keySet());
+ nozzles = nozzles.stream()
+ .filter(option -> {
+ BigDecimal normalized = nozzleLayerHeightPolicyService.normalizeNozzle(
+ BigDecimal.valueOf(option.value())
+ );
+ return normalized != null && visibleNozzles.contains(normalized);
+ })
+ .toList();
+
+ BigDecimal selectedNozzle = nozzleLayerHeightPolicyService.resolveNozzle(
+ nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
+ );
+ if (!visibleNozzles.isEmpty() && !visibleNozzles.contains(selectedNozzle)) {
+ selectedNozzle = visibleNozzles.iterator().next();
+ }
+
+ List layers = toLayerDtos(
+ effectiveRulesByNozzle.getOrDefault(selectedNozzle, List.of())
+ );
+ if (layers.isEmpty()) {
+ if (!visibleNozzles.isEmpty()) {
+ BigDecimal fallbackNozzle = visibleNozzles.iterator().next();
+ layers = toLayerDtos(effectiveRulesByNozzle.getOrDefault(fallbackNozzle, List.of()));
+ }
+ if (layers.isEmpty()) {
+ layers = rulesByNozzle.values().stream().findFirst().map(this::toLayerDtos).orElse(List.of());
+ }
+ }
+
+ List layerHeightsByNozzle = effectiveRulesByNozzle.entrySet().stream()
+ .map(entry -> new OptionsResponse.NozzleLayerHeightOptionsDTO(
+ entry.getKey().doubleValue(),
+ toLayerDtos(entry.getValue())
+ ))
+ .toList();
+
+ return ResponseEntity.ok(new OptionsResponse(
+ materialOptions,
+ qualities,
+ patterns,
+ layers,
+ nozzles,
+ layerHeightsByNozzle
+ ));
}
private Set resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) {
- PrinterMachine machine = null;
- if (printerMachineId != null) {
- machine = printerMachineRepo.findById(printerMachineId).orElse(null);
- }
- if (machine == null) {
- machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null);
- }
+ PrinterMachine machine = resolveMachine(printerMachineId);
if (machine == null) {
return Set.of();
}
- BigDecimal nozzle = nozzleDiameter != null
- ? BigDecimal.valueOf(nozzleDiameter)
- : BigDecimal.valueOf(0.40);
+ BigDecimal nozzle = nozzleLayerHeightPolicyService.resolveNozzle(
+ nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
+ );
PrinterMachineProfile machineProfile = orcaProfileResolver
.resolveMachineProfile(machine, nozzle)
@@ -172,6 +257,73 @@ public class OptionsController {
.collect(Collectors.toSet());
}
+ private PrinterMachine resolveMachine(Long printerMachineId) {
+ PrinterMachine machine = null;
+ if (printerMachineId != null) {
+ machine = printerMachineRepo.findById(printerMachineId).orElse(null);
+ }
+ if (machine == null) {
+ machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null);
+ }
+ return machine;
+ }
+
+ private List toLayerDtos(List layers) {
+ return layers.stream()
+ .sorted(Comparator.naturalOrder())
+ .map(layer -> new OptionsResponse.LayerHeightOptionDTO(
+ layer.doubleValue(),
+ String.format("%.2f mm", layer)
+ ))
+ .toList();
+ }
+
+ private List resolveCompatibleProcessLayers(PrinterMachine machine, BigDecimal nozzle) {
+ if (machine == null || nozzle == null) {
+ return List.of();
+ }
+ PrinterMachineProfile profile = orcaProfileResolver.resolveMachineProfile(machine, nozzle).orElse(null);
+ if (profile == null || profile.getOrcaMachineProfileName() == null) {
+ return List.of();
+ }
+ return profileManager.findCompatibleProcessLayers(profile.getOrcaMachineProfileName());
+ }
+
+ private List mergePolicyAndProcessLayers(List policyLayers,
+ List processLayers) {
+ if ((processLayers == null || processLayers.isEmpty())
+ && (policyLayers == null || policyLayers.isEmpty())) {
+ return List.of();
+ }
+
+ if (processLayers == null || processLayers.isEmpty()) {
+ return policyLayers != null ? policyLayers : List.of();
+ }
+
+ if (policyLayers == null || policyLayers.isEmpty()) {
+ return processLayers;
+ }
+
+ Set allowedByPolicy = policyLayers.stream()
+ .map(nozzleLayerHeightPolicyService::normalizeLayer)
+ .filter(v -> v != null)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+
+ List intersection = processLayers.stream()
+ .map(nozzleLayerHeightPolicyService::normalizeLayer)
+ .filter(v -> v != null && allowedByPolicy.contains(v))
+ .collect(Collectors.toCollection(ArrayList::new));
+
+ if (!intersection.isEmpty()) {
+ return intersection;
+ }
+
+ return processLayers.stream()
+ .map(nozzleLayerHeightPolicyService::normalizeLayer)
+ .filter(v -> v != null)
+ .collect(Collectors.toCollection(ArrayList::new));
+ }
+
private String resolveHexColor(FilamentVariant variant) {
if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) {
return variant.getColorHex();
diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java
index c8a2b5c..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.InvoicePdfRenderingService;
-import com.printcalculator.service.OrderService;
-import com.printcalculator.service.PaymentService;
-import com.printcalculator.service.QrBillService;
-import com.printcalculator.service.StorageService;
-import com.printcalculator.service.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
- Qta: {{ item.quantity }} | Colore:
+ Qta: {{ item.quantity }} | Materiale:
+ {{ getItemMaterialLabel(item) }} | Colore:
- {{ item.colorCode || "-" }}
+
+ {{ getItemColorLabel(item) }}
+
+ ({{ colorCode }})
+
+
+ | Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer:
+ {{ item.layerHeightMm ?? "-" }} mm | Infill:
+ {{ item.infillPercent ?? "-" }}% | Supporti:
+ {{ formatSupports(item.supportsEnabled) }}
| Riga:
{{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}
@@ -273,17 +283,16 @@
- Colori file
+ Parametri per file
{{ item.originalFilename }}
-
- {{ item.colorCode || "-" }}
+ {{ getItemMaterialLabel(item) }} | Colore:
+ {{ getItemColorLabel(item) }} | {{ item.nozzleDiameterMm ?? "-" }} mm
+ | {{ item.layerHeightMm ?? "-" }} mm |
+ {{ item.infillPercent ?? "-" }}% | {{ item.infillPattern || "-" }} |
+ {{ formatSupportsState(item.supportsEnabled) }}
diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts
index 74dbd4c..836425e 100644
--- a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts
+++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts
@@ -3,6 +3,7 @@ import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
AdminOrder,
+ AdminOrderItem,
AdminOrdersService,
} from '../services/admin-orders.service';
import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.directive';
@@ -273,6 +274,68 @@ export class AdminDashboardComponent implements OnInit {
);
}
+ getItemMaterialLabel(item: AdminOrderItem): string {
+ const variantName = (item.filamentVariantDisplayName || '').trim();
+ const materialCode = (item.materialCode || '').trim();
+ if (!variantName) {
+ return materialCode || '-';
+ }
+ if (!materialCode) {
+ return variantName;
+ }
+ const normalizedVariant = variantName.toLowerCase();
+ const normalizedCode = materialCode.toLowerCase();
+ return normalizedVariant.includes(normalizedCode)
+ ? variantName
+ : `${variantName} (${materialCode})`;
+ }
+
+ getItemColorLabel(item: AdminOrderItem): string {
+ const colorName = (item.filamentColorName || '').trim();
+ const colorCode = (item.colorCode || '').trim();
+ return colorName || colorCode || '-';
+ }
+
+ getItemColorHex(item: AdminOrderItem): string | null {
+ const variantHex = (item.filamentColorHex || '').trim();
+ if (this.isHexColor(variantHex)) {
+ return variantHex;
+ }
+ const code = (item.colorCode || '').trim();
+ if (this.isHexColor(code)) {
+ return code;
+ }
+ return null;
+ }
+
+ getItemColorCodeSuffix(item: AdminOrderItem): string | null {
+ const colorHex = this.getItemColorHex(item);
+ if (!colorHex) {
+ return null;
+ }
+ return colorHex === this.getItemColorLabel(item) ? null : colorHex;
+ }
+
+ formatSupports(value?: boolean): string {
+ if (value === true) {
+ return 'Sì';
+ }
+ if (value === false) {
+ return 'No';
+ }
+ return '-';
+ }
+
+ formatSupportsState(value?: boolean): string {
+ if (value === true) {
+ return 'Supporti ON';
+ }
+ if (value === false) {
+ return 'Supporti OFF';
+ }
+ return 'Supporti -';
+ }
+
isSelected(orderId: string): boolean {
return this.selectedOrder?.id === orderId;
}
diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.html b/frontend/src/app/features/admin/pages/admin-sessions.component.html
index 48492ef..aea368c 100644
--- a/frontend/src/app/features/admin/pages/admin-sessions.component.html
+++ b/frontend/src/app/features/admin/pages/admin-sessions.component.html
@@ -126,6 +126,7 @@
Qta |
Tempo |
Materiale |
+ Scelte utente |
Stato |
Prezzo unit. |
@@ -142,6 +143,16 @@
: "-"
}}
+
+ {{ item.materialCode || "-" }} |
+ {{ item.nozzleDiameterMm ?? "-" }} mm |
+ {{ item.layerHeightMm ?? "-" }} mm |
+ {{ item.infillPercent ?? "-" }}% |
+ {{ item.infillPattern || "-" }} |
+ {{
+ item.supportsEnabled ? "Supporti ON" : "Supporti OFF"
+ }}
+ |
{{ item.status }} |
{{ item.unitPriceChf | currency: "CHF" }} |
diff --git a/frontend/src/app/features/admin/services/admin-operations.service.ts b/frontend/src/app/features/admin/services/admin-operations.service.ts
index 2df5333..4f21cf5 100644
--- a/frontend/src/app/features/admin/services/admin-operations.service.ts
+++ b/frontend/src/app/features/admin/services/admin-operations.service.ts
@@ -127,7 +127,15 @@ export interface AdminQuoteSessionDetailItem {
quantity: number;
printTimeSeconds?: number;
materialGrams?: number;
+ materialCode?: string;
+ quality?: string;
+ nozzleDiameterMm?: number;
+ layerHeightMm?: number;
+ infillPercent?: number;
+ infillPattern?: string;
+ supportsEnabled?: boolean;
colorCode?: string;
+ filamentVariantId?: number;
status: string;
unitPriceChf: number;
}
diff --git a/frontend/src/app/features/admin/services/admin-orders.service.ts b/frontend/src/app/features/admin/services/admin-orders.service.ts
index 39225fe..0ac32c4 100644
--- a/frontend/src/app/features/admin/services/admin-orders.service.ts
+++ b/frontend/src/app/features/admin/services/admin-orders.service.ts
@@ -8,6 +8,16 @@ export interface AdminOrderItem {
originalFilename: string;
materialCode: string;
colorCode: string;
+ filamentVariantId?: number;
+ filamentVariantDisplayName?: string;
+ filamentColorName?: string;
+ filamentColorHex?: string;
+ quality?: string;
+ nozzleDiameterMm?: number;
+ layerHeightMm?: number;
+ infillPercent?: number;
+ infillPattern?: string;
+ supportsEnabled?: boolean;
quantity: number;
printTimeSeconds: number;
materialGrams: number;
diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html
index 070b68d..14c4970 100644
--- a/frontend/src/app/features/calculator/calculator-page.component.html
+++ b/frontend/src/app/features/calculator/calculator-page.component.html
@@ -24,7 +24,7 @@
class="mode-option"
[class.active]="mode() === 'easy'"
[class.disabled]="cadSessionLocked()"
- (click)="!cadSessionLocked() && mode.set('easy')"
+ (click)="switchMode('easy')"
>
{{ "CALC.MODE_EASY" | translate }}
@@ -32,7 +32,7 @@
class="mode-option"
[class.active]="mode() === 'advanced'"
[class.disabled]="cadSessionLocked()"
- (click)="!cadSessionLocked() && mode.set('advanced')"
+ (click)="switchMode('advanced')"
>
{{ "CALC.MODE_ADVANCED" | translate }}
@@ -45,6 +45,9 @@
[loading]="loading()"
[uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)"
+ (itemQuantityChange)="onUploadItemQuantityChange($event)"
+ (printSettingsChange)="onUploadPrintSettingsChange($event)"
+ (itemSettingsDiffChange)="onItemSettingsDiffChange($event)"
>
@@ -64,8 +67,11 @@
} @else if (result()) {
} @else if (isZeroQuoteError()) {
diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts
index 6e56a60..1bfdff4 100644
--- a/frontend/src/app/features/calculator/calculator-page.component.ts
+++ b/frontend/src/app/features/calculator/calculator-page.component.ts
@@ -25,6 +25,17 @@ import { SuccessStateComponent } from '../../shared/components/success-state/suc
import { Router, ActivatedRoute } from '@angular/router';
import { LanguageService } from '../../core/services/language.service';
+type TrackedPrintSettings = {
+ mode: 'easy' | 'advanced';
+ material: string;
+ quality: string;
+ nozzleDiameter: number;
+ layerHeight: number;
+ infillDensity: number;
+ infillPattern: string;
+ supportEnabled: boolean;
+};
+
@Component({
selector: 'app-calculator-page',
standalone: true,
@@ -42,7 +53,7 @@ import { LanguageService } from '../../core/services/language.service';
styleUrl: './calculator-page.component.scss',
})
export class CalculatorPageComponent implements OnInit {
- mode = signal('easy');
+ mode = signal<'easy' | 'advanced'>('easy');
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
loading = signal(false);
@@ -56,6 +67,15 @@ export class CalculatorPageComponent implements OnInit {
);
orderSuccess = signal(false);
+ requiresRecalculation = signal(false);
+ itemSettingsDiffByFileName = signal<
+ Record
+ >({});
+ private baselinePrintSettings: TrackedPrintSettings | null = null;
+ private baselineItemSettingsByFileName = new Map<
+ string,
+ TrackedPrintSettings
+ >();
@ViewChild('uploadForm') uploadForm!: UploadFormComponent;
@ViewChild('resultCol') resultCol!: ElementRef;
@@ -101,6 +121,15 @@ export class CalculatorPageComponent implements OnInit {
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(result);
+ this.baselinePrintSettings = this.toTrackedSettingsFromSession(
+ data.session,
+ );
+ this.baselineItemSettingsByFileName = this.buildBaselineMapFromSession(
+ data.items || [],
+ this.baselinePrintSettings,
+ );
+ this.requiresRecalculation.set(false);
+ this.itemSettingsDiffByFileName.set({});
const isCadSession = data?.session?.status === 'CAD_ACTIVE';
this.cadSessionLocked.set(isCadSession);
this.step.set('quote');
@@ -173,23 +202,40 @@ export class CalculatorPageComponent implements OnInit {
});
this.uploadForm.patchSettings(session);
- // Also restore colors?
- // setFiles inits with 'Black'. We need to update them if they differ.
- // items has colorCode.
- setTimeout(() => {
- if (this.uploadForm) {
- items.forEach((item, index) => {
- // Assuming index matches.
- // Need to be careful if items order changed, but usually ID sort or insert order.
- if (item.colorCode) {
- this.uploadForm.updateItemColor(index, {
- colorName: item.colorCode,
- filamentVariantId: item.filamentVariantId,
- });
- }
+ items.forEach((item, index) => {
+ // Preserve persisted quantities when restoring from session.
+ // Without this, setFiles() defaults every item back to 1.
+ this.uploadForm.updateItemQuantityByIndex(
+ index,
+ Number(item.quantity || 1),
+ );
+
+ const tracked = this.toTrackedSettingsFromSessionItem(
+ item,
+ this.toTrackedSettingsFromSession(session),
+ );
+ this.uploadForm.setItemPrintSettingsByIndex(index, {
+ material: tracked.material.toUpperCase(),
+ quality: tracked.quality,
+ nozzleDiameter: tracked.nozzleDiameter,
+ layerHeight: tracked.layerHeight,
+ infillDensity: tracked.infillDensity,
+ infillPattern: tracked.infillPattern,
+ supportEnabled: tracked.supportEnabled,
+ });
+
+ if (item.colorCode) {
+ this.uploadForm.updateItemColor(index, {
+ colorName: item.colorCode,
+ filamentVariantId: item.filamentVariantId,
});
}
});
+
+ const selected = this.uploadForm.selectedFile();
+ if (selected) {
+ this.uploadForm.selectFile(selected);
+ }
}
this.loading.set(false);
},
@@ -238,6 +284,11 @@ export class CalculatorPageComponent implements OnInit {
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(res);
+ this.baselinePrintSettings = this.toTrackedSettingsFromRequest(req);
+ this.baselineItemSettingsByFileName =
+ this.buildBaselineMapFromRequest(req);
+ this.requiresRecalculation.set(false);
+ this.itemSettingsDiffByFileName.set({});
this.loading.set(false);
this.uploadProgress.set(100);
this.step.set('quote');
@@ -295,9 +346,10 @@ export class CalculatorPageComponent implements OnInit {
index: number;
fileName: string;
quantity: number;
+ source?: 'left' | 'right';
}) {
// 1. Update local form for consistency (UI feedback)
- if (this.uploadForm) {
+ if (event.source !== 'left' && this.uploadForm) {
this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity);
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
}
@@ -340,6 +392,33 @@ export class CalculatorPageComponent implements OnInit {
}
}
+ onUploadItemQuantityChange(event: {
+ index: number;
+ fileName: string;
+ quantity: number;
+ }) {
+ const resultItems = this.result()?.items || [];
+ const byIndex = resultItems[event.index];
+ const byName = resultItems.find((item) => item.fileName === event.fileName);
+ const id = byIndex?.id ?? byName?.id;
+
+ this.onItemChange({
+ ...event,
+ id,
+ source: 'left',
+ });
+ }
+
+ onQuoteItemQuantityPreviewChange(event: {
+ index: number;
+ fileName: string;
+ quantity: number;
+ }) {
+ if (!this.uploadForm) return;
+ this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity);
+ this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
+ }
+
onSubmitOrder(orderData: any) {
console.log('Order Submitted:', orderData);
this.orderSuccess.set(true);
@@ -349,15 +428,37 @@ export class CalculatorPageComponent implements OnInit {
onNewQuote() {
this.step.set('upload');
this.result.set(null);
+ this.requiresRecalculation.set(false);
+ this.itemSettingsDiffByFileName.set({});
+ this.baselinePrintSettings = null;
+ this.baselineItemSettingsByFileName = new Map<
+ string,
+ TrackedPrintSettings
+ >();
this.cadSessionLocked.set(false);
this.orderSuccess.set(false);
- this.mode.set('easy'); // Reset to default
+ this.switchMode('easy'); // Reset to default and sync URL
}
private currentRequest: QuoteRequest | null = null;
+ onUploadPrintSettingsChange(_: TrackedPrintSettings) {
+ void _;
+ if (!this.result()) return;
+ this.refreshRecalculationRequirement();
+ }
+
+ onItemSettingsDiffChange(
+ diffByFileName: Record,
+ ) {
+ this.itemSettingsDiffByFileName.set(diffByFileName || {});
+ }
+
onConsult() {
- if (!this.currentRequest) {
+ const currentFormRequest = this.uploadForm?.getCurrentRequestDraft();
+ const req = currentFormRequest ?? this.currentRequest;
+
+ if (!req) {
this.router.navigate([
'/',
this.languageService.selectedLang(),
@@ -366,7 +467,6 @@ export class CalculatorPageComponent implements OnInit {
return;
}
- const req = this.currentRequest;
let details = `Richiesta Preventivo:\n`;
details += `- Materiale: ${req.material}\n`;
details += `- Qualità: ${req.quality}\n`;
@@ -411,5 +511,231 @@ export class CalculatorPageComponent implements OnInit {
this.errorKey.set(key);
this.error.set(true);
this.result.set(null);
+ this.requiresRecalculation.set(false);
+ this.itemSettingsDiffByFileName.set({});
+ this.baselinePrintSettings = null;
+ this.baselineItemSettingsByFileName = new Map<
+ string,
+ TrackedPrintSettings
+ >();
+ }
+
+ switchMode(nextMode: 'easy' | 'advanced'): void {
+ if (this.cadSessionLocked()) return;
+
+ const targetPath = nextMode === 'easy' ? 'basic' : 'advanced';
+ const currentPath = this.route.snapshot.routeConfig?.path;
+
+ this.mode.set(nextMode);
+
+ if (currentPath === targetPath) {
+ return;
+ }
+
+ this.router.navigate(['..', targetPath], {
+ relativeTo: this.route,
+ queryParamsHandling: 'preserve',
+ });
+ }
+
+ private toTrackedSettingsFromRequest(
+ req: QuoteRequest,
+ ): TrackedPrintSettings {
+ return {
+ mode: req.mode,
+ material: this.normalizeString(req.material || 'PLA'),
+ quality: this.normalizeString(req.quality || 'standard'),
+ nozzleDiameter: this.normalizeNumber(req.nozzleDiameter, 0.4, 2),
+ layerHeight: this.normalizeNumber(req.layerHeight, 0.2, 3),
+ infillDensity: this.normalizeNumber(req.infillDensity, 20, 2),
+ infillPattern: this.normalizeString(req.infillPattern || 'grid'),
+ supportEnabled: Boolean(req.supportEnabled),
+ };
+ }
+
+ private toTrackedSettingsFromItem(
+ req: QuoteRequest,
+ item: QuoteRequest['items'][number],
+ ): TrackedPrintSettings {
+ return {
+ mode: req.mode,
+ material: this.normalizeString(item.material || req.material || 'PLA'),
+ quality: this.normalizeString(item.quality || req.quality || 'standard'),
+ nozzleDiameter: this.normalizeNumber(
+ item.nozzleDiameter ?? req.nozzleDiameter,
+ 0.4,
+ 2,
+ ),
+ layerHeight: this.normalizeNumber(
+ item.layerHeight ?? req.layerHeight,
+ 0.2,
+ 3,
+ ),
+ infillDensity: this.normalizeNumber(
+ item.infillDensity ?? req.infillDensity,
+ 20,
+ 2,
+ ),
+ infillPattern: this.normalizeString(
+ item.infillPattern || req.infillPattern || 'grid',
+ ),
+ supportEnabled: Boolean(item.supportEnabled ?? req.supportEnabled),
+ };
+ }
+
+ private toTrackedSettingsFromSession(session: any): TrackedPrintSettings {
+ const layer = this.normalizeNumber(session?.layerHeightMm, 0.2, 3);
+ return {
+ mode: this.mode(),
+ material: this.normalizeString(session?.materialCode || 'PLA'),
+ quality:
+ layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard',
+ nozzleDiameter: this.normalizeNumber(session?.nozzleDiameterMm, 0.4, 2),
+ layerHeight: layer,
+ infillDensity: this.normalizeNumber(session?.infillPercent, 20, 2),
+ infillPattern: this.normalizeString(session?.infillPattern || 'grid'),
+ supportEnabled: Boolean(session?.supportsEnabled),
+ };
+ }
+
+ private toTrackedSettingsFromSessionItem(
+ item: any,
+ fallback: TrackedPrintSettings,
+ ): TrackedPrintSettings {
+ const layer = this.normalizeNumber(
+ item?.layerHeightMm,
+ fallback.layerHeight,
+ 3,
+ );
+ return {
+ mode: this.mode(),
+ material: this.normalizeString(item?.materialCode || fallback.material),
+ quality: this.normalizeString(
+ item?.quality ||
+ (layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard'),
+ ),
+ nozzleDiameter: this.normalizeNumber(
+ item?.nozzleDiameterMm,
+ fallback.nozzleDiameter,
+ 2,
+ ),
+ layerHeight: layer,
+ infillDensity: this.normalizeNumber(
+ item?.infillPercent,
+ fallback.infillDensity,
+ 2,
+ ),
+ infillPattern: this.normalizeString(
+ item?.infillPattern || fallback.infillPattern,
+ ),
+ supportEnabled: Boolean(item?.supportsEnabled ?? fallback.supportEnabled),
+ };
+ }
+
+ private buildBaselineMapFromRequest(
+ req: QuoteRequest,
+ ): Map {
+ const map = new Map();
+ req.items.forEach((item) => {
+ map.set(
+ this.normalizeFileName(item.file?.name || ''),
+ this.toTrackedSettingsFromItem(req, item),
+ );
+ });
+ return map;
+ }
+
+ private buildBaselineMapFromSession(
+ items: any[],
+ defaultSettings: TrackedPrintSettings | null,
+ ): Map {
+ const map = new Map();
+ const fallback = defaultSettings ?? this.defaultTrackedSettings();
+ items.forEach((item) => {
+ map.set(
+ this.normalizeFileName(item?.originalFilename || ''),
+ this.toTrackedSettingsFromSessionItem(item, fallback),
+ );
+ });
+ return map;
+ }
+
+ private defaultTrackedSettings(): TrackedPrintSettings {
+ return {
+ mode: this.mode(),
+ material: 'pla',
+ quality: 'standard',
+ nozzleDiameter: 0.4,
+ layerHeight: 0.2,
+ infillDensity: 20,
+ infillPattern: 'grid',
+ supportEnabled: false,
+ };
+ }
+
+ private refreshRecalculationRequirement(): void {
+ if (!this.result()) return;
+
+ const draft = this.uploadForm?.getCurrentRequestDraft();
+ if (!draft || draft.items.length === 0) {
+ this.requiresRecalculation.set(false);
+ return;
+ }
+
+ const fallback = this.baselinePrintSettings;
+ if (!fallback) {
+ this.requiresRecalculation.set(false);
+ return;
+ }
+
+ const changed = draft.items.some((item) => {
+ const key = this.normalizeFileName(item.file?.name || '');
+ const baseline = this.baselineItemSettingsByFileName.get(key) || fallback;
+ const current = this.toTrackedSettingsFromItem(draft, item);
+ return !this.sameTrackedSettings(baseline, current);
+ });
+
+ this.requiresRecalculation.set(changed);
+ }
+
+ private sameTrackedSettings(
+ a: TrackedPrintSettings,
+ b: TrackedPrintSettings,
+ ): boolean {
+ return (
+ a.mode === b.mode &&
+ a.material === this.normalizeString(b.material) &&
+ a.quality === this.normalizeString(b.quality) &&
+ Math.abs(
+ a.nozzleDiameter - this.normalizeNumber(b.nozzleDiameter, 0.4, 2),
+ ) < 0.0001 &&
+ Math.abs(a.layerHeight - this.normalizeNumber(b.layerHeight, 0.2, 3)) <
+ 0.0001 &&
+ Math.abs(a.infillDensity - this.normalizeNumber(b.infillDensity, 20, 2)) <
+ 0.0001 &&
+ a.infillPattern === this.normalizeString(b.infillPattern) &&
+ a.supportEnabled === Boolean(b.supportEnabled)
+ );
+ }
+
+ private normalizeFileName(fileName: string): string {
+ return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
+ }
+
+ private normalizeString(value: string): string {
+ return String(value || '')
+ .trim()
+ .toLowerCase();
+ }
+
+ private normalizeNumber(
+ value: unknown,
+ fallback: number,
+ decimals: number,
+ ): number {
+ const numeric = Number(value);
+ const resolved = Number.isFinite(numeric) ? numeric : fallback;
+ const factor = 10 ** decimals;
+ return Math.round(resolved * factor) / factor;
}
}
diff --git a/frontend/src/app/features/calculator/calculator.routes.ts b/frontend/src/app/features/calculator/calculator.routes.ts
index 5a670cd..134457b 100644
--- a/frontend/src/app/features/calculator/calculator.routes.ts
+++ b/frontend/src/app/features/calculator/calculator.routes.ts
@@ -3,10 +3,24 @@ import { CalculatorPageComponent } from './calculator-page.component';
export const CALCULATOR_ROUTES: Routes = [
{ path: '', redirectTo: 'basic', pathMatch: 'full' },
- { path: 'basic', component: CalculatorPageComponent, data: { mode: 'easy' } },
+ {
+ path: 'basic',
+ component: CalculatorPageComponent,
+ data: {
+ mode: 'easy',
+ seoTitle: 'Calcolatore stampa 3D base | 3D fab',
+ seoDescription:
+ 'Calcola rapidamente il prezzo della tua stampa 3D in modalita base.',
+ },
+ },
{
path: 'advanced',
component: CalculatorPageComponent,
- data: { mode: 'advanced' },
+ data: {
+ mode: 'advanced',
+ seoTitle: 'Calcolatore stampa 3D avanzato | 3D fab',
+ seoDescription:
+ 'Configura parametri avanzati e ottieni un preventivo preciso con slicing reale.',
+ },
},
];
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 d7141b7..d6caa39 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
@@ -1,17 +1,15 @@
{{ "CALC.RESULT" | translate }}
-
-
-
- {{ totals().price | currency: result().currency }}
-
+
+
{{ totals().hours }}h {{ totals().minutes }}m
@@ -22,18 +20,6 @@
-
{{
- "CALC.SETUP_NOTE"
- | translate
- : { cost: (result().setupCost | currency: result().currency) }
- }}
- @if ((result().cadTotal || 0) > 0) {
-
- Servizio CAD: {{ result().cadTotal | currency: result().currency }}
-
-
- }
{{
"CALC.SHIPPING_NOTE" | translate
}}
@@ -45,6 +31,12 @@
{{ result().notes }}
}
+ @if (recalculationRequired()) {
+
+ Hai modificato i parametri di stampa. Ricalcola il preventivo prima di
+ procedere con l'ordine.
+
+ }
@@ -56,7 +48,14 @@
{{ item.fileName }}
{{ item.unitTime / 3600 | number: "1.1-1" }}h |
- {{ item.unitWeight | number: "1.0-0" }}g
+ {{ item.unitWeight | number: "1.0-0" }}g |
+ {{ item.material || "N/D" }}
+ @if (getItemDifferenceLabel(item.fileName, item.material)) {
+ |
+
+ {{ getItemDifferenceLabel(item.fileName, item.material) }}
+
+ }
@@ -70,6 +69,7 @@
[ngModel]="item.quantity"
(ngModelChange)="updateQuantity(i, $event)"
(blur)="flushQuantityUpdate(i)"
+ (keydown.enter)="flushQuantityUpdate(i)"
class="qty-input"
/>
@@ -97,14 +97,17 @@
-
+
{{ "QUOTE.CONSULT" | translate }}
@if (!hasQuantityOverLimit()) {
-
+
{{ "QUOTE.PROCEED_ORDER" | translate }}
} @else {
@@ -112,6 +115,11 @@
"QUOTE.MAX_QTY_NOTICE" | translate: { max: directOrderLimit }
}}
}
+ @if (recalculationRequired()) {
+
+ Ricalcola il preventivo per riattivare il checkout.
+
+ }
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 38913fa..ade1db5 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 {
@@ -41,6 +42,14 @@
overflow: hidden;
text-overflow: ellipsis;
}
+
+.item-settings-diff {
+ margin-left: 2px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: #8a6d1f;
+ white-space: normal;
+}
.file-details {
font-size: 0.8rem;
color: var(--color-text-muted);
@@ -113,9 +122,6 @@
gap: var(--space-4);
}
}
-.full-width {
- grid-column: span 2;
-}
.setup-note {
text-align: center;
@@ -141,6 +147,7 @@
.actions-right {
display: flex;
align-items: center;
+ gap: var(--space-2);
}
.actions-right {
@@ -184,3 +191,14 @@
white-space: pre-wrap; /* Preserve line breaks */
}
}
+
+.recalc-banner {
+ margin-top: var(--space-4);
+ margin-bottom: var(--space-4);
+ padding: var(--space-3);
+ border: 1px solid #f0c95a;
+ background: #fff8e1;
+ border-radius: var(--radius-md);
+ color: #6f5b1a;
+ font-size: 0.9rem;
+}
diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts
index 5784b3a..e187cad 100644
--- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts
+++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts
@@ -1,4 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TranslateModule } from '@ngx-translate/core';
import { QuoteResultComponent } from './quote-result.component';
import { QuoteResult } from '../../services/quote-estimator.service';
@@ -38,7 +39,11 @@ describe('QuoteResultComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
- imports: [QuoteResultComponent, TranslateModule.forRoot()],
+ imports: [
+ QuoteResultComponent,
+ TranslateModule.forRoot(),
+ HttpClientTestingModule,
+ ],
}).compileComponents();
fixture = TestBed.createComponent(QuoteResultComponent);
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 5da71ec..0fc1c8c 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
@@ -1,6 +1,5 @@
import {
Component,
- OnDestroy,
input,
output,
signal,
@@ -13,6 +12,10 @@ import { TranslateModule } from '@ngx-translate/core';
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
+import {
+ PriceBreakdownComponent,
+ PriceBreakdownRow,
+} from '../../../../shared/components/price-breakdown/price-breakdown.component';
import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
@Component({
@@ -25,16 +28,20 @@ import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
AppCardComponent,
AppButtonComponent,
SummaryCardComponent,
+ PriceBreakdownComponent,
],
templateUrl: './quote-result.component.html',
styleUrl: './quote-result.component.scss',
})
-export class QuoteResultComponent implements OnDestroy {
+export class QuoteResultComponent {
readonly maxInputQuantity = 500;
readonly directOrderLimit = 100;
- readonly quantityAutoRefreshMs = 2000;
result = input.required();
+ recalculationRequired = input(false);
+ itemSettingsDiffByFileName = input>(
+ {},
+ );
consult = output();
proceed = output();
itemChange = output<{
@@ -43,17 +50,20 @@ export class QuoteResultComponent implements OnDestroy {
fileName: string;
quantity: number;
}>();
+ itemQuantityPreviewChange = output<{
+ id?: string;
+ index: number;
+ fileName: string;
+ quantity: number;
+ }>();
// Local mutable state for items to handle quantity changes
items = signal([]);
private lastSentQuantities = new Map();
- private quantityTimers = new Map>();
constructor() {
effect(
() => {
- this.clearAllQuantityTimers();
-
// Initialize local items when result inputs change
// We map to new objects to avoid mutating the input directly if it was a reference
const nextItems = this.result().items.map((i) => ({ ...i }));
@@ -69,17 +79,12 @@ export class QuoteResultComponent implements OnDestroy {
);
}
- ngOnDestroy(): void {
- this.clearAllQuantityTimers();
- }
-
updateQuantity(index: number, newQty: number | string) {
const normalizedQty = this.normalizeQuantity(newQty);
if (normalizedQty === null) return;
const item = this.items()[index];
if (!item) return;
- const key = item.id ?? item.fileName;
this.items.update((current) => {
const updated = [...current];
@@ -87,7 +92,12 @@ export class QuoteResultComponent implements OnDestroy {
return updated;
});
- this.scheduleQuantityRefresh(index, key);
+ this.itemQuantityPreviewChange.emit({
+ id: item.id,
+ index,
+ fileName: item.fileName,
+ quantity: normalizedQty,
+ });
}
flushQuantityUpdate(index: number): void {
@@ -95,7 +105,6 @@ export class QuoteResultComponent implements OnDestroy {
if (!item) return;
const key = item.id ?? item.fileName;
- this.clearQuantityRefreshTimer(key);
const normalizedQty = this.normalizeQuantity(item.quantity);
if (normalizedQty === null) return;
@@ -117,17 +126,57 @@ export class QuoteResultComponent implements OnDestroy {
this.items().some((item) => item.quantity > this.directOrderLimit),
);
- totals = computed(() => {
+ costBreakdown = computed(() => {
const currentItems = this.items();
- const setup = this.result().setupCost;
const cad = this.result().cadTotal || 0;
- let price = setup + cad;
+ let subtotal = cad;
+ currentItems.forEach((item) => {
+ subtotal += item.unitPrice * item.quantity;
+ });
+
+ const nozzleChange = Math.max(0, this.result().nozzleChangeCost || 0);
+ const baseSetupRaw =
+ this.result().baseSetupCost != null
+ ? this.result().baseSetupCost
+ : this.result().setupCost - nozzleChange;
+ const baseSetup = Math.max(0, baseSetupRaw || 0);
+ const total = subtotal + baseSetup + nozzleChange;
+
+ return {
+ subtotal: Math.round(subtotal * 100) / 100,
+ baseSetup: Math.round(baseSetup * 100) / 100,
+ nozzleChange: Math.round(nozzleChange * 100) / 100,
+ total: Math.round(total * 100) / 100,
+ };
+ });
+
+ priceBreakdownRows = computed(() => {
+ const breakdown = this.costBreakdown();
+
+ return [
+ {
+ labelKey: 'CHECKOUT.SUBTOTAL',
+ amount: breakdown.subtotal,
+ },
+ {
+ labelKey: 'CHECKOUT.SETUP_FEE',
+ amount: breakdown.baseSetup,
+ },
+ {
+ label: 'Cambio Ugello',
+ amount: breakdown.nozzleChange,
+ visible: breakdown.nozzleChange > 0,
+ },
+ ];
+ });
+
+ totals = computed(() => {
+ const currentItems = this.items();
let time = 0;
let weight = 0;
currentItems.forEach((i) => {
- price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity;
});
@@ -136,7 +185,7 @@ export class QuoteResultComponent implements OnDestroy {
const minutes = Math.ceil((time % 3600) / 60);
return {
- price: Math.round(price * 100) / 100,
+ price: this.costBreakdown().total,
hours,
minutes,
weight: Math.ceil(weight),
@@ -151,24 +200,30 @@ export class QuoteResultComponent implements OnDestroy {
return Math.min(qty, this.maxInputQuantity);
}
- private scheduleQuantityRefresh(index: number, key: string): void {
- this.clearQuantityRefreshTimer(key);
- const timer = setTimeout(() => {
- this.quantityTimers.delete(key);
- this.flushQuantityUpdate(index);
- }, this.quantityAutoRefreshMs);
- this.quantityTimers.set(key, timer);
- }
+ getItemDifferenceLabel(fileName: string, materialCode?: string): string {
+ const differences =
+ this.itemSettingsDiffByFileName()[fileName]?.differences || [];
+ if (differences.length === 0) return '';
- private clearQuantityRefreshTimer(key: string): void {
- const timer = this.quantityTimers.get(key);
- if (!timer) return;
- clearTimeout(timer);
- this.quantityTimers.delete(key);
- }
+ const normalizedMaterial = String(materialCode || '')
+ .trim()
+ .toLowerCase();
- private clearAllQuantityTimers(): void {
- this.quantityTimers.forEach((timer) => clearTimeout(timer));
- this.quantityTimers.clear();
+ 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 || 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 ec755cd..064d3d1 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) {
@@ -52,7 +49,7 @@
type="number"
min="1"
[value]="item.quantity"
- (change)="updateItemQuantity(i, $event)"
+ (input)="updateItemQuantity(i, $event)"
class="qty-input"
(click)="$event.stopPropagation()"
/>
@@ -63,7 +60,7 @@
@@ -83,7 +80,6 @@
}
-
+
+
+ {{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
+ {{
+ "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
+ }}.
+
+
+ @if (mode() === "easy") {
+
+
+
+
+
+ } @else {
+
+
+
+
+ @if (sameSettingsForAll()) {
+
+
Impostazioni globali
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } @else {
+ @if (getSelectedItem(); as selectedItem) {
+
+
+ Impostazioni file: {{ selectedItem.file.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ }
+ }
}
@if (items().length === 0 && form.get("itemsTouched")?.value) {
{{ "CALC.ERR_FILE_REQUIRED" | translate }}
}
-
-
- {{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
- {{
- "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
- }}.
-
-
- @if (lockedSettings()) {
-
- Parametri stampa bloccati per sessione CAD: materiale, nozzle, layer,
- infill e supporti sono definiti dal back-office.
-
- }
-
-
- @if (mode() === "easy") {
-
- } @else {
-
- }
-
-
-
-
- @if (mode() === "advanced") {
-
-
-
-
-
-
-
-
-
-
- }
-
-
@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 16ac239..be6fb46 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,9 +2,9 @@
margin-bottom: var(--space-6);
}
.upload-privacy-note {
- margin-top: var(--space-3);
- margin-bottom: 0;
- font-size: 0.78rem;
+ 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,92 @@
}
}
+.easy-global-controls {
+ margin-top: var(--space-4);
+ margin-bottom: var(--space-1);
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--space-3);
+
+ @media (min-width: 640px) {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+.easy-global-field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+
+ span {
+ font-size: 0.82rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: var(--color-text-muted);
+ }
+
+ select {
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: 0.55rem 0.75rem;
+ background: var(--color-bg-card);
+ font-size: 0.96rem;
+ font-weight: 600;
+ color: var(--color-text);
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-brand);
+ box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.25);
+ }
+ }
+}
+
+.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;
@@ -211,6 +298,12 @@
}
}
+.sync-all-row {
+ margin-top: var(--space-2);
+ margin-bottom: var(--space-4);
+ padding-top: 0;
+}
+
/* Progress Bar */
.progress-container {
margin-bottom: var(--space-3);
@@ -244,3 +337,74 @@
color: var(--color-text-muted);
font-weight: 500;
}
+
+.item-settings-panel {
+ margin-top: var(--space-4);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background: var(--color-bg-card);
+}
+
+.item-settings-title {
+ margin: 0 0 var(--space-4);
+ font-size: 1.05rem;
+ color: var(--color-text);
+}
+
+.item-settings-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--space-3);
+ margin-bottom: var(--space-3);
+
+ @media (min-width: 640px) {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+.item-settings-grid label,
+.item-settings-checkbox {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--color-text);
+}
+
+.item-settings-grid input,
+.item-settings-grid select {
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: 0.5rem 0.75rem;
+ background: var(--color-bg-card);
+ font-size: 1rem;
+ color: var(--color-text);
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-brand);
+ box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.25);
+ }
+}
+
+.item-settings-checkbox {
+ flex-direction: row;
+ align-items: center;
+ gap: var(--space-2);
+
+ input[type="checkbox"] {
+ width: 20px;
+ height: 20px;
+ accent-color: var(--color-brand);
+ }
+}
+
+.item-settings-checkbox--top {
+ margin-top: var(--space-4);
+ margin-bottom: var(--space-4);
+ color: var(--color-text);
+ font-size: 1rem;
+ font-weight: 500;
+}
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 30d323e..f86408e 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
@@ -16,13 +16,13 @@ import {
} from '@angular/forms';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
-import { AppSelectComponent } from '../../../../shared/components/app-select/app-select.component';
import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone/app-dropzone.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
import {
QuoteRequest,
+ QuoteRequestItem,
QuoteEstimatorService,
OptionsResponse,
SimpleOption,
@@ -35,10 +35,34 @@ 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;
}
+interface ItemSettingsDiffInfo {
+ differences: string[];
+}
+
+type ItemPrintSettingsUpdate = Partial<
+ Pick<
+ FormItem,
+ | 'material'
+ | 'quality'
+ | 'nozzleDiameter'
+ | 'layerHeight'
+ | 'infillDensity'
+ | 'infillPattern'
+ | 'supportEnabled'
+ >
+>;
+
@Component({
selector: 'app-upload-form',
standalone: true,
@@ -47,7 +71,6 @@ interface FormItem {
ReactiveFormsModule,
TranslateModule,
AppInputComponent,
- AppSelectComponent,
AppDropzoneComponent,
AppButtonComponent,
StlViewerComponent,
@@ -61,7 +84,24 @@ export class UploadFormComponent implements OnInit {
lockedSettings = input
(false);
loading = input(false);
uploadProgress = input(0);
+
submitRequest = output();
+ itemQuantityChange = output<{
+ index: number;
+ fileName: string;
+ quantity: number;
+ }>();
+ itemSettingsDiffChange = output>();
+ printSettingsChange = output<{
+ mode: 'easy' | 'advanced';
+ material: string;
+ quality: string;
+ nozzleDiameter: number;
+ layerHeight: number;
+ infillDensity: number;
+ infillPattern: string;
+ supportEnabled: boolean;
+ }>();
private estimator = inject(QuoteEstimatorService);
private fb = inject(FormBuilder);
@@ -71,34 +111,173 @@ 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([]);
-
- // Store full material options to lookup variants/colors if needed later
- private fullMaterialOptions: MaterialOption[] = [];
- private isPatchingSettings = false;
-
- // 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.syncItemVariantSelections();
- } else {
- this.currentMaterialVariants.set([]);
- }
- }
+ private fullMaterialOptions: MaterialOption[] = [];
+ private allLayerHeights: SimpleOption[] = [];
+ private layerHeightsByNozzle: Record = {};
+ private isPatchingSettings = false;
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: [true],
+ });
+
+ 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();
+ });
+
+ effect(() => {
+ if (this.mode() !== 'advanced') {
+ return;
+ }
+
+ if (this.items().length > 0 || this.sameSettingsForAll()) {
+ return;
+ }
+
+ this.sameSettingsForAll.set(true);
+ this.form.get('syncAllItems')?.setValue(true, { emitEvent: false });
+ });
+ }
+
+ 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();
@@ -113,201 +292,126 @@ 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;
}
- constructor() {
- this.form = this.fb.group({
- itemsTouched: [false], // Hack to track touched state for custom items list
- 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
- this.form.get('material')?.valueChanges.subscribe(() => {
- this.updateVariants();
- });
-
- this.form.get('quality')?.valueChanges.subscribe((quality) => {
- if (this.mode() !== 'easy' || this.isPatchingSettings) return;
- this.applyAdvancedPresetFromQuality(quality);
- });
-
- effect(() => {
- this.applySettingsLock(this.lockedSettings());
- });
+ getSelectedItemIndex(): number {
+ const selected = this.selectedFile();
+ if (!selected) return -1;
+ return this.items().findIndex((item) => item.file === selected);
}
- 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 });
+ getSelectedItem(): FormItem | null {
+ const index = this.getSelectedItemIndex();
+ if (index < 0) return null;
+ return this.items()[index] || null;
}
- ngOnInit() {
- this.estimator.getOptions().subscribe({
- next: (options: OptionsResponse) => {
- this.fullMaterialOptions = options.materials;
- this.updateVariants(); // Trigger initial update
+ getVariantsForMaterial(
+ materialCode: string | null | undefined,
+ ): VariantOption[] {
+ const normalized = String(materialCode || '')
+ .trim()
+ .toUpperCase();
+ if (!normalized) return [];
- 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.layerHeights.set(
- options.layerHeights.map((l) => ({ label: l.label, value: l.value })),
- );
- 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.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]);
- this.setDefaults();
- },
- });
+ const found = this.fullMaterialOptions.find(
+ (m) =>
+ String(m.code || '')
+ .trim()
+ .toUpperCase() === normalized,
+ );
+ return found?.variants || [];
}
- private setDefaults() {
- // Set Defaults if available
- if (this.materials().length > 0 && !this.form.get('material')?.value) {
- this.form.get('material')?.setValue(this.materials()[0].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
- }
- if (
- this.layerHeights().length > 0 &&
- !this.form.get('layerHeight')?.value
- ) {
- this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2
- }
- 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;
}
+ 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();
- validItems.push({
- file,
- previewFile: this.isStlFile(file) ? file : undefined,
- quantity: 1,
- color: defaultSelection.colorName,
- filamentVariantId: defaultSelection.filamentVariantId,
- });
+ 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.selectedFile.set(validItems[validItems.length - 1].file);
+ 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) {
@@ -316,9 +420,10 @@ export class UploadFormComponent implements OnInit {
this.items.update((current) => {
if (index >= current.length) return current;
- const updated = [...current];
- updated[index] = { ...updated[index], quantity: normalizedQty };
- return updated;
+
+ return current.map((item, idx) =>
+ idx === index ? { ...item, quantity: normalizedQty } : item,
+ );
});
}
@@ -328,48 +433,50 @@ export class UploadFormComponent implements OnInit {
this.items.update((current) => {
let matched = false;
+
return current.map((item) => {
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.currentMaterialVariants();
- 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;
- this.updateItemQuantityByIndex(index, quantity);
+ 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(
@@ -382,172 +489,775 @@ export class UploadFormComponent implements OnInit {
typeof newSelection === 'string'
? undefined
: newSelection.filamentVariantId;
+
this.items.update((current) => {
- const updated = [...current];
- updated[index] = {
- ...updated[index],
- color: colorName,
- filamentVariantId,
- };
- return updated;
+ if (index < 0 || index >= current.length) {
+ return current;
+ }
+
+ return current.map((item, idx) =>
+ idx === index
+ ? {
+ ...item,
+ color: colorName,
+ filamentVariantId,
+ }
+ : item,
+ );
});
}
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) {
- this.selectedFile.set(null);
+ if (!removed) {
+ return current;
}
+
+ if (this.selectedFile() === removed.file) {
+ nextSelected =
+ updated.length > 0 ? updated[Math.max(0, index - 1)].file : null;
+ }
+
return updated;
});
- }
- setFiles(files: File[]) {
- const validItems: FormItem[] = [];
- const defaultSelection = this.getDefaultVariantSelection();
- for (const file of files) {
- validItems.push({
- file,
- previewFile: this.isStlFile(file) ? file : undefined,
- quantity: 1,
- color: defaultSelection.colorName,
- filamentVariantId: defaultSelection.filamentVariantId,
- });
+ if (nextSelected) {
+ this.selectFile(nextSelected);
+ } else if (this.items().length === 0) {
+ this.selectedFile.set(null);
}
- if (validItems.length > 0) {
- this.items.set(validItems);
- this.form.get('itemsTouched')?.setValue(true);
- // Auto select last added
- this.selectedFile.set(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;
- });
- }
+ onSameSettingsToggle(enabled: boolean) {
+ this.sameSettingsForAll.set(enabled);
+ this.form.get('syncAllItems')?.setValue(enabled, { emitEvent: false });
- private getDefaultVariantSelection(): {
- colorName: string;
- filamentVariantId?: number;
- } {
- const vars = 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' };
- }
-
- private syncItemVariantSelections(): void {
- const vars = this.currentMaterialVariants();
- if (!vars || vars.length === 0) {
- return;
+ if (enabled) {
+ this.applyGlobalSettingsToAllItems();
+ } else {
+ this.loadSelectedItemSettingsIntoForm();
}
- const fallback = vars.find((v) => !v.isOutOfStock) || vars[0];
- this.items.update((current) =>
- current.map((item) => {
- const byId =
- item.filamentVariantId != null
- ? vars.find((v) => v.id === item.filamentVariantId)
- : null;
- const byColor = vars.find((v) => v.colorName === item.color);
- const selected = byId || byColor || fallback;
- return {
- ...item,
- color: selected.colorName,
- filamentVariantId: selected.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) {
- 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 {
@@ -557,12 +1267,32 @@ 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',
'material',
'quality',
'nozzleDiameter',
@@ -575,6 +1305,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 {
diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts
index 7c72852..a499a96 100644
--- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts
+++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts
@@ -1,16 +1,24 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient, HttpEventType } from '@angular/common/http';
import { Observable, of } from 'rxjs';
-import { map, catchError, tap } from 'rxjs/operators';
import { environment } from '../../../../environments/environment';
+export interface QuoteRequestItem {
+ file: File;
+ quantity: number;
+ material?: string;
+ quality?: string;
+ color?: string;
+ filamentVariantId?: number;
+ supportEnabled?: boolean;
+ infillDensity?: number;
+ infillPattern?: string;
+ layerHeight?: number;
+ nozzleDiameter?: number;
+}
+
export interface QuoteRequest {
- items: {
- file: File;
- quantity: number;
- color?: string;
- filamentVariantId?: number;
- }[];
+ items: QuoteRequestItem[];
material: string;
quality: string;
notes?: string;
@@ -26,17 +34,25 @@ export interface QuoteItem {
id?: string;
fileName: string;
unitPrice: number;
- unitTime: number; // seconds
- unitWeight: number; // grams
+ unitTime: number;
+ unitWeight: number;
quantity: number;
material?: string;
+ quality?: string;
color?: string;
filamentVariantId?: number;
+ supportEnabled?: boolean;
+ infillDensity?: number;
+ infillPattern?: string;
+ layerHeight?: number;
+ nozzleDiameter?: number;
}
export interface QuoteResult {
sessionId?: string;
items: QuoteItem[];
+ baseSetupCost?: number;
+ nozzleChangeCost?: number;
setupCost: number;
globalMachineCost: number;
cadHours?: number;
@@ -49,36 +65,12 @@ export interface QuoteResult {
notes?: string;
}
-interface BackendResponse {
- success: boolean;
- data: {
- print_time_seconds: number;
- material_grams: number;
- cost: {
- total: number;
- };
- };
- error?: string;
-}
-
-interface BackendQuoteResult {
- totalPrice: number;
- currency: string;
- setupCost: number;
- stats: {
- printTimeSeconds: number;
- printTimeFormatted: string;
- filamentWeightGrams: number;
- filamentLengthMm: number;
- };
-}
-
-// Options Interfaces
export interface MaterialOption {
code: string;
label: string;
variants: VariantOption[];
}
+
export interface VariantOption {
id: number;
name: string;
@@ -89,28 +81,36 @@ export interface VariantOption {
stockFilamentGrams: number;
isOutOfStock: boolean;
}
+
export interface QualityOption {
id: string;
label: string;
}
+
export interface InfillOption {
id: string;
label: string;
}
+
export interface NumericOption {
value: number;
label: string;
}
+export interface NozzleLayerHeightOptions {
+ nozzleDiameter: number;
+ layerHeights: NumericOption[];
+}
+
export interface OptionsResponse {
materials: MaterialOption[];
qualities: QualityOption[];
infillPatterns: InfillOption[];
layerHeights: NumericOption[];
nozzleDiameters: NumericOption[];
+ layerHeightsByNozzle: NozzleLayerHeightOptions[];
}
-// UI Option for Select Component
export interface SimpleOption {
value: string | number;
label: string;
@@ -122,69 +122,28 @@ export interface SimpleOption {
export class QuoteEstimatorService {
private http = inject(HttpClient);
- private buildEasyModePreset(quality: string | undefined): {
- quality: string;
- layerHeight: number;
- infillDensity: number;
- infillPattern: string;
- nozzleDiameter: number;
- } {
- const normalized = (quality || 'standard').toLowerCase();
-
- // Legacy alias support.
- if (normalized === 'high' || normalized === 'extra_fine') {
- return {
- quality: 'extra_fine',
- layerHeight: 0.12,
- infillDensity: 20,
- infillPattern: 'grid',
- nozzleDiameter: 0.4,
- };
- }
-
- if (normalized === 'draft') {
- return {
- quality: 'extra_fine',
- layerHeight: 0.24,
- infillDensity: 12,
- infillPattern: 'grid',
- nozzleDiameter: 0.4,
- };
- }
-
- return {
- quality: 'standard',
- layerHeight: 0.2,
- infillDensity: 15,
- infillPattern: 'grid',
- nozzleDiameter: 0.4,
- };
- }
+ private pendingConsultation = signal<{
+ files: File[];
+ message: string;
+ } | null>(null);
getOptions(): Observable {
- console.log('QuoteEstimatorService: Requesting options...');
const headers: any = {};
- return this.http
- .get(`${environment.apiUrl}/api/calculator/options`, {
+ return this.http.get(
+ `${environment.apiUrl}/api/calculator/options`,
+ {
headers,
- })
- .pipe(
- tap({
- next: (res) =>
- console.log('QuoteEstimatorService: Options loaded', res),
- error: (err) =>
- console.error('QuoteEstimatorService: Options failed', err),
- }),
- );
+ },
+ );
}
- // NEW METHODS for Order Flow
-
getQuoteSession(sessionId: string): Observable {
const headers: any = {};
return this.http.get(
`${environment.apiUrl}/api/quote-sessions/${sessionId}`,
- { headers },
+ {
+ headers,
+ },
);
}
@@ -252,73 +211,71 @@ export class QuoteEstimatorService {
}
calculate(request: QuoteRequest): Observable {
- console.log('QuoteEstimatorService: Calculating quote...', request);
- if (request.items.length === 0) {
- console.warn('QuoteEstimatorService: No items to calculate');
- return of();
+ if (!request.items || request.items.length === 0) {
+ return of(0);
}
- return new Observable((observer) => {
- // 1. Create Session first
+ return new Observable((observer) => {
const headers: any = {};
this.http
.post(`${environment.apiUrl}/api/quote-sessions`, {}, { headers })
.subscribe({
next: (sessionRes) => {
- const sessionId = sessionRes.id;
- const sessionSetupCost = sessionRes.setupCostChf || 0;
+ const sessionId = String(sessionRes?.id || '');
+ if (!sessionId) {
+ observer.error('Could not initialize quote session');
+ return;
+ }
- // 2. Upload files to this session
const totalItems = request.items.length;
- const allProgress: number[] = new Array(totalItems).fill(0);
- const finalResponses: any[] = [];
- let completedRequests = 0;
+ const uploadProgress = new Array(totalItems).fill(0);
+ const uploadResults: { success: boolean }[] = new Array(totalItems)
+ .fill(null)
+ .map(() => ({ success: false }));
+ let completed = 0;
- const checkCompletion = () => {
+ const emitProgress = () => {
const avg = Math.round(
- allProgress.reduce((a, b) => a + b, 0) / totalItems,
+ uploadProgress.reduce((sum, value) => sum + value, 0) /
+ totalItems,
);
observer.next(avg);
+ };
- if (completedRequests === totalItems) {
- finalize(finalResponses, sessionSetupCost, sessionId);
+ const finalize = () => {
+ emitProgress();
+ if (completed !== totalItems) {
+ return;
}
+
+ const hasFailure = uploadResults.some((entry) => !entry.success);
+ if (hasFailure) {
+ observer.error(
+ 'One or more files failed during upload/analysis',
+ );
+ return;
+ }
+
+ this.getQuoteSession(sessionId).subscribe({
+ next: (sessionData) => {
+ observer.next(100);
+ const result = this.mapSessionToQuoteResult(sessionData);
+ result.notes = request.notes;
+ observer.next(result);
+ observer.complete();
+ },
+ error: () => {
+ observer.error('Failed to calculate final quote');
+ },
+ });
};
request.items.forEach((item, index) => {
const formData = new FormData();
formData.append('file', item.file);
- const easyPreset =
- request.mode === 'easy'
- ? this.buildEasyModePreset(request.quality)
- : null;
-
- const settings = {
- complexityMode:
- request.mode === 'easy'
- ? 'ADVANCED'
- : request.mode.toUpperCase(),
- material: request.material,
- filamentVariantId: item.filamentVariantId,
- quality: easyPreset ? easyPreset.quality : request.quality,
- supportsEnabled: request.supportEnabled,
- color: item.color || '#FFFFFF',
- layerHeight: easyPreset
- ? easyPreset.layerHeight
- : request.layerHeight,
- infillDensity: easyPreset
- ? easyPreset.infillDensity
- : request.infillDensity,
- infillPattern: easyPreset
- ? easyPreset.infillPattern
- : request.infillPattern,
- nozzleDiameter: easyPreset
- ? easyPreset.nozzleDiameter
- : request.nozzleDiameter,
- };
-
+ const settings = this.buildSettingsPayload(request, item);
const settingsBlob = new Blob([JSON.stringify(settings)], {
type: 'application/json',
});
@@ -340,84 +297,46 @@ export class QuoteEstimatorService {
event.type === HttpEventType.UploadProgress &&
event.total
) {
- allProgress[index] = Math.round(
+ uploadProgress[index] = Math.round(
(100 * event.loaded) / event.total,
);
- checkCompletion();
- } else if (event.type === HttpEventType.Response) {
- allProgress[index] = 100;
- finalResponses[index] = {
- ...event.body,
- success: true,
- fileName: item.file.name,
- originalQty: item.quantity,
- originalItem: item,
- };
- completedRequests++;
- checkCompletion();
+ emitProgress();
+ return;
+ }
+
+ if (event.type === HttpEventType.Response) {
+ uploadProgress[index] = 100;
+ uploadResults[index] = { success: true };
+ completed += 1;
+ finalize();
}
},
- error: (err) => {
- console.error('Item upload failed', err);
- finalResponses[index] = {
- success: false,
- fileName: item.file.name,
- };
- completedRequests++;
- checkCompletion();
+ error: () => {
+ uploadProgress[index] = 100;
+ uploadResults[index] = { success: false };
+ completed += 1;
+ finalize();
},
});
});
},
- error: (err) => {
- console.error('Failed to create session', err);
+ error: () => {
observer.error('Could not initialize quote session');
},
});
-
- const finalize = (
- responses: any[],
- setupCost: number,
- sessionId: string,
- ) => {
- this.http
- .get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, {
- headers,
- })
- .subscribe({
- next: (sessionData) => {
- observer.next(100);
- const result = this.mapSessionToQuoteResult(sessionData);
- result.notes = request.notes;
- observer.next(result);
- observer.complete();
- },
- error: (err) => {
- console.error('Failed to fetch final session calculation', err);
- observer.error('Failed to calculate final quote');
- },
- });
- };
});
}
- // Consultation Data Transfer
- private pendingConsultation = signal<{
- files: File[];
- message: string;
- } | null>(null);
-
setPendingConsultation(data: { files: File[]; message: string }) {
this.pendingConsultation.set(data);
}
getPendingConsultation() {
const data = this.pendingConsultation();
- this.pendingConsultation.set(null); // Clear after reading
+ this.pendingConsultation.set(null);
return data;
}
- // Session File Retrieval
getLineItemContent(
sessionId: string,
lineItemId: string,
@@ -449,47 +368,167 @@ export class QuoteEstimatorService {
}
mapSessionToQuoteResult(sessionData: any): QuoteResult {
- const session = sessionData.session;
- const items = sessionData.items || [];
+ const session = sessionData?.session || {};
+ const items = Array.isArray(sessionData?.items) ? sessionData.items : [];
+
const totalTime = items.reduce(
(acc: number, item: any) =>
- acc + (item.printTimeSeconds || 0) * item.quantity,
- 0,
- );
- const totalWeight = items.reduce(
- (acc: number, item: any) =>
- acc + (item.materialGrams || 0) * item.quantity,
+ acc + Number(item?.printTimeSeconds || 0) * Number(item?.quantity || 1),
0,
);
+ const totalWeight = items.reduce(
+ (acc: number, item: any) =>
+ acc + Number(item?.materialGrams || 0) * Number(item?.quantity || 1),
+ 0,
+ );
+
+ const grandTotal = Number(sessionData?.grandTotalChf);
+ const effectiveSetupCost = Number(
+ sessionData?.setupCostChf ?? session?.setupCostChf ?? 0,
+ );
+ const fallbackTotal =
+ Number(sessionData?.itemsTotalChf || 0) +
+ effectiveSetupCost +
+ Number(sessionData?.shippingCostChf || 0);
+
return {
- sessionId: session.id,
+ sessionId: session?.id,
items: items.map((item: any) => ({
- id: item.id,
- fileName: item.originalFilename,
- unitPrice: item.unitPriceChf,
- unitTime: item.printTimeSeconds,
- unitWeight: item.materialGrams,
- quantity: item.quantity,
- material: session.materialCode, // Assumption: session has one material for all? or items have it?
- // Backend model QuoteSession has materialCode.
- // But line items might have different colors.
- color: item.colorCode,
- filamentVariantId: item.filamentVariantId,
+ id: item?.id,
+ fileName: item?.originalFilename,
+ unitPrice: Number(item?.unitPriceChf || 0),
+ unitTime: Number(item?.printTimeSeconds || 0),
+ unitWeight: Number(item?.materialGrams || 0),
+ quantity: Number(item?.quantity || 1),
+ material: item?.materialCode || session?.materialCode,
+ quality: item?.quality,
+ color: item?.colorCode,
+ filamentVariantId: item?.filamentVariantId,
+ supportEnabled: Boolean(item?.supportsEnabled),
+ infillDensity:
+ item?.infillPercent != null ? Number(item.infillPercent) : undefined,
+ infillPattern: item?.infillPattern,
+ layerHeight:
+ item?.layerHeightMm != null ? Number(item.layerHeightMm) : undefined,
+ nozzleDiameter:
+ item?.nozzleDiameterMm != null
+ ? Number(item.nozzleDiameterMm)
+ : undefined,
})),
- setupCost: session.setupCostChf || 0,
- globalMachineCost: sessionData.globalMachineCostChf || 0,
- cadHours: session.cadHours || 0,
- cadTotal: sessionData.cadTotalChf || 0,
- currency: 'CHF', // Fixed for now
- totalPrice:
- (sessionData.itemsTotalChf || 0) +
- (session.setupCostChf || 0) +
- (sessionData.shippingCostChf || 0),
+ baseSetupCost: Number(
+ sessionData?.baseSetupCostChf ?? session?.setupCostChf ?? 0,
+ ),
+ nozzleChangeCost: Number(sessionData?.nozzleChangeCostChf ?? 0),
+ setupCost: effectiveSetupCost,
+ globalMachineCost: Number(sessionData?.globalMachineCostChf || 0),
+ cadHours: Number(session?.cadHours || 0),
+ cadTotal: Number(sessionData?.cadTotalChf || 0),
+ currency: 'CHF',
+ totalPrice: Number.isFinite(grandTotal) ? grandTotal : fallbackTotal,
totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight),
- notes: session.notes,
+ notes: session?.notes,
+ };
+ }
+
+ private buildSettingsPayload(
+ request: QuoteRequest,
+ item: QuoteRequestItem,
+ ): any {
+ const normalizedQuality = this.normalizeQuality(
+ item.quality || request.quality,
+ );
+ const easyPreset =
+ request.mode === 'easy'
+ ? this.buildEasyModePreset(normalizedQuality)
+ : null;
+
+ return {
+ complexityMode: request.mode === 'easy' ? 'BASIC' : 'ADVANCED',
+ quantity: this.normalizeQuantity(item.quantity),
+ material: String(item.material || request.material || 'PLA'),
+ color: item.color || '#FFFFFF',
+ filamentVariantId: item.filamentVariantId,
+ quality: easyPreset ? easyPreset.quality : normalizedQuality,
+ supportsEnabled: item.supportEnabled ?? request.supportEnabled ?? false,
+ layerHeight:
+ easyPreset?.layerHeight ??
+ item.layerHeight ??
+ request.layerHeight ??
+ 0.2,
+ infillDensity:
+ easyPreset?.infillDensity ??
+ item.infillDensity ??
+ request.infillDensity ??
+ 20,
+ infillPattern:
+ easyPreset?.infillPattern ??
+ item.infillPattern ??
+ request.infillPattern ??
+ 'grid',
+ nozzleDiameter:
+ easyPreset?.nozzleDiameter ??
+ item.nozzleDiameter ??
+ request.nozzleDiameter ??
+ 0.4,
+ };
+ }
+
+ private normalizeQuantity(value: number | undefined): number {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < 1) {
+ return 1;
+ }
+ return Math.floor(numeric);
+ }
+
+ private normalizeQuality(value: string | undefined): string {
+ const normalized = String(value || 'standard')
+ .trim()
+ .toLowerCase();
+ if (normalized === 'high' || normalized === 'high_definition') {
+ return 'extra_fine';
+ }
+ return normalized || 'standard';
+ }
+
+ private buildEasyModePreset(quality: string): {
+ quality: string;
+ layerHeight: number;
+ infillDensity: number;
+ infillPattern: string;
+ nozzleDiameter: number;
+ } {
+ const normalized = this.normalizeQuality(quality);
+
+ if (normalized === 'draft') {
+ return {
+ quality: 'draft',
+ layerHeight: 0.28,
+ infillDensity: 15,
+ infillPattern: 'grid',
+ nozzleDiameter: 0.4,
+ };
+ }
+
+ if (normalized === 'extra_fine') {
+ return {
+ quality: 'extra_fine',
+ layerHeight: 0.12,
+ infillDensity: 20,
+ infillPattern: 'gyroid',
+ nozzleDiameter: 0.4,
+ };
+ }
+
+ return {
+ quality: 'standard',
+ layerHeight: 0.2,
+ infillDensity: 15,
+ infillPattern: 'grid',
+ nozzleDiameter: 0.4,
};
}
}
diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html
index 123df04..6bec838 100644
--- a/frontend/src/app/features/checkout/checkout.component.html
+++ b/frontend/src/app/features/checkout/checkout.component.html
@@ -245,17 +245,26 @@
{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}
-
+
+ {{ "CHECKOUT.MATERIAL" | translate }}:
+ {{ itemMaterial(item) }}
+
+
+
+ {{ itemColorLabel(item) }}
+
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h |
{{ item.materialGrams | number: "1.0-0" }}g
-
-
-
- {{ "CHECKOUT.SUBTOTAL" | translate }}
- {{ session.itemsTotalChf | currency: "CHF" }}
-
-
- {{ "CHECKOUT.SETUP_FEE" | translate }}
- {{ session.session.setupCostChf | currency: "CHF" }}
-
-
- {{ "CHECKOUT.SHIPPING" | translate }}
- {{ session.shippingCostChf | currency: "CHF" }}
-
-
- {{ "CHECKOUT.TOTAL" | translate }}
- {{ session.grandTotalChf | currency: "CHF" }}
-
-
+
diff --git a/frontend/src/app/features/checkout/checkout.component.scss b/frontend/src/app/features/checkout/checkout.component.scss
index 081022b..81b550f 100644
--- a/frontend/src/app/features/checkout/checkout.component.scss
+++ b/frontend/src/app/features/checkout/checkout.component.scss
@@ -230,12 +230,24 @@ app-toggle-selector.user-type-selector-compact {
font-size: 0.85rem;
color: var(--color-text-muted);
+ .item-color {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ }
+
.color-dot {
width: 14px;
height: 14px;
border-radius: 50%;
display: inline-block;
border: 1px solid var(--color-border);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
+ }
+
+ .color-name {
+ font-weight: 500;
+ color: var(--color-text-muted);
}
}
@@ -343,32 +355,6 @@ app-toggle-selector.user-type-selector-compact {
padding-right: var(--space-3);
}
-.summary-totals {
- background: var(--color-neutral-100);
- padding: var(--space-4);
- border-radius: var(--radius-md);
- margin-top: var(--space-6);
-
- .total-row {
- display: flex;
- justify-content: space-between;
- margin-bottom: var(--space-2);
- font-size: 0.95rem;
- color: var(--color-text);
- }
-
- .grand-total {
- display: flex;
- justify-content: space-between;
- color: var(--color-text);
- font-weight: 700;
- font-size: 1.5rem;
- margin-top: var(--space-4);
- padding-top: var(--space-4);
- border-top: 2px solid var(--color-border);
- }
-}
-
.actions {
margin-top: var(--space-8);
diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts
index ad95985..039a8e2 100644
--- a/frontend/src/app/features/checkout/checkout.component.ts
+++ b/frontend/src/app/features/checkout/checkout.component.ts
@@ -16,8 +16,13 @@ import {
AppToggleSelectorComponent,
ToggleOption,
} from '../../shared/components/app-toggle-selector/app-toggle-selector.component';
+import {
+ PriceBreakdownComponent,
+ PriceBreakdownRow,
+} from '../../shared/components/price-breakdown/price-breakdown.component';
import { LanguageService } from '../../core/services/language.service';
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
+import { getColorHex } from '../../core/constants/colors.const';
@Component({
selector: 'app-checkout',
@@ -30,6 +35,7 @@ import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewe
AppButtonComponent,
AppCardComponent,
AppToggleSelectorComponent,
+ PriceBreakdownComponent,
StlViewerComponent,
],
templateUrl: './checkout.component.html',
@@ -55,6 +61,8 @@ export class CheckoutComponent implements OnInit {
selectedPreviewFile = signal(null);
selectedPreviewName = signal('');
selectedPreviewColor = signal('#c9ced6');
+ private variantHexById = new Map();
+ private variantHexByColorName = new Map();
userTypeOptions: ToggleOption[] = [
{ label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' },
@@ -128,6 +136,8 @@ export class CheckoutComponent implements OnInit {
}
ngOnInit(): void {
+ this.loadMaterialColorPalette();
+
this.route.queryParams.subscribe((params) => {
this.sessionId = params['session'];
if (!this.sessionId) {
@@ -162,7 +172,11 @@ export class CheckoutComponent implements OnInit {
this.quoteService.getQuoteSession(this.sessionId).subscribe({
next: (session) => {
this.quoteSession.set(session);
- this.loadStlPreviews(session);
+ if (this.isCadSessionData(session)) {
+ this.loadStlPreviews(session);
+ } else {
+ this.resetPreviewState();
+ }
console.log('Loaded session:', session);
},
error: (err) => {
@@ -173,7 +187,7 @@ export class CheckoutComponent implements OnInit {
}
isCadSession(): boolean {
- return this.quoteSession()?.session?.status === 'CAD_ACTIVE';
+ return this.isCadSessionData(this.quoteSession());
}
cadRequestId(): string | null {
@@ -188,6 +202,35 @@ export class CheckoutComponent implements OnInit {
return this.quoteSession()?.cadTotalChf ?? 0;
}
+ checkoutPriceBreakdownRows(session: any): PriceBreakdownRow[] {
+ return [
+ {
+ labelKey: 'CHECKOUT.SUBTOTAL',
+ amount: session?.itemsTotalChf ?? 0,
+ },
+ {
+ labelKey: 'CHECKOUT.SETUP_FEE',
+ amount:
+ session?.baseSetupCostChf ?? session?.session?.setupCostChf ?? 0,
+ },
+ {
+ label: 'Cambio Ugello',
+ amount: session?.nozzleChangeCostChf ?? 0,
+ visible: (session?.nozzleChangeCostChf ?? 0) > 0,
+ },
+ {
+ labelKey: 'CHECKOUT.SHIPPING',
+ amount: session?.shippingCostChf ?? 0,
+ },
+ ];
+ }
+
+ itemMaterial(item: any): string {
+ return String(
+ item?.materialCode ?? this.quoteSession()?.session?.materialCode ?? '-',
+ );
+ }
+
isStlItem(item: any): boolean {
const name = String(item?.originalFilename ?? '').toLowerCase();
return name.endsWith('.stl');
@@ -202,8 +245,40 @@ export class CheckoutComponent implements OnInit {
}
previewColor(item: any): string {
+ return this.itemColorSwatch(item);
+ }
+
+ itemColorLabel(item: any): string {
const raw = String(item?.colorCode ?? '').trim();
- return raw || '#c9ced6';
+ return raw || '-';
+ }
+
+ itemColorSwatch(item: any): string {
+ const variantId = Number(item?.filamentVariantId);
+ if (Number.isFinite(variantId) && this.variantHexById.has(variantId)) {
+ return this.variantHexById.get(variantId)!;
+ }
+
+ const raw = String(item?.colorCode ?? '').trim();
+ if (!raw) {
+ return '#c9ced6';
+ }
+
+ if (this.isHexColor(raw)) {
+ return raw;
+ }
+
+ const byName = this.variantHexByColorName.get(raw.toLowerCase());
+ if (byName) {
+ return byName;
+ }
+
+ const fallback = getColorHex(raw);
+ if (fallback && fallback !== '#facf0a') {
+ return fallback;
+ }
+
+ return '#c9ced6';
}
isPreviewLoading(item: any): boolean {
@@ -240,8 +315,47 @@ export class CheckoutComponent implements OnInit {
this.selectedPreviewColor.set('#c9ced6');
}
+ private loadMaterialColorPalette(): void {
+ this.quoteService.getOptions().subscribe({
+ next: (options) => {
+ this.variantHexById.clear();
+ this.variantHexByColorName.clear();
+
+ for (const material of options?.materials || []) {
+ for (const variant of material?.variants || []) {
+ const variantId = Number(variant?.id);
+ const colorHex = String(variant?.hexColor || '').trim();
+ const colorName = String(variant?.colorName || '').trim();
+
+ if (Number.isFinite(variantId) && colorHex) {
+ this.variantHexById.set(variantId, colorHex);
+ }
+ if (colorName && colorHex) {
+ this.variantHexByColorName.set(colorName.toLowerCase(), colorHex);
+ }
+ }
+ }
+ },
+ error: () => {
+ this.variantHexById.clear();
+ this.variantHexByColorName.clear();
+ },
+ });
+ }
+
+ private isHexColor(value?: string): boolean {
+ return (
+ typeof value === 'string' &&
+ /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value)
+ );
+ }
+
private loadStlPreviews(session: any): void {
- if (!this.sessionId || !Array.isArray(session?.items)) {
+ if (
+ !this.sessionId ||
+ !this.isCadSessionData(session) ||
+ !Array.isArray(session?.items)
+ ) {
return;
}
@@ -276,6 +390,17 @@ export class CheckoutComponent implements OnInit {
}
}
+ private isCadSessionData(session: any): boolean {
+ return session?.session?.status === 'CAD_ACTIVE';
+ }
+
+ private resetPreviewState(): void {
+ this.previewFiles.set({});
+ this.previewLoading.set({});
+ this.previewErrors.set({});
+ this.closePreview();
+ }
+
onSubmit() {
if (this.checkoutForm.invalid) {
return;
diff --git a/frontend/src/app/features/contact/contact.routes.ts b/frontend/src/app/features/contact/contact.routes.ts
index f8ef5c5..6ee01b5 100644
--- a/frontend/src/app/features/contact/contact.routes.ts
+++ b/frontend/src/app/features/contact/contact.routes.ts
@@ -5,5 +5,10 @@ export const CONTACT_ROUTES: Routes = [
path: '',
loadComponent: () =>
import('./contact-page.component').then((m) => m.ContactPageComponent),
+ data: {
+ seoTitle: 'Contatti | 3D fab',
+ seoDescription:
+ 'Richiedi informazioni, preventivi personalizzati o supporto per progetti di stampa 3D.',
+ },
},
];
diff --git a/frontend/src/app/features/home/home.component.html b/frontend/src/app/features/home/home.component.html
index 2255de4..9284fdf 100644
--- a/frontend/src/app/features/home/home.component.html
+++ b/frontend/src/app/features/home/home.component.html
@@ -37,28 +37,40 @@
-

+
{{ "HOME.CAP_1_TITLE" | translate }}
{{ "HOME.CAP_1_TEXT" | translate }}
-

+
{{ "HOME.CAP_2_TITLE" | translate }}
{{ "HOME.CAP_2_TEXT" | translate }}
-

+
{{ "HOME.CAP_3_TITLE" | translate }}
{{ "HOME.CAP_3_TEXT" | translate }}
-

+
{{ "HOME.CAP_4_TITLE" | translate }}
{{ "HOME.CAP_4_TEXT" | translate }}
diff --git a/frontend/src/app/features/legal/legal.routes.ts b/frontend/src/app/features/legal/legal.routes.ts
index 78cc23e..274bfd7 100644
--- a/frontend/src/app/features/legal/legal.routes.ts
+++ b/frontend/src/app/features/legal/legal.routes.ts
@@ -5,10 +5,20 @@ export const LEGAL_ROUTES: Routes = [
path: 'privacy',
loadComponent: () =>
import('./privacy/privacy.component').then((m) => m.PrivacyComponent),
+ data: {
+ seoTitle: 'Privacy Policy | 3D fab',
+ seoDescription:
+ 'Informativa privacy di 3D fab: trattamento dati, finalita e contatti.',
+ },
},
{
path: 'terms',
loadComponent: () =>
import('./terms/terms.component').then((m) => m.TermsComponent),
+ data: {
+ seoTitle: 'Termini e condizioni | 3D fab',
+ seoDescription:
+ 'Termini e condizioni del servizio di stampa 3D e del calcolatore preventivi.',
+ },
},
];
diff --git a/frontend/src/app/features/order/order.component.html b/frontend/src/app/features/order/order.component.html
index 747be86..c2548ee 100644
--- a/frontend/src/app/features/order/order.component.html
+++ b/frontend/src/app/features/order/order.component.html
@@ -193,28 +193,12 @@
#{{ getDisplayOrderNumber(o) }}
-
-
- {{ "PAYMENT.SUBTOTAL" | translate }}
- {{ o.subtotalChf | currency: "CHF" }}
-
-
0">
- Servizio CAD ({{ o.cadHours || 0 }}h)
- {{ o.cadTotalChf | currency: "CHF" }}
-
-
- {{ "PAYMENT.SHIPPING" | translate }}
- {{ o.shippingCostChf | currency: "CHF" }}
-
-
- {{ "PAYMENT.SETUP_FEE" | translate }}
- {{ o.setupCostChf | currency: "CHF" }}
-
-
- {{ "PAYMENT.TOTAL" | translate }}
- {{ o.totalChf | currency: "CHF" }}
-
-
+
diff --git a/frontend/src/app/features/order/order.component.scss b/frontend/src/app/features/order/order.component.scss
index 766fa4b..d8d275a 100644
--- a/frontend/src/app/features/order/order.component.scss
+++ b/frontend/src/app/features/order/order.component.scss
@@ -184,31 +184,6 @@
top: var(--space-6);
}
-.summary-totals {
- background: var(--color-neutral-100);
- padding: var(--space-6);
- border-radius: var(--radius-md);
-
- .total-row {
- display: flex;
- justify-content: space-between;
- margin-bottom: var(--space-2);
- font-size: 0.95rem;
- color: var(--color-text-muted);
- }
-
- .grand-total-row {
- display: flex;
- justify-content: space-between;
- color: var(--color-text);
- font-weight: 700;
- font-size: 1.5rem;
- margin-top: var(--space-4);
- padding-top: var(--space-4);
- border-top: 2px solid var(--color-border);
- }
-}
-
.actions {
margin-top: var(--space-8);
}
diff --git a/frontend/src/app/features/order/order.component.ts b/frontend/src/app/features/order/order.component.ts
index 3ad4c05..18d8b86 100644
--- a/frontend/src/app/features/order/order.component.ts
+++ b/frontend/src/app/features/order/order.component.ts
@@ -6,6 +6,10 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { environment } from '../../../environments/environment';
+import {
+ PriceBreakdownComponent,
+ PriceBreakdownRow,
+} from '../../shared/components/price-breakdown/price-breakdown.component';
@Component({
selector: 'app-order',
@@ -15,6 +19,7 @@ import { environment } from '../../../environments/environment';
AppButtonComponent,
AppCardComponent,
TranslateModule,
+ PriceBreakdownComponent,
],
templateUrl: './order.component.html',
styleUrl: './order.component.scss',
@@ -171,6 +176,28 @@ export class OrderComponent implements OnInit {
return this.translate.instant('ORDER.NOT_AVAILABLE');
}
+ orderPriceBreakdownRows(order: any): PriceBreakdownRow[] {
+ return [
+ {
+ labelKey: 'PAYMENT.SUBTOTAL',
+ amount: order?.subtotalChf ?? 0,
+ },
+ {
+ label: `Servizio CAD (${order?.cadHours || 0}h)`,
+ amount: order?.cadTotalChf ?? 0,
+ visible: (order?.cadTotalChf ?? 0) > 0,
+ },
+ {
+ labelKey: 'PAYMENT.SHIPPING',
+ amount: order?.shippingCostChf ?? 0,
+ },
+ {
+ labelKey: 'PAYMENT.SETUP_FEE',
+ amount: order?.setupCostChf ?? 0,
+ },
+ ];
+ }
+
private extractOrderNumber(orderId: string): string {
return orderId.split('-')[0];
}
diff --git a/frontend/src/app/features/shop/shop.routes.ts b/frontend/src/app/features/shop/shop.routes.ts
index 22a7fb3..b94949b 100644
--- a/frontend/src/app/features/shop/shop.routes.ts
+++ b/frontend/src/app/features/shop/shop.routes.ts
@@ -3,6 +3,22 @@ import { ShopPageComponent } from './shop-page.component';
import { ProductDetailComponent } from './product-detail.component';
export const SHOP_ROUTES: Routes = [
- { path: '', component: ShopPageComponent },
- { path: ':id', component: ProductDetailComponent },
+ {
+ path: '',
+ component: ShopPageComponent,
+ data: {
+ seoTitle: 'Shop 3D fab',
+ seoDescription:
+ 'Lo shop 3D fab e in allestimento. Intanto puoi usare il calcolatore per ottenere un preventivo.',
+ seoRobots: 'noindex, nofollow',
+ },
+ },
+ {
+ path: ':id',
+ component: ProductDetailComponent,
+ data: {
+ seoTitle: 'Prodotto | 3D fab',
+ seoRobots: 'noindex, nofollow',
+ },
+ },
];
diff --git a/frontend/src/app/shared/components/price-breakdown/price-breakdown.component.html b/frontend/src/app/shared/components/price-breakdown/price-breakdown.component.html
new file mode 100644
index 0000000..2670c2d
--- /dev/null
+++ b/frontend/src/app/shared/components/price-breakdown/price-breakdown.component.html
@@ -0,0 +1,25 @@
+
+
+
+
+ {{ row.label }}
+
+
+ {{ row.labelKey ? (row.labelKey | translate) : "" }}
+
+
+ {{ row.amount | currency: currency() }}
+
+
+
+
+
+ {{ totalLabel() }}
+
+
+ {{ totalLabelKey() ? (totalLabelKey() | translate) : "" }}
+
+
+ {{ total() | currency: currency() }}{{ totalSuffix() }}
+
+
diff --git a/frontend/src/app/shared/components/price-breakdown/price-breakdown.component.scss b/frontend/src/app/shared/components/price-breakdown/price-breakdown.component.scss
new file mode 100644
index 0000000..d7e13b7
--- /dev/null
+++ b/frontend/src/app/shared/components/price-breakdown/price-breakdown.component.scss
@@ -0,0 +1,31 @@
+.price-breakdown {
+ margin-top: var(--space-2);
+ margin-bottom: var(--space-4);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background: var(--color-neutral-100);
+}
+
+.price-row,
+.price-total {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.price-row {
+ color: var(--color-text);
+ font-size: 0.95rem;
+ margin-bottom: var(--space-2);
+}
+
+.price-total {
+ margin-top: var(--space-3);
+ padding-top: var(--space-3);
+ border-top: 2px solid var(--color-border);
+ font-size: 1.2rem;
+ font-weight: 700;
+ color: var(--color-text);
+}
diff --git a/frontend/src/app/shared/components/price-breakdown/price-breakdown.component.ts b/frontend/src/app/shared/components/price-breakdown/price-breakdown.component.ts
new file mode 100644
index 0000000..28b5dca
--- /dev/null
+++ b/frontend/src/app/shared/components/price-breakdown/price-breakdown.component.ts
@@ -0,0 +1,30 @@
+import { CommonModule } from '@angular/common';
+import { Component, computed, input } from '@angular/core';
+import { TranslateModule } from '@ngx-translate/core';
+
+export interface PriceBreakdownRow {
+ label?: string;
+ labelKey?: string;
+ amount: number;
+ visible?: boolean;
+}
+
+@Component({
+ selector: 'app-price-breakdown',
+ standalone: true,
+ imports: [CommonModule, TranslateModule],
+ templateUrl: './price-breakdown.component.html',
+ styleUrl: './price-breakdown.component.scss',
+})
+export class PriceBreakdownComponent {
+ rows = input([]);
+ total = input.required();
+ currency = input('CHF');
+ totalSuffix = input('');
+ totalLabel = input('');
+ totalLabelKey = input('');
+
+ visibleRows = computed(() =>
+ this.rows().filter((row) => row.visible === undefined || row.visible),
+ );
+}
diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json
index ca1275b..2ace005 100644
--- a/frontend/src/assets/i18n/de.json
+++ b/frontend/src/assets/i18n/de.json
@@ -90,7 +90,7 @@
"PROCESSING": "Verarbeitung...",
"NOTES_PLACEHOLDER": "Spezifische Anweisungen...",
"SETUP_NOTE": "* Beinhaltet {{cost}} als Einrichtungskosten",
- "SHIPPING_NOTE": "** Versandkosten ausgeschlossen, werden im nächsten Schritt berechnet",
+ "SHIPPING_NOTE": "* Versandkosten ausgeschlossen, werden im nächsten Schritt berechnet",
"ERROR_ZERO_PRICE": "Bei der Berechnung ist etwas schiefgelaufen. Versuche ein anderes Format oder kontaktiere uns direkt über \"Beratung anfragen\".",
"ZERO_RESULT_TITLE": "Ungültiges Ergebnis",
"ZERO_RESULT_HELP": "Die Berechnung hat ungültige Werte (0) geliefert. Versuche ein anderes Dateiformat oder kontaktiere uns direkt über \"Beratung anfragen\"."
@@ -402,6 +402,7 @@
"SETUP_FEE": "Einrichtungskosten",
"TOTAL": "Gesamt",
"QTY": "Menge",
+ "MATERIAL": "Material",
"PER_PIECE": "pro Stück",
"SHIPPING": "Versand (CH)",
"PREVIEW_LOADING": "3D-Vorschau wird geladen...",
diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json
index e464d13..56fba30 100644
--- a/frontend/src/assets/i18n/en.json
+++ b/frontend/src/assets/i18n/en.json
@@ -90,7 +90,7 @@
"PROCESSING": "Processing...",
"NOTES_PLACEHOLDER": "Specific instructions...",
"SETUP_NOTE": "* Includes {{cost}} as setup cost",
- "SHIPPING_NOTE": "** Shipping costs excluded, calculated at the next step",
+ "SHIPPING_NOTE": "* Shipping costs excluded, calculated at the next step",
"ERROR_ZERO_PRICE": "Something went wrong during the calculation. Try another format or contact us directly via Request Consultation.",
"ZERO_RESULT_TITLE": "Invalid Result",
"ZERO_RESULT_HELP": "The calculation returned invalid zero values. Try another file format or contact us directly via Request Consultation."
@@ -402,6 +402,7 @@
"SETUP_FEE": "Setup Fee",
"TOTAL": "Total",
"QTY": "Qty",
+ "MATERIAL": "Material",
"PER_PIECE": "per piece",
"SHIPPING": "Shipping",
"PREVIEW_LOADING": "Loading 3D preview...",
diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json
index 02ebd0c..ecd13b0 100644
--- a/frontend/src/assets/i18n/fr.json
+++ b/frontend/src/assets/i18n/fr.json
@@ -110,7 +110,7 @@
"PROCESSING": "Traitement...",
"NOTES_PLACEHOLDER": "Instructions spécifiques...",
"SETUP_NOTE": "* Inclut {{cost}} comme coût de setup",
- "SHIPPING_NOTE": "** Frais d'expédition exclus, calculés à l'étape suivante",
+ "SHIPPING_NOTE": "* Frais d'expédition exclus, calculés à l'étape suivante",
"STEP_WARNING": "La visualisation 3D n'est pas compatible avec les fichiers STEP et 3MF",
"REMOVE_FILE": "Supprimer le fichier",
"FALLBACK_MATERIAL": "PLA (fallback)",
@@ -459,6 +459,7 @@
"SETUP_FEE": "Coût de setup",
"TOTAL": "Total",
"QTY": "Qté",
+ "MATERIAL": "Matériau",
"PER_PIECE": "par pièce",
"SHIPPING": "Expédition (CH)",
"PREVIEW_LOADING": "Chargement de l'aperçu 3D...",
diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json
index 9444cb8..435a606 100644
--- a/frontend/src/assets/i18n/it.json
+++ b/frontend/src/assets/i18n/it.json
@@ -110,7 +110,7 @@
"PROCESSING": "Elaborazione...",
"NOTES_PLACEHOLDER": "Istruzioni specifiche...",
"SETUP_NOTE": "* Include {{cost}} come costo di setup",
- "SHIPPING_NOTE": "** Costi di spedizione esclusi, calcolati al passaggio successivo",
+ "SHIPPING_NOTE": "* Costi di spedizione esclusi, calcolati al passaggio successivo",
"STEP_WARNING": "La visualizzazione 3D non è compatibile con i file step e 3mf",
"REMOVE_FILE": "Rimuovi file",
"FALLBACK_MATERIAL": "PLA (fallback)",
@@ -459,6 +459,7 @@
"SETUP_FEE": "Costo di Avvio",
"TOTAL": "Totale",
"QTY": "Qtà",
+ "MATERIAL": "Materiale",
"PER_PIECE": "al pezzo",
"SHIPPING": "Spedizione (CH)",
"PREVIEW_LOADING": "Caricamento anteprima 3D...",
diff --git a/frontend/src/index.html b/frontend/src/index.html
index 91a4429..140c33c 100644
--- a/frontend/src/index.html
+++ b/frontend/src/index.html
@@ -2,8 +2,12 @@
-
- 3D fab
+ 3D fab | Stampa 3D su misura
+
+