From ed76b13e4c841e1b7f8b259df7951a1f5f5878bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Fri, 27 Feb 2026 15:46:41 +0100 Subject: [PATCH] feat(back-end and front-end): back-office pazzo --- .../admin/AdminFilamentController.java | 285 +++++++++++++++ .../admin/AdminOperationsController.java | 179 +++++++++- .../dto/AdminContactRequestAttachmentDto.java | 52 +++ .../dto/AdminContactRequestDetailDto.java | 125 +++++++ .../dto/AdminFilamentMaterialTypeDto.java | 49 +++ .../dto/AdminFilamentVariantDto.java | 151 ++++++++ ...dminUpsertFilamentMaterialTypeRequest.java | 40 +++ .../AdminUpsertFilamentVariantRequest.java | 87 +++++ ...ustomQuoteRequestAttachmentRepository.java | 4 +- .../repository/FilamentVariantRepository.java | 3 +- .../repository/OrderRepository.java | 2 + db.sql | 1 - .../src/app/features/admin/admin.routes.ts | 4 - .../admin-contact-requests.component.html | 127 +++++-- .../admin-contact-requests.component.scss | 337 +++++++++++++++++- .../pages/admin-contact-requests.component.ts | 87 ++++- .../pages/admin-dashboard.component.html | 42 ++- .../pages/admin-dashboard.component.scss | 19 +- .../admin/pages/admin-dashboard.component.ts | 58 ++- .../pages/admin-filament-stock.component.html | 247 +++++++++++-- .../pages/admin-filament-stock.component.scss | 200 ++++++++++- .../pages/admin-filament-stock.component.ts | 260 +++++++++++++- .../pages/admin-orders-past.component.html | 39 -- .../pages/admin-orders-past.component.scss | 59 --- .../pages/admin-orders-past.component.ts | 39 -- .../admin/pages/admin-sessions.component.html | 77 +++- .../admin/pages/admin-sessions.component.scss | 73 +++- .../admin/pages/admin-sessions.component.ts | 98 ++++- .../admin/pages/admin-shell.component.html | 1 - .../services/admin-operations.service.ts | 143 ++++++++ 30 files changed, 2616 insertions(+), 272 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminContactRequestAttachmentDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminContactRequestDetailDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminFilamentMaterialTypeDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentMaterialTypeRequest.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java delete mode 100644 frontend/src/app/features/admin/pages/admin-orders-past.component.html delete mode 100644 frontend/src/app/features/admin/pages/admin-orders-past.component.scss delete mode 100644 frontend/src/app/features/admin/pages/admin-orders-past.component.ts diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java new file mode 100644 index 0000000..cb2a5e0 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java @@ -0,0 +1,285 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AdminFilamentMaterialTypeDto; +import com.printcalculator.dto.AdminFilamentVariantDto; +import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest; +import com.printcalculator.dto.AdminUpsertFilamentVariantRequest; +import com.printcalculator.entity.FilamentMaterialType; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.repository.FilamentMaterialTypeRepository; +import com.printcalculator.repository.FilamentVariantRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Comparator; +import java.util.List; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@RestController +@RequestMapping("/api/admin/filaments") +@Transactional(readOnly = true) +public class AdminFilamentController { + private static final BigDecimal MAX_NUMERIC_6_3 = new BigDecimal("999.999"); + + private final FilamentMaterialTypeRepository materialRepo; + private final FilamentVariantRepository variantRepo; + + public AdminFilamentController( + FilamentMaterialTypeRepository materialRepo, + FilamentVariantRepository variantRepo + ) { + this.materialRepo = materialRepo; + this.variantRepo = variantRepo; + } + + @GetMapping("/materials") + public ResponseEntity> getMaterials() { + List response = materialRepo.findAll().stream() + .sorted(Comparator.comparing(FilamentMaterialType::getMaterialCode, String.CASE_INSENSITIVE_ORDER)) + .map(this::toMaterialDto) + .toList(); + return ResponseEntity.ok(response); + } + + @GetMapping("/variants") + public ResponseEntity> getVariants() { + List response = variantRepo.findAll().stream() + .sorted(Comparator + .comparing((FilamentVariant v) -> { + FilamentMaterialType type = v.getFilamentMaterialType(); + return type != null && type.getMaterialCode() != null ? type.getMaterialCode() : ""; + }, String.CASE_INSENSITIVE_ORDER) + .thenComparing(v -> v.getVariantDisplayName() != null ? v.getVariantDisplayName() : "", String.CASE_INSENSITIVE_ORDER)) + .map(this::toVariantDto) + .toList(); + return ResponseEntity.ok(response); + } + + @PostMapping("/materials") + @Transactional + public ResponseEntity createMaterial( + @RequestBody AdminUpsertFilamentMaterialTypeRequest payload + ) { + String materialCode = normalizeAndValidateMaterialCode(payload); + ensureMaterialCodeAvailable(materialCode, null); + + FilamentMaterialType material = new FilamentMaterialType(); + applyMaterialPayload(material, payload, materialCode); + FilamentMaterialType saved = materialRepo.save(material); + return ResponseEntity.ok(toMaterialDto(saved)); + } + + @PutMapping("/materials/{materialTypeId}") + @Transactional + public ResponseEntity updateMaterial( + @PathVariable Long materialTypeId, + @RequestBody AdminUpsertFilamentMaterialTypeRequest payload + ) { + FilamentMaterialType material = materialRepo.findById(materialTypeId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament material not found")); + + String materialCode = normalizeAndValidateMaterialCode(payload); + ensureMaterialCodeAvailable(materialCode, materialTypeId); + + applyMaterialPayload(material, payload, materialCode); + FilamentMaterialType saved = materialRepo.save(material); + return ResponseEntity.ok(toMaterialDto(saved)); + } + + @PostMapping("/variants") + @Transactional + public ResponseEntity createVariant( + @RequestBody AdminUpsertFilamentVariantRequest payload + ) { + FilamentMaterialType material = validateAndResolveMaterial(payload); + String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName()); + String normalizedColorName = normalizeAndValidateColorName(payload.getColorName()); + validateNumericPayload(payload); + ensureVariantDisplayNameAvailable(material, normalizedDisplayName, null); + + FilamentVariant variant = new FilamentVariant(); + variant.setCreatedAt(OffsetDateTime.now()); + applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName); + FilamentVariant saved = variantRepo.save(variant); + return ResponseEntity.ok(toVariantDto(saved)); + } + + @PutMapping("/variants/{variantId}") + @Transactional + public ResponseEntity updateVariant( + @PathVariable Long variantId, + @RequestBody AdminUpsertFilamentVariantRequest payload + ) { + FilamentVariant variant = variantRepo.findById(variantId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found")); + + FilamentMaterialType material = validateAndResolveMaterial(payload); + String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName()); + String normalizedColorName = normalizeAndValidateColorName(payload.getColorName()); + validateNumericPayload(payload); + ensureVariantDisplayNameAvailable(material, normalizedDisplayName, variantId); + + applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName); + FilamentVariant saved = variantRepo.save(variant); + return ResponseEntity.ok(toVariantDto(saved)); + } + + private void applyMaterialPayload( + FilamentMaterialType material, + AdminUpsertFilamentMaterialTypeRequest payload, + String normalizedMaterialCode + ) { + boolean isFlexible = payload != null && Boolean.TRUE.equals(payload.getIsFlexible()); + boolean isTechnical = payload != null && Boolean.TRUE.equals(payload.getIsTechnical()); + String technicalTypeLabel = payload != null && payload.getTechnicalTypeLabel() != null + ? payload.getTechnicalTypeLabel().trim() + : null; + + material.setMaterialCode(normalizedMaterialCode); + material.setIsFlexible(isFlexible); + material.setIsTechnical(isTechnical); + material.setTechnicalTypeLabel(isTechnical && technicalTypeLabel != null && !technicalTypeLabel.isBlank() + ? technicalTypeLabel + : null); + } + + private void applyVariantPayload( + FilamentVariant variant, + AdminUpsertFilamentVariantRequest payload, + FilamentMaterialType material, + String normalizedDisplayName, + String normalizedColorName + ) { + variant.setFilamentMaterialType(material); + variant.setVariantDisplayName(normalizedDisplayName); + variant.setColorName(normalizedColorName); + variant.setIsMatte(Boolean.TRUE.equals(payload.getIsMatte())); + variant.setIsSpecial(Boolean.TRUE.equals(payload.getIsSpecial())); + variant.setCostChfPerKg(payload.getCostChfPerKg()); + variant.setStockSpools(payload.getStockSpools()); + variant.setSpoolNetKg(payload.getSpoolNetKg()); + variant.setIsActive(payload.getIsActive() == null || payload.getIsActive()); + } + + private String normalizeAndValidateMaterialCode(AdminUpsertFilamentMaterialTypeRequest payload) { + if (payload == null || payload.getMaterialCode() == null || payload.getMaterialCode().isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Material code is required"); + } + return payload.getMaterialCode().trim().toUpperCase(); + } + + private String normalizeAndValidateVariantDisplayName(String value) { + if (value == null || value.isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Variant display name is required"); + } + return value.trim(); + } + + private String normalizeAndValidateColorName(String value) { + if (value == null || value.isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Color name is required"); + } + return value.trim(); + } + + private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) { + if (payload == null || payload.getMaterialTypeId() == null) { + throw new ResponseStatusException(BAD_REQUEST, "Material type id is required"); + } + + return materialRepo.findById(payload.getMaterialTypeId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Material type not found")); + } + + private void validateNumericPayload(AdminUpsertFilamentVariantRequest payload) { + if (payload.getCostChfPerKg() == null || payload.getCostChfPerKg().compareTo(BigDecimal.ZERO) < 0) { + throw new ResponseStatusException(BAD_REQUEST, "Cost CHF/kg must be >= 0"); + } + validateNumeric63(payload.getStockSpools(), "Stock spools", true); + validateNumeric63(payload.getSpoolNetKg(), "Spool net kg", false); + } + + private void validateNumeric63(BigDecimal value, String fieldName, boolean allowZero) { + if (value == null) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " is required"); + } + + if (allowZero) { + if (value.compareTo(BigDecimal.ZERO) < 0) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be >= 0"); + } + } else if (value.compareTo(BigDecimal.ZERO) <= 0) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be > 0"); + } + + if (value.scale() > 3) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must have at most 3 decimal places"); + } + + if (value.compareTo(MAX_NUMERIC_6_3) > 0) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be <= 999.999"); + } + } + + private void ensureMaterialCodeAvailable(String materialCode, Long currentMaterialId) { + materialRepo.findByMaterialCode(materialCode).ifPresent(existing -> { + if (currentMaterialId == null || !existing.getId().equals(currentMaterialId)) { + throw new ResponseStatusException(BAD_REQUEST, "Material code already exists"); + } + }); + } + + private void ensureVariantDisplayNameAvailable(FilamentMaterialType material, String displayName, Long currentVariantId) { + variantRepo.findByFilamentMaterialTypeAndVariantDisplayName(material, displayName).ifPresent(existing -> { + if (currentVariantId == null || !existing.getId().equals(currentVariantId)) { + throw new ResponseStatusException(BAD_REQUEST, "Variant display name already exists for this material"); + } + }); + } + + private AdminFilamentMaterialTypeDto toMaterialDto(FilamentMaterialType material) { + AdminFilamentMaterialTypeDto dto = new AdminFilamentMaterialTypeDto(); + dto.setId(material.getId()); + dto.setMaterialCode(material.getMaterialCode()); + dto.setIsFlexible(material.getIsFlexible()); + dto.setIsTechnical(material.getIsTechnical()); + dto.setTechnicalTypeLabel(material.getTechnicalTypeLabel()); + return dto; + } + + private AdminFilamentVariantDto toVariantDto(FilamentVariant variant) { + AdminFilamentVariantDto dto = new AdminFilamentVariantDto(); + dto.setId(variant.getId()); + + FilamentMaterialType material = variant.getFilamentMaterialType(); + if (material != null) { + dto.setMaterialTypeId(material.getId()); + dto.setMaterialCode(material.getMaterialCode()); + dto.setMaterialIsFlexible(material.getIsFlexible()); + dto.setMaterialIsTechnical(material.getIsTechnical()); + dto.setMaterialTechnicalTypeLabel(material.getTechnicalTypeLabel()); + } + + dto.setVariantDisplayName(variant.getVariantDisplayName()); + dto.setColorName(variant.getColorName()); + dto.setIsMatte(variant.getIsMatte()); + dto.setIsSpecial(variant.getIsSpecial()); + dto.setCostChfPerKg(variant.getCostChfPerKg()); + dto.setStockSpools(variant.getStockSpools()); + dto.setSpoolNetKg(variant.getSpoolNetKg()); + if (variant.getStockSpools() != null && variant.getSpoolNetKg() != null) { + dto.setStockKg(variant.getStockSpools().multiply(variant.getSpoolNetKg())); + } else { + dto.setStockKg(BigDecimal.ZERO); + } + dto.setIsActive(variant.getIsActive()); + dto.setCreatedAt(variant.getCreatedAt()); + return dto; + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java index 4d55467..c39b3b8 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java @@ -1,49 +1,86 @@ package com.printcalculator.controller.admin; import com.printcalculator.dto.AdminContactRequestDto; +import com.printcalculator.dto.AdminContactRequestAttachmentDto; +import com.printcalculator.dto.AdminContactRequestDetailDto; import com.printcalculator.dto.AdminFilamentStockDto; import com.printcalculator.dto.AdminQuoteSessionDto; import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.entity.FilamentVariant; import com.printcalculator.entity.FilamentVariantStockKg; import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; import com.printcalculator.repository.CustomQuoteRequestRepository; import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.FilamentVariantStockKgRepository; +import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.QuoteSessionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; import org.springframework.data.domain.Sort; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.MalformedURLException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.springframework.http.HttpStatus.NOT_FOUND; @RestController @RequestMapping("/api/admin") @Transactional(readOnly = true) public class AdminOperationsController { + private static final Logger logger = LoggerFactory.getLogger(AdminOperationsController.class); + private static final Path CONTACT_ATTACHMENTS_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); private final FilamentVariantStockKgRepository filamentStockRepo; private final FilamentVariantRepository filamentVariantRepo; private final CustomQuoteRequestRepository customQuoteRequestRepo; + private final CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo; private final QuoteSessionRepository quoteSessionRepo; + private final OrderRepository orderRepo; public AdminOperationsController( FilamentVariantStockKgRepository filamentStockRepo, FilamentVariantRepository filamentVariantRepo, CustomQuoteRequestRepository customQuoteRequestRepo, - QuoteSessionRepository quoteSessionRepo + CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo, + QuoteSessionRepository quoteSessionRepo, + OrderRepository orderRepo ) { this.filamentStockRepo = filamentStockRepo; this.filamentVariantRepo = filamentVariantRepo; this.customQuoteRequestRepo = customQuoteRequestRepo; + this.customQuoteRequestAttachmentRepo = customQuoteRequestAttachmentRepo; this.quoteSessionRepo = quoteSessionRepo; + this.orderRepo = orderRepo; } @GetMapping("/filament-stock") @@ -103,6 +140,101 @@ public class AdminOperationsController { return ResponseEntity.ok(response); } + @GetMapping("/contact-requests/{requestId}") + public ResponseEntity getContactRequestDetail(@PathVariable UUID requestId) { + CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found")); + + List attachments = customQuoteRequestAttachmentRepo + .findByRequest_IdOrderByCreatedAtAsc(requestId) + .stream() + .map(this::toContactRequestAttachmentDto) + .toList(); + + AdminContactRequestDetailDto dto = new AdminContactRequestDetailDto(); + dto.setId(request.getId()); + dto.setRequestType(request.getRequestType()); + dto.setCustomerType(request.getCustomerType()); + dto.setEmail(request.getEmail()); + dto.setPhone(request.getPhone()); + dto.setName(request.getName()); + dto.setCompanyName(request.getCompanyName()); + dto.setContactPerson(request.getContactPerson()); + dto.setMessage(request.getMessage()); + dto.setStatus(request.getStatus()); + dto.setCreatedAt(request.getCreatedAt()); + dto.setUpdatedAt(request.getUpdatedAt()); + dto.setAttachments(attachments); + + return ResponseEntity.ok(dto); + } + + @GetMapping("/contact-requests/{requestId}/attachments/{attachmentId}/file") + public ResponseEntity downloadContactRequestAttachment( + @PathVariable UUID requestId, + @PathVariable UUID attachmentId + ) { + CustomQuoteRequestAttachment attachment = customQuoteRequestAttachmentRepo.findById(attachmentId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Attachment not found")); + + if (!attachment.getRequest().getId().equals(requestId)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment not found for request"); + } + + String relativePath = attachment.getStoredRelativePath(); + if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + String expectedPrefix = "quote-requests/" + requestId + "/attachments/" + attachmentId + "/"; + if (!relativePath.startsWith(expectedPrefix)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + Path filePath = CONTACT_ATTACHMENTS_ROOT.resolve(relativePath).normalize(); + if (!filePath.startsWith(CONTACT_ATTACHMENTS_ROOT)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + if (!Files.exists(filePath)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + try { + Resource resource = new UrlResource(filePath.toUri()); + if (!resource.exists() || !resource.isReadable()) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM; + String mimeType = attachment.getMimeType(); + if (mimeType != null && !mimeType.isBlank()) { + try { + mediaType = MediaType.parseMediaType(mimeType); + } catch (Exception ignored) { + mediaType = MediaType.APPLICATION_OCTET_STREAM; + } + } + + String filename = attachment.getOriginalFilename(); + if (filename == null || filename.isBlank()) { + filename = attachment.getStoredFilename() != null && !attachment.getStoredFilename().isBlank() + ? attachment.getStoredFilename() + : "attachment-" + attachmentId; + } + + return ResponseEntity.ok() + .contentType(mediaType) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(filename, StandardCharsets.UTF_8) + .build() + .toString()) + .body(resource); + } catch (MalformedURLException e) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + } + @GetMapping("/sessions") public ResponseEntity> getQuoteSessions() { List response = quoteSessionRepo.findAll( @@ -115,6 +247,21 @@ public class AdminOperationsController { return ResponseEntity.ok(response); } + @DeleteMapping("/sessions/{sessionId}") + @Transactional + public ResponseEntity deleteQuoteSession(@PathVariable UUID sessionId) { + QuoteSession session = quoteSessionRepo.findById(sessionId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found")); + + if (orderRepo.existsBySourceQuoteSession_Id(sessionId)) { + throw new ResponseStatusException(CONFLICT, "Cannot delete session already linked to an order"); + } + + deleteSessionFiles(sessionId); + quoteSessionRepo.delete(session); + return ResponseEntity.noContent().build(); + } + private AdminContactRequestDto toContactRequestDto(CustomQuoteRequest request) { AdminContactRequestDto dto = new AdminContactRequestDto(); dto.setId(request.getId()); @@ -129,6 +276,16 @@ public class AdminOperationsController { return dto; } + private AdminContactRequestAttachmentDto toContactRequestAttachmentDto(CustomQuoteRequestAttachment attachment) { + AdminContactRequestAttachmentDto dto = new AdminContactRequestAttachmentDto(); + dto.setId(attachment.getId()); + dto.setOriginalFilename(attachment.getOriginalFilename()); + dto.setMimeType(attachment.getMimeType()); + dto.setFileSizeBytes(attachment.getFileSizeBytes()); + dto.setCreatedAt(attachment.getCreatedAt()); + return dto; + } + private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) { AdminQuoteSessionDto dto = new AdminQuoteSessionDto(); dto.setId(session.getId()); @@ -139,4 +296,24 @@ public class AdminOperationsController { dto.setConvertedOrderId(session.getConvertedOrderId()); return dto; } + + private void deleteSessionFiles(UUID sessionId) { + Path sessionDir = Paths.get("storage_quotes", sessionId.toString()); + if (!Files.exists(sessionDir)) { + return; + } + + try (Stream walk = Files.walk(sessionDir)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (IOException | UncheckedIOException e) { + logger.error("Failed to delete files for session {}", sessionId, e); + throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Unable to delete session files"); + } + } } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminContactRequestAttachmentDto.java b/backend/src/main/java/com/printcalculator/dto/AdminContactRequestAttachmentDto.java new file mode 100644 index 0000000..0d7a0ad --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminContactRequestAttachmentDto.java @@ -0,0 +1,52 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminContactRequestAttachmentDto { + private UUID id; + private String originalFilename; + private String mimeType; + private Long fileSizeBytes; + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public void setOriginalFilename(String originalFilename) { + this.originalFilename = originalFilename; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminContactRequestDetailDto.java b/backend/src/main/java/com/printcalculator/dto/AdminContactRequestDetailDto.java new file mode 100644 index 0000000..867a0ee --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminContactRequestDetailDto.java @@ -0,0 +1,125 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public class AdminContactRequestDetailDto { + private UUID id; + private String requestType; + private String customerType; + private String email; + private String phone; + private String name; + private String companyName; + private String contactPerson; + private String message; + private String status; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + private List attachments; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getRequestType() { + return requestType; + } + + public void setRequestType(String requestType) { + this.requestType = requestType; + } + + public String getCustomerType() { + return customerType; + } + + public void setCustomerType(String customerType) { + this.customerType = customerType; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCompanyName() { + return companyName; + } + + public void setCompanyName(String companyName) { + this.companyName = companyName; + } + + public String getContactPerson() { + return contactPerson; + } + + public void setContactPerson(String contactPerson) { + this.contactPerson = contactPerson; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminFilamentMaterialTypeDto.java b/backend/src/main/java/com/printcalculator/dto/AdminFilamentMaterialTypeDto.java new file mode 100644 index 0000000..749faca --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminFilamentMaterialTypeDto.java @@ -0,0 +1,49 @@ +package com.printcalculator.dto; + +public class AdminFilamentMaterialTypeDto { + private Long id; + private String materialCode; + private Boolean isFlexible; + private Boolean isTechnical; + private String technicalTypeLabel; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getMaterialCode() { + return materialCode; + } + + public void setMaterialCode(String materialCode) { + this.materialCode = materialCode; + } + + public Boolean getIsFlexible() { + return isFlexible; + } + + public void setIsFlexible(Boolean isFlexible) { + this.isFlexible = isFlexible; + } + + public Boolean getIsTechnical() { + return isTechnical; + } + + public void setIsTechnical(Boolean isTechnical) { + this.isTechnical = isTechnical; + } + + public String getTechnicalTypeLabel() { + return technicalTypeLabel; + } + + public void setTechnicalTypeLabel(String technicalTypeLabel) { + this.technicalTypeLabel = technicalTypeLabel; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java b/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java new file mode 100644 index 0000000..f8c2141 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java @@ -0,0 +1,151 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +public class AdminFilamentVariantDto { + private Long id; + private Long materialTypeId; + private String materialCode; + private Boolean materialIsFlexible; + private Boolean materialIsTechnical; + private String materialTechnicalTypeLabel; + private String variantDisplayName; + private String colorName; + private Boolean isMatte; + private Boolean isSpecial; + private BigDecimal costChfPerKg; + private BigDecimal stockSpools; + private BigDecimal spoolNetKg; + private BigDecimal stockKg; + private Boolean isActive; + private OffsetDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getMaterialTypeId() { + return materialTypeId; + } + + public void setMaterialTypeId(Long materialTypeId) { + this.materialTypeId = materialTypeId; + } + + public String getMaterialCode() { + return materialCode; + } + + public void setMaterialCode(String materialCode) { + this.materialCode = materialCode; + } + + public Boolean getMaterialIsFlexible() { + return materialIsFlexible; + } + + public void setMaterialIsFlexible(Boolean materialIsFlexible) { + this.materialIsFlexible = materialIsFlexible; + } + + public Boolean getMaterialIsTechnical() { + return materialIsTechnical; + } + + public void setMaterialIsTechnical(Boolean materialIsTechnical) { + this.materialIsTechnical = materialIsTechnical; + } + + public String getMaterialTechnicalTypeLabel() { + return materialTechnicalTypeLabel; + } + + public void setMaterialTechnicalTypeLabel(String materialTechnicalTypeLabel) { + this.materialTechnicalTypeLabel = materialTechnicalTypeLabel; + } + + public String getVariantDisplayName() { + return variantDisplayName; + } + + public void setVariantDisplayName(String variantDisplayName) { + this.variantDisplayName = variantDisplayName; + } + + public String getColorName() { + return colorName; + } + + public void setColorName(String colorName) { + this.colorName = colorName; + } + + public Boolean getIsMatte() { + return isMatte; + } + + public void setIsMatte(Boolean isMatte) { + this.isMatte = isMatte; + } + + public Boolean getIsSpecial() { + return isSpecial; + } + + public void setIsSpecial(Boolean isSpecial) { + this.isSpecial = isSpecial; + } + + public BigDecimal getCostChfPerKg() { + return costChfPerKg; + } + + public void setCostChfPerKg(BigDecimal costChfPerKg) { + this.costChfPerKg = costChfPerKg; + } + + public BigDecimal getStockSpools() { + return stockSpools; + } + + public void setStockSpools(BigDecimal stockSpools) { + this.stockSpools = stockSpools; + } + + public BigDecimal getSpoolNetKg() { + return spoolNetKg; + } + + public void setSpoolNetKg(BigDecimal spoolNetKg) { + this.spoolNetKg = spoolNetKg; + } + + public BigDecimal getStockKg() { + return stockKg; + } + + public void setStockKg(BigDecimal stockKg) { + this.stockKg = stockKg; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentMaterialTypeRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentMaterialTypeRequest.java new file mode 100644 index 0000000..66cdd6b --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentMaterialTypeRequest.java @@ -0,0 +1,40 @@ +package com.printcalculator.dto; + +public class AdminUpsertFilamentMaterialTypeRequest { + private String materialCode; + private Boolean isFlexible; + private Boolean isTechnical; + private String technicalTypeLabel; + + public String getMaterialCode() { + return materialCode; + } + + public void setMaterialCode(String materialCode) { + this.materialCode = materialCode; + } + + public Boolean getIsFlexible() { + return isFlexible; + } + + public void setIsFlexible(Boolean isFlexible) { + this.isFlexible = isFlexible; + } + + public Boolean getIsTechnical() { + return isTechnical; + } + + public void setIsTechnical(Boolean isTechnical) { + this.isTechnical = isTechnical; + } + + public String getTechnicalTypeLabel() { + return technicalTypeLabel; + } + + public void setTechnicalTypeLabel(String technicalTypeLabel) { + this.technicalTypeLabel = technicalTypeLabel; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java new file mode 100644 index 0000000..97763be --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java @@ -0,0 +1,87 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; + +public class AdminUpsertFilamentVariantRequest { + private Long materialTypeId; + private String variantDisplayName; + private String colorName; + private Boolean isMatte; + private Boolean isSpecial; + private BigDecimal costChfPerKg; + private BigDecimal stockSpools; + private BigDecimal spoolNetKg; + private Boolean isActive; + + public Long getMaterialTypeId() { + return materialTypeId; + } + + public void setMaterialTypeId(Long materialTypeId) { + this.materialTypeId = materialTypeId; + } + + public String getVariantDisplayName() { + return variantDisplayName; + } + + public void setVariantDisplayName(String variantDisplayName) { + this.variantDisplayName = variantDisplayName; + } + + public String getColorName() { + return colorName; + } + + public void setColorName(String colorName) { + this.colorName = colorName; + } + + public Boolean getIsMatte() { + return isMatte; + } + + public void setIsMatte(Boolean isMatte) { + this.isMatte = isMatte; + } + + public Boolean getIsSpecial() { + return isSpecial; + } + + public void setIsSpecial(Boolean isSpecial) { + this.isSpecial = isSpecial; + } + + public BigDecimal getCostChfPerKg() { + return costChfPerKg; + } + + public void setCostChfPerKg(BigDecimal costChfPerKg) { + this.costChfPerKg = costChfPerKg; + } + + public BigDecimal getStockSpools() { + return stockSpools; + } + + public void setStockSpools(BigDecimal stockSpools) { + this.stockSpools = stockSpools; + } + + public BigDecimal getSpoolNetKg() { + return spoolNetKg; + } + + public void setSpoolNetKg(BigDecimal spoolNetKg) { + this.spoolNetKg = spoolNetKg; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } +} diff --git a/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestAttachmentRepository.java b/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestAttachmentRepository.java index c256003..ad44494 100644 --- a/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestAttachmentRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestAttachmentRepository.java @@ -3,7 +3,9 @@ package com.printcalculator.repository; import com.printcalculator.entity.CustomQuoteRequestAttachment; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.UUID; public interface CustomQuoteRequestAttachmentRepository extends JpaRepository { -} \ No newline at end of file + List findByRequest_IdOrderByCreatedAtAsc(UUID requestId); +} diff --git a/backend/src/main/java/com/printcalculator/repository/FilamentVariantRepository.java b/backend/src/main/java/com/printcalculator/repository/FilamentVariantRepository.java index 1c04817..43b9e73 100644 --- a/backend/src/main/java/com/printcalculator/repository/FilamentVariantRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/FilamentVariantRepository.java @@ -9,5 +9,6 @@ import java.util.Optional; public interface FilamentVariantRepository extends JpaRepository { // We try to match by color name if possible, or get first active Optional findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName); + Optional findByFilamentMaterialTypeAndVariantDisplayName(FilamentMaterialType type, String variantDisplayName); Optional findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type); -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/printcalculator/repository/OrderRepository.java b/backend/src/main/java/com/printcalculator/repository/OrderRepository.java index a4ca921..478261c 100644 --- a/backend/src/main/java/com/printcalculator/repository/OrderRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/OrderRepository.java @@ -8,4 +8,6 @@ import java.util.UUID; public interface OrderRepository extends JpaRepository { List findAllByOrderByCreatedAtDesc(); + + boolean existsBySourceQuoteSession_Id(UUID sourceQuoteSessionId); } diff --git a/db.sql b/db.sql index 327547a..339f027 100644 --- a/db.sql +++ b/db.sql @@ -59,7 +59,6 @@ create table filament_variant unique (filament_material_type_id, variant_display_name) ); --- (opzionale) kg disponibili calcolati create view filament_variant_stock_kg as select filament_variant_id, stock_spools, diff --git a/frontend/src/app/features/admin/admin.routes.ts b/frontend/src/app/features/admin/admin.routes.ts index 13632c3..51bf68f 100644 --- a/frontend/src/app/features/admin/admin.routes.ts +++ b/frontend/src/app/features/admin/admin.routes.ts @@ -20,10 +20,6 @@ export const ADMIN_ROUTES: Routes = [ path: 'orders', loadComponent: () => import('./pages/admin-dashboard.component').then(m => m.AdminDashboardComponent) }, - { - path: 'orders-past', - loadComponent: () => import('./pages/admin-orders-past.component').then(m => m.AdminOrdersPastComponent) - }, { path: 'filament-stock', loadComponent: () => import('./pages/admin-filament-stock.component').then(m => m.AdminFilamentStockComponent) diff --git a/frontend/src/app/features/admin/pages/admin-contact-requests.component.html b/frontend/src/app/features/admin/pages/admin-contact-requests.component.html index 33efec7..713f20b 100644 --- a/frontend/src/app/features/admin/pages/admin-contact-requests.component.html +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.html @@ -1,40 +1,119 @@
-
+

Richieste di contatto

Richieste preventivo personalizzato ricevute dal sito.

+ {{ requests.length }} richieste

{{ errorMessage }}

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

Lista richieste

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
DataNome / AziendaEmailTipo richiestaTipo clienteStato
{{ request.createdAt | date:'short' }} +

{{ request.name || request.companyName || '-' }}

+

{{ request.companyName }}

+
+ {{ request.requestType }} + + {{ request.customerType }} + + {{ request.status }} +
Nessuna richiesta presente.
+
+
+ +
+
+
+

Dettaglio richiesta

+

ID{{ selectedRequest.id }}

+
+
+ {{ selectedRequest.status }} + {{ selectedRequest.requestType }} + {{ selectedRequest.customerType }} +
+
+ +

Caricamento dettaglio...

+ +
+
Creata
{{ selectedRequest.createdAt | date:'medium' }}
+
Aggiornata
{{ selectedRequest.updatedAt | date:'medium' }}
+
Email
{{ selectedRequest.email }}
+
Telefono
{{ selectedRequest.phone || '-' }}
+
Nome
{{ selectedRequest.name || '-' }}
+
Azienda
{{ selectedRequest.companyName || '-' }}
+
Referente
{{ selectedRequest.contactPerson || '-' }}
+
+ +
+

Messaggio

+

{{ selectedRequest.message || '-' }}

+
+ +
+

Allegati

+
+
+
+

{{ attachment.originalFilename }}

+

+ {{ formatFileSize(attachment.fileSizeBytes) }} + | {{ attachment.mimeType }} + | {{ attachment.createdAt | date:'short' }} +

+
+ +
+
+
+
+ +
+

Nessuna richiesta selezionata

+

Seleziona una riga dalla lista per vedere il dettaglio.

+

Caricamento richieste...

+ + +

Nessun allegato disponibile.

+
diff --git a/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss b/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss index a293e62..5724cf6 100644 --- a/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss @@ -11,18 +11,44 @@ justify-content: space-between; align-items: flex-start; gap: var(--space-4); - margin-bottom: var(--space-5); + margin-bottom: var(--space-4); } -h2 { +.section-header h2 { margin: 0; + font-size: 1.4rem; } -p { +.section-header p { margin: var(--space-2) 0 0; color: var(--color-text-muted); } +.header-copy { + display: grid; + gap: var(--space-1); +} + +.total-pill { + width: fit-content; + margin-top: var(--space-1); + border-radius: 999px; + border: 1px solid var(--color-border); + background: var(--color-neutral-100); + color: var(--color-text-muted); + font-size: 0.78rem; + font-weight: 700; + line-height: 1; + padding: 6px 10px; +} + +.workspace { + display: grid; + grid-template-columns: minmax(500px, 1.25fr) minmax(420px, 1fr); + gap: var(--space-4); + align-items: start; +} + button { border: 0; border-radius: var(--radius-md); @@ -31,15 +57,31 @@ button { padding: var(--space-2) var(--space-4); font-weight: 600; cursor: pointer; - transition: background-color 0.2s ease; + transition: background-color 0.2s ease, opacity 0.2s ease; + line-height: 1.2; } button:hover:not(:disabled) { background: var(--color-brand-hover); } +button.ghost { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + color: var(--color-text); +} + +.list-panel h3 { + margin: 0 0 var(--space-2); + font-size: 1.02rem; +} + .table-wrap { overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + max-height: 72vh; } table { @@ -47,13 +89,300 @@ table { border-collapse: collapse; } +thead { + position: sticky; + top: 0; + z-index: 1; + background: var(--color-neutral-100); +} + th, td { text-align: left; padding: var(--space-3); border-bottom: 1px solid var(--color-border); + font-size: 0.92rem; + vertical-align: top; +} + +th { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted); +} + +.name-cell .primary { + margin: 0; + font-weight: 600; +} + +.name-cell .secondary { + margin: 2px 0 0; + font-size: 0.82rem; + color: var(--color-text-muted); +} + +.email-cell, +.created-at { + color: var(--color-text-muted); +} + +tbody tr { + cursor: pointer; + transition: background-color 0.15s ease; +} + +tbody tr:hover { + background: #fff9d9; +} + +tbody tr.selected { + background: #fff5b8; +} + +.empty-row { + cursor: default; +} + +.empty-row:hover { + background: transparent; +} + +.detail-panel { + display: grid; + gap: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + padding: var(--space-4); + min-height: 500px; +} + +.detail-panel.empty { + display: grid; + align-content: center; + justify-items: center; + text-align: center; +} + +.detail-panel.empty h3 { + margin: 0 0 var(--space-2); +} + +.detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); +} + +.detail-header h3 { + margin: 0; +} + +.detail-chips { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); +} + +.request-id { + margin: var(--space-2) 0 0; + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.request-id code { + display: inline-block; + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text); + background: var(--color-neutral-100); + border: 1px solid var(--color-border); + border-radius: 7px; + padding: 3px 8px; +} + +.loading-detail { + margin: 0; + color: var(--color-text-muted); + font-size: 0.85rem; +} + +.meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: var(--space-2); + margin: 0; +} + +.meta-item { + margin: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--color-neutral-100); + display: grid; + gap: 4px; +} + +.meta-item dt { + margin: 0; + font-size: 0.78rem; + font-weight: 700; + color: var(--color-text-muted); +} + +.meta-item dd { + margin: 0; + overflow-wrap: anywhere; + font-size: 0.93rem; +} + +.message-box { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-neutral-100); + padding: var(--space-3); +} + +.message-box h4 { + margin: 0 0 var(--space-2); + font-size: 0.86rem; + color: var(--color-text-muted); +} + +.message-box p { + margin: 0; + white-space: pre-wrap; +} + +.attachments h4 { + margin: 0 0 var(--space-2); +} + +.attachment-list { + display: grid; + gap: var(--space-2); +} + +.attachment-item { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + padding: var(--space-3); + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + box-shadow: var(--shadow-sm); +} + +.filename { + margin: 0; + font-weight: 600; + font-size: 0.92rem; +} + +.meta { + margin: 2px 0 0; + color: var(--color-text-muted); + font-size: 0.82rem; + overflow-wrap: anywhere; +} + +.muted { + color: var(--color-text-muted); + margin: 0; } .error { color: var(--color-danger-500); + margin-bottom: var(--space-3); +} + +.chip { + display: inline-flex; + align-items: center; + border-radius: 999px; + border: 1px solid transparent; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1; + padding: 5px 9px; + text-transform: uppercase; +} + +.chip-neutral { + background: #e9f4ff; + border-color: #c8def4; + color: #1e4d78; +} + +.chip-light { + background: #f4f5f8; + border-color: #dde1e8; + color: #4a5567; +} + +.chip-warning { + background: #fff4cd; + border-color: #f7dd85; + color: #684b00; +} + +.chip-success { + background: #dff6ea; + border-color: #b6e2cb; + color: #14543a; +} + +.chip-danger { + background: #fde4e2; + border-color: #f3c0ba; + color: #812924; +} + +button:disabled { + opacity: 0.68; + cursor: default; +} + +@media (max-width: 1060px) { + .workspace { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .section-card { + padding: var(--space-4); + } + + .section-header { + flex-direction: column; + align-items: stretch; + } + + .detail-header { + flex-direction: column; + } + + .detail-chips { + justify-content: flex-start; + } + + .attachment-item { + flex-direction: column; + align-items: flex-start; + padding: var(--space-3); + } } diff --git a/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts b/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts index 17613b6..9213583 100644 --- a/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts @@ -1,6 +1,11 @@ import { CommonModule } from '@angular/common'; import { Component, inject, OnInit } from '@angular/core'; -import { AdminContactRequest, AdminOperationsService } from '../services/admin-operations.service'; +import { + AdminContactRequest, + AdminContactRequestAttachment, + AdminContactRequestDetail, + AdminOperationsService +} from '../services/admin-operations.service'; @Component({ selector: 'app-admin-contact-requests', @@ -13,7 +18,10 @@ export class AdminContactRequestsComponent implements OnInit { private readonly adminOperationsService = inject(AdminOperationsService); requests: AdminContactRequest[] = []; + selectedRequest: AdminContactRequestDetail | null = null; + selectedRequestId: string | null = null; loading = false; + detailLoading = false; errorMessage: string | null = null; ngOnInit(): void { @@ -26,6 +34,14 @@ export class AdminContactRequestsComponent implements OnInit { this.adminOperationsService.getContactRequests().subscribe({ next: (requests) => { this.requests = requests; + if (requests.length === 0) { + this.selectedRequest = null; + this.selectedRequestId = null; + } else if (this.selectedRequestId && requests.some(r => r.id === this.selectedRequestId)) { + this.openDetails(this.selectedRequestId); + } else { + this.openDetails(requests[0].id); + } this.loading = false; }, error: () => { @@ -34,4 +50,73 @@ export class AdminContactRequestsComponent implements OnInit { } }); } + + openDetails(requestId: string): void { + this.selectedRequestId = requestId; + this.detailLoading = true; + this.adminOperationsService.getContactRequestDetail(requestId).subscribe({ + next: (detail) => { + this.selectedRequest = detail; + this.detailLoading = false; + }, + error: () => { + this.detailLoading = false; + this.errorMessage = 'Impossibile caricare il dettaglio richiesta.'; + } + }); + } + + isSelected(requestId: string): boolean { + return this.selectedRequestId === requestId; + } + + downloadAttachment(attachment: AdminContactRequestAttachment): void { + if (!this.selectedRequest) { + return; + } + + this.adminOperationsService.downloadContactRequestAttachment(this.selectedRequest.id, attachment.id).subscribe({ + next: (blob) => this.downloadBlob(blob, attachment.originalFilename || `attachment-${attachment.id}`), + error: () => { + this.errorMessage = 'Download allegato non riuscito.'; + } + }); + } + + formatFileSize(bytes?: number): string { + if (!bytes || bytes <= 0) { + return '-'; + } + const units = ['B', 'KB', 'MB', 'GB']; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; + } + + getStatusChipClass(status?: string): string { + const normalized = (status || '').trim().toUpperCase(); + if (['PENDING', 'NEW', 'OPEN', 'IN_PROGRESS'].includes(normalized)) { + return 'chip-warning'; + } + if (['DONE', 'COMPLETED', 'RESOLVED', 'CLOSED'].includes(normalized)) { + return 'chip-success'; + } + if (['REJECTED', 'FAILED', 'ERROR', 'SPAM'].includes(normalized)) { + return 'chip-danger'; + } + return 'chip-light'; + } + + private downloadBlob(blob: Blob, filename: string): void { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + window.URL.revokeObjectURL(url); + } } diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html index 4c8fe93..b60facc 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.html +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -15,14 +15,36 @@

Lista ordini

- - + + +
@@ -31,6 +53,7 @@ + @@ -43,10 +66,11 @@ + - +
Ordine Email PagamentoStato ordine Totale
{{ order.orderNumber }} {{ order.customerEmail }} {{ order.paymentStatus || 'PENDING' }}{{ order.status }} {{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}
Nessun ordine trovato per il filtro inserito.Nessun ordine trovato per i filtri selezionati.
diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.scss b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss index 8cdf210..68373dd 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.scss +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss @@ -31,7 +31,7 @@ .workspace { display: grid; - grid-template-columns: minmax(400px, 0.95fr) minmax(560px, 1.45fr); + grid-template-columns: minmax(540px, 1.35fr) minmax(420px, 0.95fr); gap: var(--space-4); align-items: start; } @@ -70,17 +70,24 @@ button:disabled { .list-toolbar { display: grid; - gap: var(--space-1); + grid-template-columns: minmax(230px, 1.6fr) minmax(170px, 1fr) minmax(190px, 1fr); + gap: var(--space-2); margin-bottom: var(--space-3); } -.list-toolbar label { +.toolbar-field { + display: grid; + gap: var(--space-1); +} + +.toolbar-field span { font-size: 0.78rem; font-weight: 600; color: var(--color-text-muted); } -.list-toolbar input { +.toolbar-field input, +.toolbar-field select { width: 100%; border: 1px solid var(--color-border); border-radius: var(--radius-md); @@ -383,6 +390,10 @@ h4 { } @media (max-width: 820px) { + .list-toolbar { + grid-template-columns: 1fr; + } + .dashboard-header { flex-direction: column; } 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 2f32b4f..7483ddd 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts @@ -19,6 +19,8 @@ export class AdminDashboardComponent implements OnInit { selectedStatus = ''; selectedPaymentMethod = 'OTHER'; orderSearchTerm = ''; + paymentStatusFilter = 'ALL'; + orderStatusFilter = 'ALL'; showPrintDetails = false; loading = false; detailLoading = false; @@ -34,6 +36,16 @@ export class AdminDashboardComponent implements OnInit { 'CANCELLED' ]; readonly paymentMethodOptions = ['TWINT', 'BANK_TRANSFER', 'CARD', 'CASH', 'OTHER']; + readonly paymentStatusFilterOptions = ['ALL', 'PENDING', 'REPORTED', 'COMPLETED']; + readonly orderStatusFilterOptions = [ + 'ALL', + 'PENDING_PAYMENT', + 'PAID', + 'IN_PRODUCTION', + 'SHIPPED', + 'COMPLETED', + 'CANCELLED' + ]; ngOnInit(): void { this.loadOrders(); @@ -72,17 +84,17 @@ export class AdminDashboardComponent implements OnInit { onSearchChange(value: string): void { this.orderSearchTerm = value; - this.refreshFilteredOrders(); + this.applyListFiltersAndSelection(); + } - if (this.filteredOrders.length === 0) { - this.selectedOrder = null; - this.selectedStatus = ''; - return; - } + onPaymentStatusFilterChange(value: string): void { + this.paymentStatusFilter = value || 'ALL'; + this.applyListFiltersAndSelection(); + } - if (!this.selectedOrder || !this.filteredOrders.some(order => order.id === this.selectedOrder?.id)) { - this.openDetails(this.filteredOrders[0].id); - } + onOrderStatusFilterChange(value: string): void { + this.orderStatusFilter = value || 'ALL'; + this.applyListFiltersAndSelection(); } openDetails(orderId: string): void { @@ -225,23 +237,39 @@ export class AdminDashboardComponent implements OnInit { private applyOrderUpdate(updatedOrder: AdminOrder): void { this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order); - this.refreshFilteredOrders(); + this.applyListFiltersAndSelection(); this.selectedOrder = updatedOrder; this.selectedStatus = updatedOrder.status; this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod; } - private refreshFilteredOrders(): void { - const term = this.orderSearchTerm.trim().toLowerCase(); - if (!term) { - this.filteredOrders = [...this.orders]; + private applyListFiltersAndSelection(): void { + this.refreshFilteredOrders(); + + if (this.filteredOrders.length === 0) { + this.selectedOrder = null; + this.selectedStatus = ''; return; } + if (!this.selectedOrder || !this.filteredOrders.some(order => order.id === this.selectedOrder?.id)) { + this.openDetails(this.filteredOrders[0].id); + } + } + + private refreshFilteredOrders(): void { + const term = this.orderSearchTerm.trim().toLowerCase(); this.filteredOrders = this.orders.filter((order) => { const fullUuid = order.id.toLowerCase(); const shortUuid = (order.orderNumber || '').toLowerCase(); - return fullUuid.includes(term) || shortUuid.includes(term); + const paymentStatus = (order.paymentStatus || 'PENDING').toUpperCase(); + const orderStatus = (order.status || '').toUpperCase(); + + const matchesSearch = !term || fullUuid.includes(term) || shortUuid.includes(term); + const matchesPayment = this.paymentStatusFilter === 'ALL' || paymentStatus === this.paymentStatusFilter; + const matchesOrderStatus = this.orderStatusFilter === 'ALL' || orderStatus === this.orderStatusFilter; + + return matchesSearch && matchesPayment && matchesOrderStatus; }); } diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.html b/frontend/src/app/features/admin/pages/admin-filament-stock.component.html index 3b2c72c..c1abfb7 100644 --- a/frontend/src/app/features/admin/pages/admin-filament-stock.component.html +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.html @@ -2,42 +2,229 @@

Stock filamenti

-

Monitoraggio quantità disponibili per variante.

+

Gestione materiali, varianti e stock per il calcolatore.

- +
-

{{ errorMessage }}

+
+

{{ errorMessage }}

+

{{ successMessage }}

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

Inserimento rapido

+
+
+

Nuovo materiale

+
+ + +
+ +
+ + +
+ + +
+ +
+

Nuova variante

+
+ + + + + + +
+ +
+ + + +
+ +

+ Stock stimato: {{ computeStockKg(newVariant.stockSpools, newVariant.spoolNetKg) | number:'1.0-3' }} kg +

+ + +
+
+
+ +
+
+

Materiali

+ +
+ +
+
+
+
+ + +
+ +
+ + +
+ + +
+
+

Nessun materiale configurato.

+
+
+ +
+

Varianti filamento

+
+
+
+ {{ variant.variantDisplayName }} + Stock basso + Stock ok +
+ +
+ + + + + + +
+ +
+ + + +
+ +

+ Totale stimato: {{ computeStockKg(variant.stockSpools, variant.spoolNetKg) | number:'1.0-3' }} kg +

+ + +
+
+

Nessuna variante configurata.

+
-

Caricamento stock...

+

Caricamento filamenti...

+
+ + +

Sezione collassata ({{ materials.length }} materiali).

diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss b/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss index 37b7847..43c1b29 100644 --- a/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss @@ -11,18 +11,168 @@ justify-content: space-between; align-items: flex-start; gap: var(--space-4); - margin-bottom: var(--space-5); + margin-bottom: var(--space-4); } -h2 { +.section-header h2 { margin: 0; } -p { +.section-header p { margin: var(--space-2) 0 0; color: var(--color-text-muted); } +.alerts { + display: grid; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.content { + display: grid; + gap: var(--space-4); +} + +.panel { + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-4); + background: var(--color-bg-card); +} + +.panel > h3 { + margin: 0 0 var(--space-3); +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.panel-header h3 { + margin: 0; +} + +.create-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.subpanel { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--color-neutral-100); +} + +.subpanel h4 { + margin: 0 0 var(--space-3); +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2) var(--space-3); + margin-bottom: var(--space-3); +} + +.form-field { + display: grid; + gap: var(--space-1); +} + +.form-field--wide { + grid-column: 1 / -1; +} + +.form-field > span { + font-size: 0.8rem; + color: var(--color-text-muted); + font-weight: 600; +} + +input, +select { + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + background: var(--color-bg-card); + font: inherit; + color: var(--color-text); +} + +input:disabled, +select:disabled { + opacity: 0.65; +} + +.toggle-group { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 0.35rem 0.65rem; + background: var(--color-bg-card); +} + +.toggle input { + width: 16px; + height: 16px; + margin: 0; +} + +.toggle span { + font-size: 0.88rem; + font-weight: 600; +} + +.material-grid, +.variant-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: var(--space-3); +} + +.material-card, +.variant-card { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-neutral-100); + padding: var(--space-3); +} + +.variant-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.variant-header strong { + font-size: 1rem; +} + +.variant-meta { + margin: 0 0 var(--space-3); + font-size: 0.9rem; + color: var(--color-text-muted); +} + button { border: 0; border-radius: var(--radius-md); @@ -38,27 +188,26 @@ button:hover:not(:disabled) { background: var(--color-brand-hover); } -.table-wrap { - overflow: auto; +button:disabled { + opacity: 0.65; + cursor: default; } -table { - width: 100%; - border-collapse: collapse; +.panel-toggle { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + color: var(--color-text); } -th, -td { - text-align: left; - padding: var(--space-3); - border-bottom: 1px solid var(--color-border); +.panel-toggle:hover:not(:disabled) { + background: var(--color-neutral-100); } .badge { display: inline-block; border-radius: 999px; padding: 0.15rem 0.5rem; - font-size: 0.78rem; + font-size: 0.75rem; font-weight: 700; } @@ -74,4 +223,27 @@ td { .error { color: var(--color-danger-500); + margin: 0; +} + +.success { + color: #157347; + margin: 0; +} + +.muted { + margin: 0; + color: var(--color-text-muted); +} + +@media (max-width: 1080px) { + .create-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .form-grid { + grid-template-columns: 1fr; + } } diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts b/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts index 73d8c80..8d70638 100644 --- a/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts @@ -1,41 +1,277 @@ import { CommonModule } from '@angular/common'; import { Component, inject, OnInit } from '@angular/core'; -import { AdminFilamentStockRow, AdminOperationsService } from '../services/admin-operations.service'; +import { FormsModule } from '@angular/forms'; +import { + AdminFilamentMaterialType, + AdminFilamentVariant, + AdminOperationsService, + AdminUpsertFilamentMaterialTypePayload, + AdminUpsertFilamentVariantPayload +} from '../services/admin-operations.service'; +import { forkJoin } from 'rxjs'; @Component({ selector: 'app-admin-filament-stock', standalone: true, - imports: [CommonModule], + imports: [CommonModule, FormsModule], templateUrl: './admin-filament-stock.component.html', styleUrl: './admin-filament-stock.component.scss' }) export class AdminFilamentStockComponent implements OnInit { private readonly adminOperationsService = inject(AdminOperationsService); - rows: AdminFilamentStockRow[] = []; + materials: AdminFilamentMaterialType[] = []; + variants: AdminFilamentVariant[] = []; loading = false; + materialsCollapsed = true; + creatingMaterial = false; + creatingVariant = false; + savingMaterialIds = new Set(); + savingVariantIds = new Set(); errorMessage: string | null = null; + successMessage: string | null = null; + + newMaterial: AdminUpsertFilamentMaterialTypePayload = { + materialCode: '', + isFlexible: false, + isTechnical: false, + technicalTypeLabel: '' + }; + + newVariant: AdminUpsertFilamentVariantPayload = { + materialTypeId: 0, + variantDisplayName: '', + colorName: '', + isMatte: false, + isSpecial: false, + costChfPerKg: 0, + stockSpools: 0, + spoolNetKg: 1, + isActive: true + }; ngOnInit(): void { - this.loadStock(); + this.loadData(); } - loadStock(): void { + loadData(): void { this.loading = true; this.errorMessage = null; - this.adminOperationsService.getFilamentStock().subscribe({ - next: (rows) => { - this.rows = rows; + this.successMessage = null; + + forkJoin({ + materials: this.adminOperationsService.getFilamentMaterials(), + variants: this.adminOperationsService.getFilamentVariants() + }).subscribe({ + next: ({ materials, variants }) => { + this.materials = this.sortMaterials(materials); + this.variants = this.sortVariants(variants); + if (!this.newVariant.materialTypeId && this.materials.length > 0) { + this.newVariant.materialTypeId = this.materials[0].id; + } this.loading = false; }, - error: () => { + error: (err) => { this.loading = false; - this.errorMessage = 'Impossibile caricare lo stock filamenti.'; + this.errorMessage = this.extractErrorMessage(err, 'Impossibile caricare i filamenti.'); } }); } - isLowStock(row: AdminFilamentStockRow): boolean { - return Number(row.stockKg) < 1; + createMaterial(): void { + if (this.creatingMaterial) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.creatingMaterial = true; + + const payload: AdminUpsertFilamentMaterialTypePayload = { + materialCode: (this.newMaterial.materialCode || '').trim(), + isFlexible: !!this.newMaterial.isFlexible, + isTechnical: !!this.newMaterial.isTechnical, + technicalTypeLabel: this.newMaterial.isTechnical + ? (this.newMaterial.technicalTypeLabel || '').trim() + : '' + }; + + this.adminOperationsService.createFilamentMaterial(payload).subscribe({ + next: (created) => { + this.materials = this.sortMaterials([...this.materials, created]); + if (!this.newVariant.materialTypeId) { + this.newVariant.materialTypeId = created.id; + } + this.newMaterial = { + materialCode: '', + isFlexible: false, + isTechnical: false, + technicalTypeLabel: '' + }; + this.creatingMaterial = false; + this.successMessage = 'Materiale aggiunto.'; + }, + error: (err) => { + this.creatingMaterial = false; + this.errorMessage = this.extractErrorMessage(err, 'Creazione materiale non riuscita.'); + } + }); + } + + saveMaterial(material: AdminFilamentMaterialType): void { + if (this.savingMaterialIds.has(material.id)) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.savingMaterialIds.add(material.id); + + const payload: AdminUpsertFilamentMaterialTypePayload = { + materialCode: (material.materialCode || '').trim(), + isFlexible: !!material.isFlexible, + isTechnical: !!material.isTechnical, + technicalTypeLabel: material.isTechnical ? (material.technicalTypeLabel || '').trim() : '' + }; + + this.adminOperationsService.updateFilamentMaterial(material.id, payload).subscribe({ + next: (updated) => { + this.materials = this.sortMaterials( + this.materials.map((m) => (m.id === updated.id ? updated : m)) + ); + this.variants = this.variants.map((variant) => { + if (variant.materialTypeId !== updated.id) { + return variant; + } + return { + ...variant, + materialCode: updated.materialCode, + materialIsFlexible: updated.isFlexible, + materialIsTechnical: updated.isTechnical, + materialTechnicalTypeLabel: updated.technicalTypeLabel + }; + }); + this.savingMaterialIds.delete(material.id); + this.successMessage = 'Materiale aggiornato.'; + }, + error: (err) => { + this.savingMaterialIds.delete(material.id); + this.errorMessage = this.extractErrorMessage(err, 'Aggiornamento materiale non riuscito.'); + } + }); + } + + createVariant(): void { + if (this.creatingVariant) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.creatingVariant = true; + + const payload = this.toVariantPayload(this.newVariant); + this.adminOperationsService.createFilamentVariant(payload).subscribe({ + next: (created) => { + this.variants = this.sortVariants([...this.variants, created]); + this.newVariant = { + materialTypeId: this.newVariant.materialTypeId || this.materials[0]?.id || 0, + variantDisplayName: '', + colorName: '', + isMatte: false, + isSpecial: false, + costChfPerKg: 0, + stockSpools: 0, + spoolNetKg: 1, + isActive: true + }; + this.creatingVariant = false; + this.successMessage = 'Variante aggiunta.'; + }, + error: (err) => { + this.creatingVariant = false; + this.errorMessage = this.extractErrorMessage(err, 'Creazione variante non riuscita.'); + } + }); + } + + saveVariant(variant: AdminFilamentVariant): void { + if (this.savingVariantIds.has(variant.id)) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.savingVariantIds.add(variant.id); + + const payload = this.toVariantPayload(variant); + this.adminOperationsService.updateFilamentVariant(variant.id, payload).subscribe({ + next: (updated) => { + this.variants = this.sortVariants( + this.variants.map((v) => (v.id === updated.id ? updated : v)) + ); + this.savingVariantIds.delete(variant.id); + this.successMessage = 'Variante aggiornata.'; + }, + error: (err) => { + this.savingVariantIds.delete(variant.id); + this.errorMessage = this.extractErrorMessage(err, 'Aggiornamento variante non riuscito.'); + } + }); + } + + isLowStock(variant: AdminFilamentVariant): boolean { + return this.computeStockKg(variant.stockSpools, variant.spoolNetKg) < 1; + } + + computeStockKg(stockSpools?: number, spoolNetKg?: number): number { + const spools = Number(stockSpools ?? 0); + const netKg = Number(spoolNetKg ?? 0); + + if (!Number.isFinite(spools) || !Number.isFinite(netKg) || spools < 0 || netKg < 0) { + return 0; + } + return spools * netKg; + } + + trackById(index: number, item: { id: number }): number { + return item.id; + } + + toggleMaterialsCollapsed(): void { + this.materialsCollapsed = !this.materialsCollapsed; + } + + private toVariantPayload(source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant): AdminUpsertFilamentVariantPayload { + return { + materialTypeId: Number(source.materialTypeId), + variantDisplayName: (source.variantDisplayName || '').trim(), + colorName: (source.colorName || '').trim(), + isMatte: !!source.isMatte, + isSpecial: !!source.isSpecial, + costChfPerKg: Number(source.costChfPerKg ?? 0), + stockSpools: Number(source.stockSpools ?? 0), + spoolNetKg: Number(source.spoolNetKg ?? 0), + isActive: source.isActive !== false + }; + } + + private sortMaterials(materials: AdminFilamentMaterialType[]): AdminFilamentMaterialType[] { + return [...materials].sort((a, b) => a.materialCode.localeCompare(b.materialCode)); + } + + private sortVariants(variants: AdminFilamentVariant[]): AdminFilamentVariant[] { + return [...variants].sort((a, b) => { + const byMaterial = (a.materialCode || '').localeCompare(b.materialCode || ''); + if (byMaterial !== 0) { + return byMaterial; + } + return (a.variantDisplayName || '').localeCompare(b.variantDisplayName || ''); + }); + } + + private extractErrorMessage(error: unknown, fallback: string): string { + const err = error as { error?: { message?: string } }; + return err?.error?.message || fallback; } } diff --git a/frontend/src/app/features/admin/pages/admin-orders-past.component.html b/frontend/src/app/features/admin/pages/admin-orders-past.component.html deleted file mode 100644 index ff9c777..0000000 --- a/frontend/src/app/features/admin/pages/admin-orders-past.component.html +++ /dev/null @@ -1,39 +0,0 @@ -
-
-
-

Ordini pagati

-
- -
- -

{{ errorMessage }}

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

Caricamento ordini passati...

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

Sessioni quote

Sessioni create dal configuratore con stato e conversione ordine.

- +

{{ errorMessage }}

+

{{ successMessage }}

@@ -19,17 +20,75 @@ + - - - - - - - - + + + + + + + + + + + + + +
Materiale Stato Ordine convertitoAzioni
{{ session.id }}{{ session.createdAt | date:'short' }}{{ session.expiresAt | date:'short' }}{{ session.materialCode }}{{ session.status }}{{ session.convertedOrderId || '-' }}
{{ session.id | slice:0:8 }}{{ session.createdAt | date:'short' }}{{ session.expiresAt | date:'short' }}{{ session.materialCode }}{{ session.status }}{{ session.convertedOrderId || '-' }} + + +
+
Caricamento dettaglio...
+
+
+
Elementi: {{ detail.items.length }}
+
Totale articoli: {{ detail.itemsTotalChf | currency:'CHF' }}
+
Spedizione: {{ detail.shippingCostChf | currency:'CHF' }}
+
Totale sessione: {{ detail.grandTotalChf | currency:'CHF' }}
+
+ + + + + + + + + + + + + + + + + + + + + + +
FileQtaTempoMaterialeStatoPrezzo unit.
{{ item.originalFilename }}{{ item.quantity }}{{ formatPrintTime(item.printTimeSeconds) }}{{ item.materialGrams ? (item.materialGrams | number:'1.0-2') + ' g' : '-' }}{{ item.status }}{{ item.unitPriceChf | currency:'CHF' }}
+ +

Nessun elemento in questa sessione.

+
+
+
diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.scss b/frontend/src/app/features/admin/pages/admin-sessions.component.scss index a293e62..4dee770 100644 --- a/frontend/src/app/features/admin/pages/admin-sessions.component.scss +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.scss @@ -26,18 +26,40 @@ p { button { border: 0; border-radius: var(--radius-md); - background: var(--color-brand); - color: var(--color-neutral-900); padding: var(--space-2) var(--space-4); font-weight: 600; cursor: pointer; transition: background-color 0.2s ease; } -button:hover:not(:disabled) { +.btn-primary { + background: var(--color-brand); + color: var(--color-neutral-900); +} + +.btn-primary:hover:not(:disabled) { background: var(--color-brand-hover); } +.btn-danger { + background: var(--color-danger-500); + color: #fff; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + +.btn-secondary { + background: transparent; + color: var(--color-text); + border: 1px solid var(--color-border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-neutral-100); +} + .table-wrap { overflow: auto; } @@ -57,3 +79,48 @@ td { .error { color: var(--color-danger-500); } + +.success { + color: var(--color-success-500); +} + +.actions { + display: flex; + gap: var(--space-2); + white-space: nowrap; +} + +.detail-cell { + background: var(--color-neutral-100); + padding: var(--space-4); +} + +.detail-box { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + padding: var(--space-4); +} + +.detail-summary { + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + margin-bottom: var(--space-3); +} + +.detail-table { + width: 100%; + border-collapse: collapse; +} + +.detail-table th, +.detail-table td { + text-align: left; + padding: var(--space-2); + border-bottom: 1px solid var(--color-border); +} + +.muted { + color: var(--color-text-muted); +} diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.ts b/frontend/src/app/features/admin/pages/admin-sessions.component.ts index c47b761..236f741 100644 --- a/frontend/src/app/features/admin/pages/admin-sessions.component.ts +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.ts @@ -1,6 +1,10 @@ import { CommonModule } from '@angular/common'; import { Component, inject, OnInit } from '@angular/core'; -import { AdminOperationsService, AdminQuoteSession } from '../services/admin-operations.service'; +import { + AdminOperationsService, + AdminQuoteSession, + AdminQuoteSessionDetail +} from '../services/admin-operations.service'; @Component({ selector: 'app-admin-sessions', @@ -13,8 +17,13 @@ export class AdminSessionsComponent implements OnInit { private readonly adminOperationsService = inject(AdminOperationsService); sessions: AdminQuoteSession[] = []; + sessionDetailsById: Record = {}; loading = false; + deletingSessionIds = new Set(); + loadingDetailSessionIds = new Set(); + expandedSessionId: string | null = null; errorMessage: string | null = null; + successMessage: string | null = null; ngOnInit(): void { this.loadSessions(); @@ -23,6 +32,7 @@ export class AdminSessionsComponent implements OnInit { loadSessions(): void { this.loading = true; this.errorMessage = null; + this.successMessage = null; this.adminOperationsService.getSessions().subscribe({ next: (sessions) => { this.sessions = sessions; @@ -34,4 +44,90 @@ export class AdminSessionsComponent implements OnInit { } }); } + + deleteSession(session: AdminQuoteSession): void { + if (this.deletingSessionIds.has(session.id)) { + return; + } + + const confirmed = window.confirm( + `Vuoi eliminare la sessione ${session.id}? Questa azione non si puo annullare.` + ); + if (!confirmed) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.deletingSessionIds.add(session.id); + + this.adminOperationsService.deleteSession(session.id).subscribe({ + next: () => { + this.sessions = this.sessions.filter((item) => item.id !== session.id); + this.deletingSessionIds.delete(session.id); + this.successMessage = 'Sessione eliminata.'; + }, + error: (err) => { + this.deletingSessionIds.delete(session.id); + this.errorMessage = this.extractErrorMessage(err, 'Impossibile eliminare la sessione.'); + } + }); + } + + isDeletingSession(sessionId: string): boolean { + return this.deletingSessionIds.has(sessionId); + } + + toggleSessionDetail(session: AdminQuoteSession): void { + if (this.expandedSessionId === session.id) { + this.expandedSessionId = null; + return; + } + + this.expandedSessionId = session.id; + if (this.sessionDetailsById[session.id] || this.loadingDetailSessionIds.has(session.id)) { + return; + } + + this.loadingDetailSessionIds.add(session.id); + this.adminOperationsService.getSessionDetail(session.id).subscribe({ + next: (detail) => { + this.sessionDetailsById = { + ...this.sessionDetailsById, + [session.id]: detail + }; + this.loadingDetailSessionIds.delete(session.id); + }, + error: (err) => { + this.loadingDetailSessionIds.delete(session.id); + this.errorMessage = this.extractErrorMessage(err, 'Impossibile caricare il dettaglio sessione.'); + } + }); + } + + isDetailOpen(sessionId: string): boolean { + return this.expandedSessionId === sessionId; + } + + isLoadingDetail(sessionId: string): boolean { + return this.loadingDetailSessionIds.has(sessionId); + } + + getSessionDetail(sessionId: string): AdminQuoteSessionDetail | undefined { + return this.sessionDetailsById[sessionId]; + } + + formatPrintTime(seconds?: number): string { + if (!seconds || seconds <= 0) { + return '-'; + } + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + } + + private extractErrorMessage(error: unknown, fallback: string): string { + const err = error as { error?: { message?: string } }; + return err?.error?.message || fallback; + } } diff --git a/frontend/src/app/features/admin/pages/admin-shell.component.html b/frontend/src/app/features/admin/pages/admin-shell.component.html index 7f3d0e2..583d1d8 100644 --- a/frontend/src/app/features/admin/pages/admin-shell.component.html +++ b/frontend/src/app/features/admin/pages/admin-shell.component.html @@ -8,7 +8,6 @@