produzione 1 #9

Merged
JoeKung merged 135 commits from dev into main 2026-03-03 09:58:04 +01:00
30 changed files with 2616 additions and 272 deletions
Showing only changes of commit ed76b13e4c - Show all commits

View File

@@ -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<List<AdminFilamentMaterialTypeDto>> getMaterials() {
List<AdminFilamentMaterialTypeDto> 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<List<AdminFilamentVariantDto>> getVariants() {
List<AdminFilamentVariantDto> 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<AdminFilamentMaterialTypeDto> 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<AdminFilamentMaterialTypeDto> 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<AdminFilamentVariantDto> 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<AdminFilamentVariantDto> 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;
}
}

View File

@@ -1,49 +1,86 @@
package com.printcalculator.controller.admin; package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminContactRequestDto; import com.printcalculator.dto.AdminContactRequestDto;
import com.printcalculator.dto.AdminContactRequestAttachmentDto;
import com.printcalculator.dto.AdminContactRequestDetailDto;
import com.printcalculator.dto.AdminFilamentStockDto; import com.printcalculator.dto.AdminFilamentStockDto;
import com.printcalculator.dto.AdminQuoteSessionDto; import com.printcalculator.dto.AdminQuoteSessionDto;
import com.printcalculator.entity.CustomQuoteRequest; import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.CustomQuoteRequestAttachment;
import com.printcalculator.entity.FilamentVariant; import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.FilamentVariantStockKg; import com.printcalculator.entity.FilamentVariantStockKg;
import com.printcalculator.entity.QuoteSession; import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository; import com.printcalculator.repository.CustomQuoteRequestRepository;
import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.FilamentVariantStockKgRepository; import com.printcalculator.repository.FilamentVariantStockKgRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.QuoteSessionRepository; 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.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.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; 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.Collections;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors; 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 @RestController
@RequestMapping("/api/admin") @RequestMapping("/api/admin")
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class AdminOperationsController { 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 FilamentVariantStockKgRepository filamentStockRepo;
private final FilamentVariantRepository filamentVariantRepo; private final FilamentVariantRepository filamentVariantRepo;
private final CustomQuoteRequestRepository customQuoteRequestRepo; private final CustomQuoteRequestRepository customQuoteRequestRepo;
private final CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo;
private final QuoteSessionRepository quoteSessionRepo; private final QuoteSessionRepository quoteSessionRepo;
private final OrderRepository orderRepo;
public AdminOperationsController( public AdminOperationsController(
FilamentVariantStockKgRepository filamentStockRepo, FilamentVariantStockKgRepository filamentStockRepo,
FilamentVariantRepository filamentVariantRepo, FilamentVariantRepository filamentVariantRepo,
CustomQuoteRequestRepository customQuoteRequestRepo, CustomQuoteRequestRepository customQuoteRequestRepo,
QuoteSessionRepository quoteSessionRepo CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo,
QuoteSessionRepository quoteSessionRepo,
OrderRepository orderRepo
) { ) {
this.filamentStockRepo = filamentStockRepo; this.filamentStockRepo = filamentStockRepo;
this.filamentVariantRepo = filamentVariantRepo; this.filamentVariantRepo = filamentVariantRepo;
this.customQuoteRequestRepo = customQuoteRequestRepo; this.customQuoteRequestRepo = customQuoteRequestRepo;
this.customQuoteRequestAttachmentRepo = customQuoteRequestAttachmentRepo;
this.quoteSessionRepo = quoteSessionRepo; this.quoteSessionRepo = quoteSessionRepo;
this.orderRepo = orderRepo;
} }
@GetMapping("/filament-stock") @GetMapping("/filament-stock")
@@ -103,6 +140,101 @@ public class AdminOperationsController {
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@GetMapping("/contact-requests/{requestId}")
public ResponseEntity<AdminContactRequestDetailDto> getContactRequestDetail(@PathVariable UUID requestId) {
CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found"));
List<AdminContactRequestAttachmentDto> 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<Resource> 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") @GetMapping("/sessions")
public ResponseEntity<List<AdminQuoteSessionDto>> getQuoteSessions() { public ResponseEntity<List<AdminQuoteSessionDto>> getQuoteSessions() {
List<AdminQuoteSessionDto> response = quoteSessionRepo.findAll( List<AdminQuoteSessionDto> response = quoteSessionRepo.findAll(
@@ -115,6 +247,21 @@ public class AdminOperationsController {
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@DeleteMapping("/sessions/{sessionId}")
@Transactional
public ResponseEntity<Void> 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) { private AdminContactRequestDto toContactRequestDto(CustomQuoteRequest request) {
AdminContactRequestDto dto = new AdminContactRequestDto(); AdminContactRequestDto dto = new AdminContactRequestDto();
dto.setId(request.getId()); dto.setId(request.getId());
@@ -129,6 +276,16 @@ public class AdminOperationsController {
return dto; 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) { private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) {
AdminQuoteSessionDto dto = new AdminQuoteSessionDto(); AdminQuoteSessionDto dto = new AdminQuoteSessionDto();
dto.setId(session.getId()); dto.setId(session.getId());
@@ -139,4 +296,24 @@ public class AdminOperationsController {
dto.setConvertedOrderId(session.getConvertedOrderId()); dto.setConvertedOrderId(session.getConvertedOrderId());
return dto; return dto;
} }
private void deleteSessionFiles(UUID sessionId) {
Path sessionDir = Paths.get("storage_quotes", sessionId.toString());
if (!Files.exists(sessionDir)) {
return;
}
try (Stream<Path> 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");
}
}
} }

View File

@@ -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;
}
}

View File

@@ -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<AdminContactRequestAttachmentDto> 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<AdminContactRequestAttachmentDto> getAttachments() {
return attachments;
}
public void setAttachments(List<AdminContactRequestAttachmentDto> attachments) {
this.attachments = attachments;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -3,7 +3,9 @@ package com.printcalculator.repository;
import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.entity.CustomQuoteRequestAttachment;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID; import java.util.UUID;
public interface CustomQuoteRequestAttachmentRepository extends JpaRepository<CustomQuoteRequestAttachment, UUID> { public interface CustomQuoteRequestAttachmentRepository extends JpaRepository<CustomQuoteRequestAttachment, UUID> {
List<CustomQuoteRequestAttachment> findByRequest_IdOrderByCreatedAtAsc(UUID requestId);
} }

View File

@@ -9,5 +9,6 @@ import java.util.Optional;
public interface FilamentVariantRepository extends JpaRepository<FilamentVariant, Long> { public interface FilamentVariantRepository extends JpaRepository<FilamentVariant, Long> {
// We try to match by color name if possible, or get first active // We try to match by color name if possible, or get first active
Optional<FilamentVariant> findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName); Optional<FilamentVariant> findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName);
Optional<FilamentVariant> findByFilamentMaterialTypeAndVariantDisplayName(FilamentMaterialType type, String variantDisplayName);
Optional<FilamentVariant> findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type); Optional<FilamentVariant> findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type);
} }

View File

@@ -8,4 +8,6 @@ import java.util.UUID;
public interface OrderRepository extends JpaRepository<Order, UUID> { public interface OrderRepository extends JpaRepository<Order, UUID> {
List<Order> findAllByOrderByCreatedAtDesc(); List<Order> findAllByOrderByCreatedAtDesc();
boolean existsBySourceQuoteSession_Id(UUID sourceQuoteSessionId);
} }

1
db.sql
View File

@@ -59,7 +59,6 @@ create table filament_variant
unique (filament_material_type_id, variant_display_name) unique (filament_material_type_id, variant_display_name)
); );
-- (opzionale) kg disponibili calcolati
create view filament_variant_stock_kg as create view filament_variant_stock_kg as
select filament_variant_id, select filament_variant_id,
stock_spools, stock_spools,

View File

@@ -20,10 +20,6 @@ export const ADMIN_ROUTES: Routes = [
path: 'orders', path: 'orders',
loadComponent: () => import('./pages/admin-dashboard.component').then(m => m.AdminDashboardComponent) 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', path: 'filament-stock',
loadComponent: () => import('./pages/admin-filament-stock.component').then(m => m.AdminFilamentStockComponent) loadComponent: () => import('./pages/admin-filament-stock.component').then(m => m.AdminFilamentStockComponent)

View File

@@ -1,16 +1,20 @@
<section class="section-card"> <section class="section-card">
<header class="section-header"> <header class="section-header">
<div> <div class="header-copy">
<h2>Richieste di contatto</h2> <h2>Richieste di contatto</h2>
<p>Richieste preventivo personalizzato ricevute dal sito.</p> <p>Richieste preventivo personalizzato ricevute dal sito.</p>
<span class="total-pill">{{ requests.length }} richieste</span>
</div> </div>
<button type="button" (click)="loadRequests()" [disabled]="loading">Aggiorna</button> <button type="button" (click)="loadRequests()" [disabled]="loading">Aggiorna</button>
</header> </header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p> <p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<div class="table-wrap" *ngIf="!loading; else loadingTpl"> <div class="workspace" *ngIf="!loading; else loadingTpl">
<table> <section class="list-panel">
<h3>Lista richieste</h3>
<div class="table-wrap">
<table class="requests-table">
<thead> <thead>
<tr> <tr>
<th>Data</th> <th>Data</th>
@@ -22,19 +26,94 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let request of requests"> <tr
<td>{{ request.createdAt | date:'short' }}</td> *ngFor="let request of requests"
<td>{{ request.name || request.companyName || '-' }}</td> [class.selected]="isSelected(request.id)"
<td>{{ request.email }}</td> (click)="openDetails(request.id)"
<td>{{ request.requestType }}</td> >
<td>{{ request.customerType }}</td> <td class="created-at">{{ request.createdAt | date:'short' }}</td>
<td>{{ request.status }}</td> <td class="name-cell">
<p class="primary">{{ request.name || request.companyName || '-' }}</p>
<p class="secondary" *ngIf="request.name && request.companyName">{{ request.companyName }}</p>
</td>
<td class="email-cell">{{ request.email }}</td>
<td>
<span class="chip chip-neutral">{{ request.requestType }}</span>
</td>
<td>
<span class="chip chip-light">{{ request.customerType }}</span>
</td>
<td>
<span class="chip" [ngClass]="getStatusChipClass(request.status)">{{ request.status }}</span>
</td>
</tr>
<tr class="empty-row" *ngIf="requests.length === 0">
<td colspan="6">Nessuna richiesta presente.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
<section class="detail-panel" *ngIf="selectedRequest">
<header class="detail-header">
<div>
<h3>Dettaglio richiesta</h3>
<p class="request-id"><span>ID</span><code>{{ selectedRequest.id }}</code></p>
</div>
<div class="detail-chips">
<span class="chip" [ngClass]="getStatusChipClass(selectedRequest.status)">{{ selectedRequest.status }}</span>
<span class="chip chip-neutral">{{ selectedRequest.requestType }}</span>
<span class="chip chip-light">{{ selectedRequest.customerType }}</span>
</div>
</header>
<p class="loading-detail" *ngIf="detailLoading">Caricamento dettaglio...</p>
<dl class="meta-grid">
<div class="meta-item"><dt>Creata</dt><dd>{{ selectedRequest.createdAt | date:'medium' }}</dd></div>
<div class="meta-item"><dt>Aggiornata</dt><dd>{{ selectedRequest.updatedAt | date:'medium' }}</dd></div>
<div class="meta-item"><dt>Email</dt><dd>{{ selectedRequest.email }}</dd></div>
<div class="meta-item"><dt>Telefono</dt><dd>{{ selectedRequest.phone || '-' }}</dd></div>
<div class="meta-item"><dt>Nome</dt><dd>{{ selectedRequest.name || '-' }}</dd></div>
<div class="meta-item"><dt>Azienda</dt><dd>{{ selectedRequest.companyName || '-' }}</dd></div>
<div class="meta-item"><dt>Referente</dt><dd>{{ selectedRequest.contactPerson || '-' }}</dd></div>
</dl>
<div class="message-box">
<h4>Messaggio</h4>
<p>{{ selectedRequest.message || '-' }}</p>
</div>
<div class="attachments">
<h4>Allegati</h4>
<div class="attachment-list" *ngIf="selectedRequest.attachments.length > 0; else noAttachmentsTpl">
<article class="attachment-item" *ngFor="let attachment of selectedRequest.attachments">
<div>
<p class="filename">{{ attachment.originalFilename }}</p>
<p class="meta">
{{ formatFileSize(attachment.fileSizeBytes) }}
<span *ngIf="attachment.mimeType"> | {{ attachment.mimeType }}</span>
<span *ngIf="attachment.createdAt"> | {{ attachment.createdAt | date:'short' }}</span>
</p>
</div>
<button type="button" class="ghost" (click)="downloadAttachment(attachment)">Scarica file</button>
</article>
</div>
</div>
</section>
<section class="detail-panel empty" *ngIf="!selectedRequest">
<h3>Nessuna richiesta selezionata</h3>
<p>Seleziona una riga dalla lista per vedere il dettaglio.</p>
</section>
</div>
</section>
<ng-template #loadingTpl> <ng-template #loadingTpl>
<p>Caricamento richieste...</p> <p>Caricamento richieste...</p>
</ng-template> </ng-template>
<ng-template #noAttachmentsTpl>
<p class="muted">Nessun allegato disponibile.</p>
</ng-template>

View File

@@ -11,18 +11,44 @@
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: var(--space-4); gap: var(--space-4);
margin-bottom: var(--space-5); margin-bottom: var(--space-4);
} }
h2 { .section-header h2 {
margin: 0; margin: 0;
font-size: 1.4rem;
} }
p { .section-header p {
margin: var(--space-2) 0 0; margin: var(--space-2) 0 0;
color: var(--color-text-muted); 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 { button {
border: 0; border: 0;
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -31,15 +57,31 @@ button {
padding: var(--space-2) var(--space-4); padding: var(--space-2) var(--space-4);
font-weight: 600; font-weight: 600;
cursor: pointer; 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) { button:hover:not(:disabled) {
background: var(--color-brand-hover); 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 { .table-wrap {
overflow: auto; overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-card);
max-height: 72vh;
} }
table { table {
@@ -47,13 +89,300 @@ table {
border-collapse: collapse; border-collapse: collapse;
} }
thead {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-neutral-100);
}
th, th,
td { td {
text-align: left; text-align: left;
padding: var(--space-3); padding: var(--space-3);
border-bottom: 1px solid var(--color-border); 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 { .error {
color: var(--color-danger-500); 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);
}
} }

View File

@@ -1,6 +1,11 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core'; 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({ @Component({
selector: 'app-admin-contact-requests', selector: 'app-admin-contact-requests',
@@ -13,7 +18,10 @@ export class AdminContactRequestsComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService); private readonly adminOperationsService = inject(AdminOperationsService);
requests: AdminContactRequest[] = []; requests: AdminContactRequest[] = [];
selectedRequest: AdminContactRequestDetail | null = null;
selectedRequestId: string | null = null;
loading = false; loading = false;
detailLoading = false;
errorMessage: string | null = null; errorMessage: string | null = null;
ngOnInit(): void { ngOnInit(): void {
@@ -26,6 +34,14 @@ export class AdminContactRequestsComponent implements OnInit {
this.adminOperationsService.getContactRequests().subscribe({ this.adminOperationsService.getContactRequests().subscribe({
next: (requests) => { next: (requests) => {
this.requests = 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; this.loading = false;
}, },
error: () => { 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);
}
} }

View File

@@ -15,7 +15,8 @@
<section class="list-panel"> <section class="list-panel">
<h2>Lista ordini</h2> <h2>Lista ordini</h2>
<div class="list-toolbar"> <div class="list-toolbar">
<label for="order-search">Cerca UUID</label> <label class="toolbar-field" for="order-search">
<span>Cerca UUID</span>
<input <input
id="order-search" id="order-search"
type="search" type="search"
@@ -23,6 +24,27 @@
(ngModelChange)="onSearchChange($event)" (ngModelChange)="onSearchChange($event)"
placeholder="UUID completo o prefisso (es. 738131d8)" placeholder="UUID completo o prefisso (es. 738131d8)"
/> />
</label>
<label class="toolbar-field" for="payment-status-filter">
<span>Stato pagamento</span>
<select
id="payment-status-filter"
[ngModel]="paymentStatusFilter"
(ngModelChange)="onPaymentStatusFilterChange($event)"
>
<option *ngFor="let option of paymentStatusFilterOptions" [ngValue]="option">{{ option }}</option>
</select>
</label>
<label class="toolbar-field" for="order-status-filter">
<span>Stato ordine</span>
<select
id="order-status-filter"
[ngModel]="orderStatusFilter"
(ngModelChange)="onOrderStatusFilterChange($event)"
>
<option *ngFor="let option of orderStatusFilterOptions" [ngValue]="option">{{ option }}</option>
</select>
</label>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@@ -31,6 +53,7 @@
<th>Ordine</th> <th>Ordine</th>
<th>Email</th> <th>Email</th>
<th>Pagamento</th> <th>Pagamento</th>
<th>Stato ordine</th>
<th>Totale</th> <th>Totale</th>
</tr> </tr>
</thead> </thead>
@@ -43,10 +66,11 @@
<td>{{ order.orderNumber }}</td> <td>{{ order.orderNumber }}</td>
<td>{{ order.customerEmail }}</td> <td>{{ order.customerEmail }}</td>
<td>{{ order.paymentStatus || 'PENDING' }}</td> <td>{{ order.paymentStatus || 'PENDING' }}</td>
<td>{{ order.status }}</td>
<td>{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}</td> <td>{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}</td>
</tr> </tr>
<tr class="no-results" *ngIf="filteredOrders.length === 0"> <tr class="no-results" *ngIf="filteredOrders.length === 0">
<td colspan="4">Nessun ordine trovato per il filtro inserito.</td> <td colspan="5">Nessun ordine trovato per i filtri selezionati.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -31,7 +31,7 @@
.workspace { .workspace {
display: grid; 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); gap: var(--space-4);
align-items: start; align-items: start;
} }
@@ -70,17 +70,24 @@ button:disabled {
.list-toolbar { .list-toolbar {
display: grid; 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); margin-bottom: var(--space-3);
} }
.list-toolbar label { .toolbar-field {
display: grid;
gap: var(--space-1);
}
.toolbar-field span {
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 600; font-weight: 600;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.list-toolbar input { .toolbar-field input,
.toolbar-field select {
width: 100%; width: 100%;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -383,6 +390,10 @@ h4 {
} }
@media (max-width: 820px) { @media (max-width: 820px) {
.list-toolbar {
grid-template-columns: 1fr;
}
.dashboard-header { .dashboard-header {
flex-direction: column; flex-direction: column;
} }

View File

@@ -19,6 +19,8 @@ export class AdminDashboardComponent implements OnInit {
selectedStatus = ''; selectedStatus = '';
selectedPaymentMethod = 'OTHER'; selectedPaymentMethod = 'OTHER';
orderSearchTerm = ''; orderSearchTerm = '';
paymentStatusFilter = 'ALL';
orderStatusFilter = 'ALL';
showPrintDetails = false; showPrintDetails = false;
loading = false; loading = false;
detailLoading = false; detailLoading = false;
@@ -34,6 +36,16 @@ export class AdminDashboardComponent implements OnInit {
'CANCELLED' 'CANCELLED'
]; ];
readonly paymentMethodOptions = ['TWINT', 'BANK_TRANSFER', 'CARD', 'CASH', 'OTHER']; 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 { ngOnInit(): void {
this.loadOrders(); this.loadOrders();
@@ -72,17 +84,17 @@ export class AdminDashboardComponent implements OnInit {
onSearchChange(value: string): void { onSearchChange(value: string): void {
this.orderSearchTerm = value; this.orderSearchTerm = value;
this.refreshFilteredOrders(); this.applyListFiltersAndSelection();
if (this.filteredOrders.length === 0) {
this.selectedOrder = null;
this.selectedStatus = '';
return;
} }
if (!this.selectedOrder || !this.filteredOrders.some(order => order.id === this.selectedOrder?.id)) { onPaymentStatusFilterChange(value: string): void {
this.openDetails(this.filteredOrders[0].id); this.paymentStatusFilter = value || 'ALL';
this.applyListFiltersAndSelection();
} }
onOrderStatusFilterChange(value: string): void {
this.orderStatusFilter = value || 'ALL';
this.applyListFiltersAndSelection();
} }
openDetails(orderId: string): void { openDetails(orderId: string): void {
@@ -225,23 +237,39 @@ export class AdminDashboardComponent implements OnInit {
private applyOrderUpdate(updatedOrder: AdminOrder): void { private applyOrderUpdate(updatedOrder: AdminOrder): void {
this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order); this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order);
this.refreshFilteredOrders(); this.applyListFiltersAndSelection();
this.selectedOrder = updatedOrder; this.selectedOrder = updatedOrder;
this.selectedStatus = updatedOrder.status; this.selectedStatus = updatedOrder.status;
this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod; this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod;
} }
private refreshFilteredOrders(): void { private applyListFiltersAndSelection(): void {
const term = this.orderSearchTerm.trim().toLowerCase(); this.refreshFilteredOrders();
if (!term) {
this.filteredOrders = [...this.orders]; if (this.filteredOrders.length === 0) {
this.selectedOrder = null;
this.selectedStatus = '';
return; 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) => { this.filteredOrders = this.orders.filter((order) => {
const fullUuid = order.id.toLowerCase(); const fullUuid = order.id.toLowerCase();
const shortUuid = (order.orderNumber || '').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;
}); });
} }

View File

@@ -2,42 +2,229 @@
<header class="section-header"> <header class="section-header">
<div> <div>
<h2>Stock filamenti</h2> <h2>Stock filamenti</h2>
<p>Monitoraggio quantità disponibili per variante.</p> <p>Gestione materiali, varianti e stock per il calcolatore.</p>
</div> </div>
<button type="button" (click)="loadStock()" [disabled]="loading">Aggiorna</button> <button type="button" (click)="loadData()" [disabled]="loading">Aggiorna</button>
</header> </header>
<div class="alerts">
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p> <p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<p class="success" *ngIf="successMessage">{{ successMessage }}</p>
</div>
<div class="table-wrap" *ngIf="!loading; else loadingTpl"> <div class="content" *ngIf="!loading; else loadingTpl">
<table> <section class="panel">
<thead> <h3>Inserimento rapido</h3>
<tr> <div class="create-grid">
<th>Materiale</th> <section class="subpanel">
<th>Variante</th> <h4>Nuovo materiale</h4>
<th>Colore</th> <div class="form-grid">
<th>Spool</th> <label class="form-field form-field--wide">
<th>Kg totali</th> <span>Codice materiale</span>
<th>Stato</th> <input type="text" [(ngModel)]="newMaterial.materialCode" placeholder="PLA, PETG, TPU..." />
</tr> </label>
</thead> <label class="form-field form-field--wide">
<tbody> <span>Etichetta tecnico</span>
<tr *ngFor="let row of rows"> <input
<td>{{ row.materialCode }}</td> type="text"
<td>{{ row.variantDisplayName }}</td> [(ngModel)]="newMaterial.technicalTypeLabel"
<td>{{ row.colorName }}</td> [disabled]="!newMaterial.isTechnical"
<td>{{ row.stockSpools | number:'1.0-3' }}</td> placeholder="alta temperatura, rinforzato..."
<td>{{ row.stockKg | number:'1.0-3' }} kg</td> />
<td> </label>
<span class="badge low" *ngIf="isLowStock(row)">Basso</span> </div>
<span class="badge ok" *ngIf="!isLowStock(row)">OK</span>
</td> <div class="toggle-group">
</tr> <label class="toggle">
</tbody> <input type="checkbox" [(ngModel)]="newMaterial.isFlexible" />
</table> <span>Flessibile</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="newMaterial.isTechnical" />
<span>Tecnico</span>
</label>
</div>
<button type="button" (click)="createMaterial()" [disabled]="creatingMaterial">
{{ creatingMaterial ? 'Salvataggio...' : 'Aggiungi materiale' }}
</button>
</section>
<section class="subpanel">
<h4>Nuova variante</h4>
<div class="form-grid">
<label class="form-field">
<span>Materiale</span>
<select [(ngModel)]="newVariant.materialTypeId">
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id">
{{ material.materialCode }}
</option>
</select>
</label>
<label class="form-field">
<span>Nome variante</span>
<input type="text" [(ngModel)]="newVariant.variantDisplayName" placeholder="PLA Nero Opaco BrandX" />
</label>
<label class="form-field">
<span>Colore</span>
<input type="text" [(ngModel)]="newVariant.colorName" placeholder="Nero, Bianco..." />
</label>
<label class="form-field">
<span>Costo CHF/kg</span>
<input type="number" step="0.01" min="0" [(ngModel)]="newVariant.costChfPerKg" />
</label>
<label class="form-field">
<span>Stock spool</span>
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="newVariant.stockSpools" />
</label>
<label class="form-field">
<span>Spool netto kg</span>
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="newVariant.spoolNetKg" />
</label>
</div>
<div class="toggle-group">
<label class="toggle">
<input type="checkbox" [(ngModel)]="newVariant.isMatte" />
<span>Matte</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="newVariant.isSpecial" />
<span>Special</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="newVariant.isActive" />
<span>Attiva</span>
</label>
</div>
<p class="variant-meta">
Stock stimato: <strong>{{ computeStockKg(newVariant.stockSpools, newVariant.spoolNetKg) | number:'1.0-3' }} kg</strong>
</p>
<button type="button" (click)="createVariant()" [disabled]="creatingVariant || !materials.length">
{{ creatingVariant ? 'Salvataggio...' : 'Aggiungi variante' }}
</button>
</section>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h3>Materiali</h3>
<button type="button" class="panel-toggle" (click)="toggleMaterialsCollapsed()">
{{ materialsCollapsed ? 'Espandi' : 'Collassa' }}
</button>
</div>
<div *ngIf="!materialsCollapsed; else materialsCollapsedTpl">
<div class="material-grid">
<article class="material-card" *ngFor="let material of materials; trackBy: trackById">
<div class="form-grid">
<label class="form-field form-field--wide">
<span>Codice</span>
<input type="text" [(ngModel)]="material.materialCode" />
</label>
<label class="form-field form-field--wide">
<span>Etichetta tecnico</span>
<input type="text" [(ngModel)]="material.technicalTypeLabel" [disabled]="!material.isTechnical" />
</label>
</div>
<div class="toggle-group">
<label class="toggle">
<input type="checkbox" [(ngModel)]="material.isFlexible" />
<span>Flessibile</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="material.isTechnical" />
<span>Tecnico</span>
</label>
</div>
<button type="button" (click)="saveMaterial(material)" [disabled]="savingMaterialIds.has(material.id)">
{{ savingMaterialIds.has(material.id) ? 'Salvataggio...' : 'Salva materiale' }}
</button>
</article>
</div>
<p class="muted" *ngIf="materials.length === 0">Nessun materiale configurato.</p>
</div>
</section>
<section class="panel">
<h3>Varianti filamento</h3>
<div class="variant-grid">
<article class="variant-card" *ngFor="let variant of variants; trackBy: trackById">
<div class="variant-header">
<strong>{{ variant.variantDisplayName }}</strong>
<span class="badge low" *ngIf="isLowStock(variant)">Stock basso</span>
<span class="badge ok" *ngIf="!isLowStock(variant)">Stock ok</span>
</div>
<div class="form-grid">
<label class="form-field">
<span>Materiale</span>
<select [(ngModel)]="variant.materialTypeId">
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id">
{{ material.materialCode }}
</option>
</select>
</label>
<label class="form-field">
<span>Nome variante</span>
<input type="text" [(ngModel)]="variant.variantDisplayName" />
</label>
<label class="form-field">
<span>Colore</span>
<input type="text" [(ngModel)]="variant.colorName" />
</label>
<label class="form-field">
<span>Costo CHF/kg</span>
<input type="number" step="0.01" min="0" [(ngModel)]="variant.costChfPerKg" />
</label>
<label class="form-field">
<span>Stock spool</span>
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="variant.stockSpools" />
</label>
<label class="form-field">
<span>Spool netto kg</span>
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="variant.spoolNetKg" />
</label>
</div>
<div class="toggle-group">
<label class="toggle">
<input type="checkbox" [(ngModel)]="variant.isMatte" />
<span>Matte</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="variant.isSpecial" />
<span>Special</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="variant.isActive" />
<span>Attiva</span>
</label>
</div>
<p class="variant-meta">
Totale stimato: <strong>{{ computeStockKg(variant.stockSpools, variant.spoolNetKg) | number:'1.0-3' }} kg</strong>
</p>
<button type="button" (click)="saveVariant(variant)" [disabled]="savingVariantIds.has(variant.id)">
{{ savingVariantIds.has(variant.id) ? 'Salvataggio...' : 'Salva variante' }}
</button>
</article>
</div>
<p class="muted" *ngIf="variants.length === 0">Nessuna variante configurata.</p>
</section>
</div> </div>
</section> </section>
<ng-template #loadingTpl> <ng-template #loadingTpl>
<p>Caricamento stock...</p> <p>Caricamento filamenti...</p>
</ng-template>
<ng-template #materialsCollapsedTpl>
<p class="muted">Sezione collassata ({{ materials.length }} materiali).</p>
</ng-template> </ng-template>

View File

@@ -11,18 +11,168 @@
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: var(--space-4); gap: var(--space-4);
margin-bottom: var(--space-5); margin-bottom: var(--space-4);
} }
h2 { .section-header h2 {
margin: 0; margin: 0;
} }
p { .section-header p {
margin: var(--space-2) 0 0; margin: var(--space-2) 0 0;
color: var(--color-text-muted); 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 { button {
border: 0; border: 0;
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -38,27 +188,26 @@ button:hover:not(:disabled) {
background: var(--color-brand-hover); background: var(--color-brand-hover);
} }
.table-wrap { button:disabled {
overflow: auto; opacity: 0.65;
cursor: default;
} }
table { .panel-toggle {
width: 100%; background: var(--color-bg-card);
border-collapse: collapse; border: 1px solid var(--color-border);
color: var(--color-text);
} }
th, .panel-toggle:hover:not(:disabled) {
td { background: var(--color-neutral-100);
text-align: left;
padding: var(--space-3);
border-bottom: 1px solid var(--color-border);
} }
.badge { .badge {
display: inline-block; display: inline-block;
border-radius: 999px; border-radius: 999px;
padding: 0.15rem 0.5rem; padding: 0.15rem 0.5rem;
font-size: 0.78rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
} }
@@ -74,4 +223,27 @@ td {
.error { .error {
color: var(--color-danger-500); 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;
}
} }

View File

@@ -1,41 +1,277 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core'; 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({ @Component({
selector: 'app-admin-filament-stock', selector: 'app-admin-filament-stock',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule, FormsModule],
templateUrl: './admin-filament-stock.component.html', templateUrl: './admin-filament-stock.component.html',
styleUrl: './admin-filament-stock.component.scss' styleUrl: './admin-filament-stock.component.scss'
}) })
export class AdminFilamentStockComponent implements OnInit { export class AdminFilamentStockComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService); private readonly adminOperationsService = inject(AdminOperationsService);
rows: AdminFilamentStockRow[] = []; materials: AdminFilamentMaterialType[] = [];
variants: AdminFilamentVariant[] = [];
loading = false; loading = false;
materialsCollapsed = true;
creatingMaterial = false;
creatingVariant = false;
savingMaterialIds = new Set<number>();
savingVariantIds = new Set<number>();
errorMessage: string | null = null; 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 { ngOnInit(): void {
this.loadStock(); this.loadData();
} }
loadStock(): void { loadData(): void {
this.loading = true; this.loading = true;
this.errorMessage = null; this.errorMessage = null;
this.adminOperationsService.getFilamentStock().subscribe({ this.successMessage = null;
next: (rows) => {
this.rows = rows; 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; this.loading = false;
}, },
error: () => { error: (err) => {
this.loading = false; this.loading = false;
this.errorMessage = 'Impossibile caricare lo stock filamenti.'; this.errorMessage = this.extractErrorMessage(err, 'Impossibile caricare i filamenti.');
} }
}); });
} }
isLowStock(row: AdminFilamentStockRow): boolean { createMaterial(): void {
return Number(row.stockKg) < 1; 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;
} }
} }

View File

@@ -1,39 +0,0 @@
<section class="section-card">
<header class="section-header">
<div>
<h2>Ordini pagati</h2>
</div>
<button type="button" (click)="loadOrders()" [disabled]="loading">Aggiorna</button>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
<table>
<thead>
<tr>
<th>Ordine</th>
<th>Data</th>
<th>Email</th>
<th>Stato ordine</th>
<th>Stato pagamento</th>
<th>Totale</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let order of orders">
<td>{{ order.orderNumber }}</td>
<td>{{ order.createdAt | date:'short' }}</td>
<td>{{ order.customerEmail }}</td>
<td>{{ order.status }}</td>
<td>{{ order.paymentStatus || 'PENDING' }}</td>
<td>{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<ng-template #loadingTpl>
<p>Caricamento ordini passati...</p>
</ng-template>

View File

@@ -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);
}

View File

@@ -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.';
}
});
}
}

View File

@@ -4,10 +4,11 @@
<h2>Sessioni quote</h2> <h2>Sessioni quote</h2>
<p>Sessioni create dal configuratore con stato e conversione ordine.</p> <p>Sessioni create dal configuratore con stato e conversione ordine.</p>
</div> </div>
<button type="button" (click)="loadSessions()" [disabled]="loading">Aggiorna</button> <button type="button" class="btn-primary" (click)="loadSessions()" [disabled]="loading">Aggiorna</button>
</header> </header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p> <p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<p class="success" *ngIf="successMessage">{{ successMessage }}</p>
<div class="table-wrap" *ngIf="!loading; else loadingTpl"> <div class="table-wrap" *ngIf="!loading; else loadingTpl">
<table> <table>
@@ -19,17 +20,75 @@
<th>Materiale</th> <th>Materiale</th>
<th>Stato</th> <th>Stato</th>
<th>Ordine convertito</th> <th>Ordine convertito</th>
<th>Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let session of sessions"> <ng-container *ngFor="let session of sessions">
<td>{{ session.id }}</td> <tr>
<td [title]="session.id">{{ session.id | slice:0:8 }}</td>
<td>{{ session.createdAt | date:'short' }}</td> <td>{{ session.createdAt | date:'short' }}</td>
<td>{{ session.expiresAt | date:'short' }}</td> <td>{{ session.expiresAt | date:'short' }}</td>
<td>{{ session.materialCode }}</td> <td>{{ session.materialCode }}</td>
<td>{{ session.status }}</td> <td>{{ session.status }}</td>
<td>{{ session.convertedOrderId || '-' }}</td> <td>{{ session.convertedOrderId || '-' }}</td>
<td class="actions">
<button
type="button"
class="btn-secondary"
(click)="toggleSessionDetail(session)">
{{ isDetailOpen(session.id) ? 'Nascondi' : 'Vedi' }}
</button>
<button
type="button"
class="btn-danger"
(click)="deleteSession(session)"
[disabled]="isDeletingSession(session.id) || !!session.convertedOrderId"
[title]="session.convertedOrderId ? 'Sessione collegata a un ordine, non eliminabile.' : ''">
{{ isDeletingSession(session.id) ? 'Eliminazione...' : 'Elimina' }}
</button>
</td>
</tr> </tr>
<tr *ngIf="isDetailOpen(session.id)">
<td colspan="7" class="detail-cell">
<div *ngIf="isLoadingDetail(session.id)">Caricamento dettaglio...</div>
<div *ngIf="!isLoadingDetail(session.id) && getSessionDetail(session.id) as detail" class="detail-box">
<div class="detail-summary">
<div><strong>Elementi:</strong> {{ detail.items.length }}</div>
<div><strong>Totale articoli:</strong> {{ detail.itemsTotalChf | currency:'CHF' }}</div>
<div><strong>Spedizione:</strong> {{ detail.shippingCostChf | currency:'CHF' }}</div>
<div><strong>Totale sessione:</strong> {{ detail.grandTotalChf | currency:'CHF' }}</div>
</div>
<table class="detail-table" *ngIf="detail.items.length > 0; else noItemsTpl">
<thead>
<tr>
<th>File</th>
<th>Qta</th>
<th>Tempo</th>
<th>Materiale</th>
<th>Stato</th>
<th>Prezzo unit.</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of detail.items">
<td>{{ item.originalFilename }}</td>
<td>{{ item.quantity }}</td>
<td>{{ formatPrintTime(item.printTimeSeconds) }}</td>
<td>{{ item.materialGrams ? (item.materialGrams | number:'1.0-2') + ' g' : '-' }}</td>
<td>{{ item.status }}</td>
<td>{{ item.unitPriceChf | currency:'CHF' }}</td>
</tr>
</tbody>
</table>
<ng-template #noItemsTpl>
<p class="muted">Nessun elemento in questa sessione.</p>
</ng-template>
</div>
</td>
</tr>
</ng-container>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -26,18 +26,40 @@ p {
button { button {
border: 0; border: 0;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-neutral-900);
padding: var(--space-2) var(--space-4); padding: var(--space-2) var(--space-4);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease; 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); 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 { .table-wrap {
overflow: auto; overflow: auto;
} }
@@ -57,3 +79,48 @@ td {
.error { .error {
color: var(--color-danger-500); 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);
}

View File

@@ -1,6 +1,10 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core'; 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({ @Component({
selector: 'app-admin-sessions', selector: 'app-admin-sessions',
@@ -13,8 +17,13 @@ export class AdminSessionsComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService); private readonly adminOperationsService = inject(AdminOperationsService);
sessions: AdminQuoteSession[] = []; sessions: AdminQuoteSession[] = [];
sessionDetailsById: Record<string, AdminQuoteSessionDetail | undefined> = {};
loading = false; loading = false;
deletingSessionIds = new Set<string>();
loadingDetailSessionIds = new Set<string>();
expandedSessionId: string | null = null;
errorMessage: string | null = null; errorMessage: string | null = null;
successMessage: string | null = null;
ngOnInit(): void { ngOnInit(): void {
this.loadSessions(); this.loadSessions();
@@ -23,6 +32,7 @@ export class AdminSessionsComponent implements OnInit {
loadSessions(): void { loadSessions(): void {
this.loading = true; this.loading = true;
this.errorMessage = null; this.errorMessage = null;
this.successMessage = null;
this.adminOperationsService.getSessions().subscribe({ this.adminOperationsService.getSessions().subscribe({
next: (sessions) => { next: (sessions) => {
this.sessions = 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;
}
} }

View File

@@ -8,7 +8,6 @@
<nav class="menu"> <nav class="menu">
<a routerLink="orders" routerLinkActive="active">Ordini</a> <a routerLink="orders" routerLinkActive="active">Ordini</a>
<a routerLink="orders-past" routerLinkActive="active">Ordini passati</a>
<a routerLink="filament-stock" routerLinkActive="active">Stock filamenti</a> <a routerLink="filament-stock" routerLinkActive="active">Stock filamenti</a>
<a routerLink="contact-requests" routerLinkActive="active">Richieste contatto</a> <a routerLink="contact-requests" routerLinkActive="active">Richieste contatto</a>
<a routerLink="sessions" routerLinkActive="active">Sessioni</a> <a routerLink="sessions" routerLinkActive="active">Sessioni</a>

View File

@@ -14,6 +14,52 @@ export interface AdminFilamentStockRow {
active: boolean; active: boolean;
} }
export interface AdminFilamentMaterialType {
id: number;
materialCode: string;
isFlexible: boolean;
isTechnical: boolean;
technicalTypeLabel?: string;
}
export interface AdminFilamentVariant {
id: number;
materialTypeId: number;
materialCode: string;
materialIsFlexible: boolean;
materialIsTechnical: boolean;
materialTechnicalTypeLabel?: string;
variantDisplayName: string;
colorName: string;
isMatte: boolean;
isSpecial: boolean;
costChfPerKg: number;
stockSpools: number;
spoolNetKg: number;
stockKg: number;
isActive: boolean;
createdAt: string;
}
export interface AdminUpsertFilamentMaterialTypePayload {
materialCode: string;
isFlexible: boolean;
isTechnical: boolean;
technicalTypeLabel?: string;
}
export interface AdminUpsertFilamentVariantPayload {
materialTypeId: number;
variantDisplayName: string;
colorName: string;
isMatte: boolean;
isSpecial: boolean;
costChfPerKg: number;
stockSpools: number;
spoolNetKg: number;
isActive: boolean;
}
export interface AdminContactRequest { export interface AdminContactRequest {
id: string; id: string;
requestType: string; requestType: string;
@@ -26,6 +72,30 @@ export interface AdminContactRequest {
createdAt: string; createdAt: string;
} }
export interface AdminContactRequestAttachment {
id: string;
originalFilename: string;
mimeType?: string;
fileSizeBytes?: number;
createdAt: string;
}
export interface AdminContactRequestDetail {
id: string;
requestType: string;
customerType: string;
email: string;
phone?: string;
name?: string;
companyName?: string;
contactPerson?: string;
message: string;
status: string;
createdAt: string;
updatedAt: string;
attachments: AdminContactRequestAttachment[];
}
export interface AdminQuoteSession { export interface AdminQuoteSession {
id: string; id: string;
status: string; status: string;
@@ -35,6 +105,33 @@ export interface AdminQuoteSession {
convertedOrderId?: string; convertedOrderId?: string;
} }
export interface AdminQuoteSessionDetailItem {
id: string;
originalFilename: string;
quantity: number;
printTimeSeconds?: number;
materialGrams?: number;
colorCode?: string;
status: string;
unitPriceChf: number;
}
export interface AdminQuoteSessionDetail {
session: {
id: string;
status: string;
materialCode: string;
setupCostChf?: number;
supportsEnabled?: boolean;
notes?: string;
};
items: AdminQuoteSessionDetailItem[];
itemsTotalChf: number;
shippingCostChf: number;
globalMachineCostChf: number;
grandTotalChf: number;
}
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
@@ -46,11 +143,57 @@ export class AdminOperationsService {
return this.http.get<AdminFilamentStockRow[]>(`${this.baseUrl}/filament-stock`, { withCredentials: true }); return this.http.get<AdminFilamentStockRow[]>(`${this.baseUrl}/filament-stock`, { withCredentials: true });
} }
getFilamentMaterials(): Observable<AdminFilamentMaterialType[]> {
return this.http.get<AdminFilamentMaterialType[]>(`${this.baseUrl}/filaments/materials`, { withCredentials: true });
}
getFilamentVariants(): Observable<AdminFilamentVariant[]> {
return this.http.get<AdminFilamentVariant[]>(`${this.baseUrl}/filaments/variants`, { withCredentials: true });
}
createFilamentMaterial(payload: AdminUpsertFilamentMaterialTypePayload): Observable<AdminFilamentMaterialType> {
return this.http.post<AdminFilamentMaterialType>(`${this.baseUrl}/filaments/materials`, payload, { withCredentials: true });
}
updateFilamentMaterial(materialId: number, payload: AdminUpsertFilamentMaterialTypePayload): Observable<AdminFilamentMaterialType> {
return this.http.put<AdminFilamentMaterialType>(`${this.baseUrl}/filaments/materials/${materialId}`, payload, { withCredentials: true });
}
createFilamentVariant(payload: AdminUpsertFilamentVariantPayload): Observable<AdminFilamentVariant> {
return this.http.post<AdminFilamentVariant>(`${this.baseUrl}/filaments/variants`, payload, { withCredentials: true });
}
updateFilamentVariant(variantId: number, payload: AdminUpsertFilamentVariantPayload): Observable<AdminFilamentVariant> {
return this.http.put<AdminFilamentVariant>(`${this.baseUrl}/filaments/variants/${variantId}`, payload, { withCredentials: true });
}
getContactRequests(): Observable<AdminContactRequest[]> { getContactRequests(): Observable<AdminContactRequest[]> {
return this.http.get<AdminContactRequest[]>(`${this.baseUrl}/contact-requests`, { withCredentials: true }); return this.http.get<AdminContactRequest[]>(`${this.baseUrl}/contact-requests`, { withCredentials: true });
} }
getContactRequestDetail(requestId: string): Observable<AdminContactRequestDetail> {
return this.http.get<AdminContactRequestDetail>(`${this.baseUrl}/contact-requests/${requestId}`, { withCredentials: true });
}
downloadContactRequestAttachment(requestId: string, attachmentId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/contact-requests/${requestId}/attachments/${attachmentId}/file`, {
withCredentials: true,
responseType: 'blob'
});
}
getSessions(): Observable<AdminQuoteSession[]> { getSessions(): Observable<AdminQuoteSession[]> {
return this.http.get<AdminQuoteSession[]>(`${this.baseUrl}/sessions`, { withCredentials: true }); return this.http.get<AdminQuoteSession[]>(`${this.baseUrl}/sessions`, { withCredentials: true });
} }
deleteSession(sessionId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/sessions/${sessionId}`, { withCredentials: true });
}
getSessionDetail(sessionId: string): Observable<AdminQuoteSessionDetail> {
return this.http.get<AdminQuoteSessionDetail>(
`${environment.apiUrl}/api/quote-sessions/${sessionId}`,
{ withCredentials: true }
);
}
} }